Compare commits

...

33 Commits

Author SHA1 Message Date
Ryan Vogel
aacf1d20d3 update app hanlding 2026-03-30 13:07:30 -04:00
Ryan Vogel
bcf7817127 update mobile dictation controls
Add mobile permission approval flow, simplify dictation settings into toggles, and remove oversized Whisper models while syncing the iOS project with the current runtime configuration.
2026-03-30 13:01:14 -04:00
Ryan Vogel
abf79ae24c refactor mobile screen orchestration
Extract server/session and monitoring workflows into focused hooks so DictationScreen no longer owns every network and notification path. Add a dedicated mobile typecheck config so TypeScript checks pass without breaking Expo export resolution.
2026-03-30 08:57:35 -04:00
Ryan Vogel
922633ea9d refactor mobile derived ui state
Rewrite a focused cluster of nested ternaries in the mobile screen into straight-line derived logic so the render state is easier to read without changing behavior.
2026-03-30 08:31:46 -04:00
Ryan Vogel
49b40e3c90 refactor mobile fire-and-forget calls
Mark intentional async work in the mobile screen with the void operator so lint can distinguish real promise bugs from deliberate fire-and-forget behavior.
2026-03-30 08:30:28 -04:00
Ryan Vogel
df3276fc87 refactor mobile web color hydration
Replace the hydration state effect with useSyncExternalStore so the web color-scheme hook keeps its static-render fallback without triggering the set-state-in-effect lint warning.
2026-03-30 08:28:25 -04:00
Ryan Vogel
f8f986536b refactor mobile session payload parsing
Move server session response parsing into a typed helper so the mobile screen no longer relies on inline any-based mapping in the refresh path.
2026-03-30 08:27:16 -04:00
Ryan Vogel
785635caef refactor mobile onboarding config
Replace the onboarding step ternary chain with a typed step config so the screen is easier to read and lint can highlight the remaining hotspots more clearly.
2026-03-30 08:18:51 -04:00
Ryan Vogel
ec27518eca update mobile voice quality guardrails
Document package-specific React Native best practices and add lint warnings so state, effect, and complexity issues surface earlier during mobile-voice work.
2026-03-30 08:15:29 -04:00
Ryan Vogel
8ee4ada38e update for onboarding 2026-03-30 07:45:21 -04:00
Ryan Vogel
ab7b1d78bf Update settings 2026-03-30 07:33:30 -04:00
Ryan Vogel
2f44d1900e feat: support deep-link QR pairing in mobile
Generate mobilevoice deep links in serve QR output and let mobile parse both raw payloads and pair query links, while keeping advertised-host ordering and removing QR name overrides.
2026-03-29 19:38:19 -04:00
Ryan Vogel
cb535eef9d feat: support advertised QR hosts for mobile pairing
Allow serve to publish preferred host/domain entries in QR payloads and make mobile choose the first reachable host by QR order so preferred addresses like .ts.net are selected consistently.
2026-03-29 18:32:21 -04:00
Ryan Vogel
d3ec6f75f4 feat: route push notifications by server and session
Include serverID in relay event payloads and prefer server+session matching in mobile notification handling so taps reliably open the correct context and stale state is refreshed.
2026-03-29 17:52:07 -04:00
Ryan Vogel
9a8b2ae0b1 update apn server 2026-03-29 16:26:16 -04:00
Ryan Vogel
eadb0e25da update to the apn and server management 2026-03-29 16:17:57 -04:00
Ryan Vogel
ddd30ef304 update 2026-03-28 21:38:21 -04:00
Ryan Vogel
2abf1100ee update for whisper 2026-03-28 21:12:24 -04:00
Ryan Vogel
bd2e34f3bd update 2026-03-28 19:03:13 -04:00
Ryan Vogel
a45c3a0049 feat: harden mobile server flow and enrich push alerts
Persist scanned servers across reloads, smooth server/session UI states, and make recording feel immediate. Add session-aware push notification title/body metadata from the OpenCode server.
2026-03-28 18:10:35 -04:00
Ryan Vogel
52d1ee70a0 feat: use new mobile app icon and QR-only server add flow
Replace Expo icon/adaptive icon assets with the provided image and simplify the server dropdown so adding a server is done by scanning the setup QR code only.
2026-03-28 17:30:13 -04:00
Ryan Vogel
0a9fcab56f chore: update dependencies and enhance mobile-voice functionality
- Updated package dependencies in bun.lock and package.json for mobile-voice and opencode.
- Added expo-camera and improved camera permission handling in mobile-voice.
- Introduced QR code generation for relay setup in opencode serve command.
- Enhanced server management and logging in DictationScreen component.
2026-03-28 17:05:35 -04:00
Ryan Vogel
62fae6d182 fix: auto-recover APNs env mismatch in relay
Retry sends on BadEnvironmentKeyInToken with the opposite APNs environment, persist the corrected env, and add request/send logs for register/unregister/event delivery debugging.
2026-03-28 16:58:36 -04:00
Ryan Vogel
3a5be7ad33 update index.ts 2026-03-28 14:31:16 -04:00
Ryan Vogel
f1e88d35ba update for the db.ts 2026-03-28 14:28:44 -04:00
Ryan Vogel
b737e87d9a update env again 2026-03-28 14:16:57 -04:00
Ryan Vogel
bd6e81f30b update for env checks 2026-03-28 14:11:02 -04:00
Ryan Vogel
f080147363 update for app and bun 2026-03-28 14:03:57 -04:00
Ryan Vogel
0051b605ae feat: improve mobile model download UX and relay defaults
Add in-button model download progress plus a model reset control in mobile-voice, and switch APN relay defaults to apn.dev.opencode.ai in serve and docs.
2026-03-28 14:03:57 -04:00
Ryan Vogel
56e0e5ce65 Update packages json for the porter stuff 2026-03-28 14:03:57 -04:00
porter-deployment-app[bot]
d065d5a8ec Add Porter workflow files for APN relay project (#19547)
Co-authored-by: porter-deployment-app[bot] <87230664+porter-deployment-app[bot]@users.noreply.github.com>
2026-03-28 13:59:37 -04:00
Ryan Vogel
cf79208055 mobile-voice commit 2026-03-28 13:30:21 -04:00
Ryan Vogel
f276a8db42 feat: add APN relay MVP and experimental push bridge 2026-03-28 13:28:24 -04:00
102 changed files with 21061 additions and 142 deletions

View File

@@ -0,0 +1,27 @@
"on":
push:
branches:
- opencode-remote-voice
name: Deploy to apn-relay
jobs:
porter-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set Github tag
id: vars
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
- name: Setup porter
uses: porter-dev/setup-porter@v0.1.0
- name: Deploy stack
timeout-minutes: 30
run: porter apply
env:
PORTER_APP_NAME: apn-relay
PORTER_CLUSTER: "5534"
PORTER_DEPLOYMENT_TARGET_ID: d60e67f5-b0a6-4275-8ed6-3cebaf092147
PORTER_HOST: https://dashboard.porter.run
PORTER_PROJECT: "18525"
PORTER_TAG: ${{ steps.vars.outputs.sha_short }}
PORTER_TOKEN: ${{ secrets.PORTER_APP_18525_975734319 }}

252
AGENTS.md
View File

@@ -1,128 +1,162 @@
- To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
- The default branch in this repo is `dev`.
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
- Prefer automation: execute requested actions without confirmation unless blocked by missing info or safety/irreversibility.
# OpenCode Monorepo Agent Guide
## Style Guide
This file is for coding agents working in `/Users/ryanvogel/dev/opencode`.
### General Principles
## Scope And Precedence
- Keep things in one function unless composable or reusable
- Avoid `try`/`catch` where possible
- Avoid using the `any` type
- Prefer single word variable names where possible
- Use Bun APIs when possible, like `Bun.file()`
- Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity
- Prefer functional array methods (flatMap, filter, map) over for loops; use type guards on filter to maintain type inference downstream
- Start with this file for repo-wide defaults.
- Then check package-local `AGENTS.md` files for stricter rules.
- Existing local guides include `packages/opencode/AGENTS.md` and `packages/app/AGENTS.md`.
- Package-specific guides override this file when they conflict.
## Repo Facts
- Package manager: `bun` (`bun@1.3.11`).
- Monorepo tool: `turbo`.
- Default branch: `dev`.
- Root test script intentionally fails; do not run tests from root.
## Cursor / Copilot Rules
- No `.cursor/rules/` directory found.
- No `.cursorrules` file found.
- No `.github/copilot-instructions.md` file found.
- If these files are added later, treat them as mandatory project policy.
## High-Value Commands
Run commands from the correct package directory unless noted.
### Root
- Install deps: `bun install`
- Run all typechecks via turbo: `bun run typecheck`
- OpenCode dev CLI entry: `bun run dev`
- OpenCode serve (common): `bun run dev serve --hostname 0.0.0.0 --port 4096`
### `packages/opencode`
- Dev CLI: `bun run dev`
- Typecheck: `bun run typecheck`
- Tests (all): `bun test --timeout 30000`
- Tests (single file): `bun test test/path/to/file.test.ts --timeout 30000`
- Tests (single test name): `bun test test/path/to/file.test.ts -t "name fragment" --timeout 30000`
- Build: `bun run build`
- Drizzle helper: `bun run db`
### `packages/app`
- Dev server: `bun dev`
- Build: `bun run build`
- Typecheck: `bun run typecheck`
- Unit tests (all): `bun run test:unit`
- Unit tests (single file): `bun test --preload ./happydom.ts ./src/path/to/file.test.ts`
- Unit tests (single test name): `bun test --preload ./happydom.ts ./src/path/to/file.test.ts -t "name fragment"`
- E2E tests: `bun run test:e2e`
### `packages/mobile-voice`
- Start Expo: `bun run start`
- Start Expo dev client: `bunx expo start --dev-client --clear --host lan`
- iOS native run: `bun run ios`
- Android native run: `bun run android`
- Lint: `bun run lint`
- Expo doctor: `bunx expo-doctor`
- Dependency compatibility check: `bunx expo install --check`
### `packages/apn-relay`
- Start relay: `bun run dev`
- Typecheck: `bun run typecheck`
- DB connectivity check: `bun run db:check`
## Build / Lint / Test Expectations
- Always run the narrowest checks that prove your change.
- For backend changes: run package typecheck + relevant tests.
- For mobile changes: run `expo lint` and at least one `expo` compile-style command if possible.
- Never claim tests passed unless you ran them in this workspace.
## Single-Test Guidance
- Prefer running one file first, then broaden scope.
- For Bun tests, pass the file path directly.
- For name filtering, use `-t "..."`.
- Keep original timeouts when scripts define them.
## Code Style Guidelines
These conventions are already used heavily in this repo and should be preserved.
### Formatting
- Use Prettier defaults configured in root: `semi: false`, `printWidth: 120`.
- Keep imports grouped and stable; avoid noisy reorder-only edits.
- Avoid unrelated formatting churn in touched files.
### Imports
- Prefer explicit imports over dynamic imports unless runtime gating is required.
- Prefer existing alias patterns (for example `@/...`) where already configured.
- Do not introduce new dependency layers when a local util already exists.
### Types
- Avoid `any`.
- Prefer inference for local variables.
- Add explicit annotations for exported APIs and complex boundaries.
- Prefer `zod` schemas for request/response validation and parsing.
### Naming
Prefer single word names for variables and functions. Only use multiple words if necessary.
### Naming Enforcement (Read This)
THIS RULE IS MANDATORY FOR AGENT WRITTEN CODE.
- Use single word names by default for new locals, params, and helper functions.
- Multi-word names are allowed only when a single word would be unclear or ambiguous.
- Do not introduce new camelCase compounds when a short single-word alternative is clear.
- Before finishing edits, review touched lines and shorten newly introduced identifiers where possible.
- Good short names to prefer: `pid`, `cfg`, `err`, `opts`, `dir`, `root`, `child`, `state`, `timeout`.
- Examples to avoid unless truly required: `inputPID`, `existingClient`, `connectTimeout`, `workerPath`.
```ts
// Good
const foo = 1
function journal(dir: string) {}
// Bad
const fooBar = 1
function prepareJournal(dir: string) {}
```
Reduce total variable count by inlining when a value is only used once.
```ts
// Good
const journal = await Bun.file(path.join(dir, "journal.json")).json()
// Bad
const journalPath = path.join(dir, "journal.json")
const journal = await Bun.file(journalPath).json()
```
### Destructuring
Avoid unnecessary destructuring. Use dot notation to preserve context.
```ts
// Good
obj.a
obj.b
// Bad
const { a, b } = obj
```
### Variables
Prefer `const` over `let`. Use ternaries or early returns instead of reassignment.
```ts
// Good
const foo = condition ? 1 : 2
// Bad
let foo
if (condition) foo = 1
else foo = 2
```
- Follow existing repo preference for short, clear names.
- Use single-word names when readable; use multi-word only for clarity.
- Keep naming consistent with nearby code.
### Control Flow
Avoid `else` statements. Prefer early returns.
- Prefer early returns over nested `else` blocks.
- Keep functions focused; split only when it improves reuse or readability.
```ts
// Good
function foo() {
if (condition) return 1
return 2
}
### Error Handling
// Bad
function foo() {
if (condition) return 1
else return 2
}
```
- Fail with actionable messages.
- Avoid swallowing errors silently.
- Log enough context to debug production issues (IDs, env, status), but never secrets.
- In UI code, degrade gracefully for missing capabilities.
### Schema Definitions (Drizzle)
### Data / DB
Use snake_case for field names so column names don't need to be redefined as strings.
- For Drizzle schema, use snake_case fields and columns.
- Keep migration and schema changes minimal and explicit.
- Follow package-specific DB guidance in `packages/opencode/AGENTS.md`.
```ts
// Good
const table = sqliteTable("session", {
id: text().primaryKey(),
project_id: text().notNull(),
created_at: integer().notNull(),
})
### Testing Philosophy
// Bad
const table = sqliteTable("session", {
id: text("id").primaryKey(),
projectID: text("project_id").notNull(),
createdAt: integer("created_at").notNull(),
})
```
- Prefer testing real behavior over mocks.
- Add regression tests for bug fixes where practical.
- Keep fixtures small and focused.
## Testing
## Agent Workflow Tips
- Avoid mocks as much as possible
- Test actual implementation, do not duplicate logic into tests
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
- Read existing code paths before introducing new abstractions.
- Match local patterns first; do not impose a new style per file.
- If a package has its own `AGENTS.md`, review it before editing.
- For OpenCode Effect services, follow `packages/opencode/AGENTS.md` strictly.
## Type Checking
## Known Operational Notes
- Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly.
- `packages/app/AGENTS.md` says: never restart app/server processes during that package's debugging workflow.
- `packages/app/AGENTS.md` also documents local backend+web split for UI work.
- `packages/opencode/AGENTS.md` contains mandatory Effect and database conventions.
## Regeneration / Special Scripts
- Regenerate JS SDK with: `./packages/sdk/js/script/build.ts`
## Quick Checklist Before Finishing
- Ran relevant package checks.
- Updated docs/config when behavior changed.
- Avoided committing unrelated files.
- Kept edits minimal and aligned with local conventions.

1599
bun.lock

File diff suppressed because it is too large Load Diff

0
eas.json Normal file
View File

View File

@@ -0,0 +1,11 @@
PORT=8787
DATABASE_HOST=
DATABASE_USERNAME=
DATABASE_PASSWORD=
DATABASE_NAME=main
APNS_TEAM_ID=
APNS_KEY_ID=
APNS_PRIVATE_KEY=
APNS_DEFAULT_BUNDLE_ID=com.anomalyco.mobilevoice

View File

@@ -0,0 +1,106 @@
# apn-relay Agent Guide
This file defines package-specific guidance for agents working in `packages/apn-relay`.
## Scope And Precedence
- Follow root `AGENTS.md` first.
- This file provides stricter package-level conventions for relay service work.
- If future local guides are added, closest guide wins.
## Project Overview
- Minimal APNs relay service (Hono + Bun + PlanetScale via Drizzle).
- Core routes:
- `GET /health`
- `GET /`
- `POST /v1/device/register`
- `POST /v1/device/unregister`
- `POST /v1/event`
## Commands
Run all commands from `packages/apn-relay`.
- Install deps: `bun install`
- Start relay locally: `bun run dev`
- Typecheck: `bun run typecheck`
- DB connectivity check: `bun run db:check`
## Build / Test Expectations
- There is no dedicated package test script currently.
- Required validation for behavior changes:
- `bun run typecheck`
- `bun run db:check` when DB/env changes are involved
- manual endpoint verification against `/health`, `/v1/device/register`, `/v1/event`
## Single-Test Guidance
- No single-test command exists for this package today.
- For focused checks, run endpoint-level manual tests against a local dev server.
## Code Style Guidelines
### Formatting / Structure
- Keep handlers compact and explicit.
- Prefer small local helpers for repeated route logic.
- Avoid broad refactors when a targeted fix is enough.
### Types / Validation
- Validate request bodies with `zod` at route boundaries.
- Keep payload and DB row shapes explicit and close to usage.
- Avoid `any`; narrow unknown input immediately after parsing.
### Naming
- Follow existing concise naming in this package (`reg`, `unreg`, `evt`, `row`, `key`).
- For DB columns, keep snake_case alignment with schema.
### Error Handling
- Return clear JSON errors for invalid input.
- Keep handler failures observable via `app.onError` and structured logs.
- Do not leak secrets in responses or logs.
### Logging
- Log delivery lifecycle at key checkpoints:
- registration/unregistration attempts
- event fanout start/end
- APNs send failures and retries
- Mask sensitive values; prefer token suffixes and metadata.
### APNs Environment Rules
- Keep APNs env explicit per registration (`sandbox` / `production`).
- For `BadEnvironmentKeyInToken`, retry once with flipped env and persist correction.
- Avoid infinite retry loops; one retry max per delivery attempt.
## Database Conventions
- Schema is in `src/schema.sql.ts`.
- Keep table/column names snake_case.
- Maintain index naming consistency with existing schema.
- For upserts, update only fields required by current behavior.
## API Behavior Expectations
- `register`/`unregister` must be idempotent.
- `event` should return success envelope even when no devices are registered.
- Delivery logs should capture per-attempt result and error payload.
## Operational Notes
- Ensure `APNS_PRIVATE_KEY` supports escaped newline format (`\n`) and raw multiline.
- Validate that `APNS_DEFAULT_BUNDLE_ID` matches mobile app bundle identifier.
- Avoid coupling route behavior to deployment platform specifics.
## Before Finishing
- Run `bun run typecheck`.
- If DB/env behavior changed, run `bun run db:check`.
- Manually exercise affected endpoints.
- Confirm logs are useful and secret-safe.

View File

@@ -0,0 +1,14 @@
FROM oven/bun:1.3.11-alpine
WORKDIR /app
COPY package.json ./
COPY tsconfig.json ./
COPY drizzle.config.ts ./
RUN bun install --production
COPY src ./src
EXPOSE 8787
CMD ["bun", "run", "src/index.ts"]

View File

@@ -0,0 +1,46 @@
# APN Relay
Minimal APNs relay for OpenCode mobile background notifications.
## What it does
- Registers iOS device tokens for a shared secret.
- Receives OpenCode event posts (`complete`, `permission`, `error`).
- Sends APNs notifications to mapped devices.
- Stores delivery rows in PlanetScale.
## Routes
- `GET /health`
- `GET /` (simple dashboard)
- `POST /v1/device/register`
- `POST /v1/device/unregister`
- `POST /v1/event`
## Environment
Use `.env.example` as a starting point.
- `DATABASE_HOST`
- `DATABASE_USERNAME`
- `DATABASE_PASSWORD`
- `APNS_TEAM_ID`
- `APNS_KEY_ID`
- `APNS_PRIVATE_KEY`
- `APNS_DEFAULT_BUNDLE_ID`
## Run locally
```bash
bun install
bun run src/index.ts
```
## Docker
Build from this directory:
```bash
docker build -t apn-relay .
docker run --rm -p 8787:8787 --env-file .env apn-relay
```

View File

@@ -0,0 +1,17 @@
import { defineConfig } from "drizzle-kit"
export default defineConfig({
out: "./migration",
strict: true,
schema: ["./src/**/*.sql.ts"],
dialect: "mysql",
dbCredentials: {
host: process.env.DATABASE_HOST ?? "",
user: process.env.DATABASE_USERNAME ?? "",
password: process.env.DATABASE_PASSWORD ?? "",
database: process.env.DATABASE_NAME ?? "main",
ssl: {
rejectUnauthorized: false,
},
},
})

View File

@@ -0,0 +1,27 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/apn-relay",
"version": "0.0.0",
"private": true,
"type": "module",
"license": "MIT",
"scripts": {
"dev": "bun run src/index.ts",
"db:check": "bun run --env-file .env src/check.ts",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@planetscale/database": "1.19.0",
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
"hono": "4.10.7",
"jose": "6.0.11",
"zod": "4.1.8"
},
"devDependencies": {
"@tsconfig/bun": "1.0.9",
"@types/bun": "1.3.11",
"@typescript/native-preview": "7.0.0-dev.20251207.1",
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
"typescript": "5.8.2"
}
}

View File

