Add device pairing command and voice mute toggle
Introduce `paseo daemon pair` CLI command that generates a local pairing offer URL with QR code without requiring a running daemon. Update message input to support mute/unmute toggle during realtime voice sessions with visual feedback (MicOff icon). Fix android:prod build script to use prebuild + assembleRelease flow.
This commit is contained in:
@@ -10,7 +10,7 @@
|
|||||||
"android:debug": "APP_VARIANT=debug bash ./scripts/android-run-variant.sh",
|
"android:debug": "APP_VARIANT=debug bash ./scripts/android-run-variant.sh",
|
||||||
"android:fast:build": "APP_VARIANT=fast bash ./scripts/android-build-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: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",
|
"android:release": "npm run android:prod",
|
||||||
"ios": "expo run:ios",
|
"ios": "expo run:ios",
|
||||||
"ios:release": "expo run:ios --configuration Release",
|
"ios:release": "expo run:ios --configuration Release",
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
forwardRef,
|
forwardRef,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
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, {
|
import Animated, {
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
@@ -244,6 +244,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
isDictatingRef.current = isDictating;
|
isDictatingRef.current = isDictating;
|
||||||
}, [isDictating]);
|
}, [isDictating]);
|
||||||
|
|
||||||
|
const isRealtimeVoiceForCurrentAgent =
|
||||||
|
!!voice &&
|
||||||
|
!!voiceServerId &&
|
||||||
|
!!voiceAgentId &&
|
||||||
|
voice.isVoiceModeForAgent(voiceServerId, voiceAgentId);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isDictating || isDictationProcessing) {
|
if (isDictating || isDictationProcessing) {
|
||||||
return;
|
return;
|
||||||
@@ -314,20 +320,23 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const handleVoicePress = useCallback(async () => {
|
const handleVoicePress = useCallback(async () => {
|
||||||
const isRealtimeVoiceForCurrentAgent =
|
if (isRealtimeVoiceForCurrentAgent && voice) {
|
||||||
voice &&
|
voice.toggleMute();
|
||||||
!!voiceServerId &&
|
return;
|
||||||
!!voiceAgentId &&
|
}
|
||||||
voice.isVoiceModeForAgent(voiceServerId, voiceAgentId);
|
|
||||||
if (isDictating) {
|
if (isDictating) {
|
||||||
await cancelDictation();
|
await cancelDictation();
|
||||||
} else {
|
} else {
|
||||||
if (isRealtimeVoiceForCurrentAgent) {
|
|
||||||
await voice.stopVoice();
|
|
||||||
}
|
|
||||||
await startDictation();
|
await startDictation();
|
||||||
}
|
}
|
||||||
}, [isDictating, cancelDictation, startDictation, voice, voiceAgentId, voiceServerId]);
|
}, [
|
||||||
|
cancelDictation,
|
||||||
|
isDictating,
|
||||||
|
isRealtimeVoiceForCurrentAgent,
|
||||||
|
startDictation,
|
||||||
|
voice,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleCancelRecording = useCallback(async () => {
|
const handleCancelRecording = useCallback(async () => {
|
||||||
await cancelDictation();
|
await cancelDictation();
|
||||||
@@ -623,6 +632,16 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
<Pressable
|
<Pressable
|
||||||
onPress={handleVoicePress}
|
onPress={handleVoicePress}
|
||||||
disabled={!isConnected || disabled}
|
disabled={!isConnected || disabled}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={
|
||||||
|
isRealtimeVoiceForCurrentAgent
|
||||||
|
? voice?.isMuted
|
||||||
|
? "Unmute realtime voice"
|
||||||
|
: "Mute realtime voice"
|
||||||
|
: isDictating
|
||||||
|
? "Stop dictation"
|
||||||
|
: "Start dictation"
|
||||||
|
}
|
||||||
style={[
|
style={[
|
||||||
styles.voiceButton,
|
styles.voiceButton,
|
||||||
(!isConnected || disabled) && styles.buttonDisabled,
|
(!isConnected || disabled) && styles.buttonDisabled,
|
||||||
@@ -631,6 +650,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
>
|
>
|
||||||
{isDictating ? (
|
{isDictating ? (
|
||||||
<Square size={14} color="white" fill="white" />
|
<Square size={14} color="white" fill="white" />
|
||||||
|
) : isRealtimeVoiceForCurrentAgent && voice?.isMuted ? (
|
||||||
|
<MicOff size={20} color={theme.colors.foreground} />
|
||||||
) : (
|
) : (
|
||||||
<Mic size={20} color={theme.colors.foreground} />
|
<Mic size={20} color={theme.colors.foreground} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import { startCommand } from './start.js'
|
|||||||
import { runStatusCommand } from './status.js'
|
import { runStatusCommand } from './status.js'
|
||||||
import { runStopCommand } from './stop.js'
|
import { runStopCommand } from './stop.js'
|
||||||
import { runRestartCommand } from './restart.js'
|
import { runRestartCommand } from './restart.js'
|
||||||
|
import { pairCommand } from './pair.js'
|
||||||
import { withOutput } from '../../output/index.js'
|
import { withOutput } from '../../output/index.js'
|
||||||
|
|
||||||
export function createDaemonCommand(): Command {
|
export function createDaemonCommand(): Command {
|
||||||
const daemon = new Command('daemon').description('Manage the Paseo daemon')
|
const daemon = new Command('daemon').description('Manage the Paseo daemon')
|
||||||
|
|
||||||
daemon.addCommand(startCommand())
|
daemon.addCommand(startCommand())
|
||||||
|
daemon.addCommand(pairCommand())
|
||||||
|
|
||||||
daemon
|
daemon
|
||||||
.command('status')
|
.command('status')
|
||||||
|
|||||||
@@ -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 <path>', 'Paseo home directory (default: ~/.paseo)')
|
||||||
|
.action(async (options: PairOptions) => {
|
||||||
|
await runPairCommand(options)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runPairCommand(options: PairOptions): Promise<void> {
|
||||||
|
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`)
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
*
|
*
|
||||||
* Tests:
|
* Tests:
|
||||||
* - daemon --help shows subcommands
|
* - 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 fails gracefully when daemon not running
|
||||||
* - daemon status --json outputs valid JSON (even for errors)
|
* - daemon status --json outputs valid JSON (even for errors)
|
||||||
* - daemon stop handles daemon not running gracefully
|
* - 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('status'), 'help should mention status')
|
||||||
assert(result.stdout.includes('stop'), 'help should mention stop')
|
assert(result.stdout.includes('stop'), 'help should mention stop')
|
||||||
assert(result.stdout.includes('restart'), 'help should mention restart')
|
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')
|
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 =
|
const result =
|
||||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo daemon status`.nothrow()
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo daemon status`.nothrow()
|
||||||
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
|
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')
|
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 =
|
const result =
|
||||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo daemon status --json`.nothrow()
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo daemon status --json`.nothrow()
|
||||||
// Should still fail (daemon not running)
|
// 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 =
|
const result =
|
||||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo daemon stop`.nothrow()
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo daemon stop`.nothrow()
|
||||||
// Stop should succeed even if daemon not running (idempotent)
|
// 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 =
|
const result =
|
||||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo daemon restart`.nothrow()
|
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)
|
// Restart should fail when daemon not running (can't restart something that's not running)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export { loadConfig, type CliConfigOverrides } from "./config.js";
|
|||||||
export { resolvePaseoHome } from "./paseo-home.js";
|
export { resolvePaseoHome } from "./paseo-home.js";
|
||||||
export { createRootLogger, type LogLevel, type LogFormat } from "./logger.js";
|
export { createRootLogger, type LogLevel, type LogFormat } from "./logger.js";
|
||||||
export { loadPersistedConfig, type PersistedConfig } from "./persisted-config.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";
|
export { DaemonClient, type DaemonClientConfig, type ConnectionState, type DaemonEvent } from "../client/daemon-client.js";
|
||||||
|
|
||||||
// Agent SDK types for CLI commands
|
// Agent SDK types for CLI commands
|
||||||
|
|||||||
@@ -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<LocalPairingOffer> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ function shouldPrintPairingQr(): boolean {
|
|||||||
return Boolean(process.stdout.isTTY);
|
return Boolean(process.stdout.isTTY);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderQr(url: string): Promise<string> {
|
export async function renderPairingQr(url: string): Promise<string> {
|
||||||
const terminalOptions: QRCodeToStringOptionsTerminal = {
|
const terminalOptions: QRCodeToStringOptionsTerminal = {
|
||||||
type: "terminal",
|
type: "terminal",
|
||||||
small: true,
|
small: true,
|
||||||
@@ -39,7 +39,7 @@ export async function printPairingQrIfEnabled(args: {
|
|||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
if (!shouldPrintPairingQr()) return;
|
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`;
|
const out = `\nScan to pair:\n${qr}\n${args.url}\n`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user