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)
Start the development server for the capacitor web version:
Start the development server for the capacitor:
```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:
```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.
You can see the list of available devices and simulators by running `pnpm exec cap run ios --list`.
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 config: CapacitorConfig = {
appId: 'ai.moeru.airi',
appId: 'ai.moeru.airi-pocket',
appName: 'AIRI',
webDir: 'dist',
server: serverURL
+2 -2
View File
@@ -16,8 +16,7 @@
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit",
"dev:web": "vite",
"dev:ios": "VITE_CAP_SYNC_IOS_AFTER_BUILD=1 vite build --watch",
"open:ios": "vite build && cap sync ios && cap open ios"
"dev:ios": "cap-vite ios"
},
"dependencies": {
"@capacitor/core": "catalog:",
@@ -111,6 +110,7 @@
"@iconify-json/svg-spinners": "^1.2.4",
"@iconify-json/vscode-icons": "^1.2.44",
"@intlify/unplugin-vue-i18n": "^11.0.7",
"@proj-airi/cap-vite": "workspace:*",
"@proj-airi/iconify-meteocons": "catalog:",
"@proj-airi/lobe-icons": "^1.0.18",
"@proj-airi/unplugin-fetch": "catalog:",
+2 -1
View File
@@ -17,7 +17,8 @@
"dev:docs": "pnpm -rF @proj-airi/docs run dev",
"dev:ui": "pnpm -rF @proj-airi/stage-ui run story: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:tamagotchi": "pnpm -rF @proj-airi/stage-tamagotchi run 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':
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))
'@proj-airi/cap-vite':
specifier: workspace:*
version: link:../../packages/cap-vite
'@proj-airi/iconify-meteocons':
specifier: 'catalog:'
version: 0.1.4
@@ -1949,6 +1952,18 @@ importers:
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)
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:
dependencies:
meta-png:
@@ -3202,7 +3217,7 @@ importers:
version: 14.1.0(vue@3.5.29(typescript@5.9.3))
'@wxt-dev/module-vue':
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:
specifier: ^5.1.6
version: 5.1.6
@@ -24078,9 +24093,9 @@ snapshots:
'@types/filesystem': 0.0.36
'@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:
'@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)
transitivePeerDependencies:
- vite