@@ -0,0 +1,185 @@
import { connect } from "node:http2"
import { SignJWT, importPKCS8 } from "jose"
import { env } from "./env"
export type PushEnv = "sandbox" | "production"
type PushInput = {
token: string
bundle: string
env: PushEnv
title: string
body: string
data: Record<string, unknown>
}
type PushResult = {
ok: boolean
code: number
error?: string
}
function tokenSuffix(input: string) {
return input.length > 8 ? input.slice(-8) : input
}
let jwt = ""
let exp = 0
let pk: Awaited<ReturnType<typeof importPKCS8>> | undefined
function host(input: PushEnv) {
if (input === "sandbox") return "api.sandbox.push.apple.com"
return "api.push.apple.com"
}
function key() {
if (env.APNS_PRIVATE_KEY.includes("\\n")) return env.APNS_PRIVATE_KEY.replace(/\\n/g, "\n")
return env.APNS_PRIVATE_KEY
}
async function sign() {
if (!pk) pk = await importPKCS8(key(), "ES256")
const now = Math.floor(Date.now() / 1000)
if (jwt && now < exp) return jwt
jwt = await new SignJWT({})
.setProtectedHeader({ alg: "ES256", kid: env.APNS_KEY_ID })
.setIssuer(env.APNS_TEAM_ID)
.setIssuedAt(now)
.sign(pk)
exp = now + 50 * 60
return jwt
}
function post(input: {
host: string
token: string
auth: string
bundle: string
payload: string
}): Promise<{ code: number; body: string }> {
return new Promise((resolve, reject) => {
const cli = connect(`https://${input.host}`)
let done = false
let code = 0
let body = ""
const stop = (fn: () => void) => {
if (done) return
done = true
fn()
}
cli.on("error", (err) => {
stop(() => reject(err))
cli.close()
})
const req = cli.request({
":method": "POST",
":path": `/3/device/${input.token}`,
authorization: `bearer ${input.auth}`,
"apns-topic": input.bundle,
"apns-push-type": "alert",
"apns-priority": "10",
"content-type": "application/json",
})
req.setEncoding("utf8")
req.on("response", (headers) => {
code = Number(headers[":status"] ?? 0)
})
req.on("data", (chunk) => {
body += chunk
})
req.on("end", () => {
stop(() => resolve({ code, body }))
cli.close()
})
req.on("error", (err) => {
stop(() => reject(err))
cli.close()
})
req.end(input.payload)
})
}
export async function send(input: PushInput): Promise<PushResult> {
const apnsHost = host(input.env)
const suffix = tokenSuffix(input.token)
console.log("[ APN RELAY ] push:start", {
env: input.env,
host: apnsHost,
bundle: input.bundle,
tokenSuffix: suffix,
})
const auth = await sign().catch((err) => {
return `error:${String(err)}`
})
if (auth.startsWith("error:")) {
console.log("[ APN RELAY ] push:auth-failed", {
env: input.env,
host: apnsHost,
bundle: input.bundle,
tokenSuffix: suffix,
error: auth,
})
return {
ok: false,
code: 0,
error: auth,
}
}
const payload = JSON.stringify({
aps: {
alert: {
title: input.title,
body: input.body,
},
sound: "alert.wav",
},
...input.data,
})
const out = await post({
host: apnsHost,
token: input.token,
auth,
bundle: input.bundle,
payload,
}).catch((err) => ({
code: 0,
body: String(err),
}))
if (out.code === 200) {
console.log("[ APN RELAY ] push:sent", {
env: input.env,
host: apnsHost,
bundle: input.bundle,
tokenSuffix: suffix,
code: out.code,
})
return {
ok: true,
code: 200,
}
}
console.log("[ APN RELAY ] push:failed", {
env: input.env,
host: apnsHost,
bundle: input.bundle,
tokenSuffix: suffix,
code: out.code,
error: out.body,
})
return {
ok: false,
code: out.code,
error: out.body,
}
}

View File

@@ -0,0 +1,28 @@
import { sql } from "drizzle-orm"
import { db } from "./db"
import { env } from "./env"
import { delivery_log, device_registration } from "./schema.sql"
import { setup } from "./setup"
async function run() {
console.log(`[apn-relay] DB host: ${env.DATABASE_HOST}`)
await db.execute(sql`SELECT 1`)
console.log("[apn-relay] DB connection OK")
await setup()
console.log("[apn-relay] Setup migration OK")
const [a] = await db.select({ value: sql<number>`count(*)` }).from(device_registration)
const [b] = await db.select({ value: sql<number>`count(*)` }).from(delivery_log)
console.log(`[apn-relay] device_registration rows: ${Number(a?.value ?? 0)}`)
console.log(`[apn-relay] delivery_log rows: ${Number(b?.value ?? 0)}`)
console.log("[apn-relay] DB check passed")
}
run().catch((err) => {
console.error("[apn-relay] DB check failed")
console.error(err)
process.exit(1)
})

View File

@@ -0,0 +1,11 @@
import { Client } from "@planetscale/database"
import { drizzle } from "drizzle-orm/planetscale-serverless"
import { env } from "./env"
const client = new Client({
host: env.DATABASE_HOST,
username: env.DATABASE_USERNAME,
password: env.DATABASE_PASSWORD,
})
export const db = drizzle({ client })

View File

@@ -0,0 +1,47 @@
import { z } from "zod"
const bad = new Set(["undefined", "null"])
const txt = z
.string()
.transform((input) => input.trim())
.refine((input) => input.length > 0 && !bad.has(input.toLowerCase()))
const schema = z.object({
PORT: z.coerce.number().int().positive().default(8787),
DATABASE_HOST: txt,
DATABASE_USERNAME: txt,
DATABASE_PASSWORD: txt,
APNS_TEAM_ID: txt,
APNS_KEY_ID: txt,
APNS_PRIVATE_KEY: txt,
APNS_DEFAULT_BUNDLE_ID: txt,
})
const req = [
"DATABASE_HOST",
"DATABASE_USERNAME",
"DATABASE_PASSWORD",
"APNS_TEAM_ID",
"APNS_KEY_ID",
"APNS_PRIVATE_KEY",
"APNS_DEFAULT_BUNDLE_ID",
] as const
const out = schema.safeParse(process.env)
if (!out.success) {
const miss = req.filter((key) => !process.env[key]?.trim())
const bad = out.error.issues
.map((item) => item.path[0])
.filter((key): key is string => typeof key === "string")
.filter((key) => !miss.includes(key as (typeof req)[number]))
console.error("[apn-relay] Invalid startup configuration")
if (miss.length) console.error(`[apn-relay] Missing required env vars: ${miss.join(", ")}`)
if (bad.length) console.error(`[apn-relay] Invalid env vars: ${Array.from(new Set(bad)).join(", ")}`)
console.error("[apn-relay] Check .env.example and restart")
throw new Error("Startup configuration invalid")
}
export const env = out.data

View File

@@ -0,0 +1,5 @@
import { createHash } from "node:crypto"
export function hash(input: string) {
return createHash("sha256").update(input).digest("hex")
}

View File

@@ -0,0 +1,448 @@
import { randomUUID } from "node:crypto"
import { and, desc, eq, sql } from "drizzle-orm"
import { Hono } from "hono"
import { z } from "zod"
import { send } from "./apns"
import { db } from "./db"
import { env } from "./env"
import { hash } from "./hash"
import { delivery_log, device_registration } from "./schema.sql"
import { setup } from "./setup"
function bad(input?: string) {
if (!input) return false
return input.includes("BadEnvironmentKeyInToken")
}
function flip(input: "sandbox" | "production") {
if (input === "sandbox") return "production"
return "sandbox"
}
function tail(input: string) {
return input.slice(-8)
}
function esc(input: unknown) {
return String(input ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;")
}
function fmt(input: number) {
return new Date(input).toISOString()
}
const reg = z.object({
secret: z.string().min(1),
deviceToken: z.string().min(1),
bundleId: z.string().min(1).optional(),
apnsEnv: z.enum(["sandbox", "production"]).default("production"),
})
const unreg = z.object({
secret: z.string().min(1),
deviceToken: z.string().min(1),
})
const evt = z.object({
secret: z.string().min(1),
serverID: z.string().min(1).optional(),
eventType: z.enum(["complete", "permission", "error"]),
sessionID: z.string().min(1),
title: z.string().min(1).optional(),
body: z.string().min(1).optional(),
})
function title(input: z.infer<typeof evt>["eventType"]) {
if (input === "complete") return "Session complete"
if (input === "permission") return "Action needed"
return "Session error"
}
function body(input: z.infer<typeof evt>["eventType"]) {
if (input === "complete") return "OpenCode finished your session."
if (input === "permission") return "OpenCode needs your permission decision."
return "OpenCode reported an error for your session."
}
const app = new Hono()
app.onError((err, c) => {
return c.json(
{
ok: false,
error: err.message,
},
500,
)
})
app.notFound((c) => {
return c.json(
{
ok: false,
error: "Not found",
},
404,
)
})
app.get("/health", async (c) => {
const [a] = await db.select({ value: sql<number>`count(*)` }).from(device_registration)
const [b] = await db.select({ value: sql<number>`count(*)` }).from(delivery_log)
return c.json({
ok: true,
devices: Number(a?.value ?? 0),
deliveries: Number(b?.value ?? 0),
})
})
app.get("/", async (c) => {
const [a] = await db.select({ value: sql<number>`count(*)` }).from(device_registration)
const [b] = await db.select({ value: sql<number>`count(*)` }).from(delivery_log)
const devices = await db.select().from(device_registration).orderBy(desc(device_registration.updated_at)).limit(100)
const byBundle = await db
.select({
bundle: device_registration.bundle_id,
env: device_registration.apns_env,
value: sql<number>`count(*)`,
})
.from(device_registration)
.groupBy(device_registration.bundle_id, device_registration.apns_env)
.orderBy(desc(sql<number>`count(*)`))
const rows = await db.select().from(delivery_log).orderBy(desc(delivery_log.created_at)).limit(20)
const html = `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>APN Relay</title>
<style>
body { font-family: ui-sans-serif, system-ui, sans-serif; margin: 24px; color: #111827; }
h1 { margin: 0 0 12px 0; }
h2 { margin: 22px 0 10px 0; font-size: 16px; }
.stats { display: flex; gap: 16px; margin: 0 0 18px 0; }
.card { border: 1px solid #e5e7eb; border-radius: 8px; padding: 10px 12px; min-width: 160px; }
.muted { color: #6b7280; font-size: 12px; }
.small { font-size: 11px; color: #6b7280; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #e5e7eb; text-align: left; padding: 8px; font-size: 12px; }
th { background: #f9fafb; }
</style>
</head>
<body>
<h1>APN Relay</h1>
<p class="muted">MVP dashboard</p>
<div class="stats">
<div class="card">
<div class="muted">Registered devices</div>
<div>${Number(a?.value ?? 0)}</div>
</div>
<div class="card">
<div class="muted">Delivery log rows</div>
<div>${Number(b?.value ?? 0)}</div>
</div>
</div>
<h2>Registered devices</h2>
<p class="small">Most recent 100 registrations. Token values are masked to suffix only.</p>
<table>
<thead>
<tr>
<th>updated</th>
<th>created</th>
<th>token suffix</th>
<th>env</th>
<th>bundle</th>
<th>secret hash</th>
</tr>
</thead>
<tbody>
${
devices.length
? devices
.map(
(row) => `<tr>
<td>${esc(fmt(row.updated_at))}</td>
<td>${esc(fmt(row.created_at))}</td>
<td>${esc(tail(row.device_token))}</td>
<td>${esc(row.apns_env)}</td>
<td>${esc(row.bundle_id)}</td>
<td>${esc(`${row.secret_hash.slice(0, 12)}`)}</td>
</tr>`,
)
.join("")
: `<tr><td colspan="6" class="muted">No devices registered.</td></tr>`
}
</tbody>
</table>
<h2>Bundle breakdown</h2>
<table>
<thead>
<tr>
<th>bundle</th>
<th>env</th>
<th>count</th>
</tr>
</thead>
<tbody>
${
byBundle.length
? byBundle
.map(
(row) => `<tr>
<td>${esc(row.bundle)}</td>
<td>${esc(row.env)}</td>
<td>${esc(Number(row.value ?? 0))}</td>
</tr>`,
)
.join("")
: `<tr><td colspan="3" class="muted">No device data.</td></tr>`
}
</tbody>
</table>
<h2>Recent deliveries</h2>
<table>
<thead>
<tr>
<th>time</th>
<th>event</th>
<th>session</th>
<th>status</th>
<th>error</th>
</tr>
</thead>
<tbody>
${rows
.map(
(row) => `<tr>
<td>${esc(fmt(row.created_at))}</td>
<td>${esc(row.event_type)}</td>
<td>${esc(row.session_id)}</td>
<td>${esc(row.status)}</td>
<td>${esc(row.error ?? "")}</td>
</tr>`,
)
.join("")}
</tbody>
</table>
</body>
</html>`
return c.html(html)
})
app.post("/v1/device/register", async (c) => {
const raw = await c.req.json().catch(() => undefined)
const check = reg.safeParse(raw)
if (!check.success) {
return c.json(
{
ok: false,
error: "Invalid request body",
},
400,
)
}
const now = Date.now()
const key = hash(check.data.secret)
const row = {
id: randomUUID(),
secret_hash: key,
device_token: check.data.deviceToken,
bundle_id: check.data.bundleId ?? env.APNS_DEFAULT_BUNDLE_ID,
apns_env: check.data.apnsEnv,
created_at: now,
updated_at: now,
}
console.log("[relay] register", {
token: tail(row.device_token),
env: row.apns_env,
bundle: row.bundle_id,
secretHash: `${key.slice(0, 12)}...`,
})
await db
.insert(device_registration)
.values(row)
.onDuplicateKeyUpdate({
set: {
bundle_id: row.bundle_id,
apns_env: row.apns_env,
updated_at: now,
},
})
return c.json({ ok: true })
})
app.post("/v1/device/unregister", async (c) => {
const raw = await c.req.json().catch(() => undefined)
const check = unreg.safeParse(raw)
if (!check.success) {
return c.json(
{
ok: false,
error: "Invalid request body",
},
400,
)
}
const key = hash(check.data.secret)
console.log("[relay] unregister", {
token: tail(check.data.deviceToken),
secretHash: `${key.slice(0, 12)}...`,
})
await db
.delete(device_registration)
.where(and(eq(device_registration.secret_hash, key), eq(device_registration.device_token, check.data.deviceToken)))
return c.json({ ok: true })
})
app.post("/v1/event", async (c) => {
const raw = await c.req.json().catch(() => undefined)
const check = evt.safeParse(raw)
if (!check.success) {
return c.json(
{
ok: false,
error: "Invalid request body",
},
400,
)
}
const key = hash(check.data.secret)
const list = await db.select().from(device_registration).where(eq(device_registration.secret_hash, key))
console.log("[relay] event", {
type: check.data.eventType,
serverID: check.data.serverID,
session: check.data.sessionID,
secretHash: `${key.slice(0, 12)}...`,
devices: list.length,
})
if (!list.length) {
const [total] = await db.select({ value: sql<number>`count(*)` }).from(device_registration)
console.log("[relay] event:no-matching-devices", {
type: check.data.eventType,
serverID: check.data.serverID,
session: check.data.sessionID,
secretHash: `${key.slice(0, 12)}...`,
totalDevices: Number(total?.value ?? 0),
})
return c.json({
ok: true,
sent: 0,
failed: 0,
})
}
const out = await Promise.all(
list.map(async (row) => {
const env = row.apns_env === "sandbox" ? "sandbox" : "production"
const payload = {
token: row.device_token,
bundle: row.bundle_id,
title: check.data.title ?? title(check.data.eventType),
body: check.data.body ?? body(check.data.eventType),
data: {
serverID: check.data.serverID,
eventType: check.data.eventType,
sessionID: check.data.sessionID,
},
}
const first = await send({ ...payload, env })
if (first.ok || !bad(first.error)) {
if (!first.ok) {
console.log("[relay] send:error", {
token: tail(row.device_token),
env,
error: first.error,
})
}
return first
}
const alt = flip(env)
console.log("[relay] send:retry-env", {
token: tail(row.device_token),
from: env,
to: alt,
})
const second = await send({ ...payload, env: alt })
if (!second.ok) {
console.log("[relay] send:error", {
token: tail(row.device_token),
env: alt,
error: second.error,
})
return second
}
await db
.update(device_registration)
.set({ apns_env: alt, updated_at: Date.now() })
.where(
and(
eq(device_registration.secret_hash, row.secret_hash),
eq(device_registration.device_token, row.device_token),
),
)
console.log("[relay] send:env-updated", {
token: tail(row.device_token),
env: alt,
})
return second
}),
)
const now = Date.now()
await db.insert(delivery_log).values(
out.map((item) => ({
id: randomUUID(),
secret_hash: key,
event_type: check.data.eventType,
session_id: check.data.sessionID,
status: item.ok ? "sent" : "failed",
error: item.error,
created_at: now,
})),
)
const sent = out.filter((item) => item.ok).length
console.log("[relay] event:done", {
type: check.data.eventType,
session: check.data.sessionID,
sent,
failed: out.length - sent,
})
return c.json({
ok: true,
sent,
failed: out.length - sent,
})
})
await setup()
if (import.meta.main) {
Bun.serve({
port: env.PORT,
fetch: app.fetch,
})
console.log(`apn-relay listening on http://0.0.0.0:${env.PORT}`)
}
export { app }

View File

@@ -0,0 +1,35 @@
import { bigint, index, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
export const device_registration = mysqlTable(
"device_registration",
{
id: varchar("id", { length: 36 }).primaryKey(),
secret_hash: varchar("secret_hash", { length: 64 }).notNull(),
device_token: varchar("device_token", { length: 255 }).notNull(),
bundle_id: varchar("bundle_id", { length: 255 }).notNull(),
apns_env: varchar("apns_env", { length: 16 }).notNull().default("production"),
created_at: bigint("created_at", { mode: "number" }).notNull(),
updated_at: bigint("updated_at", { mode: "number" }).notNull(),
},
(table) => [
uniqueIndex("device_registration_secret_token_idx").on(table.secret_hash, table.device_token),
index("device_registration_secret_hash_idx").on(table.secret_hash),
],
)
export const delivery_log = mysqlTable(
"delivery_log",
{
id: varchar("id", { length: 36 }).primaryKey(),
secret_hash: varchar("secret_hash", { length: 64 }).notNull(),
event_type: varchar("event_type", { length: 32 }).notNull(),
session_id: varchar("session_id", { length: 255 }).notNull(),
status: varchar("status", { length: 16 }).notNull(),
error: varchar("error", { length: 1024 }),
created_at: bigint("created_at", { mode: "number" }).notNull(),
},
(table) => [
index("delivery_log_secret_hash_idx").on(table.secret_hash),
index("delivery_log_created_at_idx").on(table.created_at),
],
)

View File

@@ -0,0 +1,34 @@
import { sql } from "drizzle-orm"
import { db } from "./db"
export async function setup() {
await db.execute(sql`
CREATE TABLE IF NOT EXISTS device_registration (
id varchar(36) NOT NULL,
secret_hash varchar(64) NOT NULL,
device_token varchar(255) NOT NULL,
bundle_id varchar(255) NOT NULL,
apns_env varchar(16) NOT NULL DEFAULT 'production',
created_at bigint NOT NULL,
updated_at bigint NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY device_registration_secret_token_idx (secret_hash, device_token),
KEY device_registration_secret_hash_idx (secret_hash)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`)
await db.execute(sql`
CREATE TABLE IF NOT EXISTS delivery_log (
id varchar(36) NOT NULL,
secret_hash varchar(64) NOT NULL,
event_type varchar(32) NOT NULL,
session_id varchar(255) NOT NULL,
status varchar(16) NOT NULL,
error varchar(1024) NULL,
created_at bigint NOT NULL,
PRIMARY KEY (id),
KEY delivery_log_secret_hash_idx (secret_hash),
KEY delivery_log_created_at_idx (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`)
}

View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"noUncheckedIndexedAccess": false
}
}

View File

@@ -0,0 +1,7 @@
{
"plugins": {
"figma": {
"enabled": true
}
}
}

43
packages/mobile-voice/.gitignore vendored Normal file
View File

@@ -0,0 +1,43 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
app-example
# generated native folders
/ios
/android

View File

