diff --git a/apps/stage-pocket/package.json b/apps/stage-pocket/package.json index c824b3c65..b9b1cd1f4 100644 --- a/apps/stage-pocket/package.json +++ b/apps/stage-pocket/package.json @@ -16,7 +16,7 @@ "preview": "vite preview", "typecheck": "vue-tsc --noEmit", "dev:web": "vite", - "dev:ios": "cap-vite ios" + "dev:ios": "cap-vite -- ios" }, "dependencies": { "@capacitor/core": "catalog:", diff --git a/packages/cap-vite/README.md b/packages/cap-vite/README.md index d565f02ae..2014c0d54 100644 --- a/packages/cap-vite/README.md +++ b/packages/cap-vite/README.md @@ -5,25 +5,32 @@ CLI for [Capacitor](https://capacitorjs.com/) live-reload development using Vite ## Usage ```bash -pnpm cap-vite ios --target -pnpm cap-vite android --target -pnpm cap-vite ios --target -- --scheme AIRI -pnpm cap-vite android --target -- --flavor release -# Or -CAPACITOR_DEVICE_ID= pnpm cap-vite ios -CAPACITOR_DEVICE_ID= pnpm cap-vite android +cap-vite [vite args...] -- [cap run args...] ``` -- Arguments after `--` are forwarded to `cap run`, example: `pnpm cap-vite ios --target -- --scheme AIRI` will run `cap run ios --target --scheme AIRI`. +Examples: + +```bash +pnpm exec cap-vite -- ios --target +pnpm exec cap-vite -- --host 0.0.0.0 --port 5173 -- android --target --flavor release +CAPACITOR_DEVICE_ID= pnpm exec cap-vite -- ios +pnpm -F @proj-airi/stage-pocket run dev:ios -- --target +``` + +- Arguments before `--` are forwarded to `vite`. +- Arguments after `--` are forwarded to `cap run`. +- If `CAPACITOR_DEVICE_ID` is set and `cap run` args do not contain `--target`, `cap-vite` injects `--target ` automatically. +- `cap-vite` always launches the Vite dev server. Do not pass `vite dev` or `vite serve` as extra args. 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`. +You need to set `server.url` in `capacitor.config.ts` to the env variable `CAPACITOR_DEV_SERVER_URL`, then the cli will handle rest for you. ```ts const serverURL = env.CAPACITOR_DEV_SERVER_URL +const isCleartext = serverURL?.startsWith('http://') ?? false const config: CapacitorConfig = { appId: 'com.example.app', @@ -32,7 +39,7 @@ const config: CapacitorConfig = { server: serverURL ? { url: serverURL, - cleartext: false, + cleartext: isCleartext, } : undefined, } @@ -40,8 +47,15 @@ const config: CapacitorConfig = { export default config ``` -## What It Does +## Why we need this? -- 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. +- No need to care what `server.url` should be, it will be automatically set to the correct value. +- Rerun native app when native code changes, you won't forget to start it. +- No need to open two terminals to run the project, you can run it with one command. + +## Architecture Notes + +- Vite arguments are left to the real Vite CLI instead of being reimplemented inside `cap-vite`. +- `cap-vite` injects a wrapper config so it can append its own Vite plugin without editing the user's existing `vite.config.*`. +- The injected plugin reads `server.resolvedUrls`, starts `cap run`, and restarts it when files under the native platform directory change. +- `cap-vite` only splits the two argument groups and passes the `cap run` arguments into the injected plugin through environment variables. diff --git a/packages/cap-vite/src/bin/run.ts b/packages/cap-vite/src/bin/run.ts index 4583b29af..a870fa7c1 100644 --- a/packages/cap-vite/src/bin/run.ts +++ b/packages/cap-vite/src/bin/run.ts @@ -3,15 +3,19 @@ import process from 'node:process' import { runCapVite } from '..' -import { parseCapViteCliArgs } from '../cli' +import { getCapViteCliHelpText, parseCapViteCliArgs } from '../cli' async function main() { const parsed = parseCapViteCliArgs(process.argv.slice(2)) if (!parsed) { + process.stdout.write(`${getCapViteCliHelpText()}\n`) return } - await runCapVite(parsed.platform, parsed.target, { capArgs: parsed.capArgs }) + const result = await runCapVite(parsed.viteArgs, parsed.capArgs) + if (typeof result.exitCode === 'number') { + process.exitCode = result.exitCode + } } void main().catch((error) => { diff --git a/packages/cap-vite/src/cli.test.ts b/packages/cap-vite/src/cli.test.ts index e5c567498..a373fc673 100644 --- a/packages/cap-vite/src/cli.test.ts +++ b/packages/cap-vite/src/cli.test.ts @@ -3,39 +3,39 @@ import { describe, expect, it } from 'vitest' import { parseCapViteCliArgs } from './cli' describe('parseCapViteCliArgs', () => { - it('parses target and cap args after double dashes', () => { - expect(parseCapViteCliArgs(['ios', '--target', 'iPhone 16 Pro', '--', '--flavor', 'release'])).toEqual({ - capArgs: ['--flavor', 'release'], - platform: 'ios', - target: 'iPhone 16 Pro', + it('splits vite args from cap run args', () => { + expect(parseCapViteCliArgs(['--host', '0.0.0.0', '--port', '5173', '--', 'ios', '--target', 'iPhone 16 Pro'])).toEqual({ + capArgs: ['ios', '--target', 'iPhone 16 Pro'], + viteArgs: ['--host', '0.0.0.0', '--port', '5173'], }) }) - it('falls back to CAPACITOR_DEVICE_ID when --target is omitted', () => { - expect(parseCapViteCliArgs(['android'], { CAPACITOR_DEVICE_ID: 'emulator-5554' })).toEqual({ - capArgs: [], - platform: 'android', - target: 'emulator-5554', + it('returns null for help output', () => { + expect(parseCapViteCliArgs(['--help'])).toBeNull() + }) + + it('keeps cap run args untouched after double dashes', () => { + expect(parseCapViteCliArgs(['--mode', 'release', '--', 'android', '--target', 'emulator-5554', '--flavor', 'release'])).toEqual({ + capArgs: ['android', '--target', 'emulator-5554', '--flavor', 'release'], + viteArgs: ['--mode', 'release'], }) }) - it('keeps cap args untouched even when they look like wrapper flags', () => { - expect(parseCapViteCliArgs(['android', '--target', 'emulator-5554', '--', '--mode', 'release'])).toEqual({ - capArgs: ['--mode', 'release'], - platform: 'android', - target: 'emulator-5554', - }) + it('rejects invocations without the cap run separator', () => { + expect(() => parseCapViteCliArgs(['ios', '--target', 'iPhone 16 Pro'])).toThrow( + 'cap-vite [vite args...] -- [cap run args...]', + ) }) - it('rejects unknown wrapper options before double dashes', () => { - expect(() => parseCapViteCliArgs(['ios', '--target', 'iPhone 16 Pro', '--mode', 'mobile'])).toThrow(/Unknown option `--mode`/) - }) - - it('allows only one positional argument', () => { - expect(() => parseCapViteCliArgs(['ios', 'iPhone 16 Pro'])).toThrow('cap-vite [--target ] [-- ]') + it('rejects invocations without a platform after the separator', () => { + expect(() => parseCapViteCliArgs(['--host', '0.0.0.0', '--'])).toThrow( + 'cap-vite [vite args...] -- [cap run args...]', + ) }) it('rejects unsupported platforms', () => { - expect(() => parseCapViteCliArgs(['web', '--target', 'chrome'])).toThrow('cap-vite [--target ] [-- ]') + expect(() => parseCapViteCliArgs(['--host', '0.0.0.0', '--', 'web', '--target', 'chrome'])).toThrow( + 'cap-vite [vite args...] -- [cap run args...]', + ) }) }) diff --git a/packages/cap-vite/src/cli.ts b/packages/cap-vite/src/cli.ts index 943446496..ec89703b8 100644 --- a/packages/cap-vite/src/cli.ts +++ b/packages/cap-vite/src/cli.ts @@ -1,63 +1,55 @@ -import type { CapacitorPlatform } from '.' +const usage = 'cap-vite [vite args...] -- [cap run args...]' -import process from 'node:process' - -import { cac } from 'cac' +const helpText = [ + 'Run a Vite dev server and forward a second argument group to `cap run`.', + '', + 'Usage:', + ` ${usage}`, + '', + 'Examples:', + ' cap-vite -- ios --target "iPhone 16 Pro"', + ' cap-vite --host 0.0.0.0 --port 5173 -- android --target emulator-5554 --flavor release', + '', + 'Notes:', + ' Arguments before `--` are forwarded to Vite.', + ' Arguments after `--` are forwarded to `cap run`.', +].join('\n') export interface ParsedCapViteCliArgs { capArgs: string[] - platform: CapacitorPlatform - target: string + viteArgs: string[] } -const usage = 'cap-vite [--target ] [-- ]' - -function createCapViteCli() { - const cli = cac('cap-vite') - - cli.help() - cli - .command('', 'Run Capacitor with a Vite dev server') - .usage(usage) - .option('--target ', 'Set the Capacitor device target') - .example('cap-vite ios --target "iPhone 16 Pro" -- --scheme AIRI') - .example('CAPACITOR_DEVICE_ID=emulator-5554 cap-vite android -- --flavor release') - - return cli +export function getCapViteCliHelpText(): string { + return helpText } -export function parseCapViteCliArgs( - argv: string[], - env: NodeJS.ProcessEnv = process.env, -): ParsedCapViteCliArgs | null { - const cli = createCapViteCli() - const parsed = cli.parse(['node', 'cap-vite', ...argv], { run: false }) +export function getCapViteCliUsage(): string { + return usage +} - if (cli.options.help) { +export function parseCapViteCliArgs(argv: string[]): ParsedCapViteCliArgs | null { + if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) { return null } - cli.matchedCommand?.checkUnknownOptions() - cli.matchedCommand?.checkOptionValue() - cli.matchedCommand?.checkRequiredArgs() - - if (parsed.args.length > 1) { + const separatorIndex = argv.indexOf('--') + if (separatorIndex === -1) { throw new Error(usage) } - const platform = parsed.args[0] + const capArgs = argv.slice(separatorIndex + 1) + if (capArgs.length === 0) { + throw new Error(usage) + } + + const platform = capArgs[0] if (platform !== 'android' && platform !== 'ios') { throw new Error(usage) } - const target = typeof parsed.options.target === 'string' ? parsed.options.target : env.CAPACITOR_DEVICE_ID - if (!target) { - throw new Error(usage) - } - return { - capArgs: Array.isArray(parsed.options['--']) ? parsed.options['--'] : [], - platform, - target, + capArgs, + viteArgs: argv.slice(0, separatorIndex), } } diff --git a/packages/cap-vite/src/index.test.ts b/packages/cap-vite/src/index.test.ts index e94622c24..c206d20ca 100644 --- a/packages/cap-vite/src/index.test.ts +++ b/packages/cap-vite/src/index.test.ts @@ -1,9 +1,8 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { resolve } from 'node:path' -import { runCapVite } from './index' +import { describe, expect, it, vi } from 'vitest' -const { createServer, x } = vi.hoisted(() => ({ - createServer: vi.fn(), +const { x } = vi.hoisted(() => ({ x: vi.fn(), })) @@ -11,68 +10,70 @@ vi.mock('tinyexec', () => ({ x, })) -vi.mock('vite', () => ({ - createServer, -})) +describe('prepareCapViteLaunch', () => { + it('captures the user config while forwarding the remaining vite args', async () => { + const { prepareCapViteLaunch } = await import('./index') + + expect(prepareCapViteLaunch(['--host', '0.0.0.0', '--config', 'vite.mobile.ts', '--configLoader', 'runner'])).toEqual({ + baseConfigFile: resolve(process.cwd(), 'vite.mobile.ts'), + configLoader: 'runner', + projectRoot: process.cwd(), + viteArgs: ['--host', '0.0.0.0', '--configLoader', 'runner'], + wrapperConfigFile: expect.stringMatching(/packages\/cap-vite\/(src|dist)\/vite-wrapper-config\.(ts|mjs)$/), + }) + }) + + it('uses the leading positional vite root as the wrapper root', async () => { + const { prepareCapViteLaunch } = await import('./index') + + expect(prepareCapViteLaunch(['apps/stage-pocket', '--host', '0.0.0.0'])).toEqual({ + baseConfigFile: undefined, + configLoader: undefined, + projectRoot: resolve(process.cwd(), 'apps/stage-pocket'), + viteArgs: ['apps/stage-pocket', '--host', '0.0.0.0'], + wrapperConfigFile: expect.stringMatching(/packages\/cap-vite\/(src|dist)\/vite-wrapper-config\.(ts|mjs)$/), + }) + }) + + it('throws when --config is missing its value', async () => { + const { prepareCapViteLaunch } = await import('./index') + + expect(() => prepareCapViteLaunch(['--config'])).toThrow('Missing value for `--config`.') + }) +}) describe('runCapVite', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - afterEach(() => { - vi.restoreAllMocks() - }) - - it('forwards cap args into cap run', async () => { - const processOnce = vi.spyOn(process, 'once').mockImplementation(() => process) - - createServer.mockResolvedValue({ - close: vi.fn().mockResolvedValue(undefined), - config: { - logger: { - info: vi.fn(), - }, - }, - listen: vi.fn().mockResolvedValue(undefined), - printUrls: vi.fn(), - resolvedUrls: { - local: ['http://127.0.0.1:5173/'], - }, - watcher: { - add: vi.fn(), - off: vi.fn(), - on: vi.fn(), - unwatch: vi.fn().mockResolvedValue(undefined), - }, + it('launches vite with the wrapper config and cap-vite env vars', async () => { + const { runCapVite } = await import('./index') + x.mockResolvedValue({ + exitCode: 0, + stderr: '', + stdout: '', }) - x.mockReturnValue({ - kill: vi.fn(), - }) + await runCapVite( + ['--host', '0.0.0.0', '--config', 'vite.mobile.ts', '--configLoader=runner'], + ['ios', '--target', 'iPhone 16 Pro', '--scheme', 'AIRI'], + ) - await runCapVite('android', 'emulator-5554', { - capArgs: ['--flavor', 'release'], - }) - - expect(createServer).toHaveBeenCalledWith({ - clearScreen: false, - root: process.cwd(), - }) - - expect(x).toHaveBeenCalledWith('cap', ['run', 'android', '--target', 'emulator-5554', '--flavor', 'release'], { + expect(x).toHaveBeenCalledWith('vite', [ + '--config', + expect.stringMatching(/packages\/cap-vite\/(src|dist)\/vite-wrapper-config\.(ts|mjs)$/), + '--host', + '0.0.0.0', + '--configLoader=runner', + ], { nodeOptions: { cwd: process.cwd(), env: { - CAPACITOR_DEV_SERVER_URL: 'http://127.0.0.1:5173/', + CAP_VITE_BASE_CONFIG: resolve(process.cwd(), 'vite.mobile.ts'), + CAP_VITE_CAP_ARGS_JSON: JSON.stringify(['ios', '--target', 'iPhone 16 Pro', '--scheme', 'AIRI']), + CAP_VITE_CONFIG_LOADER: 'runner', + CAP_VITE_ROOT: process.cwd(), }, stdio: 'inherit', }, - persist: true, throwOnError: false, }) - - expect(processOnce).toHaveBeenCalledWith('SIGINT', expect.any(Function)) - expect(processOnce).toHaveBeenCalledWith('SIGTERM', expect.any(Function)) }) }) diff --git a/packages/cap-vite/src/index.ts b/packages/cap-vite/src/index.ts index e037b6ff7..88982f95d 100644 --- a/packages/cap-vite/src/index.ts +++ b/packages/cap-vite/src/index.ts @@ -1,221 +1,176 @@ -import type { Result } from 'tinyexec' -import type { ViteDevServer } from 'vite' +import type { Output } from 'tinyexec' import process from 'node:process' -import { basename, extname, relative, resolve, sep } from 'node:path' +import { extname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' import { x } from 'tinyexec' -import { createServer } from 'vite' -export type CapacitorPlatform = 'android' | 'ios' +import { parseCapacitorPlatform } from './native' + +export type { CapacitorPlatform } from './native' export interface RunCapViteOptions { - capArgs?: string[] 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', - ]), +interface PreparedViteLaunch { + baseConfigFile?: string + configLoader?: 'bundle' | 'native' | 'runner' + projectRoot: string + viteArgs: string[] + wrapperConfigFile: string } -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', - ]), +interface ParsedViteArg { + baseConfigFile?: string + configLoader?: 'bundle' | 'native' | 'runner' + consumedArgs: number + forwardedArgs: string[] } -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 resolveWrapperConfigFile(): string { + const currentModulePath = fileURLToPath(import.meta.url) + const wrapperExtension = extname(currentModulePath) === '.ts' ? '.ts' : '.mjs' + return fileURLToPath(new URL(`./vite-wrapper-config${wrapperExtension}`, import.meta.url)) } -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.') +function parseViteConfigLoader(value: string | undefined): 'bundle' | 'native' | 'runner' | undefined { + if (value === 'bundle' || value === 'native' || value === 'runner') { + return value } - const resolved = new URL(url) - - return resolved + return undefined } -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()) +function resolveConfigPath(cwd: string, value: string): string { + return resolve(cwd, value) } -async function stopCapProcess(current: Result | undefined) { - if (!current) { - return +function readRequiredOptionValue(viteArgs: string[], index: number, optionName: string): string { + const value = viteArgs[index + 1] + if (!value) { + throw new Error(`Missing value for \`${optionName}\`.`) } - current.kill('SIGINT') - - try { - await current - } - catch { - // tinyexec rejects on interrupted exits when the child was stopped for a restart. - } + return value } -function startCapProcess(cwd: string, platform: CapacitorPlatform, target: string, url: URL, capArgs: string[]) { - console.info('\n----------------------\n') - console.info('Running cap run', platform, '--target', target, ...capArgs) - return x('cap', ['run', platform, '--target', target, ...capArgs], { persist: true, throwOnError: false, nodeOptions: { cwd, stdio: 'inherit', env: { CAPACITOR_DEV_SERVER_URL: url.toString() } } }) +function parseConfigArg(viteArgs: string[], index: number, cwd: string): ParsedViteArg | null { + const arg = viteArgs[index] + + // NOTICE: Vite only accepts one `--config` entrypoint. cap-vite consumes that slot + // for its wrapper config, then loads the user config from inside the wrapper. + if (arg === '--config' || arg === '-c') { + return { + baseConfigFile: resolveConfigPath(cwd, readRequiredOptionValue(viteArgs, index, '--config')), + consumedArgs: 2, + forwardedArgs: [], + } + } + + if (arg.startsWith('--config=')) { + return { + baseConfigFile: resolveConfigPath(cwd, arg.slice('--config='.length)), + consumedArgs: 1, + forwardedArgs: [], + } + } + + return null +} + +function parseConfigLoaderArg(viteArgs: string[], index: number): ParsedViteArg | null { + const arg = viteArgs[index] + + if (arg === '--configLoader') { + const value = readRequiredOptionValue(viteArgs, index, '--configLoader') + + return { + configLoader: parseViteConfigLoader(value), + consumedArgs: 2, + forwardedArgs: [arg, value], + } + } + + if (arg.startsWith('--configLoader=')) { + return { + configLoader: parseViteConfigLoader(arg.slice('--configLoader='.length)), + consumedArgs: 1, + forwardedArgs: [arg], + } + } + + return null +} + +function parseViteArg(viteArgs: string[], index: number, cwd: string): ParsedViteArg { + return parseConfigArg(viteArgs, index, cwd) + ?? parseConfigLoaderArg(viteArgs, index) + ?? { + consumedArgs: 1, + forwardedArgs: [viteArgs[index]], + } +} + +function resolveProjectRoot(viteArgs: string[], cwd: string): string { + const firstArg = viteArgs[0] + + return firstArg && !firstArg.startsWith('-') + ? resolve(cwd, firstArg) + : cwd +} + +export function prepareCapViteLaunch(viteArgs: string[], cwd: string = process.cwd()): PreparedViteLaunch { + const resolvedCwd = resolve(cwd) + const projectRoot = resolveProjectRoot(viteArgs, resolvedCwd) + + let baseConfigFile: string | undefined + let configLoader: 'bundle' | 'native' | 'runner' | undefined + const forwardedViteArgs: string[] = [] + + for (let index = 0; index < viteArgs.length;) { + const parsedArg = parseViteArg(viteArgs, index, resolvedCwd) + + baseConfigFile = parsedArg.baseConfigFile ?? baseConfigFile + configLoader = parsedArg.configLoader ?? configLoader + forwardedViteArgs.push(...parsedArg.forwardedArgs) + index += parsedArg.consumedArgs + } + + return { + baseConfigFile, + configLoader, + projectRoot, + viteArgs: forwardedViteArgs, + wrapperConfigFile: resolveWrapperConfigFile(), + } } export async function runCapVite( - platform: CapacitorPlatform, - target: string, + viteArgs: string[], + capArgs: string[], options: RunCapViteOptions = {}, -): Promise { - const capArgs = options.capArgs ?? [] +): Promise { + if (!parseCapacitorPlatform(capArgs[0])) { + throw new Error('The first `cap run` argument must be `ios` or `android`.') + } + const cwd = resolve(options.cwd ?? process.cwd()) - const debounceMs = options.debounceMs ?? 300 - const server = await createServer({ - clearScreen: false, - root: cwd, - }) + const prepared = prepareCapViteLaunch(viteArgs, cwd) - await server.listen() - server.printUrls() - - const url = pickServerUrl(server) - const logger = server.config.logger - - let currentCapProcess: Result | undefined = startCapProcess(cwd, platform, target, url, capArgs) - 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, target, url, capArgs) - } - - 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) + return await x('vite', ['--config', prepared.wrapperConfigFile, ...prepared.viteArgs], { + throwOnError: false, + nodeOptions: { + cwd, + env: { + CAP_VITE_BASE_CONFIG: prepared.baseConfigFile ?? '', + CAP_VITE_CAP_ARGS_JSON: JSON.stringify(capArgs), + CAP_VITE_CONFIG_LOADER: prepared.configLoader ?? '', + CAP_VITE_ROOT: prepared.projectRoot, + }, + stdio: 'inherit', + }, }) } diff --git a/packages/cap-vite/src/native.test.ts b/packages/cap-vite/src/native.test.ts new file mode 100644 index 000000000..0278c017d --- /dev/null +++ b/packages/cap-vite/src/native.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' + +import { hasCapacitorTargetArg, parseCapacitorPlatform, pickServerUrl, resolveCapRunArgs, shouldRestartForNativeChange } from './native' + +describe('parseCapacitorPlatform', () => { + it('accepts supported platforms', () => { + expect(parseCapacitorPlatform('ios')).toBe('ios') + expect(parseCapacitorPlatform('android')).toBe('android') + }) + + it('rejects unsupported platforms', () => { + expect(parseCapacitorPlatform('web')).toBeNull() + expect(parseCapacitorPlatform(undefined)).toBeNull() + }) +}) + +describe('pickServerUrl', () => { + it('prefers network urls over local urls', () => { + expect(pickServerUrl({ + resolvedUrls: { + local: ['http://127.0.0.1:5173/'], + network: ['http://192.168.1.10:5173/'], + }, + } as any).toString()).toBe('http://192.168.1.10:5173/') + }) + + it('falls back to local urls when no network url exists', () => { + expect(pickServerUrl({ + resolvedUrls: { + local: ['http://127.0.0.1:5173/'], + }, + } as any).toString()).toBe('http://127.0.0.1:5173/') + }) + + it('throws when vite did not expose any reachable url', () => { + expect(() => pickServerUrl({ + resolvedUrls: { + local: [], + network: [], + }, + } as any)).toThrow('Vite did not expose a reachable dev server URL.') + }) +}) + +describe('resolveCapRunArgs', () => { + it('keeps an explicit --target argument untouched', () => { + expect(resolveCapRunArgs( + ['ios', '--target', 'iPhone 16 Pro', '--scheme', 'AIRI'], + { CAPACITOR_DEVICE_ID: 'ignored-device' }, + )).toEqual(['ios', '--target', 'iPhone 16 Pro', '--scheme', 'AIRI']) + }) + + it('injects --target from CAPACITOR_DEVICE_ID when it is missing', () => { + expect(resolveCapRunArgs( + ['android', '--flavor', 'release'], + { CAPACITOR_DEVICE_ID: 'emulator-5554' }, + )).toEqual(['android', '--target', 'emulator-5554', '--flavor', 'release']) + }) + + it('supports the --target=value form when checking existing args', () => { + expect(hasCapacitorTargetArg(['android', '--target=emulator-5554'])).toBe(true) + expect(resolveCapRunArgs( + ['android', '--target=emulator-5554', '--flavor', 'release'], + { CAPACITOR_DEVICE_ID: 'ignored-device' }, + )).toEqual(['android', '--target=emulator-5554', '--flavor', 'release']) + }) +}) + +describe('shouldRestartForNativeChange', () => { + it('restarts for native source files inside the selected platform directory', () => { + expect(shouldRestartForNativeChange('/repo/app/ios/App/AppDelegate.swift', 'ios', '/repo/app')).toBe(true) + expect(shouldRestartForNativeChange('/repo/app/android/app/src/main/AndroidManifest.xml', 'android', '/repo/app')).toBe(true) + }) + + it('ignores web-side files and generated native output', () => { + expect(shouldRestartForNativeChange('/repo/app/src/main.ts', 'ios', '/repo/app')).toBe(false) + expect(shouldRestartForNativeChange('/repo/app/ios/App/CapApp-SPM/Package.swift', 'ios', '/repo/app')).toBe(false) + expect(shouldRestartForNativeChange('/repo/app/android/build/generated/file.kt', 'android', '/repo/app')).toBe(false) + }) + + it('ignores capacitor config json updates', () => { + expect(shouldRestartForNativeChange('/repo/app/android/capacitor.config.json', 'android', '/repo/app')).toBe(false) + }) +}) diff --git a/packages/cap-vite/src/native.ts b/packages/cap-vite/src/native.ts new file mode 100644 index 000000000..f2a08f51e --- /dev/null +++ b/packages/cap-vite/src/native.ts @@ -0,0 +1,140 @@ +import type { ViteDevServer } from 'vite' + +import process from 'node:process' + +import { basename, extname, relative, resolve, sep } from 'node:path' + +export type CapacitorPlatform = 'android' | 'ios' + +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: [], +} + +export function parseCapacitorPlatform(value: string | undefined): CapacitorPlatform | null { + return value === 'android' || value === 'ios' ? value : null +} + +export function hasCapacitorTargetArg(capArgs: string[]): boolean { + return capArgs.some((arg, index) => arg === '--target' || (index > 0 && arg.startsWith('--target='))) +} + +export function resolveCapRunArgs(capArgs: string[], env: NodeJS.ProcessEnv = process.env): string[] { + if (capArgs.length === 0 || hasCapacitorTargetArg(capArgs)) { + return capArgs + } + + const target = env.CAPACITOR_DEVICE_ID + if (!target) { + return capArgs + } + + const [platform, ...rest] = capArgs + + return [platform, '--target', target, ...rest] +} + +export function pickServerUrl(server: Pick): 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.') + } + + return new URL(url) +} + +export 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()) +} diff --git a/packages/cap-vite/src/vite-plugin.ts b/packages/cap-vite/src/vite-plugin.ts new file mode 100644 index 000000000..07e560577 --- /dev/null +++ b/packages/cap-vite/src/vite-plugin.ts @@ -0,0 +1,124 @@ +import type { Result } from 'tinyexec' +import type { Plugin } from 'vite' + +import process from 'node:process' + +import { resolve } from 'node:path' + +import { x } from 'tinyexec' + +import { parseCapacitorPlatform, pickServerUrl, resolveCapRunArgs, shouldRestartForNativeChange } from './native' + +export interface CapVitePluginOptions { + capArgs: string[] +} + +async function stopCapProcess(current: Result | undefined) { + if (!current) { + return + } + + current.kill('SIGINT') + + try { + await current + } + catch { + // tinyexec rejects when a process is stopped during a restart. + } +} + +function startCapProcess(cwd: string, capArgs: string[], url: URL) { + console.info('\n----------------------\n') + console.info('Running cap run', ...capArgs) + + return x('cap', ['run', ...capArgs], { + throwOnError: false, + nodeOptions: { + cwd, + env: { + CAPACITOR_DEV_SERVER_URL: url.toString(), + }, + stdio: 'inherit', + }, + }) +} + +export function capVitePlugin(options: CapVitePluginOptions): Plugin { + const resolvedCapArgs = resolveCapRunArgs(options.capArgs) + const platform = parseCapacitorPlatform(resolvedCapArgs[0]) + if (!platform) { + throw new Error('The first `cap run` argument must be `ios` or `android`.') + } + + return { + apply: 'serve', + name: 'cap-vite:run-capacitor', + configureServer(server) { + const cwd = resolve(server.config.root) + const platformRoot = resolve(cwd, platform) + const debounceMs = 300 + const logger = server.config.logger + + let currentCapProcess: Result | undefined + let shuttingDown = false + let restartTimer: NodeJS.Timeout | undefined + + const start = () => { + const url = pickServerUrl(server) + currentCapProcess = startCapProcess(cwd, resolvedCapArgs, url) + } + + const restartCapProcess = async (reason: string) => { + if (shuttingDown) { + return + } + + logger.info(`[cap-vite] ${reason}. Re-running cap run ${platform}.`) + const previous = currentCapProcess + currentCapProcess = undefined + await stopCapProcess(previous) + start() + } + + const onWatcherEvent = (_event, file) => { + if (!shouldRestartForNativeChange(file, platform, cwd)) { + return + } + + clearTimeout(restartTimer) + restartTimer = setTimeout(() => { + restartCapProcess(`native file changed: ${resolve(cwd, file)}`) + }, debounceMs) + } + + const shutdown = async () => { + if (shuttingDown) { + return + } + + shuttingDown = true + clearTimeout(restartTimer) + server.watcher.off('all', onWatcherEvent) + await server.watcher.unwatch(platformRoot) + await stopCapProcess(currentCapProcess) + } + + server.watcher.add(platformRoot) + server.watcher.on('all', onWatcherEvent) + + server.httpServer?.once('listening', () => { + try { + start() + } + catch (error) { + logger.error(`[cap-vite] ${error instanceof Error ? error.message : String(error)}`) + shutdown() + } + }) + server.httpServer?.once('close', shutdown) + process.once('SIGINT', shutdown) + process.once('SIGTERM', shutdown) + }, + } +} diff --git a/packages/cap-vite/src/vite-wrapper-config.test.ts b/packages/cap-vite/src/vite-wrapper-config.test.ts new file mode 100644 index 000000000..7324a2534 --- /dev/null +++ b/packages/cap-vite/src/vite-wrapper-config.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { capVitePlugin, defineConfig, loadConfigFromFile, mergeConfig } = vi.hoisted(() => ({ + capVitePlugin: vi.fn(), + defineConfig: vi.fn((config: unknown) => config), + loadConfigFromFile: vi.fn(), + mergeConfig: vi.fn(), +})) + +vi.mock('vite', () => ({ + defineConfig, + loadConfigFromFile, + mergeConfig, +})) + +vi.mock('./vite-plugin', () => ({ + capVitePlugin, +})) + +describe('vite-wrapper-config', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.resetModules() + process.env.CAP_VITE_BASE_CONFIG = '/repo/app/vite.config.ts' + process.env.CAP_VITE_CAP_ARGS_JSON = JSON.stringify(['ios', '--target', 'iPhone 16 Pro']) + process.env.CAP_VITE_CONFIG_LOADER = 'runner' + process.env.CAP_VITE_ROOT = '/repo/app' + }) + + it('loads the user config and merges the injected plugin', async () => { + loadConfigFromFile.mockResolvedValue({ + config: { + server: { + port: 5173, + }, + }, + dependencies: [], + path: '/repo/app/vite.config.ts', + }) + mergeConfig.mockImplementation((defaults: Record, overrides: Record) => ({ + ...defaults, + ...overrides, + })) + capVitePlugin.mockReturnValue({ name: 'cap-vite:run-capacitor' }) + + const module = await import('./vite-wrapper-config') + const config = await module.default({ + command: 'serve', + mode: 'development', + isPreview: false, + }) + + expect(defineConfig).toHaveBeenCalledTimes(1) + expect(loadConfigFromFile).toHaveBeenCalledWith( + { + command: 'serve', + isPreview: false, + mode: 'development', + }, + '/repo/app/vite.config.ts', + '/repo/app', + undefined, + undefined, + 'runner', + ) + expect(capVitePlugin).toHaveBeenCalledWith({ + capArgs: ['ios', '--target', 'iPhone 16 Pro'], + }) + expect(config).toEqual({ + plugins: [{ name: 'cap-vite:run-capacitor' }], + server: { + port: 5173, + }, + }) + }) +}) diff --git a/packages/cap-vite/src/vite-wrapper-config.ts b/packages/cap-vite/src/vite-wrapper-config.ts new file mode 100644 index 000000000..bb1ac3fa1 --- /dev/null +++ b/packages/cap-vite/src/vite-wrapper-config.ts @@ -0,0 +1,51 @@ +import process from 'node:process' + +import { defineConfig, loadConfigFromFile, mergeConfig } from 'vite' + +import { capVitePlugin } from './vite-plugin' + +function parseCapArgs(): string[] { + const value = process.env.CAP_VITE_CAP_ARGS_JSON + if (!value) { + return [] + } + + const parsed = JSON.parse(value) + if (!Array.isArray(parsed) || parsed.some(arg => typeof arg !== 'string')) { + throw new Error('CAP_VITE_CAP_ARGS_JSON must be a JSON string array.') + } + + return parsed +} + +function parseConfigLoader(): 'bundle' | 'native' | 'runner' | undefined { + const value = process.env.CAP_VITE_CONFIG_LOADER + if (value === 'bundle' || value === 'native' || value === 'runner') { + return value + } + + return undefined +} + +export default defineConfig(async (env) => { + const root = process.env.CAP_VITE_ROOT ?? process.cwd() + const baseConfigFile = process.env.CAP_VITE_BASE_CONFIG || undefined + const configLoader = parseConfigLoader() + + const loaded = await loadConfigFromFile( + env, + baseConfigFile, + root, + undefined, + undefined, + configLoader, + ) + + return mergeConfig(loaded?.config ?? {}, { + plugins: [ + capVitePlugin({ + capArgs: parseCapArgs(), + }), + ], + }) +}) diff --git a/packages/cap-vite/tsdown.config.ts b/packages/cap-vite/tsdown.config.ts index 9158cb27b..35e0d9aef 100644 --- a/packages/cap-vite/tsdown.config.ts +++ b/packages/cap-vite/tsdown.config.ts @@ -4,6 +4,8 @@ export default defineConfig({ entry: { 'index': 'src/index.ts', 'bin/run': 'src/bin/run.ts', + 'vite-plugin': 'src/vite-plugin.ts', + 'vite-wrapper-config': 'src/vite-wrapper-config.ts', }, target: 'node18', outDir: 'dist',