From 25e66f664ccd4173b3d0b94cb48ef70850f0a5bb Mon Sep 17 00:00:00 2001 From: LemonNeko Date: Sun, 8 Mar 2026 17:17:54 +0800 Subject: [PATCH] feat(stage-pocket): introducing a new package for better DX --- README.md | 26 +-- apps/stage-pocket/capacitor.config.ts | 2 +- apps/stage-pocket/package.json | 4 +- package.json | 3 +- packages/cap-vite/README.md | 43 +++++ packages/cap-vite/package.json | 45 ++++++ packages/cap-vite/src/bin/run.ts | 25 +++ packages/cap-vite/src/index.ts | 219 ++++++++++++++++++++++++++ packages/cap-vite/tsconfig.json | 14 ++ packages/cap-vite/tsdown.config.ts | 12 ++ pnpm-lock.yaml | 21 ++- 11 files changed, 386 insertions(+), 28 deletions(-) create mode 100644 packages/cap-vite/README.md create mode 100644 packages/cap-vite/package.json create mode 100644 packages/cap-vite/src/bin/run.ts create mode 100644 packages/cap-vite/src/index.ts create mode 100644 packages/cap-vite/tsconfig.json create mode 100644 packages/cap-vite/tsdown.config.ts diff --git a/README.md b/README.md index 752b894b0..2d750ae1c 100644 --- a/README.md +++ b/README.md @@ -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 +# Or +CAPACITOR_DEVICE_ID= 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://: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://: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: diff --git a/apps/stage-pocket/capacitor.config.ts b/apps/stage-pocket/capacitor.config.ts index 0e42f8019..58acac883 100644 --- a/apps/stage-pocket/capacitor.config.ts +++ b/apps/stage-pocket/capacitor.config.ts @@ -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 diff --git a/apps/stage-pocket/package.json b/apps/stage-pocket/package.json index 6c2caeeff..c824b3c65 100644 --- a/apps/stage-pocket/package.json +++ b/apps/stage-pocket/package.json @@ -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:", diff --git a/package.json b/package.json index a60c611ac..52e8b6e1e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/cap-vite/README.md b/packages/cap-vite/README.md new file mode 100644 index 000000000..97a513e19 --- /dev/null +++ b/packages/cap-vite/README.md @@ -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 +pnpm cap-vite android +# Or +CAPACITOR_DEVICE_ID= pnpm cap-vite ios +CAPACITOR_DEVICE_ID= 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. diff --git a/packages/cap-vite/package.json b/packages/cap-vite/package.json new file mode 100644 index 000000000..e61c16d53 --- /dev/null +++ b/packages/cap-vite/package.json @@ -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" + } +} diff --git a/packages/cap-vite/src/bin/run.ts b/packages/cap-vite/src/bin/run.ts new file mode 100644 index 000000000..9fc5caa57 --- /dev/null +++ b/packages/cap-vite/src/bin/run.ts @@ -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 ') + } + + if (platform !== 'android' && platform !== 'ios') { + process.stderr.write('Usage: cap-vite \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) +}) diff --git a/packages/cap-vite/src/index.ts b/packages/cap-vite/src/index.ts new file mode 100644 index 000000000..5bad9b70d --- /dev/null +++ b/packages/cap-vite/src/index.ts @@ -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> = { + 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> = { + 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 = { + 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 { + 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) + }) +} diff --git a/packages/cap-vite/tsconfig.json b/packages/cap-vite/tsconfig.json new file mode 100644 index 000000000..5ffa921a9 --- /dev/null +++ b/packages/cap-vite/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "lib": [ + "ESNext" + ], + "types": [ + "node" + ] + }, + "include": [ + "./src/**/*" + ] +} diff --git a/packages/cap-vite/tsdown.config.ts b/packages/cap-vite/tsdown.config.ts new file mode 100644 index 000000000..9158cb27b --- /dev/null +++ b/packages/cap-vite/tsdown.config.ts @@ -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, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ae604409..a470f30cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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