@@ -0,0 +1,137 @@
# mobile-voice Agent Guide
This file defines package-specific guidance for agents working in `packages/mobile-voice`.
## Scope And Precedence
- Follow root `AGENTS.md` first.
- This file overrides root guidance for this package when rules conflict.
- If additional local guides are added later, treat the closest guide as highest priority.
## Project Overview
- Expo + React Native app for voice dictation and OpenCode session monitoring.
- Uses native/device-heavy modules such as `whisper.rn`, `react-native-audio-api`, `expo-notifications`, and `expo-camera`.
- Development builds are required for native module changes.
## Commands
Run all commands from `packages/mobile-voice`.
- Install deps: `bun install`
- Start Metro: `bun run start`
- Start dev client server (recommended): `bunx expo start --dev-client --clear --host lan`
- iOS run: `bun run ios`
- Android run: `bun run android`
- Lint: `bun run lint`
- Typecheck: `bun run typecheck`
- Expo doctor: `bunx expo-doctor`
- Dependency compatibility check: `bunx expo install --check`
- Export bundle smoke test: `bunx expo export --platform ios --clear`
## Build / Verification Expectations
- For JS-only changes: run `bun run lint` and verify app behavior via dev client.
- For TS-heavy refactors: run `bun run typecheck` in addition to lint.
- For native dependency/config/plugin changes: rebuild dev client via EAS before validation.
- If notifications, camera, microphone, or audio-session behavior changes, verify on a physical iOS device.
- Do not claim a fix unless you validated in Metro logs and app runtime behavior.
## Single-Test Guidance
- This package currently has no dedicated unit test script.
- Use targeted validation commands instead:
- `bun run lint`
- `bun run typecheck`
- `bunx expo export --platform ios --clear`
- manual runtime test in dev client
## Architecture Priorities
- Keep screens focused on composition and orchestration. Once a screen owns multiple workflows, extract hooks/components before adding more local state.
- Prefer extracting pure helpers and config objects before introducing new stores or abstractions.
- Treat `src/app/index.tsx` as a composition root, not as the permanent home for onboarding, dictation, monitoring, pairing, persistence, and all UI details.
- Avoid mirrored `state + ref` pairs unless they are needed for imperative native APIs, race cancellation, or subscription callbacks.
## Code Style And Patterns
### Formatting / Structure
- Preserve existing style (`semi: false`, concise JSX, stable import grouping).
- Keep UI changes localized and behavior-preserving; avoid unrelated formatting churn.
- Prefer feature-adjacent hooks/components over growing a single screen file.
### React State / Effects
- Effects are for subscriptions, timers, persistence, network I/O, and native bridge setup/cleanup.
- Do not add `useEffect` just to derive render data from props or state. Derive during render instead.
- Prefer one source of truth. If a value can be computed from existing state, do not store it separately.
- Use `useMemo` only when computation is expensive or stable identity actually matters.
- Use `useCallback` only when stable function identity matters for dependencies, cleanup, or memoized children.
- When UI branches are driven by a small finite state, prefer config tables/objects over long nested ternaries.
### Types
- Avoid `any`; prefer local type aliases for component state and network payloads.
- Keep exported/shared boundaries typed explicitly.
- Parse persisted and network payloads as `unknown` first, then validate before use.
- Use discriminated unions for UI modes/status where practical.
### Naming
- Prefer short, readable names consistent with nearby code.
- Keep naming aligned with existing app state keys (`monitorStatus`, `activeSessionId`, etc.).
### Error Handling / Logging
- Fail gracefully in UI (alerts, disabled actions, fallback text).
- Avoid bare `catch {}` or `.catch(() => {})` for meaningful work. If failure is intentionally best-effort, leave a short comment or use a helper that makes that explicit.
- Log actionable diagnostics for runtime workflows such as server health checks, relay registration, and notification token lifecycle.
- Never log secrets or full APNs tokens.
- Keep hot-path logging behind `__DEV__` when possible.
### Network / Relay Integration
- Normalize and validate URLs before storing server configs.
- Use `AbortController` or request IDs for overlapping requests, streams, and polling.
- Keep relay registration idempotent.
- Guard duplicate scan/add flows to avoid repeated server entries.
### Notifications / APNs
- This package currently assumes APNs relay registration uses the `production` environment only. Do not add environment switching unless explicitly requested.
- On registration changes, ensure old token unregister flow remains intact.
- Treat permission failures as non-fatal and degrade to foreground monitoring when needed.
### Performance / RN
- Validate performance-sensitive changes in a dev client or release build, not only Metro dev mode.
- During recording and monitoring flows, keep JS-thread work light.
- Prefer Reanimated/native-thread-friendly animations for motion.
- For small menus a `ScrollView` is fine; if a list grows beyond a small bounded menu, move to `FlatList` or `FlashList`.
## Lint / Quality Bar
- Keep hooks lint warnings clean before finishing.
- Treat `any`, `no-console`, complexity, and max-lines warnings as refactor prompts, not noise to suppress.
- Do not disable React Hooks lint rules inline unless there is a documented native-interop reason.
- When introducing new persistence or network payloads, add or reuse a parser instead of scattering casts.
## Native-Module Safety
- If adding a native module, ensure it is in `package.json` with an SDK-compatible version.
- Rebuild the dev client after native module additions or changes.
- For optional native capability usage, prefer runtime fallback paths instead of hard crashes.
## Common Pitfalls
- Black screen + "No script URL provided" often means a stale dev client binary.
- `expo-doctor` duplicate module warnings may appear in Bun workspaces; prioritize runtime verification.
- `expo lint` may auto-generate `eslint.config.js`; do not commit accidental generated config unless requested.
## Before Finishing
- Run `bun run lint`.
- 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.

View File

@@ -0,0 +1,39 @@
# Mobile Voice
Expo app for voice dictation and OpenCode session monitoring.
## Current monitoring behavior
- Foreground: app reads OpenCode SSE (`GET /event`) and updates monitor status live.
- Background/terminated: app relies on APNs notifications sent by `apn-relay`.
- The app registers its native APNs device token with relay route `POST /v1/device/register`.
## App requirements
- Use a development build or production build (not Expo Go).
- `expo-notifications` plugin is enabled with `enableBackgroundRemoteNotifications: true`.
- Notification permission must be granted.
## Server entry fields in app
When adding a server, provide:
- OpenCode URL
- APN relay URL
- Relay shared secret
Default APN relay URL: `https://apn.dev.opencode.ai`
The app uses these values to:
- send prompts to OpenCode
- register/unregister APNs token with relay
- receive background push updates for monitored sessions
## Local dev
```bash
npx expo start
```
Use your machine LAN IP / reachable host values for OpenCode and relay when testing on a physical device.

View File

