Compare commits

..

14 Commits

Author SHA1 Message Date
Frank
a94ac5aa2c zen: ZDR policy 2026-03-28 20:16:52 -04:00
Frank
62ac45a9c9 wip: zen 2026-03-28 20:16:51 -04:00
Frank
f7c2ef876f wip: zen 2026-03-28 20:16:51 -04:00
opencode-agent[bot]
6639f92739 chore: update nix node_modules hashes 2026-03-29 00:07:30 +00:00
Aiden Cline
36aeb32159 ignore: kill todo (#19566) 2026-03-29 00:06:32 +00:00
Caleb Norton
ff37d7c2df fix: nix embedded web-ui support (#19561) 2026-03-28 18:39:22 -05:00
Caleb Norton
4f96eb239f fix: respect semver build identifiers for nix (#11915) 2026-03-28 18:39:03 -05:00
Sebastian
38af99dcb4 prompt slot (#19563) 2026-03-29 00:27:27 +01:00
opencode-agent[bot]
772059acb5 chore: update nix node_modules hashes 2026-03-28 20:46:03 +00:00
Vladimir Glafirov
1f290fc1ba fix: update opencode-gitlab-auth to 2.0.1 (#19552) 2026-03-28 20:10:06 +00:00
Sebastian
77d4f99497 use theme color for prompt placeholder (#19535) 2026-03-28 17:20:37 +01:00
Dax
aa2d753e7e feat: dialog variant menu and subagent improvements (#19537) 2026-03-28 16:16:00 +00:00
Kit Langton
860531c275 refactor(session): effectify session processor (#19485) 2026-03-28 12:09:47 -04:00
Dax
2b86b36c8c feat: open dialog for model variant selection instead of cycling (#19534) 2026-03-28 15:37:20 +00:00
136 changed files with 2593 additions and 18435 deletions

View File

@@ -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 }}

View File

@@ -1,5 +1,5 @@
/** @jsxImportSource @opentui/solid */
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid"
import { RGBA, VignetteEffect } from "@opentui/core"
import type {
TuiKeybindSet,
@@ -615,7 +615,7 @@ const Modal = (props: {
)
}
const home = (input: Cfg): TuiSlotPlugin => ({
const home = (api: TuiPluginApi, input: Cfg) => ({
slots: {
home_logo(ctx) {
const map = ctx.theme.current
@@ -649,6 +649,36 @@ const home = (input: Cfg): TuiSlotPlugin => ({
</box>
)
},
home_prompt(ctx, value) {
const skin = look(ctx.theme.current)
type Prompt = (props: {
workspaceID?: string
hint?: JSX.Element
placeholders?: {
normal?: string[]
shell?: string[]
}
}) => JSX.Element
if (!("Prompt" in api.ui)) return null
const view = api.ui.Prompt
if (typeof view !== "function") return null
const Prompt = view as Prompt
const normal = [
`[SMOKE] route check for ${input.label}`,
"[SMOKE] confirm home_prompt slot override",
"[SMOKE] verify api.ui.Prompt rendering",
]
const shell = ["printf '[SMOKE] home prompt\n'", "git status --short", "bun --version"]
const Hint = (
<box flexShrink={0} flexDirection="row" gap={1}>
<text fg={skin.muted}>
<span style={{ fg: skin.accent }}></span> smoke home prompt
</text>
</box>
)
return <Prompt workspaceID={value.workspace_id} hint={Hint} placeholders={{ normal, shell }} />
},
home_bottom(ctx) {
const skin = look(ctx.theme.current)
const text = "extra content in the unified home bottom slot"
@@ -706,8 +736,8 @@ const block = (input: Cfg, order: number, title: string, text: string): TuiSlotP
},
})
const slot = (input: Cfg): TuiSlotPlugin[] => [
home(input),
const slot = (api: TuiPluginApi, input: Cfg): TuiSlotPlugin[] => [
home(api, input),
block(input, 50, "Smoke above", "renders above internal sidebar blocks"),
block(input, 250, "Smoke between", "renders between internal sidebar blocks"),
block(input, 650, "Smoke below", "renders below internal sidebar blocks"),
@@ -848,7 +878,7 @@ const tui: TuiPlugin = async (api, options, meta) => {
])
reg(api, value, keys)
for (const item of slot(value)) {
for (const item of slot(api, value)) {
api.slots.register(item)
}
}

252
AGENTS.md
View File

@@ -1,162 +1,128 @@
# 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
- Start with this file for repo-wide defaults.
- Then check package-local `AGENTS.md` files for stricter rules.
- Existing local guides include `packages/opencode/AGENTS.md` and `packages/app/AGENTS.md`.
- Package-specific guides override this file when they conflict.
## Repo Facts
- Package manager: `bun` (`bun@1.3.11`).
- Monorepo tool: `turbo`.
- Default branch: `dev`.
- Root test script intentionally fails; do not run tests from root.
## Cursor / Copilot Rules
- No `.cursor/rules/` directory found.
- No `.cursorrules` file found.
- No `.github/copilot-instructions.md` file found.
- If these files are added later, treat them as mandatory project policy.
## High-Value Commands
Run commands from the correct package directory unless noted.
### Root
- Install deps: `bun install`
- Run all typechecks via turbo: `bun run typecheck`
- OpenCode dev CLI entry: `bun run dev`
- OpenCode serve (common): `bun run dev serve --hostname 0.0.0.0 --port 4096`
### `packages/opencode`
- Dev CLI: `bun run dev`
- Typecheck: `bun run typecheck`
- Tests (all): `bun test --timeout 30000`
- Tests (single file): `bun test test/path/to/file.test.ts --timeout 30000`
- Tests (single test name): `bun test test/path/to/file.test.ts -t "name fragment" --timeout 30000`
- Build: `bun run build`
- Drizzle helper: `bun run db`
### `packages/app`
- Dev server: `bun dev`
- Build: `bun run build`
- Typecheck: `bun run typecheck`
- Unit tests (all): `bun run test:unit`
- Unit tests (single file): `bun test --preload ./happydom.ts ./src/path/to/file.test.ts`
- Unit tests (single test name): `bun test --preload ./happydom.ts ./src/path/to/file.test.ts -t "name fragment"`
- E2E tests: `bun run test:e2e`
### `packages/mobile-voice`
- Start Expo: `bun run start`
- Start Expo dev client: `bunx expo start --dev-client --clear --host lan`
- iOS native run: `bun run ios`
- Android native run: `bun run android`
- Lint: `bun run lint`
- Expo doctor: `bunx expo-doctor`
- Dependency compatibility check: `bunx expo install --check`
### `packages/apn-relay`
- Start relay: `bun run dev`
- Typecheck: `bun run typecheck`
- DB connectivity check: `bun run db:check`
## Build / Lint / Test Expectations
- Always run the narrowest checks that prove your change.
- For backend changes: run package typecheck + relevant tests.
- For mobile changes: run `expo lint` and at least one `expo` compile-style command if possible.
- Never claim tests passed unless you ran them in this workspace.
## Single-Test Guidance
- Prefer running one file first, then broaden scope.
- For Bun tests, pass the file path directly.
- For name filtering, use `-t "..."`.
- Keep original timeouts when scripts define them.
## Code Style Guidelines
These conventions are already used heavily in this repo and should be preserved.
### Formatting
- Use Prettier defaults configured in root: `semi: false`, `printWidth: 120`.
- Keep imports grouped and stable; avoid noisy reorder-only edits.
- Avoid unrelated formatting churn in touched files.
### Imports
- Prefer explicit imports over dynamic imports unless runtime gating is required.
- Prefer existing alias patterns (for example `@/...`) where already configured.
- Do not introduce new dependency layers when a local util already exists.
### Types
- Avoid `any`.
- Prefer inference for local variables.
- Add explicit annotations for exported APIs and complex boundaries.
- Prefer `zod` schemas for request/response validation and parsing.
- Keep things in one function unless composable or reusable
- Avoid `try`/`catch` where possible
- Avoid using the `any` type
- Prefer single word variable names where possible
- Use Bun APIs when possible, like `Bun.file()`
- Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity
- Prefer functional array methods (flatMap, filter, map) over for loops; use type guards on filter to maintain type inference downstream
### Naming
- Follow existing repo preference for short, clear names.
- Use single-word names when readable; use multi-word only for clarity.
- Keep naming consistent with nearby code.
Prefer single word names for variables and functions. Only use multiple words if necessary.
### Naming Enforcement (Read This)
THIS RULE IS MANDATORY FOR AGENT WRITTEN CODE.
- Use single word names by default for new locals, params, and helper functions.
- Multi-word names are allowed only when a single word would be unclear or ambiguous.
- Do not introduce new camelCase compounds when a short single-word alternative is clear.
- Before finishing edits, review touched lines and shorten newly introduced identifiers where possible.
- Good short names to prefer: `pid`, `cfg`, `err`, `opts`, `dir`, `root`, `child`, `state`, `timeout`.
- Examples to avoid unless truly required: `inputPID`, `existingClient`, `connectTimeout`, `workerPath`.
```ts
// Good
const foo = 1
function journal(dir: string) {}
// Bad
const fooBar = 1
function prepareJournal(dir: string) {}
```
Reduce total variable count by inlining when a value is only used once.
```ts
// Good
const journal = await Bun.file(path.join(dir, "journal.json")).json()
// Bad
const journalPath = path.join(dir, "journal.json")
const journal = await Bun.file(journalPath).json()
```
### Destructuring
Avoid unnecessary destructuring. Use dot notation to preserve context.
```ts
// Good
obj.a
obj.b
// Bad
const { a, b } = obj
```
### Variables
Prefer `const` over `let`. Use ternaries or early returns instead of reassignment.
```ts
// Good
const foo = condition ? 1 : 2
// Bad
let foo
if (condition) foo = 1
else foo = 2
```
### 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.

1417
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-5VHEo9GCBP+MeLMoWqSuJLAX/qwGLdFjZe20yatgogM=",
"aarch64-linux": "sha256-hn+V2UpoCj1ddKcq1ySGOMRVvsd3T8sqgpd6nHYfUoA=",
"aarch64-darwin": "sha256-Qctkv6AHDrl+qdB1L+DeqLREeWm6BQqtVCK4tibIMCc=",
"x86_64-darwin": "sha256-YHHnow2dqtKOsjQvbyKk6HQmTo8cAv8frgOfD5aS3h8="
"x86_64-linux": "sha256-ppK5TVMsmy/7uP1kc6hw3gHMxokD/hBZYt5IGHR3/ok=",
"aarch64-linux": "sha256-lrZjanBS8iHJa5TJJHQ9Gaz+lUqNaTgAUuDd6QHu8No=",
"aarch64-darwin": "sha256-EojkRZQF5NqKPF3Bd/8UIiNngpkBk7uAM8m875bfOUo=",
"x86_64-darwin": "sha256-fEO9Hx8yikkvdGj8nC06fy4u/hTGWO6kjENsU/B2OyY="
}
}

View File

@@ -20,7 +20,7 @@ let
in
stdenvNoCC.mkDerivation {
pname = "opencode-node_modules";
version = "${packageJson.version}-${rev}";
version = "${packageJson.version}+${lib.replaceString "-" "." rev}";
src = lib.fileset.toSource {
root = ../.;
@@ -54,6 +54,7 @@ stdenvNoCC.mkDerivation {
--filter '!./' \
--filter './packages/opencode' \
--filter './packages/desktop' \
--filter './packages/app' \
--frozen-lockfile \
--ignore-scripts \
--no-progress

View File

@@ -3,6 +3,7 @@
stdenvNoCC,
callPackage,
bun,
nodejs,
sysctl,
makeBinaryWrapper,
models-dev,
@@ -19,6 +20,7 @@ stdenvNoCC.mkDerivation (finalAttrs: {
nativeBuildInputs = [
bun
nodejs # for patchShebangs node_modules
installShellFiles
makeBinaryWrapper
models-dev
@@ -29,6 +31,8 @@ stdenvNoCC.mkDerivation (finalAttrs: {
runHook preConfigure
cp -R ${finalAttrs.node_modules}/. .
patchShebangs node_modules
patchShebangs packages/*/node_modules
runHook postConfigure
'';

View File

@@ -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

View File

@@ -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.

View File

@@ -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"]

View File

@@ -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
```

View File

@@ -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,
},
},
})

View File

@@ -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"
}
}

View File

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

View File

@@ -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)
})

View File

@@ -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 })

View File

@@ -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

View File

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

View File

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

View File

@@ -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),
],
)

View File

@@ -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;
`)
}

View File

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

View File

@@ -331,8 +331,7 @@ export const dict = {
"go.faq.a4.p3": "ألغِ في أي وقت.",
"go.faq.q5": "ماذا عن البيانات والخصوصية؟",
"go.faq.a5.body":
"تم تصميم الخطة بشكل أساسي للمستخدمين الدوليين، مع استضافة النماذج في الولايات المتحدة والاتحاد الأوروبي وسنغافورة للحصول على وصول عالمي مستقر.",
"go.faq.a5.contactAfter": "إذا كان لديك أي أسئلة.",
"تم تصميم الخطة بشكل أساسي للمستخدمين الدوليين، مع استضافة النماذج في الولايات المتحدة والاتحاد الأوروبي وسنغافورة للحصول على وصول عالمي مستقر. يتبع مزودونا سياسة عدم الاحتفاظ بالبيانات ولا يستخدمون بياناتك لتدريب النماذج.",
"go.faq.a5.beforeExceptions":
"تتم استضافة نماذج Go في الولايات المتحدة. يتبع المزودون سياسة عدم الاحتفاظ بالبيانات ولا يستخدمون بياناتك لتدريب النماذج، مع",
"go.faq.a5.exceptionsLink": "الاستثناءات التالية",

View File

@@ -338,8 +338,7 @@ export const dict = {
"go.faq.a4.p3": "Cancele a qualquer momento.",
"go.faq.q5": "E sobre dados e privacidade?",
"go.faq.a5.body":
"O plano é projetado principalmente para usuários internacionais, com modelos hospedados nos EUA, UE e Singapura para acesso global estável.",
"go.faq.a5.contactAfter": "se você tiver alguma dúvida.",
"O plano é projetado principalmente para usuários internacionais, com modelos hospedados nos EUA, UE e Singapura para acesso global estável. Nossos provedores seguem uma política de retenção zero e não usam seus dados para treinamento de modelos.",
"go.faq.a5.beforeExceptions":
"Os modelos Go são hospedados nos EUA. Os provedores seguem uma política de retenção zero e não usam seus dados para treinamento de modelos, com as",
"go.faq.a5.exceptionsLink": "seguintes exceções",

View File

@@ -336,8 +336,7 @@ export const dict = {
"go.faq.a4.p3": "Annuller til enhver tid.",
"go.faq.q5": "Hvad med data og privatliv?",
"go.faq.a5.body":
"Planen er primært designet til internationale brugere, med modeller hostet i USA, EU og Singapore for stabil global adgang.",
"go.faq.a5.contactAfter": "hvis du har spørgsmål.",
"Planen er primært designet til internationale brugere, med modeller hostet i USA, EU og Singapore for stabil global adgang. Vores udbydere følger en nulopbevaringspolitik og bruger ikke dine data til modeltræning.",
"go.faq.a5.beforeExceptions":
"Go-modeller hostes i USA. Udbydere følger en nulopbevaringspolitik og bruger ikke dine data til modeltræning, med de",
"go.faq.a5.exceptionsLink": "følgende undtagelser",

View File

@@ -338,8 +338,7 @@ export const dict = {
"go.faq.a4.p3": "Jederzeit kündbar.",
"go.faq.q5": "Was ist mit Daten und Privatsphäre?",
"go.faq.a5.body":
"Der Plan ist primär für internationale Nutzer konzipiert, mit Modellen gehostet in den USA, der EU und Singapur für stabilen globalen Zugang.",
"go.faq.a5.contactAfter": "wenn du Fragen hast.",
"Der Plan ist primär für internationale Nutzer konzipiert, mit Modellen gehostet in den USA, der EU und Singapur für stabilen globalen Zugang. Unsere Anbieter verfolgen eine Zero-Retention-Politik und nutzen deine Daten nicht für das Training von Modellen.",
"go.faq.a5.beforeExceptions":
"Go-Modelle werden in den USA gehostet. Anbieter verfolgen eine Zero-Retention-Politik und nutzen deine Daten nicht für das Training von Modellen, mit den",
"go.faq.a5.exceptionsLink": "folgenden Ausnahmen",

View File

@@ -331,8 +331,8 @@ export const dict = {
"go.faq.a4.p3": "Cancel any time.",
"go.faq.q5": "What about data and privacy?",
"go.faq.a5.body":
"The plan is designed primarily for international users, with models hosted in the US, EU, and Singapore for stable global access.",
"go.faq.a5.contactAfter": "if you have any questions.",
"The plan is designed primarily for international users, with models hosted in the US, EU, and Singapore for stable global access. Our providers follow a zero-retention policy and do not use your data for model training.",
"go.faq.a5.beforeExceptions":
"Go models are hosted in the US. Providers follow a zero-retention policy and do not use your data for model training, with the",
"go.faq.a5.exceptionsLink": "following exceptions",

View File

@@ -338,8 +338,7 @@ export const dict = {
"go.faq.a4.p3": "Cancela en cualquier momento.",
"go.faq.q5": "¿Qué pasa con los datos y la privacidad?",
"go.faq.a5.body":
"El plan está diseñado principalmente para usuarios internacionales, con modelos alojados en EE. UU., UE y Singapur para un acceso global estable.",
"go.faq.a5.contactAfter": "si tienes alguna pregunta.",
"El plan está diseñado principalmente para usuarios internacionales, con modelos alojados en EE. UU., UE y Singapur para un acceso global estable. Nuestros proveedores siguen una política de retención cero y no utilizan tus datos para el entrenamiento de modelos.",
"go.faq.a5.beforeExceptions":
"Los modelos de Go están alojados en EE. UU. Los proveedores siguen una política de retención cero y no utilizan tus datos para el entrenamiento de modelos, con las",
"go.faq.a5.exceptionsLink": "siguientes excepciones",

View File

@@ -340,8 +340,7 @@ export const dict = {
"go.faq.a4.p3": "Annulez à tout moment.",
"go.faq.q5": "Et pour les données et la confidentialité ?",
"go.faq.a5.body":
"Le plan est conçu principalement pour les utilisateurs internationaux, avec des modèles hébergés aux États-Unis, dans l'UE et à Singapour pour un accès mondial stable.",
"go.faq.a5.contactAfter": "si vous avez des questions.",
"Le plan est conçu principalement pour les utilisateurs internationaux, avec des modèles hébergés aux États-Unis, dans l'UE et à Singapour pour un accès mondial stable. Nos fournisseurs suivent une politique de rétention zéro et n'utilisent pas vos données pour l'entraînement des modèles.",
"go.faq.a5.beforeExceptions":
"Les modèles Go sont hébergés aux États-Unis. Les fournisseurs suivent une politique de rétention zéro et n'utilisent pas vos données pour l'entraînement des modèles, avec les",
"go.faq.a5.exceptionsLink": "exceptions suivantes",

View File

@@ -334,8 +334,7 @@ export const dict = {
"go.faq.a4.p3": "Annulla in qualsiasi momento.",
"go.faq.q5": "E per quanto riguarda dati e privacy?",
"go.faq.a5.body":
"Il piano è progettato principalmente per gli utenti internazionali, con modelli ospitati negli Stati Uniti, UE e Singapore per un accesso globale stabile.",
"go.faq.a5.contactAfter": "se hai domande.",
"Il piano è progettato principalmente per gli utenti internazionali, con modelli ospitati negli Stati Uniti, UE e Singapore per un accesso globale stabile. I nostri provider seguono una policy di zero-retention e non usano i tuoi dati per l'addestramento dei modelli.",
"go.faq.a5.beforeExceptions":
"I modelli Go sono ospitati negli Stati Uniti. I provider seguono una policy di zero-retention e non usano i tuoi dati per l'addestramento dei modelli, con le",
"go.faq.a5.exceptionsLink": "seguenti eccezioni",

View File

@@ -335,8 +335,7 @@ export const dict = {
"go.faq.a4.p3": "いつでもキャンセル可能です。",
"go.faq.q5": "データとプライバシーは?",
"go.faq.a5.body":
"このプランは主に海外ユーザー向けに設計されており、米国、EU、シンガポールでホストされたモデルにより安定したグローバルアクセスを提供します。",
"go.faq.a5.contactAfter": "ご質問がございましたら。",
"このプランは主に海外ユーザー向けに設計されており、米国、EU、シンガポールでホストされたモデルにより安定したグローバルアクセスを提供します。プロバイダーはゼロ保持ポリシーに従い、お客様のデータをモデルのトレーニングに使用しません。",
"go.faq.a5.beforeExceptions":
"Goのモデルは米国でホストされています。プロバイダーはゼロ保持ポリシーに従い、モデルのトレーニングにデータを使用しません",
"go.faq.a5.exceptionsLink": "以下の例外",

View File

@@ -331,8 +331,7 @@ export const dict = {
"go.faq.a4.p3": "언제든지 취소할 수 있습니다.",
"go.faq.q5": "데이터와 프라이버시는 어떤가요?",
"go.faq.a5.body":
"이 플랜은 주로 글로벌 사용자를 위해 설계되었으며, 안정적인 글로벌 액세스를 위해 미국, EU, 싱가포르에 모델이 호스팅되어 있습니다.",
"go.faq.a5.contactAfter": "질문이 있으시면 언제든지 문의해 주세요.",
"이 플랜은 주로 글로벌 사용자를 위해 설계되었으며, 안정적인 글로벌 액세스를 위해 미국, EU, 싱가포르에 모델이 호스팅되어 있습니다. 당사의 제공자들은 데이터 보존 금지 정책을 따르며 모델 학습에 데이터를 사용하지 않습니다.",
"go.faq.a5.beforeExceptions":
"Go 모델은 미국에서 호스팅됩니다. 제공자들은 데이터 보존 금지 정책을 따르며 모델 학습에 데이터를 사용하지 않습니다. 단,",
"go.faq.a5.exceptionsLink": "다음 예외",

View File

@@ -335,8 +335,7 @@ export const dict = {
"go.faq.a4.p3": "Avslutt når som helst.",
"go.faq.q5": "Hva med data og personvern?",
"go.faq.a5.body":
"Planen er primært designet for internasjonale brukere, med modeller driftet i USA, EU og Singapore for stabil global tilgang.",
"go.faq.a5.contactAfter": "hvis du har spørsmål.",
"Planen er primært designet for internasjonale brukere, med modeller driftet i USA, EU og Singapore for stabil global tilgang. Våre leverandører følger en policy om null oppbevaring og bruker ikke dataene dine til modelltrening.",
"go.faq.a5.beforeExceptions":
"Go-modeller hostes i USA. Leverandører følger en policy om null oppbevaring og bruker ikke dataene dine til modelltrening, med",
"go.faq.a5.exceptionsLink": "følgende unntak",

View File

@@ -336,8 +336,7 @@ export const dict = {
"go.faq.a4.p3": "Anuluj w dowolnym momencie.",
"go.faq.q5": "A co z danymi i prywatnością?",
"go.faq.a5.body":
"Plan został zaprojektowany głównie dla użytkowników międzynarodowych, z modelami hostowanymi w USA, UE i Singapurze, aby zapewnić stabilny globalny dostęp.",
"go.faq.a5.contactAfter": "jeśli masz jakiekolwiek pytania.",
"Plan został zaprojektowany głównie dla użytkowników międzynarodowych, z modelami hostowanymi w USA, UE i Singapurze, aby zapewnić stabilny globalny dostęp. Nasi dostawcy stosują politykę zerowej retencji i nie używają Twoich danych do trenowania modeli.",
"go.faq.a5.beforeExceptions":
"Modele Go są hostowane w USA. Dostawcy stosują politykę zerowej retencji i nie używają Twoich danych do trenowania modeli, z",
"go.faq.a5.exceptionsLink": "następującymi wyjątkami",

View File

@@ -340,8 +340,7 @@ export const dict = {
"go.faq.a4.p3": "Отмена в любое время.",
"go.faq.q5": "Как насчет данных и приватности?",
"go.faq.a5.body":
"План разработан в первую очередь для международных пользователей, с моделями, размещенными в США, ЕС и Сингапуре для стабильного глобального доступа.",
"go.faq.a5.contactAfter": "если у вас есть вопросы.",
"План разработан в первую очередь для международных пользователей, с моделями, размещенными в США, ЕС и Сингапуре для стабильного глобального доступа. Наши провайдеры следуют политике нулевого хранения и не используют ваши данные для обучения моделей.",
"go.faq.a5.beforeExceptions":
"Модели Go размещены в США. Провайдеры следуют политике нулевого хранения и не используют ваши данные для обучения моделей, за",
"go.faq.a5.exceptionsLink": "следующими исключениями",

View File

@@ -333,8 +333,7 @@ export const dict = {
"go.faq.a4.p3": "ยกเลิกได้ตลอดเวลา",
"go.faq.q5": "แล้วเรื่องข้อมูลและความเป็นส่วนตัวล่ะ?",
"go.faq.a5.body":
"แผนนี้ออกแบบมาเพื่อผู้ใช้งานระหว่างประเทศเป็นหลัก โดยมีโมเดลโฮสต์ในสหรัฐอเมริกา สหภาพยุโรป และสิงคโปร์ เพื่อการเข้าถึงทั่วโลกที่เสถียร",
"go.faq.a5.contactAfter": "หากคุณมีคำถามใดๆ",
"แผนนี้ออกแบบมาเพื่อผู้ใช้งานระหว่างประเทศเป็นหลัก โดยมีโมเดลโฮสต์ในสหรัฐอเมริกา สหภาพยุโรป และสิงคโปร์ เพื่อการเข้าถึงทั่วโลกที่เสถียร ผู้ให้บริการของเราปฏิบัติตามนโยบายไม่เก็บรักษาข้อมูลและไม่ใช้ข้อมูลของคุณสำหรับการฝึกโมเดล",
"go.faq.a5.beforeExceptions":
"โมเดล Go โฮสต์ในสหรัฐอเมริกา ผู้ให้บริการปฏิบัติตามนโยบายไม่เก็บรักษาข้อมูล (zero-retention policy) และไม่ใช้ข้อมูลของคุณสำหรับการฝึกโมเดล โดยมี",
"go.faq.a5.exceptionsLink": "ข้อยกเว้นดังนี้",

View File

@@ -339,8 +339,7 @@ export const dict = {
"go.faq.a4.p3": "yönetebilirsiniz. İstediğiniz zaman iptal edin.",
"go.faq.q5": "Veri ve gizlilik ne olacak?",
"go.faq.a5.body":
"Bu plan öncelikle uluslararası kullanıcılar için tasarlanmış olup, istikrarlı küresel erişim için modeller ABD, AB ve Singapur'da barındırılmaktadır.",
"go.faq.a5.contactAfter": "herhangi bir sorunuz varsa.",
"Bu plan öncelikle uluslararası kullanıcılar için tasarlanmış olup, istikrarlı küresel erişim için modeller ABD, AB ve Singapur'da barındırılmaktadır. Sağlayıcılarımız sıfır saklama politikası izler ve verilerinizi model eğitimi için kullanmaz.",
"go.faq.a5.beforeExceptions":
"Go modelleri ABD'de barındırılmaktadır. Sağlayıcılar sıfır saklama politikası izler ve verilerinizi model eğitimi için kullanmaz; şu",
"go.faq.a5.exceptionsLink": "aşağıdaki istisnalar",

View File

@@ -319,8 +319,8 @@ export const dict = {
"go.faq.a4.p2.accountLink": "账户",
"go.faq.a4.p3": "中管理订阅。随时取消。",
"go.faq.q5": "数据和隐私如何?",
"go.faq.a5.body": "该计划主要面向国际用户设计,模型部署在美国、欧盟和新加坡,以确保稳定的全球访问。",
"go.faq.a5.contactAfter": "如果您有任何问题。",
"go.faq.a5.body":
"该计划主要面向国际用户设计,模型部署在美国、欧盟和新加坡,以确保稳定的全球访问。我们的提供商遵循零留存政策,不使用您的数据进行模型训练。",
"go.faq.a5.beforeExceptions": "Go 模型托管在美国。提供商遵循零留存政策,不使用您的数据进行模型训练,",
"go.faq.a5.exceptionsLink": "以下例外情况除外",
"go.faq.q6": "我可以充值余额吗?",

View File

@@ -319,8 +319,8 @@ export const dict = {
"go.faq.a4.p2.accountLink": "帳戶",
"go.faq.a4.p3": "中管理訂閱。隨時取消。",
"go.faq.q5": "資料與隱私怎麼辦?",
"go.faq.a5.body": "該方案主要面向國際用戶設計,模型託管在美國、歐盟和新加坡,以確保全球穩定存取。",
"go.faq.a5.contactAfter": "如果你有任何問題。",
"go.faq.a5.body":
"該方案主要面向國際用戶設計,模型託管在美國、歐盟和新加坡,以確保全球穩定存取。我們的供應商遵循零留存政策,不會將你的資料用於模型訓練。",
"go.faq.a5.beforeExceptions": "Go 模型託管在美國。供應商遵循零留存政策,不會將你的資料用於模型訓練,但有",
"go.faq.a5.exceptionsLink": "以下例外",
"go.faq.q6": "我可以儲值額度嗎?",

View File

@@ -433,8 +433,7 @@ export default function Home() {
</li>
<li>
<Faq question={i18n.t("go.faq.q5")}>
{i18n.t("go.faq.a5.body")} <a href="mailto:contact@anoma.ly">{i18n.t("common.contactUs")}</a>{" "}
{i18n.t("go.faq.a5.contactAfter")}
{i18n.t("go.faq.a5.body")} <a href="mailto:contact@anoma.ly">{i18n.t("common.contactUs")}</a>
</Faq>
</li>
<li>

View File

@@ -0,0 +1,39 @@
import { Billing } from "../src/billing.js"
import { Database, eq } from "../src/drizzle/index.js"
import { BillingTable } from "../src/schema/billing.sql.js"
import { WorkspaceTable } from "../src/schema/workspace.sql.js"
import { microCentsToCents } from "../src/util/price.js"
// get input from command line
const workspaceID = process.argv[2]
if (!workspaceID) {
console.error("Usage: bun freeze-workspace.ts <workspaceID>")
process.exit(1)
}
// check workspace exists
const workspace = await Database.use((tx) =>
tx
.select()
.from(WorkspaceTable)
.where(eq(WorkspaceTable.id, workspaceID))
.then((rows) => rows[0]),
)
if (!workspace) {
console.error("Error: Workspace not found")
process.exit(1)
}
const billing = await Database.use((tx) =>
tx
.select()
.from(BillingTable)
.where(eq(BillingTable.workspaceID, workspaceID))
.then((rows) => rows[0]),
)
const amountInDollars = microCentsToCents(billing.balance) / 100
await Billing.grantCredit(workspaceID, 0 - amountInDollars)
console.log(`Removed payment of $${amountInDollars.toFixed(2)} from workspace ${workspaceID}`)

View File

@@ -18,8 +18,9 @@ import { ModelTable } from "../src/schema/model.sql.js"
// get input from command line
const identifier = process.argv[2]
const verbose = process.argv[process.argv.length - 1] === "-v"
if (!identifier) {
console.error("Usage: bun lookup-user.ts <email|workspaceID|apiKey>")
console.error("Usage: bun lookup-user.ts <email|workspaceID|apiKey> [-v]")
process.exit(1)
}
@@ -223,93 +224,68 @@ async function printWorkspace(workspaceID: string) {
),
)
await printTable("28-Day Usage", (tx) =>
tx
.select({
date: sql<string>`DATE(${UsageTable.timeCreated})`.as("date"),
requests: sql<number>`COUNT(*)`.as("requests"),
inputTokens: sql<number>`SUM(${UsageTable.inputTokens})`.as("input_tokens"),
outputTokens: sql<number>`SUM(${UsageTable.outputTokens})`.as("output_tokens"),
reasoningTokens: sql<number>`SUM(${UsageTable.reasoningTokens})`.as("reasoning_tokens"),
cacheReadTokens: sql<number>`SUM(${UsageTable.cacheReadTokens})`.as("cache_read_tokens"),
cacheWrite5mTokens: sql<number>`SUM(${UsageTable.cacheWrite5mTokens})`.as("cache_write_5m_tokens"),
cacheWrite1hTokens: sql<number>`SUM(${UsageTable.cacheWrite1hTokens})`.as("cache_write_1h_tokens"),
cost: sql<number>`SUM(${UsageTable.cost})`.as("cost"),
})
.from(UsageTable)
.where(
and(
eq(UsageTable.workspaceID, workspace.id),
sql`${UsageTable.timeCreated} >= DATE_SUB(NOW(), INTERVAL 28 DAY)`,
if (verbose) {
await printTable("28-Day Usage", (tx) =>
tx
.select({
date: sql<string>`DATE(${UsageTable.timeCreated})`.as("date"),
requests: sql<number>`COUNT(*)`.as("requests"),
inputTokens: sql<number>`SUM(${UsageTable.inputTokens})`.as("input_tokens"),
outputTokens: sql<number>`SUM(${UsageTable.outputTokens})`.as("output_tokens"),
reasoningTokens: sql<number>`SUM(${UsageTable.reasoningTokens})`.as("reasoning_tokens"),
cacheReadTokens: sql<number>`SUM(${UsageTable.cacheReadTokens})`.as("cache_read_tokens"),
cacheWrite5mTokens: sql<number>`SUM(${UsageTable.cacheWrite5mTokens})`.as("cache_write_5m_tokens"),
cacheWrite1hTokens: sql<number>`SUM(${UsageTable.cacheWrite1hTokens})`.as("cache_write_1h_tokens"),
cost: sql<number>`SUM(${UsageTable.cost})`.as("cost"),
})
.from(UsageTable)
.where(
and(
eq(UsageTable.workspaceID, workspace.id),
sql`${UsageTable.timeCreated} >= DATE_SUB(NOW(), INTERVAL 28 DAY)`,
),
)
.groupBy(sql`DATE(${UsageTable.timeCreated})`)
.orderBy(sql`DATE(${UsageTable.timeCreated}) DESC`)
.then((rows) => {
const totalCost = rows.reduce((sum, r) => sum + Number(r.cost), 0)
const mapped = rows.map((row) => ({
...row,
cost: `$${(Number(row.cost) / 100000000).toFixed(2)}`,
}))
if (mapped.length > 0) {
mapped.push({
date: "TOTAL",
requests: null as any,
inputTokens: null as any,
outputTokens: null as any,
reasoningTokens: null as any,
cacheReadTokens: null as any,
cacheWrite5mTokens: null as any,
cacheWrite1hTokens: null as any,
cost: `$${(totalCost / 100000000).toFixed(2)}`,
})
}
return mapped
}),
)
await printTable("Disabled Models", (tx) =>
tx
.select({
model: ModelTable.model,
timeCreated: ModelTable.timeCreated,
})
.from(ModelTable)
.where(eq(ModelTable.workspaceID, workspace.id))
.orderBy(sql`${ModelTable.timeCreated} DESC`)
.then((rows) =>
rows.map((row) => ({
model: row.model,
timeCreated: formatDate(row.timeCreated),
})),
),
)
.groupBy(sql`DATE(${UsageTable.timeCreated})`)
.orderBy(sql`DATE(${UsageTable.timeCreated}) DESC`)
.then((rows) => {
const totalCost = rows.reduce((sum, r) => sum + Number(r.cost), 0)
const mapped = rows.map((row) => ({
...row,
cost: `$${(Number(row.cost) / 100000000).toFixed(2)}`,
}))
if (mapped.length > 0) {
mapped.push({
date: "TOTAL",
requests: null as any,
inputTokens: null as any,
outputTokens: null as any,
reasoningTokens: null as any,
cacheReadTokens: null as any,
cacheWrite5mTokens: null as any,
cacheWrite1hTokens: null as any,
cost: `$${(totalCost / 100000000).toFixed(2)}`,
})
}
return mapped
}),
)
/*
await printTable("Usage", (tx) =>
tx
.select({
model: UsageTable.model,
provider: UsageTable.provider,
inputTokens: UsageTable.inputTokens,
outputTokens: UsageTable.outputTokens,
reasoningTokens: UsageTable.reasoningTokens,
cacheReadTokens: UsageTable.cacheReadTokens,
cacheWrite5mTokens: UsageTable.cacheWrite5mTokens,
cacheWrite1hTokens: UsageTable.cacheWrite1hTokens,
cost: UsageTable.cost,
timeCreated: UsageTable.timeCreated,
})
.from(UsageTable)
.where(eq(UsageTable.workspaceID, workspace.id))
.orderBy(sql`${UsageTable.timeCreated} DESC`)
.limit(10)
.then((rows) =>
rows.map((row) => ({
...row,
cost: `$${(row.cost / 100000000).toFixed(2)}`,
})),
),
)
await printTable("Disabled Models", (tx) =>
tx
.select({
model: ModelTable.model,
timeCreated: ModelTable.timeCreated,
})
.from(ModelTable)
.where(eq(ModelTable.workspaceID, workspace.id))
.orderBy(sql`${ModelTable.timeCreated} DESC`)
.then((rows) =>
rows.map((row) => ({
model: row.model,
timeCreated: formatDate(row.timeCreated),
})),
),
)
*/
)
}
}
function formatMicroCents(value: number | null | undefined) {

View File

@@ -1,3 +1,7 @@
export function centsToMicroCents(amount: number) {
return Math.round(amount * 1000000)
}
export function microCentsToCents(amount: number) {
return Math.round(amount / 1000000)
}

View File

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

View File

@@ -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

View File

@@ -1,103 +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 modules (`react-native-executorch`, `react-native-audio-api`, `expo-notifications`, `expo-camera`).
- Development builds are required for native module changes.
## Commands
Run all commands from `packages/mobile-voice`.
- Install deps: `bun install`
- Start Metro: `bun run start`
- Start dev client server (recommended): `bunx expo start --dev-client --clear --host lan`
- iOS run: `bun run ios`
- Android run: `bun run android`
- Lint: `bun run lint`
- Expo doctor: `bunx expo-doctor`
- Dependency compatibility check: `bunx expo install --check`
- Export bundle smoke test: `bunx expo export --platform ios --clear`
## Build / Verification Expectations
- For JS-only changes: run `bun run lint` and verify app behavior via dev client.
- For native dependency/config/plugin changes: rebuild dev client via EAS before validation.
- If notifications/camera/audio behavior changes, verify on a physical iOS device.
- Do not claim a fix unless you validated in Metro logs and app runtime behavior.
## Single-Test Guidance
- This package currently has no dedicated unit test script.
- Use targeted validation commands instead:
- `bun run lint`
- `bunx expo export --platform ios --clear`
- manual runtime test in dev client
## Code Style And Patterns
### Formatting / Structure
- Preserve existing style (`semi: false`, concise JSX, stable import grouping).
- Keep UI changes localized; avoid large architectural rewrites.
- Avoid unrelated formatting churn.
### Types
- Avoid `any`; prefer local type aliases for component state and network payloads.
- Keep exported/shared boundaries typed explicitly.
- Use discriminated unions for UI modes/status where practical.
### Naming
- Prefer short, readable names consistent with nearby code.
- Keep naming aligned with existing app state keys (`serverDraftURL`, `monitorStatus`, etc.).
### Error Handling / Logging
- Fail gracefully in UI (alerts, disabled actions, fallback text).
- Log actionable diagnostics for runtime workflows:
- server health checks
- relay registration attempts
- notification token lifecycle
- Never log secrets or full APNs tokens.
### Network / Relay Integration
- Normalize and validate URLs before storing server configs.
- Keep relay registration idempotent.
- Guard duplicate scan/add flows to avoid repeated server entries.
### Notifications / APNs
- Distinguish sandbox vs production token environments correctly.
- On registration changes, ensure old token unregister flow remains intact.
- Treat permission failures as non-fatal and degrade to foreground monitoring when needed.
## Native-Module Safety
- If adding a native module, ensure it is in `package.json` with SDK-compatible version.
- Rebuild dev client after native module additions/changes.
- For optional native capability usage, prefer runtime fallback paths instead of hard crashes.
## Common Pitfalls
- Black screen + "No script URL provided" often means stale dev client binary.
- `expo-doctor` duplicate module warnings may appear in Bun workspaces; prioritize runtime verification.
- `expo lint` may auto-generate `eslint.config.js`; do not commit accidental generated config unless requested.
## Before Finishing
- Run `bun run lint`.
- If behavior could break startup, run `bunx expo export --platform ios --clear`.
- Confirm no accidental config side effects were introduced.
- Summarize what was verified on-device vs only in tooling.

View File

@@ -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.

View File

@@ -1,84 +0,0 @@
{
"expo": {
"name": "Control",
"slug": "mobile-voice",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "mobilevoice",
"userInterfaceStyle": "automatic",
"ios": {
"icon": "./assets/images/icon.png",
"bundleIdentifier": "com.anomalyco.mobilevoice",
"entitlements": {
"com.apple.developer.kernel.extended-virtual-addressing": true
},
"infoPlist": {
"NSMicrophoneUsageDescription": "This app needs microphone access for live speech-to-text dictation.",
"NSAppTransportSecurity": {
"NSAllowsLocalNetworking": true,
"NSExceptionDomains": {
"ts.net": {
"NSIncludesSubdomains": true,
"NSExceptionAllowsInsecureHTTPLoads": true
}
}
},
"ITSAppUsesNonExemptEncryption": false
}
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
},
"permissions": [
"RECORD_AUDIO",
"POST_NOTIFICATIONS",
"android.permission.FOREGROUND_SERVICE",
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
"android.permission.RECORD_AUDIO",
"android.permission.MODIFY_AUDIO_SETTINGS"
],
"predictiveBackGestureEnabled": false
},
"web": {
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"plugins": [
"expo-router",
[
"expo-splash-screen",
{
"backgroundColor": "#121212",
"android": {
"image": "./assets/images/splash-icon.png",
"imageWidth": 76
}
}
],
"react-native-audio-api",
"expo-asset",
"expo-audio",
[
"expo-notifications",
{
"enableBackgroundRemoteNotifications": true
}
]
],
"experiments": {
"typedRoutes": true,
"reactCompiler": true
},
"extra": {
"router": {},
"eas": {
"projectId": "89248f34-51fc-49e9-acb3-728497520c5a"
}
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

View File

@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -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"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 347 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 468 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 253 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 343 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 479 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -1,7 +0,0 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: ['react-native-reanimated/plugin'],
};
};

View File

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

View File

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

View File

@@ -1 +0,0 @@
- 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.

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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