diff --git a/packages/app/package.json b/packages/app/package.json index bddd5aa37..3aaf1571e 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -10,7 +10,7 @@ "android:debug": "APP_VARIANT=debug bash ./scripts/android-run-variant.sh", "android:fast:build": "APP_VARIANT=fast bash ./scripts/android-build-variant.sh", "android:debug:build": "APP_VARIANT=debug bash ./scripts/android-build-variant.sh", - "android:prod": "APP_VARIANT=prod expo run:android --variant=release", + "android:prod": "APP_VARIANT=prod npx expo prebuild --platform android --clean --no-install && (cd android && ./gradlew clean app:assembleRelease --rerun-tasks) && adb install -r android/app/build/outputs/apk/release/app-release.apk", "android:release": "npm run android:prod", "ios": "expo run:ios", "ios:release": "expo run:ios --configuration Release", diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index 60db3b1de..9d4b39d38 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -18,7 +18,7 @@ import { forwardRef, } from "react"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { Mic, ArrowUp, Paperclip, X, Square } from "lucide-react-native"; +import { Mic, MicOff, ArrowUp, Paperclip, X, Square } from "lucide-react-native"; import Animated, { useSharedValue, useAnimatedStyle, @@ -244,6 +244,12 @@ export const MessageInput = forwardRef( isDictatingRef.current = isDictating; }, [isDictating]); + const isRealtimeVoiceForCurrentAgent = + !!voice && + !!voiceServerId && + !!voiceAgentId && + voice.isVoiceModeForAgent(voiceServerId, voiceAgentId); + useEffect(() => { if (isDictating || isDictationProcessing) { return; @@ -314,20 +320,23 @@ export const MessageInput = forwardRef( })); const handleVoicePress = useCallback(async () => { - const isRealtimeVoiceForCurrentAgent = - voice && - !!voiceServerId && - !!voiceAgentId && - voice.isVoiceModeForAgent(voiceServerId, voiceAgentId); + if (isRealtimeVoiceForCurrentAgent && voice) { + voice.toggleMute(); + return; + } + if (isDictating) { await cancelDictation(); } else { - if (isRealtimeVoiceForCurrentAgent) { - await voice.stopVoice(); - } await startDictation(); } - }, [isDictating, cancelDictation, startDictation, voice, voiceAgentId, voiceServerId]); + }, [ + cancelDictation, + isDictating, + isRealtimeVoiceForCurrentAgent, + startDictation, + voice, + ]); const handleCancelRecording = useCallback(async () => { await cancelDictation(); @@ -623,6 +632,16 @@ export const MessageInput = forwardRef( ( > {isDictating ? ( + ) : isRealtimeVoiceForCurrentAgent && voice?.isMuted ? ( + ) : ( )} diff --git a/packages/cli/src/commands/daemon/index.ts b/packages/cli/src/commands/daemon/index.ts index 57936a7d9..526c7d880 100644 --- a/packages/cli/src/commands/daemon/index.ts +++ b/packages/cli/src/commands/daemon/index.ts @@ -3,12 +3,14 @@ import { startCommand } from './start.js' import { runStatusCommand } from './status.js' import { runStopCommand } from './stop.js' import { runRestartCommand } from './restart.js' +import { pairCommand } from './pair.js' import { withOutput } from '../../output/index.js' export function createDaemonCommand(): Command { const daemon = new Command('daemon').description('Manage the Paseo daemon') daemon.addCommand(startCommand()) + daemon.addCommand(pairCommand()) daemon .command('status') diff --git a/packages/cli/src/commands/daemon/pair.ts b/packages/cli/src/commands/daemon/pair.ts new file mode 100644 index 000000000..55e34f7e0 --- /dev/null +++ b/packages/cli/src/commands/daemon/pair.ts @@ -0,0 +1,42 @@ +import { Command } from 'commander' +import chalk from 'chalk' +import { generateLocalPairingOffer, loadConfig, resolvePaseoHome } from '@getpaseo/server' + +interface PairOptions { + home?: string +} + +export function pairCommand(): Command { + return new Command('pair') + .description('Print the daemon pairing QR code and link') + .option('--home ', 'Paseo home directory (default: ~/.paseo)') + .action(async (options: PairOptions) => { + await runPairCommand(options) + }) +} + +export async function runPairCommand(options: PairOptions): Promise { + if (options.home) { + process.env.PASEO_HOME = options.home + } + + const paseoHome = resolvePaseoHome() + const config = loadConfig(paseoHome) + const pairing = await generateLocalPairingOffer({ + paseoHome, + relayEnabled: config.relayEnabled, + relayEndpoint: config.relayEndpoint, + relayPublicEndpoint: config.relayPublicEndpoint, + appBaseUrl: config.appBaseUrl, + includeQr: true, + }) + + if (!pairing.relayEnabled || !pairing.url) { + console.error(chalk.red('Relay pairing is disabled for this daemon config.')) + console.error(chalk.yellow('Enable relay and run this command again.')) + process.exit(1) + } + + const qrBlock = pairing.qr ? `${pairing.qr}\n` : '' + process.stdout.write(`\nScan to pair:\n${qrBlock}${pairing.url}\n`) +} diff --git a/packages/cli/tests/03-daemon.test.ts b/packages/cli/tests/03-daemon.test.ts index ccd5894df..ef91af267 100644 --- a/packages/cli/tests/03-daemon.test.ts +++ b/packages/cli/tests/03-daemon.test.ts @@ -8,6 +8,7 @@ * * Tests: * - daemon --help shows subcommands + * - daemon pair prints a local pairing link without requiring a running daemon * - daemon status fails gracefully when daemon not running * - daemon status --json outputs valid JSON (even for errors) * - daemon stop handles daemon not running gracefully @@ -37,12 +38,24 @@ try { assert(result.stdout.includes('status'), 'help should mention status') assert(result.stdout.includes('stop'), 'help should mention stop') assert(result.stdout.includes('restart'), 'help should mention restart') + assert(result.stdout.includes('pair'), 'help should mention pair') console.log('✓ daemon --help shows subcommands\n') } - // Test 2: daemon status fails gracefully when daemon not running + // Test 2: daemon pair works without daemon process { - console.log('Test 2: daemon status fails gracefully when not running') + console.log('Test 2: daemon pair prints local pairing URL') + const result = + await $`PASEO_HOME=${paseoHome} npx paseo daemon pair`.nothrow() + assert.strictEqual(result.exitCode, 0, 'daemon pair should succeed') + assert(result.stdout.includes('Scan to pair:'), 'output should include scan header') + assert(result.stdout.includes('#offer='), 'output should include pairing offer fragment') + console.log('✓ daemon pair prints local pairing URL\n') + } + + // Test 3: daemon status fails gracefully when daemon not running + { + console.log('Test 3: daemon status fails gracefully when not running') const result = await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo daemon status`.nothrow() assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running') @@ -56,9 +69,9 @@ try { console.log('✓ daemon status fails gracefully when not running\n') } - // Test 3: daemon status --json outputs valid JSON (even for errors) + // Test 4: daemon status --json outputs valid JSON (even for errors) { - console.log('Test 3: daemon status --json outputs JSON') + console.log('Test 4: daemon status --json outputs JSON') const result = await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo daemon status --json`.nothrow() // Should still fail (daemon not running) @@ -79,9 +92,9 @@ try { } } - // Test 4: daemon stop handles daemon not running gracefully + // Test 5: daemon stop handles daemon not running gracefully { - console.log('Test 4: daemon stop handles daemon not running') + console.log('Test 5: daemon stop handles daemon not running') const result = await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo daemon stop`.nothrow() // Stop should succeed even if daemon not running (idempotent) @@ -105,9 +118,9 @@ try { } } - // Test 5: daemon restart fails when daemon not running + // Test 6: daemon restart fails when daemon not running { - console.log('Test 5: daemon restart fails when daemon not running') + console.log('Test 6: daemon restart fails when daemon not running') const result = await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo daemon restart`.nothrow() // Restart should fail when daemon not running (can't restart something that's not running) diff --git a/packages/server/src/server/exports.ts b/packages/server/src/server/exports.ts index 31262a6a0..f1ce98afd 100644 --- a/packages/server/src/server/exports.ts +++ b/packages/server/src/server/exports.ts @@ -4,6 +4,7 @@ export { loadConfig, type CliConfigOverrides } from "./config.js"; export { resolvePaseoHome } from "./paseo-home.js"; export { createRootLogger, type LogLevel, type LogFormat } from "./logger.js"; export { loadPersistedConfig, type PersistedConfig } from "./persisted-config.js"; +export { generateLocalPairingOffer, type LocalPairingOffer } from "./pairing-offer.js"; export { DaemonClient, type DaemonClientConfig, type ConnectionState, type DaemonEvent } from "../client/daemon-client.js"; // Agent SDK types for CLI commands diff --git a/packages/server/src/server/pairing-offer.ts b/packages/server/src/server/pairing-offer.ts new file mode 100644 index 000000000..2b23291a4 --- /dev/null +++ b/packages/server/src/server/pairing-offer.ts @@ -0,0 +1,64 @@ +import type { Logger } from "pino"; + +import { createConnectionOfferV2, encodeOfferToFragmentUrl } from "./connection-offer.js"; +import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js"; +import { renderPairingQr } from "./pairing-qr.js"; +import { getOrCreateServerId } from "./server-id.js"; + +export type LocalPairingOffer = { + relayEnabled: boolean; + url: string | null; + qr: string | null; +}; + +export async function generateLocalPairingOffer(args: { + paseoHome: string; + relayEnabled?: boolean; + relayEndpoint?: string; + relayPublicEndpoint?: string; + appBaseUrl?: string; + includeQr?: boolean; + logger?: Logger; +}): Promise { + const relayEnabled = args.relayEnabled ?? true; + if (!relayEnabled) { + return { + relayEnabled: false, + url: null, + qr: null, + }; + } + + const relayEndpoint = args.relayEndpoint ?? "relay.paseo.sh:443"; + const relayPublicEndpoint = args.relayPublicEndpoint ?? relayEndpoint; + const appBaseUrl = args.appBaseUrl ?? "https://app.paseo.sh"; + const serverId = getOrCreateServerId(args.paseoHome, { logger: args.logger }); + const daemonKeyPair = await loadOrCreateDaemonKeyPair(args.paseoHome, args.logger); + const offer = await createConnectionOfferV2({ + serverId, + daemonPublicKeyB64: daemonKeyPair.publicKeyB64, + relay: { endpoint: relayPublicEndpoint }, + }); + const url = encodeOfferToFragmentUrl({ offer, appBaseUrl }); + + if (args.includeQr === false) { + return { + relayEnabled: true, + url, + qr: null, + }; + } + + let qr: string | null = null; + try { + qr = await renderPairingQr(url); + } catch (error) { + args.logger?.debug({ error }, "Failed to render pairing QR"); + } + + return { + relayEnabled: true, + url, + qr, + }; +} diff --git a/packages/server/src/server/pairing-qr.ts b/packages/server/src/server/pairing-qr.ts index 3b4ab3399..872e7e7db 100644 --- a/packages/server/src/server/pairing-qr.ts +++ b/packages/server/src/server/pairing-qr.ts @@ -16,7 +16,7 @@ function shouldPrintPairingQr(): boolean { return Boolean(process.stdout.isTTY); } -async function renderQr(url: string): Promise { +export async function renderPairingQr(url: string): Promise { const terminalOptions: QRCodeToStringOptionsTerminal = { type: "terminal", small: true, @@ -39,7 +39,7 @@ export async function printPairingQrIfEnabled(args: { }): Promise { if (!shouldPrintPairingQr()) return; - const qr = await renderQr(args.url); + const qr = await renderPairingQr(args.url); const out = `\nScan to pair:\n${qr}\n${args.url}\n`; try {