@@ -0,0 +1,92 @@
{
"expo": {
"name": "Control",
"slug": "control",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "mobilevoice",
"userInterfaceStyle": "automatic",
"ios": {
"icon": "./assets/images/icon.png",
"bundleIdentifier": "com.anomalyco.mobilevoice",
"entitlements": {
"com.apple.developer.kernel.extended-virtual-addressing": true
},
"infoPlist": {
"NSMicrophoneUsageDescription": "This app needs microphone access for live speech-to-text dictation.",
"NSAppTransportSecurity": {
"NSAllowsLocalNetworking": true,
"NSExceptionDomains": {
"ts.net": {
"NSIncludesSubdomains": true,
"NSExceptionAllowsInsecureHTTPLoads": true
}
}
},
"ITSAppUsesNonExemptEncryption": false
}
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
},
"permissions": [
"RECORD_AUDIO",
"POST_NOTIFICATIONS",
"android.permission.FOREGROUND_SERVICE",
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
"android.permission.RECORD_AUDIO",
"android.permission.MODIFY_AUDIO_SETTINGS"
],
"predictiveBackGestureEnabled": false
},
"web": {
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"plugins": [
"expo-router",
[
"expo-splash-screen",
{
"backgroundColor": "#121212",
"android": {
"image": "./assets/images/splash-icon.png",
"imageWidth": 76
}
}
],
"react-native-audio-api",
"expo-asset",
"expo-audio",
[
"expo-notifications",
{
"enableBackgroundRemoteNotifications": true,
"sounds": ["./assets/sounds/alert.wav"]
}
]
],
"experiments": {
"typedRoutes": true,
"reactCompiler": true
},
"extra": {
"router": {},
"eas": {
"projectId": "50b3dac3-8b5e-4142-b749-65ecf7b2904d"
}
},
"owner": "anomaly-co",
"runtimeVersion": {
"policy": "appVersion"
},
"updates": {
"url": "https://u.expo.dev/50b3dac3-8b5e-4142-b749-65ecf7b2904d"
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

View File

@@ -0,0 +1,3 @@
<svg width="652" height="606" viewBox="0 0 652 606" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M353.554 0H298.446C273.006 0 249.684 14.6347 237.962 37.9539L4.37994 502.646C-1.04325 513.435 -1.45067 526.178 3.2716 537.313L22.6123 582.918C34.6475 611.297 72.5404 614.156 88.4414 587.885L309.863 222.063C313.34 216.317 319.439 212.826 326 212.826C332.561 212.826 338.659 216.317 342.137 222.063L563.559 587.885C579.46 614.156 617.352 611.297 629.388 582.918L648.728 537.313C653.451 526.178 653.043 513.435 647.62 502.646L414.038 37.9539C402.316 14.6347 378.994 0 353.554 0Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 608 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -0,0 +1,40 @@
{
"fill" : {
"automatic-gradient" : "extended-srgb:0.00000,0.47843,1.00000,1.00000"
},
"groups" : [
{
"layers" : [
{
"image-name" : "expo-symbol 2.svg",
"name" : "expo-symbol 2",
"position" : {
"scale" : 1,
"translation-in-points" : [
1.1008400065293245e-05,
-16.046875
]
}
},
{
"image-name" : "grid.png",
"name" : "grid"
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 479 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,24 @@
{
"cli": {
"version": ">= 18.4.0",
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"channel": "development"
},
"preview": {
"distribution": "internal",
"channel": "preview"
},
"production": {
"autoIncrement": true,
"channel": "production"
}
},
"submit": {
"production": {}
}
}

View File

@@ -0,0 +1,52 @@
// https://docs.expo.dev/guides/using-eslint/
const { defineConfig } = require("eslint/config")
const tsGuard = require("@typescript-eslint/eslint-plugin")
const expoConfig = require("eslint-config-expo/flat")
const reactHooksNext = require("eslint-plugin-react-hooks")
module.exports = defineConfig([
expoConfig,
{
ignores: ["dist/*"],
},
{
files: ["**/*.{ts,tsx}"],
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: __dirname,
},
},
plugins: {
"react-hooks-next": reactHooksNext,
"ts-guard": tsGuard,
},
rules: {
"ts-guard/no-explicit-any": "warn",
"ts-guard/no-floating-promises": "warn",
complexity: ["warn", 20],
"max-lines": [
"warn",
{
max: 1200,
skipBlankLines: true,
skipComments: true,
},
],
"max-lines-per-function": [
"warn",
{
max: 250,
skipBlankLines: true,
skipComments: true,
},
],
"no-console": ["warn", { allow: ["warn", "error"] }],
"no-nested-ternary": "warn",
"react-hooks/exhaustive-deps": "error",
"react-hooks-next/refs": "warn",
"react-hooks-next/set-state-in-effect": "warn",
"react-hooks-next/static-components": "warn",
},
},
])

View File

@@ -0,0 +1,578 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
4318840A4939F9117F5CB295 /* libPods-mobilevoice.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 9D01178681A4FA33E647BDA6 /* libPods-mobilevoice.a */; };
5BCC3D8ACE1241D9ADF08705 /* expo.icon in Resources */ = {isa = PBXBuildFile; fileRef = F1C6EB0F46C84143B321E16B /* expo.icon */; };
7F4CD5196803F15ED532DB9D /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5883CCA53017A900D5BA6B8 /* ExpoModulesProvider.swift */; };
A9D355AEC99B2C485E3E171E /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = BFF46FE16E7CF5862CD6C307 /* PrivacyInfo.xcprivacy */; };
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
13B07F961A680F5B00A75B9A /* mobilevoice.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = mobilevoice.app; sourceTree = BUILT_PRODUCTS_DIR; };
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = mobilevoice/Images.xcassets; sourceTree = "<group>"; };
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = mobilevoice/Info.plist; sourceTree = "<group>"; };
2EF11B4CD2A527AE1396409B /* Pods-mobilevoice.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-mobilevoice.release.xcconfig"; path = "Target Support Files/Pods-mobilevoice/Pods-mobilevoice.release.xcconfig"; sourceTree = "<group>"; };
791CA91656B547FFB3774C02 /* Pods-mobilevoice.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-mobilevoice.debug.xcconfig"; path = "Target Support Files/Pods-mobilevoice/Pods-mobilevoice.debug.xcconfig"; sourceTree = "<group>"; };
9D01178681A4FA33E647BDA6 /* libPods-mobilevoice.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-mobilevoice.a"; sourceTree = BUILT_PRODUCTS_DIR; };
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = mobilevoice/SplashScreen.storyboard; sourceTree = "<group>"; };
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
BFF46FE16E7CF5862CD6C307 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = mobilevoice/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
E5883CCA53017A900D5BA6B8 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-mobilevoice/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = mobilevoice/AppDelegate.swift; sourceTree = "<group>"; };
F11748442D0722820044C1D9 /* mobilevoice-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "mobilevoice-Bridging-Header.h"; path = "mobilevoice/mobilevoice-Bridging-Header.h"; sourceTree = "<group>"; };
F1C6EB0F46C84143B321E16B /* expo.icon */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = undefined; includeInIndex = 0; lastKnownFileType = unknown; name = expo.icon; path = mobilevoice/expo.icon; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
13B07F8C1A680F5B00A75B9A /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
4318840A4939F9117F5CB295 /* libPods-mobilevoice.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
13B07FAE1A68108700A75B9A /* mobilevoice */ = {
isa = PBXGroup;
children = (
F11748412D0307B40044C1D9 /* AppDelegate.swift */,
F11748442D0722820044C1D9 /* mobilevoice-Bridging-Header.h */,
BB2F792B24A3F905000567C9 /* Supporting */,
13B07FB51A68108700A75B9A /* Images.xcassets */,
13B07FB61A68108700A75B9A /* Info.plist */,
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */,
F1C6EB0F46C84143B321E16B /* expo.icon */,
BFF46FE16E7CF5862CD6C307 /* PrivacyInfo.xcprivacy */,
);
name = mobilevoice;
sourceTree = "<group>";
};
2D16E6871FA4F8E400B85C8A /* Frameworks */ = {
isa = PBXGroup;
children = (
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
9D01178681A4FA33E647BDA6 /* libPods-mobilevoice.a */,
);
name = Frameworks;
sourceTree = "<group>";
};
6FA8507500F4DE261E8F6EB0 /* Pods */ = {
isa = PBXGroup;
children = (
791CA91656B547FFB3774C02 /* Pods-mobilevoice.debug.xcconfig */,
2EF11B4CD2A527AE1396409B /* Pods-mobilevoice.release.xcconfig */,
);
name = Pods;
path = Pods;
sourceTree = "<group>";
};
832341AE1AAA6A7D00B99B32 /* Libraries */ = {
isa = PBXGroup;
children = (
);
name = Libraries;
sourceTree = "<group>";
};
83CBB9F61A601CBA00E9B192 = {
isa = PBXGroup;
children = (
13B07FAE1A68108700A75B9A /* mobilevoice */,
832341AE1AAA6A7D00B99B32 /* Libraries */,
83CBBA001A601CBA00E9B192 /* Products */,
2D16E6871FA4F8E400B85C8A /* Frameworks */,
6FA8507500F4DE261E8F6EB0 /* Pods */,
8768F75DE2083C7536A3A210 /* ExpoModulesProviders */,
);
indentWidth = 2;
sourceTree = "<group>";
tabWidth = 2;
usesTabs = 0;
};
83CBBA001A601CBA00E9B192 /* Products */ = {
isa = PBXGroup;
children = (
13B07F961A680F5B00A75B9A /* mobilevoice.app */,
);
name = Products;
sourceTree = "<group>";
};
8768F75DE2083C7536A3A210 /* ExpoModulesProviders */ = {
isa = PBXGroup;
children = (
C0F0F32FD747A33E1176E0FD /* mobilevoice */,
);
name = ExpoModulesProviders;
sourceTree = "<group>";
};
BB2F792B24A3F905000567C9 /* Supporting */ = {
isa = PBXGroup;
children = (
BB2F792C24A3F905000567C9 /* Expo.plist */,
);
name = Supporting;
path = mobilevoice/Supporting;
sourceTree = "<group>";
};
C0F0F32FD747A33E1176E0FD /* mobilevoice */ = {
isa = PBXGroup;
children = (
E5883CCA53017A900D5BA6B8 /* ExpoModulesProvider.swift */,
);
name = mobilevoice;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
13B07F861A680F5B00A75B9A /* mobilevoice */ = {
isa = PBXNativeTarget;
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "mobilevoice" */;
buildPhases = (
08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */,
92A85735D2EE37AB89277662 /* [Expo] Configure project */,
13B07F871A680F5B00A75B9A /* Sources */,
13B07F8C1A680F5B00A75B9A /* Frameworks */,
13B07F8E1A680F5B00A75B9A /* Resources */,
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */,
B09C32BB889007E7F37BBC9C /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
dependencies = (
);
name = mobilevoice;
productName = mobilevoice;
productReference = 13B07F961A680F5B00A75B9A /* mobilevoice.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
83CBB9F71A601CBA00E9B192 /* Project object */ = {
isa = PBXProject;
attributes = {
LastUpgradeCheck = 1130;
TargetAttributes = {
13B07F861A680F5B00A75B9A = {
LastSwiftMigration = 1250;
DevelopmentTeam = "9G68SMNHEU";
ProvisioningStyle = Automatic;
};
};
};
buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "mobilevoice" */;
compatibilityVersion = "Xcode 3.2";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 83CBB9F61A601CBA00E9B192;
productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
13B07F861A680F5B00A75B9A /* mobilevoice */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
13B07F8E1A680F5B00A75B9A /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */,
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,
5BCC3D8ACE1241D9ADF08705 /* expo.icon in Resources */,
A9D355AEC99B2C485E3E171E /* PrivacyInfo.xcprivacy in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"$(SRCROOT)/.xcode.env",
"$(SRCROOT)/.xcode.env.local",
);
name = "Bundle React Native code and images";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "if [[ -f \"$PODS_ROOT/../.xcode.env\" ]]; then\n source \"$PODS_ROOT/../.xcode.env\"\nfi\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n# The project root by default is one level up from the ios directory\nexport PROJECT_ROOT=\"$PROJECT_DIR\"/..\n\nif [[ \"$CONFIGURATION\" = *Debug* ]]; then\n export SKIP_BUNDLING=1\nfi\nif [[ -z \"$ENTRY_FILE\" ]]; then\n # Set the entry JS file using the bundler's entry resolution.\n export ENTRY_FILE=\"$(\"$NODE_BINARY\" -e \"require('expo/scripts/resolveAppEntry')\" \"$PROJECT_ROOT\" ios absolute | tail -n 1)\"\nfi\n\nif [[ -z \"$CLI_PATH\" ]]; then\n # Use Expo CLI\n export CLI_PATH=\"$(\"$NODE_BINARY\" --print \"require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })\")\"\nfi\nif [[ -z \"$BUNDLE_COMMAND\" ]]; then\n # Default Expo CLI command for bundling\n export BUNDLE_COMMAND=\"export:embed\"\nfi\n\n# Source .xcode.env.updates if it exists to allow\n# SKIP_BUNDLING to be unset if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.updates\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.updates\"\nfi\n# Source local changes to allow overrides\n# if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n`\"$NODE_BINARY\" --print \"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'\"`\n\n";
};
08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-mobilevoice-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${PODS_ROOT}/Target Support Files/Pods-mobilevoice/Pods-mobilevoice-resources.sh",
"${PODS_CONFIGURATION_BUILD_DIR}/EXApplication/ExpoApplication_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoDevice/ExpoDevice_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoNotifications/ExpoNotifications_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoSystemUI/ExpoSystemUI_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoTaskManager/ExpoTaskManager_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage/SDWebImage.bundle",
);
name = "[CP] Copy Pods Resources";
outputPaths = (
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoApplication_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoConstants_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoDevice_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoNotifications_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoSystemUI_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoTaskManager_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SDWebImage.bundle",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-mobilevoice/Pods-mobilevoice-resources.sh\"\n";
showEnvVarsInLog = 0;
};
92A85735D2EE37AB89277662 /* [Expo] Configure project */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"$(SRCROOT)/.xcode.env",
"$(SRCROOT)/.xcode.env.local",
"$(SRCROOT)/mobilevoice/mobilevoice.entitlements",
"$(SRCROOT)/Pods/Target Support Files/Pods-mobilevoice/expo-configure-project.sh",
);
name = "[Expo] Configure project";
outputFileListPaths = (
);
outputPaths = (
"$(SRCROOT)/Pods/Target Support Files/Pods-mobilevoice/ExpoModulesProvider.swift",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-mobilevoice/expo-configure-project.sh\"\n";
};
B09C32BB889007E7F37BBC9C /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${PODS_ROOT}/Target Support Files/Pods-mobilevoice/Pods-mobilevoice-frameworks.sh",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/RNAudioAPI/libavcodec.framework/libavcodec",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/RNAudioAPI/libavformat.framework/libavformat",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/RNAudioAPI/libavutil.framework/libavutil",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/RNAudioAPI/libswresample.framework/libswresample",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/React-Core-prebuilt/React.framework/React",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ReactNativeDependencies/ReactNativeDependencies.framework/ReactNativeDependencies",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermesvm.framework/hermesvm",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/react-native-executorch/ExecutorchLib.framework/ExecutorchLib",
);
name = "[CP] Embed Pods Frameworks";
outputPaths = (
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libavcodec.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libavformat.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libavutil.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libswresample.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/React.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ReactNativeDependencies.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermesvm.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ExecutorchLib.framework",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-mobilevoice/Pods-mobilevoice-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
13B07F871A680F5B00A75B9A /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */,
7F4CD5196803F15ED532DB9D /* ExpoModulesProvider.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
13B07F941A680F5B00A75B9A /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 791CA91656B547FFB3774C02 /* Pods-mobilevoice.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = expo;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = mobilevoice/mobilevoice.entitlements;
CURRENT_PROJECT_VERSION = 1;
ENABLE_BITCODE = NO;
GCC_PREPROCESSOR_DEFINITIONS = (
"$(inherited)",
"FB_SONARKIT_ENABLED=1",
);
INFOPLIST_FILE = mobilevoice/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
"-lc++",
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
PRODUCT_BUNDLE_IDENTIFIER = com.anomalyco.mobilevoice;
PRODUCT_NAME = mobilevoice;
SWIFT_OBJC_BRIDGING_HEADER = "mobilevoice/mobilevoice-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
VERSIONING_SYSTEM = "apple-generic";
DEVELOPMENT_TEAM = "9G68SMNHEU";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
};
name = Debug;
};
13B07F951A680F5B00A75B9A /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 2EF11B4CD2A527AE1396409B /* Pods-mobilevoice.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = expo;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = mobilevoice/mobilevoice.entitlements;
CURRENT_PROJECT_VERSION = 1;
INFOPLIST_FILE = mobilevoice/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
"-lc++",
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.anomalyco.mobilevoice;
PRODUCT_NAME = mobilevoice;
SWIFT_OBJC_BRIDGING_HEADER = "mobilevoice/mobilevoice-Bridging-Header.h";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
VERSIONING_SYSTEM = "apple-generic";
DEVELOPMENT_TEAM = "9G68SMNHEU";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
};
name = Release;
};
83CBBA201A601CBA00E9B192 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_CXX_LANGUAGE_STANDARD = "c++20";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_SYMBOLS_PRIVATE_EXTERN = NO;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = (
/usr/lib/swift,
"$(inherited)",
);
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
OTHER_CFLAGS = "$(inherited)";
OTHER_CPLUSPLUSFLAGS = "$(inherited)";
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
USE_HERMES = true;
};
name = Debug;
};
83CBBA211A601CBA00E9B192 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_CXX_LANGUAGE_STANDARD = "c++20";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = YES;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = (
/usr/lib/swift,
"$(inherited)",
);
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = NO;
OTHER_CFLAGS = "$(inherited)";
OTHER_CPLUSPLUSFLAGS = "$(inherited)";
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
USE_HERMES = true;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "mobilevoice" */ = {
isa = XCConfigurationList;
buildConfigurations = (
13B07F941A680F5B00A75B9A /* Debug */,
13B07F951A680F5B00A75B9A /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "mobilevoice" */ = {
isa = XCConfigurationList;
buildConfigurations = (
83CBBA201A601CBA00E9B192 /* Debug */,
83CBBA211A601CBA00E9B192 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */;
}

View File

@@ -0,0 +1,92 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Control</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>mobilevoice</string>
<string>com.anomalyco.mobilevoice</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<false/>
<key>NSAllowsLocalNetworking</key>
<true/>
<key>NSExceptionDomains</key>
<dict>
<key>ts.net</key>
<dict>
<key>NSIncludesSubdomains</key>
<true/>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
</dict>
</dict>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSMicrophoneUsageDescription</key>
<string>This app needs microphone access for live speech-to-text dictation.</string>
<key>NSUserActivityTypes</key>
<array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string>
</array>
<key>RCTNewArchEnabled</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UILaunchStoryboardName</key>
<string>SplashScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UIRequiresFullScreen</key>
<false/>
<key>UIStatusBarStyle</key>
<string>UIStatusBarStyleDefault</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
</array>
<key>UIUserInterfaceStyle</key>
<string>Automatic</string>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict>
</plist>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.kernel.extended-virtual-addressing</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,8 @@
const { getDefaultConfig } = require('expo/metro-config');
const config = getDefaultConfig(__dirname);
// Required for react-native-executorch model files
config.resolver.assetExts.push('pte', 'bin');
module.exports = config;

View File

@@ -0,0 +1,4 @@
- While the model is loading for the first time, there should be some fun little like onboarding sequence that you can go through that makes sure the model is automated properly.
- When a permission/session complete notification is sent, if you click on it, the session/server should auto be selected.
- We need some sort of permissions UI in the top half of the generation.
- Need to figure out a good way to start new sessions.

8913
packages/mobile-voice/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,64 @@
{
"name": "mobile-voice",
"main": "expo-router/entry",
"version": "1.0.0",
"scripts": {
"start": "expo start",
"relay": "echo 'Use packages/apn-relay for APNs relay server'",
"relay:legacy": "node ./relay/opencode-relay.mjs",
"reset-project": "node ./scripts/reset-project.js",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
"lint": "expo lint",
"typecheck": "tsc -p tsconfig.typecheck.json --noEmit"
},
"dependencies": {
"@fugood/react-native-audio-pcm-stream": "1.1.4",
"@react-navigation/bottom-tabs": "^7.15.5",
"@react-navigation/elements": "^2.9.10",
"@react-navigation/native": "^7.1.33",
"expo": "~55.0.9",
"expo-asset": "~55.0.10",
"expo-audio": "~55.0.9",
"expo-camera": "~55.0.11",
"expo-constants": "~55.0.9",
"expo-dev-client": "~55.0.19",
"expo-device": "~55.0.10",
"expo-file-system": "~55.0.12",
"expo-font": "~55.0.4",
"expo-glass-effect": "~55.0.8",
"expo-haptics": "~55.0.9",
"expo-image": "~55.0.6",
"expo-linking": "~55.0.9",
"expo-notifications": "~55.0.14",
"expo-router": "~55.0.8",
"expo-splash-screen": "~55.0.13",
"expo-status-bar": "~55.0.4",
"expo-symbols": "~55.0.5",
"expo-system-ui": "~55.0.11",
"expo-task-manager": "~55.0.10",
"expo-updates": "~55.0.16",
"expo-web-browser": "~55.0.10",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-native": "0.83.4",
"react-native-audio-api": "^0.11.7",
"react-native-gesture-handler": "~2.30.0",
"react-native-reanimated": "4.2.1",
"react-native-safe-area-context": "~5.6.2",
"react-native-screens": "~4.23.0",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.7.2",
"whisper.rn": "0.5.5"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^8.57.2",
"@typescript-eslint/parser": "^8.57.2",
"@types/react": "~19.2.2",
"babel-preset-expo": "~55.0.8",
"eslint-plugin-react-hooks": "^7.0.1",
"typescript": "~5.9.2"
},
"private": true
}

View File

@@ -0,0 +1,88 @@
# Mobile Voice Refactor Plan
## Goals
- Reduce the surface area of `src/app/index.tsx` without changing product behavior.
- Make device, network, and monitoring flows easier to reason about.
- Move toward React Native / Expo best practices for state, effects, and file structure.
- Use the new lint warnings as refactor prompts, not as permanent background noise.
## Current Pain Points
- `DictationScreen` currently owns onboarding, permissions, Whisper/model lifecycle, dictation, pairing, server/session sync, relay registration, notification handling, and most UI rendering.
- The screen mixes render-time derived state, imperative refs, polling, persistence, and native cleanup in one place.
- There are many nested conditionals and long derived blocks that are hard to scan.
- Best-effort async cleanup and silent catches make failures harder to understand.
## Target Shape
- `src/app/index.tsx`
- compose hooks and presentational sections
- keep only screen-level orchestration
- `src/features/onboarding/`
- onboarding step config
- onboarding UI component
- `src/features/dictation/`
- `use-whisper-dictation`
- transcript helpers
- `src/features/servers/`
- server/session refresh and pairing helpers
- persisted server state helpers
- `src/features/monitoring/`
- foreground SSE monitoring
- notification payload handling
- relay registration helpers
- `src/lib/`
- parser/validation helpers
- logger helper for dev-only diagnostics
## Refactor Order
### Phase 1: Extract pure helpers first
- Move onboarding step text/style selection into a config object or array.
- Move server/session payload parsing into dedicated helpers.
- Keep existing behavior and props the same.
### Phase 2: Extract onboarding UI
- Create an `OnboardingFlow` component that receives explicit state and handlers.
- Keep onboarding persistence in the screen until the UI extraction is stable.
### Phase 3: Extract dictation logic
- Move Whisper loading, recording, bulk/realtime transcription, and waveform state into a `useWhisperDictation` hook.
- Expose a small interface: recording state, transcript, actions, and model status.
### Phase 4: Extract server/session management
- Move server restore/save, pairing, health refresh, and active server/session selection into a dedicated hook.
- Centralize server parsing and dedupe logic.
### Phase 5: Extract monitoring and notifications
- Move SSE monitoring, push payload handling, and relay registration into a `useMonitoring` hook.
- Keep side effects close to the feature that owns them.
### Phase 6: Lint burn-down
- Replace `any` with explicit parsed shapes.
- Reduce nested ternaries in favor of config tables.
- Replace ad hoc `console.log` calls with a logger helper or `__DEV__`-gated diagnostics.
- Audit bare `.catch(() => {})` and convert non-trivial cases to explicit best-effort helpers or real error handling.
## Guardrails During Refactor
- Keep one behavior-preserving slice per PR.
- Do not introduce more derived state in `useEffect`.
- Prefer explicit hook inputs/outputs over hidden cross-hook coupling.
- Only use refs for imperative APIs, subscriptions, and race control.
- Re-run lint after each slice.
- Validate app behavior in the dev client for microphone, notifications, pairing, and monitoring flows.
## Exit Criteria
- `src/app/index.tsx` is mostly screen composition and stays under roughly 800-1200 lines.
- Feature logic lives in focused hooks/components with clearer ownership.
- New payload parsing does not rely on `any`.
- Lint warnings trend down instead of growing.

View File

@@ -0,0 +1,328 @@
import { createServer } from 'node:http';
const PORT = Number(process.env.PORT || 8787);
const HOST = process.env.HOST || '0.0.0.0';
const EXPO_PUSH_URL = 'https://exp.host/--/api/v2/push/send';
/** @type {Map<string, {jobID: string, sessionID: string, opencodeBaseURL: string, relayBaseURL: string, expoPushToken: string, createdAt: number, done: boolean}>} */
const jobs = new Map();
/** @type {Map<string, {key: string, opencodeBaseURL: string, abortController: AbortController, sessions: Set<string>, running: boolean}>} */
const streams = new Map();
/** @type {Set<string>} */
const dedupe = new Set();
function json(res, status, body) {
const value = JSON.stringify(body);
res.writeHead(status, {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(value),
});
res.end(value);
}
async function readJSON(req) {
let raw = '';
for await (const chunk of req) {
raw += chunk;
if (raw.length > 1_000_000) {
throw new Error('Payload too large');
}
}
if (!raw.trim()) return {};
return JSON.parse(raw);
}
function extractSessionID(event) {
const properties = event?.properties ?? {};
if (typeof properties.sessionID === 'string') return properties.sessionID;
if (properties.info && typeof properties.info === 'object' && typeof properties.info.sessionID === 'string') {
return properties.info.sessionID;
}
if (properties.part && typeof properties.part === 'object' && typeof properties.part.sessionID === 'string') {
return properties.part.sessionID;
}
return null;
}
function classifyEvent(event) {
const type = String(event?.type || '');
const lower = type.toLowerCase();
if (lower.includes('permission')) return 'permission';
if (lower.includes('error')) return 'error';
if (type === 'session.status') {
const statusType = event?.properties?.status?.type;
if (statusType === 'idle') return 'complete';
}
if (type === 'message.updated') {
const info = event?.properties?.info;
if (info && typeof info === 'object') {
if (info.error) return 'error';
if (info.role === 'assistant' && info.time && typeof info.time === 'object' && info.time.completed) {
return 'complete';
}
}
}
return null;
}
function notificationBody(eventType) {
if (eventType === 'complete') {
return {
title: 'Session complete',
body: 'OpenCode finished your monitored prompt.',
};
}
if (eventType === 'permission') {
return {
title: 'Action needed',
body: 'OpenCode needs a permission decision.',
};
}
return {
title: 'Session error',
body: 'OpenCode reported an error for your monitored session.',
};
}
async function sendPush({ expoPushToken, eventType, sessionID, jobID }) {
const dedupeKey = `${jobID}:${eventType}`;
if (dedupe.has(dedupeKey)) return;
dedupe.add(dedupeKey);
const text = notificationBody(eventType);
const payload = {
to: expoPushToken,
priority: 'high',
_contentAvailable: true,
data: {
eventType,
sessionID,
jobID,
title: text.title,
body: text.body,
dedupeKey,
at: Date.now(),
},
};
const response = await fetch(EXPO_PUSH_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const body = await response.text();
throw new Error(`Push send failed (${response.status}): ${body || response.statusText}`);
}
}
async function* parseSSE(readable) {
const reader = readable.getReader();
const decoder = new TextDecoder();
let pending = '';
try {
while (true) {
const next = await reader.read();
if (next.done) break;
pending += decoder.decode(next.value, { stream: true });
const blocks = pending.split(/\r?\n\r?\n/);
pending = blocks.pop() || '';
for (const block of blocks) {
const lines = block.split(/\r?\n/);
const dataLines = [];
for (const line of lines) {
if (!line || line.startsWith(':')) continue;
if (line.startsWith('data:')) {
dataLines.push(line.slice(5).trimStart());
}
}
if (dataLines.length > 0) {
yield dataLines.join('\n');
}
}
}
} finally {
reader.releaseLock();
}
}
function cleanupStreamIfUnused(baseURL) {
const key = baseURL.replace(/\/+$/, '');
const entry = streams.get(key);
if (!entry) return;
const stillUsed = Array.from(jobs.values()).some((job) => !job.done && job.opencodeBaseURL === key);
if (stillUsed) return;
entry.abortController.abort();
streams.delete(key);
}
async function runStream(baseURL) {
const key = baseURL.replace(/\/+$/, '');
if (streams.has(key)) return;
const abortController = new AbortController();
streams.set(key, {
key,
opencodeBaseURL: key,
abortController,
sessions: new Set(),
running: true,
});
while (!abortController.signal.aborted) {
try {
const response = await fetch(`${key}/event`, {
signal: abortController.signal,
headers: {
Accept: 'text/event-stream',
'Cache-Control': 'no-cache',
},
});
if (!response.ok || !response.body) {
throw new Error(`SSE connect failed (${response.status})`);
}
for await (const data of parseSSE(response.body)) {
if (abortController.signal.aborted) break;
let event;
try {
event = JSON.parse(data);
} catch {
continue;
}
const sessionID = extractSessionID(event);
if (!sessionID) continue;
const eventType = classifyEvent(event);
if (!eventType) continue;
const related = Array.from(jobs.values()).filter(
(job) => !job.done && job.opencodeBaseURL === key && job.sessionID === sessionID,
);
if (related.length === 0) continue;
await Promise.allSettled(
related.map(async (job) => {
await sendPush({
expoPushToken: job.expoPushToken,
eventType,
sessionID,
jobID: job.jobID,
});
if (eventType === 'complete' || eventType === 'error') {
const current = jobs.get(job.jobID);
if (current) current.done = true;
}
}),
);
}
} catch (error) {
if (abortController.signal.aborted) break;
console.warn('[relay] SSE loop error:', error instanceof Error ? error.message : String(error));
await new Promise((resolve) => setTimeout(resolve, 1200));
}
}
}
const server = createServer(async (req, res) => {
if (!req.url || !req.method) {
json(res, 400, { ok: false, error: 'Invalid request' });
return;
}
if (req.url === '/health' && req.method === 'GET') {
json(res, 200, {
ok: true,
activeJobs: Array.from(jobs.values()).filter((job) => !job.done).length,
streams: streams.size,
});
return;
}
if (req.url === '/v1/monitor/start' && req.method === 'POST') {
try {
const body = await readJSON(req);
const jobID = String(body.jobID || '').trim();
const sessionID = String(body.sessionID || '').trim();
const opencodeBaseURL = String(body.opencodeBaseURL || '').trim().replace(/\/+$/, '');
const relayBaseURL = String(body.relayBaseURL || '').trim().replace(/\/+$/, '');
const expoPushToken = String(body.expoPushToken || '').trim();
if (!jobID || !sessionID || !opencodeBaseURL || !expoPushToken) {
json(res, 400, { ok: false, error: 'Missing required fields' });
return;
}
jobs.set(jobID, {
jobID,
sessionID,
opencodeBaseURL,
relayBaseURL,
expoPushToken,
createdAt: Date.now(),
done: false,
});
runStream(opencodeBaseURL).catch((error) => {
console.warn('[relay] runStream failed:', error instanceof Error ? error.message : String(error));
});
json(res, 200, { ok: true });
return;
} catch (error) {
json(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) });
return;
}
}
if (req.url === '/v1/monitor/stop' && req.method === 'POST') {
try {
const body = await readJSON(req);
const jobID = String(body.jobID || '').trim();
const token = String(body.expoPushToken || '').trim();
if (!jobID || !token) {
json(res, 400, { ok: false, error: 'Missing required fields' });
return;
}
const job = jobs.get(jobID);
if (job && job.expoPushToken === token) {
job.done = true;
cleanupStreamIfUnused(job.opencodeBaseURL);
}
json(res, 200, { ok: true });
return;
} catch (error) {
json(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) });
return;
}
}
json(res, 404, { ok: false, error: 'Not found' });
});
server.listen(PORT, HOST, () => {
console.log(`[relay] listening on http://${HOST}:${PORT}`);
});

View File

@@ -0,0 +1,114 @@
#!/usr/bin/env node
/**
* This script is used to reset the project to a blank state.
* It deletes or moves the /src and /scripts directories to /example based on user input and creates a new /src/app directory with an index.tsx and _layout.tsx file.
* You can remove the `reset-project` script from package.json and safely delete this file after running it.
*/
const fs = require("fs");
const path = require("path");
const readline = require("readline");
const root = process.cwd();
const oldDirs = ["src", "scripts"];
const exampleDir = "example";
const newAppDir = "src/app";
const exampleDirPath = path.join(root, exampleDir);
const indexContent = `import { Text, View, StyleSheet } from "react-native";
export default function Index() {
return (
<View style={styles.container}>
<Text>Edit src/app/index.tsx to edit this screen.</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
},
});
`;
const layoutContent = `import { Stack } from "expo-router";
export default function RootLayout() {
return <Stack />;
}
`;
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const moveDirectories = async (userInput) => {
try {
if (userInput === "y") {
// Create the app-example directory
await fs.promises.mkdir(exampleDirPath, { recursive: true });
console.log(`📁 /${exampleDir} directory created.`);
}
// Move old directories to new app-example directory or delete them
for (const dir of oldDirs) {
const oldDirPath = path.join(root, dir);
if (fs.existsSync(oldDirPath)) {
if (userInput === "y") {
const newDirPath = path.join(root, exampleDir, dir);
await fs.promises.rename(oldDirPath, newDirPath);
console.log(`➡️ /${dir} moved to /${exampleDir}/${dir}.`);
} else {
await fs.promises.rm(oldDirPath, { recursive: true, force: true });
console.log(`❌ /${dir} deleted.`);
}
} else {
console.log(`➡️ /${dir} does not exist, skipping.`);
}
}
// Create new /src/app directory
const newAppDirPath = path.join(root, newAppDir);
await fs.promises.mkdir(newAppDirPath, { recursive: true });
console.log("\n📁 New /src/app directory created.");
// Create index.tsx
const indexPath = path.join(newAppDirPath, "index.tsx");
await fs.promises.writeFile(indexPath, indexContent);
console.log("📄 src/app/index.tsx created.");
// Create _layout.tsx
const layoutPath = path.join(newAppDirPath, "_layout.tsx");
await fs.promises.writeFile(layoutPath, layoutContent);
console.log("📄 src/app/_layout.tsx created.");
console.log("\n✅ Project reset complete. Next steps:");
console.log(
`1. Run \`npx expo start\` to start a development server.\n2. Edit src/app/index.tsx to edit the main screen.\n3. Put all your application code in /src, only screens and layout files should be in /src/app.${
userInput === "y"
? `\n4. Delete the /${exampleDir} directory when you're done referencing it.`
: ""
}`
);
} catch (error) {
console.error(`❌ Error during script execution: ${error.message}`);
}
};
rl.question(
"Do you want to move existing files to /example instead of deleting them? (Y/n): ",
(answer) => {
const userInput = answer.trim().toLowerCase() || "y";
if (userInput === "y" || userInput === "n") {
moveDirectories(userInput).finally(() => rl.close());
} else {
console.log("❌ Invalid input. Please enter 'Y' or 'N'.");
rl.close();
}
}
);

View File

@@ -0,0 +1,20 @@
import React from "react"
import { Slot } from "expo-router"
import { LogBox } from "react-native"
import {
configureNotificationBehavior,
registerBackgroundNotificationTask,
} from "@/notifications/monitoring-notifications"
// Suppress known non-actionable warnings from third-party libs.
LogBox.ignoreLogs([
"RecordingNotificationManager is not implemented on iOS",
"`transcribeRealtime` is deprecated, use `RealtimeTranscriber` instead",
])
configureNotificationBehavior()
registerBackgroundNotificationTask().catch(() => {})
export default function RootLayout() {
return <Slot />
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
.expoLogoBackground {
background-image: linear-gradient(180deg, #3c9ffe, #0274df);
border-radius: 40px;
width: 128px;
height: 128px;
}

View File

@@ -0,0 +1,132 @@
import { Image } from 'expo-image';
import { useState } from 'react';
import { Dimensions, StyleSheet, View } from 'react-native';
import Animated, { Easing, Keyframe } from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';
const INITIAL_SCALE_FACTOR = Dimensions.get('screen').height / 90;
const DURATION = 600;
export function AnimatedSplashOverlay() {
const [visible, setVisible] = useState(true);
if (!visible) return null;
const splashKeyframe = new Keyframe({
0: {
transform: [{ scale: INITIAL_SCALE_FACTOR }],
opacity: 1,
},
20: {
opacity: 1,
},
70: {
opacity: 0,
easing: Easing.elastic(0.7),
},
100: {
opacity: 0,
transform: [{ scale: 1 }],
easing: Easing.elastic(0.7),
},
});
return (
<Animated.View
entering={splashKeyframe.duration(DURATION).withCallback((finished) => {
'worklet';
if (finished) {
scheduleOnRN(setVisible, false);
}
})}
style={styles.backgroundSolidColor}
/>
);
}
const keyframe = new Keyframe({
0: {
transform: [{ scale: INITIAL_SCALE_FACTOR }],
},
100: {
transform: [{ scale: 1 }],
easing: Easing.elastic(0.7),
},
});
const logoKeyframe = new Keyframe({
0: {
transform: [{ scale: 1.3 }],
opacity: 0,
},
40: {
transform: [{ scale: 1.3 }],
opacity: 0,
easing: Easing.elastic(0.7),
},
100: {
opacity: 1,
transform: [{ scale: 1 }],
easing: Easing.elastic(0.7),
},
});
const glowKeyframe = new Keyframe({
0: {
transform: [{ rotateZ: '0deg' }],
},
100: {
transform: [{ rotateZ: '7200deg' }],
},
});
export function AnimatedIcon() {
return (
<View style={styles.iconContainer}>
<Animated.View entering={glowKeyframe.duration(60 * 1000 * 4)} style={styles.glow}>
<Image style={styles.glow} source={require('@/assets/images/logo-glow.png')} />
</Animated.View>
<Animated.View entering={keyframe.duration(DURATION)} style={styles.background} />
<Animated.View style={styles.imageContainer} entering={logoKeyframe.duration(DURATION)}>
<Image style={styles.image} source={require('@/assets/images/expo-logo.png')} />
</Animated.View>
</View>
);
}
const styles = StyleSheet.create({
imageContainer: {
justifyContent: 'center',
alignItems: 'center',
},
glow: {
width: 201,
height: 201,
position: 'absolute',
},
iconContainer: {
justifyContent: 'center',
alignItems: 'center',
width: 128,
height: 128,
zIndex: 100,
},
image: {
position: 'absolute',
width: 76,
height: 71,
},
background: {
borderRadius: 40,
experimental_backgroundImage: `linear-gradient(180deg, #3C9FFE, #0274DF)`,
width: 128,
height: 128,
position: 'absolute',
},
backgroundSolidColor: {
...StyleSheet.absoluteFillObject,
backgroundColor: '#208AEF',
zIndex: 1000,
},
});

View File

@@ -0,0 +1,108 @@
import { Image } from 'expo-image';
import { StyleSheet, View } from 'react-native';
import Animated, { Keyframe, Easing } from 'react-native-reanimated';
import classes from './animated-icon.module.css';
const DURATION = 300;
export function AnimatedSplashOverlay() {
return null;
}
const keyframe = new Keyframe({
0: {
transform: [{ scale: 0 }],
},
60: {
transform: [{ scale: 1.2 }],
easing: Easing.elastic(1.2),
},
100: {
transform: [{ scale: 1 }],
easing: Easing.elastic(1.2),
},
});
const logoKeyframe = new Keyframe({
0: {
opacity: 0,
},
60: {
transform: [{ scale: 1.2 }],
opacity: 0,
easing: Easing.elastic(1.2),
},
100: {
transform: [{ scale: 1 }],
opacity: 1,
easing: Easing.elastic(1.2),
},
});
const glowKeyframe = new Keyframe({
0: {
transform: [{ rotateZ: '-180deg' }, { scale: 0.8 }],
opacity: 0,
},
[DURATION / 1000]: {
transform: [{ rotateZ: '0deg' }, { scale: 1 }],
opacity: 1,
easing: Easing.elastic(0.7),
},
100: {
transform: [{ rotateZ: '7200deg' }],
},
});
export function AnimatedIcon() {
return (
<View style={styles.iconContainer}>
<Animated.View entering={glowKeyframe.duration(60 * 1000 * 4)} style={styles.glow}>
<Image style={styles.glow} source={require('@/assets/images/logo-glow.png')} />
</Animated.View>
<Animated.View style={styles.background} entering={keyframe.duration(DURATION)}>
<div className={classes.expoLogoBackground} />
</Animated.View>
<Animated.View style={styles.imageContainer} entering={logoKeyframe.duration(DURATION)}>
<Image style={styles.image} source={require('@/assets/images/expo-logo.png')} />
</Animated.View>
</View>
);
}
const styles = StyleSheet.create({
container: {
alignItems: 'center',
width: '100%',
zIndex: 1000,
position: 'absolute',
top: 128 / 2 + 138,
},
imageContainer: {
justifyContent: 'center',
alignItems: 'center',
},
glow: {
width: 201,
height: 201,
position: 'absolute',
},
iconContainer: {
justifyContent: 'center',
alignItems: 'center',
width: 128,
height: 128,
},
image: {
position: 'absolute',
width: 76,
height: 71,
},
background: {
width: 128,
height: 128,
position: 'absolute',
},
});

View File

@@ -0,0 +1,7 @@
// Not used - single page app. Kept to avoid breaking template imports.
import { Slot } from 'expo-router';
import React from 'react';
export default function AppTabs() {
return <Slot />;
}

View File

@@ -0,0 +1,7 @@
// Not used - single page app. Kept to avoid breaking template imports.
import { Slot } from 'expo-router';
import React from 'react';
export default function AppTabs() {
return <Slot />;
}

View File

@@ -0,0 +1,25 @@
import { Href, Link } from 'expo-router';
import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser';
import { type ComponentProps } from 'react';
type Props = Omit<ComponentProps<typeof Link>, 'href'> & { href: Href & string };
export function ExternalLink({ href, ...rest }: Props) {
return (
<Link
target="_blank"
{...rest}
href={href}
onPress={async (event) => {
if (process.env.EXPO_OS !== 'web') {
// Prevent the default behavior of linking to the default browser on native.
event.preventDefault();
// Open the link in an in-app browser.
await openBrowserAsync(href, {
presentationStyle: WebBrowserPresentationStyle.AUTOMATIC,
});
}
}}
/>
);
}

View File

@@ -0,0 +1,35 @@
import React, { type ReactNode } from 'react';
import { View, StyleSheet } from 'react-native';
import { ThemedText } from './themed-text';
import { ThemedView } from './themed-view';
import { Spacing } from '@/constants/theme';
type HintRowProps = {
title?: string;
hint?: ReactNode;
};
export function HintRow({ title = 'Try editing', hint = 'app/index.tsx' }: HintRowProps) {
return (
<View style={styles.stepRow}>
<ThemedText type="small">{title}</ThemedText>
<ThemedView type="backgroundSelected" style={styles.codeSnippet}>
<ThemedText themeColor="textSecondary">{hint}</ThemedText>
</ThemedView>
</View>
);
}
const styles = StyleSheet.create({
stepRow: {
flexDirection: 'row',
justifyContent: 'space-between',
},
codeSnippet: {
borderRadius: Spacing.two,
paddingVertical: Spacing.half,
paddingHorizontal: Spacing.two,
},
});

View File

@@ -0,0 +1,73 @@
import { Platform, StyleSheet, Text, type TextProps } from 'react-native';
import { Fonts, ThemeColor } from '@/constants/theme';
import { useTheme } from '@/hooks/use-theme';
export type ThemedTextProps = TextProps & {
type?: 'default' | 'title' | 'small' | 'smallBold' | 'subtitle' | 'link' | 'linkPrimary' | 'code';
themeColor?: ThemeColor;
};
export function ThemedText({ style, type = 'default', themeColor, ...rest }: ThemedTextProps) {
const theme = useTheme();
return (
<Text
style={[
{ color: theme[themeColor ?? 'text'] },
type === 'default' && styles.default,
type === 'title' && styles.title,
type === 'small' && styles.small,
type === 'smallBold' && styles.smallBold,
type === 'subtitle' && styles.subtitle,
type === 'link' && styles.link,
type === 'linkPrimary' && styles.linkPrimary,
type === 'code' && styles.code,
style,
]}
{...rest}
/>
);
}
const styles = StyleSheet.create({
small: {
fontSize: 14,
lineHeight: 20,
fontWeight: 500,
},
smallBold: {
fontSize: 14,
lineHeight: 20,
fontWeight: 700,
},
default: {
fontSize: 16,
lineHeight: 24,
fontWeight: 500,
},
title: {
fontSize: 48,
fontWeight: 600,
lineHeight: 52,
},
subtitle: {
fontSize: 32,
lineHeight: 44,
fontWeight: 600,
},
link: {
lineHeight: 30,
fontSize: 14,
},
linkPrimary: {
lineHeight: 30,
fontSize: 14,
color: '#3c87f7',
},
code: {
fontFamily: Fonts.mono,
fontWeight: Platform.select({ android: 700 }) ?? 500,
fontSize: 12,
},
});

View File

@@ -0,0 +1,16 @@
import { View, type ViewProps } from 'react-native';
import { ThemeColor } from '@/constants/theme';
import { useTheme } from '@/hooks/use-theme';
export type ThemedViewProps = ViewProps & {
lightColor?: string;
darkColor?: string;
type?: ThemeColor;
};
export function ThemedView({ style, lightColor, darkColor, type, ...otherProps }: ThemedViewProps) {
const theme = useTheme();
return <View style={[{ backgroundColor: theme[type ?? 'background'] }, style]} {...otherProps} />;
}

View File

@@ -0,0 +1,65 @@
import { SymbolView } from 'expo-symbols';
import { PropsWithChildren, useState } from 'react';
import { Pressable, StyleSheet } from 'react-native';
import Animated, { FadeIn } from 'react-native-reanimated';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { Spacing } from '@/constants/theme';
import { useTheme } from '@/hooks/use-theme';
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
const [isOpen, setIsOpen] = useState(false);
const theme = useTheme();
return (
<ThemedView>
<Pressable
style={({ pressed }) => [styles.heading, pressed && styles.pressedHeading]}
onPress={() => setIsOpen((value) => !value)}>
<ThemedView type="backgroundElement" style={styles.button}>
<SymbolView
name={{ ios: 'chevron.right', android: 'chevron_right', web: 'chevron_right' }}
size={14}
weight="bold"
tintColor={theme.text}
style={{ transform: [{ rotate: isOpen ? '-90deg' : '90deg' }] }}
/>
</ThemedView>
<ThemedText type="small">{title}</ThemedText>
</Pressable>
{isOpen && (
<Animated.View entering={FadeIn.duration(200)}>
<ThemedView type="backgroundElement" style={styles.content}>
{children}
</ThemedView>
</Animated.View>
)}
</ThemedView>
);
}
const styles = StyleSheet.create({
heading: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.two,
},
pressedHeading: {
opacity: 0.7,
},
button: {
width: Spacing.four,
height: Spacing.four,
borderRadius: 12,
justifyContent: 'center',
alignItems: 'center',
},
content: {
marginTop: Spacing.three,
borderRadius: Spacing.three,
marginLeft: Spacing.four,
padding: Spacing.four,
},
});

View File

@@ -0,0 +1,44 @@
import { version } from 'expo/package.json';
import { Image } from 'expo-image';
import React from 'react';
import { useColorScheme, StyleSheet } from 'react-native';
import { ThemedText } from './themed-text';
import { ThemedView } from './themed-view';
import { Spacing } from '@/constants/theme';
export function WebBadge() {
const scheme = useColorScheme();
return (
<ThemedView style={styles.container}>
<ThemedText type="code" themeColor="textSecondary" style={styles.versionText}>
v{version}
</ThemedText>
<Image
source={
scheme === 'dark'
? require('@/assets/images/expo-badge-white.png')
: require('@/assets/images/expo-badge.png')
}
style={styles.badgeImage}
/>
</ThemedView>
);
}
const styles = StyleSheet.create({
container: {
padding: Spacing.five,
alignItems: 'center',
gap: Spacing.two,
},
versionText: {
textAlign: 'center',
},
badgeImage: {
width: 123,
aspectRatio: 123 / 24,
},
});

View File

@@ -0,0 +1,65 @@
/**
* Below are the colors that are used in the app. The colors are defined in the light and dark mode.
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
*/
import '@/global.css';
import { Platform } from 'react-native';
export const Colors = {
light: {
text: '#000000',
background: '#ffffff',
backgroundElement: '#F0F0F3',
backgroundSelected: '#E0E1E6',
textSecondary: '#60646C',
},
dark: {
text: '#ffffff',
background: '#000000',
backgroundElement: '#212225',
backgroundSelected: '#2E3135',
textSecondary: '#B0B4BA',
},
} as const;
export type ThemeColor = keyof typeof Colors.light & keyof typeof Colors.dark;
export const Fonts = Platform.select({
ios: {
/** iOS `UIFontDescriptorSystemDesignDefault` */
sans: 'system-ui',
/** iOS `UIFontDescriptorSystemDesignSerif` */
serif: 'ui-serif',
/** iOS `UIFontDescriptorSystemDesignRounded` */
rounded: 'ui-rounded',
/** iOS `UIFontDescriptorSystemDesignMonospaced` */
mono: 'ui-monospace',
},
default: {
sans: 'normal',
serif: 'serif',
rounded: 'normal',
mono: 'monospace',
},
web: {
sans: 'var(--font-display)',
serif: 'var(--font-serif)',
rounded: 'var(--font-rounded)',
mono: 'var(--font-mono)',
},
});
export const Spacing = {
half: 2,
one: 4,
two: 8,
three: 16,
four: 24,
five: 32,
six: 64,
} as const;
export const BottomTabInset = Platform.select({ ios: 50, android: 80 }) ?? 0;
export const MaxContentWidth = 800;

View File

@@ -0,0 +1,9 @@
:root {
--font-display:
Spline Sans, Inter, ui-sans-serif, system-ui, sans-serif, Apple Color Emoji, Segoe UI Emoji,
Segoe UI Symbol, Noto Color Emoji;
--font-mono:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace;
--font-rounded: 'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif;
--font-serif: Georgia, 'Times New Roman', serif;
}

View File

@@ -0,0 +1 @@
export { useColorScheme } from 'react-native';

View File

@@ -0,0 +1,29 @@
import { useSyncExternalStore } from "react"
import { useColorScheme as useRNColorScheme } from "react-native"
function subscribe() {
return () => {}
}
function getSnapshot() {
return true
}
function getServerSnapshot() {
return false
}
/**
* To support static rendering, this value needs to be re-calculated on the client side for web
*/
export function useColorScheme() {
const hasHydrated = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
const colorScheme = useRNColorScheme()
if (hasHydrated) {
return colorScheme
}
return "light"
}

View File

@@ -0,0 +1,849 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type Dispatch,
type MutableRefObject,
type SetStateAction,
} from "react"
import { AppState, Platform, type AppStateStatus } from "react-native"
import * as Haptics from "expo-haptics"
import * as Notifications from "expo-notifications"
import Constants from "expo-constants"
import { fetch as expoFetch } from "expo/fetch"
import {
classifyMonitorEvent,
extractSessionID,
formatMonitorEventLabel,
type OpenCodeEvent,
type MonitorEventType,
} from "@/lib/opencode-events"
import {
parsePendingPermissionRequest,
parsePendingPermissionRequests,
type PendingPermissionRequest,
} from "@/lib/pending-permissions"
import { registerRelayDevice, unregisterRelayDevice } from "@/lib/relay-client"
import { parseSSEStream } from "@/lib/sse"
import { getDevicePushToken, onPushTokenChange } from "@/notifications/monitoring-notifications"
import type { ServerItem } from "@/hooks/use-server-sessions"
export type MonitorJob = {
id: string
sessionID: string
opencodeBaseURL: string
startedAt: number
}
export type PermissionDecision = "once" | "always" | "reject"
type SessionRuntimeStatus = "idle" | "busy" | "retry"
type PermissionPromptState = "idle" | "pending" | "granted" | "denied"
type NotificationPayload = {
serverID: string | null
eventType: MonitorEventType | null
sessionID: string | null
}
type CuePlayer = {
seekTo: (position: number) => unknown
play: () => unknown
}
type UseMonitoringOptions = {
completePlayer: CuePlayer
closeDropdown: () => void
findServerForSession: (sessionID: string, preferredServerID?: string | null) => Promise<ServerItem | null>
refreshServerStatusAndSessions: (serverID: string, includeSessions?: boolean) => Promise<void>
servers: ServerItem[]
serversRef: MutableRefObject<ServerItem[]>
restoredRef: MutableRefObject<boolean>
activeServerId: string | null
activeSessionId: string | null
activeServerIdRef: MutableRefObject<string | null>
activeSessionIdRef: MutableRefObject<string | null>
setActiveServerId: Dispatch<SetStateAction<string | null>>
setActiveSessionId: Dispatch<SetStateAction<string | null>>
setAgentStateDismissed: Dispatch<SetStateAction<boolean>>
setNotificationPermissionState: Dispatch<SetStateAction<PermissionPromptState>>
}
function parseMonitorEventType(value: unknown): MonitorEventType | null {
if (value === "complete" || value === "permission" || value === "error") {
return value
}
return null
}
function parseNotificationPayload(data: unknown): NotificationPayload | null {
if (!data || typeof data !== "object") return null
const serverIDRaw = (data as { serverID?: unknown }).serverID
const serverID = typeof serverIDRaw === "string" && serverIDRaw.length > 0 ? serverIDRaw : null
const eventType = parseMonitorEventType((data as { eventType?: unknown }).eventType)
const sessionIDRaw = (data as { sessionID?: unknown }).sessionID
const sessionID = typeof sessionIDRaw === "string" && sessionIDRaw.length > 0 ? sessionIDRaw : null
if (!eventType && !sessionID && !serverID) return null
return {
serverID,
eventType,
sessionID,
}
}
export function useMonitoring({
completePlayer,
closeDropdown,
findServerForSession,
refreshServerStatusAndSessions,
servers,
serversRef,
restoredRef,
activeServerId,
activeSessionId,
activeServerIdRef,
activeSessionIdRef,
setActiveServerId,
setActiveSessionId,
setAgentStateDismissed,
setNotificationPermissionState,
}: UseMonitoringOptions) {
const [devicePushToken, setDevicePushToken] = useState<string | null>(null)
const [monitorJob, setMonitorJob] = useState<MonitorJob | null>(null)
const [monitorStatus, setMonitorStatus] = useState("")
const [latestAssistantResponse, setLatestAssistantResponse] = useState("")
const [pendingPermissions, setPendingPermissions] = useState<PendingPermissionRequest[]>([])
const [replyingPermissionID, setReplyingPermissionID] = useState<string | null>(null)
const [appState, setAppState] = useState<AppStateStatus>(AppState.currentState)
const foregroundMonitorAbortRef = useRef<AbortController | null>(null)
const monitorJobRef = useRef<MonitorJob | null>(null)
const pendingNotificationEventsRef = useRef<{ payload: NotificationPayload; source: "received" | "response" }[]>([])
const notificationHandlerRef = useRef<(payload: NotificationPayload, source: "received" | "response") => void>(
(payload, source) => {
pendingNotificationEventsRef.current.push({ payload, source })
},
)
const previousPushTokenRef = useRef<string | null>(null)
const previousAppStateRef = useRef<AppStateStatus>(AppState.currentState)
const latestAssistantRequestRef = useRef(0)
const latestPermissionRequestRef = useRef(0)
const upsertPendingPermission = useCallback(
(request: PendingPermissionRequest) => {
setPendingPermissions((current) => {
const next = current.filter((item) => item.id !== request.id)
return [request, ...next]
})
closeDropdown()
setAgentStateDismissed(false)
},
[closeDropdown, setAgentStateDismissed],
)
useEffect(() => {
monitorJobRef.current = monitorJob
}, [monitorJob])
useEffect(() => {
const sub = AppState.addEventListener("change", (nextState) => {
setAppState(nextState)
})
return () => sub.remove()
}, [])
useEffect(() => {
let active = true
void (async () => {
try {
if (Platform.OS !== "ios") return
const existing = await Notifications.getPermissionsAsync()
const granted = Boolean((existing as { granted?: unknown }).granted)
if (active) {
setNotificationPermissionState(granted ? "granted" : "idle")
}
if (!granted) return
const token = await getDevicePushToken()
if (token) {
setDevicePushToken(token)
}
} catch {
// Non-fatal: monitoring can still work in-app via foreground SSE.
}
})()
const sub = onPushTokenChange((token) => {
if (!active) return
setDevicePushToken(token)
})
return () => {
active = false
sub.remove()
}
}, [setNotificationPermissionState])
useEffect(() => {
const notificationSub = Notifications.addNotificationReceivedListener((notification: unknown) => {
const data = (notification as { request?: { content?: { data?: unknown } } }).request?.content?.data
const payload = parseNotificationPayload(data)
if (!payload) return
notificationHandlerRef.current(payload, "received")
})
const responseSub = Notifications.addNotificationResponseReceivedListener((response: unknown) => {
const data = (response as { notification?: { request?: { content?: { data?: unknown } } } }).notification?.request
?.content?.data
const payload = parseNotificationPayload(data)
if (!payload) return
notificationHandlerRef.current(payload, "response")
})
void Notifications.getLastNotificationResponseAsync()
.then((response) => {
if (!response) return
const data = (response as { notification?: { request?: { content?: { data?: unknown } } } }).notification
?.request?.content?.data
const payload = parseNotificationPayload(data)
if (!payload) return
notificationHandlerRef.current(payload, "response")
})
.catch(() => {})
return () => {
notificationSub.remove()
responseSub.remove()
}
}, [])
const stopForegroundMonitor = useCallback(() => {
const aborter = foregroundMonitorAbortRef.current
if (aborter) {
aborter.abort()
foregroundMonitorAbortRef.current = null
}
}, [])
const loadLatestAssistantResponse = useCallback(
async (baseURL: string, sessionID: string) => {
const requestID = latestAssistantRequestRef.current + 1
latestAssistantRequestRef.current = requestID
const base = baseURL.replace(/\/+$/, "")
try {
const response = await fetch(`${base}/session/${sessionID}/message?limit=60`)
if (!response.ok) {
throw new Error(`Session messages failed (${response.status})`)
}
const payload = (await response.json()) as unknown
const text = findLatestAssistantCompletionText(payload)
if (latestAssistantRequestRef.current !== requestID) return
if (activeSessionIdRef.current !== sessionID) return
setLatestAssistantResponse(text)
if (text) {
setAgentStateDismissed(false)
}
} catch {
if (latestAssistantRequestRef.current !== requestID) return
if (activeSessionIdRef.current !== sessionID) return
setLatestAssistantResponse("")
}
},
[activeSessionIdRef, setAgentStateDismissed],
)
const loadPendingPermissions = useCallback(
async (baseURL: string, sessionID: string) => {
const requestID = latestPermissionRequestRef.current + 1
latestPermissionRequestRef.current = requestID
const base = baseURL.replace(/\/+$/, "")
try {
const response = await fetch(`${base}/permission`)
if (!response.ok) {
throw new Error(`Permission list failed (${response.status})`)
}
const payload = (await response.json()) as unknown
const requests = parsePendingPermissionRequests(payload).filter((item) => item.sessionID === sessionID)
if (latestPermissionRequestRef.current !== requestID) return
if (activeSessionIdRef.current !== sessionID) return
setPendingPermissions(requests)
if (requests.length > 0) {
closeDropdown()
setAgentStateDismissed(false)
}
} catch {
if (latestPermissionRequestRef.current !== requestID) return
if (activeSessionIdRef.current !== sessionID) return
}
},
[activeSessionIdRef, closeDropdown, setAgentStateDismissed],
)
const fetchSessionRuntimeStatus = useCallback(
async (baseURL: string, sessionID: string): Promise<SessionRuntimeStatus | null> => {
const base = baseURL.replace(/\/+$/, "")
try {
const response = await fetch(`${base}/session/status`)
if (!response.ok) {
throw new Error(`Session status failed (${response.status})`)
}
const payload = (await response.json()) as unknown
if (!payload || typeof payload !== "object") return null
const status = (payload as Record<string, unknown>)[sessionID]
if (!status || typeof status !== "object") return "idle"
const type = (status as { type?: unknown }).type
if (type === "busy" || type === "retry" || type === "idle") {
return type
}
return null
} catch {
return null
}
},
[],
)
const handleMonitorEvent = useCallback(
(eventType: MonitorEventType, job: MonitorJob) => {
setMonitorStatus(formatMonitorEventLabel(eventType))
if (eventType === "permission") {
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning).catch(() => {})
void loadPendingPermissions(job.opencodeBaseURL, job.sessionID)
return
}
if (eventType === "complete") {
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {})
void completePlayer.seekTo(0)
void completePlayer.play()
stopForegroundMonitor()
setMonitorJob(null)
void loadLatestAssistantResponse(job.opencodeBaseURL, job.sessionID)
return
}
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {})
stopForegroundMonitor()
setMonitorJob(null)
},
[completePlayer, loadLatestAssistantResponse, loadPendingPermissions, stopForegroundMonitor],
)
const startForegroundMonitor = useCallback(
(job: MonitorJob) => {
stopForegroundMonitor()
const abortController = new AbortController()
foregroundMonitorAbortRef.current = abortController
const base = job.opencodeBaseURL.replace(/\/+$/, "")
void (async () => {
try {
const response = await expoFetch(`${base}/event`, {
signal: abortController.signal,
headers: {
Accept: "text/event-stream",
"Cache-Control": "no-cache",
},
})
if (!response.ok || !response.body) {
throw new Error(`SSE monitor failed (${response.status})`)
}
for await (const message of parseSSEStream(response.body)) {
let parsed: OpenCodeEvent | null = null
try {
parsed = JSON.parse(message.data) as OpenCodeEvent
} catch {
continue
}
if (!parsed) continue
const sessionID = extractSessionID(parsed)
if (sessionID !== job.sessionID) continue
if (parsed.type === "permission.asked") {
const request = parsePendingPermissionRequest(parsed.properties)
if (request) {
upsertPendingPermission(request)
}
}
const eventType = classifyMonitorEvent(parsed)
if (!eventType) continue
const active = monitorJobRef.current
if (!active || active.id !== job.id) return
handleMonitorEvent(eventType, job)
}
} catch {
if (abortController.signal.aborted) return
}
})()
},
[handleMonitorEvent, stopForegroundMonitor, upsertPendingPermission],
)
const beginMonitoring = useCallback(
async (job: MonitorJob) => {
setMonitorJob(job)
setMonitorStatus("Monitoring…")
startForegroundMonitor(job)
},
[startForegroundMonitor],
)
useEffect(() => {
const active = monitorJobRef.current
if (!active) return
if (appState === "active") {
startForegroundMonitor(active)
return
}
stopForegroundMonitor()
}, [appState, startForegroundMonitor, stopForegroundMonitor])
useEffect(() => {
const active = monitorJobRef.current
if (!active) return
if (activeSessionId === active.sessionID) return
stopForegroundMonitor()
setMonitorJob(null)
setMonitorStatus("")
}, [activeSessionId, stopForegroundMonitor])
useEffect(() => {
setLatestAssistantResponse("")
setPendingPermissions([])
setAgentStateDismissed(false)
if (!activeServerId || !activeSessionId) return
const server = serversRef.current.find((item) => item.id === activeServerId)
if (!server || server.status !== "online") return
void loadLatestAssistantResponse(server.url, activeSessionId)
void loadPendingPermissions(server.url, activeSessionId)
}, [
activeServerId,
activeSessionId,
loadLatestAssistantResponse,
loadPendingPermissions,
serversRef,
setAgentStateDismissed,
])
useEffect(() => {
return () => {
stopForegroundMonitor()
}
}, [stopForegroundMonitor])
const syncSessionState = useCallback(
async (input: { serverID: string; sessionID: string; preserveStatusLabel?: boolean }) => {
await refreshServerStatusAndSessions(input.serverID)
const server = serversRef.current.find((item) => item.id === input.serverID)
if (!server || server.status !== "online") return
const runtimeStatus = await fetchSessionRuntimeStatus(server.url, input.sessionID)
await loadLatestAssistantResponse(server.url, input.sessionID)
await loadPendingPermissions(server.url, input.sessionID)
if (runtimeStatus === "busy" || runtimeStatus === "retry") {
const nextJob: MonitorJob = {
id: `job-resume-${Date.now()}`,
sessionID: input.sessionID,
opencodeBaseURL: server.url.replace(/\/+$/, ""),
startedAt: Date.now(),
}
setMonitorJob(nextJob)
setMonitorStatus("Monitoring…")
if (appState === "active") {
startForegroundMonitor(nextJob)
}
return
}
if (runtimeStatus === "idle") {
stopForegroundMonitor()
setMonitorJob(null)
if (!input.preserveStatusLabel) {
setMonitorStatus("")
}
}
},
[
appState,
fetchSessionRuntimeStatus,
loadLatestAssistantResponse,
loadPendingPermissions,
refreshServerStatusAndSessions,
serversRef,
startForegroundMonitor,
stopForegroundMonitor,
],
)
const handleNotificationPayload = useCallback(
async (payload: NotificationPayload, source: "received" | "response") => {
const activeServer = activeServerIdRef.current
? serversRef.current.find((server) => server.id === activeServerIdRef.current)
: null
const matchesActiveSession =
!!payload.sessionID &&
activeSessionIdRef.current === payload.sessionID &&
(!payload.serverID || activeServer?.serverID === payload.serverID)
if (payload.eventType && (source === "response" || matchesActiveSession || !payload.sessionID)) {
setMonitorStatus(formatMonitorEventLabel(payload.eventType))
}
if (payload.eventType === "complete" && source === "received") {
void completePlayer.seekTo(0)
void completePlayer.play()
}
if (
(payload.eventType === "complete" || payload.eventType === "error") &&
(source === "response" || matchesActiveSession)
) {
stopForegroundMonitor()
setMonitorJob(null)
}
if (!payload.sessionID) return
if (source === "response") {
const matched = await findServerForSession(payload.sessionID, payload.serverID)
if (!matched) {
console.log("[Notification] open:session-not-found", {
serverID: payload.serverID,
sessionID: payload.sessionID,
eventType: payload.eventType,
})
return
}
activeServerIdRef.current = matched.id
activeSessionIdRef.current = payload.sessionID
setActiveServerId(matched.id)
setActiveSessionId(payload.sessionID)
closeDropdown()
setAgentStateDismissed(false)
await syncSessionState({
serverID: matched.id,
sessionID: payload.sessionID,
preserveStatusLabel: Boolean(payload.eventType),
})
return
}
if (!matchesActiveSession) return
const activeServerID = activeServerIdRef.current
if (!activeServerID) return
await syncSessionState({
serverID: activeServerID,
sessionID: payload.sessionID,
preserveStatusLabel: Boolean(payload.eventType),
})
},
[
activeServerIdRef,
activeSessionIdRef,
closeDropdown,
completePlayer,
findServerForSession,
serversRef,
setActiveServerId,
setActiveSessionId,
setAgentStateDismissed,
stopForegroundMonitor,
syncSessionState,
],
)
useEffect(() => {
notificationHandlerRef.current = (payload, source) => {
void handleNotificationPayload(payload, source)
}
if (!pendingNotificationEventsRef.current.length) return
const queued = [...pendingNotificationEventsRef.current]
pendingNotificationEventsRef.current = []
queued.forEach(({ payload, source }) => {
void handleNotificationPayload(payload, source)
})
}, [handleNotificationPayload])
useEffect(() => {
const previous = previousAppStateRef.current
previousAppStateRef.current = appState
if (appState !== "active" || previous === "active") return
const serverID = activeServerIdRef.current
const sessionID = activeSessionIdRef.current
if (!serverID || !sessionID) return
void syncSessionState({ serverID, sessionID })
}, [activeServerIdRef, activeSessionIdRef, appState, syncSessionState])
const respondToPermission = useCallback(
async (input: { serverID: string; sessionID: string; requestID: string; reply: PermissionDecision }) => {
const server = serversRef.current.find((item) => item.id === input.serverID)
if (!server) {
throw new Error("Server unavailable")
}
const base = server.url.replace(/\/+$/, "")
setReplyingPermissionID(input.requestID)
setMonitorStatus(input.reply === "reject" ? "Rejecting request…" : "Sending approval…")
let removed: PendingPermissionRequest | undefined
setPendingPermissions((current) => {
removed = current.find((item) => item.id === input.requestID)
return current.filter((item) => item.id !== input.requestID)
})
try {
const response = await fetch(`${base}/permission/${input.requestID}/reply`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ reply: input.reply }),
})
if (!response.ok) {
throw new Error(`Permission reply failed (${response.status})`)
}
await syncSessionState({
serverID: input.serverID,
sessionID: input.sessionID,
})
} catch (error) {
if (removed) {
setPendingPermissions((current) => {
const restored = removed
if (!restored) {
return current
}
if (current.some((item) => item.id === restored.id)) {
return current
}
return [restored, ...current]
})
}
throw error
} finally {
setReplyingPermissionID((current) => (current === input.requestID ? null : current))
}
},
[serversRef, syncSessionState],
)
const activePermissionRequest = pendingPermissions[0] ?? null
const relayServersKey = useMemo(
() =>
servers
.filter((server) => server.relaySecret.trim().length > 0)
.map((server) => `${server.id}:${server.relayURL}:${server.relaySecret.trim()}`)
.join("|"),
[servers],
)
useEffect(() => {
if (Platform.OS !== "ios") return
if (!devicePushToken) return
const list = serversRef.current.filter((server) => server.relaySecret.trim().length > 0)
if (!list.length) return
const bundleId = Constants.expoConfig?.ios?.bundleIdentifier ?? "com.anomalyco.mobilevoice"
const apnsEnv = "production"
console.log("[Relay] env", {
dev: __DEV__,
node: process.env.NODE_ENV,
apnsEnv,
})
console.log("[Relay] register:batch", {
tokenSuffix: devicePushToken.slice(-8),
count: list.length,
apnsEnv,
bundleId,
})
void Promise.allSettled(
list.map(async (server) => {
const secret = server.relaySecret.trim()
const relay = server.relayURL
console.log("[Relay] register:start", {
id: server.id,
relay,
tokenSuffix: devicePushToken.slice(-8),
secretLength: secret.length,
})
try {
await registerRelayDevice({
relayBaseURL: relay,
secret,
deviceToken: devicePushToken,
bundleId,
apnsEnv,
})
console.log("[Relay] register:ok", { id: server.id, relay })
} catch (err) {
console.log("[Relay] register:error", {
id: server.id,
relay,
error: err instanceof Error ? err.message : String(err),
})
}
}),
).catch(() => {})
}, [devicePushToken, relayServersKey, serversRef])
useEffect(() => {
if (Platform.OS !== "ios") return
if (!devicePushToken) return
const previous = previousPushTokenRef.current
previousPushTokenRef.current = devicePushToken
if (!previous || previous === devicePushToken) return
const list = serversRef.current.filter((server) => server.relaySecret.trim().length > 0)
if (!list.length) return
console.log("[Relay] unregister:batch", {
previousSuffix: previous.slice(-8),
nextSuffix: devicePushToken.slice(-8),
count: list.length,
})
void Promise.allSettled(
list.map(async (server) => {
const secret = server.relaySecret.trim()
const relay = server.relayURL
console.log("[Relay] unregister:start", {
id: server.id,
relay,
tokenSuffix: previous.slice(-8),
secretLength: secret.length,
})
try {
await unregisterRelayDevice({
relayBaseURL: relay,
secret,
deviceToken: previous,
})
console.log("[Relay] unregister:ok", { id: server.id, relay })
} catch (err) {
console.log("[Relay] unregister:error", {
id: server.id,
relay,
error: err instanceof Error ? err.message : String(err),
})
}
}),
).catch(() => {})
}, [devicePushToken, relayServersKey, serversRef])
return {
devicePushToken,
setDevicePushToken,
monitorJob,
monitorStatus,
setMonitorStatus,
latestAssistantResponse,
activePermissionRequest,
pendingPermissionCount: pendingPermissions.length,
respondingPermissionID: replyingPermissionID,
respondToPermission,
beginMonitoring,
}
}
type SessionMessageInfo = {
role?: unknown
time?: unknown
}
type SessionMessagePart = {
type?: unknown
text?: unknown
}
type SessionMessagePayload = {
info?: unknown
parts?: unknown
}
function cleanTranscriptText(text: string): string {
return text.replace(/[ \t]+$/gm, "").trimEnd()
}
function cleanSessionText(text: string): string {
return cleanTranscriptText(text).trimStart()
}
function findLatestAssistantCompletionText(payload: unknown): string {
if (!Array.isArray(payload)) return ""
for (let index = payload.length - 1; index >= 0; index -= 1) {
const candidate = payload[index] as SessionMessagePayload
if (!candidate || typeof candidate !== "object") continue
const info = candidate.info as SessionMessageInfo
if (!info || typeof info !== "object") continue
if (info.role !== "assistant") continue
const time = info.time as { completed?: unknown } | undefined
if (!time || typeof time !== "object") continue
if (typeof time.completed !== "number") continue
const parts = Array.isArray(candidate.parts) ? (candidate.parts as SessionMessagePart[]) : []
const text = parts
.filter((part) => part && part.type === "text" && typeof part.text === "string")
.map((part) => cleanSessionText(part.text as string))
.filter((part) => part.length > 0)
.join("\n\n")
if (text.length > 0) {
return text
}
}
return ""
}

