Compare commits
88 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
33498a5121 | |
|
|
eb5cbebf9e | |
|
|
3196df1ccf | |
|
|
5913bc8dc7 | |
|
|
4133f55458 | |
|
|
5c0d1bc221 | |
|
|
07e958b843 | |
|
|
41372c8e52 | |
|
|
2ae0bb7335 | |
|
|
d73f635fc1 | |
|
|
d29cbbcf32 | |
|
|
8cc0e20125 | |
|
|
8515994094 | |
|
|
14042e2a56 | |
|
|
eb30104732 | |
|
|
b797a14857 | |
|
|
da38da0add | |
|
|
759acb60cd | |
|
|
291e168df0 | |
|
|
cd3a58a4c2 | |
|
|
a5614f988f | |
|
|
0f58efe030 | |
|
|
4abb464345 | |
|
|
d1d3d420bf | |
|
|
1d68cd288c | |
|
|
0b8f8bc196 | |
|
|
4d30ad1e7c | |
|
|
c90640e0e1 | |
|
|
21e7f3f5c1 | |
|
|
36b51cad33 | |
|
|
776e61d1ec | |
|
|
28aebb2772 | |
|
|
6494f48136 | |
|
|
15fae6cb60 | |
|
|
aacf1d20d3 | |
|
|
bcf7817127 | |
|
|
abf79ae24c | |
|
|
922633ea9d | |
|
|
49b40e3c90 | |
|
|
df3276fc87 | |
|
|
f8f986536b | |
|
|
785635caef | |
|
|
ec27518eca | |
|
|
8ee4ada38e | |
|
|
ab7b1d78bf | |
|
|
2f44d1900e | |
|
|
cb535eef9d | |
|
|
d3ec6f75f4 | |
|
|
9a8b2ae0b1 | |
|
|
eadb0e25da | |
|
|
ddd30ef304 | |
|
|
2abf1100ee | |
|
|
bd2e34f3bd | |
|
|
a45c3a0049 | |
|
|
52d1ee70a0 | |
|
|
0a9fcab56f | |
|
|
62fae6d182 | |
|
|
3a5be7ad33 | |
|
|
f1e88d35ba | |
|
|
b737e87d9a | |
|
|
bd6e81f30b | |
|
|
f080147363 | |
|
|
0051b605ae | |
|
|
56e0e5ce65 | |
|
|
d065d5a8ec | |
|
|
cf79208055 | |
|
|
f276a8db42 | |
|
|
fa9674edf9 | |
|
|
f45e084b3e | |
|
|
700d0fe3cc | |
|
|
5792a80a8c | |
|
|
db039db7f5 | |
|
|
c1a3936b61 | |
|
|
611e616010 | |
|
|
b286c0ae3f | |
|
|
81a61f8dbd | |
|
|
752e449e38 | |
|
|
5d419a0211 | |
|
|
8b168981aa | |
|
|
724dd665ec | |
|
|
9d78b69cd3 | |
|
|
a531f3f36d | |
|
|
bb3382311d | |
|
|
ad545d0cc9 | |
|
|
ac244b1458 | |
|
|
f202536b65 | |
|
|
405cc3f610 | |
|
|
878c1b8c2d |
|
|
@ -0,0 +1,27 @@
|
||||||
|
"on":
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- opencode-remote-voice
|
||||||
|
name: Deploy to apn-relay
|
||||||
|
jobs:
|
||||||
|
porter-deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Set Github tag
|
||||||
|
id: vars
|
||||||
|
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||||
|
- name: Setup porter
|
||||||
|
uses: porter-dev/setup-porter@v0.1.0
|
||||||
|
- name: Deploy stack
|
||||||
|
timeout-minutes: 30
|
||||||
|
run: porter apply
|
||||||
|
env:
|
||||||
|
PORTER_APP_NAME: apn-relay
|
||||||
|
PORTER_CLUSTER: "5534"
|
||||||
|
PORTER_DEPLOYMENT_TARGET_ID: d60e67f5-b0a6-4275-8ed6-3cebaf092147
|
||||||
|
PORTER_HOST: https://dashboard.porter.run
|
||||||
|
PORTER_PROJECT: "18525"
|
||||||
|
PORTER_TAG: ${{ steps.vars.outputs.sha_short }}
|
||||||
|
PORTER_TOKEN: ${{ secrets.PORTER_APP_18525_975734319 }}
|
||||||
252
AGENTS.md
|
|
@ -1,128 +1,162 @@
|
||||||
- To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
|
# OpenCode Monorepo Agent Guide
|
||||||
- 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.
|
|
||||||
|
|
||||||
## 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
|
- Start with this file for repo-wide defaults.
|
||||||
- Avoid `try`/`catch` where possible
|
- Then check package-local `AGENTS.md` files for stricter rules.
|
||||||
- Avoid using the `any` type
|
- Existing local guides include `packages/opencode/AGENTS.md` and `packages/app/AGENTS.md`.
|
||||||
- Prefer single word variable names where possible
|
- Package-specific guides override this file when they conflict.
|
||||||
- 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
|
## Repo Facts
|
||||||
- Prefer functional array methods (flatMap, filter, map) over for loops; use type guards on filter to maintain type inference downstream
|
|
||||||
|
- 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
|
### Naming
|
||||||
|
|
||||||
Prefer single word names for variables and functions. Only use multiple words if necessary.
|
- Follow existing repo preference for short, clear names.
|
||||||
|
- Use single-word names when readable; use multi-word only for clarity.
|
||||||
### Naming Enforcement (Read This)
|
- Keep naming consistent with nearby code.
|
||||||
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
### Control Flow
|
### 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
|
### Error Handling
|
||||||
// Good
|
|
||||||
function foo() {
|
|
||||||
if (condition) return 1
|
|
||||||
return 2
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bad
|
- Fail with actionable messages.
|
||||||
function foo() {
|
- Avoid swallowing errors silently.
|
||||||
if (condition) return 1
|
- Log enough context to debug production issues (IDs, env, status), but never secrets.
|
||||||
else return 2
|
- 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
|
### Testing Philosophy
|
||||||
// Good
|
|
||||||
const table = sqliteTable("session", {
|
|
||||||
id: text().primaryKey(),
|
|
||||||
project_id: text().notNull(),
|
|
||||||
created_at: integer().notNull(),
|
|
||||||
})
|
|
||||||
|
|
||||||
// Bad
|
- Prefer testing real behavior over mocks.
|
||||||
const table = sqliteTable("session", {
|
- Add regression tests for bug fixes where practical.
|
||||||
id: text("id").primaryKey(),
|
- Keep fixtures small and focused.
|
||||||
projectID: text("project_id").notNull(),
|
|
||||||
createdAt: integer("created_at").notNull(),
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing
|
## Agent Workflow Tips
|
||||||
|
|
||||||
- Avoid mocks as much as possible
|
- Read existing code paths before introducing new abstractions.
|
||||||
- Test actual implementation, do not duplicate logic into tests
|
- Match local patterns first; do not impose a new style per file.
|
||||||
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
|
- 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.
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,8 @@
|
||||||
"@solidjs/router": "0.15.4",
|
"@solidjs/router": "0.15.4",
|
||||||
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
|
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
|
||||||
"solid-js": "1.9.10",
|
"solid-js": "1.9.10",
|
||||||
"vite-plugin-solid": "2.11.10"
|
"vite-plugin-solid": "2.11.10",
|
||||||
|
"@lydell/node-pty": "1.2.0-beta.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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
|
||||||
|
```
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,185 @@
|
||||||
|
import { connect } from "node:http2"
|
||||||
|
import { SignJWT, importPKCS8 } from "jose"
|
||||||
|
import { env } from "./env"
|
||||||
|
|
||||||
|
export type PushEnv = "sandbox" | "production"
|
||||||
|
|
||||||
|
type PushInput = {
|
||||||
|
token: string
|
||||||
|
bundle: string
|
||||||
|
env: PushEnv
|
||||||
|
title: string
|
||||||
|
body: string
|
||||||
|
data: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
type PushResult = {
|
||||||
|
ok: boolean
|
||||||
|
code: number
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenSuffix(input: string) {
|
||||||
|
return input.length > 8 ? input.slice(-8) : input
|
||||||
|
}
|
||||||
|
|
||||||
|
let jwt = ""
|
||||||
|
let exp = 0
|
||||||
|
let pk: Awaited<ReturnType<typeof importPKCS8>> | undefined
|
||||||
|
|
||||||
|
function host(input: PushEnv) {
|
||||||
|
if (input === "sandbox") return "api.sandbox.push.apple.com"
|
||||||
|
return "api.push.apple.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
function key() {
|
||||||
|
if (env.APNS_PRIVATE_KEY.includes("\\n")) return env.APNS_PRIVATE_KEY.replace(/\\n/g, "\n")
|
||||||
|
return env.APNS_PRIVATE_KEY
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sign() {
|
||||||
|
if (!pk) pk = await importPKCS8(key(), "ES256")
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
if (jwt && now < exp) return jwt
|
||||||
|
jwt = await new SignJWT({})
|
||||||
|
.setProtectedHeader({ alg: "ES256", kid: env.APNS_KEY_ID })
|
||||||
|
.setIssuer(env.APNS_TEAM_ID)
|
||||||
|
.setIssuedAt(now)
|
||||||
|
.sign(pk)
|
||||||
|
exp = now + 50 * 60
|
||||||
|
return jwt
|
||||||
|
}
|
||||||
|
|
||||||
|
function post(input: {
|
||||||
|
host: string
|
||||||
|
token: string
|
||||||
|
auth: string
|
||||||
|
bundle: string
|
||||||
|
payload: string
|
||||||
|
}): Promise<{ code: number; body: string }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const cli = connect(`https://${input.host}`)
|
||||||
|
let done = false
|
||||||
|
let code = 0
|
||||||
|
let body = ""
|
||||||
|
|
||||||
|
const stop = (fn: () => void) => {
|
||||||
|
if (done) return
|
||||||
|
done = true
|
||||||
|
fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
cli.on("error", (err) => {
|
||||||
|
stop(() => reject(err))
|
||||||
|
cli.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
const req = cli.request({
|
||||||
|
":method": "POST",
|
||||||
|
":path": `/3/device/${input.token}`,
|
||||||
|
authorization: `bearer ${input.auth}`,
|
||||||
|
"apns-topic": input.bundle,
|
||||||
|
"apns-push-type": "alert",
|
||||||
|
"apns-priority": "10",
|
||||||
|
"content-type": "application/json",
|
||||||
|
})
|
||||||
|
|
||||||
|
req.setEncoding("utf8")
|
||||||
|
req.on("response", (headers) => {
|
||||||
|
code = Number(headers[":status"] ?? 0)
|
||||||
|
})
|
||||||
|
req.on("data", (chunk) => {
|
||||||
|
body += chunk
|
||||||
|
})
|
||||||
|
req.on("end", () => {
|
||||||
|
stop(() => resolve({ code, body }))
|
||||||
|
cli.close()
|
||||||
|
})
|
||||||
|
req.on("error", (err) => {
|
||||||
|
stop(() => reject(err))
|
||||||
|
cli.close()
|
||||||
|
})
|
||||||
|
req.end(input.payload)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function send(input: PushInput): Promise<PushResult> {
|
||||||
|
const apnsHost = host(input.env)
|
||||||
|
const suffix = tokenSuffix(input.token)
|
||||||
|
|
||||||
|
console.log("[ APN RELAY ] push:start", {
|
||||||
|
env: input.env,
|
||||||
|
host: apnsHost,
|
||||||
|
bundle: input.bundle,
|
||||||
|
tokenSuffix: suffix,
|
||||||
|
})
|
||||||
|
|
||||||
|
const auth = await sign().catch((err) => {
|
||||||
|
return `error:${String(err)}`
|
||||||
|
})
|
||||||
|
if (auth.startsWith("error:")) {
|
||||||
|
console.log("[ APN RELAY ] push:auth-failed", {
|
||||||
|
env: input.env,
|
||||||
|
host: apnsHost,
|
||||||
|
bundle: input.bundle,
|
||||||
|
tokenSuffix: suffix,
|
||||||
|
error: auth,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: 0,
|
||||||
|
error: auth,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
aps: {
|
||||||
|
alert: {
|
||||||
|
title: input.title,
|
||||||
|
body: input.body,
|
||||||
|
},
|
||||||
|
sound: "alert.wav",
|
||||||
|
},
|
||||||
|
...input.data,
|
||||||
|
})
|
||||||
|
|
||||||
|
const out = await post({
|
||||||
|
host: apnsHost,
|
||||||
|
token: input.token,
|
||||||
|
auth,
|
||||||
|
bundle: input.bundle,
|
||||||
|
payload,
|
||||||
|
}).catch((err) => ({
|
||||||
|
code: 0,
|
||||||
|
body: String(err),
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (out.code === 200) {
|
||||||
|
console.log("[ APN RELAY ] push:sent", {
|
||||||
|
env: input.env,
|
||||||
|
host: apnsHost,
|
||||||
|
bundle: input.bundle,
|
||||||
|
tokenSuffix: suffix,
|
||||||
|
code: out.code,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
code: 200,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[ APN RELAY ] push:failed", {
|
||||||
|
env: input.env,
|
||||||
|
host: apnsHost,
|
||||||
|
bundle: input.bundle,
|
||||||
|
tokenSuffix: suffix,
|
||||||
|
code: out.code,
|
||||||
|
error: out.body,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: out.code,
|
||||||
|
error: out.body,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
|
@ -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 })
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { createHash } from "node:crypto"
|
||||||
|
|
||||||
|
export function hash(input: string) {
|
||||||
|
return createHash("sha256").update(input).digest("hex")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,448 @@
|
||||||
|
import { randomUUID } from "node:crypto"
|
||||||
|
import { and, desc, eq, sql } from "drizzle-orm"
|
||||||
|
import { Hono } from "hono"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { send } from "./apns"
|
||||||
|
import { db } from "./db"
|
||||||
|
import { env } from "./env"
|
||||||
|
import { hash } from "./hash"
|
||||||
|
import { delivery_log, device_registration } from "./schema.sql"
|
||||||
|
import { setup } from "./setup"
|
||||||
|
|
||||||
|
function bad(input?: string) {
|
||||||
|
if (!input) return false
|
||||||
|
return input.includes("BadEnvironmentKeyInToken")
|
||||||
|
}
|
||||||
|
|
||||||
|
function flip(input: "sandbox" | "production") {
|
||||||
|
if (input === "sandbox") return "production"
|
||||||
|
return "sandbox"
|
||||||
|
}
|
||||||
|
|
||||||
|
function tail(input: string) {
|
||||||
|
return input.slice(-8)
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(input: unknown) {
|
||||||
|
return String(input ?? "")
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.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),
|
||||||
|
serverID: z.string().min(1).optional(),
|
||||||
|
eventType: z.enum(["complete", "permission", "error"]),
|
||||||
|
sessionID: z.string().min(1),
|
||||||
|
title: z.string().min(1).optional(),
|
||||||
|
body: z.string().min(1).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
function title(input: z.infer<typeof evt>["eventType"]) {
|
||||||
|
if (input === "complete") return "Session complete"
|
||||||
|
if (input === "permission") return "Action needed"
|
||||||
|
return "Session error"
|
||||||
|
}
|
||||||
|
|
||||||
|
function body(input: z.infer<typeof evt>["eventType"]) {
|
||||||
|
if (input === "complete") return "OpenCode finished your session."
|
||||||
|
if (input === "permission") return "OpenCode needs your permission decision."
|
||||||
|
return "OpenCode reported an error for your session."
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = new Hono()
|
||||||
|
|
||||||
|
app.onError((err, c) => {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
error: err.message,
|
||||||
|
},
|
||||||
|
500,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.notFound((c) => {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
error: "Not found",
|
||||||
|
},
|
||||||
|
404,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get("/health", async (c) => {
|
||||||
|
const [a] = await db.select({ value: sql<number>`count(*)` }).from(device_registration)
|
||||||
|
const [b] = await db.select({ value: sql<number>`count(*)` }).from(delivery_log)
|
||||||
|
return c.json({
|
||||||
|
ok: true,
|
||||||
|
devices: Number(a?.value ?? 0),
|
||||||
|
deliveries: Number(b?.value ?? 0),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get("/", async (c) => {
|
||||||
|
const [a] = await db.select({ value: sql<number>`count(*)` }).from(device_registration)
|
||||||
|
const [b] = await db.select({ value: sql<number>`count(*)` }).from(delivery_log)
|
||||||
|
const devices = await db.select().from(device_registration).orderBy(desc(device_registration.updated_at)).limit(100)
|
||||||
|
const byBundle = await db
|
||||||
|
.select({
|
||||||
|
bundle: device_registration.bundle_id,
|
||||||
|
env: device_registration.apns_env,
|
||||||
|
value: sql<number>`count(*)`,
|
||||||
|
})
|
||||||
|
.from(device_registration)
|
||||||
|
.groupBy(device_registration.bundle_id, device_registration.apns_env)
|
||||||
|
.orderBy(desc(sql<number>`count(*)`))
|
||||||
|
const rows = await db.select().from(delivery_log).orderBy(desc(delivery_log.created_at)).limit(20)
|
||||||
|
|
||||||
|
const html = `<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>APN Relay</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: ui-sans-serif, system-ui, sans-serif; margin: 24px; color: #111827; }
|
||||||
|
h1 { margin: 0 0 12px 0; }
|
||||||
|
h2 { margin: 22px 0 10px 0; font-size: 16px; }
|
||||||
|
.stats { display: flex; gap: 16px; margin: 0 0 18px 0; }
|
||||||
|
.card { border: 1px solid #e5e7eb; border-radius: 8px; padding: 10px 12px; min-width: 160px; }
|
||||||
|
.muted { color: #6b7280; font-size: 12px; }
|
||||||
|
.small { font-size: 11px; color: #6b7280; }
|
||||||
|
table { border-collapse: collapse; width: 100%; }
|
||||||
|
th, td { border: 1px solid #e5e7eb; text-align: left; padding: 8px; font-size: 12px; }
|
||||||
|
th { background: #f9fafb; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>APN Relay</h1>
|
||||||
|
<p class="muted">MVP dashboard</p>
|
||||||
|
<div class="stats">
|
||||||
|
<div class="card">
|
||||||
|
<div class="muted">Registered devices</div>
|
||||||
|
<div>${Number(a?.value ?? 0)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="muted">Delivery log rows</div>
|
||||||
|
<div>${Number(b?.value ?? 0)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2>Registered devices</h2>
|
||||||
|
<p class="small">Most recent 100 registrations. Token values are masked to suffix only.</p>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>updated</th>
|
||||||
|
<th>created</th>
|
||||||
|
<th>token suffix</th>
|
||||||
|
<th>env</th>
|
||||||
|
<th>bundle</th>
|
||||||
|
<th>secret hash</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${
|
||||||
|
devices.length
|
||||||
|
? devices
|
||||||
|
.map(
|
||||||
|
(row) => `<tr>
|
||||||
|
<td>${esc(fmt(row.updated_at))}</td>
|
||||||
|
<td>${esc(fmt(row.created_at))}</td>
|
||||||
|
<td>${esc(tail(row.device_token))}</td>
|
||||||
|
<td>${esc(row.apns_env)}</td>
|
||||||
|
<td>${esc(row.bundle_id)}</td>
|
||||||
|
<td>${esc(`${row.secret_hash.slice(0, 12)}…`)}</td>
|
||||||
|
</tr>`,
|
||||||
|
)
|
||||||
|
.join("")
|
||||||
|
: `<tr><td colspan="6" class="muted">No devices registered.</td></tr>`
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<h2>Bundle breakdown</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>bundle</th>
|
||||||
|
<th>env</th>
|
||||||
|
<th>count</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${
|
||||||
|
byBundle.length
|
||||||
|
? byBundle
|
||||||
|
.map(
|
||||||
|
(row) => `<tr>
|
||||||
|
<td>${esc(row.bundle)}</td>
|
||||||
|
<td>${esc(row.env)}</td>
|
||||||
|
<td>${esc(Number(row.value ?? 0))}</td>
|
||||||
|
</tr>`,
|
||||||
|
)
|
||||||
|
.join("")
|
||||||
|
: `<tr><td colspan="3" class="muted">No device data.</td></tr>`
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<h2>Recent deliveries</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>time</th>
|
||||||
|
<th>event</th>
|
||||||
|
<th>session</th>
|
||||||
|
<th>status</th>
|
||||||
|
<th>error</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${rows
|
||||||
|
.map(
|
||||||
|
(row) => `<tr>
|
||||||
|
<td>${esc(fmt(row.created_at))}</td>
|
||||||
|
<td>${esc(row.event_type)}</td>
|
||||||
|
<td>${esc(row.session_id)}</td>
|
||||||
|
<td>${esc(row.status)}</td>
|
||||||
|
<td>${esc(row.error ?? "")}</td>
|
||||||
|
</tr>`,
|
||||||
|
)
|
||||||
|
.join("")}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
|
return c.html(html)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/v1/device/register", async (c) => {
|
||||||
|
const raw = await c.req.json().catch(() => undefined)
|
||||||
|
const check = reg.safeParse(raw)
|
||||||
|
if (!check.success) {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
error: "Invalid request body",
|
||||||
|
},
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const key = hash(check.data.secret)
|
||||||
|
const row = {
|
||||||
|
id: randomUUID(),
|
||||||
|
secret_hash: key,
|
||||||
|
device_token: check.data.deviceToken,
|
||||||
|
bundle_id: check.data.bundleId ?? env.APNS_DEFAULT_BUNDLE_ID,
|
||||||
|
apns_env: check.data.apnsEnv,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[relay] register", {
|
||||||
|
token: tail(row.device_token),
|
||||||
|
env: row.apns_env,
|
||||||
|
bundle: row.bundle_id,
|
||||||
|
secretHash: `${key.slice(0, 12)}...`,
|
||||||
|
})
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(device_registration)
|
||||||
|
.values(row)
|
||||||
|
.onDuplicateKeyUpdate({
|
||||||
|
set: {
|
||||||
|
bundle_id: row.bundle_id,
|
||||||
|
apns_env: row.apns_env,
|
||||||
|
updated_at: now,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return c.json({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/v1/device/unregister", async (c) => {
|
||||||
|
const raw = await c.req.json().catch(() => undefined)
|
||||||
|
const check = unreg.safeParse(raw)
|
||||||
|
if (!check.success) {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
error: "Invalid request body",
|
||||||
|
},
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = hash(check.data.secret)
|
||||||
|
|
||||||
|
console.log("[relay] unregister", {
|
||||||
|
token: tail(check.data.deviceToken),
|
||||||
|
secretHash: `${key.slice(0, 12)}...`,
|
||||||
|
})
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(device_registration)
|
||||||
|
.where(and(eq(device_registration.secret_hash, key), eq(device_registration.device_token, check.data.deviceToken)))
|
||||||
|
|
||||||
|
return c.json({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/v1/event", async (c) => {
|
||||||
|
const raw = await c.req.json().catch(() => undefined)
|
||||||
|
const check = evt.safeParse(raw)
|
||||||
|
if (!check.success) {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
error: "Invalid request body",
|
||||||
|
},
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = hash(check.data.secret)
|
||||||
|
const list = await db.select().from(device_registration).where(eq(device_registration.secret_hash, key))
|
||||||
|
console.log("[relay] event", {
|
||||||
|
type: check.data.eventType,
|
||||||
|
serverID: check.data.serverID,
|
||||||
|
session: check.data.sessionID,
|
||||||
|
secretHash: `${key.slice(0, 12)}...`,
|
||||||
|
devices: list.length,
|
||||||
|
})
|
||||||
|
if (!list.length) {
|
||||||
|
const [total] = await db.select({ value: sql<number>`count(*)` }).from(device_registration)
|
||||||
|
console.log("[relay] event:no-matching-devices", {
|
||||||
|
type: check.data.eventType,
|
||||||
|
serverID: check.data.serverID,
|
||||||
|
session: check.data.sessionID,
|
||||||
|
secretHash: `${key.slice(0, 12)}...`,
|
||||||
|
totalDevices: Number(total?.value ?? 0),
|
||||||
|
})
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
ok: true,
|
||||||
|
sent: 0,
|
||||||
|
failed: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = await Promise.all(
|
||||||
|
list.map(async (row) => {
|
||||||
|
const env = row.apns_env === "sandbox" ? "sandbox" : "production"
|
||||||
|
const payload = {
|
||||||
|
token: row.device_token,
|
||||||
|
bundle: row.bundle_id,
|
||||||
|
title: check.data.title ?? title(check.data.eventType),
|
||||||
|
body: check.data.body ?? body(check.data.eventType),
|
||||||
|
data: {
|
||||||
|
serverID: check.data.serverID,
|
||||||
|
eventType: check.data.eventType,
|
||||||
|
sessionID: check.data.sessionID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const first = await send({ ...payload, env })
|
||||||
|
if (first.ok || !bad(first.error)) {
|
||||||
|
if (!first.ok) {
|
||||||
|
console.log("[relay] send:error", {
|
||||||
|
token: tail(row.device_token),
|
||||||
|
env,
|
||||||
|
error: first.error,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return first
|
||||||
|
}
|
||||||
|
|
||||||
|
const alt = flip(env)
|
||||||
|
console.log("[relay] send:retry-env", {
|
||||||
|
token: tail(row.device_token),
|
||||||
|
from: env,
|
||||||
|
to: alt,
|
||||||
|
})
|
||||||
|
const second = await send({ ...payload, env: alt })
|
||||||
|
if (!second.ok) {
|
||||||
|
console.log("[relay] send:error", {
|
||||||
|
token: tail(row.device_token),
|
||||||
|
env: alt,
|
||||||
|
error: second.error,
|
||||||
|
})
|
||||||
|
return second
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(device_registration)
|
||||||
|
.set({ apns_env: alt, updated_at: Date.now() })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(device_registration.secret_hash, row.secret_hash),
|
||||||
|
eq(device_registration.device_token, row.device_token),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log("[relay] send:env-updated", {
|
||||||
|
token: tail(row.device_token),
|
||||||
|
env: alt,
|
||||||
|
})
|
||||||
|
return second
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
await db.insert(delivery_log).values(
|
||||||
|
out.map((item) => ({
|
||||||
|
id: randomUUID(),
|
||||||
|
secret_hash: key,
|
||||||
|
event_type: check.data.eventType,
|
||||||
|
session_id: check.data.sessionID,
|
||||||
|
status: item.ok ? "sent" : "failed",
|
||||||
|
error: item.error,
|
||||||
|
created_at: now,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const sent = out.filter((item) => item.ok).length
|
||||||
|
console.log("[relay] event:done", {
|
||||||
|
type: check.data.eventType,
|
||||||
|
session: check.data.sessionID,
|
||||||
|
sent,
|
||||||
|
failed: out.length - sent,
|
||||||
|
})
|
||||||
|
return c.json({
|
||||||
|
ok: true,
|
||||||
|
sent,
|
||||||
|
failed: out.length - sent,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await setup()
|
||||||
|
|
||||||
|
if (import.meta.main) {
|
||||||
|
Bun.serve({
|
||||||
|
port: env.PORT,
|
||||||
|
fetch: app.fetch,
|
||||||
|
})
|
||||||
|
console.log(`apn-relay listening on http://0.0.0.0:${env.PORT}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { app }
|
||||||
|
|
@ -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),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
@ -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;
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/tsconfig",
|
||||||
|
"extends": "@tsconfig/bun/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||||
|
"noUncheckedIndexedAccess": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import type { Message } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { findAssistantMessages } from "@opencode-ai/ui/find-assistant-messages"
|
||||||
|
|
||||||
|
function user(id: string): Message {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
role: "user",
|
||||||
|
sessionID: "session-1",
|
||||||
|
time: { created: 1 },
|
||||||
|
} as unknown as Message
|
||||||
|
}
|
||||||
|
|
||||||
|
function assistant(id: string, parentID: string): Message {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
role: "assistant",
|
||||||
|
sessionID: "session-1",
|
||||||
|
parentID,
|
||||||
|
time: { created: 1 },
|
||||||
|
} as unknown as Message
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("findAssistantMessages", () => {
|
||||||
|
test("normal ordering: assistant after user in array → found via forward scan", () => {
|
||||||
|
const messages = [user("u1"), assistant("a1", "u1")]
|
||||||
|
const result = findAssistantMessages(messages, 0, "u1")
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].id).toBe("a1")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("clock skew: assistant before user in array → found via backward scan", () => {
|
||||||
|
// When client clock is ahead, user ID sorts after assistant ID,
|
||||||
|
// so assistant appears earlier in the ID-sorted message array
|
||||||
|
const messages = [assistant("a1", "u1"), user("u1")]
|
||||||
|
const result = findAssistantMessages(messages, 1, "u1")
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].id).toBe("a1")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("no assistant messages → returns empty array", () => {
|
||||||
|
const messages = [user("u1"), user("u2")]
|
||||||
|
const result = findAssistantMessages(messages, 0, "u1")
|
||||||
|
expect(result).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("multiple assistant messages with matching parentID → all found", () => {
|
||||||
|
const messages = [user("u1"), assistant("a1", "u1"), assistant("a2", "u1")]
|
||||||
|
const result = findAssistantMessages(messages, 0, "u1")
|
||||||
|
expect(result).toHaveLength(2)
|
||||||
|
expect(result[0].id).toBe("a1")
|
||||||
|
expect(result[1].id).toBe("a2")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not return assistant messages with different parentID", () => {
|
||||||
|
const messages = [user("u1"), assistant("a1", "u1"), assistant("a2", "other")]
|
||||||
|
const result = findAssistantMessages(messages, 0, "u1")
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].id).toBe("a1")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("stops forward scan at next user message", () => {
|
||||||
|
const messages = [user("u1"), assistant("a1", "u1"), user("u2"), assistant("a2", "u1")]
|
||||||
|
const result = findAssistantMessages(messages, 0, "u1")
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].id).toBe("a1")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("stops backward scan at previous user message", () => {
|
||||||
|
const messages = [assistant("a0", "u1"), user("u0"), assistant("a1", "u1"), user("u1")]
|
||||||
|
const result = findAssistantMessages(messages, 3, "u1")
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].id).toBe("a1")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("invalid index returns empty array", () => {
|
||||||
|
const messages = [user("u1")]
|
||||||
|
expect(findAssistantMessages(messages, -1, "u1")).toHaveLength(0)
|
||||||
|
expect(findAssistantMessages(messages, 5, "u1")).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -16,6 +16,7 @@ import { useLanguage } from "@/context/language"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useServer } from "@/context/server"
|
import { useServer } from "@/context/server"
|
||||||
|
import { useSettings } from "@/context/settings"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useTerminal } from "@/context/terminal"
|
import { useTerminal } from "@/context/terminal"
|
||||||
import { focusTerminalById } from "@/pages/session/helpers"
|
import { focusTerminalById } from "@/pages/session/helpers"
|
||||||
|
|
@ -134,6 +135,7 @@ export function SessionHeader() {
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const settings = useSettings()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const terminal = useTerminal()
|
const terminal = useTerminal()
|
||||||
const { params, view } = useSessionLayout()
|
const { params, view } = useSessionLayout()
|
||||||
|
|
@ -151,6 +153,10 @@ export function SessionHeader() {
|
||||||
})
|
})
|
||||||
const hotkey = createMemo(() => command.keybind("file.open"))
|
const hotkey = createMemo(() => command.keybind("file.open"))
|
||||||
const os = createMemo(() => detectOS(platform))
|
const os = createMemo(() => detectOS(platform))
|
||||||
|
const search = createMemo(() => platform.platform !== "desktop" || settings.general.showSearch())
|
||||||
|
const tree = createMemo(() => platform.platform !== "desktop" || settings.general.showFileTree())
|
||||||
|
const term = createMemo(() => platform.platform !== "desktop" || settings.general.showTerminal())
|
||||||
|
const status = createMemo(() => platform.platform !== "desktop" || settings.general.showStatus())
|
||||||
|
|
||||||
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
|
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
|
||||||
finder: true,
|
finder: true,
|
||||||
|
|
@ -267,35 +273,37 @@ export function SessionHeader() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Show when={centerMount()}>
|
<Show when={search()}>
|
||||||
{(mount) => (
|
<Show when={centerMount()}>
|
||||||
<Portal mount={mount()}>
|
{(mount) => (
|
||||||
<Button
|
<Portal mount={mount()}>
|
||||||
type="button"
|
<Button
|
||||||
variant="ghost"
|
type="button"
|
||||||
size="small"
|
variant="ghost"
|
||||||
class="hidden md:flex w-[240px] max-w-full min-w-0 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-panel shadow-none cursor-default"
|
size="small"
|
||||||
onClick={() => command.trigger("file.open")}
|
class="hidden md:flex w-[240px] max-w-full min-w-0 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-panel shadow-none cursor-default"
|
||||||
aria-label={language.t("session.header.searchFiles")}
|
onClick={() => command.trigger("file.open")}
|
||||||
>
|
aria-label={language.t("session.header.searchFiles")}
|
||||||
<div class="flex min-w-0 flex-1 items-center overflow-visible">
|
>
|
||||||
<span class="flex-1 min-w-0 text-12-regular text-text-weak truncate text-left">
|
<div class="flex min-w-0 flex-1 items-center overflow-visible">
|
||||||
{language.t("session.header.search.placeholder", {
|
<span class="flex-1 min-w-0 text-12-regular text-text-weak truncate text-left">
|
||||||
project: name(),
|
{language.t("session.header.search.placeholder", {
|
||||||
})}
|
project: name(),
|
||||||
</span>
|
})}
|
||||||
</div>
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Show when={hotkey()}>
|
<Show when={hotkey()}>
|
||||||
{(keybind) => (
|
{(keybind) => (
|
||||||
<Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0 text-text-weaker">
|
<Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0 text-text-weaker">
|
||||||
{keybind()}
|
{keybind()}
|
||||||
</Keybind>
|
</Keybind>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
</Button>
|
</Button>
|
||||||
</Portal>
|
</Portal>
|
||||||
)}
|
)}
|
||||||
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={rightMount()}>
|
<Show when={rightMount()}>
|
||||||
{(mount) => (
|
{(mount) => (
|
||||||
|
|
@ -415,24 +423,28 @@ export function SessionHeader() {
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
|
<Show when={status()}>
|
||||||
<StatusPopover />
|
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
|
||||||
</Tooltip>
|
<StatusPopover />
|
||||||
<TooltipKeybind
|
</Tooltip>
|
||||||
title={language.t("command.terminal.toggle")}
|
</Show>
|
||||||
keybind={command.keybind("terminal.toggle")}
|
<Show when={term()}>
|
||||||
>
|
<TooltipKeybind
|
||||||
<Button
|
title={language.t("command.terminal.toggle")}
|
||||||
variant="ghost"
|
keybind={command.keybind("terminal.toggle")}
|
||||||
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
|
|
||||||
onClick={toggleTerminal}
|
|
||||||
aria-label={language.t("command.terminal.toggle")}
|
|
||||||
aria-expanded={view().terminal.opened()}
|
|
||||||
aria-controls="terminal-panel"
|
|
||||||
>
|
>
|
||||||
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
|
<Button
|
||||||
</Button>
|
variant="ghost"
|
||||||
</TooltipKeybind>
|
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
|
||||||
|
onClick={toggleTerminal}
|
||||||
|
aria-label={language.t("command.terminal.toggle")}
|
||||||
|
aria-expanded={view().terminal.opened()}
|
||||||
|
aria-controls="terminal-panel"
|
||||||
|
>
|
||||||
|
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
|
||||||
|
</Button>
|
||||||
|
</TooltipKeybind>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<div class="hidden md:flex items-center gap-1 shrink-0">
|
<div class="hidden md:flex items-center gap-1 shrink-0">
|
||||||
<TooltipKeybind
|
<TooltipKeybind
|
||||||
|
|
@ -451,30 +463,32 @@ export function SessionHeader() {
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
|
|
||||||
<TooltipKeybind
|
<Show when={tree()}>
|
||||||
title={language.t("command.fileTree.toggle")}
|
<TooltipKeybind
|
||||||
keybind={command.keybind("fileTree.toggle")}
|
title={language.t("command.fileTree.toggle")}
|
||||||
>
|
keybind={command.keybind("fileTree.toggle")}
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
class="titlebar-icon w-8 h-6 p-0 box-border"
|
|
||||||
onClick={() => layout.fileTree.toggle()}
|
|
||||||
aria-label={language.t("command.fileTree.toggle")}
|
|
||||||
aria-expanded={layout.fileTree.opened()}
|
|
||||||
aria-controls="file-tree-panel"
|
|
||||||
>
|
>
|
||||||
<div class="relative flex items-center justify-center size-4">
|
<Button
|
||||||
<Icon
|
variant="ghost"
|
||||||
size="small"
|
class="titlebar-icon w-8 h-6 p-0 box-border"
|
||||||
name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
|
onClick={() => layout.fileTree.toggle()}
|
||||||
classList={{
|
aria-label={language.t("command.fileTree.toggle")}
|
||||||
"text-icon-strong": layout.fileTree.opened(),
|
aria-expanded={layout.fileTree.opened()}
|
||||||
"text-icon-weak": !layout.fileTree.opened(),
|
aria-controls="file-tree-panel"
|
||||||
}}
|
>
|
||||||
/>
|
<div class="relative flex items-center justify-center size-4">
|
||||||
</div>
|
<Icon
|
||||||
</Button>
|
size="small"
|
||||||
</TooltipKeybind>
|
name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
|
||||||
|
classList={{
|
||||||
|
"text-icon-strong": layout.fileTree.opened(),
|
||||||
|
"text-icon-weak": !layout.fileTree.opened(),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</TooltipKeybind>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,7 @@ export const SettingsGeneral: Component = () => {
|
||||||
|
|
||||||
permission.disableAutoAccept(params.id, value)
|
permission.disableAutoAccept(params.id, value)
|
||||||
}
|
}
|
||||||
|
const desktop = createMemo(() => platform.platform === "desktop")
|
||||||
|
|
||||||
const check = () => {
|
const check = () => {
|
||||||
if (!platform.checkUpdate) return
|
if (!platform.checkUpdate) return
|
||||||
|
|
@ -279,6 +280,74 @@ export const SettingsGeneral: Component = () => {
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const AdvancedSection = () => (
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.advanced")}</h3>
|
||||||
|
|
||||||
|
<SettingsList>
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.row.showFileTree.title")}
|
||||||
|
description={language.t("settings.general.row.showFileTree.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-show-file-tree">
|
||||||
|
<Switch
|
||||||
|
checked={settings.general.showFileTree()}
|
||||||
|
onChange={(checked) => settings.general.setShowFileTree(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.row.showNavigation.title")}
|
||||||
|
description={language.t("settings.general.row.showNavigation.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-show-navigation">
|
||||||
|
<Switch
|
||||||
|
checked={settings.general.showNavigation()}
|
||||||
|
onChange={(checked) => settings.general.setShowNavigation(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.row.showSearch.title")}
|
||||||
|
description={language.t("settings.general.row.showSearch.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-show-search">
|
||||||
|
<Switch
|
||||||
|
checked={settings.general.showSearch()}
|
||||||
|
onChange={(checked) => settings.general.setShowSearch(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.row.showTerminal.title")}
|
||||||
|
description={language.t("settings.general.row.showTerminal.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-show-terminal">
|
||||||
|
<Switch
|
||||||
|
checked={settings.general.showTerminal()}
|
||||||
|
onChange={(checked) => settings.general.setShowTerminal(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.row.showStatus.title")}
|
||||||
|
description={language.t("settings.general.row.showStatus.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-show-status">
|
||||||
|
<Switch
|
||||||
|
checked={settings.general.showStatus()}
|
||||||
|
onChange={(checked) => settings.general.setShowStatus(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
</SettingsList>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
const AppearanceSection = () => (
|
const AppearanceSection = () => (
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3>
|
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3>
|
||||||
|
|
@ -609,6 +678,10 @@ export const SettingsGeneral: Component = () => {
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
<Show when={desktop()}>
|
||||||
|
<AdvancedSection />
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -518,6 +518,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||||
const next = new URL(url + `/pty/${id}/connect`)
|
const next = new URL(url + `/pty/${id}/connect`)
|
||||||
next.searchParams.set("directory", directory)
|
next.searchParams.set("directory", directory)
|
||||||
next.searchParams.set("cursor", String(seek))
|
next.searchParams.set("cursor", String(seek))
|
||||||
|
next.searchParams.set("auth_token", btoa(`${username}:${password}`))
|
||||||
next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
|
next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
|
||||||
next.username = username
|
next.username = username
|
||||||
next.password = password
|
next.password = password
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { useLayout } from "@/context/layout"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useCommand } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useSettings } from "@/context/settings"
|
||||||
import { applyPath, backPath, forwardPath } from "./titlebar-history"
|
import { applyPath, backPath, forwardPath } from "./titlebar-history"
|
||||||
|
|
||||||
type TauriDesktopWindow = {
|
type TauriDesktopWindow = {
|
||||||
|
|
@ -40,6 +41,7 @@ export function Titlebar() {
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const settings = useSettings()
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
|
@ -78,6 +80,7 @@ export function Titlebar() {
|
||||||
const canBack = createMemo(() => history.index > 0)
|
const canBack = createMemo(() => history.index > 0)
|
||||||
const canForward = createMemo(() => history.index < history.stack.length - 1)
|
const canForward = createMemo(() => history.index < history.stack.length - 1)
|
||||||
const hasProjects = createMemo(() => layout.projects.list().length > 0)
|
const hasProjects = createMemo(() => layout.projects.list().length > 0)
|
||||||
|
const nav = createMemo(() => platform.platform !== "desktop" || settings.general.showNavigation())
|
||||||
|
|
||||||
const back = () => {
|
const back = () => {
|
||||||
const next = backPath(history)
|
const next = backPath(history)
|
||||||
|
|
@ -252,7 +255,7 @@ export function Titlebar() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={hasProjects()}>
|
<Show when={hasProjects() && nav()}>
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-0 transition-transform"
|
class="flex items-center gap-0 transition-transform"
|
||||||
classList={{
|
classList={{
|
||||||
|
|
@ -287,6 +290,9 @@ export function Titlebar() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
|
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
|
||||||
|
<div class="bg-icon-interactive-base text-background-base font-medium px-2 rounded-sm uppercase font-mono">
|
||||||
|
BETA
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="min-w-0 flex items-center justify-center pointer-events-none">
|
<div class="min-w-0 flex items-center justify-center pointer-events-none">
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,11 @@ export interface Settings {
|
||||||
autoSave: boolean
|
autoSave: boolean
|
||||||
releaseNotes: boolean
|
releaseNotes: boolean
|
||||||
followup: "queue" | "steer"
|
followup: "queue" | "steer"
|
||||||
|
showFileTree: boolean
|
||||||
|
showNavigation: boolean
|
||||||
|
showSearch: boolean
|
||||||
|
showStatus: boolean
|
||||||
|
showTerminal: boolean
|
||||||
showReasoningSummaries: boolean
|
showReasoningSummaries: boolean
|
||||||
shellToolPartsExpanded: boolean
|
shellToolPartsExpanded: boolean
|
||||||
editToolPartsExpanded: boolean
|
editToolPartsExpanded: boolean
|
||||||
|
|
@ -89,6 +94,11 @@ const defaultSettings: Settings = {
|
||||||
autoSave: true,
|
autoSave: true,
|
||||||
releaseNotes: true,
|
releaseNotes: true,
|
||||||
followup: "steer",
|
followup: "steer",
|
||||||
|
showFileTree: false,
|
||||||
|
showNavigation: false,
|
||||||
|
showSearch: false,
|
||||||
|
showStatus: false,
|
||||||
|
showTerminal: false,
|
||||||
showReasoningSummaries: false,
|
showReasoningSummaries: false,
|
||||||
shellToolPartsExpanded: false,
|
shellToolPartsExpanded: false,
|
||||||
editToolPartsExpanded: false,
|
editToolPartsExpanded: false,
|
||||||
|
|
@ -162,6 +172,26 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||||
setFollowup(value: "queue" | "steer") {
|
setFollowup(value: "queue" | "steer") {
|
||||||
setStore("general", "followup", value === "queue" ? "steer" : value)
|
setStore("general", "followup", value === "queue" ? "steer" : value)
|
||||||
},
|
},
|
||||||
|
showFileTree: withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree),
|
||||||
|
setShowFileTree(value: boolean) {
|
||||||
|
setStore("general", "showFileTree", value)
|
||||||
|
},
|
||||||
|
showNavigation: withFallback(() => store.general?.showNavigation, defaultSettings.general.showNavigation),
|
||||||
|
setShowNavigation(value: boolean) {
|
||||||
|
setStore("general", "showNavigation", value)
|
||||||
|
},
|
||||||
|
showSearch: withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch),
|
||||||
|
setShowSearch(value: boolean) {
|
||||||
|
setStore("general", "showSearch", value)
|
||||||
|
},
|
||||||
|
showStatus: withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus),
|
||||||
|
setShowStatus(value: boolean) {
|
||||||
|
setStore("general", "showStatus", value)
|
||||||
|
},
|
||||||
|
showTerminal: withFallback(() => store.general?.showTerminal, defaultSettings.general.showTerminal),
|
||||||
|
setShowTerminal(value: boolean) {
|
||||||
|
setStore("general", "showTerminal", value)
|
||||||
|
},
|
||||||
showReasoningSummaries: withFallback(
|
showReasoningSummaries: withFallback(
|
||||||
() => store.general?.showReasoningSummaries,
|
() => store.general?.showReasoningSummaries,
|
||||||
defaultSettings.general.showReasoningSummaries,
|
defaultSettings.general.showReasoningSummaries,
|
||||||
|
|
|
||||||
|
|
@ -719,6 +719,7 @@ export const dict = {
|
||||||
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
|
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
|
||||||
|
|
||||||
"settings.general.section.appearance": "Appearance",
|
"settings.general.section.appearance": "Appearance",
|
||||||
|
"settings.general.section.advanced": "Advanced",
|
||||||
"settings.general.section.notifications": "System notifications",
|
"settings.general.section.notifications": "System notifications",
|
||||||
"settings.general.section.updates": "Updates",
|
"settings.general.section.updates": "Updates",
|
||||||
"settings.general.section.sounds": "Sound effects",
|
"settings.general.section.sounds": "Sound effects",
|
||||||
|
|
@ -741,6 +742,16 @@ export const dict = {
|
||||||
"settings.general.row.followup.description": "Choose whether follow-up prompts steer immediately or wait in a queue",
|
"settings.general.row.followup.description": "Choose whether follow-up prompts steer immediately or wait in a queue",
|
||||||
"settings.general.row.followup.option.queue": "Queue",
|
"settings.general.row.followup.option.queue": "Queue",
|
||||||
"settings.general.row.followup.option.steer": "Steer",
|
"settings.general.row.followup.option.steer": "Steer",
|
||||||
|
"settings.general.row.showFileTree.title": "File tree",
|
||||||
|
"settings.general.row.showFileTree.description": "Show the file tree toggle and panel in desktop sessions",
|
||||||
|
"settings.general.row.showNavigation.title": "Navigation controls",
|
||||||
|
"settings.general.row.showNavigation.description": "Show the back and forward buttons in the desktop title bar",
|
||||||
|
"settings.general.row.showSearch.title": "Command palette",
|
||||||
|
"settings.general.row.showSearch.description": "Show the search and command palette button in the desktop title bar",
|
||||||
|
"settings.general.row.showTerminal.title": "Terminal",
|
||||||
|
"settings.general.row.showTerminal.description": "Show the terminal button in the desktop title bar",
|
||||||
|
"settings.general.row.showStatus.title": "Server status",
|
||||||
|
"settings.general.row.showStatus.description": "Show the server status button in the desktop title bar",
|
||||||
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
|
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
|
||||||
"settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
|
"settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
|
||||||
"settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts",
|
"settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts",
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useMutation } from "@tanstack/solid-query"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
|
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
|
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
|
@ -73,6 +74,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||||
customOn: cached?.customOn ?? ([] as boolean[]),
|
customOn: cached?.customOn ?? ([] as boolean[]),
|
||||||
editing: false,
|
editing: false,
|
||||||
focus: 0,
|
focus: 0,
|
||||||
|
collapsed: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
let root: HTMLDivElement | undefined
|
let root: HTMLDivElement | undefined
|
||||||
|
|
@ -87,6 +89,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||||
const on = createMemo(() => store.customOn[store.tab] === true)
|
const on = createMemo(() => store.customOn[store.tab] === true)
|
||||||
const multi = createMemo(() => question()?.multiple === true)
|
const multi = createMemo(() => question()?.multiple === true)
|
||||||
const count = createMemo(() => options().length + 1)
|
const count = createMemo(() => options().length + 1)
|
||||||
|
const num = createMemo(() => store.answers[store.tab]?.length ?? 0)
|
||||||
|
|
||||||
const summary = createMemo(() => {
|
const summary = createMemo(() => {
|
||||||
const n = Math.min(store.tab + 1, total())
|
const n = Math.min(store.tab + 1, total())
|
||||||
|
|
@ -98,6 +101,8 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||||
|
|
||||||
const last = createMemo(() => store.tab >= total() - 1)
|
const last = createMemo(() => store.tab >= total() - 1)
|
||||||
|
|
||||||
|
const fold = () => setStore("collapsed", (value) => !value)
|
||||||
|
|
||||||
const customUpdate = (value: string, selected: boolean = on()) => {
|
const customUpdate = (value: string, selected: boolean = on()) => {
|
||||||
const prev = input().trim()
|
const prev = input().trim()
|
||||||
const next = value.trim()
|
const next = value.trim()
|
||||||
|
|
@ -426,9 +431,21 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||||
ref={(el) => (root = el)}
|
ref={(el) => (root = el)}
|
||||||
onKeyDown={nav}
|
onKeyDown={nav}
|
||||||
header={
|
header={
|
||||||
<>
|
<div
|
||||||
|
data-action="session-question-toggle"
|
||||||
|
class="flex flex-1 min-w-0 items-center gap-2 cursor-default select-none"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
style={{ margin: "0 -10px", padding: "0 0 0 10px" }}
|
||||||
|
onClick={fold}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key !== "Enter" && event.key !== " ") return
|
||||||
|
event.preventDefault()
|
||||||
|
fold()
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div data-slot="question-header-title">{summary()}</div>
|
<div data-slot="question-header-title">{summary()}</div>
|
||||||
<div data-slot="question-progress">
|
<div data-slot="question-progress" class="ml-auto mr-1">
|
||||||
<For each={questions()}>
|
<For each={questions()}>
|
||||||
{(_, i) => (
|
{(_, i) => (
|
||||||
<button
|
<button
|
||||||
|
|
@ -437,13 +454,38 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||||
data-active={i() === store.tab}
|
data-active={i() === store.tab}
|
||||||
data-answered={answered(i())}
|
data-answered={answered(i())}
|
||||||
disabled={sending()}
|
disabled={sending()}
|
||||||
onClick={() => jump(i())}
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
}}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
jump(i())
|
||||||
|
}}
|
||||||
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
|
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</>
|
<div>
|
||||||
|
<IconButton
|
||||||
|
data-action="session-question-toggle-button"
|
||||||
|
icon="chevron-down"
|
||||||
|
size="normal"
|
||||||
|
variant="ghost"
|
||||||
|
classList={{ "rotate-180": store.collapsed }}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
}}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
fold()
|
||||||
|
}}
|
||||||
|
aria-label={store.collapsed ? language.t("session.todo.expand") : language.t("session.todo.collapse")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
|
|
@ -469,99 +511,137 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div data-slot="question-text">{question()?.question}</div>
|
<div
|
||||||
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
|
data-slot="question-text"
|
||||||
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
|
class="cursor-default"
|
||||||
|
classList={{
|
||||||
|
"mb-6": store.collapsed && num() === 0,
|
||||||
|
}}
|
||||||
|
role={store.collapsed ? "button" : undefined}
|
||||||
|
tabIndex={store.collapsed ? 0 : undefined}
|
||||||
|
onClick={fold}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (!store.collapsed) return
|
||||||
|
if (event.key !== "Enter" && event.key !== " ") return
|
||||||
|
event.preventDefault()
|
||||||
|
fold()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{question()?.question}
|
||||||
|
</div>
|
||||||
|
<Show when={store.collapsed && num() > 0}>
|
||||||
|
<div data-slot="question-hint" class="cursor-default mb-6">
|
||||||
|
{num()} answer{num() === 1 ? "" : "s"} selected
|
||||||
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<div data-slot="question-options">
|
<div data-slot="question-answers" hidden={store.collapsed} aria-hidden={store.collapsed}>
|
||||||
<For each={options()}>
|
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
|
||||||
{(opt, i) => (
|
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
|
||||||
<Option
|
</Show>
|
||||||
multi={multi()}
|
<div data-slot="question-options">
|
||||||
picked={picked(opt.label)}
|
<For each={options()}>
|
||||||
label={opt.label}
|
{(opt, i) => (
|
||||||
description={opt.description}
|
<Option
|
||||||
disabled={sending()}
|
multi={multi()}
|
||||||
ref={(el) => (optsRef[i()] = el)}
|
picked={picked(opt.label)}
|
||||||
onFocus={() => setStore("focus", i())}
|
label={opt.label}
|
||||||
onClick={() => selectOption(i())}
|
description={opt.description}
|
||||||
/>
|
disabled={sending()}
|
||||||
)}
|
ref={(el) => (optsRef[i()] = el)}
|
||||||
</For>
|
onFocus={() => setStore("focus", i())}
|
||||||
|
onClick={() => selectOption(i())}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
|
||||||
<Show
|
<Show
|
||||||
when={store.editing}
|
when={store.editing}
|
||||||
fallback={
|
fallback={
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
ref={customRef}
|
ref={customRef}
|
||||||
|
data-slot="question-option"
|
||||||
|
data-custom="true"
|
||||||
|
data-picked={on()}
|
||||||
|
role={multi() ? "checkbox" : "radio"}
|
||||||
|
aria-checked={on()}
|
||||||
|
disabled={sending()}
|
||||||
|
onFocus={() => setStore("focus", options().length)}
|
||||||
|
onClick={customOpen}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
data-slot="question-option-check"
|
||||||
|
aria-hidden="true"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
customToggle()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
|
||||||
|
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
||||||
|
<Icon name="check-small" size="small" />
|
||||||
|
</Show>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span data-slot="question-option-main">
|
||||||
|
<span data-slot="option-label">{customLabel()}</span>
|
||||||
|
<span data-slot="option-description">{input() || customPlaceholder()}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form
|
||||||
data-slot="question-option"
|
data-slot="question-option"
|
||||||
data-custom="true"
|
data-custom="true"
|
||||||
data-picked={on()}
|
data-picked={on()}
|
||||||
role={multi() ? "checkbox" : "radio"}
|
role={multi() ? "checkbox" : "radio"}
|
||||||
aria-checked={on()}
|
aria-checked={on()}
|
||||||
disabled={sending()}
|
onMouseDown={(e) => {
|
||||||
onFocus={() => setStore("focus", options().length)}
|
if (sending()) {
|
||||||
onClick={customOpen}
|
e.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.target instanceof HTMLTextAreaElement) return
|
||||||
|
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
|
||||||
|
if (input instanceof HTMLTextAreaElement) input.focus()
|
||||||
|
}}
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
commitCustom()
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
|
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
|
||||||
<span data-slot="question-option-main">
|
<span data-slot="question-option-main">
|
||||||
<span data-slot="option-label">{customLabel()}</span>
|
<span data-slot="option-label">{customLabel()}</span>
|
||||||
<span data-slot="option-description">{input() || customPlaceholder()}</span>
|
<textarea
|
||||||
</span>
|
ref={focusCustom}
|
||||||
</button>
|
data-slot="question-custom-input"
|
||||||
}
|
placeholder={customPlaceholder()}
|
||||||
>
|
value={input()}
|
||||||
<form
|
rows={1}
|
||||||
data-slot="question-option"
|
disabled={sending()}
|
||||||
data-custom="true"
|
onKeyDown={(e) => {
|
||||||
data-picked={on()}
|
if (e.key === "Escape") {
|
||||||
role={multi() ? "checkbox" : "radio"}
|
e.preventDefault()
|
||||||
aria-checked={on()}
|
setStore("editing", false)
|
||||||
onMouseDown={(e) => {
|
focus(options().length)
|
||||||
if (sending()) {
|
return
|
||||||
e.preventDefault()
|
}
|
||||||
return
|
if ((e.metaKey || e.ctrlKey) && !e.altKey) return
|
||||||
}
|
if (e.key !== "Enter" || e.shiftKey) return
|
||||||
if (e.target instanceof HTMLTextAreaElement) return
|
|
||||||
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
|
|
||||||
if (input instanceof HTMLTextAreaElement) input.focus()
|
|
||||||
}}
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
commitCustom()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
|
|
||||||
<span data-slot="question-option-main">
|
|
||||||
<span data-slot="option-label">{customLabel()}</span>
|
|
||||||
<textarea
|
|
||||||
ref={focusCustom}
|
|
||||||
data-slot="question-custom-input"
|
|
||||||
placeholder={customPlaceholder()}
|
|
||||||
value={input()}
|
|
||||||
rows={1}
|
|
||||||
disabled={sending()}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setStore("editing", false)
|
commitCustom()
|
||||||
focus(options().length)
|
}}
|
||||||
return
|
onInput={(e) => {
|
||||||
}
|
customUpdate(e.currentTarget.value)
|
||||||
if ((e.metaKey || e.ctrlKey) && !e.altKey) return
|
resizeInput(e.currentTarget)
|
||||||
if (e.key !== "Enter" || e.shiftKey) return
|
}}
|
||||||
e.preventDefault()
|
/>
|
||||||
commitCustom()
|
</span>
|
||||||
}}
|
</form>
|
||||||
onInput={(e) => {
|
</Show>
|
||||||
customUpdate(e.currentTarget.value)
|
</div>
|
||||||
resizeInput(e.currentTarget)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</form>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
</DockPrompt>
|
</DockPrompt>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ import { useCommand } from "@/context/command"
|
||||||
import { useFile, type SelectedLineRange } from "@/context/file"
|
import { useFile, type SelectedLineRange } from "@/context/file"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { useSettings } from "@/context/settings"
|
||||||
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
|
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
|
||||||
import { FileTabContent } from "@/pages/session/file-tabs"
|
import { FileTabContent } from "@/pages/session/file-tabs"
|
||||||
import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
|
import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
|
||||||
|
|
@ -39,6 +41,8 @@ export function SessionSidePanel(props: {
|
||||||
size: Sizing
|
size: Sizing
|
||||||
}) {
|
}) {
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
|
const platform = usePlatform()
|
||||||
|
const settings = useSettings()
|
||||||
const file = useFile()
|
const file = useFile()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
|
|
@ -46,9 +50,10 @@ export function SessionSidePanel(props: {
|
||||||
const { sessionKey, tabs, view } = useSessionLayout()
|
const { sessionKey, tabs, view } = useSessionLayout()
|
||||||
|
|
||||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||||
|
const shown = createMemo(() => platform.platform !== "desktop" || settings.general.showFileTree())
|
||||||
|
|
||||||
const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
|
const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
|
||||||
const fileOpen = createMemo(() => isDesktop() && layout.fileTree.opened())
|
const fileOpen = createMemo(() => isDesktop() && shown() && layout.fileTree.opened())
|
||||||
const open = createMemo(() => reviewOpen() || fileOpen())
|
const open = createMemo(() => reviewOpen() || fileOpen())
|
||||||
const reviewTab = createMemo(() => isDesktop())
|
const reviewTab = createMemo(() => isDesktop())
|
||||||
const panelWidth = createMemo(() => {
|
const panelWidth = createMemo(() => {
|
||||||
|
|
@ -341,98 +346,100 @@ export function SessionSidePanel(props: {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<Show when={shown()}>
|
||||||
id="file-tree-panel"
|
|
||||||
aria-hidden={!fileOpen()}
|
|
||||||
inert={!fileOpen()}
|
|
||||||
class="relative min-w-0 h-full shrink-0 overflow-hidden"
|
|
||||||
classList={{
|
|
||||||
"pointer-events-none": !fileOpen(),
|
|
||||||
"transition-[width] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
|
||||||
!props.size.active(),
|
|
||||||
}}
|
|
||||||
style={{ width: treeWidth() }}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
class="h-full flex flex-col overflow-hidden group/filetree"
|
id="file-tree-panel"
|
||||||
classList={{ "border-l border-border-weaker-base": reviewOpen() }}
|
aria-hidden={!fileOpen()}
|
||||||
|
inert={!fileOpen()}
|
||||||
|
class="relative min-w-0 h-full shrink-0 overflow-hidden"
|
||||||
|
classList={{
|
||||||
|
"pointer-events-none": !fileOpen(),
|
||||||
|
"transition-[width] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
||||||
|
!props.size.active(),
|
||||||
|
}}
|
||||||
|
style={{ width: treeWidth() }}
|
||||||
>
|
>
|
||||||
<Tabs
|
<div
|
||||||
variant="pill"
|
class="h-full flex flex-col overflow-hidden group/filetree"
|
||||||
value={fileTreeTab()}
|
classList={{ "border-l border-border-weaker-base": reviewOpen() }}
|
||||||
onChange={setFileTreeTabValue}
|
|
||||||
class="h-full"
|
|
||||||
data-scope="filetree"
|
|
||||||
>
|
>
|
||||||
<Tabs.List>
|
<Tabs
|
||||||
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
|
variant="pill"
|
||||||
{props.reviewCount()}{" "}
|
value={fileTreeTab()}
|
||||||
{language.t(
|
onChange={setFileTreeTabValue}
|
||||||
props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
|
class="h-full"
|
||||||
)}
|
data-scope="filetree"
|
||||||
</Tabs.Trigger>
|
>
|
||||||
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
|
<Tabs.List>
|
||||||
{language.t("session.files.all")}
|
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
|
||||||
</Tabs.Trigger>
|
{props.reviewCount()}{" "}
|
||||||
</Tabs.List>
|
{language.t(
|
||||||
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
|
props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
|
||||||
<Switch>
|
)}
|
||||||
<Match when={props.hasReview() || !props.diffsReady()}>
|
</Tabs.Trigger>
|
||||||
<Show
|
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
|
||||||
when={props.diffsReady()}
|
{language.t("session.files.all")}
|
||||||
fallback={
|
</Tabs.Trigger>
|
||||||
<div class="px-2 py-2 text-12-regular text-text-weak">
|
</Tabs.List>
|
||||||
{language.t("common.loading")}
|
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
|
||||||
{language.t("common.loading.ellipsis")}
|
<Switch>
|
||||||
</div>
|
<Match when={props.hasReview() || !props.diffsReady()}>
|
||||||
}
|
<Show
|
||||||
>
|
when={props.diffsReady()}
|
||||||
|
fallback={
|
||||||
|
<div class="px-2 py-2 text-12-regular text-text-weak">
|
||||||
|
{language.t("common.loading")}
|
||||||
|
{language.t("common.loading.ellipsis")}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FileTree
|
||||||
|
path=""
|
||||||
|
class="pt-3"
|
||||||
|
allowed={diffFiles()}
|
||||||
|
kinds={kinds()}
|
||||||
|
draggable={false}
|
||||||
|
active={props.activeDiff}
|
||||||
|
onFileClick={(node) => props.focusReviewDiff(node.path)}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</Match>
|
||||||
|
<Match when={true}>{empty(props.empty())}</Match>
|
||||||
|
</Switch>
|
||||||
|
</Tabs.Content>
|
||||||
|
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
|
||||||
|
<Switch>
|
||||||
|
<Match when={nofiles()}>{empty(language.t("session.files.empty"))}</Match>
|
||||||
|
<Match when={true}>
|
||||||
<FileTree
|
<FileTree
|
||||||
path=""
|
path=""
|
||||||
class="pt-3"
|
class="pt-3"
|
||||||
allowed={diffFiles()}
|
modified={diffFiles()}
|
||||||
kinds={kinds()}
|
kinds={kinds()}
|
||||||
draggable={false}
|
onFileClick={(node) => openTab(file.tab(node.path))}
|
||||||
active={props.activeDiff}
|
|
||||||
onFileClick={(node) => props.focusReviewDiff(node.path)}
|
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Match>
|
||||||
</Match>
|
</Switch>
|
||||||
<Match when={true}>{empty(props.empty())}</Match>
|
</Tabs.Content>
|
||||||
</Switch>
|
</Tabs>
|
||||||
</Tabs.Content>
|
|
||||||
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
|
|
||||||
<Switch>
|
|
||||||
<Match when={nofiles()}>{empty(language.t("session.files.empty"))}</Match>
|
|
||||||
<Match when={true}>
|
|
||||||
<FileTree
|
|
||||||
path=""
|
|
||||||
class="pt-3"
|
|
||||||
modified={diffFiles()}
|
|
||||||
kinds={kinds()}
|
|
||||||
onFileClick={(node) => openTab(file.tab(node.path))}
|
|
||||||
/>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</Tabs.Content>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
<Show when={fileOpen()}>
|
|
||||||
<div onPointerDown={() => props.size.start()}>
|
|
||||||
<ResizeHandle
|
|
||||||
direction="horizontal"
|
|
||||||
edge="start"
|
|
||||||
size={layout.fileTree.width()}
|
|
||||||
min={200}
|
|
||||||
max={480}
|
|
||||||
onResize={(width) => {
|
|
||||||
props.size.touch()
|
|
||||||
layout.fileTree.resize(width)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
<Show when={fileOpen()}>
|
||||||
</div>
|
<div onPointerDown={() => props.size.start()}>
|
||||||
|
<ResizeHandle
|
||||||
|
direction="horizontal"
|
||||||
|
edge="start"
|
||||||
|
size={layout.fileTree.width()}
|
||||||
|
min={200}
|
||||||
|
max={480}
|
||||||
|
onResize={(width) => {
|
||||||
|
props.size.touch()
|
||||||
|
layout.fileTree.resize(width)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,10 @@ import { useLanguage } from "@/context/language"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
import { usePrompt } from "@/context/prompt"
|
import { usePrompt } from "@/context/prompt"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
|
import { useSettings } from "@/context/settings"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useTerminal } from "@/context/terminal"
|
import { useTerminal } from "@/context/terminal"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
|
|
@ -39,8 +41,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const permission = usePermission()
|
const permission = usePermission()
|
||||||
|
const platform = usePlatform()
|
||||||
const prompt = usePrompt()
|
const prompt = usePrompt()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
|
const settings = useSettings()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const terminal = useTerminal()
|
const terminal = useTerminal()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
|
|
@ -66,6 +70,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||||
})
|
})
|
||||||
const activeFileTab = tabState.activeFileTab
|
const activeFileTab = tabState.activeFileTab
|
||||||
const closableTab = tabState.closableTab
|
const closableTab = tabState.closableTab
|
||||||
|
const shown = () => platform.platform !== "desktop" || settings.general.showFileTree()
|
||||||
|
|
||||||
const idle = { type: "idle" as const }
|
const idle = { type: "idle" as const }
|
||||||
const status = () => sync.data.session_status[params.id ?? ""] ?? idle
|
const status = () => sync.data.session_status[params.id ?? ""] ?? idle
|
||||||
|
|
@ -457,12 +462,16 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||||
keybind: "mod+shift+r",
|
keybind: "mod+shift+r",
|
||||||
onSelect: () => view().reviewPanel.toggle(),
|
onSelect: () => view().reviewPanel.toggle(),
|
||||||
}),
|
}),
|
||||||
viewCommand({
|
...(shown()
|
||||||
id: "fileTree.toggle",
|
? [
|
||||||
title: language.t("command.fileTree.toggle"),
|
viewCommand({
|
||||||
keybind: "mod+\\",
|
id: "fileTree.toggle",
|
||||||
onSelect: () => layout.fileTree.toggle(),
|
title: language.t("command.fileTree.toggle"),
|
||||||
}),
|
keybind: "mod+\\",
|
||||||
|
onSelect: () => layout.fileTree.toggle(),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
viewCommand({
|
viewCommand({
|
||||||
id: "input.focus",
|
id: "input.focus",
|
||||||
title: language.t("command.input.focus"),
|
title: language.t("command.input.focus"),
|
||||||
|
|
|
||||||
|
|
@ -34,11 +34,6 @@ const getBase = (): Configuration => ({
|
||||||
},
|
},
|
||||||
files: ["out/**/*", "resources/**/*"],
|
files: ["out/**/*", "resources/**/*"],
|
||||||
extraResources: [
|
extraResources: [
|
||||||
{
|
|
||||||
from: "resources/",
|
|
||||||
to: "",
|
|
||||||
filter: ["opencode-cli*"],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
from: "native/",
|
from: "native/",
|
||||||
to: "native/",
|
to: "native/",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { defineConfig } from "electron-vite"
|
import { defineConfig } from "electron-vite"
|
||||||
import appPlugin from "@opencode-ai/app/vite"
|
import appPlugin from "@opencode-ai/app/vite"
|
||||||
|
import * as fs from "node:fs/promises"
|
||||||
|
|
||||||
const channel = (() => {
|
const channel = (() => {
|
||||||
const raw = process.env.OPENCODE_CHANNEL
|
const raw = process.env.OPENCODE_CHANNEL
|
||||||
|
|
@ -7,6 +8,10 @@ const channel = (() => {
|
||||||
return "dev"
|
return "dev"
|
||||||
})()
|
})()
|
||||||
|
|
||||||
|
const OPENCODE_SERVER_DIST = "../opencode/dist/node"
|
||||||
|
|
||||||
|
const nodePtyPkg = `@lydell/node-pty-${process.platform}-${process.arch}`
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
main: {
|
main: {
|
||||||
define: {
|
define: {
|
||||||
|
|
@ -16,7 +21,33 @@ export default defineConfig({
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: { index: "src/main/index.ts" },
|
input: { index: "src/main/index.ts" },
|
||||||
},
|
},
|
||||||
|
externalizeDeps: { include: [nodePtyPkg] },
|
||||||
},
|
},
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
name: "opencode:node-pty-narrower",
|
||||||
|
enforce: "pre",
|
||||||
|
resolveId(s) {
|
||||||
|
if (s === "@lydell/node-pty") return nodePtyPkg
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "opencode:virtual-server-module",
|
||||||
|
enforce: "pre",
|
||||||
|
resolveId(id) {
|
||||||
|
if (id === "virtual:opencode-server") return this.resolve(`${OPENCODE_SERVER_DIST}/node.js`)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "opencode:copy-server-assets",
|
||||||
|
async writeBundle() {
|
||||||
|
for (const l of await fs.readdir(OPENCODE_SERVER_DIST)) {
|
||||||
|
if (!l.endsWith(".wasm")) continue
|
||||||
|
await fs.writeFile(`./out/main/chunks/${l}`, await fs.readFile(`${OPENCODE_SERVER_DIST}/${l}`))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
preload: {
|
preload: {
|
||||||
build: {
|
build: {
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
"typecheck": "tsgo -b",
|
"typecheck": "tsgo -b",
|
||||||
"predev": "bun ./scripts/predev.ts",
|
"predev": "bun ./scripts/predev.ts",
|
||||||
"dev": "electron-vite dev",
|
"dev": "electron-vite dev",
|
||||||
"prebuild": "bun ./scripts/copy-icons.ts",
|
"prebuild": "bun ./scripts/prebuild.ts",
|
||||||
"build": "electron-vite build",
|
"build": "electron-vite build",
|
||||||
"preview": "electron-vite preview",
|
"preview": "electron-vite preview",
|
||||||
"package": "electron-builder --config electron-builder.config.ts",
|
"package": "electron-builder --config electron-builder.config.ts",
|
||||||
|
|
@ -24,31 +24,42 @@
|
||||||
},
|
},
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/app": "workspace:*",
|
|
||||||
"@opencode-ai/ui": "workspace:*",
|
|
||||||
"@solid-primitives/i18n": "2.2.1",
|
|
||||||
"@solid-primitives/storage": "catalog:",
|
|
||||||
"@solidjs/meta": "catalog:",
|
|
||||||
"@solidjs/router": "0.15.4",
|
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"electron-context-menu": "4.1.2",
|
"electron-context-menu": "4.1.2",
|
||||||
"electron-log": "^5",
|
"electron-log": "^5",
|
||||||
"electron-store": "^10",
|
"electron-store": "^10",
|
||||||
"electron-updater": "^6",
|
"electron-updater": "^6",
|
||||||
"electron-window-state": "^5.0.3",
|
"electron-window-state": "^5.0.3",
|
||||||
"marked": "^15",
|
"marked": "^15"
|
||||||
"solid-js": "catalog:",
|
|
||||||
"tree-kill": "^1.2.2"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@actions/artifact": "4.0.0",
|
"@actions/artifact": "4.0.0",
|
||||||
|
"@lydell/node-pty": "catalog:",
|
||||||
|
"@opencode-ai/app": "workspace:*",
|
||||||
|
"@opencode-ai/ui": "workspace:*",
|
||||||
|
"@solid-primitives/i18n": "2.2.1",
|
||||||
|
"@solid-primitives/storage": "catalog:",
|
||||||
|
"@solidjs/meta": "catalog:",
|
||||||
|
"@solidjs/router": "0.15.4",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
|
"@valibot/to-json-schema": "1.6.0",
|
||||||
"electron": "40.4.1",
|
"electron": "40.4.1",
|
||||||
"electron-builder": "^26",
|
"electron-builder": "^26",
|
||||||
"electron-vite": "^5",
|
"electron-vite": "^5",
|
||||||
|
"solid-js": "catalog:",
|
||||||
|
"sury": "11.0.0-alpha.4",
|
||||||
"typescript": "~5.6.2",
|
"typescript": "~5.6.2",
|
||||||
"vite": "catalog:"
|
"vite": "catalog:",
|
||||||
|
"zod-openapi": "5.4.6"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@lydell/node-pty-darwin-arm64": "1.2.0-beta.10",
|
||||||
|
"@lydell/node-pty-darwin-x64": "1.2.0-beta.10",
|
||||||
|
"@lydell/node-pty-linux-arm64": "1.2.0-beta.10",
|
||||||
|
"@lydell/node-pty-linux-x64": "1.2.0-beta.10",
|
||||||
|
"@lydell/node-pty-win32-arm64": "1.2.0-beta.10",
|
||||||
|
"@lydell/node-pty-win32-x64": "1.2.0-beta.10"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
#!/usr/bin/env bun
|
||||||
|
import { $ } from "bun"
|
||||||
|
|
||||||
|
import { resolveChannel } from "./utils"
|
||||||
|
|
||||||
|
const channel = resolveChannel()
|
||||||
|
await $`bun ./scripts/copy-icons.ts ${channel}`
|
||||||
|
|
||||||
|
await $`cd ../opencode && bun script/build-node.ts`
|
||||||
|
|
@ -1,17 +1,5 @@
|
||||||
import { $ } from "bun"
|
import { $ } from "bun"
|
||||||
|
|
||||||
import { copyBinaryToSidecarFolder, getCurrentSidecar, windowsify } from "./utils"
|
|
||||||
|
|
||||||
await $`bun ./scripts/copy-icons.ts ${process.env.OPENCODE_CHANNEL ?? "dev"}`
|
await $`bun ./scripts/copy-icons.ts ${process.env.OPENCODE_CHANNEL ?? "dev"}`
|
||||||
|
|
||||||
const RUST_TARGET = Bun.env.RUST_TARGET
|
await $`cd ../opencode && bun script/build-node.ts`
|
||||||
|
|
||||||
const sidecarConfig = getCurrentSidecar(RUST_TARGET)
|
|
||||||
|
|
||||||
const binaryPath = windowsify(`../opencode/dist/${sidecarConfig.ocBinary}/bin/opencode`)
|
|
||||||
|
|
||||||
await (sidecarConfig.ocBinary.includes("-baseline")
|
|
||||||
? $`cd ../opencode && bun run build --single --baseline`
|
|
||||||
: $`cd ../opencode && bun run build --single`)
|
|
||||||
|
|
||||||
await copyBinaryToSidecarFolder(binaryPath, RUST_TARGET)
|
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,9 @@
|
||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
import { $ } from "bun"
|
|
||||||
|
|
||||||
import { Script } from "@opencode-ai/script"
|
import { Script } from "@opencode-ai/script"
|
||||||
import { copyBinaryToSidecarFolder, getCurrentSidecar, resolveChannel, windowsify } from "./utils"
|
|
||||||
|
|
||||||
const channel = resolveChannel()
|
await import("./prebuild")
|
||||||
await $`bun ./scripts/copy-icons.ts ${channel}`
|
|
||||||
|
|
||||||
const pkg = await Bun.file("./package.json").json()
|
const pkg = await Bun.file("./package.json").json()
|
||||||
pkg.version = Script.version
|
pkg.version = Script.version
|
||||||
await Bun.write("./package.json", JSON.stringify(pkg, null, 2) + "\n")
|
await Bun.write("./package.json", JSON.stringify(pkg, null, 2) + "\n")
|
||||||
console.log(`Updated package.json version to ${Script.version}`)
|
console.log(`Updated package.json version to ${Script.version}`)
|
||||||
|
|
||||||
const sidecarConfig = getCurrentSidecar()
|
|
||||||
const artifact = process.env.OPENCODE_CLI_ARTIFACT ?? "opencode-cli"
|
|
||||||
|
|
||||||
const dir = "resources/opencode-binaries"
|
|
||||||
|
|
||||||
await $`mkdir -p ${dir}`
|
|
||||||
await $`gh run download ${process.env.GITHUB_RUN_ID} -n ${artifact}`.cwd(dir)
|
|
||||||
|
|
||||||
await copyBinaryToSidecarFolder(windowsify(`${dir}/${sidecarConfig.ocBinary}/bin/opencode`))
|
|
||||||
|
|
||||||
await $`rm -rf ${dir}`
|
|
||||||
|
|
|
||||||
|
|
@ -1,283 +0,0 @@
|
||||||
import { execFileSync, spawn } from "node:child_process"
|
|
||||||
import { EventEmitter } from "node:events"
|
|
||||||
import { chmodSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
|
|
||||||
import { tmpdir } from "node:os"
|
|
||||||
import { dirname, join } from "node:path"
|
|
||||||
import readline from "node:readline"
|
|
||||||
import { fileURLToPath } from "node:url"
|
|
||||||
import { app } from "electron"
|
|
||||||
import treeKill from "tree-kill"
|
|
||||||
|
|
||||||
import { WSL_ENABLED_KEY } from "./constants"
|
|
||||||
import { getUserShell, loadShellEnv, mergeShellEnv } from "./shell-env"
|
|
||||||
import { store } from "./store"
|
|
||||||
|
|
||||||
const CLI_INSTALL_DIR = ".opencode/bin"
|
|
||||||
const CLI_BINARY_NAME = "opencode"
|
|
||||||
|
|
||||||
export type ServerConfig = {
|
|
||||||
hostname?: string
|
|
||||||
port?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Config = {
|
|
||||||
server?: ServerConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TerminatedPayload = { code: number | null; signal: number | null }
|
|
||||||
|
|
||||||
export type CommandEvent =
|
|
||||||
| { type: "stdout"; value: string }
|
|
||||||
| { type: "stderr"; value: string }
|
|
||||||
| { type: "error"; value: string }
|
|
||||||
| { type: "terminated"; value: TerminatedPayload }
|
|
||||||
| { type: "sqlite"; value: SqliteMigrationProgress }
|
|
||||||
|
|
||||||
export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" }
|
|
||||||
|
|
||||||
export type CommandChild = {
|
|
||||||
pid: number | undefined
|
|
||||||
kill: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const root = dirname(fileURLToPath(import.meta.url))
|
|
||||||
|
|
||||||
export function getSidecarPath() {
|
|
||||||
const suffix = process.platform === "win32" ? ".exe" : ""
|
|
||||||
const path = app.isPackaged
|
|
||||||
? join(process.resourcesPath, `opencode-cli${suffix}`)
|
|
||||||
: join(root, "../../resources", `opencode-cli${suffix}`)
|
|
||||||
console.log(`[cli] Sidecar path resolved: ${path} (isPackaged: ${app.isPackaged})`)
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getConfig(): Promise<Config | null> {
|
|
||||||
const { events } = spawnCommand("debug config", {})
|
|
||||||
let output = ""
|
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
events.on("stdout", (line: string) => {
|
|
||||||
output += line
|
|
||||||
})
|
|
||||||
events.on("stderr", (line: string) => {
|
|
||||||
output += line
|
|
||||||
})
|
|
||||||
events.on("terminated", () => resolve())
|
|
||||||
events.on("error", () => resolve())
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
return JSON.parse(output) as Config
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function installCli(): Promise<string> {
|
|
||||||
if (process.platform === "win32") {
|
|
||||||
throw new Error("CLI installation is only supported on macOS & Linux")
|
|
||||||
}
|
|
||||||
|
|
||||||
const sidecar = getSidecarPath()
|
|
||||||
const scriptPath = join(app.getAppPath(), "install")
|
|
||||||
const script = readFileSync(scriptPath, "utf8")
|
|
||||||
const tempScript = join(tmpdir(), "opencode-install.sh")
|
|
||||||
|
|
||||||
writeFileSync(tempScript, script, "utf8")
|
|
||||||
chmodSync(tempScript, 0o755)
|
|
||||||
|
|
||||||
const cmd = spawn(tempScript, ["--binary", sidecar], { stdio: "pipe" })
|
|
||||||
return await new Promise<string>((resolve, reject) => {
|
|
||||||
cmd.on("exit", (code: number | null) => {
|
|
||||||
try {
|
|
||||||
unlinkSync(tempScript)
|
|
||||||
} catch {}
|
|
||||||
if (code === 0) {
|
|
||||||
const installPath = getCliInstallPath()
|
|
||||||
if (installPath) return resolve(installPath)
|
|
||||||
return reject(new Error("Could not determine install path"))
|
|
||||||
}
|
|
||||||
reject(new Error("Install script failed"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function syncCli() {
|
|
||||||
if (!app.isPackaged) return
|
|
||||||
const installPath = getCliInstallPath()
|
|
||||||
if (!installPath) return
|
|
||||||
|
|
||||||
let version = ""
|
|
||||||
try {
|
|
||||||
version = execFileSync(installPath, ["--version"], { windowsHide: true }).toString().trim()
|
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const cli = parseVersion(version)
|
|
||||||
const appVersion = parseVersion(app.getVersion())
|
|
||||||
if (!cli || !appVersion) return
|
|
||||||
if (compareVersions(cli, appVersion) >= 0) return
|
|
||||||
void installCli().catch(() => undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function serve(hostname: string, port: number, password: string) {
|
|
||||||
const args = `--print-logs --log-level WARN serve --hostname ${hostname} --port ${port}`
|
|
||||||
const env = {
|
|
||||||
OPENCODE_SERVER_USERNAME: "opencode",
|
|
||||||
OPENCODE_SERVER_PASSWORD: password,
|
|
||||||
}
|
|
||||||
|
|
||||||
return spawnCommand(args, env)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function spawnCommand(args: string, extraEnv: Record<string, string>) {
|
|
||||||
console.log(`[cli] Spawning command with args: ${args}`)
|
|
||||||
const base = Object.fromEntries(
|
|
||||||
Object.entries(process.env).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
|
||||||
)
|
|
||||||
const env = {
|
|
||||||
...base,
|
|
||||||
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
|
|
||||||
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
|
|
||||||
OPENCODE_CLIENT: "desktop",
|
|
||||||
XDG_STATE_HOME: app.getPath("userData"),
|
|
||||||
...extraEnv,
|
|
||||||
}
|
|
||||||
const shell = process.platform === "win32" ? null : getUserShell()
|
|
||||||
const envs = shell ? mergeShellEnv(loadShellEnv(shell), env) : env
|
|
||||||
|
|
||||||
const { cmd, cmdArgs } = buildCommand(args, envs, shell)
|
|
||||||
console.log(`[cli] Executing: ${cmd} ${cmdArgs.join(" ")}`)
|
|
||||||
const child = spawn(cmd, cmdArgs, {
|
|
||||||
env: envs,
|
|
||||||
detached: process.platform !== "win32",
|
|
||||||
windowsHide: true,
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
|
||||||
})
|
|
||||||
console.log(`[cli] Spawned process with PID: ${child.pid}`)
|
|
||||||
|
|
||||||
const events = new EventEmitter()
|
|
||||||
const exit = new Promise<TerminatedPayload>((resolve) => {
|
|
||||||
child.on("exit", (code: number | null, signal: NodeJS.Signals | null) => {
|
|
||||||
console.log(`[cli] Process exited with code: ${code}, signal: ${signal}`)
|
|
||||||
resolve({ code: code ?? null, signal: null })
|
|
||||||
})
|
|
||||||
child.on("error", (error: Error) => {
|
|
||||||
console.error(`[cli] Process error: ${error.message}`)
|
|
||||||
events.emit("error", error.message)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const stdout = child.stdout
|
|
||||||
const stderr = child.stderr
|
|
||||||
|
|
||||||
if (stdout) {
|
|
||||||
readline.createInterface({ input: stdout }).on("line", (line: string) => {
|
|
||||||
if (handleSqliteProgress(events, line)) return
|
|
||||||
events.emit("stdout", `${line}\n`)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stderr) {
|
|
||||||
readline.createInterface({ input: stderr }).on("line", (line: string) => {
|
|
||||||
if (handleSqliteProgress(events, line)) return
|
|
||||||
events.emit("stderr", `${line}\n`)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
exit.then((payload) => {
|
|
||||||
events.emit("terminated", payload)
|
|
||||||
})
|
|
||||||
|
|
||||||
const kill = () => {
|
|
||||||
if (!child.pid) return
|
|
||||||
treeKill(child.pid)
|
|
||||||
}
|
|
||||||
|
|
||||||
return { events, child: { pid: child.pid, kill }, exit }
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSqliteProgress(events: EventEmitter, line: string) {
|
|
||||||
const stripped = line.startsWith("sqlite-migration:") ? line.slice("sqlite-migration:".length).trim() : null
|
|
||||||
if (!stripped) return false
|
|
||||||
if (stripped === "done") {
|
|
||||||
events.emit("sqlite", { type: "Done" })
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
const value = Number.parseInt(stripped, 10)
|
|
||||||
if (!Number.isNaN(value)) {
|
|
||||||
events.emit("sqlite", { type: "InProgress", value })
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCommand(args: string, env: Record<string, string>, shell: string | null) {
|
|
||||||
if (process.platform === "win32" && isWslEnabled()) {
|
|
||||||
console.log(`[cli] Using WSL mode`)
|
|
||||||
const version = app.getVersion()
|
|
||||||
const script = [
|
|
||||||
"set -e",
|
|
||||||
'BIN="$HOME/.opencode/bin/opencode"',
|
|
||||||
'if [ ! -x "$BIN" ]; then',
|
|
||||||
` curl -fsSL https://opencode.ai/install | bash -s -- --version ${shellEscape(version)} --no-modify-path`,
|
|
||||||
"fi",
|
|
||||||
`${envPrefix(env)} exec "$BIN" ${args}`,
|
|
||||||
].join("\n")
|
|
||||||
|
|
||||||
return { cmd: "wsl", cmdArgs: ["-e", "bash", "-lc", script] }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.platform === "win32") {
|
|
||||||
const sidecar = getSidecarPath()
|
|
||||||
console.log(`[cli] Windows direct mode, sidecar: ${sidecar}`)
|
|
||||||
return { cmd: sidecar, cmdArgs: args.split(" ") }
|
|
||||||
}
|
|
||||||
|
|
||||||
const sidecar = getSidecarPath()
|
|
||||||
const user = shell || getUserShell()
|
|
||||||
const line = user.endsWith("/nu") ? `^\"${sidecar}\" ${args}` : `\"${sidecar}\" ${args}`
|
|
||||||
console.log(`[cli] Unix mode, shell: ${user}, command: ${line}`)
|
|
||||||
return { cmd: user, cmdArgs: ["-l", "-c", line] }
|
|
||||||
}
|
|
||||||
|
|
||||||
function envPrefix(env: Record<string, string>) {
|
|
||||||
const entries = Object.entries(env).map(([key, value]) => `${key}=${shellEscape(value)}`)
|
|
||||||
return entries.join(" ")
|
|
||||||
}
|
|
||||||
|
|
||||||
function shellEscape(input: string) {
|
|
||||||
if (!input) return "''"
|
|
||||||
return `'${input.replace(/'/g, `'"'"'`)}'`
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCliInstallPath() {
|
|
||||||
const home = process.env.HOME
|
|
||||||
if (!home) return null
|
|
||||||
return join(home, CLI_INSTALL_DIR, CLI_BINARY_NAME)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isWslEnabled() {
|
|
||||||
return store.get(WSL_ENABLED_KEY) === true
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseVersion(value: string) {
|
|
||||||
const parts = value
|
|
||||||
.replace(/^v/, "")
|
|
||||||
.split(".")
|
|
||||||
.map((part) => Number.parseInt(part, 10))
|
|
||||||
if (parts.some((part) => Number.isNaN(part))) return null
|
|
||||||
return parts
|
|
||||||
}
|
|
||||||
|
|
||||||
function compareVersions(a: number[], b: number[]) {
|
|
||||||
const len = Math.max(a.length, b.length)
|
|
||||||
for (let i = 0; i < len; i += 1) {
|
|
||||||
const left = a[i] ?? 0
|
|
||||||
const right = b[i] ?? 0
|
|
||||||
if (left > right) return 1
|
|
||||||
if (left < right) return -1
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
@ -5,3 +5,25 @@ interface ImportMetaEnv {
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
readonly env: ImportMetaEnv
|
readonly env: ImportMetaEnv
|
||||||
}
|
}
|
||||||
|
declare module "virtual:opencode-server" {
|
||||||
|
export namespace Server {
|
||||||
|
export const listen: typeof import("../../../opencode/dist/types/src/node").Server.listen
|
||||||
|
export type Listener = import("../../../opencode/dist/types/src/node").Server.Listener
|
||||||
|
}
|
||||||
|
export namespace Config {
|
||||||
|
export const get: typeof import("../../../opencode/dist/types/src/node").Config.get
|
||||||
|
export type Info = import("../../../opencode/dist/types/src/node").Config.Info
|
||||||
|
}
|
||||||
|
export namespace Log {
|
||||||
|
export const init: typeof import("../../../opencode/dist/types/src/node").Log.init
|
||||||
|
}
|
||||||
|
export namespace Database {
|
||||||
|
export const Path: typeof import("../../../opencode/dist/types/src/node").Database.Path
|
||||||
|
export const Client: typeof import("../../../opencode/dist/types/src/node").Database.Client
|
||||||
|
}
|
||||||
|
export namespace JsonMigration {
|
||||||
|
export type Progress = import("../../../opencode/dist/types/src/node").JsonMigration.Progress
|
||||||
|
export const run: typeof import("../../../opencode/dist/types/src/node").JsonMigration.run
|
||||||
|
}
|
||||||
|
export const bootstrap: typeof import("../../../opencode/dist/types/src/node").bootstrap
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ import pkg from "electron-updater"
|
||||||
import contextMenu from "electron-context-menu"
|
import contextMenu from "electron-context-menu"
|
||||||
contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false })
|
contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false })
|
||||||
|
|
||||||
|
process.env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "true"
|
||||||
|
|
||||||
const APP_NAMES: Record<string, string> = {
|
const APP_NAMES: Record<string, string> = {
|
||||||
dev: "OpenCode Dev",
|
dev: "OpenCode Dev",
|
||||||
beta: "OpenCode Beta",
|
beta: "OpenCode Beta",
|
||||||
|
|
@ -27,8 +29,6 @@ const { autoUpdater } = pkg
|
||||||
|
|
||||||
import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types"
|
import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types"
|
||||||
import { checkAppExists, resolveAppPath, wslPath } from "./apps"
|
import { checkAppExists, resolveAppPath, wslPath } from "./apps"
|
||||||
import type { CommandChild } from "./cli"
|
|
||||||
import { installCli, syncCli } from "./cli"
|
|
||||||
import { CHANNEL, UPDATER_ENABLED } from "./constants"
|
import { CHANNEL, UPDATER_ENABLED } from "./constants"
|
||||||
import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc"
|
import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc"
|
||||||
import { initLogging } from "./logging"
|
import { initLogging } from "./logging"
|
||||||
|
|
@ -36,12 +36,13 @@ import { parseMarkdown } from "./markdown"
|
||||||
import { createMenu } from "./menu"
|
import { createMenu } from "./menu"
|
||||||
import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server"
|
import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server"
|
||||||
import { createLoadingWindow, createMainWindow, setBackgroundColor, setDockIcon } from "./windows"
|
import { createLoadingWindow, createMainWindow, setBackgroundColor, setDockIcon } from "./windows"
|
||||||
|
import type { Server } from "virtual:opencode-server"
|
||||||
|
|
||||||
const initEmitter = new EventEmitter()
|
const initEmitter = new EventEmitter()
|
||||||
let initStep: InitStep = { phase: "server_waiting" }
|
let initStep: InitStep = { phase: "server_waiting" }
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null
|
let mainWindow: BrowserWindow | null = null
|
||||||
let sidecar: CommandChild | null = null
|
let server: Server.Listener | null = null
|
||||||
const loadingComplete = defer<void>()
|
const loadingComplete = defer<void>()
|
||||||
|
|
||||||
const pendingDeepLinks: string[] = []
|
const pendingDeepLinks: string[] = []
|
||||||
|
|
@ -96,11 +97,9 @@ function setupApp() {
|
||||||
}
|
}
|
||||||
|
|
||||||
void app.whenReady().then(async () => {
|
void app.whenReady().then(async () => {
|
||||||
// migrate()
|
|
||||||
app.setAsDefaultProtocolClient("opencode")
|
app.setAsDefaultProtocolClient("opencode")
|
||||||
setDockIcon()
|
setDockIcon()
|
||||||
setupAutoUpdater()
|
setupAutoUpdater()
|
||||||
syncCli()
|
|
||||||
await initialize()
|
await initialize()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -134,8 +133,8 @@ async function initialize() {
|
||||||
const password = randomUUID()
|
const password = randomUUID()
|
||||||
|
|
||||||
logger.log("spawning sidecar", { url })
|
logger.log("spawning sidecar", { url })
|
||||||
const { child, health, events } = spawnLocalServer(hostname, port, password)
|
const { listener, health } = await spawnLocalServer(hostname, port, password)
|
||||||
sidecar = child
|
server = listener
|
||||||
serverReady.resolve({
|
serverReady.resolve({
|
||||||
url,
|
url,
|
||||||
username: "opencode",
|
username: "opencode",
|
||||||
|
|
@ -145,7 +144,7 @@ async function initialize() {
|
||||||
const loadingTask = (async () => {
|
const loadingTask = (async () => {
|
||||||
logger.log("sidecar connection started", { url })
|
logger.log("sidecar connection started", { url })
|
||||||
|
|
||||||
events.on("sqlite", (progress: SqliteMigrationProgress) => {
|
initEmitter.on("sqlite", (progress: SqliteMigrationProgress) => {
|
||||||
setInitStep({ phase: "sqlite_waiting" })
|
setInitStep({ phase: "sqlite_waiting" })
|
||||||
if (overlay) sendSqliteMigrationProgress(overlay, progress)
|
if (overlay) sendSqliteMigrationProgress(overlay, progress)
|
||||||
if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress)
|
if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress)
|
||||||
|
|
@ -198,9 +197,6 @@ function wireMenu() {
|
||||||
if (!mainWindow) return
|
if (!mainWindow) return
|
||||||
createMenu({
|
createMenu({
|
||||||
trigger: (id) => mainWindow && sendMenuCommand(mainWindow, id),
|
trigger: (id) => mainWindow && sendMenuCommand(mainWindow, id),
|
||||||
installCli: () => {
|
|
||||||
void installCli()
|
|
||||||
},
|
|
||||||
checkForUpdates: () => {
|
checkForUpdates: () => {
|
||||||
void checkForUpdates(true)
|
void checkForUpdates(true)
|
||||||
},
|
},
|
||||||
|
|
@ -215,7 +211,6 @@ function wireMenu() {
|
||||||
|
|
||||||
registerIpcHandlers({
|
registerIpcHandlers({
|
||||||
killSidecar: () => killSidecar(),
|
killSidecar: () => killSidecar(),
|
||||||
installCli: async () => installCli(),
|
|
||||||
awaitInitialization: async (sendStep) => {
|
awaitInitialization: async (sendStep) => {
|
||||||
sendStep(initStep)
|
sendStep(initStep)
|
||||||
const listener = (step: InitStep) => sendStep(step)
|
const listener = (step: InitStep) => sendStep(step)
|
||||||
|
|
@ -247,16 +242,9 @@ registerIpcHandlers({
|
||||||
})
|
})
|
||||||
|
|
||||||
function killSidecar() {
|
function killSidecar() {
|
||||||
if (!sidecar) return
|
if (!server) return
|
||||||
const pid = sidecar.pid
|
server.stop()
|
||||||
sidecar.kill()
|
server = null
|
||||||
sidecar = null
|
|
||||||
// tree-kill is async; also send process group signal as immediate fallback
|
|
||||||
if (pid && process.platform !== "win32") {
|
|
||||||
try {
|
|
||||||
process.kill(-pid, "SIGTERM")
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureLoopbackNoProxy() {
|
function ensureLoopbackNoProxy() {
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ const pickerFilters = (ext?: string[]) => {
|
||||||
|
|
||||||
type Deps = {
|
type Deps = {
|
||||||
killSidecar: () => void
|
killSidecar: () => void
|
||||||
installCli: () => Promise<string>
|
|
||||||
awaitInitialization: (sendStep: (step: InitStep) => void) => Promise<ServerReadyData>
|
awaitInitialization: (sendStep: (step: InitStep) => void) => Promise<ServerReadyData>
|
||||||
getDefaultServerUrl: () => Promise<string | null> | string | null
|
getDefaultServerUrl: () => Promise<string | null> | string | null
|
||||||
setDefaultServerUrl: (url: string | null) => Promise<void> | void
|
setDefaultServerUrl: (url: string | null) => Promise<void> | void
|
||||||
|
|
@ -34,7 +33,6 @@ type Deps = {
|
||||||
|
|
||||||
export function registerIpcHandlers(deps: Deps) {
|
export function registerIpcHandlers(deps: Deps) {
|
||||||
ipcMain.handle("kill-sidecar", () => deps.killSidecar())
|
ipcMain.handle("kill-sidecar", () => deps.killSidecar())
|
||||||
ipcMain.handle("install-cli", () => deps.installCli())
|
|
||||||
ipcMain.handle("await-initialization", (event: IpcMainInvokeEvent) => {
|
ipcMain.handle("await-initialization", (event: IpcMainInvokeEvent) => {
|
||||||
const send = (step: InitStep) => event.sender.send("init-step", step)
|
const send = (step: InitStep) => event.sender.send("init-step", step)
|
||||||
return deps.awaitInitialization(send)
|
return deps.awaitInitialization(send)
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import { createMainWindow } from "./windows"
|
||||||
|
|
||||||
type Deps = {
|
type Deps = {
|
||||||
trigger: (id: string) => void
|
trigger: (id: string) => void
|
||||||
installCli: () => void
|
|
||||||
checkForUpdates: () => void
|
checkForUpdates: () => void
|
||||||
reload: () => void
|
reload: () => void
|
||||||
relaunch: () => void
|
relaunch: () => void
|
||||||
|
|
@ -24,10 +23,6 @@ export function createMenu(deps: Deps) {
|
||||||
enabled: UPDATER_ENABLED,
|
enabled: UPDATER_ENABLED,
|
||||||
click: () => deps.checkForUpdates(),
|
click: () => deps.checkForUpdates(),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: "Install CLI...",
|
|
||||||
click: () => deps.installCli(),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: "Reload Webview",
|
label: "Reload Webview",
|
||||||
click: () => deps.reload(),
|
click: () => deps.reload(),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { serve, type CommandChild } from "./cli"
|
import { app } from "electron"
|
||||||
import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
|
import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
|
||||||
|
import { getUserShell, loadShellEnv } from "./shell-env"
|
||||||
import { store } from "./store"
|
import { store } from "./store"
|
||||||
|
|
||||||
export type WslConfig = { enabled: boolean }
|
export type WslConfig = { enabled: boolean }
|
||||||
|
|
@ -29,8 +30,16 @@ export function setWslConfig(config: WslConfig) {
|
||||||
store.set(WSL_ENABLED_KEY, config.enabled)
|
store.set(WSL_ENABLED_KEY, config.enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function spawnLocalServer(hostname: string, port: number, password: string) {
|
export async function spawnLocalServer(hostname: string, port: number, password: string) {
|
||||||
const { child, exit, events } = serve(hostname, port, password)
|
prepareServerEnv(password)
|
||||||
|
const { Log, Server } = await import("virtual:opencode-server")
|
||||||
|
await Log.init({ level: "WARN" })
|
||||||
|
const listener = await Server.listen({
|
||||||
|
port,
|
||||||
|
hostname,
|
||||||
|
username: "opencode",
|
||||||
|
password,
|
||||||
|
})
|
||||||
|
|
||||||
const wait = (async () => {
|
const wait = (async () => {
|
||||||
const url = `http://${hostname}:${port}`
|
const url = `http://${hostname}:${port}`
|
||||||
|
|
@ -42,19 +51,26 @@ export function spawnLocalServer(hostname: string, port: number, password: strin
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const terminated = async () => {
|
await ready()
|
||||||
const payload = await exit
|
|
||||||
throw new Error(
|
|
||||||
`Sidecar terminated before becoming healthy (code=${payload.code ?? "unknown"} signal=${
|
|
||||||
payload.signal ?? "unknown"
|
|
||||||
})`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.race([ready(), terminated()])
|
|
||||||
})()
|
})()
|
||||||
|
|
||||||
return { child, health: { wait }, events }
|
return { listener, health: { wait } }
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareServerEnv(password: string) {
|
||||||
|
const shell = process.platform === "win32" ? null : getUserShell()
|
||||||
|
const shellEnv = shell ? (loadShellEnv(shell) ?? {}) : {}
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
...shellEnv,
|
||||||
|
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
|
||||||
|
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
|
||||||
|
OPENCODE_CLIENT: "desktop",
|
||||||
|
OPENCODE_SERVER_USERNAME: "opencode",
|
||||||
|
OPENCODE_SERVER_PASSWORD: password,
|
||||||
|
XDG_STATE_HOME: app.getPath("userData"),
|
||||||
|
}
|
||||||
|
Object.assign(process.env, env)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
|
export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
|
||||||
|
|
@ -82,5 +98,3 @@ export async function checkHealth(url: string, password?: string | null): Promis
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { CommandChild }
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { spawnSync } from "node:child_process"
|
import { spawnSync } from "node:child_process"
|
||||||
import { basename } from "node:path"
|
import { basename } from "node:path"
|
||||||
|
|
||||||
const SHELL_ENV_TIMEOUT = 5_000
|
const TIMEOUT = 5_000
|
||||||
|
|
||||||
type Probe = { type: "Loaded"; value: Record<string, string> } | { type: "Timeout" } | { type: "Unavailable" }
|
type Probe = { type: "Loaded"; value: Record<string, string> } | { type: "Timeout" } | { type: "Unavailable" }
|
||||||
|
|
||||||
|
|
@ -20,28 +20,28 @@ export function parseShellEnv(out: Buffer) {
|
||||||
return env
|
return env
|
||||||
}
|
}
|
||||||
|
|
||||||
function probeShellEnv(shell: string, mode: "-il" | "-l"): Probe {
|
function probe(shell: string, mode: "-il" | "-l"): Probe {
|
||||||
const out = spawnSync(shell, [mode, "-c", "env -0"], {
|
const out = spawnSync(shell, [mode, "-c", "env -0"], {
|
||||||
stdio: ["ignore", "pipe", "ignore"],
|
stdio: ["ignore", "pipe", "ignore"],
|
||||||
timeout: SHELL_ENV_TIMEOUT,
|
timeout: TIMEOUT,
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const err = out.error as NodeJS.ErrnoException | undefined
|
const err = out.error as NodeJS.ErrnoException | undefined
|
||||||
if (err) {
|
if (err) {
|
||||||
if (err.code === "ETIMEDOUT") return { type: "Timeout" }
|
if (err.code === "ETIMEDOUT") return { type: "Timeout" }
|
||||||
console.log(`[cli] Shell env probe failed for ${shell} ${mode}: ${err.message}`)
|
console.log(`[server] Shell env probe failed for ${shell} ${mode}: ${err.message}`)
|
||||||
return { type: "Unavailable" }
|
return { type: "Unavailable" }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (out.status !== 0) {
|
if (out.status !== 0) {
|
||||||
console.log(`[cli] Shell env probe exited with non-zero status for ${shell} ${mode}`)
|
console.log(`[server] Shell env probe exited with non-zero status for ${shell} ${mode}`)
|
||||||
return { type: "Unavailable" }
|
return { type: "Unavailable" }
|
||||||
}
|
}
|
||||||
|
|
||||||
const env = parseShellEnv(out.stdout)
|
const env = parseShellEnv(out.stdout)
|
||||||
if (Object.keys(env).length === 0) {
|
if (Object.keys(env).length === 0) {
|
||||||
console.log(`[cli] Shell env probe returned empty env for ${shell} ${mode}`)
|
console.log(`[server] Shell env probe returned empty env for ${shell} ${mode}`)
|
||||||
return { type: "Unavailable" }
|
return { type: "Unavailable" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,27 +56,27 @@ export function isNushell(shell: string) {
|
||||||
|
|
||||||
export function loadShellEnv(shell: string) {
|
export function loadShellEnv(shell: string) {
|
||||||
if (isNushell(shell)) {
|
if (isNushell(shell)) {
|
||||||
console.log(`[cli] Skipping shell env probe for nushell: ${shell}`)
|
console.log(`[server] Skipping shell env probe for nushell: ${shell}`)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const interactive = probeShellEnv(shell, "-il")
|
const interactive = probe(shell, "-il")
|
||||||
if (interactive.type === "Loaded") {
|
if (interactive.type === "Loaded") {
|
||||||
console.log(`[cli] Loaded shell environment with -il (${Object.keys(interactive.value).length} vars)`)
|
console.log(`[server] Loaded shell environment with -il (${Object.keys(interactive.value).length} vars)`)
|
||||||
return interactive.value
|
return interactive.value
|
||||||
}
|
}
|
||||||
if (interactive.type === "Timeout") {
|
if (interactive.type === "Timeout") {
|
||||||
console.warn(`[cli] Interactive shell env probe timed out: ${shell}`)
|
console.warn(`[server] Interactive shell env probe timed out: ${shell}`)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const login = probeShellEnv(shell, "-l")
|
const login = probe(shell, "-l")
|
||||||
if (login.type === "Loaded") {
|
if (login.type === "Loaded") {
|
||||||
console.log(`[cli] Loaded shell environment with -l (${Object.keys(login.value).length} vars)`)
|
console.log(`[server] Loaded shell environment with -l (${Object.keys(login.value).length} vars)`)
|
||||||
return login.value
|
return login.value
|
||||||
}
|
}
|
||||||
|
|
||||||
console.warn(`[cli] Falling back to app environment: ${shell}`)
|
console.warn(`[server] Falling back to app environment: ${shell}`)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"plugins": {
|
||||||
|
"figma": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,183 @@
|
||||||
|
# mobile-voice Agent Guide
|
||||||
|
|
||||||
|
This file defines package-specific guidance for agents working in `packages/mobile-voice`.
|
||||||
|
|
||||||
|
## Scope And Precedence
|
||||||
|
|
||||||
|
- Follow root `AGENTS.md` first.
|
||||||
|
- This file overrides root guidance for this package when rules conflict.
|
||||||
|
- If additional local guides are added later, treat the closest guide as highest priority.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
- Expo + React Native app for voice dictation and OpenCode session monitoring.
|
||||||
|
- Uses native/device-heavy modules such as `whisper.rn`, `react-native-audio-api`, `expo-notifications`, and `expo-camera`.
|
||||||
|
- Development builds are required for native module changes.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
Run all commands from `packages/mobile-voice`.
|
||||||
|
|
||||||
|
- Install deps: `bun install`
|
||||||
|
- Start Metro: `bun run start`
|
||||||
|
- Start dev client server (recommended): `bunx expo start --dev-client --clear --host lan`
|
||||||
|
- iOS run: `bun run ios`
|
||||||
|
- Android run: `bun run android`
|
||||||
|
- Lint: `bun run lint`
|
||||||
|
- Typecheck: `bun run typecheck`
|
||||||
|
- Expo doctor: `bunx expo-doctor`
|
||||||
|
- Dependency compatibility check: `bunx expo install --check`
|
||||||
|
- Export bundle smoke test: `bunx expo export --platform ios --clear`
|
||||||
|
|
||||||
|
## Build / Verification Expectations
|
||||||
|
|
||||||
|
- For JS-only changes: run `bun run lint` and verify app behavior via dev client.
|
||||||
|
- For TS-heavy refactors: run `bun run typecheck` in addition to lint.
|
||||||
|
- For native dependency/config/plugin changes: rebuild dev client via EAS before validation.
|
||||||
|
- If notifications, camera, microphone, or audio-session behavior changes, verify on a physical iOS device.
|
||||||
|
- Do not claim a fix unless you validated in Metro logs and app runtime behavior.
|
||||||
|
|
||||||
|
## Single-Test Guidance
|
||||||
|
|
||||||
|
- This package currently has no dedicated unit test script.
|
||||||
|
- Use targeted validation commands instead:
|
||||||
|
- `bun run lint`
|
||||||
|
- `bun run typecheck`
|
||||||
|
- `bunx expo export --platform ios --clear`
|
||||||
|
- manual runtime test in dev client
|
||||||
|
|
||||||
|
## Architecture Priorities
|
||||||
|
|
||||||
|
- Keep screens focused on composition and orchestration. Once a screen owns multiple workflows, extract hooks/components before adding more local state.
|
||||||
|
- Prefer extracting pure helpers and config objects before introducing new stores or abstractions.
|
||||||
|
- Treat `src/app/index.tsx` as a composition root, not as the permanent home for onboarding, dictation, monitoring, pairing, persistence, and all UI details.
|
||||||
|
- Avoid mirrored `state + ref` pairs unless they are needed for imperative native APIs, race cancellation, or subscription callbacks.
|
||||||
|
|
||||||
|
## Code Style And Patterns
|
||||||
|
|
||||||
|
### Formatting / Structure
|
||||||
|
|
||||||
|
- Preserve existing style (`semi: false`, concise JSX, stable import grouping).
|
||||||
|
- Keep UI changes localized and behavior-preserving; avoid unrelated formatting churn.
|
||||||
|
- Prefer feature-adjacent hooks/components over growing a single screen file.
|
||||||
|
|
||||||
|
### React State / Effects
|
||||||
|
|
||||||
|
- Effects are for subscriptions, timers, persistence, network I/O, and native bridge setup/cleanup.
|
||||||
|
- Do not add `useEffect` just to derive render data from props or state. Derive during render instead.
|
||||||
|
- Prefer one source of truth. If a value can be computed from existing state, do not store it separately.
|
||||||
|
- Use `useMemo` only when computation is expensive or stable identity actually matters.
|
||||||
|
- Use `useCallback` only when stable function identity matters for dependencies, cleanup, or memoized children.
|
||||||
|
- When UI branches are driven by a small finite state, prefer config tables/objects over long nested ternaries.
|
||||||
|
|
||||||
|
### Types
|
||||||
|
|
||||||
|
- Avoid `any`; prefer local type aliases for component state and network payloads.
|
||||||
|
- Keep exported/shared boundaries typed explicitly.
|
||||||
|
- Parse persisted and network payloads as `unknown` first, then validate before use.
|
||||||
|
- Use discriminated unions for UI modes/status where practical.
|
||||||
|
|
||||||
|
### Naming
|
||||||
|
|
||||||
|
- Prefer short, readable names consistent with nearby code.
|
||||||
|
- Keep naming aligned with existing app state keys (`monitorStatus`, `activeSessionId`, etc.).
|
||||||
|
|
||||||
|
### Error Handling / Logging
|
||||||
|
|
||||||
|
- Fail gracefully in UI (alerts, disabled actions, fallback text).
|
||||||
|
- Avoid bare `catch {}` or `.catch(() => {})` for meaningful work. If failure is intentionally best-effort, leave a short comment or use a helper that makes that explicit.
|
||||||
|
- Log actionable diagnostics for runtime workflows such as server health checks, relay registration, and notification token lifecycle.
|
||||||
|
- Never log secrets or full APNs tokens.
|
||||||
|
- Keep hot-path logging behind `__DEV__` when possible.
|
||||||
|
|
||||||
|
### Network / Relay Integration
|
||||||
|
|
||||||
|
- Normalize and validate URLs before storing server configs.
|
||||||
|
- Use `AbortController` or request IDs for overlapping requests, streams, and polling.
|
||||||
|
- Keep relay registration idempotent.
|
||||||
|
- Guard duplicate scan/add flows to avoid repeated server entries.
|
||||||
|
|
||||||
|
### Notifications / APNs
|
||||||
|
|
||||||
|
- This package currently assumes APNs relay registration uses the `production` environment only. Do not add environment switching unless explicitly requested.
|
||||||
|
- On registration changes, ensure old token unregister flow remains intact.
|
||||||
|
- Treat permission failures as non-fatal and degrade to foreground monitoring when needed.
|
||||||
|
|
||||||
|
### Performance / RN
|
||||||
|
|
||||||
|
- Validate performance-sensitive changes in a dev client or release build, not only Metro dev mode.
|
||||||
|
- During recording and monitoring flows, keep JS-thread work light.
|
||||||
|
- Prefer Reanimated/native-thread-friendly animations for motion.
|
||||||
|
- For small menus a `ScrollView` is fine; if a list grows beyond a small bounded menu, move to `FlatList` or `FlashList`.
|
||||||
|
|
||||||
|
## Lint / Quality Bar
|
||||||
|
|
||||||
|
- Keep hooks lint warnings clean before finishing.
|
||||||
|
- Treat `any`, `no-console`, complexity, and max-lines warnings as refactor prompts, not noise to suppress.
|
||||||
|
- Do not disable React Hooks lint rules inline unless there is a documented native-interop reason.
|
||||||
|
- When introducing new persistence or network payloads, add or reuse a parser instead of scattering casts.
|
||||||
|
|
||||||
|
## Native-Module Safety
|
||||||
|
|
||||||
|
- If adding a native module, ensure it is in `package.json` with an SDK-compatible version.
|
||||||
|
- Rebuild the dev client after native module additions or changes.
|
||||||
|
- For optional native capability usage, prefer runtime fallback paths instead of hard crashes.
|
||||||
|
|
||||||
|
## Expo Native Config (EAS)
|
||||||
|
|
||||||
|
- Treat `packages/mobile-voice/app.json` as the source of truth for iOS native metadata in EAS cloud builds.
|
||||||
|
- Do not rely on manual edits in `ios/mobilevoice/Info.plist`, entitlements files, or `PrivacyInfo.xcprivacy`; for this package they are generated outputs.
|
||||||
|
- Keep generated native folders untracked in git (`/ios`, `/android`) to avoid mixed CNG/bare behavior during EAS builds.
|
||||||
|
- Put App Store compliance and permission metadata in app config using these fields:
|
||||||
|
- `expo.ios.infoPlist` for Info.plist keys (usage strings, ATS, Bonjour, and related keys).
|
||||||
|
- `expo.ios.config.usesNonExemptEncryption` for export-compliance encryption declaration.
|
||||||
|
- `expo.ios.entitlements` for iOS entitlements.
|
||||||
|
- `expo.ios.privacyManifests` for Apple privacy manifest declarations.
|
||||||
|
- Keep `app.json` entries explicit and review-friendly:
|
||||||
|
- Permission descriptions should be complete, product-specific sentences.
|
||||||
|
- Compliance keys should be set intentionally rather than relying on implicit defaults.
|
||||||
|
- Preserve existing JSON style in this package (concise arrays and stable key grouping).
|
||||||
|
- After native config changes, verify resolved config with `bunx expo config --type prebuild --json` and check the resulting `ios` fields.
|
||||||
|
|
||||||
|
Example shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"ios": {
|
||||||
|
"infoPlist": {
|
||||||
|
"NSCameraUsageDescription": "...",
|
||||||
|
"NSMicrophoneUsageDescription": "..."
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"usesNonExemptEncryption": false
|
||||||
|
},
|
||||||
|
"entitlements": {
|
||||||
|
"com.apple.developer.kernel.extended-virtual-addressing": true
|
||||||
|
},
|
||||||
|
"privacyManifests": {
|
||||||
|
"NSPrivacyAccessedAPITypes": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
- Black screen + "No script URL provided" often means a stale dev client binary.
|
||||||
|
- `expo-doctor` duplicate module warnings may appear in Bun workspaces; prioritize runtime verification.
|
||||||
|
- `expo lint` may auto-generate `eslint.config.js`; do not commit accidental generated config unless requested.
|
||||||
|
|
||||||
|
## Before Finishing
|
||||||
|
|
||||||
|
- Run `bun run lint`.
|
||||||
|
- If behavior could break startup, run `bunx expo export --platform ios --clear`.
|
||||||
|
- Confirm no accidental config side effects were introduced.
|
||||||
|
- Summarize what was verified on-device vs only in tooling.
|
||||||
|
|
||||||
|
|
||||||
|
- Dev build (internal/dev client):
|
||||||
|
- bunx eas build --profile development --platform ios
|
||||||
|
- Production build + auto-submit:
|
||||||
|
- bunx eas build --profile production --platform ios --auto-submit
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "Control",
|
||||||
|
"slug": "control",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"icon": "./assets/images/icon.png",
|
||||||
|
"scheme": "mobilevoice",
|
||||||
|
"userInterfaceStyle": "automatic",
|
||||||
|
"ios": {
|
||||||
|
"icon": "./assets/images/icon.png",
|
||||||
|
"bundleIdentifier": "com.anomalyco.mobilevoice",
|
||||||
|
"config": {
|
||||||
|
"usesNonExemptEncryption": false
|
||||||
|
},
|
||||||
|
"entitlements": {
|
||||||
|
"com.apple.developer.kernel.extended-virtual-addressing": true
|
||||||
|
},
|
||||||
|
"infoPlist": {
|
||||||
|
"NSMicrophoneUsageDescription": "Control uses the microphone while you hold Record to turn your speech into text for an OpenCode session.",
|
||||||
|
"NSCameraUsageDescription": "Control uses the camera to scan the OpenCode pairing QR code shown on your computer.",
|
||||||
|
"NSLocalNetworkUsageDescription": "Control uses your local network to discover and connect to OpenCode servers running on your computer.",
|
||||||
|
"NSBonjourServices": ["_http._tcp."],
|
||||||
|
"NSAppTransportSecurity": {
|
||||||
|
"NSAllowsLocalNetworking": true,
|
||||||
|
"NSExceptionDomains": {
|
||||||
|
"100.64.0.0/10": {
|
||||||
|
"NSExceptionAllowsInsecureHTTPLoads": true
|
||||||
|
},
|
||||||
|
"ts.net": {
|
||||||
|
"NSIncludesSubdomains": true,
|
||||||
|
"NSExceptionAllowsInsecureHTTPLoads": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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",
|
||||||
|
"android.permission.ACCESS_NETWORK_STATE",
|
||||||
|
"android.permission.ACCESS_WIFI_STATE",
|
||||||
|
"android.permission.CHANGE_WIFI_MULTICAST_STATE"
|
||||||
|
],
|
||||||
|
"predictiveBackGestureEnabled": false
|
||||||
|
},
|
||||||
|
"web": {
|
||||||
|
"output": "static",
|
||||||
|
"favicon": "./assets/images/favicon.png"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"expo-router",
|
||||||
|
[
|
||||||
|
"expo-splash-screen",
|
||||||
|
{
|
||||||
|
"backgroundColor": "#121212",
|
||||||
|
"android": {
|
||||||
|
"image": "./assets/images/splash-icon.png",
|
||||||
|
"imageWidth": 76
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"react-native-audio-api",
|
||||||
|
"expo-asset",
|
||||||
|
"expo-audio",
|
||||||
|
[
|
||||||
|
"expo-notifications",
|
||||||
|
{
|
||||||
|
"enableBackgroundRemoteNotifications": true,
|
||||||
|
"sounds": ["./assets/sounds/alert.wav"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"experiments": {
|
||||||
|
"typedRoutes": true,
|
||||||
|
"reactCompiler": true
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"router": {},
|
||||||
|
"eas": {
|
||||||
|
"projectId": "50b3dac3-8b5e-4142-b749-65ecf7b2904d"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"owner": "anomaly-co",
|
||||||
|
"runtimeVersion": "1.0.0",
|
||||||
|
"updates": {
|
||||||
|
"url": "https://u.expo.dev/50b3dac3-8b5e-4142-b749-65ecf7b2904d"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 248 KiB |
|
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 |
|
After Width: | Height: | Size: 52 KiB |
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 324 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 215 B |
|
After Width: | Height: | Size: 347 B |
|
After Width: | Height: | Size: 468 B |
|
After Width: | Height: | Size: 253 B |
|
After Width: | Height: | Size: 343 B |
|
After Width: | Height: | Size: 479 B |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"cli": {
|
||||||
|
"version": ">= 18.4.0",
|
||||||
|
"appVersionSource": "remote"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"development": {
|
||||||
|
"bun": "1.3.11",
|
||||||
|
"developmentClient": true,
|
||||||
|
"distribution": "internal",
|
||||||
|
"channel": "development"
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"bun": "1.3.11",
|
||||||
|
"distribution": "internal",
|
||||||
|
"channel": "preview"
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"bun": "1.3.11",
|
||||||
|
"autoIncrement": true,
|
||||||
|
"channel": "production"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"submit": {
|
||||||
|
"production": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
// https://docs.expo.dev/guides/using-eslint/
|
||||||
|
const { defineConfig } = require("eslint/config")
|
||||||
|
const tsGuard = require("@typescript-eslint/eslint-plugin")
|
||||||
|
const expoConfig = require("eslint-config-expo/flat")
|
||||||
|
const reactHooksNext = require("eslint-plugin-react-hooks")
|
||||||
|
|
||||||
|
module.exports = defineConfig([
|
||||||
|
expoConfig,
|
||||||
|
{
|
||||||
|
ignores: ["dist/*"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["**/*.{ts,tsx}"],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
tsconfigRootDir: __dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
"react-hooks-next": reactHooksNext,
|
||||||
|
"ts-guard": tsGuard,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"ts-guard/no-explicit-any": "warn",
|
||||||
|
"ts-guard/no-floating-promises": "warn",
|
||||||
|
complexity: ["warn", 20],
|
||||||
|
"max-lines": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
max: 1200,
|
||||||
|
skipBlankLines: true,
|
||||||
|
skipComments: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"max-lines-per-function": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
max: 250,
|
||||||
|
skipBlankLines: true,
|
||||||
|
skipComments: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"no-console": ["warn", { allow: ["warn", "error"] }],
|
||||||
|
"no-nested-ternary": "warn",
|
||||||
|
"react-hooks/exhaustive-deps": "error",
|
||||||
|
"react-hooks-next/refs": "warn",
|
||||||
|
"react-hooks-next/set-state-in-effect": "warn",
|
||||||
|
"react-hooks-next/static-components": "warn",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
- While the model is loading for the first time, there should be some fun little like onboarding sequence that you can go through that makes sure the model is automated properly.
|
||||||
|
- When a permission/session complete notification is sent, if you click on it, the session/server should auto be selected.
|
||||||
|
- We need some sort of permissions UI in the top half of the generation.
|
||||||
|
- Need to figure out a good way to start new sessions.
|
||||||
|
- When an agent returns a generation, we should be able to expand it into a reader mode view.
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
{
|
||||||
|
"name": "mobile-voice",
|
||||||
|
"main": "expo-router/entry",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"start": "expo start",
|
||||||
|
"expo:start": "REACT_NATIVE_PACKAGER_HOSTNAME=exos.husky-tilapia.ts.net expo start --dev-client --clear --host lan",
|
||||||
|
"relay": "echo 'Use packages/apn-relay for APNs relay server'",
|
||||||
|
"relay:legacy": "node ./relay/opencode-relay.mjs",
|
||||||
|
"reset-project": "node ./scripts/reset-project.js",
|
||||||
|
"android": "expo run:android",
|
||||||
|
"ios": "expo run:ios",
|
||||||
|
"web": "expo start --web",
|
||||||
|
"lint": "expo lint",
|
||||||
|
"typecheck": "tsc -p tsconfig.typecheck.json --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fugood/react-native-audio-pcm-stream": "1.1.4",
|
||||||
|
"@react-navigation/bottom-tabs": "^7.15.5",
|
||||||
|
"@react-navigation/elements": "^2.9.10",
|
||||||
|
"@react-navigation/native": "^7.1.33",
|
||||||
|
"expo": "~55.0.9",
|
||||||
|
"expo-asset": "~55.0.10",
|
||||||
|
"expo-audio": "~55.0.9",
|
||||||
|
"expo-camera": "~55.0.11",
|
||||||
|
"expo-constants": "~55.0.9",
|
||||||
|
"expo-dev-client": "~55.0.19",
|
||||||
|
"expo-device": "~55.0.10",
|
||||||
|
"expo-file-system": "~55.0.12",
|
||||||
|
"expo-font": "~55.0.4",
|
||||||
|
"expo-glass-effect": "~55.0.8",
|
||||||
|
"expo-haptics": "~55.0.9",
|
||||||
|
"expo-image": "~55.0.6",
|
||||||
|
"expo-linking": "~55.0.9",
|
||||||
|
"expo-notifications": "~55.0.14",
|
||||||
|
"expo-router": "~55.0.8",
|
||||||
|
"expo-splash-screen": "~55.0.13",
|
||||||
|
"expo-status-bar": "~55.0.4",
|
||||||
|
"expo-symbols": "~55.0.5",
|
||||||
|
"expo-system-ui": "~55.0.11",
|
||||||
|
"expo-task-manager": "~55.0.10",
|
||||||
|
"expo-updates": "~55.0.16",
|
||||||
|
"expo-web-browser": "~55.0.10",
|
||||||
|
"react": "19.2.0",
|
||||||
|
"react-dom": "19.2.0",
|
||||||
|
"react-native": "0.83.4",
|
||||||
|
"react-native-audio-api": "^0.11.7",
|
||||||
|
"react-native-gesture-handler": "~2.30.0",
|
||||||
|
"react-native-reanimated": "4.2.1",
|
||||||
|
"react-native-safe-area-context": "~5.6.2",
|
||||||
|
"react-native-screens": "~4.23.0",
|
||||||
|
"react-native-web": "~0.21.0",
|
||||||
|
"react-native-worklets": "0.7.2",
|
||||||
|
"react-native-zeroconf": "0.14.0",
|
||||||
|
"whisper.rn": "0.5.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.57.2",
|
||||||
|
"@typescript-eslint/parser": "^8.57.2",
|
||||||
|
"@types/react": "~19.2.2",
|
||||||
|
"babel-preset-expo": "~55.0.8",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"typescript": "~5.9.2"
|
||||||
|
},
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
# Mobile Voice Refactor Plan
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Reduce the surface area of `src/app/index.tsx` without changing product behavior.
|
||||||
|
- Make device, network, and monitoring flows easier to reason about.
|
||||||
|
- Move toward React Native / Expo best practices for state, effects, and file structure.
|
||||||
|
- Use the new lint warnings as refactor prompts, not as permanent background noise.
|
||||||
|
|
||||||
|
## Current Pain Points
|
||||||
|
|
||||||
|
- `DictationScreen` currently owns onboarding, permissions, Whisper/model lifecycle, dictation, pairing, server/session sync, relay registration, notification handling, and most UI rendering.
|
||||||
|
- The screen mixes render-time derived state, imperative refs, polling, persistence, and native cleanup in one place.
|
||||||
|
- There are many nested conditionals and long derived blocks that are hard to scan.
|
||||||
|
- Best-effort async cleanup and silent catches make failures harder to understand.
|
||||||
|
|
||||||
|
## Target Shape
|
||||||
|
|
||||||
|
- `src/app/index.tsx`
|
||||||
|
- compose hooks and presentational sections
|
||||||
|
- keep only screen-level orchestration
|
||||||
|
- `src/features/onboarding/`
|
||||||
|
- onboarding step config
|
||||||
|
- onboarding UI component
|
||||||
|
- `src/features/dictation/`
|
||||||
|
- `use-whisper-dictation`
|
||||||
|
- transcript helpers
|
||||||
|
- `src/features/servers/`
|
||||||
|
- server/session refresh and pairing helpers
|
||||||
|
- persisted server state helpers
|
||||||
|
- `src/features/monitoring/`
|
||||||
|
- foreground SSE monitoring
|
||||||
|
- notification payload handling
|
||||||
|
- relay registration helpers
|
||||||
|
- `src/lib/`
|
||||||
|
- parser/validation helpers
|
||||||
|
- logger helper for dev-only diagnostics
|
||||||
|
|
||||||
|
## Refactor Order
|
||||||
|
|
||||||
|
### Phase 1: Extract pure helpers first
|
||||||
|
|
||||||
|
- Move onboarding step text/style selection into a config object or array.
|
||||||
|
- Move server/session payload parsing into dedicated helpers.
|
||||||
|
- Keep existing behavior and props the same.
|
||||||
|
|
||||||
|
### Phase 2: Extract onboarding UI
|
||||||
|
|
||||||
|
- Create an `OnboardingFlow` component that receives explicit state and handlers.
|
||||||
|
- Keep onboarding persistence in the screen until the UI extraction is stable.
|
||||||
|
|
||||||
|
### Phase 3: Extract dictation logic
|
||||||
|
|
||||||
|
- Move Whisper loading, recording, bulk/realtime transcription, and waveform state into a `useWhisperDictation` hook.
|
||||||
|
- Expose a small interface: recording state, transcript, actions, and model status.
|
||||||
|
|
||||||
|
### Phase 4: Extract server/session management
|
||||||
|
|
||||||
|
- Move server restore/save, pairing, health refresh, and active server/session selection into a dedicated hook.
|
||||||
|
- Centralize server parsing and dedupe logic.
|
||||||
|
|
||||||
|
### Phase 5: Extract monitoring and notifications
|
||||||
|
|
||||||
|
- Move SSE monitoring, push payload handling, and relay registration into a `useMonitoring` hook.
|
||||||
|
- Keep side effects close to the feature that owns them.
|
||||||
|
|
||||||
|
### Phase 6: Lint burn-down
|
||||||
|
|
||||||
|
- Replace `any` with explicit parsed shapes.
|
||||||
|
- Reduce nested ternaries in favor of config tables.
|
||||||
|
- Replace ad hoc `console.log` calls with a logger helper or `__DEV__`-gated diagnostics.
|
||||||
|
- Audit bare `.catch(() => {})` and convert non-trivial cases to explicit best-effort helpers or real error handling.
|
||||||
|
|
||||||
|
## Guardrails During Refactor
|
||||||
|
|
||||||
|
- Keep one behavior-preserving slice per PR.
|
||||||
|
- Do not introduce more derived state in `useEffect`.
|
||||||
|
- Prefer explicit hook inputs/outputs over hidden cross-hook coupling.
|
||||||
|
- Only use refs for imperative APIs, subscriptions, and race control.
|
||||||
|
- Re-run lint after each slice.
|
||||||
|
- Validate app behavior in the dev client for microphone, notifications, pairing, and monitoring flows.
|
||||||
|
|
||||||
|
## Exit Criteria
|
||||||
|
|
||||||
|
- `src/app/index.tsx` is mostly screen composition and stays under roughly 800-1200 lines.
|
||||||
|
- Feature logic lives in focused hooks/components with clearer ownership.
|
||||||
|
- New payload parsing does not rely on `any`.
|
||||||
|
- Lint warnings trend down instead of growing.
|
||||||
|
|
@ -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}`);
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
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",
|
||||||
|
"Parsed error meta:",
|
||||||
|
"Session activation failed",
|
||||||
|
])
|
||||||
|
|
||||||
|
configureNotificationBehavior()
|
||||||
|
registerBackgroundNotificationTask().catch(() => {})
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
return <Slot />
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
.expoLogoBackground {
|
||||||
|
background-image: linear-gradient(180deg, #3c9ffe, #0274df);
|
||||||
|
border-radius: 40px;
|
||||||
|
width: 128px;
|
||||||
|
height: 128px;
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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 />;
|
||||||
|
}
|
||||||
|
|
@ -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 />;
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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} />;
|
||||||
|
}
|
||||||