diff --git a/packages/mobile-voice/AGENTS.md b/packages/mobile-voice/AGENTS.md index 53ddc77028..a198a84753 100644 --- a/packages/mobile-voice/AGENTS.md +++ b/packages/mobile-voice/AGENTS.md @@ -175,3 +175,9 @@ Example shape: - If behavior could break startup, run `bunx expo export --platform ios --clear`. - Confirm no accidental config side effects were introduced. - Summarize what was verified on-device vs only in tooling. + + +- Dev build (internal/dev client): + - bunx eas build --profile development --platform ios +- Production build + auto-submit: + - bunx eas build --profile production --platform ios --auto-submit diff --git a/packages/mobile-voice/package.json b/packages/mobile-voice/package.json index 41535a8b00..b47354d900 100644 --- a/packages/mobile-voice/package.json +++ b/packages/mobile-voice/package.json @@ -4,6 +4,7 @@ "version": "1.0.0", "scripts": { "start": "expo start", + "expo:start": "REACT_NATIVE_PACKAGER_HOSTNAME=exos.husky-tilapia.ts.net expo start --dev-client --clear --host lan", "relay": "echo 'Use packages/apn-relay for APNs relay server'", "relay:legacy": "node ./relay/opencode-relay.mjs", "reset-project": "node ./scripts/reset-project.js", diff --git a/packages/mobile-voice/src/app/index.tsx b/packages/mobile-voice/src/app/index.tsx index c45171ded4..caf52386e8 100644 --- a/packages/mobile-voice/src/app/index.tsx +++ b/packages/mobile-voice/src/app/index.tsx @@ -48,6 +48,10 @@ const WAVEFORM_ROWS = 5 const WAVEFORM_CELL_SIZE = 8 const WAVEFORM_CELL_GAP = 2 const DROPDOWN_VISIBLE_ROWS = 6 +const DROPDOWN_ROW_HEIGHT = 42 +const SERVER_MENU_SECTION_HEIGHT = 56 +const SERVER_MENU_ENTRY_HEIGHT = 36 +const SERVER_MENU_FOOTER_HEIGHT = 28 // If the press duration is shorter than this, treat it as a tap (toggle) const TAP_THRESHOLD_MS = 300 const SERVER_STATE_FILE = `${FileSystem.documentDirectory}mobile-voice-servers.json` @@ -591,6 +595,10 @@ export default function DictationScreen() { const [readerModeRendered, setReaderModeRendered] = useState(false) const [dropdownMode, setDropdownMode] = useState("none") const [dropdownRenderMode, setDropdownRenderMode] = useState>("server") + const [serverMenuListHeight, setServerMenuListHeight] = useState(0) + const [sessionMenuListHeight, setSessionMenuListHeight] = useState(0) + const [serverMenuFooterHeight, setServerMenuFooterHeight] = useState(0) + const [sessionMenuFooterHeight, setSessionMenuFooterHeight] = useState(0) const [sessionCreateMode, setSessionCreateMode] = useState<"same" | "root" | null>(null) const [scanOpen, setScanOpen] = useState(false) const [pairSelectionOpen, setPairSelectionOpen] = useState(false) @@ -633,6 +641,8 @@ export default function DictationScreen() { setDropdownMode("none") }, []) + const discoveryEnabled = onboardingComplete && localNetworkPermissionState !== "denied" && dropdownMode === "server" + const { servers, serversRef, @@ -655,7 +665,7 @@ export default function DictationScreen() { const { discoveredServers, discoveryStatus, discoveryError, discoveryAvailable, refreshDiscovery } = useMdnsDiscovery( { - enabled: onboardingComplete && localNetworkPermissionState !== "denied", + enabled: discoveryEnabled, }, ) @@ -2000,17 +2010,29 @@ export default function DictationScreen() { ], })) - const serverMenuRows = 2 + Math.max(servers.length, 1) + Math.max(discoveredServerOptions.length, 1) - const menuRows = effectiveDropdownMode === "server" ? serverMenuRows : Math.max(activeServer?.sessions.length ?? 0, 1) - const expandedRowsHeight = Math.min(menuRows, DROPDOWN_VISIBLE_ROWS) * 42 + const maxDropdownListHeight = DROPDOWN_VISIBLE_ROWS * DROPDOWN_ROW_HEIGHT + const serverMenuEntries = Math.max(servers.length, 1) + Math.max(discoveredServerOptions.length, 1) + const estimatedServerMenuRowsHeight = Math.min( + SERVER_MENU_SECTION_HEIGHT + serverMenuEntries * SERVER_MENU_ENTRY_HEIGHT, + maxDropdownListHeight, + ) + const sessionMenuRows = Math.max(activeServer?.sessions.length ?? 0, 1) + const estimatedSessionMenuRowsHeight = Math.min(sessionMenuRows, DROPDOWN_VISIBLE_ROWS) * DROPDOWN_ROW_HEIGHT + const serverMenuRowsHeight = Math.min(serverMenuListHeight || estimatedServerMenuRowsHeight, maxDropdownListHeight) + const sessionMenuRowsHeight = Math.min(sessionMenuListHeight || estimatedSessionMenuRowsHeight, maxDropdownListHeight) + const expandedRowsHeight = effectiveDropdownMode === "server" ? serverMenuRowsHeight : sessionMenuRowsHeight + + const estimatedSessionFooterHeight = sessionCreationChoiceCount === 2 ? 72 : sessionCreationChoiceCount === 1 ? 38 : 8 + + const measuredServerFooterHeight = serverMenuFooterHeight || SERVER_MENU_FOOTER_HEIGHT + const measuredSessionFooterHeight = sessionMenuFooterHeight || estimatedSessionFooterHeight + const dropdownFooterExtraHeight = effectiveDropdownMode === "server" - ? 38 - : sessionCreationChoiceCount === 2 - ? 72 - : sessionCreationChoiceCount === 1 - ? 38 - : 8 + ? measuredServerFooterHeight + : showSessionCreationChoices + ? measuredSessionFooterHeight + : 8 const expandedHeaderHeight = 51 + 12 + expandedRowsHeight + dropdownFooterExtraHeight const animatedHeaderStyle = useAnimatedStyle(() => ({ @@ -2104,6 +2126,26 @@ export default function DictationScreen() { setWaveformLevels(next) }, []) + const handleServerMenuListLayout = useCallback((event: LayoutChangeEvent) => { + const next = Math.ceil(event.nativeEvent.layout.height) + setServerMenuListHeight((prev) => (prev === next ? prev : next)) + }, []) + + const handleSessionMenuListLayout = useCallback((event: LayoutChangeEvent) => { + const next = Math.ceil(event.nativeEvent.layout.height) + setSessionMenuListHeight((prev) => (prev === next ? prev : next)) + }, []) + + const handleServerMenuFooterLayout = useCallback((event: LayoutChangeEvent) => { + const next = Math.ceil(event.nativeEvent.layout.height) + setServerMenuFooterHeight((prev) => (prev === next ? prev : next)) + }, []) + + const handleSessionMenuFooterLayout = useCallback((event: LayoutChangeEvent) => { + const next = Math.ceil(event.nativeEvent.layout.height) + setSessionMenuFooterHeight((prev) => (prev === next ? prev : next)) + }, []) + const toggleServerMenu = useCallback(() => { void Haptics.selectionAsync().catch(() => {}) setDropdownMode((prev) => { @@ -2764,7 +2806,7 @@ export default function DictationScreen() { bounces={false} > {effectiveDropdownMode === "server" ? ( - <> + Saved: {servers.length === 0 ? ( @@ -2833,9 +2875,9 @@ export default function DictationScreen() { {discoveryError} ) : null} - + ) : activeServer ? ( - <> + {activeSession ? ( <> @@ -2890,18 +2932,20 @@ export default function DictationScreen() { )) )} - + ) : ( Select a server first )} {effectiveDropdownMode === "server" ? ( - void handleStartScan()} style={styles.addServerButton}> - Add server by scanning QR code - + + void handleStartScan()} style={styles.addServerButton}> + Add server by scanning QR code + + ) : effectiveDropdownMode === "session" && activeServer?.status === "online" ? ( - + {activeSession ? ( () const preferred: string[] = [] const entries: Array<{ url: string; tier: number }> = [] @@ -72,12 +81,15 @@ function hosts(hostname: string, port: number, advertised: string[] = []) { advertised.forEach(addPreferred) - add(hostname) - Object.values(os.networkInterfaces()) - .flatMap((item) => item ?? []) - .filter((item) => item.family === "IPv4" && !item.internal) - .map((item) => item.address) - .forEach(add) + if (includeLocal) { + add(hostname) + Object.values(os.networkInterfaces()) + .flatMap((item) => item ?? []) + .filter((item) => item.family === "IPv4" && !item.internal) + .map((item) => item.address) + .forEach(add) + } + entries.sort((a, b) => a.tier - b.tier) return [...preferred, ...entries.map((item) => item.url)] } @@ -86,11 +98,39 @@ function pairLink(pair: unknown) { return `mobilevoice:///?pair=${encodeURIComponent(JSON.stringify(pair))}` } +function pairServerID(input: { relayURL: string; relaySecret: string }) { + return createHash("sha256").update(`${input.relayURL}|${input.relaySecret}`).digest("hex").slice(0, 16) +} + function secretHash(input: string) { if (!input) return "none" return `${createHash("sha256").update(input).digest("hex").slice(0, 12)}...` } +async function printPairQR(pair: PairPayload) { + const link = pairLink(pair) + const qrConfig = { + type: "terminal" as const, + small: true, + errorCorrectionLevel: "M" as const, + } + log.info("pair qr", { + relayURL: pair.relayURL, + relaySecretHash: secretHash(pair.relaySecret), + serverID: pair.serverID, + hosts: pair.hosts, + hostCount: pair.hosts.length, + hasLoopbackHost: pair.hosts.some((item) => item.includes("127.0.0.1") || item.includes("localhost")), + linkLength: link.length, + qr: qrConfig, + }) + const code = await QRCode.toString(link, { + ...qrConfig, + }) + console.log("scan qr code in mobile app or phone camera") + console.log(code) +} + export const ServeCommand = cmd({ command: "serve", builder: (yargs) => @@ -107,16 +147,15 @@ export const ServeCommand = cmd({ type: "string", array: true, describe: "preferred host/domain for mobile QR (repeatable, supports host[:port] or URL)", + }) + .option("connect-qr", { + type: "boolean", + default: false, + describe: "print mobile connect QR and exit without starting the server", }), describe: "starts a headless opencode server", handler: async (args) => { - if (!Flag.OPENCODE_SERVER_PASSWORD) { - console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.") - } const opts = await resolveNetworkOptions(args) - const server = Server.listen(opts) - console.log(`opencode server listening on http://${server.hostname}:${server.port}`) - const relayURL = ( args["relay-url"] ?? process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_URL ?? @@ -136,6 +175,40 @@ export const ServeCommand = cmd({ const input = (args["relay-secret"] ?? process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_SECRET ?? "").trim() const relaySecret = input || randomBytes(18).toString("base64url") + const connectQR = Boolean(args["connect-qr"]) + + if (connectQR) { + const pairHosts = hosts(opts.hostname, opts.port > 0 ? opts.port : 4096, advertiseHosts, false) + if (!pairHosts.length) { + console.log("connect qr mode requires at least one valid --advertise-host value") + return + } + + if (!input) { + console.log("experimental push relay secret generated") + console.log( + "set --relay-secret or OPENCODE_EXPERIMENTAL_PUSH_RELAY_SECRET to keep push registrations stable across server restarts", + ) + } + + console.log("printing connect qr without starting the server") + await printPairQR({ + v: 1, + serverID: pairServerID({ relayURL, relaySecret }), + relayURL, + relaySecret, + hosts: pairHosts, + }) + return + } + + if (!Flag.OPENCODE_SERVER_PASSWORD) { + console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.") + } + + const server = Server.listen(opts) + console.log(`opencode server listening on http://${server.hostname}:${server.port}`) + if (!input) { console.log("experimental push relay secret generated") console.log( @@ -155,6 +228,7 @@ export const ServeCommand = cmd({ const pair = started ?? PushRelay.pair() ?? { v: 1 as const, + serverID: pairServerID({ relayURL, relaySecret }), relayURL, relaySecret, hosts: hosts(host, port, advertiseHosts), @@ -164,27 +238,7 @@ export const ServeCommand = cmd({ } if (pair) { console.log("experimental push relay enabled") - const link = pairLink(pair) - const qrConfig = { - type: "terminal" as const, - small: true, - errorCorrectionLevel: "M" as const, - } - log.info("pair qr", { - relayURL: pair.relayURL, - relaySecretHash: secretHash(pair.relaySecret), - serverID: pair.serverID, - hosts: pair.hosts, - hostCount: pair.hosts.length, - hasLoopbackHost: pair.hosts.some((item) => item.includes("127.0.0.1") || item.includes("localhost")), - linkLength: link.length, - qr: qrConfig, - }) - const code = await QRCode.toString(link, { - ...qrConfig, - }) - console.log("scan qr code in mobile app or phone camera") - console.log(code) + await printPairQR(pair) } } diff --git a/packages/opencode/src/server/push-relay.ts b/packages/opencode/src/server/push-relay.ts index f0188d3de7..e8bc08c3f4 100644 --- a/packages/opencode/src/server/push-relay.ts +++ b/packages/opencode/src/server/push-relay.ts @@ -105,9 +105,10 @@ function advertiseURL(input: string, port: number): string | undefined { if (!raw) return try { - const parsed = new URL(raw.includes("://") ? raw : `http://${raw}`) + const hasScheme = raw.includes("://") + const parsed = new URL(hasScheme ? raw : `http://${raw}`) if (!parsed.hostname) return - if (!parsed.port) { + if (!parsed.port && !hasScheme) { parsed.port = String(port) } return norm(`${parsed.protocol}//${parsed.host}`) @@ -205,6 +206,22 @@ function fallback(input: Type) { return "OpenCode reported an error for your session." } +function titlePrefix(input: Type) { + if (input === "permission") return "Action Needed" + if (input === "error") return "Error" + return +} + +function titleForType(input: Type, title: string) { + const next = text(title) + if (!next) return next + const prefix = titlePrefix(input) + if (!prefix) return next + const tagged = `${prefix}:` + if (next.toLowerCase().startsWith(tagged.toLowerCase())) return next + return `${tagged} ${next}` +} + async function notify(input: { type: Type; sessionID: string }): Promise { const out: Notify = { type: input.type, @@ -252,6 +269,7 @@ async function notify(input: { type: Type; sessionID: string }): Promise } if (!out.title) out.title = `Session ${input.type}` + out.title = titleForType(input.type, out.title) if (!out.body) out.body = fallback(input.type) return out }