View File

@@ -0,0 +1,494 @@
import { useCallback, useEffect, useRef, useState } from "react"
import {
DEFAULT_RELAY_URL,
parseSessionItems,
persistServerState,
restoreServerState,
serverBases,
looksLikeLocalHost,
type ServerItem,
} from "@/lib/server-sessions"
export { DEFAULT_RELAY_URL, looksLikeLocalHost, type ServerItem, type SessionItem } from "@/lib/server-sessions"
export function useServerSessions() {
const [servers, setServers] = useState<ServerItem[]>([])
const [activeServerId, setActiveServerId] = useState<string | null>(null)
const [activeSessionId, setActiveSessionId] = useState<string | null>(null)
const serversRef = useRef<ServerItem[]>([])
const restoredRef = useRef(false)
const refreshSeqRef = useRef<Record<string, number>>({})
const activeServerIdRef = useRef<string | null>(null)
const activeSessionIdRef = useRef<string | null>(null)
useEffect(() => {
serversRef.current = servers
}, [servers])
useEffect(() => {
activeServerIdRef.current = activeServerId
}, [activeServerId])
useEffect(() => {
activeSessionIdRef.current = activeSessionId
}, [activeSessionId])
useEffect(() => {
let mounted = true
void (async () => {
try {
const next = await restoreServerState()
if (!mounted || !next) return
setServers(next.servers)
setActiveServerId(next.activeServerId)
setActiveSessionId(next.activeSessionId)
console.log("[Server] restore", {
count: next.servers.length,
activeServerId: next.activeServerId,
})
} finally {
restoredRef.current = true
}
})()
return () => {
mounted = false
}
}, [])
useEffect(() => {
if (!restoredRef.current) return
void persistServerState(servers, activeServerId, activeSessionId).catch(() => {})
}, [activeServerId, activeSessionId, servers])
const refreshServerStatusAndSessions = useCallback(async (serverID: string, includeSessions = true) => {
const server = serversRef.current.find((item) => item.id === serverID)
if (!server) return
const req = (refreshSeqRef.current[serverID] ?? 0) + 1
refreshSeqRef.current[serverID] = req
const current = () => refreshSeqRef.current[serverID] === req
const candidates = serverBases(server.url)
const base = candidates[0] ?? server.url.replace(/\/+$/, "")
const healthURL = `${base}/health`
const sessionsURL = `${base}/experimental/session?limit=100`
let insecureRemote = false
try {
const parsedBase = new URL(base)
insecureRemote = parsedBase.protocol === "http:" && !looksLikeLocalHost(parsedBase.hostname)
} catch {
insecureRemote = base.startsWith("http://")
}
console.log("[Server] refresh:start", {
id: server.id,
name: server.name,
base,
healthURL,
sessionsURL,
includeSessions,
})
setServers((prev) =>
prev.map((item) => (item.id === serverID && includeSessions ? { ...item, sessionsLoading: true } : item)),
)
let activeBase = base
try {
let healthRes: Response | null = null
let healthErr: unknown
for (const item of candidates) {
const url = `${item}/health`
try {
const next = await fetch(url)
if (next.ok) {
healthRes = next
activeBase = item
if (item !== server.url.replace(/\/+$/, "") && current()) {
setServers((prev) => prev.map((entry) => (entry.id === serverID ? { ...entry, url: item } : entry)))
console.log("[Server] refresh:scheme-upgrade", {
id: server.id,
from: server.url,
to: item,
})
}
break
}
healthRes = next
activeBase = item
} catch (err) {
healthErr = err
console.log("[Server] health:attempt-error", {
id: server.id,
url,
error: err instanceof Error ? `${err.name}: ${err.message}` : String(err),
})
}
}
const online = !!healthRes?.ok
if (!current()) {
console.log("[Server] refresh:stale-skip", { id: server.id, req })
return
}
console.log("[Server] health", {
id: server.id,
base: activeBase,
url: `${activeBase}/health`,
status: healthRes?.status ?? "fetch_error",
online,
})
if (!online) {
setServers((prev) =>
prev.map((item) =>
item.id === serverID ? { ...item, status: "offline", sessionsLoading: false, sessions: [] } : item,
),
)
console.log("[Server] refresh:offline", {
id: server.id,
base,
candidates,
error: healthErr instanceof Error ? `${healthErr.name}: ${healthErr.message}` : String(healthErr),
})
return
}
if (!includeSessions) {
setServers((prev) =>
prev.map((item) => (item.id === serverID ? { ...item, status: "online", sessionsLoading: false } : item)),
)
console.log("[Server] refresh:online", { id: server.id, base })
return
}
const resolvedSessionsURL = `${activeBase}/experimental/session?limit=100`
const sessionsRes = await fetch(resolvedSessionsURL)
if (!current()) {
console.log("[Server] refresh:stale-skip", { id: server.id, req })
return
}
if (!sessionsRes.ok) {
console.log("[Server] sessions:http-error", {
id: server.id,
url: resolvedSessionsURL,
status: sessionsRes.status,
})
}
const json = sessionsRes.ok ? await sessionsRes.json() : []
const sessions = parseSessionItems(json)
setServers((prev) =>
prev.map((item) =>
item.id === serverID ? { ...item, status: "online", sessionsLoading: false, sessions } : item,
),
)
console.log("[Server] sessions", { id: server.id, count: sessions.length })
} catch (err) {
if (!current()) {
console.log("[Server] refresh:stale-skip", { id: server.id, req })
return
}
setServers((prev) =>
prev.map((item) =>
item.id === serverID ? { ...item, status: "offline", sessionsLoading: false, sessions: [] } : item,
),
)
console.log("[Server] refresh:error", {
id: server.id,
base,
healthURL,
sessionsURL,
candidates,
insecureRemote,
error: err instanceof Error ? `${err.name}: ${err.message}` : String(err),
})
if (insecureRemote) {
console.log("[Server] refresh:hint", {
id: server.id,
message: "Remote http:// host may be blocked by iOS ATS; prefer https:// for non-local hosts.",
})
}
}
}, [])
const refreshAllServerHealth = useCallback(() => {
const ids = serversRef.current.map((item) => item.id)
ids.forEach((id) => {
void refreshServerStatusAndSessions(id, false)
})
}, [refreshServerStatusAndSessions])
const selectServer = useCallback((id: string) => {
setActiveServerId(id)
setActiveSessionId(null)
}, [])
const selectSession = useCallback((id: string) => {
setActiveSessionId(id)
}, [])
const removeServer = useCallback((id: string) => {
setServers((prev) => prev.filter((item) => item.id !== id))
setActiveServerId((prev) => (prev === id ? null : prev))
if (activeServerIdRef.current === id) {
setActiveSessionId(null)
}
}, [])
const addServer = useCallback(
(serverURL: string, relayURL: string, relaySecretRaw: string, serverIDRaw?: string) => {
const raw = serverURL.trim()
if (!raw) return false
const normalized = raw.startsWith("http://") || raw.startsWith("https://") ? raw : `http://${raw}`
const rawRelay = relayURL.trim()
const relayNormalizedRaw = rawRelay.length > 0 ? rawRelay : DEFAULT_RELAY_URL
const normalizedRelay =
relayNormalizedRaw.startsWith("http://") || relayNormalizedRaw.startsWith("https://")
? relayNormalizedRaw
: `http://${relayNormalizedRaw}`
let parsed: URL
let relayParsed: URL
try {
parsed = new URL(normalized)
relayParsed = new URL(normalizedRelay)
} catch {
return false
}
const id = `srv-${Date.now()}`
const relaySecret = relaySecretRaw.trim()
const serverID = typeof serverIDRaw === "string" && serverIDRaw.length > 0 ? serverIDRaw : null
const url = `${parsed.protocol}//${parsed.host}`
const inferredName =
parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost" ? "Local OpenCode" : parsed.hostname
const relay = `${relayParsed.protocol}//${relayParsed.host}`
const existing = serversRef.current.find(
(item) =>
item.url === url &&
item.relayURL === relay &&
item.relaySecret.trim() === relaySecret &&
(!serverID || item.serverID === serverID || item.serverID === null),
)
if (existing) {
if (serverID && existing.serverID !== serverID) {
setServers((prev) =>
prev.map((item) => (item.id === existing.id ? { ...item, serverID: serverID ?? item.serverID } : item)),
)
}
setActiveServerId(existing.id)
setActiveSessionId(null)
void refreshServerStatusAndSessions(existing.id)
return true
}
setServers((prev) => [
...prev,
{
id,
name: inferredName,
url,
serverID,
relayURL: relay,
relaySecret,
status: "offline",
sessions: [],
sessionsLoading: false,
},
])
setActiveServerId(id)
setActiveSessionId(null)
void refreshServerStatusAndSessions(id)
return true
},
[refreshServerStatusAndSessions],
)
const createSession = useCallback(
async (
serverID: string,
options?: {
directory?: string
workspaceID?: string
title?: string
},
) => {
const server = serversRef.current.find((item) => item.id === serverID)
if (!server) {
return null
}
const base = server.url.replace(/\/+$/, "")
const params = new URLSearchParams()
const directory = options?.directory?.trim()
const workspaceID = options?.workspaceID?.trim()
const title = options?.title?.trim()
if (directory) {
params.set("directory", directory)
}
const body: {
workspaceID?: string
title?: string
} = {}
if (workspaceID) {
body.workspaceID = workspaceID
}
if (title) {
body.title = title
}
const query = params.toString()
const endpoint = `${base}/session${query ? `?${query}` : ""}`
try {
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
})
if (!response.ok) {
console.log("[Server] session:create:http-error", {
id: server.id,
endpoint,
status: response.status,
})
return null
}
const payload = (await response.json()) as unknown
const parsed = parseSessionItems([payload])[0]
if (!parsed) {
void refreshServerStatusAndSessions(serverID)
return null
}
const created = parsed.updated > 0 ? parsed : { ...parsed, updated: Date.now() }
setServers((prev) =>
prev.map((item) => {
if (item.id !== serverID) return item
const sessions = [created, ...item.sessions.filter((session) => session.id !== created.id)].sort(
(a, b) => b.updated - a.updated,
)
return {
...item,
status: "online",
sessionsLoading: false,
sessions,
}
}),
)
setActiveServerId(serverID)
setActiveSessionId(created.id)
console.log("[Server] session:create", {
id: server.id,
sessionID: created.id,
hasDirectory: Boolean(created.directory),
hasWorkspaceID: Boolean(created.workspaceID),
})
return created
} catch (err) {
console.log("[Server] session:create:error", {
id: server.id,
endpoint,
error: err instanceof Error ? `${err.name}: ${err.message}` : String(err),
})
return null
}
},
[refreshServerStatusAndSessions],
)
const findServerForSession = useCallback(
async (sessionID: string, preferredServerID?: string | null): Promise<ServerItem | null> => {
if (!serversRef.current.length && !restoredRef.current) {
for (let attempt = 0; attempt < 20; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 150))
if (serversRef.current.length > 0 || restoredRef.current) {
break
}
}
}
if (preferredServerID) {
const preferred = serversRef.current.find((server) => server.serverID === preferredServerID)
if (preferred?.sessions.some((session) => session.id === sessionID)) {
return preferred
}
if (preferred) {
await refreshServerStatusAndSessions(preferred.id)
const refreshed = serversRef.current.find((server) => server.id === preferred.id)
if (refreshed?.sessions.some((session) => session.id === sessionID)) {
return refreshed
}
}
}
const direct = serversRef.current.find((server) => server.sessions.some((session) => session.id === sessionID))
if (direct) return direct
const ids = serversRef.current.map((server) => server.id)
for (const id of ids) {
await refreshServerStatusAndSessions(id)
const matched = serversRef.current.find(
(server) => server.id === id && server.sessions.some((session) => session.id === sessionID),
)
if (matched) {
return matched
}
}
return null
},
[refreshServerStatusAndSessions],
)
return {
servers,
setServers,
serversRef,
activeServerId,
setActiveServerId,
activeServerIdRef,
activeSessionId,
setActiveSessionId,
activeSessionIdRef,
restoredRef,
refreshServerStatusAndSessions,
refreshAllServerHealth,
selectServer,
selectSession,
removeServer,
addServer,
createSession,
findServerForSession,
}
}

