Compare commits
45 Commits
opencode-r
...
github-v1.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
702f741267 | ||
|
|
665a843086 | ||
|
|
1508196c0f | ||
|
|
f6243603f8 | ||
|
|
379e40d772 | ||
|
|
6c7e9f6f3a | ||
|
|
48f88af9aa | ||
|
|
60c927cf4f | ||
|
|
069cef8a44 | ||
|
|
cf423d2769 | ||
|
|
62ddb9d3ad | ||
|
|
0b975b01fb | ||
|
|
bb90aa6cb2 | ||
|
|
ce4e47a2e3 | ||
|
|
e3677c2ba2 | ||
|
|
a653a4b887 | ||
|
|
f7edffc11a | ||
|
|
dc16488bd7 | ||
|
|
d7a072dd46 | ||
|
|
5ae91aa810 | ||
|
|
18538e359b | ||
|
|
47577ae857 | ||
|
|
d22b5f026d | ||
|
|
26cdbc20b2 | ||
|
|
360d8dd940 | ||
|
|
426815a829 | ||
|
|
c6286d1bb9 | ||
|
|
710c81984a | ||
|
|
a1dbfb5967 | ||
|
|
64cc4623b5 | ||
|
|
5eae926846 | ||
|
|
cce05c1665 | ||
|
|
34213d4446 | ||
|
|
70aeebf2df | ||
|
|
d6b14e2467 | ||
|
|
6625766350 | ||
|
|
7baf998752 | ||
|
|
1d81335ab5 | ||
|
|
f7d4665e40 | ||
|
|
bbdbc107ae | ||
|
|
0fb0135e51 | ||
|
|
02f2cf439e | ||
|
|
6d42f97644 | ||
|
|
307251bf3c | ||
|
|
074ef032ee |
27
.github/workflows/porter-app-5534-apn-relay.yml
vendored
@@ -1,27 +0,0 @@
|
||||
"on":
|
||||
push:
|
||||
branches:
|
||||
- opencode-remote-voice
|
||||
name: Deploy to apn-relay
|
||||
jobs:
|
||||
porter-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: Set Github tag
|
||||
id: vars
|
||||
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
- name: Setup porter
|
||||
uses: porter-dev/setup-porter@v0.1.0
|
||||
- name: Deploy stack
|
||||
timeout-minutes: 30
|
||||
run: porter apply
|
||||
env:
|
||||
PORTER_APP_NAME: apn-relay
|
||||
PORTER_CLUSTER: "5534"
|
||||
PORTER_DEPLOYMENT_TARGET_ID: d60e67f5-b0a6-4275-8ed6-3cebaf092147
|
||||
PORTER_HOST: https://dashboard.porter.run
|
||||
PORTER_PROJECT: "18525"
|
||||
PORTER_TAG: ${{ steps.vars.outputs.sha_short }}
|
||||
PORTER_TOKEN: ${{ secrets.PORTER_APP_18525_975734319 }}
|
||||
@@ -1,10 +1,36 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicolo-ribaudo/oxc-project.github.io/refs/heads/json-schema/src/public/.oxlintrc.schema.json",
|
||||
"categories": {
|
||||
"suspicious": "warn"
|
||||
},
|
||||
"rules": {
|
||||
// Effect uses `function*` with Effect.gen/Effect.fnUntraced that don't always yield
|
||||
"require-yield": "off",
|
||||
// SolidJS uses `let ref: T | undefined` for JSX ref bindings assigned at runtime
|
||||
"no-unassigned-vars": "off"
|
||||
"no-unassigned-vars": "off",
|
||||
// SolidJS tracks reactive deps by reading properties inside createEffect
|
||||
"no-unused-expressions": "off",
|
||||
// Intentional control char matching (ANSI escapes, null byte sanitization)
|
||||
"no-control-regex": "off",
|
||||
// SST and plugin tools require triple-slash references
|
||||
"triple-slash-reference": "off",
|
||||
|
||||
// Suspicious category: suppress noisy rules
|
||||
// Effect's nested function* closures inherently shadow outer scope
|
||||
"no-shadow": "off",
|
||||
// Namespace-heavy codebase makes this too noisy
|
||||
"unicorn/consistent-function-scoping": "off",
|
||||
// Opinionated — .sort()/.reverse() mutation is fine in this codebase
|
||||
"unicorn/no-array-sort": "off",
|
||||
"unicorn/no-array-reverse": "off",
|
||||
// Not relevant — this isn't a DOM event handler codebase
|
||||
"unicorn/prefer-add-event-listener": "off",
|
||||
// Bundler handles module resolution
|
||||
"unicorn/require-module-specifiers": "off",
|
||||
// postMessage target origin not relevant for this codebase
|
||||
"unicorn/require-post-message-target-origin": "off",
|
||||
// Side-effectful constructors are intentional in some places
|
||||
"no-new": "off"
|
||||
},
|
||||
"ignorePatterns": ["**/node_modules", "**/dist", "**/.build", "**/.sst", "**/*.d.ts"]
|
||||
}
|
||||
|
||||
80
AGENTS.md
@@ -1,8 +1,12 @@
|
||||
# OpenCode Monorepo Agent Guide
|
||||
- To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
|
||||
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
|
||||
- The default branch in this repo is `dev`.
|
||||
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
|
||||
- Prefer automation: execute requested actions without confirmation unless blocked by missing info or safety/irreversibility.
|
||||
|
||||
This file is for coding agents working in `/Users/ryanvogel/dev/opencode`.
|
||||
## Style Guide
|
||||
|
||||
## Scope And Precedence
|
||||
### General Principles
|
||||
|
||||
- Keep things in one function unless composable or reusable
|
||||
- Avoid `try`/`catch` where possible
|
||||
@@ -51,48 +55,48 @@ else foo = 2
|
||||
|
||||
### Control Flow
|
||||
|
||||
- Prefer early returns over nested `else` blocks.
|
||||
- Keep functions focused; split only when it improves reuse or readability.
|
||||
Avoid `else` statements. Prefer early returns.
|
||||
|
||||
### Error Handling
|
||||
```ts
|
||||
// Good
|
||||
function foo() {
|
||||
if (condition) return 1
|
||||
return 2
|
||||
}
|
||||
|
||||
- Fail with actionable messages.
|
||||
- Avoid swallowing errors silently.
|
||||
- Log enough context to debug production issues (IDs, env, status), but never secrets.
|
||||
- In UI code, degrade gracefully for missing capabilities.
|
||||
// Bad
|
||||
function foo() {
|
||||
if (condition) return 1
|
||||
else return 2
|
||||
}
|
||||
```
|
||||
|
||||
### Data / DB
|
||||
### Schema Definitions (Drizzle)
|
||||
|
||||
- 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`.
|
||||
Use snake_case for field names so column names don't need to be redefined as strings.
|
||||
|
||||
### Testing Philosophy
|
||||
```ts
|
||||
// Good
|
||||
const table = sqliteTable("session", {
|
||||
id: text().primaryKey(),
|
||||
project_id: text().notNull(),
|
||||
created_at: integer().notNull(),
|
||||
})
|
||||
|
||||
- Prefer testing real behavior over mocks.
|
||||
- Add regression tests for bug fixes where practical.
|
||||
- Keep fixtures small and focused.
|
||||
// Bad
|
||||
const table = sqliteTable("session", {
|
||||
id: text("id").primaryKey(),
|
||||
projectID: text("project_id").notNull(),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
})
|
||||
```
|
||||
|
||||
## Agent Workflow Tips
|
||||
## Testing
|
||||
|
||||
- Read existing code paths before introducing new abstractions.
|
||||
- Match local patterns first; do not impose a new style per file.
|
||||
- If a package has its own `AGENTS.md`, review it before editing.
|
||||
- For OpenCode Effect services, follow `packages/opencode/AGENTS.md` strictly.
|
||||
- Avoid mocks as much as possible
|
||||
- Test actual implementation, do not duplicate logic into tests
|
||||
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
|
||||
|
||||
## Known Operational Notes
|
||||
## Type Checking
|
||||
|
||||
- `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.
|
||||
- Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly.
|
||||
|
||||
@@ -281,7 +281,7 @@ async function assertOpencodeConnected() {
|
||||
})
|
||||
connected = true
|
||||
break
|
||||
} catch (e) {}
|
||||
} catch {}
|
||||
await sleep(300)
|
||||
} while (retry++ < 30)
|
||||
|
||||
@@ -542,7 +542,7 @@ async function subscribeSessionEvents() {
|
||||
? JSON.stringify(part.state.input)
|
||||
: "Unknown"
|
||||
console.log()
|
||||
console.log(color + `|`, "\x1b[0m\x1b[2m" + ` ${tool.padEnd(7, " ")}`, "", "\x1b[0m" + title)
|
||||
console.log(`${color}|`, `\x1b[0m\x1b[2m ${tool.padEnd(7, " ")}`, "", `\x1b[0m${title}`)
|
||||
}
|
||||
|
||||
if (part.type === "text") {
|
||||
@@ -561,7 +561,7 @@ async function subscribeSessionEvents() {
|
||||
if (evt.properties.info.id !== session.id) continue
|
||||
session = evt.properties.info
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
@@ -576,7 +576,7 @@ async function subscribeSessionEvents() {
|
||||
async function summarize(response: string) {
|
||||
try {
|
||||
return await chat(`Summarize the following in less than 40 characters:\n\n${response}`)
|
||||
} catch (e) {
|
||||
} catch {
|
||||
if (isScheduleEvent()) {
|
||||
return "Scheduled task changes"
|
||||
}
|
||||
@@ -776,7 +776,7 @@ async function assertPermissions() {
|
||||
console.log(` permission: ${permission}`)
|
||||
} catch (error) {
|
||||
console.error(`Failed to check permissions: ${error}`)
|
||||
throw new Error(`Failed to check permissions for user ${actor}: ${error}`)
|
||||
throw new Error(`Failed to check permissions for user ${actor}: ${error}`, { cause: error })
|
||||
}
|
||||
|
||||
if (!["admin", "write"].includes(permission)) throw new Error(`User ${actor} does not have write permissions`)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { SECRET } from "./secret"
|
||||
import { domain, shortDomain } from "./stage"
|
||||
import { shortDomain } from "./stage"
|
||||
|
||||
const storage = new sst.cloudflare.Bucket("EnterpriseStorage")
|
||||
|
||||
const teams = new sst.cloudflare.x.SolidStart("Teams", {
|
||||
new sst.cloudflare.x.SolidStart("Teams", {
|
||||
domain: shortDomain,
|
||||
path: "packages/enterprise",
|
||||
buildCommand: "bun run build:cloudflare",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-PvIx2g1J5QIUIzkz2ABaAM4K/k/+xlBPDUExoOJNNuo=",
|
||||
"aarch64-linux": "sha256-YTAL+P13L5hgNJdDSiBED/UNa5zdTntnUUYDYL+Jdzo=",
|
||||
"aarch64-darwin": "sha256-y2VCJifYAp+H0lpDcJ0QfKNMG00Q/usFElaUIpdc8Vs=",
|
||||
"x86_64-darwin": "sha256-yz8edIlqLp06Y95ad8YjKz5azP7YATPle4TcDx6lM+U="
|
||||
"x86_64-linux": "sha256-VIgTxIjmZ4Bfwwdj/YFmRJdBpPHYhJSY31kh06EXX+0=",
|
||||
"aarch64-linux": "sha256-9118AS1ED0nrliURgZYBRuF/18RqXpUouhYJRlZ6jeA=",
|
||||
"aarch64-darwin": "sha256-ppo3MfSIGKQHJCdYEZiLFRc61PtcJ9J0kAXH1pNIonA=",
|
||||
"x86_64-darwin": "sha256-m+CZSOglBCTfNzbdBX6hXdDqqOzHNMzAddVp6BZVDtU="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
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
|
||||
@@ -1,106 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,14 +0,0 @@
|
||||
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"]
|
||||
@@ -1,46 +0,0 @@
|
||||
# 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
|
||||
```
|
||||
@@ -1,17 +0,0 @@
|
||||
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,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"$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"
|
||||
}
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
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)
|
||||
})
|
||||
@@ -1,11 +0,0 @@
|
||||
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 })
|
||||
@@ -1,47 +0,0 @@
|
||||
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
|
||||
@@ -1,5 +0,0 @@
|
||||
import { createHash } from "node:crypto"
|
||||
|
||||
export function hash(input: string) {
|
||||
return createHash("sha256").update(input).digest("hex")
|
||||
}
|
||||
@@ -1,448 +0,0 @@
|
||||
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 }
|
||||
@@ -1,35 +0,0 @@
|
||||
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),
|
||||
],
|
||||
)
|
||||
@@ -1,34 +0,0 @@
|
||||
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;
|
||||
`)
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@tsconfig/bun/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"noUncheckedIndexedAccess": false
|
||||
}
|
||||
}
|
||||
@@ -180,8 +180,8 @@ describe("SerializeAddon", () => {
|
||||
await writeAndWait(term, input)
|
||||
|
||||
const origLine = term.buffer.active.getLine(0)
|
||||
const origFg = origLine!.getCell(0)!.getFgColor()
|
||||
const origBg = origLine!.getCell(0)!.getBgColor()
|
||||
const _origFg = origLine!.getCell(0)!.getFgColor()
|
||||
const _origBg = origLine!.getCell(0)!.getBgColor()
|
||||
expect(origLine!.getCell(0)!.isBold()).toBe(1)
|
||||
|
||||
const serialized = addon.serialize({ range: { start: 0, end: 0 } })
|
||||
|
||||
@@ -258,8 +258,8 @@ class StringSerializeHandler extends BaseSerializeHandler {
|
||||
}
|
||||
|
||||
protected _beforeSerialize(rows: number, start: number, _end: number): void {
|
||||
this._allRows = new Array<string>(rows)
|
||||
this._allRowSeparators = new Array<string>(rows)
|
||||
this._allRows = Array.from<string>({ length: rows })
|
||||
this._allRowSeparators = Array.from<string>({ length: rows })
|
||||
this._rowIndex = 0
|
||||
|
||||
this._currentRow = ""
|
||||
|
||||
@@ -10,7 +10,7 @@ import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
||||
import { MetaProvider } from "@solidjs/meta"
|
||||
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||
import { type Duration, Effect } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import {
|
||||
type Component,
|
||||
createMemo,
|
||||
@@ -156,11 +156,6 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
|
||||
)
|
||||
}
|
||||
|
||||
const effectMinDuration =
|
||||
(duration: Duration.Input) =>
|
||||
<A, E, R>(e: Effect.Effect<A, E, R>) =>
|
||||
Effect.all([e, Effect.sleep(duration)], { concurrency: "unbounded" }).pipe(Effect.map((v) => v[0]))
|
||||
|
||||
function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
|
||||
const server = useServer()
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
|
||||
@@ -8,20 +8,14 @@ import { SettingsGeneral } from "./settings-general"
|
||||
import { SettingsKeybinds } from "./settings-keybinds"
|
||||
import { SettingsProviders } from "./settings-providers"
|
||||
import { SettingsModels } from "./settings-models"
|
||||
import { SettingsPair } from "./settings-pair"
|
||||
|
||||
export const DialogSettings: Component<{ defaultTab?: string }> = (props) => {
|
||||
export const DialogSettings: Component = () => {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
|
||||
return (
|
||||
<Dialog size="x-large" transition>
|
||||
<Tabs
|
||||
orientation="vertical"
|
||||
variant="settings"
|
||||
defaultValue={props.defaultTab ?? "general"}
|
||||
class="h-full settings-dialog"
|
||||
>
|
||||
<Tabs orientation="vertical" variant="settings" defaultValue="general" class="h-full settings-dialog">
|
||||
<Tabs.List>
|
||||
<div class="flex flex-col justify-between h-full w-full">
|
||||
<div class="flex flex-col gap-3 w-full pt-3">
|
||||
@@ -51,10 +45,6 @@ export const DialogSettings: Component<{ defaultTab?: string }> = (props) => {
|
||||
<Icon name="models" />
|
||||
{language.t("settings.models.title")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="pair">
|
||||
<Icon name="link" />
|
||||
{language.t("settings.pair.title")}
|
||||
</Tabs.Trigger>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -77,9 +67,6 @@ export const DialogSettings: Component<{ defaultTab?: string }> = (props) => {
|
||||
<Tabs.Content value="models" class="no-scrollbar">
|
||||
<SettingsModels />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="pair" class="no-scrollbar">
|
||||
<SettingsPair />
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
Switch,
|
||||
untrack,
|
||||
type ComponentProps,
|
||||
type JSXElement,
|
||||
type ParentProps,
|
||||
} from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { getFilename } from "@opencode-ai/shared/util/path"
|
||||
import { createEffect, createMemo, For, onCleanup, Show } from "solid-js"
|
||||
import { createEffect, createMemo, For, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Portal } from "solid-js/web"
|
||||
import { useCommand } from "@/context/command"
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import { type Component, createResource, Show } from "solid-js"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { SettingsList } from "./settings-list"
|
||||
|
||||
type PairResult = { enabled: false } | { enabled: true; hosts: string[]; link: string; qr: string }
|
||||
|
||||
export const SettingsPair: Component = () => {
|
||||
const language = useLanguage()
|
||||
const globalSDK = useGlobalSDK()
|
||||
const platform = usePlatform()
|
||||
|
||||
const [data] = createResource(async () => {
|
||||
const f = platform.fetch ?? fetch
|
||||
const res = await f(`${globalSDK.url}/experimental/push/pair`)
|
||||
if (!res.ok) return { enabled: false as const }
|
||||
return (await res.json()) as PairResult
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-6 py-4 px-5">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="text-16-semibold text-text-strong">{language.t("settings.pair.title")}</h2>
|
||||
<p class="text-13-regular text-text-weak">{language.t("settings.pair.description")}</p>
|
||||
</div>
|
||||
|
||||
<Show when={data.loading}>
|
||||
<SettingsList>
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<span class="text-14-regular text-text-weak">{language.t("settings.pair.loading")}</span>
|
||||
</div>
|
||||
</SettingsList>
|
||||
</Show>
|
||||
|
||||
<Show when={data.error}>
|
||||
<SettingsList>
|
||||
<div class="flex flex-col items-center justify-center py-12 gap-3 text-center">
|
||||
<Icon name="warning" size="large" />
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-14-medium text-text-strong">{language.t("settings.pair.error.title")}</span>
|
||||
<span class="text-13-regular text-text-weak max-w-md">
|
||||
{language.t("settings.pair.error.description")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsList>
|
||||
</Show>
|
||||
|
||||
<Show when={!data.loading && !data.error && data()}>
|
||||
{(result) => (
|
||||
<Show
|
||||
when={result().enabled && result()}
|
||||
fallback={
|
||||
<SettingsList>
|
||||
<div class="flex flex-col items-center justify-center py-12 gap-3 text-center">
|
||||
<Icon name="link" size="large" />
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-14-medium text-text-strong">{language.t("settings.pair.disabled.title")}</span>
|
||||
<span class="text-13-regular text-text-weak max-w-md">
|
||||
{language.t("settings.pair.disabled.description")}
|
||||
</span>
|
||||
</div>
|
||||
<code class="text-12-regular text-text-weak bg-surface-inset px-3 py-1.5 rounded mt-1">
|
||||
opencode serve --relay-url <url> --relay-secret <secret>
|
||||
</code>
|
||||
</div>
|
||||
</SettingsList>
|
||||
}
|
||||
>
|
||||
{(pair) => (
|
||||
<SettingsList>
|
||||
<div class="flex flex-col items-center py-8 gap-4">
|
||||
<img src={(pair() as PairResult & { enabled: true }).qr} alt="Pairing QR code" class="w-64 h-64" />
|
||||
<div class="flex flex-col gap-1 text-center max-w-sm">
|
||||
<span class="text-14-medium text-text-strong">
|
||||
{language.t("settings.pair.instructions.title")}
|
||||
</span>
|
||||
<span class="text-13-regular text-text-weak">
|
||||
{language.t("settings.pair.instructions.description")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsList>
|
||||
)}
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createEffect, createMemo, onCleanup, Show, untrack } from "solid-js"
|
||||
import { createEffect, createMemo, Show, untrack } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
|
||||
@@ -128,6 +128,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
||||
if (started) return run
|
||||
started = true
|
||||
run = (async () => {
|
||||
// oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit
|
||||
while (!abort.signal.aborted && started) {
|
||||
attempt = new AbortController()
|
||||
lastEventAt = Date.now()
|
||||
|
||||
@@ -65,22 +65,6 @@ function runAll(list: Array<() => Promise<unknown>>) {
|
||||
return Promise.allSettled(list.map((item) => item()))
|
||||
}
|
||||
|
||||
function showErrors(input: {
|
||||
errors: unknown[]
|
||||
title: string
|
||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||
formatMoreCount: (count: number) => string
|
||||
}) {
|
||||
if (input.errors.length === 0) return
|
||||
const message = formatServerError(input.errors[0], input.translate)
|
||||
const more = input.errors.length > 1 ? input.formatMoreCount(input.errors.length - 1) : ""
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.title,
|
||||
description: message + more,
|
||||
})
|
||||
}
|
||||
|
||||
export async function bootstrapGlobal(input: {
|
||||
globalSDK: OpencodeClient
|
||||
requestFailedTitle: string
|
||||
|
||||
@@ -63,6 +63,7 @@ export function createRefreshQueue(input: QueueInput) {
|
||||
}
|
||||
} finally {
|
||||
running = false
|
||||
// oxlint-disable-next-line no-unsafe-finally -- intentional: early return skips schedule() when paused
|
||||
if (input.paused()) return
|
||||
if (root || queued.size) schedule()
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
Part,
|
||||
Path,
|
||||
PermissionRequest,
|
||||
Project,
|
||||
ProviderListResponse,
|
||||
QuestionRequest,
|
||||
Session,
|
||||
|
||||
@@ -28,7 +28,6 @@ export const dict = {
|
||||
"command.provider.connect": "Connect provider",
|
||||
"command.server.switch": "Switch server",
|
||||
"command.settings.open": "Open settings",
|
||||
"command.pair.show": "Pair mobile device",
|
||||
"command.session.previous": "Previous session",
|
||||
"command.session.next": "Next session",
|
||||
"command.session.previous.unseen": "Previous unread session",
|
||||
@@ -858,17 +857,6 @@ export const dict = {
|
||||
"settings.providers.tag.config": "Config",
|
||||
"settings.providers.tag.custom": "Custom",
|
||||
"settings.providers.tag.other": "Other",
|
||||
"settings.pair.title": "Pair",
|
||||
"settings.pair.description": "Pair a mobile device for push notifications.",
|
||||
"settings.pair.loading": "Loading pairing info...",
|
||||
"settings.pair.error.title": "Could not load pairing info",
|
||||
"settings.pair.error.description": "Check that the server is reachable and try again.",
|
||||
"settings.pair.disabled.title": "Push relay is not enabled",
|
||||
"settings.pair.disabled.description": "Start the server with push relay options to enable mobile pairing.",
|
||||
"settings.pair.instructions.title": "Scan with the OpenCode Control app",
|
||||
"settings.pair.instructions.description":
|
||||
"Open the OpenCode Control app and scan this QR code to pair your device for push notifications.",
|
||||
|
||||
"settings.models.title": "Models",
|
||||
"settings.models.description": "Model settings will be configurable here.",
|
||||
"settings.agents.title": "Agents",
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { dict as en } from "./en"
|
||||
|
||||
type Keys = keyof typeof en
|
||||
|
||||
export const dict = {
|
||||
"command.category.suggested": "추천",
|
||||
"command.category.view": "보기",
|
||||
|
||||
@@ -704,7 +704,7 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
createEffect(() => {
|
||||
const active = new Set(visibleSessionDirs())
|
||||
for (const directory of [...prefetchedByDir.keys()]) {
|
||||
for (const directory of prefetchedByDir.keys()) {
|
||||
if (active.has(directory)) continue
|
||||
prefetchedByDir.delete(directory)
|
||||
}
|
||||
@@ -1058,13 +1058,6 @@ export default function Layout(props: ParentProps) {
|
||||
keybind: "mod+comma",
|
||||
onSelect: () => openSettings(),
|
||||
},
|
||||
{
|
||||
id: "pair.show",
|
||||
title: language.t("command.pair.show"),
|
||||
category: language.t("command.category.settings"),
|
||||
slash: "pair",
|
||||
onSelect: () => openSettings("pair"),
|
||||
},
|
||||
{
|
||||
id: "session.previous",
|
||||
title: language.t("command.session.previous"),
|
||||
@@ -1217,11 +1210,11 @@ export default function Layout(props: ParentProps) {
|
||||
})
|
||||
}
|
||||
|
||||
function openSettings(defaultTab?: string) {
|
||||
function openSettings() {
|
||||
const run = ++dialogRun
|
||||
void import("@/components/dialog-settings").then((x) => {
|
||||
if (dialogDead || dialogRun !== run) return
|
||||
dialog.show(() => <x.DialogSettings defaultTab={defaultTab} />)
|
||||
dialog.show(() => <x.DialogSettings />)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -433,7 +433,6 @@ export default function Page() {
|
||||
const isChildSession = createMemo(() => !!info()?.parentID)
|
||||
const diffs = createMemo(() => (params.id ? list(sync.data.session_diff[params.id]) : []))
|
||||
const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
|
||||
const hasSessionReview = createMemo(() => sessionCount() > 0)
|
||||
const canReview = createMemo(() => !!sync.project)
|
||||
const reviewTab = createMemo(() => isDesktop())
|
||||
const tabState = createSessionTabs({
|
||||
@@ -443,8 +442,6 @@ export default function Page() {
|
||||
review: reviewTab,
|
||||
hasReview: canReview,
|
||||
})
|
||||
const contextOpen = tabState.contextOpen
|
||||
const openedTabs = tabState.openedTabs
|
||||
const activeTab = tabState.activeTab
|
||||
const activeFileTab = tabState.activeFileTab
|
||||
const revertMessageID = createMemo(() => info()?.revert?.messageID)
|
||||
|
||||
@@ -378,12 +378,6 @@ export function FileTabContent(props: { tab: string }) {
|
||||
requestAnimationFrame(() => comments.clearFocus())
|
||||
})
|
||||
|
||||
const cancelCommenting = () => {
|
||||
const p = path()
|
||||
if (p) file.setSelectedLines(p, null)
|
||||
setNote("commenting", null)
|
||||
}
|
||||
|
||||
let prev = {
|
||||
loaded: false,
|
||||
ready: false,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createEffect, createSignal, onCleanup, type JSX } from "solid-js"
|
||||
import { createEffect, onCleanup, type JSX } from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import { SessionReview } from "@opencode-ai/ui/session-review"
|
||||
|
||||
@@ -46,7 +46,9 @@ describe("runtime adapters", () => {
|
||||
})
|
||||
|
||||
test("resolves speech recognition constructor with webkit precedence", () => {
|
||||
// oxlint-disable-next-line no-extraneous-class
|
||||
class SpeechCtor {}
|
||||
// oxlint-disable-next-line no-extraneous-class
|
||||
class WebkitCtor {}
|
||||
const ctor = getSpeechRecognitionCtor({
|
||||
SpeechRecognition: SpeechCtor,
|
||||
|
||||
@@ -8,7 +8,6 @@ import { LOCALES, route } from "../src/lib/language.js"
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const BASE_URL = config.baseUrl
|
||||
const PUBLIC_DIR = join(__dirname, "../public")
|
||||
const ROUTES_DIR = join(__dirname, "../src/routes")
|
||||
const DOCS_DIR = join(__dirname, "../../../web/src/content/docs")
|
||||
|
||||
interface SitemapEntry {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { action, useSubmission } from "@solidjs/router"
|
||||
import dock from "../asset/lander/dock.png"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
import { Show } from "solid-js"
|
||||
import { useI18n } from "~/context/i18n"
|
||||
|
||||
@@ -47,7 +47,7 @@ export function Header(props: { zen?: boolean; go?: boolean; hideGetStarted?: bo
|
||||
notation: "compact",
|
||||
compactDisplay: "short",
|
||||
maximumFractionDigits: 0,
|
||||
}).format(githubData()?.stars!)
|
||||
}).format(githubData()?.stars)
|
||||
: config.github.starsFormatted.compact,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { JSX } from "solid-js"
|
||||
|
||||
export function IconZen(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
||||
export function IconZen(_props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
||||
return (
|
||||
<svg width="84" height="30" viewBox="0 0 84 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M24 24H6V18H18V12H24V24ZM6 18H0V12H6V18Z" fill="currentColor" fill-opacity="0.2" />
|
||||
@@ -13,7 +13,7 @@ export function IconZen(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function IconGo(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
||||
export function IconGo(_props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
||||
return (
|
||||
<svg width="54" height="30" viewBox="0 0 54 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M24 30H0V0H24V6H6V24H18V18H12V12H24V30Z" fill="currentColor" />
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export {}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { APIEvent } from "@solidjs/start"
|
||||
import { useAuthSession } from "~/context/auth"
|
||||
|
||||
export async function GET(input: APIEvent) {
|
||||
export async function GET(_input: APIEvent) {
|
||||
const session = await useAuthSession()
|
||||
return Response.json(session.data)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Title } from "@solidjs/meta"
|
||||
import { createAsync, query, useParams } from "@solidjs/router"
|
||||
import { createSignal, For, Show } from "solid-js"
|
||||
import { Database, desc, eq } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { Database, eq } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { BenchmarkTable } from "@opencode-ai/console-core/schema/benchmark.sql.js"
|
||||
import { useI18n } from "~/context/i18n"
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { json } from "@solidjs/router"
|
||||
import { Database } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
|
||||
|
||||
export async function GET(evt: APIEvent) {
|
||||
export async function GET(_evt: APIEvent) {
|
||||
return json({
|
||||
data: await Database.use(async (tx) => {
|
||||
const result = await tx.$count(UserTable)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import "./index.css"
|
||||
import { createAsync, query, redirect } from "@solidjs/router"
|
||||
import { createAsync, query } from "@solidjs/router"
|
||||
import { Title, Meta } from "@solidjs/meta"
|
||||
import { For, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
||||
//import { HttpHeader } from "@solidjs/start"
|
||||
|
||||
@@ -31,8 +31,6 @@ export default function Home() {
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
const githubData = createAsync(() => github())
|
||||
const release = createMemo(() => githubData()?.release)
|
||||
|
||||
const handleCopyClick = (event: Event) => {
|
||||
const button = event.currentTarget as HTMLButtonElement
|
||||
const text = button.textContent
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useI18n } from "~/context/i18n"
|
||||
import { useLanguage } from "~/context/language"
|
||||
import "./user-menu.css"
|
||||
|
||||
const logout = action(async () => {
|
||||
const _logout = action(async () => {
|
||||
"use server"
|
||||
const auth = await useAuthSession()
|
||||
const event = getRequestEvent()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { query, useParams, action, createAsync, redirect, useSubmission } from "@solidjs/router"
|
||||
import { For, Show, createEffect } from "solid-js"
|
||||
import { For, createEffect } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { withActor } from "~/context/auth.withActor"
|
||||
import { Actor } from "@opencode-ai/console-core/actor.js"
|
||||
|
||||
@@ -90,7 +90,7 @@ export function ReloadSection() {
|
||||
}
|
||||
const info = billingInfo()!
|
||||
setStore("show", true)
|
||||
setStore("reload", info.reload ? true : true)
|
||||
setStore("reload", true)
|
||||
setStore("reloadAmount", info.reloadAmount.toString())
|
||||
setStore("reloadTrigger", info.reloadTrigger.toString())
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import "./index.css"
|
||||
import { createAsync, query, redirect } from "@solidjs/router"
|
||||
import { createAsync, query } from "@solidjs/router"
|
||||
import { Title, Meta } from "@solidjs/meta"
|
||||
//import { HttpHeader } from "@solidjs/start"
|
||||
import zenLogoLight from "../../asset/zen-ornate-light.svg"
|
||||
|
||||
@@ -345,7 +345,7 @@ export async function handler(
|
||||
logger.metric({
|
||||
"error.cause2": JSON.stringify(error.cause),
|
||||
})
|
||||
} catch (e) {}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Note: both top level "type" and "error.type" fields are used by the @ai-sdk/anthropic client to render the error message.
|
||||
|
||||
@@ -153,7 +153,7 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
|
||||
let json
|
||||
try {
|
||||
json = JSON.parse(data.slice(6))
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ export const googleHelper: ProviderHelper = ({ providerModel }) => ({
|
||||
let json
|
||||
try {
|
||||
json = JSON.parse(chunk.slice(6)) as { usageMetadata?: Usage }
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage, safetyIdentif
|
||||
headers.set("authorization", `Bearer ${apiKey}`)
|
||||
headers.set("x-session-affinity", headers.get("x-opencode-session") ?? "")
|
||||
},
|
||||
modifyBody: (body: Record<string, any>, workspaceID?: string) => {
|
||||
modifyBody: (body: Record<string, any>, _workspaceID?: string) => {
|
||||
return {
|
||||
...body,
|
||||
...(body.stream ? { stream_options: { include_usage: true } } : {}),
|
||||
@@ -49,7 +49,7 @@ export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage, safetyIdentif
|
||||
let json
|
||||
try {
|
||||
json = JSON.parse(chunk.slice(6)) as { usage?: Usage }
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -289,7 +289,7 @@ export function fromOaCompatibleResponse(resp: any): CommonResponse {
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant" as const,
|
||||
...(content.length > 0 && content.some((c) => c.type === "text")
|
||||
...(content.some((c) => c.type === "text")
|
||||
? {
|
||||
content: content
|
||||
.filter((c) => c.type === "text")
|
||||
@@ -297,7 +297,7 @@ export function fromOaCompatibleResponse(resp: any): CommonResponse {
|
||||
.join(""),
|
||||
}
|
||||
: {}),
|
||||
...(content.length > 0 && content.some((c) => c.type === "tool_use")
|
||||
...(content.some((c) => c.type === "tool_use")
|
||||
? {
|
||||
tool_calls: content
|
||||
.filter((c) => c.type === "tool_use")
|
||||
|
||||
@@ -36,7 +36,7 @@ export const openaiHelper: ProviderHelper = ({ workspaceID }) => ({
|
||||
let json
|
||||
try {
|
||||
json = JSON.parse(data.slice(6)) as { response?: { usage?: Usage } }
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.j
|
||||
import { ModelTable } from "@opencode-ai/console-core/schema/model.sql.js"
|
||||
import { ZenData } from "@opencode-ai/console-core/model.js"
|
||||
|
||||
export async function OPTIONS(input: APIEvent) {
|
||||
export async function OPTIONS(_input: APIEvent) {
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
|
||||
@@ -6,8 +6,8 @@ export function POST(input: APIEvent) {
|
||||
format: "google",
|
||||
modelList: "full",
|
||||
parseApiKey: (headers: Headers) => headers.get("x-goog-api-key") ?? undefined,
|
||||
parseModel: (url: string, body: any) => url.split("/").pop()?.split(":")?.[0] ?? "",
|
||||
parseIsStream: (url: string, body: any) =>
|
||||
parseModel: (url: string, _body: any) => url.split("/").pop()?.split(":")?.[0] ?? "",
|
||||
parseIsStream: (url: string, _body: any) =>
|
||||
// ie. url: https://opencode.ai/zen/v1/models/gemini-3-pro:streamGenerateContent?alt=sse'
|
||||
url.split("/").pop()?.split(":")?.[1]?.startsWith("streamGenerateContent") ?? false,
|
||||
})
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { subscribe } from "diagnostics_channel"
|
||||
import { Billing } from "../src/billing.js"
|
||||
import { and, Database, eq } from "../src/drizzle/index.js"
|
||||
import { BillingTable, PaymentTable, SubscriptionTable } from "../src/schema/billing.sql.js"
|
||||
import { Database, eq } from "../src/drizzle/index.js"
|
||||
import { BillingTable } from "../src/schema/billing.sql.js"
|
||||
|
||||
const workspaceID = process.argv[2]
|
||||
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { Billing } from "../src/billing.js"
|
||||
import { and, Database, eq, isNull, sql } from "../src/drizzle/index.js"
|
||||
import { and, Database, eq, isNull } from "../src/drizzle/index.js"
|
||||
import { UserTable } from "../src/schema/user.sql.js"
|
||||
import { BillingTable, PaymentTable, SubscriptionTable } from "../src/schema/billing.sql.js"
|
||||
import { BillingTable, SubscriptionTable } from "../src/schema/billing.sql.js"
|
||||
import { Identifier } from "../src/identifier.js"
|
||||
import { centsToMicroCents } from "../src/util/price.js"
|
||||
import { AuthTable } from "../src/schema/auth.sql.js"
|
||||
import { BlackData } from "../src/black.js"
|
||||
import { Actor } from "../src/actor.js"
|
||||
|
||||
const plan = "200"
|
||||
const couponID = "JAIr0Pe1"
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { subscribe } from "diagnostics_channel"
|
||||
import { Billing } from "../src/billing.js"
|
||||
import { and, Database, eq } from "../src/drizzle/index.js"
|
||||
import { BillingTable, PaymentTable, SubscriptionTable } from "../src/schema/billing.sql.js"
|
||||
import { Database, eq } from "../src/drizzle/index.js"
|
||||
import { BillingTable } from "../src/schema/billing.sql.js"
|
||||
|
||||
const workspaceID = process.argv[2]
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Database, eq, and, sql, inArray, isNull, count } from "../src/drizzle/index.js"
|
||||
import { Database, eq, and, sql, inArray, isNull } from "../src/drizzle/index.js"
|
||||
import { BillingTable, BlackPlans } from "../src/schema/billing.sql.js"
|
||||
import { UserTable } from "../src/schema/user.sql.js"
|
||||
import { AuthTable } from "../src/schema/auth.sql.js"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export {}
|
||||
|
||||
@@ -48,7 +48,7 @@ export namespace Log {
|
||||
function use() {
|
||||
try {
|
||||
return ctx.use()
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return { tags: {} }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export function wslPath(path: string, mode: "windows" | "linux" | null): string
|
||||
const output = execFileSync("wsl", ["-e", "wslpath", flag, path])
|
||||
return output.toString().trim()
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to run wslpath: ${String(error)}`)
|
||||
throw new Error(`Failed to run wslpath: ${String(error)}`, { cause: error })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test, afterAll } from "bun:test"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Share } from "../../src/core/share"
|
||||
import { Storage } from "../../src/core/storage"
|
||||
import { Identifier } from "@opencode-ai/shared/util/identifier"
|
||||
|
||||
@@ -12,21 +12,8 @@ type Env = {
|
||||
WEB_DOMAIN: string
|
||||
}
|
||||
|
||||
async function getFeishuTenantToken(): Promise<string> {
|
||||
const response = await fetch("https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
app_id: Resource.FEISHU_APP_ID.value,
|
||||
app_secret: Resource.FEISHU_APP_SECRET.value,
|
||||
}),
|
||||
})
|
||||
const data = (await response.json()) as { tenant_access_token?: string }
|
||||
if (!data.tenant_access_token) throw new Error("Failed to get Feishu tenant token")
|
||||
return data.tenant_access_token
|
||||
}
|
||||
|
||||
export class SyncServer extends DurableObject<Env> {
|
||||
// oxlint-disable-next-line no-useless-constructor
|
||||
constructor(ctx: DurableObjectState, env: Env) {
|
||||
super(ctx, env)
|
||||
}
|
||||
@@ -49,9 +36,9 @@ export class SyncServer extends DurableObject<Env> {
|
||||
})
|
||||
}
|
||||
|
||||
async webSocketMessage(ws, message) {}
|
||||
async webSocketMessage(_ws, _message) {}
|
||||
|
||||
async webSocketClose(ws, code, reason, wasClean) {
|
||||
async webSocketClose(ws, code, _reason, _wasClean) {
|
||||
ws.close(code, "Durable Object is closing WebSocket")
|
||||
}
|
||||
|
||||
@@ -195,7 +182,7 @@ export default new Hono<{ Bindings: Env }>()
|
||||
let info
|
||||
const messages: Record<string, any> = {}
|
||||
data.forEach((d) => {
|
||||
const [root, type, ...splits] = d.key.split("/")
|
||||
const [root, type] = d.key.split("/")
|
||||
if (root !== "session") return
|
||||
if (type === "info") {
|
||||
info = d.content
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"plugins": {
|
||||
"figma": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
43
packages/mobile-voice/.gitignore
vendored
@@ -1,43 +0,0 @@
|
||||
# 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
|
||||
@@ -1,183 +0,0 @@
|
||||
# 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
|
||||
@@ -1,39 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,101 +0,0 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "Control",
|
||||
"slug": "control",
|
||||
"version": "1.0.2",
|
||||
"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": "#1a1a1a",
|
||||
"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.2",
|
||||
"updates": {
|
||||
"url": "https://u.expo.dev/50b3dac3-8b5e-4142-b749-65ecf7b2904d"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 755 KiB |
@@ -1,3 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 608 B |
|
Before Width: | Height: | Size: 52 KiB |
@@ -1,40 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 755 KiB |
|
Before Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 755 KiB |
|
Before Width: | Height: | Size: 324 KiB |
|
Before Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 215 B |