Compare commits
30 Commits
refactor/o
...
opencode-r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddd30ef304 | ||
|
|
2abf1100ee | ||
|
|
bd2e34f3bd | ||
|
|
a45c3a0049 | ||
|
|
52d1ee70a0 | ||
|
|
0a9fcab56f | ||
|
|
62fae6d182 | ||
|
|
3a5be7ad33 | ||
|
|
f1e88d35ba | ||
|
|
b737e87d9a | ||
|
|
bd6e81f30b | ||
|
|
f080147363 | ||
|
|
0051b605ae | ||
|
|
56e0e5ce65 | ||
|
|
d065d5a8ec | ||
|
|
cf79208055 | ||
|
|
f276a8db42 | ||
|
|
8ac2fbbd12 | ||
|
|
26382c6216 | ||
|
|
0981b8eb71 | ||
|
|
aa9ed001d3 | ||
|
|
6086072567 | ||
|
|
6c14ea1d22 | ||
|
|
c3a9ec4a99 | ||
|
|
41b0d03f6a | ||
|
|
81eb6e670b | ||
|
|
8446719b13 | ||
|
|
15a8c22a26 | ||
|
|
48326e8d9c | ||
|
|
43bc5551e8 |
27
.github/workflows/porter-app-5534-apn-relay.yml
vendored
Normal 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 }}
|
||||
@@ -1,34 +0,0 @@
|
||||
---
|
||||
description: ALWAYS use this when writing docs
|
||||
color: "#38A3EE"
|
||||
---
|
||||
|
||||
You are an expert technical documentation writer
|
||||
|
||||
You are not verbose
|
||||
|
||||
Use a relaxed and friendly tone
|
||||
|
||||
The title of the page should be a word or a 2-3 word phrase
|
||||
|
||||
The description should be one short line, should not start with "The", should
|
||||
avoid repeating the title of the page, should be 5-10 words long
|
||||
|
||||
Chunks of text should not be more than 2 sentences long
|
||||
|
||||
Each section is separated by a divider of 3 dashes
|
||||
|
||||
The section titles are short with only the first letter of the word capitalized
|
||||
|
||||
The section titles are in the imperative mood
|
||||
|
||||
The section titles should not repeat the term used in the page title, for
|
||||
example, if the page title is "Models", avoid using a section title like "Add
|
||||
new models". This might be unavoidable in some cases, but try to avoid it.
|
||||
|
||||
Check out the /packages/web/src/content/docs/docs/index.mdx as an example.
|
||||
|
||||
For JS or TS code snippets remove trailing semicolons and any trailing commas
|
||||
that might not be needed.
|
||||
|
||||
If you are making a commit prefix the commit message with `docs:`
|
||||
252
AGENTS.md
@@ -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.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-aqmdiQeFREbUfRi3YX+ot4+CjykDuJpxYQH54W3hxME=",
|
||||
"aarch64-linux": "sha256-ykJp6rFFwXkfJpMRJheTw+r495Wpmx5nj2LKxgSSVDw=",
|
||||
"aarch64-darwin": "sha256-xHGM1rLld8sqkY+lhvec7fWkPPajIE403viIcpsFnk4=",
|
||||
"x86_64-darwin": "sha256-QkGtT76P9Kf2+Ny0rI4CwMrIFzRIXiZwi8KS2o+jECU="
|
||||
"x86_64-linux": "sha256-5VHEo9GCBP+MeLMoWqSuJLAX/qwGLdFjZe20yatgogM=",
|
||||
"aarch64-linux": "sha256-hn+V2UpoCj1ddKcq1ySGOMRVvsd3T8sqgpd6nHYfUoA=",
|
||||
"aarch64-darwin": "sha256-Qctkv6AHDrl+qdB1L+DeqLREeWm6BQqtVCK4tibIMCc=",
|
||||
"x86_64-darwin": "sha256-YHHnow2dqtKOsjQvbyKk6HQmTo8cAv8frgOfD5aS3h8="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"packages/slack"
|
||||
],
|
||||
"catalog": {
|
||||
"@effect/platform-node": "4.0.0-beta.37",
|
||||
"@effect/platform-node": "4.0.0-beta.42",
|
||||
"@types/bun": "1.3.11",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
@@ -45,7 +45,7 @@
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
||||
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
||||
"effect": "4.0.0-beta.37",
|
||||
"effect": "4.0.0-beta.42",
|
||||
"ai": "6.0.138",
|
||||
"hono": "4.10.7",
|
||||
"hono-openapi": "1.1.2",
|
||||
|
||||
11
packages/apn-relay/.env.example
Normal 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
|
||||
106
packages/apn-relay/AGENTS.md
Normal 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.
|
||||
14
packages/apn-relay/Dockerfile
Normal 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"]
|
||||
46
packages/apn-relay/README.md
Normal 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
|
||||
```
|
||||
17
packages/apn-relay/drizzle.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
})
|
||||
27
packages/apn-relay/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
148
packages/apn-relay/src/apns.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
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
|
||||
}
|
||||
|
||||
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 auth = await sign().catch((err) => {
|
||||
return `error:${String(err)}`
|
||||
})
|
||||
if (auth.startsWith("error:")) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 0,
|
||||
error: auth,
|
||||
}
|
||||
}
|
||||
|
||||
const payload = JSON.stringify({
|
||||
aps: {
|
||||
alert: {
|
||||
title: input.title,
|
||||
body: input.body,
|
||||
},
|
||||
sound: "default",
|
||||
},
|
||||
...input.data,
|
||||
})
|
||||
|
||||
const out = await post({
|
||||
host: host(input.env),
|
||||
token: input.token,
|
||||
auth,
|
||||
bundle: input.bundle,
|
||||
payload,
|
||||
}).catch((err) => ({
|
||||
code: 0,
|
||||
body: String(err),
|
||||
}))
|
||||
|
||||
if (out.code === 200) {
|
||||
return {
|
||||
ok: true,
|
||||
code: 200,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
code: out.code,
|
||||
error: out.body,
|
||||
}
|
||||
}
|
||||
28
packages/apn-relay/src/check.ts
Normal 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)
|
||||
})
|
||||
11
packages/apn-relay/src/db.ts
Normal 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 })
|
||||
47
packages/apn-relay/src/env.ts
Normal 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
|
||||
5
packages/apn-relay/src/hash.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createHash } from "node:crypto"
|
||||
|
||||
export function hash(input: string) {
|
||||
return createHash("sha256").update(input).digest("hex")
|
||||
}
|
||||
436
packages/apn-relay/src/index.ts
Normal file
@@ -0,0 +1,436 @@
|
||||
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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'")
|
||||
}
|
||||
|
||||
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),
|
||||
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,
|
||||
})
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
console.log("[relay] unregister", {
|
||||
token: tail(check.data.deviceToken),
|
||||
})
|
||||
|
||||
await db
|
||||
.delete(device_registration)
|
||||
.where(
|
||||
and(
|
||||
eq(device_registration.secret_hash, hash(check.data.secret)),
|
||||
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,
|
||||
session: check.data.sessionID,
|
||||
devices: list.length,
|
||||
})
|
||||
if (!list.length) {
|
||||
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: {
|
||||
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 }
|
||||
35
packages/apn-relay/src/schema.sql.ts
Normal 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),
|
||||
],
|
||||
)
|
||||
34
packages/apn-relay/src/setup.ts
Normal 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;
|
||||
`)
|
||||
}
|
||||
8
packages/apn-relay/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@tsconfig/bun/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"noUncheckedIndexedAccess": false
|
||||
}
|
||||
}
|
||||
@@ -183,7 +183,7 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
|
||||
}
|
||||
}).pipe(
|
||||
effectMinDuration(checkMode() === "blocking" ? "1.2 seconds" : 0),
|
||||
Effect.timeoutOrElse({ duration: "10 seconds", onTimeout: () => Effect.succeed(false) }),
|
||||
Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.succeed(false) }),
|
||||
Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
|
||||
Effect.runPromise,
|
||||
),
|
||||
|
||||
7
packages/mobile-voice/.cursor/settings.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"plugins": {
|
||||
"figma": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
43
packages/mobile-voice/.gitignore
vendored
Normal 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
|
||||
103
packages/mobile-voice/AGENTS.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# 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 modules (`react-native-executorch`, `react-native-audio-api`, `expo-notifications`, `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`
|
||||
- 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 native dependency/config/plugin changes: rebuild dev client via EAS before validation.
|
||||
- If notifications/camera/audio 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`
|
||||
- `bunx expo export --platform ios --clear`
|
||||
- manual runtime test in dev client
|
||||
|
||||
## Code Style And Patterns
|
||||
|
||||
### Formatting / Structure
|
||||
|
||||
- Preserve existing style (`semi: false`, concise JSX, stable import grouping).
|
||||
- Keep UI changes localized; avoid large architectural rewrites.
|
||||
- Avoid unrelated formatting churn.
|
||||
|
||||
### Types
|
||||
|
||||
- Avoid `any`; prefer local type aliases for component state and network payloads.
|
||||
- Keep exported/shared boundaries typed explicitly.
|
||||
- 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 (`serverDraftURL`, `monitorStatus`, etc.).
|
||||
|
||||
### Error Handling / Logging
|
||||
|
||||
- Fail gracefully in UI (alerts, disabled actions, fallback text).
|
||||
- Log actionable diagnostics for runtime workflows:
|
||||
- server health checks
|
||||
- relay registration attempts
|
||||
- notification token lifecycle
|
||||
- Never log secrets or full APNs tokens.
|
||||
|
||||
### Network / Relay Integration
|
||||
|
||||
- Normalize and validate URLs before storing server configs.
|
||||
- Keep relay registration idempotent.
|
||||
- Guard duplicate scan/add flows to avoid repeated server entries.
|
||||
|
||||
### Notifications / APNs
|
||||
|
||||
- Distinguish sandbox vs production token environments correctly.
|
||||
- On registration changes, ensure old token unregister flow remains intact.
|
||||
- Treat permission failures as non-fatal and degrade to foreground monitoring when needed.
|
||||
|
||||
## Native-Module Safety
|
||||
|
||||
- If adding a native module, ensure it is in `package.json` with SDK-compatible version.
|
||||
- Rebuild dev client after native module additions/changes.
|
||||
- For optional native capability usage, prefer runtime fallback paths instead of hard crashes.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Black screen + "No script URL provided" often means 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.
|
||||
39
packages/mobile-voice/README.md
Normal 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.
|
||||
84
packages/mobile-voice/app.json
Normal file
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "Control",
|
||||
"slug": "mobile-voice",
|
||||
"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
|
||||
}
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true,
|
||||
"reactCompiler": true
|
||||
},
|
||||
"extra": {
|
||||
"router": {},
|
||||
"eas": {
|
||||
"projectId": "89248f34-51fc-49e9-acb3-728497520c5a"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
packages/mobile-voice/assets/android-icon-background.png
Normal file
|
After Width: | Height: | Size: 248 KiB |
BIN
packages/mobile-voice/assets/android-icon-foreground.png
Normal file
|
After Width: | Height: | Size: 248 KiB |
BIN
packages/mobile-voice/assets/android-icon-monochrome.png
Normal file
|
After Width: | Height: | Size: 248 KiB |
@@ -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 |
BIN
packages/mobile-voice/assets/expo.icon/Assets/grid.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
40
packages/mobile-voice/assets/expo.icon/icon.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
packages/mobile-voice/assets/favicon.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
packages/mobile-voice/assets/icon.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
packages/mobile-voice/assets/images/android-icon-background.png
Normal file
|
After Width: | Height: | Size: 248 KiB |
BIN
packages/mobile-voice/assets/images/android-icon-foreground.png
Normal file
|
After Width: | Height: | Size: 248 KiB |
BIN
packages/mobile-voice/assets/images/android-icon-monochrome.png
Normal file
|
After Width: | Height: | Size: 248 KiB |
BIN
packages/mobile-voice/assets/images/expo-badge-white.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
packages/mobile-voice/assets/images/expo-badge.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
packages/mobile-voice/assets/images/expo-logo.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
packages/mobile-voice/assets/images/favicon.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
packages/mobile-voice/assets/images/icon.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
packages/mobile-voice/assets/images/logo-glow.png
Normal file
|
After Width: | Height: | Size: 324 KiB |
BIN
packages/mobile-voice/assets/images/react-logo.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
packages/mobile-voice/assets/images/react-logo@2x.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
packages/mobile-voice/assets/images/react-logo@3x.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
packages/mobile-voice/assets/images/splash-icon.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
packages/mobile-voice/assets/images/tabIcons/explore.png
Normal file
|
After Width: | Height: | Size: 215 B |
BIN
packages/mobile-voice/assets/images/tabIcons/explore@2x.png
Normal file
|
After Width: | Height: | Size: 347 B |
BIN
packages/mobile-voice/assets/images/tabIcons/explore@3x.png
Normal file
|
After Width: | Height: | Size: 468 B |
BIN
packages/mobile-voice/assets/images/tabIcons/home.png
Normal file
|
After Width: | Height: | Size: 253 B |
BIN
packages/mobile-voice/assets/images/tabIcons/home@2x.png
Normal file
|
After Width: | Height: | Size: 343 B |
BIN
packages/mobile-voice/assets/images/tabIcons/home@3x.png
Normal file
|
After Width: | Height: | Size: 479 B |
BIN
packages/mobile-voice/assets/images/tutorial-web.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
packages/mobile-voice/assets/sounds/send-whoosh.mp3
Normal file
BIN
packages/mobile-voice/assets/splash-icon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
7
packages/mobile-voice/babel.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
plugins: ['react-native-reanimated/plugin'],
|
||||
};
|
||||
};
|
||||
21
packages/mobile-voice/eas.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 18.4.0",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal"
|
||||
},
|
||||
"production": {
|
||||
"autoIncrement": true
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
}
|
||||
}
|
||||
8
packages/mobile-voice/metro.config.js
Normal 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;
|
||||
1
packages/mobile-voice/notes.md
Normal file
@@ -0,0 +1 @@
|
||||
- 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.
|
||||
8913
packages/mobile-voice/package-lock.json
generated
Normal file
59
packages/mobile-voice/package.json
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
"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-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": {
|
||||
"@types/react": "~19.2.2",
|
||||
"babel-preset-expo": "~55.0.8",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
328
packages/mobile-voice/relay/opencode-relay.mjs
Normal 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}`);
|
||||
});
|
||||
114
packages/mobile-voice/scripts/reset-project.js
Executable 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();
|
||||
}
|
||||
}
|
||||
);
|
||||
20
packages/mobile-voice/src/app/_layout.tsx
Normal 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 />
|
||||
}
|
||||
3604
packages/mobile-voice/src/app/index.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
.expoLogoBackground {
|
||||
background-image: linear-gradient(180deg, #3c9ffe, #0274df);
|
||||
border-radius: 40px;
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
}
|
||||
132
packages/mobile-voice/src/components/animated-icon.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
108
packages/mobile-voice/src/components/animated-icon.web.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
7
packages/mobile-voice/src/components/app-tabs.tsx
Normal 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 />;
|
||||
}
|
||||
7
packages/mobile-voice/src/components/app-tabs.web.tsx
Normal 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 />;
|
||||
}
|
||||
25
packages/mobile-voice/src/components/external-link.tsx
Normal 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,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
35
packages/mobile-voice/src/components/hint-row.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
73
packages/mobile-voice/src/components/themed-text.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
16
packages/mobile-voice/src/components/themed-view.tsx
Normal 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} />;
|
||||
}
|
||||
65
packages/mobile-voice/src/components/ui/collapsible.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
44
packages/mobile-voice/src/components/web-badge.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
65
packages/mobile-voice/src/constants/theme.ts
Normal 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;
|
||||
9
packages/mobile-voice/src/global.css
Normal 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;
|
||||
}
|
||||
1
packages/mobile-voice/src/hooks/use-color-scheme.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { useColorScheme } from 'react-native';
|
||||
21
packages/mobile-voice/src/hooks/use-color-scheme.web.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useColorScheme as useRNColorScheme } from 'react-native';
|
||||
|
||||
/**
|
||||
* To support static rendering, this value needs to be re-calculated on the client side for web
|
||||
*/
|
||||
export function useColorScheme() {
|
||||
const [hasHydrated, setHasHydrated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setHasHydrated(true);
|
||||
}, []);
|
||||
|
||||
const colorScheme = useRNColorScheme();
|
||||
|
||||
if (hasHydrated) {
|
||||
return colorScheme;
|
||||
}
|
||||
|
||||
return 'light';
|
||||
}
|
||||
14
packages/mobile-voice/src/hooks/use-theme.ts
Normal 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];
|
||||
}
|
||||
76
packages/mobile-voice/src/lib/opencode-events.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
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.includes('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';
|
||||
}
|
||||
}
|
||||
63
packages/mobile-voice/src/lib/relay-client.ts
Normal 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.",
|
||||
})
|
||||
}
|
||||
72
packages/mobile-voice/src/lib/sse.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
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 ?? {},
|
||||
},
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
138
packages/mobile-voice/src/types/whisper-rn.d.ts
vendored
Normal 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"
|
||||
}
|
||||
11
packages/mobile-voice/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@/assets/*": ["./assets/*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"test": "bun test --timeout 30000",
|
||||
"build": "bun run script/build.ts",
|
||||
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
|
||||
"dev": "bun run --conditions=browser ./src/index.ts",
|
||||
"random": "echo 'Random script updated at $(date)' && echo 'Change queued successfully' && echo 'Another change made' && echo 'Yet another change' && echo 'One more change' && echo 'Final change' && echo 'Another final change' && echo 'Yet another final change'",
|
||||
"clean": "echo 'Cleaning up...' && rm -rf node_modules dist",
|
||||
@@ -52,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",
|
||||
@@ -101,8 +103,8 @@
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "2.3.3",
|
||||
"@opentui/core": "0.1.90",
|
||||
"@opentui/solid": "0.1.90",
|
||||
"@opentui/core": "0.1.91",
|
||||
"@opentui/solid": "0.1.91",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
@@ -136,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:",
|
||||
|
||||
64
packages/opencode/script/upgrade-opentui.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import path from "node:path"
|
||||
|
||||
const raw = process.argv[2]
|
||||
if (!raw) {
|
||||
console.error("Usage: bun run script/upgrade-opentui.ts <version>")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const ver = raw.replace(/^v/, "")
|
||||
const root = path.resolve(import.meta.dir, "../../..")
|
||||
const skip = new Set([".git", ".opencode", ".turbo", "dist", "node_modules"])
|
||||
const keys = ["@opentui/core", "@opentui/solid"] as const
|
||||
|
||||
const files = (await Array.fromAsync(new Bun.Glob("**/package.json").scan({ cwd: root }))).filter(
|
||||
(file) => !file.split("/").some((part) => skip.has(part)),
|
||||
)
|
||||
|
||||
const set = (cur: string) => {
|
||||
if (cur.startsWith(">=")) return `>=${ver}`
|
||||
if (cur.startsWith("^")) return `^${ver}`
|
||||
if (cur.startsWith("~")) return `~${ver}`
|
||||
return ver
|
||||
}
|
||||
|
||||
const edit = (obj: unknown) => {
|
||||
if (!obj || typeof obj !== "object") return false
|
||||
const map = obj as Record<string, unknown>
|
||||
return keys
|
||||
.map((key) => {
|
||||
const cur = map[key]
|
||||
if (typeof cur !== "string") return false
|
||||
const next = set(cur)
|
||||
if (next === cur) return false
|
||||
map[key] = next
|
||||
return true
|
||||
})
|
||||
.some(Boolean)
|
||||
}
|
||||
|
||||
const out = (
|
||||
await Promise.all(
|
||||
files.map(async (rel) => {
|
||||
const file = path.join(root, rel)
|
||||
const txt = await Bun.file(file).text()
|
||||
const json = JSON.parse(txt)
|
||||
const hit = [json.dependencies, json.devDependencies, json.peerDependencies].map(edit).some(Boolean)
|
||||
if (!hit) return null
|
||||
await Bun.write(file, `${JSON.stringify(json, null, 2)}\n`)
|
||||
return rel
|
||||
}),
|
||||
)
|
||||
).filter((item): item is string => item !== null)
|
||||
|
||||
if (out.length === 0) {
|
||||
console.log("No opentui deps found")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
console.log(`Updated opentui to ${ver} in:`)
|
||||
for (const file of out) {
|
||||
console.log(`- ${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,39 @@ 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 hosts(hostname: string, port: number) {
|
||||
const list = new Set<string>()
|
||||
const add = (item: string) => {
|
||||
if (!item) return
|
||||
if (item === "0.0.0.0") return
|
||||
if (item === "::") return
|
||||
list.add(`http://${item}:${port}`)
|
||||
}
|
||||
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)
|
||||
return [...list]
|
||||
}
|
||||
|
||||
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",
|
||||
}),
|
||||
describe: "starts a headless opencode server",
|
||||
handler: async (args) => {
|
||||
if (!Flag.OPENCODE_SERVER_PASSWORD) {
|
||||
@@ -18,6 +49,50 @@ 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 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")
|
||||
}
|
||||
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,
|
||||
})
|
||||
const pair = started ??
|
||||
PushRelay.pair() ?? {
|
||||
v: 1 as const,
|
||||
relayURL,
|
||||
relaySecret,
|
||||
hosts: hosts(host, port),
|
||||
}
|
||||
if (!started) {
|
||||
console.log("experimental push relay failed to initialize; showing setup qr anyway")
|
||||
}
|
||||
if (pair) {
|
||||
console.log("experimental push relay enabled")
|
||||
const payload = JSON.stringify(pair)
|
||||
const code = await QRCode.toString(payload, {
|
||||
type: "terminal",
|
||||
small: true,
|
||||
errorCorrectionLevel: "M",
|
||||
})
|
||||
console.log("scan qr code in mobile app")
|
||||
console.log(code)
|
||||
console.log("qr payload")
|
||||
console.log(JSON.stringify(pair, null, 2))
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise(() => {})
|
||||
await server.stop()
|
||||
},
|
||||
|
||||
@@ -584,7 +584,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
value: "variant.cycle",
|
||||
keybind: "variant_cycle",
|
||||
category: "Agent",
|
||||
hidden: true,
|
||||
onSelect: () => {
|
||||
local.model.variant.cycle()
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import { map, pipe, flatMap, entries, filter, sortBy, take } from "remeda"
|
||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
|
||||
import { DialogVariant } from "./dialog-variant"
|
||||
import { useKeybind } from "../context/keybind"
|
||||
import * as fuzzysort from "fuzzysort"
|
||||
|
||||
@@ -50,8 +51,7 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
disabled: provider.id === "opencode" && model.id.includes("-nano"),
|
||||
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
|
||||
onSelect: () => {
|
||||
dialog.clear()
|
||||
local.model.set({ providerID: provider.id, modelID: model.id }, { recent: true })
|
||||
onSelect(provider.id, model.id)
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -88,8 +88,7 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
disabled: provider.id === "opencode" && model.includes("-nano"),
|
||||
footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
|
||||
onSelect() {
|
||||
dialog.clear()
|
||||
local.model.set({ providerID: provider.id, modelID: model }, { recent: true })
|
||||
onSelect(provider.id, model)
|
||||
},
|
||||
})),
|
||||
filter((x) => {
|
||||
@@ -135,6 +134,15 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
|
||||
const title = createMemo(() => provider()?.name ?? "Select model")
|
||||
|
||||
function onSelect(providerID: string, modelID: string) {
|
||||
local.model.set({ providerID, modelID }, { recent: true })
|
||||
if (local.model.variant.list().length > 0) {
|
||||
dialog.replace(() => <DialogVariant />)
|
||||
return
|
||||
}
|
||||
dialog.clear()
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogSelect<ReturnType<typeof options>[number]["value"]>
|
||||
options={options()}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { useLocal } from "@tui/context/local"
|
||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
|
||||
export function DialogVariant() {
|
||||
const local = useLocal()
|
||||
const dialog = useDialog()
|
||||
|
||||
const options = createMemo(() => {
|
||||
return local.model.variant.list().map((variant) => ({
|
||||
value: variant,
|
||||
title: variant,
|
||||
onSelect: () => {
|
||||
dialog.clear()
|
||||
local.model.variant.set(variant)
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogSelect<string>
|
||||
options={options()}
|
||||
title={"Select variant"}
|
||||
current={local.model.variant.current()}
|
||||
flat={true}
|
||||
skipFilter={true}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import path from "path"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { useLocal } from "@tui/context/local"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { EmptyBorder } from "@tui/component/border"
|
||||
import { EmptyBorder, SplitBorder } from "@tui/component/border"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
@@ -22,7 +22,7 @@ import { useKeyboard, useRenderer } from "@opentui/solid"
|
||||
import { Editor } from "@tui/util/editor"
|
||||
import { useExit } from "../../context/exit"
|
||||
import { Clipboard } from "../../util/clipboard"
|
||||
import type { FilePart } from "@opencode-ai/sdk/v2"
|
||||
import type { AssistantMessage, FilePart } from "@opencode-ai/sdk/v2"
|
||||
import { TuiEvent } from "../../event"
|
||||
import { iife } from "@/util/iife"
|
||||
import { Locale } from "@/util/locale"
|
||||
@@ -59,6 +59,10 @@ export type PromptRef = {
|
||||
|
||||
const PLACEHOLDERS = ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"]
|
||||
const SHELL_PLACEHOLDERS = ["ls -la", "git status", "pwd"]
|
||||
const money = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
})
|
||||
|
||||
export function Prompt(props: PromptProps) {
|
||||
let input: TextareaRenderable
|
||||
@@ -122,6 +126,25 @@ export function Prompt(props: PromptProps) {
|
||||
return messages.findLast((m) => m.role === "user")
|
||||
})
|
||||
|
||||
const usage = createMemo(() => {
|
||||
if (!props.sessionID) return
|
||||
const msg = sync.data.message[props.sessionID] ?? []
|
||||
const last = msg.findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0)
|
||||
if (!last) return
|
||||
|
||||
const tokens =
|
||||
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
|
||||
if (tokens <= 0) return
|
||||
|
||||
const model = sync.data.provider.find((item) => item.id === last.providerID)?.models[last.modelID]
|
||||
const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined
|
||||
const cost = msg.reduce((sum, item) => sum + (item.role === "assistant" ? item.cost : 0), 0)
|
||||
return {
|
||||
context: pct ? `${Locale.number(tokens)} (${pct})` : Locale.number(tokens),
|
||||
cost: cost > 0 ? money.format(cost) : undefined,
|
||||
}
|
||||
})
|
||||
|
||||
const [store, setStore] = createStore<{
|
||||
prompt: PromptInfo
|
||||
mode: "normal" | "shell"
|
||||
@@ -833,8 +856,7 @@ export function Prompt(props: PromptProps) {
|
||||
border={["left"]}
|
||||
borderColor={highlight()}
|
||||
customBorderChars={{
|
||||
...EmptyBorder,
|
||||
vertical: "┃",
|
||||
...SplitBorder.customBorderChars,
|
||||
bottomLeft: "╹",
|
||||
}}
|
||||
>
|
||||
@@ -1158,14 +1180,20 @@ export function Prompt(props: PromptProps) {
|
||||
<box gap={2} flexDirection="row">
|
||||
<Switch>
|
||||
<Match when={store.mode === "normal"}>
|
||||
<Show when={local.model.variant.list().length > 0}>
|
||||
<text fg={theme.text}>
|
||||
{keybind.print("variant_cycle")} <span style={{ fg: theme.textMuted }}>variants</span>
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={theme.text}>
|
||||
{keybind.print("agent_cycle")} <span style={{ fg: theme.textMuted }}>agents</span>
|
||||
</text>
|
||||
<Switch>
|
||||
<Match when={usage()}>
|
||||
{(item) => (
|
||||
<text fg={theme.textMuted} wrapMode="none">
|
||||
{[item().context, item().cost].filter(Boolean).join(" · ")}
|
||||
</text>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<text fg={theme.text}>
|
||||
{keybind.print("agent_cycle")} <span style={{ fg: theme.textMuted }}>agents</span>
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
<text fg={theme.text}>
|
||||
{keybind.print("command_list")} <span style={{ fg: theme.textMuted }}>commands</span>
|
||||
</text>
|
||||
|
||||
@@ -399,7 +399,16 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
})
|
||||
|
||||
const values = createMemo(() => {
|
||||
return resolveTheme(store.themes[store.active] ?? store.themes.opencode, store.mode)
|
||||
const active = store.themes[store.active]
|
||||
if (active) return resolveTheme(active, store.mode)
|
||||
|
||||
const saved = kv.get("theme")
|
||||
if (typeof saved === "string") {
|
||||
const theme = store.themes[saved]
|
||||
if (theme) return resolveTheme(theme, store.mode)
|
||||
}
|
||||
|
||||
return resolveTheme(store.themes.opencode, store.mode)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
import { type Accessor, createMemo, createSignal, Match, Show, Switch } from "solid-js"
|
||||
import { useRouteData } from "@tui/context/route"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { pipe, sumBy } from "remeda"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { SplitBorder } from "@tui/component/border"
|
||||
import type { AssistantMessage, Session } from "@opencode-ai/sdk/v2"
|
||||
import { useCommandDialog } from "@tui/component/dialog-command"
|
||||
import { useKeybind } from "../../context/keybind"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
|
||||
const Title = (props: { session: Accessor<Session> }) => {
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<text fg={theme.text}>
|
||||
<span style={{ bold: true }}>#</span> <span style={{ bold: true }}>{props.session().title}</span>
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
const ContextInfo = (props: { context: Accessor<string | undefined>; cost: Accessor<string> }) => {
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<Show when={props.context()}>
|
||||
<text fg={theme.textMuted} wrapMode="none" flexShrink={0}>
|
||||
{props.context()} ({props.cost()})
|
||||
</text>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
const WorkspaceInfo = (props: { workspace: Accessor<string | undefined> }) => {
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<Show when={props.workspace()}>
|
||||
<text fg={theme.textMuted} wrapMode="none" flexShrink={0}>
|
||||
{props.workspace()}
|
||||
</text>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export function Header() {
|
||||
const route = useRouteData("session")
|
||||
const sync = useSync()
|
||||
const session = createMemo(() => sync.session.get(route.sessionID)!)
|
||||
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
|
||||
|
||||
const cost = createMemo(() => {
|
||||
const total = pipe(
|
||||
messages(),
|
||||
sumBy((x) => (x.role === "assistant" ? x.cost : 0)),
|
||||
)
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(total)
|
||||
})
|
||||
|
||||
const context = createMemo(() => {
|
||||
const last = messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage
|
||||
if (!last) return
|
||||
const total =
|
||||
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
|
||||
const model = sync.data.provider.find((x) => x.id === last.providerID)?.models[last.modelID]
|
||||
let result = total.toLocaleString()
|
||||
if (model?.limit.context) {
|
||||
result += " " + Math.round((total / model.limit.context) * 100) + "%"
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const workspace = createMemo(() => {
|
||||
const id = session()?.workspaceID
|
||||
if (!id) return "Workspace local"
|
||||
const info = sync.workspace.get(id)
|
||||
if (!info) return `Workspace ${id}`
|
||||
return `Workspace ${id} (${info.type})`
|
||||
})
|
||||
|
||||
const { theme } = useTheme()
|
||||
const keybind = useKeybind()
|
||||
const command = useCommandDialog()
|
||||
const [hover, setHover] = createSignal<"parent" | "prev" | "next" | null>(null)
|
||||
const dimensions = useTerminalDimensions()
|
||||
const narrow = createMemo(() => dimensions().width < 80)
|
||||
|
||||
return (
|
||||
<box flexShrink={0}>
|
||||
<box
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={1}
|
||||
{...SplitBorder}
|
||||
border={["left"]}
|
||||
borderColor={theme.border}
|
||||
flexShrink={0}
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={session()?.parentID}>
|
||||
<box flexDirection="column" gap={1}>
|
||||
<box flexDirection={narrow() ? "column" : "row"} justifyContent="space-between" gap={narrow() ? 1 : 0}>
|
||||
{Flag.OPENCODE_EXPERIMENTAL_WORKSPACES ? (
|
||||
<box flexDirection="column">
|
||||
<text fg={theme.text}>
|
||||
<b>Subagent session</b>
|
||||
</text>
|
||||
<WorkspaceInfo workspace={workspace} />
|
||||
</box>
|
||||
) : (
|
||||
<text fg={theme.text}>
|
||||
<b>Subagent session</b>
|
||||
</text>
|
||||
)}
|
||||
|
||||
<ContextInfo context={context} cost={cost} />
|
||||
</box>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<box
|
||||
onMouseOver={() => setHover("parent")}
|
||||
onMouseOut={() => setHover(null)}
|
||||
onMouseUp={() => command.trigger("session.parent")}
|
||||
backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel}
|
||||
>
|
||||
<text fg={theme.text}>
|
||||
Parent <span style={{ fg: theme.textMuted }}>{keybind.print("session_parent")}</span>
|
||||
</text>
|
||||
</box>
|
||||
<box
|
||||
onMouseOver={() => setHover("prev")}
|
||||
onMouseOut={() => setHover(null)}
|
||||
onMouseUp={() => command.trigger("session.child.previous")}
|
||||
backgroundColor={hover() === "prev" ? theme.backgroundElement : theme.backgroundPanel}
|
||||
>
|
||||
<text fg={theme.text}>
|
||||
Prev <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle_reverse")}</span>
|
||||
</text>
|
||||
</box>
|
||||
<box
|
||||
onMouseOver={() => setHover("next")}
|
||||
onMouseOut={() => setHover(null)}
|
||||
onMouseUp={() => command.trigger("session.child.next")}
|
||||
backgroundColor={hover() === "next" ? theme.backgroundElement : theme.backgroundPanel}
|
||||
>
|
||||
<text fg={theme.text}>
|
||||
Next <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle")}</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<box flexDirection={narrow() ? "column" : "row"} justifyContent="space-between" gap={1}>
|
||||
{Flag.OPENCODE_EXPERIMENTAL_WORKSPACES ? (
|
||||
<box flexDirection="column">
|
||||
<Title session={session} />
|
||||
<WorkspaceInfo workspace={workspace} />
|
||||
</box>
|
||||
) : (
|
||||
<Title session={session} />
|
||||
)}
|
||||
<ContextInfo context={context} cost={cost} />
|
||||
</box>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -51,7 +51,6 @@ import { useSDK } from "@tui/context/sdk"
|
||||
import { useCommandDialog } from "@tui/component/dialog-command"
|
||||
import type { DialogContext } from "@tui/ui/dialog"
|
||||
import { useKeybind } from "@tui/context/keybind"
|
||||
import { Header } from "./header"
|
||||
import { parsePatch } from "diff"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
import { TodoItem } from "../../component/todo-item"
|
||||
@@ -62,6 +61,7 @@ import { DialogTimeline } from "./dialog-timeline"
|
||||
import { DialogForkFromTimeline } from "./dialog-fork-from-timeline"
|
||||
import { DialogSessionRename } from "../../component/dialog-session-rename"
|
||||
import { Sidebar } from "./sidebar"
|
||||
import { SubagentFooter } from "./subagent-footer.tsx"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
|
||||
import parsers from "../../../../../../parsers-config.ts"
|
||||
@@ -154,7 +154,6 @@ export function Session() {
|
||||
const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true)
|
||||
const [showAssistantMetadata, setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true)
|
||||
const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", true)
|
||||
const [showHeader, setShowHeader] = kv.signal("header_visible", true)
|
||||
const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word")
|
||||
const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true)
|
||||
const [showGenericToolOutput, setShowGenericToolOutput] = kv.signal("generic_tool_output_visibility", false)
|
||||
@@ -635,15 +634,6 @@ export function Session() {
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: showHeader() ? "Hide header" : "Show header",
|
||||
value: "session.toggle.header",
|
||||
category: "Session",
|
||||
onSelect: (dialog) => {
|
||||
setShowHeader((prev) => !prev)
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: showGenericToolOutput() ? "Hide generic tool output" : "Show generic tool output",
|
||||
value: "session.toggle.generic_tool_output",
|
||||
@@ -1045,11 +1035,8 @@ export function Session() {
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row">
|
||||
<box flexGrow={1} paddingBottom={1} paddingTop={1} paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexGrow={1} paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<Show when={session()}>
|
||||
<Show when={showHeader() && (!sidebarVisible() || !wide())}>
|
||||
<Header />
|
||||
</Show>
|
||||
<scrollbox
|
||||
ref={(r) => (scroll = r)}
|
||||
viewportOptions={{
|
||||
@@ -1068,6 +1055,7 @@ export function Session() {
|
||||
flexGrow={1}
|
||||
scrollAcceleration={scrollAcceleration()}
|
||||
>
|
||||
<box height={1} />
|
||||
<For each={messages()}>
|
||||
{(message, index) => (
|
||||
<Switch>
|
||||
@@ -1171,6 +1159,9 @@ export function Session() {
|
||||
<Show when={permissions().length === 0 && questions().length > 0}>
|
||||
<QuestionPrompt request={questions()[0]} />
|
||||
</Show>
|
||||
<Show when={session()?.parentID}>
|
||||
<SubagentFooter />
|
||||
</Show>
|
||||
<Prompt
|
||||
visible={!session()?.parentID && permissions().length === 0 && questions().length === 0}
|
||||
ref={(r) => {
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { createMemo, createSignal, Show } from "solid-js"
|
||||
import { useRouteData } from "@tui/context/route"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { SplitBorder } from "@tui/component/border"
|
||||
import type { AssistantMessage } from "@opencode-ai/sdk/v2"
|
||||
import { useCommandDialog } from "@tui/component/dialog-command"
|
||||
import { useKeybind } from "../../context/keybind"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
|
||||
export function SubagentFooter() {
|
||||
const route = useRouteData("session")
|
||||
const sync = useSync()
|
||||
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
|
||||
|
||||
const usage = createMemo(() => {
|
||||
const msg = messages()
|
||||
const last = msg.findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0)
|
||||
if (!last) return
|
||||
|
||||
const tokens =
|
||||
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
|
||||
if (tokens <= 0) return
|
||||
|
||||
const model = sync.data.provider.find((item) => item.id === last.providerID)?.models[last.modelID]
|
||||
const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined
|
||||
const cost = msg.reduce((sum, item) => sum + (item.role === "assistant" ? item.cost : 0), 0)
|
||||
|
||||
const money = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
})
|
||||
|
||||
return {
|
||||
context: pct ? `${Locale.number(tokens)} (${pct})` : Locale.number(tokens),
|
||||
cost: cost > 0 ? money.format(cost) : undefined,
|
||||
}
|
||||
})
|
||||
|
||||
const { theme } = useTheme()
|
||||
const keybind = useKeybind()
|
||||
const command = useCommandDialog()
|
||||
const [hover, setHover] = createSignal<"parent" | "prev" | "next" | null>(null)
|
||||
const dimensions = useTerminalDimensions()
|
||||
|
||||
return (
|
||||
<box flexShrink={0}>
|
||||
<box
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={1}
|
||||
{...SplitBorder}
|
||||
border={["left"]}
|
||||
borderColor={theme.border}
|
||||
flexShrink={0}
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
>
|
||||
<box flexDirection="row" justifyContent="space-between" gap={1}>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<text fg={theme.text}>
|
||||
<b>Subagent session</b>
|
||||
</text>
|
||||
<Show when={usage()}>
|
||||
{(item) => (
|
||||
<text fg={theme.textMuted} wrapMode="none">
|
||||
{[item().context, item().cost].filter(Boolean).join(" · ")}
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<box
|
||||
onMouseOver={() => setHover("parent")}
|
||||
onMouseOut={() => setHover(null)}
|
||||
onMouseUp={() => command.trigger("session.parent")}
|
||||
backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel}
|
||||
>
|
||||
<text fg={theme.text}>
|
||||
Parent <span style={{ fg: theme.textMuted }}>{keybind.print("session_parent")}</span>
|
||||
</text>
|
||||
</box>
|
||||
<box
|
||||
onMouseOver={() => setHover("prev")}
|
||||
onMouseOut={() => setHover(null)}
|
||||
onMouseUp={() => command.trigger("session.child.previous")}
|
||||
backgroundColor={hover() === "prev" ? theme.backgroundElement : theme.backgroundPanel}
|
||||
>
|
||||
<text fg={theme.text}>
|
||||
Prev <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle_reverse")}</span>
|
||||
</text>
|
||||
</box>
|
||||
<box
|
||||
onMouseOver={() => setHover("next")}
|
||||
onMouseOut={() => setHover(null)}
|
||||
onMouseUp={() => command.trigger("session.child.next")}
|
||||
backgroundColor={hover() === "next" ? theme.backgroundElement : theme.backgroundPanel}
|
||||
>
|
||||
<text fg={theme.text}>
|
||||
Next <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle")}</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||