View File

@@ -0,0 +1,14 @@
/**
* Learn more about light and dark modes:
* https://docs.expo.dev/guides/color-schemes/
*/
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
export function useTheme() {
const scheme = useColorScheme();
const theme = scheme === 'unspecified' ? 'light' : scheme;
return Colors[theme];
}

View File

@@ -0,0 +1,81 @@
export type OpenCodeEvent = {
type: string
properties?: Record<string, unknown>
}
export type MonitorEventType = "complete" | "permission" | "error"
export function extractSessionID(event: OpenCodeEvent): string | null {
const props = event.properties ?? {}
const fromDirect = props.sessionID
if (typeof fromDirect === "string" && fromDirect.length > 0) return fromDirect
const info = props.info
if (info && typeof info === "object") {
const infoSessionID = (info as Record<string, unknown>).sessionID
if (typeof infoSessionID === "string" && infoSessionID.length > 0) return infoSessionID
}
const part = props.part
if (part && typeof part === "object") {
const partSessionID = (part as Record<string, unknown>).sessionID
if (typeof partSessionID === "string" && partSessionID.length > 0) return partSessionID
}
return null
}
export function classifyMonitorEvent(event: OpenCodeEvent): MonitorEventType | null {
const type = event.type
const lowerType = type.toLowerCase()
if (lowerType === "permission.asked" || lowerType === "permission") {
return "permission"
}
if (lowerType.includes("error")) {
return "error"
}
if (type === "session.status") {
const status = event.properties?.status
if (status && typeof status === "object") {
const statusType = (status as Record<string, unknown>).type
if (statusType === "idle") {
return "complete"
}
}
}
if (type === "message.updated") {
const info = event.properties?.info
if (info && typeof info === "object") {
const role = (info as Record<string, unknown>).role
const time = (info as Record<string, unknown>).time
if (
role === "assistant" &&
time &&
typeof time === "object" &&
"completed" in (time as Record<string, unknown>)
) {
return "complete"
}
}
}
return null
}
export function formatMonitorEventLabel(eventType: MonitorEventType): string {
switch (eventType) {
case "complete":
return "Session complete"
case "permission":
return "Action needed"
case "error":
return "Session error"
default:
return "Session update"
}
}

