feat(cap-vite): press R to re-run capacitor cli

This commit is contained in:
LemonNeko
2026-03-18 19:11:16 +08:00
parent 60238530f0
commit 071e095ed0
4 changed files with 326 additions and 25 deletions
+3 -1
View File
@@ -21,6 +21,7 @@ pnpm -F @proj-airi/stage-pocket run dev:ios -- --target <DEVICE_ID_OR_SIMULATOR_
- 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 <CAPACITOR_DEVICE_ID>` automatically.
- `cap-vite` always launches the Vite dev server. Do not pass `vite dev` or `vite serve` as extra args.
- After the dev server starts, press `R` in the terminal to re-run `cap run` without restarting Vite.
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`.
@@ -51,11 +52,12 @@ export default config
- 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.
- Rerun `cap run` on demand from the same terminal when you need a clean native relaunch.
- 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.
- The injected plugin reads `server.resolvedUrls`, starts `cap run`, and restarts it when files under the native platform directory change or when you press `R` in the terminal.
- `cap-vite` only splits the two argument groups and passes the `cap run` arguments into the injected plugin through environment variables.
+1
View File
@@ -13,6 +13,7 @@ const helpText = [
'Notes:',
' Arguments before `--` are forwarded to Vite.',
' Arguments after `--` are forwarded to `cap run`.',
' After the dev server starts, press `R` to re-run `cap run`.',
].join('\n')
export interface ParsedCapViteCliArgs {
+197
View File
@@ -0,0 +1,197 @@
import type { Plugin } from 'vite'
import process from 'node:process'
import { EventEmitter } from 'node:events'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { emitKeypressEvents, x } = vi.hoisted(() => ({
emitKeypressEvents: vi.fn(),
x: vi.fn(),
}))
vi.mock('node:readline', () => ({
emitKeypressEvents,
}))
vi.mock('tinyexec', () => ({
x,
}))
type MockResult = Promise<{ exitCode: number, stderr: string, stdout: string }> & {
kill: ReturnType<typeof vi.fn>
}
class MockWatcher extends EventEmitter {
add = vi.fn()
unwatch = vi.fn(async () => {})
}
class MockHttpServer extends EventEmitter {}
function createMockResult(): MockResult {
const output = {
exitCode: 0,
stderr: '',
stdout: '',
}
return Object.assign(Promise.resolve(output), {
kill: vi.fn(() => true),
})
}
function createMockServer() {
const watcher = new MockWatcher()
const httpServer = new MockHttpServer()
return {
config: {
logger: {
error: vi.fn(),
info: vi.fn(),
},
root: '/repo/app',
},
httpServer,
resolvedUrls: {
local: ['http://127.0.0.1:5173/'],
},
watcher,
}
}
function createMockStdin() {
const stdin = new EventEmitter() as EventEmitter & {
isRaw: boolean
isTTY: boolean
resume: ReturnType<typeof vi.fn>
setEncoding: ReturnType<typeof vi.fn>
setRawMode: ReturnType<typeof vi.fn>
}
stdin.isRaw = false
stdin.isTTY = true
stdin.resume = vi.fn()
stdin.setEncoding = vi.fn()
stdin.setRawMode = vi.fn((value: boolean) => {
stdin.isRaw = value
})
return stdin
}
function configurePluginServer(plugin: Plugin, server: ReturnType<typeof createMockServer>) {
const configureServer = plugin.configureServer
if (!configureServer) {
throw new Error('cap-vite plugin is missing configureServer().')
}
const handler = typeof configureServer === 'function'
? configureServer
: configureServer.handler
handler.call({} as any, server as any)
}
const originalStdin = Object.getOwnPropertyDescriptor(process, 'stdin')
describe('capVitePlugin', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
})
afterEach(() => {
if (originalStdin) {
Object.defineProperty(process, 'stdin', originalStdin)
}
})
it('restarts cap run when the terminal shortcut receives r', async () => {
const firstRun = createMockResult()
const secondRun = createMockResult()
x.mockReturnValueOnce(firstRun).mockReturnValueOnce(secondRun)
const stdin = createMockStdin()
Object.defineProperty(process, 'stdin', {
configurable: true,
value: stdin,
})
const { capVitePlugin } = await import('./vite-plugin')
const server = createMockServer()
configurePluginServer(capVitePlugin({
capArgs: ['ios', '--scheme', 'AIRI'],
}), server)
server.httpServer.emit('listening')
expect(emitKeypressEvents).toHaveBeenCalledWith(stdin)
expect(stdin.setRawMode).toHaveBeenCalledWith(true)
expect(x).toHaveBeenNthCalledWith(1, 'cap', ['run', 'ios', '--scheme', 'AIRI'], {
nodeOptions: {
cwd: '/repo/app',
env: {
CAPACITOR_DEV_SERVER_URL: 'http://127.0.0.1:5173/',
},
stdio: ['ignore', 'inherit', 'inherit'],
},
throwOnError: false,
})
stdin.emit('keypress', 'r', { ctrl: false, name: 'r' })
await vi.waitFor(() => {
expect(firstRun.kill).toHaveBeenCalledWith('SIGINT')
expect(server.config.logger.info).toHaveBeenCalledWith('[cap-vite] manual restart requested. Re-running cap run ios.')
expect(x).toHaveBeenNthCalledWith(2, 'cap', ['run', 'ios', '--scheme', 'AIRI'], {
nodeOptions: {
cwd: '/repo/app',
env: {
CAPACITOR_DEV_SERVER_URL: 'http://127.0.0.1:5173/',
},
stdio: ['ignore', 'inherit', 'inherit'],
},
throwOnError: false,
})
})
server.httpServer.emit('close')
await vi.waitFor(() => {
expect(secondRun.kill).toHaveBeenCalledWith('SIGINT')
})
})
it('restores terminal state before forwarding ctrl+c to vite', async () => {
const run = createMockResult()
x.mockReturnValue(run)
const stdin = createMockStdin()
Object.defineProperty(process, 'stdin', {
configurable: true,
value: stdin,
})
const kill = vi.spyOn(process, 'kill').mockReturnValue(true)
const { capVitePlugin } = await import('./vite-plugin')
const server = createMockServer()
configurePluginServer(capVitePlugin({
capArgs: ['android'],
}), server)
server.httpServer.emit('listening')
stdin.emit('keypress', '\u0003', { ctrl: true, name: 'c' })
await vi.waitFor(() => {
expect(run.kill).toHaveBeenCalledWith('SIGINT')
expect(stdin.setRawMode).toHaveBeenNthCalledWith(1, true)
expect(stdin.setRawMode).toHaveBeenNthCalledWith(2, false)
expect(kill).toHaveBeenCalledWith(process.pid, 'SIGINT')
})
})
})
+125 -24
View File
@@ -1,10 +1,14 @@
import type { Result } from 'tinyexec'
import type { Plugin } from 'vite'
import type { Logger, Plugin } from 'vite'
import type { CapacitorPlatform } from './native'
import process from 'node:process'
import { resolve } from 'node:path'
import * as readline from 'node:readline'
import { x } from 'tinyexec'
import { parseCapacitorPlatform, pickServerUrl, resolveCapRunArgs, shouldRestartForNativeChange } from './native'
@@ -31,6 +35,7 @@ async function stopCapProcess(current: Result | undefined) {
function startCapProcess(cwd: string, capArgs: string[], url: URL) {
console.info('\n----------------------\n')
console.info('Running cap run', ...capArgs)
console.info('[cap-vite] Press R to restart cap run. Press Ctrl+C to exit.')
return x('cap', ['run', ...capArgs], {
throwOnError: false,
@@ -39,67 +44,168 @@ function startCapProcess(cwd: string, capArgs: string[], url: URL) {
env: {
CAPACITOR_DEV_SERVER_URL: url.toString(),
},
stdio: 'inherit',
// NOTICE: cap-vite owns the terminal shortcuts, so cap run should not
// consume stdin while still mirroring its stdout/stderr to the console.
stdio: ['ignore', 'inherit', 'inherit'],
},
})
}
function bindCapViteShortcuts(
logger: Logger,
onRestart: () => void,
onShutdown: () => Promise<void>,
) {
if (!process.stdin.isTTY || typeof process.stdin.setRawMode !== 'function') {
return () => {}
}
process.stdin.resume()
process.stdin.setEncoding('utf8')
readline.emitKeypressEvents(process.stdin)
const shouldRestoreRawMode = !process.stdin.isRaw
if (shouldRestoreRawMode) {
process.stdin.setRawMode(true)
}
async function shutdownFromShortcut() {
try {
await onShutdown()
}
finally {
if (shouldRestoreRawMode) {
process.stdin.setRawMode(false)
}
process.kill(process.pid, 'SIGINT')
}
}
const onKeyPress = (input: string, key: readline.Key) => {
if (key.ctrl && key.name === 'c') {
void shutdownFromShortcut()
return
}
const keyName = key.name?.toLowerCase() ?? input.toLowerCase()
if (!key.ctrl && !key.meta && keyName === 'r') {
onRestart()
}
}
process.stdin.on('keypress', onKeyPress)
logger.info('[cap-vite] Terminal shortcuts enabled: R restarts cap run.')
return () => {
process.stdin.off('keypress', onKeyPress)
if (shouldRestoreRawMode) {
process.stdin.setRawMode(false)
}
}
}
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`.')
}
const resolvedPlatform: CapacitorPlatform = platform
return {
apply: 'serve',
name: 'cap-vite:run-capacitor',
configureServer(server) {
const cwd = resolve(server.config.root)
const platformRoot = resolve(cwd, platform)
const platformRoot = resolve(cwd, resolvedPlatform)
const debounceMs = 300
const logger = server.config.logger
let currentCapProcess: Result | undefined
let restartTask: Promise<void> | undefined
let queuedRestartReason: string | undefined
let disposeShortcut: (() => void) | undefined
let shuttingDown = false
let restartTimer: NodeJS.Timeout | undefined
const start = () => {
function launchCapProcess() {
const url = pickServerUrl(server)
currentCapProcess = startCapProcess(cwd, resolvedCapArgs, url)
}
const restartCapProcess = async (reason: string) => {
function requestRestart(reason: string) {
if (shuttingDown) {
return
}
logger.info(`[cap-vite] ${reason}. Re-running cap run ${platform}.`)
const previous = currentCapProcess
currentCapProcess = undefined
await stopCapProcess(previous)
start()
queuedRestartReason = reason
if (!restartTask) {
restartTask = flushPendingRestarts()
}
}
const onWatcherEvent = (_event, file) => {
if (!shouldRestartForNativeChange(file, platform, cwd)) {
async function flushPendingRestarts() {
try {
while (queuedRestartReason) {
const activeReason = queuedRestartReason
queuedRestartReason = undefined
if (shuttingDown) {
return
}
logger.info(`[cap-vite] ${activeReason}. Re-running cap run ${resolvedPlatform}.`)
const previous = currentCapProcess
currentCapProcess = undefined
await stopCapProcess(previous)
if (shuttingDown) {
return
}
launchCapProcess()
}
}
catch (error) {
logger.error(`[cap-vite] ${error instanceof Error ? error.message : String(error)}`)
await shutdown()
}
finally {
restartTask = undefined
}
}
function onWatcherEvent(_event, file) {
if (!shouldRestartForNativeChange(file, resolvedPlatform, cwd)) {
return
}
clearTimeout(restartTimer)
restartTimer = setTimeout(() => {
restartCapProcess(`native file changed: ${resolve(cwd, file)}`)
requestRestart(`native file changed: ${resolve(cwd, file)}`)
}, debounceMs)
}
const shutdown = async () => {
function handleShutdownRequest() {
void shutdown()
}
async function shutdown() {
if (shuttingDown) {
return
}
shuttingDown = true
clearTimeout(restartTimer)
queuedRestartReason = undefined
const disposeBoundShortcut = disposeShortcut
disposeShortcut = undefined
disposeBoundShortcut?.()
server.watcher.off('all', onWatcherEvent)
process.off('SIGINT', handleShutdownRequest)
process.off('SIGTERM', handleShutdownRequest)
await server.watcher.unwatch(platformRoot)
await stopCapProcess(currentCapProcess)
}
@@ -108,17 +214,12 @@ export function capVitePlugin(options: CapVitePluginOptions): Plugin {
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()
}
launchCapProcess()
disposeShortcut = bindCapViteShortcuts(logger, () => requestRestart('manual restart requested'), shutdown)
})
server.httpServer?.once('close', shutdown)
process.once('SIGINT', shutdown)
process.once('SIGTERM', shutdown)
server.httpServer?.once('close', handleShutdownRequest)
process.once('SIGINT', handleShutdownRequest)
process.once('SIGTERM', handleShutdownRequest)
},
}
}