feat(stage-pocket): introducing a new package for better DX

This commit is contained in:
LemonNeko
2026-03-08 17:17:54 +08:00
parent 8467b84417
commit 25e66f664c
11 changed files with 386 additions and 28 deletions
+5 -21
View File
@@ -175,31 +175,15 @@ nix run github:moeru-ai/airi
### Stage Pocket (Mobile Version) ### Stage Pocket (Mobile Version)
Start the development server for the capacitor web version: Start the development server for the capacitor:
```shell ```shell
pnpm dev:pocket pnpm dev:pocket:ios <DEVICE_ID_OR_SIMULATOR_NAME>
# Or
CAPACITOR_DEVICE_ID=<DEVICE_ID_OR_SIMULATOR_NAME> pnpm dev:pocket:ios
``` ```
Check your IP address in the output of the command above: You can see the list of available devices and simulators by running `pnpm exec cap run ios --list`.
```shell
ROLLDOWN-VITE v7.3.0 ready in 1073 ms
➜ Local: https://localhost:5273/
➜ Network: https://<ip-will-be-here>:5273/
➜ Vue DevTools: Open https://localhost:5273/__devtools__/ as a separate window
➜ Vue DevTools: Press Option()+Shift()+D in App to toggle the Vue DevTools
➜ UnoCSS Inspector: https://localhost:5273/__unocss/
```
Open the Xcode project:
```shell
CAPACITOR_DEV_SERVER_URL=https://<your-ip-address>:5273 pnpm open:ios
```
Then Xcode will open and you can click the "Run" button to run the app on your iPhone.
If you need to connect server channel on pocket in wireless mode, you need to start tamagotchi as root: If you need to connect server channel on pocket in wireless mode, you need to start tamagotchi as root:
+1 -1
View File
@@ -5,7 +5,7 @@ import { env } from 'node:process'
const serverURL = env.CAPACITOR_DEV_SERVER_URL const serverURL = env.CAPACITOR_DEV_SERVER_URL
const config: CapacitorConfig = { const config: CapacitorConfig = {
appId: 'ai.moeru.airi', appId: 'ai.moeru.airi-pocket',
appName: 'AIRI', appName: 'AIRI',
webDir: 'dist', webDir: 'dist',
server: serverURL server: serverURL
+2 -2
View File
@@ -16,8 +16,7 @@
"preview": "vite preview", "preview": "vite preview",
"typecheck": "vue-tsc --noEmit", "typecheck": "vue-tsc --noEmit",
"dev:web": "vite", "dev:web": "vite",
"dev:ios": "VITE_CAP_SYNC_IOS_AFTER_BUILD=1 vite build --watch", "dev:ios": "cap-vite ios"
"open:ios": "vite build && cap sync ios && cap open ios"
}, },
"dependencies": { "dependencies": {
"@capacitor/core": "catalog:", "@capacitor/core": "catalog:",
@@ -111,6 +110,7 @@
"@iconify-json/svg-spinners": "^1.2.4", "@iconify-json/svg-spinners": "^1.2.4",
"@iconify-json/vscode-icons": "^1.2.44", "@iconify-json/vscode-icons": "^1.2.44",
"@intlify/unplugin-vue-i18n": "^11.0.7", "@intlify/unplugin-vue-i18n": "^11.0.7",
"@proj-airi/cap-vite": "workspace:*",
"@proj-airi/iconify-meteocons": "catalog:", "@proj-airi/iconify-meteocons": "catalog:",
"@proj-airi/lobe-icons": "^1.0.18", "@proj-airi/lobe-icons": "^1.0.18",
"@proj-airi/unplugin-fetch": "catalog:", "@proj-airi/unplugin-fetch": "catalog:",
+2 -1
View File
@@ -17,7 +17,8 @@
"dev:docs": "pnpm -rF @proj-airi/docs run dev", "dev:docs": "pnpm -rF @proj-airi/docs run dev",
"dev:ui": "pnpm -rF @proj-airi/stage-ui run story:dev", "dev:ui": "pnpm -rF @proj-airi/stage-ui run story:dev",
"dev:web": "pnpm -rF @proj-airi/stage-web run dev", "dev:web": "pnpm -rF @proj-airi/stage-web run dev",
"dev:pocket": "pnpm -rF @proj-airi/stage-pocket run dev:web", "dev:pocket:ios": "pnpm -rF @proj-airi/stage-pocket run dev:ios",
"dev:pocket:android": "pnpm -rF @proj-airi/stage-pocket run dev:android",
"dev:server": "pnpm -rF @proj-airi/server-runtime run dev", "dev:server": "pnpm -rF @proj-airi/server-runtime run dev",
"dev:tamagotchi": "pnpm -rF @proj-airi/stage-tamagotchi run dev", "dev:tamagotchi": "pnpm -rF @proj-airi/stage-tamagotchi run dev",
"dev:apps": "pnpm -rF=\"./apps/*\" run --parallel dev", "dev:apps": "pnpm -rF=\"./apps/*\" run --parallel dev",
+43
View File
@@ -0,0 +1,43 @@
# @proj-airi/cap-vite
CLI for [Capacitor](https://capacitorjs.com/) live-reload development using Vite.
## Usage
```bash
pnpm cap-vite ios <DEVICE_ID_OR_SIMULATOR_NAME>
pnpm cap-vite android <DEVICE_ID_OR_SIMULATOR_NAME>
# Or
CAPACITOR_DEVICE_ID=<DEVICE_ID_OR_SIMULATOR_NAME> pnpm cap-vite ios
CAPACITOR_DEVICE_ID=<DEVICE_ID_OR_SIMULATOR_NAME> pnpm cap-vite android
```
You can see the list of available devices and simulators by running `pnpm exec cap run ios --list` or `pnpm exec cap run android --list`.
## Capacitor Configuration
You need to set `server.url` in `capacitor.config.ts` to the env variable `CAPACITOR_DEV_SERVER_URL`.
```ts
const serverURL = env.CAPACITOR_DEV_SERVER_URL
const config: CapacitorConfig = {
appId: 'com.example.app',
appName: 'Example App',
webDir: 'dist',
server: serverURL
? {
url: serverURL,
cleartext: false,
}
: undefined,
}
export default config
```
## What It Does
- Starts the project's own Vite config through the Vite API.
- Executes the local `cap` binary via `tinyexec`.
- Watches native files under `ios/` or `android/` and re-runs `cap run` after a small debounce.
+45
View File
@@ -0,0 +1,45 @@
{
"name": "@proj-airi/cap-vite",
"type": "module",
"version": "0.9.0-alpha.2",
"private": true,
"description": "CLI that starts a Vite dev server and runs Capacitor live reload",
"author": {
"name": "Moeru AI Project AIRI Team",
"email": "airi@moeru.ai",
"url": "https://github.com/moeru-ai"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/moeru-ai/airi.git",
"directory": "packages/cap-vite"
},
"exports": {
".": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
}
},
"main": "./dist/index.mjs",
"types": "./dist/index.d.mts",
"bin": {
"cap-vite": "./dist/bin/run.mjs"
},
"files": [
"README.md",
"dist",
"package.json"
],
"scripts": {
"build": "tsdown",
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"@capacitor/cli": "^8.0.0",
"vite": "^7.0.0 || ^8.0.0-beta.0"
},
"dependencies": {
"tinyexec": "^1.0.2"
}
}
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env node
import process from 'node:process'
import { runCapVite } from '..'
async function main() {
const platform = process.argv[2]
const deviceId = process.env.CAPACITOR_DEVICE_ID || process.argv[3]
if (!deviceId) {
throw new Error('Usage: cap-vite <ios|android> <DEVICE_ID_OR_SIMULATOR_NAME>')
}
if (platform !== 'android' && platform !== 'ios') {
process.stderr.write('Usage: cap-vite <ios|android> <DEVICE_ID_OR_SIMULATOR_NAME>\n')
process.exit(1)
}
await runCapVite(platform, deviceId)
}
void main().catch((error) => {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
process.exit(1)
})
+219
View File
@@ -0,0 +1,219 @@
import type { Result } from 'tinyexec'
import type { ViteDevServer } from 'vite'
import process from 'node:process'
import { basename, extname, relative, resolve, sep } from 'node:path'
import { x } from 'tinyexec'
import { createServer } from 'vite'
export type CapacitorPlatform = 'android' | 'ios'
export interface RunCapViteOptions {
cwd?: string
debounceMs?: number
}
const nativeExtensionsByPlatform: Record<CapacitorPlatform, Set<string>> = {
ios: new Set([
'.entitlements',
'.h',
'.hpp',
'.m',
'.mm',
'.pbxproj',
'.plist',
'.storyboard',
'.strings',
'.swift',
'.xcodeproj',
'.xcconfig',
'.xcscheme',
'.xib',
]),
android: new Set([
'.gradle',
'.java',
'.json',
'.kts',
'.kt',
'.properties',
'.xml',
]),
}
const nativeNamesByPlatform: Record<CapacitorPlatform, Set<string>> = {
ios: new Set([
'Podfile',
'Podfile.lock',
'project.pbxproj',
]),
android: new Set([
'AndroidManifest.xml',
'build.gradle',
'build.gradle.kts',
'gradle.properties',
'settings.gradle',
'settings.gradle.kts',
]),
}
const ignoredNames = new Set([
'capacitor.config.json',
])
const ignoredPathSegments = new Set([
'.gradle',
'DerivedData',
'Pods',
'build',
'xcuserdata',
])
const ignoredPathPrefixesByPlatform: Record<CapacitorPlatform, string[][]> = {
ios: [
['App', 'CapApp-SPM'],
],
android: [],
}
function pickServerUrl(server: ViteDevServer): URL {
const url = server.resolvedUrls?.network?.[0] ?? server.resolvedUrls?.local?.[0]
if (!url) {
throw new Error('Vite did not expose a reachable dev server URL.')
}
const resolved = new URL(url)
return resolved
}
function shouldRestartForNativeChange(file: string, platform: CapacitorPlatform, cwd: string): boolean {
const absoluteFile = resolve(cwd, file)
const platformRoot = resolve(cwd, platform)
if (!absoluteFile.startsWith(`${platformRoot}${sep}`) && absoluteFile !== platformRoot) {
return false
}
const fileName = basename(absoluteFile)
if (ignoredNames.has(fileName)) {
return false
}
const segments = absoluteFile.split(sep)
if (segments.some(segment => ignoredPathSegments.has(segment))) {
return false
}
const relativeFile = relative(platformRoot, absoluteFile)
const relativeSegments = relativeFile.split(sep).filter(Boolean)
if (ignoredPathPrefixesByPlatform[platform].some(prefix =>
prefix.every((segment, index) => relativeSegments[index] === segment),
)) {
// NOTICE: Capacitor regenerates ios/App/CapApp-SPM/Package.swift during `cap run`.
// Treating that generated tree as a native source change causes an infinite restart loop.
return false
}
if (nativeNamesByPlatform[platform].has(fileName)) {
return true
}
return nativeExtensionsByPlatform[platform].has(extname(fileName).toLowerCase())
}
async function stopCapProcess(current: Result | undefined) {
if (!current) {
return
}
current.kill('SIGINT')
try {
await current
}
catch {
// tinyexec rejects on interrupted exits when the child was stopped for a restart.
}
}
function startCapProcess(cwd: string, platform: CapacitorPlatform, deviceId: string) {
return x('cap', ['run', platform, '--target', deviceId], { persist: true, throwOnError: false, nodeOptions: { cwd, stdio: 'inherit' } })
}
export async function runCapVite(
platform: CapacitorPlatform,
deviceId: string,
options: RunCapViteOptions = {},
): Promise<void> {
const cwd = resolve(options.cwd ?? process.cwd())
const debounceMs = options.debounceMs ?? 300
const server = await createServer({
clearScreen: false,
root: cwd,
})
await server.listen()
server.printUrls()
const url = pickServerUrl(server)
const logger = server.config.logger
await x('cap', ['sync', platform], { persist: true, throwOnError: false, nodeOptions: { cwd, env: { CAPACITOR_DEV_SERVER_URL: url.toString() }, stdio: 'inherit' } })
let currentCapProcess: Result | undefined = startCapProcess(cwd, platform, deviceId)
let restartTimer: NodeJS.Timeout | undefined
let shuttingDown = false
async function restartCapProcess(reason: string) {
if (shuttingDown) {
return
}
logger.info(`[cap-vite] ${reason}. Re-running cap run ${platform}.`)
const previous = currentCapProcess
currentCapProcess = undefined
await stopCapProcess(previous)
currentCapProcess = startCapProcess(cwd, platform, deviceId)
}
const onWatcherEvent = (_event: string, file: string) => {
if (!shouldRestartForNativeChange(file, platform, cwd)) {
return
}
clearTimeout(restartTimer)
restartTimer = setTimeout(() => {
void restartCapProcess(`native file changed: ${resolve(cwd, file)}`)
}, debounceMs)
}
const shutdown = async (exitCode: number) => {
if (shuttingDown) {
return
}
shuttingDown = true
clearTimeout(restartTimer)
server.watcher.off('all', onWatcherEvent)
await server.watcher.unwatch(platform)
await server.close()
await stopCapProcess(currentCapProcess)
process.exit(exitCode)
}
server.watcher.add(platform)
server.watcher.on('all', onWatcherEvent)
process.once('SIGINT', () => {
void shutdown(0)
})
process.once('SIGTERM', () => {
void shutdown(0)
})
}
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"lib": [
"ESNext"
],
"types": [
"node"
]
},
"include": [
"./src/**/*"
]
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'tsdown'
export default defineConfig({
entry: {
'index': 'src/index.ts',
'bin/run': 'src/bin/run.ts',
},
target: 'node18',
outDir: 'dist',
dts: true,
sourcemap: true,
})
+18 -3
View File
@@ -830,6 +830,9 @@ importers:
'@intlify/unplugin-vue-i18n': '@intlify/unplugin-vue-i18n':
specifier: ^11.0.7 specifier: ^11.0.7
version: 11.0.7(@vue/compiler-dom@3.5.29)(eslint@9.39.3(jiti@2.6.1))(rollup@2.80.0)(typescript@5.9.3)(vue-i18n@11.2.8(vue@3.5.29(typescript@5.9.3)))(vue@3.5.29(typescript@5.9.3)) version: 11.0.7(@vue/compiler-dom@3.5.29)(eslint@9.39.3(jiti@2.6.1))(rollup@2.80.0)(typescript@5.9.3)(vue-i18n@11.2.8(vue@3.5.29(typescript@5.9.3)))(vue@3.5.29(typescript@5.9.3))
'@proj-airi/cap-vite':
specifier: workspace:*
version: link:../../packages/cap-vite
'@proj-airi/iconify-meteocons': '@proj-airi/iconify-meteocons':
specifier: 'catalog:' specifier: 'catalog:'
version: 0.1.4 version: 0.1.4
@@ -1949,6 +1952,18 @@ importers:
specifier: catalog:vitest specifier: catalog:vitest
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.14)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0(bufferutil@4.1.0)(canvas@3.2.1)(utf-8-validate@5.0.10))(less@4.5.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.14)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0(bufferutil@4.1.0)(canvas@3.2.1)(utf-8-validate@5.0.10))(less@4.5.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
packages/cap-vite:
dependencies:
'@capacitor/cli':
specifier: ^8.0.0
version: 8.1.0
tinyexec:
specifier: ^1.0.2
version: 1.0.2
vite:
specifier: ^7.0.0 || ^8.0.0-beta.0
version: 7.3.1(@types/node@24.10.14)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
packages/ccc: packages/ccc:
dependencies: dependencies:
meta-png: meta-png:
@@ -3202,7 +3217,7 @@ importers:
version: 14.1.0(vue@3.5.29(typescript@5.9.3)) version: 14.1.0(vue@3.5.29(typescript@5.9.3))
'@wxt-dev/module-vue': '@wxt-dev/module-vue':
specifier: ^1.0.3 specifier: ^1.0.3
version: 1.0.3(vite@7.3.1(@types/node@24.10.14)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.29(typescript@5.9.3))(wxt@0.20.18(@types/node@24.10.14)(canvas@3.2.1)(eslint@9.39.3(jiti@2.6.1))(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(rollup@4.59.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) version: 1.0.3(vite@8.0.0-beta.15(@types/node@24.10.14)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.29(typescript@5.9.3))(wxt@0.20.18(@types/node@24.10.14)(canvas@3.2.1)(eslint@9.39.3(jiti@2.6.1))(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(rollup@4.59.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
nanoid: nanoid:
specifier: ^5.1.6 specifier: ^5.1.6
version: 5.1.6 version: 5.1.6
@@ -24078,9 +24093,9 @@ snapshots:
'@types/filesystem': 0.0.36 '@types/filesystem': 0.0.36
'@types/har-format': 1.2.16 '@types/har-format': 1.2.16
'@wxt-dev/module-vue@1.0.3(vite@7.3.1(@types/node@24.10.14)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.29(typescript@5.9.3))(wxt@0.20.18(@types/node@24.10.14)(canvas@3.2.1)(eslint@9.39.3(jiti@2.6.1))(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(rollup@4.59.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': '@wxt-dev/module-vue@1.0.3(vite@8.0.0-beta.15(@types/node@24.10.14)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.29(typescript@5.9.3))(wxt@0.20.18(@types/node@24.10.14)(canvas@3.2.1)(eslint@9.39.3(jiti@2.6.1))(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(rollup@4.59.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
dependencies: dependencies:
'@vitejs/plugin-vue': 6.0.4(vite@7.3.1(@types/node@24.10.14)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.29(typescript@5.9.3)) '@vitejs/plugin-vue': 6.0.4(vite@8.0.0-beta.15(@types/node@24.10.14)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.29(typescript@5.9.3))
wxt: 0.20.18(@types/node@24.10.14)(canvas@3.2.1)(eslint@9.39.3(jiti@2.6.1))(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(rollup@4.59.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) wxt: 0.20.18(@types/node@24.10.14)(canvas@3.2.1)(eslint@9.39.3(jiti@2.6.1))(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(rollup@4.59.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
transitivePeerDependencies: transitivePeerDependencies:
- vite - vite