View File

@@ -0,0 +1,256 @@
export type PendingPermissionRequest = {
id: string
sessionID: string
permission: string
patterns: string[]
metadata: Record<string, unknown>
always: string[]
tool?: {
messageID: string
callID: string
}
}
export type PermissionCardSection = {
label: string
text: string
mono?: boolean
}
export type PermissionCardModel = {
eyebrow: string
title: string
body: string
sections: PermissionCardSection[]
}
function record(input: unknown): Record<string, unknown> | null {
if (!input || typeof input !== "object") return null
return input as Record<string, unknown>
}
function maybeString(input: unknown): string | undefined {
return typeof input === "string" && input.trim().length > 0 ? input : undefined
}
function stringList(input: unknown): string[] {
if (!Array.isArray(input)) return []
return input.filter((item): item is string => typeof item === "string" && item.length > 0)
}
function previewText(input: string, options?: { maxLines?: number; maxChars?: number }): string {
const maxLines = options?.maxLines ?? 18
const maxChars = options?.maxChars ?? 1200
const normalized = input.replace(/\r\n/g, "\n").trim()
if (!normalized) return ""
const lines = normalized.split("\n")
const sliced = lines.slice(0, maxLines)
let text = sliced.join("\n")
if (text.length > maxChars) {
text = `${text.slice(0, maxChars).trimEnd()}\n…`
} else if (lines.length > maxLines) {
text = `${text}\n…`
}
return text
}
function formatPath(input: string): string {
return input.replace(/\\/g, "/")
}
function permissionTool(input: unknown): PendingPermissionRequest["tool"] | undefined {
const value = record(input)
if (!value) return
const messageID = maybeString(value.messageID)
const callID = maybeString(value.callID)
if (!messageID || !callID) return
return {
messageID,
callID,
}
}
function parsePendingPermissionRequest(input: unknown): PendingPermissionRequest | null {
const value = record(input)
if (!value) return null
const id = maybeString(value.id)
const sessionID = maybeString(value.sessionID)
const permission = maybeString(value.permission)
if (!id || !sessionID || !permission) return null
return {
id,
sessionID,
permission,
patterns: stringList(value.patterns),
metadata: record(value.metadata) ?? {},
always: stringList(value.always),
tool: permissionTool(value.tool),
}
}
export { parsePendingPermissionRequest }
export function parsePendingPermissionRequests(payload: unknown): PendingPermissionRequest[] {
if (!Array.isArray(payload)) return []
return payload
.map((item) => parsePendingPermissionRequest(item))
.filter((item): item is PendingPermissionRequest => item !== null)
}
function firstPattern(request: PendingPermissionRequest): string | undefined {
return request.patterns.find((item) => item.trim().length > 0)
}
function externalDirectory(request: PendingPermissionRequest): string | undefined {
const fromMetadata = maybeString(request.metadata.parentDir) ?? maybeString(request.metadata.filepath)
if (fromMetadata) return fromMetadata
const pattern = firstPattern(request)
if (!pattern) return
return pattern.endsWith("/*") ? pattern.slice(0, -2) : pattern
}
function allowScopeSection(request: PendingPermissionRequest): PermissionCardSection | null {
if (request.always.length === 0) return null
if (
request.always.length === request.patterns.length &&
request.always.every((item, index) => item === request.patterns[index])
) {
return null
}
if (request.always.length === 1 && request.always[0] === "*") {
return {
label: "Always allow",
text: "Applies to all future requests of this permission until OpenCode restarts.",
}
}
return {
label: "Always allow scope",
text: previewText(request.always.join("\n"), { maxLines: 8, maxChars: 600 }),
mono: true,
}
}
export function buildPermissionCardModel(request: PendingPermissionRequest): PermissionCardModel {
const filepath = maybeString(request.metadata.filepath)
const diff = maybeString(request.metadata.diff)
const commandText = previewText(request.patterns.join("\n"), { maxLines: 6, maxChars: 700 })
const scope = allowScopeSection(request)
if (request.permission === "edit") {
const sections: PermissionCardSection[] = []
if (filepath) {
sections.push({ label: "File", text: formatPath(filepath), mono: true })
}
if (diff) {
sections.push({ label: "Diff preview", text: previewText(diff), mono: true })
}
if (scope) sections.push(scope)
return {
eyebrow: "EDIT",
title: "Allow file edit?",
body: "OpenCode wants to change a file in this session.",
sections,
}
}
if (request.permission === "bash") {
const sections: PermissionCardSection[] = []
if (commandText) {
sections.push({ label: "Command", text: commandText, mono: true })
}
if (scope) sections.push(scope)
return {
eyebrow: "BASH",
title: "Allow shell command?",
body: "OpenCode wants to run a shell command for this session.",
sections,
}
}
if (request.permission === "read") {
const sections: PermissionCardSection[] = []
const path = firstPattern(request)
if (path) {
sections.push({ label: "Path", text: formatPath(path), mono: true })
}
if (scope) sections.push(scope)
return {
eyebrow: "READ",
title: "Allow file read?",
body: "OpenCode wants to read a path from your machine.",
sections,
}
}
if (request.permission === "external_directory") {
const sections: PermissionCardSection[] = []
const dir = externalDirectory(request)
if (dir) {
sections.push({ label: "Directory", text: formatPath(dir), mono: true })
}
if (request.patterns.length > 0) {
sections.push({
label: "Patterns",
text: previewText(request.patterns.join("\n"), { maxLines: 8, maxChars: 600 }),
mono: true,
})
}
if (scope) sections.push(scope)
return {
eyebrow: "DIRECTORY",
title: "Allow external access?",
body: "OpenCode wants to work with files outside the current project directory.",
sections,
}
}
if (request.permission === "task") {
const sections: PermissionCardSection[] = []
if (request.patterns.length > 0) {
sections.push({
label: "Patterns",
text: previewText(request.patterns.join("\n"), { maxLines: 8, maxChars: 600 }),
mono: true,
})
}
if (scope) sections.push(scope)
return {
eyebrow: "TASK",
title: "Allow delegated task?",
body: "OpenCode wants to launch another task as part of this session.",
sections,
}
}
const sections: PermissionCardSection[] = []
if (request.patterns.length > 0) {
sections.push({
label: "Patterns",
text: previewText(request.patterns.join("\n"), { maxLines: 8, maxChars: 600 }),
mono: true,
})
}
if (scope) sections.push(scope)
return {
eyebrow: request.permission.toUpperCase(),
title: `Allow ${request.permission}?`,
body: "OpenCode needs your permission before it can continue this session.",
sections,
}
}

View File

@@ -0,0 +1,63 @@
export type RegisterDeviceInput = {
relayBaseURL: string
secret: string
deviceToken: string
bundleId?: string
apnsEnv?: "sandbox" | "production"
}
export type UnregisterDeviceInput = {
relayBaseURL: string
secret: string
deviceToken: string
}
function normalizeBase(url: string): string {
return url.replace(/\/+$/, "")
}
async function postRelay(path: string, relayBaseURL: string, body: Record<string, unknown>): Promise<void> {
const relay = normalizeBase(relayBaseURL)
const response = await fetch(`${relay}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
})
if (!response.ok) {
const text = await response.text()
throw new Error(`Relay request failed (${response.status}): ${text || response.statusText}`)
}
}
export async function registerRelayDevice(input: RegisterDeviceInput): Promise<void> {
await postRelay("/v1/device/register", input.relayBaseURL, {
secret: input.secret,
deviceToken: input.deviceToken,
bundleId: input.bundleId,
apnsEnv: input.apnsEnv,
})
}
export async function unregisterRelayDevice(input: UnregisterDeviceInput): Promise<void> {
await postRelay("/v1/device/unregister", input.relayBaseURL, {
secret: input.secret,
deviceToken: input.deviceToken,
})
}
export async function sendRelayTestEvent(input: {
relayBaseURL: string
secret: string
sessionID: string
}): Promise<void> {
await postRelay("/v1/event", input.relayBaseURL, {
secret: input.secret,
eventType: "permission",
sessionID: input.sessionID,
title: "APN relay test",
body: "If you can read this, APN relay registration is working.",
})
}

View File

@@ -0,0 +1,185 @@
import * as FileSystem from "expo-file-system/legacy"
export const DEFAULT_RELAY_URL = "https://apn.dev.opencode.ai"
const SERVER_STATE_FILE = `${FileSystem.documentDirectory}mobile-voice-servers.json`
export type SessionItem = {
id: string
title: string
updated: number
directory?: string
workspaceID?: string
projectID?: string
}
type ServerSessionPayload = {
id?: unknown
title?: unknown
directory?: unknown
workspaceID?: unknown
projectID?: unknown
time?: {
updated?: unknown
}
}
export type ServerItem = {
id: string
name: string
url: string
serverID: string | null
relayURL: string
relaySecret: string
status: "checking" | "online" | "offline"
sessions: SessionItem[]
sessionsLoading: boolean
}
type SavedServer = {
id: string
name: string
url: string
serverID: string | null
relayURL: string
relaySecret: string
}
type SavedState = {
servers: SavedServer[]
activeServerId: string | null
activeSessionId: string | null
}
export function parseSessionItems(payload: unknown): SessionItem[] {
if (!Array.isArray(payload)) return []
return payload
.filter((item): item is ServerSessionPayload => !!item && typeof item === "object")
.map((item) => {
const directory = typeof item.directory === "string" && item.directory.length > 0 ? item.directory : undefined
const workspaceID =
typeof item.workspaceID === "string" && item.workspaceID.length > 0 ? item.workspaceID : undefined
const projectID = typeof item.projectID === "string" && item.projectID.length > 0 ? item.projectID : undefined
return {
id: String(item.id ?? ""),
title: String(item.title ?? item.id ?? "Untitled session"),
updated: Number(item.time?.updated ?? 0),
directory,
workspaceID,
projectID,
}
})
.filter((item) => item.id.length > 0)
.sort((a, b) => b.updated - a.updated)
}
function isCarrierGradeNat(hostname: string): boolean {
const match = /^100\.(\d{1,3})\./.exec(hostname)
if (!match) return false
const octet = Number(match[1])
return octet >= 64 && octet <= 127
}
export function looksLikeLocalHost(hostname: string): boolean {
return (
hostname === "127.0.0.1" ||
hostname === "::1" ||
hostname === "localhost" ||
hostname.endsWith(".local") ||
hostname.startsWith("10.") ||
hostname.startsWith("192.168.") ||
/^172\.(1[6-9]|2\d|3[0-1])\./.test(hostname) ||
isCarrierGradeNat(hostname)
)
}
export function serverBases(input: string): string[] {
const base = input.replace(/\/+$/, "")
const list = [base]
try {
const url = new URL(base)
const local = looksLikeLocalHost(url.hostname)
const tailnet = url.hostname.endsWith(".ts.net")
const secure = `https://${url.host}`
const insecure = `http://${url.host}`
if (url.protocol === "http:" && !local) {
if (tailnet) {
list.unshift(secure)
} else {
list.push(secure)
}
} else if (url.protocol === "https:" && tailnet) {
list.push(insecure)
}
} catch {
// Keep original base only.
}
return [...new Set(list)]
}
function toSaved(servers: ServerItem[], activeServerId: string | null, activeSessionId: string | null): SavedState {
return {
servers: servers.map((item) => ({
id: item.id,
name: item.name,
url: item.url,
serverID: item.serverID,
relayURL: item.relayURL,
relaySecret: item.relaySecret,
})),
activeServerId,
activeSessionId,
}
}
function fromSaved(input: SavedState): {
servers: ServerItem[]
activeServerId: string | null
activeSessionId: string | null
} {
const servers = input.servers.map((item) => ({
id: item.id,
name: item.name,
url: item.url,
serverID: item.serverID ?? null,
relayURL: item.relayURL,
relaySecret: item.relaySecret,
status: "checking" as const,
sessions: [] as SessionItem[],
sessionsLoading: false,
}))
const hasActive = input.activeServerId && servers.some((item) => item.id === input.activeServerId)
const activeServerId = hasActive ? input.activeServerId : (servers[0]?.id ?? null)
return {
servers,
activeServerId,
activeSessionId: hasActive ? input.activeSessionId : null,
}
}
export async function restoreServerState(): Promise<{
servers: ServerItem[]
activeServerId: string | null
activeSessionId: string | null
} | null> {
try {
const data = await FileSystem.readAsStringAsync(SERVER_STATE_FILE)
if (!data) {
return null
}
return fromSaved(JSON.parse(data) as SavedState)
} catch {
return null
}
}
export function persistServerState(
servers: ServerItem[],
activeServerId: string | null,
activeSessionId: string | null,
): Promise<void> {
const payload = toSaved(servers, activeServerId, activeSessionId)
return FileSystem.writeAsStringAsync(SERVER_STATE_FILE, JSON.stringify(payload))
}

View File

@@ -0,0 +1,72 @@
export type SSEMessage = {
event?: string;
data: string;
id?: string;
};
function parseBlock(block: string): SSEMessage | null {
if (!block.trim()) return null;
const lines = block.split(/\r?\n/);
let event: string | undefined;
let id: string | undefined;
const dataLines: string[] = [];
for (const line of lines) {
if (!line || line.startsWith(':')) continue;
const sep = line.indexOf(':');
const field = sep === -1 ? line : line.slice(0, sep);
const value = sep === -1 ? '' : line.slice(sep + 1).replace(/^\s/, '');
if (field === 'event') {
event = value;
continue;
}
if (field === 'id') {
id = value;
continue;
}
if (field === 'data') {
dataLines.push(value);
}
}
if (dataLines.length === 0) return null;
return {
event,
id,
data: dataLines.join('\n'),
};
}
export async function* parseSSEStream(stream: ReadableStream<Uint8Array>): AsyncGenerator<SSEMessage> {
const reader = stream.getReader();
const decoder = new TextDecoder();
let pending = '';
try {
while (true) {
const result = await reader.read();
if (result.done) break;
pending += decoder.decode(result.value, { stream: true });
const blocks = pending.split(/\r?\n\r?\n/);
pending = blocks.pop() ?? '';
for (const block of blocks) {
const parsed = parseBlock(block);
if (parsed) yield parsed;
}
}
pending += decoder.decode();
const tail = parseBlock(pending);
if (tail) yield tail;
} finally {
reader.releaseLock();
}
}

View File

@@ -0,0 +1,88 @@
import * as Notifications from "expo-notifications"
import * as TaskManager from "expo-task-manager"
import { Platform } from "react-native"
const BACKGROUND_TASK_NAME = "monitoring-background-notification-task"
type BackgroundPayload = {
eventType?: string
sessionID?: string
title?: string
body?: string
}
let configured = false
TaskManager.defineTask(BACKGROUND_TASK_NAME, async ({ data }: { data?: unknown }) => {
const payload = data as BackgroundPayload | undefined
const title = payload?.title ?? "OpenCode update"
const body = payload?.body ?? "Your monitored session has a new update."
await Notifications.scheduleNotificationAsync({
content: {
title,
body,
data: payload ?? {},
sound: "alert.wav",
...(Platform.OS === "android" ? { channelId: "monitoring" } : {}),
},
trigger: null,
})
return Notifications.BackgroundNotificationTaskResult.NewData
})
export function configureNotificationBehavior(): void {
if (configured) return
configured = true
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowBanner: false,
shouldShowList: false,
shouldPlaySound: false,
shouldSetBadge: false,
}),
})
}
export async function registerBackgroundNotificationTask(): Promise<void> {
const already = await TaskManager.isTaskRegisteredAsync(BACKGROUND_TASK_NAME)
if (already) return
await Notifications.registerTaskAsync(BACKGROUND_TASK_NAME)
}
export async function ensureNotificationPermissions(): Promise<boolean> {
if (Platform.OS === "android") {
await Notifications.setNotificationChannelAsync("monitoring", {
name: "OpenCode Monitoring",
importance: Notifications.AndroidImportance.HIGH,
sound: "alert.wav",
})
}
const existing = await Notifications.getPermissionsAsync()
let granted = existing.granted
if (!granted) {
const requested = await Notifications.requestPermissionsAsync()
granted = requested.granted
}
return granted
}
export async function getDevicePushToken(): Promise<string | null> {
const result = await Notifications.getDevicePushTokenAsync()
if (typeof result.data !== "string" || result.data.length === 0) {
return null
}
return result.data
}
export function onPushTokenChange(callback: (token: string) => void): { remove: () => void } {
return Notifications.addPushTokenListener((next: { data: unknown }) => {
if (typeof next.data !== "string" || next.data.length === 0) return
callback(next.data)
})
}

View File

@@ -0,0 +1,138 @@
declare module "whisper.rn" {
export type TranscribeOptions = {
language?: string
translate?: boolean
maxLen?: number
prompt?: string
[key: string]: unknown
}
export type TranscribeResult = {
result: string
language: string
segments: {
text: string
t0: number
t1: number
}[]
isAborted?: boolean
}
export type TranscribeRealtimeEvent = {
contextId: number
jobId: number
isCapturing: boolean
isStoppedByAction?: boolean
code: number
data?: TranscribeResult
error?: string
processTime: number
recordingTime: number
}
export type TranscribeRealtimeOptions = TranscribeOptions & {
realtimeAudioSec?: number
realtimeAudioSliceSec?: number
realtimeAudioMinSec?: number
[key: string]: unknown
}
export type WhisperContext = {
id: number
gpu: boolean
reasonNoGPU: string
transcribeRealtime(options?: TranscribeRealtimeOptions): Promise<{
stop: () => Promise<void>
subscribe: (callback: (event: TranscribeRealtimeEvent) => void) => void
}>
transcribeData(
data: ArrayBuffer,
options?: TranscribeOptions,
): {
stop: () => Promise<void>
promise: Promise<TranscribeResult>
}
release(): Promise<void>
}
export type ContextOptions = {
filePath: string | number
useGpu?: boolean
useCoreMLIos?: boolean
useFlashAttn?: boolean
}
export function initWhisper(options: ContextOptions): Promise<WhisperContext>
export function releaseAllWhisper(): Promise<void>
}
declare module "whisper.rn/realtime-transcription/index" {
import type { TranscribeOptions, TranscribeResult, WhisperContext } from "whisper.rn"
export type RealtimeTranscribeEvent = {
type: "start" | "transcribe" | "end" | "error"
sliceIndex: number
data?: TranscribeResult
isCapturing: boolean
processTime: number
recordingTime: number
}
export type RealtimeOptions = {
audioSliceSec?: number
audioMinSec?: number
maxSlicesInMemory?: number
transcribeOptions?: TranscribeOptions
logger?: (message: string) => void
}
export type RealtimeTranscriberCallbacks = {
onTranscribe?: (event: RealtimeTranscribeEvent) => void
onError?: (error: string) => void
onStatusChange?: (isActive: boolean) => void
}
export type RealtimeTranscriberDependencies = {
whisperContext: WhisperContext
audioStream: unknown
vadContext?: unknown
fs?: unknown
}
export class RealtimeTranscriber {
constructor(
dependencies: RealtimeTranscriberDependencies,
options?: RealtimeOptions,
callbacks?: RealtimeTranscriberCallbacks,
)
start(): Promise<void>
stop(): Promise<void>
release(): Promise<void>
updateCallbacks(callbacks: Partial<RealtimeTranscriberCallbacks>): void
}
}
declare module "whisper.rn/realtime-transcription" {
export * from "whisper.rn/realtime-transcription/index"
}
declare module "whisper.rn/src/realtime-transcription" {
export * from "whisper.rn/realtime-transcription/index"
}
declare module "whisper.rn/realtime-transcription/adapters/AudioPcmStreamAdapter" {
export class AudioPcmStreamAdapter {
initialize(config: Record<string, unknown>): Promise<void>
start(): Promise<void>
stop(): Promise<void>
isRecording(): boolean
onData(callback: (data: unknown) => void): void
onError(callback: (error: string) => void): void
onStatusChange(callback: (isRecording: boolean) => void): void
release(): Promise<void>
}
}
declare module "whisper.rn/src/realtime-transcription/adapters/AudioPcmStreamAdapter" {
export * from "whisper.rn/realtime-transcription/adapters/AudioPcmStreamAdapter"
}

View File

@@ -0,0 +1,13 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"baseUrl": ".",
"typeRoots": ["./node_modules/@types"],
"paths": {
"@/*": ["./src/*"],
"@/assets/*": ["./assets/*"]
}
},
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
}

View File

@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"paths": {
"@/*": ["./src/*"],
"@/assets/*": ["./assets/*"],
"react": ["./node_modules/@types/react"],
"react/jsx-runtime": ["./node_modules/@types/react/jsx-runtime"],
"react/jsx-dev-runtime": ["./node_modules/@types/react/jsx-dev-runtime"]
}
}
}

View File

@@ -53,6 +53,7 @@
"@types/bun": "catalog:",
"@types/cross-spawn": "6.0.6",
"@types/mime-types": "3.0.1",
"@types/qrcode": "1.5.5",
"@types/semver": "^7.5.8",
"@types/turndown": "5.0.5",
"@types/which": "3.0.4",
@@ -137,6 +138,7 @@
"opencode-poe-auth": "0.0.1",
"opentui-spinner": "0.0.6",
"partial-json": "0.1.7",
"qrcode": "1.5.4",
"remeda": "catalog:",
"semver": "^7.6.3",
"solid-js": "catalog:",

View File

@@ -1,3 +1,5 @@
import { randomBytes } from "node:crypto"
import os from "node:os"
import { Server } from "../../server/server"
import { cmd } from "./cmd"
import { withNetworkOptions, resolveNetworkOptions } from "../network"
@@ -5,10 +7,100 @@ import { Flag } from "../../flag/flag"
import { Workspace } from "../../control-plane/workspace"
import { Project } from "../../project/project"
import { Installation } from "../../installation"
import { PushRelay } from "../../server/push-relay"
import * as QRCode from "qrcode"
function ipTier(address: string): number {
const parts = address.split(".")
if (parts.length !== 4) return 4
const a = Number(parts[0])
const b = Number(parts[1])
if (a === 127) return 4
if (a === 169 && b === 254) return 3
if (a === 10) return 2
if (a === 172 && b >= 16 && b <= 31) return 2
if (a === 192 && b === 168) return 2
if (a === 100 && b >= 64 && b <= 127) return 1
return 0
}
function norm(input: string) {
return input.replace(/\/+$/, "")
}
function advertiseURL(input: string, port: number): string | undefined {
const raw = input.trim()
if (!raw) return
try {
const parsed = new URL(raw.includes("://") ? raw : `http://${raw}`)
if (!parsed.hostname) return
if (!parsed.port) {
parsed.port = String(port)
}
return norm(`${parsed.protocol}//${parsed.host}`)
} catch {
return
}
}
function hosts(hostname: string, port: number, advertised: string[] = []) {
const seen = new Set<string>()
const preferred: string[] = []
const entries: Array<{ url: string; tier: number }> = []
const addPreferred = (value: string) => {
const url = advertiseURL(value, port)
if (!url) return
if (seen.has(url)) return
seen.add(url)
preferred.push(url)
}
const add = (item: string) => {
if (!item) return
if (item === "0.0.0.0") return
if (item === "::") return
const url = `http://${item}:${port}`
if (seen.has(url)) return
seen.add(url)
entries.push({ url, tier: ipTier(item) })
}
advertised.forEach(addPreferred)
add(hostname)
add("127.0.0.1")
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)]
}
function pairLink(pair: unknown) {
return `mobilevoice:///?pair=${encodeURIComponent(JSON.stringify(pair))}`
}
export const ServeCommand = cmd({
command: "serve",
builder: (yargs) => withNetworkOptions(yargs),
builder: (yargs) =>
withNetworkOptions(yargs)
.option("relay-url", {
type: "string",
describe: "experimental APN relay URL",
})
.option("relay-secret", {
type: "string",
describe: "experimental APN relay secret",
})
.option("advertise-host", {
type: "string",
array: true,
describe: "preferred host/domain for mobile QR (repeatable, supports host[:port] or URL)",
}),
describe: "starts a headless opencode server",
handler: async (args) => {
if (!Flag.OPENCODE_SERVER_PASSWORD) {
@@ -18,6 +110,64 @@ export const ServeCommand = cmd({
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 ??
"https://apn.dev.opencode.ai"
).trim()
const advertiseHostArg = args["advertise-host"]
const advertiseHostsFromArg = Array.isArray(advertiseHostArg)
? advertiseHostArg
: typeof advertiseHostArg === "string"
? [advertiseHostArg]
: []
const advertiseHostsFromEnv = (process.env.OPENCODE_EXPERIMENTAL_PUSH_ADVERTISE_HOSTS ?? "")
.split(",")
.map((item) => item.trim())
.filter(Boolean)
const advertiseHosts = [...new Set([...advertiseHostsFromArg, ...advertiseHostsFromEnv])]
const input = (args["relay-secret"] ?? process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_SECRET ?? "").trim()
const relaySecret = input || randomBytes(18).toString("base64url")
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",
)
}
if (relayURL && relaySecret) {
const host = server.hostname ?? opts.hostname
const port = server.port || opts.port || 4096
const started = PushRelay.start({
relayURL,
relaySecret,
hostname: host,
port,
advertiseHosts,
})
const pair = started ??
PushRelay.pair() ?? {
v: 1 as const,
relayURL,
relaySecret,
hosts: hosts(host, port, advertiseHosts),
}
if (!started) {
console.log("experimental push relay failed to initialize; showing setup qr anyway")
}
if (pair) {
console.log("experimental push relay enabled")
const link = pairLink(pair)
const code = await QRCode.toString(link, {
type: "terminal",
small: true,
errorCorrectionLevel: "M",
})
console.log("scan qr code in mobile app or phone camera")
console.log(code)
}
}
await new Promise(() => {})
await server.stop()
},

View File

@@ -0,0 +1,451 @@
import os from "node:os"
import { createHash } from "node:crypto"
import { SessionID } from "@/session/schema"
import { GlobalBus } from "@/bus/global"
import { Log } from "@/util/log"
type Type = "complete" | "permission" | "error"
type Pair = {
v: 1
serverID?: string
relayURL: string
relaySecret: string
hosts: string[]
}
type Input = {
relayURL: string
relaySecret: string
hostname: string
port: number
advertiseHosts?: string[]
}
type State = {
relayURL: string
relaySecret: string
pair: Pair
stop: () => void
seen: Map<string, number>
gc: number
}
type Event = {
type: string
properties: unknown
}
type Notify = {
type: Type
sessionID: string
title?: string
body?: string
}
const log = Log.create({ service: "push-relay" })
let state: State | undefined
function obj(input: unknown): input is Record<string, unknown> {
return typeof input === "object" && input !== null
}
function str(input: unknown) {
return typeof input === "string" && input.length > 0 ? input : undefined
}
function norm(input: string) {
return input.replace(/\/+$/, "")
}
function secretHash(input: string) {
if (!input) return "none"
return `${createHash("sha256").update(input).digest("hex").slice(0, 12)}...`
}
function serverID(input: { relayURL: string; relaySecret: string }) {
return createHash("sha256").update(`${input.relayURL}|${input.relaySecret}`).digest("hex").slice(0, 16)
}
/**
* Classify an IPv4 address into a reachability tier.
* Lower number = more likely reachable from an external/overlay network device.
*
* 0 public / routable
* 1 CGNAT / shared (100.64.0.0/10) used by Tailscale, Cloudflare WARP, carrier NAT, etc.
* 2 private LAN (10.0.0.0/8, 172.16-31.x, 192.168.x)
* 3 link-local (169.254.x)
* 4 loopback (127.x)
*/
function ipTier(address: string): number {
const parts = address.split(".")
if (parts.length !== 4) return 4
const a = Number(parts[0])
const b = Number(parts[1])
// loopback 127.0.0.0/8
if (a === 127) return 4
// link-local 169.254.0.0/16
if (a === 169 && b === 254) return 3
// private 10.0.0.0/8
if (a === 10) return 2
// private 172.16.0.0/12
if (a === 172 && b >= 16 && b <= 31) return 2
// private 192.168.0.0/16
if (a === 192 && b === 168) return 2
// CGNAT / shared address space 100.64.0.0/10 (100.64.x 100.127.x)
if (a === 100 && b >= 64 && b <= 127) return 1
// everything else is routable
return 0
}
function advertiseURL(input: string, port: number): string | undefined {
const raw = input.trim()
if (!raw) return
try {
const parsed = new URL(raw.includes("://") ? raw : `http://${raw}`)
if (!parsed.hostname) return
if (!parsed.port) {
parsed.port = String(port)
}
return norm(`${parsed.protocol}//${parsed.host}`)
} catch {
return
}
}
function list(hostname: string, port: number, advertised: string[] = []) {
const seen = new Set<string>()
const preferred: string[] = []
const hosts: Array<{ url: string; tier: number }> = []
const addPreferred = (input: string) => {
const url = advertiseURL(input, port)
if (!url) return
if (seen.has(url)) return
seen.add(url)
preferred.push(url)
}
const add = (host: string) => {
if (!host) return
if (host === "0.0.0.0") return
if (host === "::") return
const url = `http://${host}:${port}`
if (seen.has(url)) return
seen.add(url)
hosts.push({ url, tier: ipTier(host) })
}
advertised.forEach(addPreferred)
add(hostname)
add("127.0.0.1")
const nets = Object.values(os.networkInterfaces())
.flatMap((item) => item ?? [])
.filter((item) => item.family === "IPv4" && !item.internal)
.map((item) => item.address)
nets.forEach(add)
// sort: most externally reachable first, loopback last
hosts.sort((a, b) => a.tier - b.tier)
return [...preferred, ...hosts.map((item) => item.url)]
}
function map(event: Event): { type: Type; sessionID: string } | undefined {
if (!obj(event.properties)) return
if (event.type === "permission.asked") {
const sessionID = str(event.properties.sessionID)
if (!sessionID) return
return { type: "permission", sessionID }
}
if (event.type === "session.error") {
const sessionID = str(event.properties.sessionID)
if (!sessionID) return
return { type: "error", sessionID }
}
if (event.type === "session.idle") {
const sessionID = str(event.properties.sessionID)
if (!sessionID) return
return { type: "complete", sessionID }
}
if (event.type !== "session.status") return
const sessionID = str(event.properties.sessionID)
if (!sessionID) return
if (!obj(event.properties.status)) return
if (event.properties.status.type !== "idle") return
return { type: "complete", sessionID }
}
function text(input: string) {
return input.replace(/\s+/g, " ").trim()
}
function words(input: string, max = 18, chars = 140) {
const clean = text(input)
if (!clean) return ""
const split = clean.split(" ")
const cut = split.slice(0, max).join(" ")
if (cut.length <= chars && split.length <= max) return cut
const short = cut.slice(0, chars).trim()
return short.endsWith("…") ? short : `${short}`
}
function fallback(input: Type) {
if (input === "complete") return "Session complete."
if (input === "permission") return "OpenCode needs your permission decision."
return "OpenCode reported an error for your session."
}
async function notify(input: { type: Type; sessionID: string }): Promise<Notify> {
const out: Notify = {
type: input.type,
sessionID: input.sessionID,
}
try {
const [{ Session }, { MessageV2 }] = await Promise.all([import("@/session"), import("@/session/message-v2")])
const sessionID = SessionID.make(input.sessionID)
const session = await Session.get(sessionID)
out.title = session.title
let latestUser: string | undefined
for await (const msg of MessageV2.stream(sessionID)) {
const body = msg.parts
.map((part) => {
if (part.type !== "text") return ""
if (part.ignored) return ""
return part.text
})
.filter(Boolean)
.join(" ")
const next = words(body)
if (!next) continue
if (msg.info.role === "assistant") {
out.body = next
break
}
if (!latestUser && msg.info.role === "user") {
latestUser = next
}
}
if (!out.body) {
out.body = latestUser
}
} catch (error) {
log.info("notification metadata unavailable", {
type: input.type,
sessionID: input.sessionID,
error: String(error),
})
}
if (!out.title) out.title = `Session ${input.type}`
if (!out.body) out.body = fallback(input.type)
return out
}
function dedupe(input: { type: Type; sessionID: string }) {
if (input.type !== "complete") return false
const next = state
if (!next) return false
const now = Date.now()
if (next.seen.size > 2048 || now - next.gc > 60_000) {
next.gc = now
for (const [key, time] of next.seen) {
if (now - time > 60_000) {
next.seen.delete(key)
}
}
const drop = next.seen.size - 2048
if (drop > 0) {
let i = 0
for (const key of next.seen.keys()) {
next.seen.delete(key)
i += 1
if (i >= drop) break
}
}
}
const key = `${input.type}:${input.sessionID}`
const prev = next.seen.get(key)
next.seen.set(key, now)
if (!prev) return false
return now - prev < 5_000
}
async function post(input: { type: Type; sessionID: string }) {
const next = state
if (!next) return false
if (dedupe(input)) return true
const content = await notify(input)
console.log("[ APN RELAY ] posting event", {
serverID: next.pair.serverID,
relayURL: next.relayURL,
secretHash: secretHash(next.relaySecret),
type: input.type,
sessionID: input.sessionID,
title: content.title,
})
log.info("[ APN RELAY ] posting event", {
serverID: next.pair.serverID,
relayURL: next.relayURL,
secretHash: secretHash(next.relaySecret),
type: input.type,
sessionID: input.sessionID,
title: content.title,
})
void fetch(`${next.relayURL}/v1/event`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
secret: next.relaySecret,
serverID: next.pair.serverID,
eventType: input.type,
sessionID: input.sessionID,
title: content.title,
body: content.body,
}),
})
.then(async (res) => {
if (res.ok) {
console.log("[ APN RELAY ] relay accepted event", {
status: res.status,
serverID: next.pair.serverID,
secretHash: secretHash(next.relaySecret),
type: input.type,
sessionID: input.sessionID,
title: content.title,
})
log.info("[ APN RELAY ] relay accepted event", {
status: res.status,
serverID: next.pair.serverID,
secretHash: secretHash(next.relaySecret),
type: input.type,
sessionID: input.sessionID,
title: content.title,
})
return
}
const error = await res.text().catch(() => "")
log.warn("relay post failed", {
status: res.status,
type: input.type,
sessionID: input.sessionID,
title: content.title,
error,
})
})
.catch((error) => {
log.warn("relay post failed", {
type: input.type,
sessionID: input.sessionID,
title: content.title,
error: String(error),
})
})
return true
}
export namespace PushRelay {
export function start(input: Input) {
const relayURL = norm(input.relayURL.trim())
const relaySecret = input.relaySecret.trim()
if (!relayURL) return
if (!relaySecret) return
stop()
const pair: Pair = {
v: 1,
serverID: serverID({ relayURL, relaySecret }),
relayURL,
relaySecret,
hosts: list(input.hostname, input.port, input.advertiseHosts ?? []),
}
const callback = (event: { payload: Event }) => {
const next = map(event.payload)
if (!next) return
void post(next)
}
GlobalBus.on("event", callback)
const unsub = () => {
GlobalBus.off("event", callback)
}
state = {
relayURL,
relaySecret,
pair,
stop: unsub,
seen: new Map(),
gc: 0,
}
log.info("enabled", {
relayURL,
hosts: pair.hosts,
})
return pair
}
export function stop() {
const next = state
if (!next) return
state = undefined
next.stop()
}
export function status() {
const next = state
if (!next) {
return {
enabled: false,
relaySecretSet: false,
} as const
}
return {
enabled: true,
relaySecretSet: next.relaySecret.length > 0,
} as const
}
export function pair() {
return state?.pair
}
export function test(input: { type: Type; sessionID: string }) {
void post(input)
return true
}
export function auth(input: string) {
const next = state
if (!next) return false
return next.relaySecret === input
}
}

Some files were not shown because too many files have changed in this diff Show More