diff --git a/.agents/skills/locale-ui-patterns/SKILL.md b/.agents/skills/locale-ui-patterns/SKILL.md index ff57255bca..9315356239 100644 --- a/.agents/skills/locale-ui-patterns/SKILL.md +++ b/.agents/skills/locale-ui-patterns/SKILL.md @@ -11,10 +11,16 @@ User-facing UI text must go through `@/lib/i18n`; do not hardcode English string Use this skill for any React UI change that adds or edits visible text, accessible labels, placeholders, tooltips, toasts, dialogs, settings labels, navigation labels, or empty/error states. +## Translate everything immediately (no English placeholders) + +Every key you add to a non-English dictionary MUST contain a real translation in that language — never the English source string as a stand-in. There is NO "leave it in English for now" convention in this project; if an agent told you there was, it was wrong. Copying the English value into `es.ts`/`fr.ts`/`ko.ts`/`pl.ts`/`pt-BR.ts`/`uk.ts`/`zh-CN.ts`/`zh-TW.ts` is a defect, not a deferral. The app ships every locale at once, so an untranslated key is a visible bug for those users. + +If you genuinely cannot translate a language, say so explicitly to the user instead of silently pasting English. Do not invent a fallback policy. + ## Required Flow 1. Add or reuse a key in `packages/ui/src/lib/i18n/messages/en.ts`. -2. Add the same key to every non-English dictionary in `packages/ui/src/lib/i18n/messages/`. +2. Add the same key — fully translated, not the English text — to every non-English dictionary in `packages/ui/src/lib/i18n/messages/`. 3. In components, call `const { t } = useI18n()` from `@/lib/i18n` and render `t('key')`. 4. For locale names or language picker labels, use `label(locale)` from `useI18n()`. 5. Keep locale state in `packages/ui/src/lib/i18n/*`; do not add locale fields to broad stores like `useUIStore`. diff --git a/.agents/skills/serve-sim/SKILL.md b/.agents/skills/serve-sim/SKILL.md new file mode 100644 index 0000000000..cd18d986a1 --- /dev/null +++ b/.agents/skills/serve-sim/SKILL.md @@ -0,0 +1,69 @@ +--- +name: serve-sim +description: Use when working with the OpenChamber iOS Simulator app without opening Xcode - boot/install/launch the Capacitor iOS app, start a browser stream, tap/type/gesture/rotate, inspect accessibility, or hand a simulator URL to the user. +--- + +# serve-sim + +Use `serve-sim` to stream and control a booted Apple Simulator from the terminal. It captures the simulator framebuffer, serves a browser preview, and exposes CLI controls for taps, typing, gestures, hardware buttons, rotation, memory warnings, permissions, camera injection, and accessibility inspection. + +## OpenChamber Defaults + +- Mobile package: `packages/mobile` +- iOS bundle id: `com.openchamber.app` +- Headless env wrapper: `packages/mobile/scripts/with-mobile-env.mjs` +- iOS simulator helper: `packages/mobile/scripts/ios-sim.mjs` +- Preferred scripts: + - `bun run mobile:build:ios:simulator` + - `bun run mobile:sim:run` + - `bun run mobile:sim:serve` + - `bun run mobile:sim:list` + - `bun run mobile:sim:kill` + +## Workflow + +1. Build the simulator app without opening Xcode: + ```sh + bun run mobile:build:ios:simulator + ``` + +2. Boot a simulator if needed, install, and launch the app: + ```sh + bun run mobile:sim:run + ``` + +3. Start the browser stream in detached JSON mode: + ```sh + bun run mobile:sim:serve + ``` + Surface the returned `url` to the user. It normally starts at `http://localhost:3200`. + +4. Stop helpers when finished unless the user asks to keep them running: + ```sh + bun run mobile:sim:kill + ``` + +## Direct CLI Controls + +- Tap normalized coordinates: `bunx serve-sim tap 0.5 0.5` +- Type focused text: `bunx serve-sim type "hello"` +- Hardware home: `bunx serve-sim button home` +- Rotate: `bunx serve-sim rotate portrait` +- List streams: `bunx serve-sim --list -q` +- Accessibility tree: `curl http://localhost:3100/ax` + +Coordinates are normalized `0..1`, not pixels. Prefer `tap` for simple taps; do not emulate taps using separate `gesture` begin/end commands because that can register as long press. + +## Preconditions + +- macOS host. +- Xcode installed; use `DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer` if `xcode-select` points at CommandLineTools. +- Node 18+. +- At least one simulator can be booted with `xcrun simctl`. + +## Anti-Patterns + +- Do not open Xcode just to build/install/launch during agent work; use the scripts above. +- Do not parse human output from `serve-sim`; use `-q` for JSON. +- Do not leave helper streams running unintentionally. +- Do not guess coordinates after accessibility lookup fails; report the missing target instead. diff --git a/.github/workflows/build-macos-arm64-dmg.yml b/.github/workflows/build-macos-arm64-dmg.yml index c819b9c9b7..a96c1da87f 100644 --- a/.github/workflows/build-macos-arm64-dmg.yml +++ b/.github/workflows/build-macos-arm64-dmg.yml @@ -32,11 +32,25 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: "20" + node-version: "22" - name: Install dependencies run: bun install --frozen-lockfile + - name: Get bundled OpenCode CLI version + id: opencode_cli_version + run: | + VERSION=$(node -p "require('./package.json').dependencies['@opencode-ai/sdk']") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Cache bundled OpenCode CLI artifact + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + with: + path: packages/electron/.cache/opencode-cli + key: opencode-cli-${{ runner.os }}-arm64-${{ steps.opencode_cli_version.outputs.version }} + restore-keys: | + opencode-cli-${{ runner.os }}-arm64- + - name: Install Apple Certificate env: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} @@ -68,9 +82,12 @@ jobs: ELECTRON_BUILDER_ARCH: arm64 run: | bun run build:web-assets + bun run prepare:opencode-cli + bun run verify:opencode-cli bun run bundle:main bun run rebuild:native ./node_modules/.bin/electron-builder --mac --arm64 --publish=never + bun run verify:opencode-cli:packaged - name: Prepare DMG artifact run: | diff --git a/.github/workflows/label-merge-conflict.yml b/.github/workflows/label-merge-conflict.yml new file mode 100644 index 0000000000..8d61194e12 --- /dev/null +++ b/.github/workflows/label-merge-conflict.yml @@ -0,0 +1,31 @@ +name: label-merge-conflict + +on: + push: + branches: [main] + pull_request_target: + types: [opened, synchronize, reopened] + workflow_dispatch: + +permissions: {} + +jobs: + label: + if: ${{ github.repository == 'openchamber/openchamber' }} + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - name: Generate bot app token + id: app-token + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2 + with: + app-id: ${{ secrets.OC_REVIEW_APP_ID }} + private-key: ${{ secrets.OC_REVIEW_APP_PRIVATE_KEY }} + + - name: Label pull requests with merge conflicts + uses: eps1lon/actions-label-merge-conflict@0273be72a0bbd58fcd71d0d6c02c209b50d1e5e1 # v3.1.0 + with: + dirtyLabel: "merge-conflict:true" + repoToken: ${{ steps.app-token.outputs.token }} diff --git a/.github/workflows/oc-review.yml b/.github/workflows/oc-review.yml index 65e490948e..655b666595 100644 --- a/.github/workflows/oc-review.yml +++ b/.github/workflows/oc-review.yml @@ -18,7 +18,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '22' - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/release-desktop-smoke.yml b/.github/workflows/release-desktop-smoke.yml index 022292a654..e81407d8ed 100644 --- a/.github/workflows/release-desktop-smoke.yml +++ b/.github/workflows/release-desktop-smoke.yml @@ -65,11 +65,25 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '22' - name: Install dependencies run: bun install --frozen-lockfile + - name: Get bundled OpenCode CLI version + id: opencode_cli_version + run: | + VERSION=$(node -p "require('./package.json').dependencies['@opencode-ai/sdk']") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Cache bundled OpenCode CLI artifact + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + with: + path: packages/electron/.cache/opencode-cli + key: opencode-cli-${{ runner.os }}-${{ matrix.arch }}-${{ steps.opencode_cli_version.outputs.version }} + restore-keys: | + opencode-cli-${{ runner.os }}-${{ matrix.arch }}- + - name: Install Apple Certificate env: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} @@ -101,12 +115,15 @@ jobs: ELECTRON_BUILDER_ARCH: ${{ matrix.arch }} run: | bun run build:web-assets + bun run prepare:opencode-cli + bun run verify:opencode-cli bun run bundle:main # npmRebuild=false in package.json, so electron-builder won't # recompile native deps on its own. Rebuild against the target # Electron ABI before packaging, matching the release workflow. bun run rebuild:native bunx electron-builder --mac --${{ matrix.arch }} --publish=never + bun run verify:opencode-cli:packaged - name: Verify signature + entitlements + notarization run: | @@ -165,7 +182,10 @@ jobs: build-windows-electron: if: ${{ inputs.build_windows }} name: Build Windows Electron (x64) - runs-on: windows-latest + # Match the production release workflow. windows-latest currently resolves + # to a runner with Visual Studio 18, which this Electron/node-gyp stack does + # not detect correctly. + runs-on: windows-2022 strategy: fail-fast: false matrix: @@ -186,15 +206,37 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '22' - name: Install dependencies run: bun install --frozen-lockfile + - name: Get bundled OpenCode CLI version + id: opencode_cli_version + shell: bash + run: | + VERSION=$(node -p "require('./package.json').dependencies['@opencode-ai/sdk']") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Cache bundled OpenCode CLI artifact + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + with: + path: packages/electron/.cache/opencode-cli + key: opencode-cli-${{ runner.os }}-${{ matrix.arch }}-${{ steps.opencode_cli_version.outputs.version }} + restore-keys: | + opencode-cli-${{ runner.os }}-${{ matrix.arch }}- + - name: Build web assets working-directory: packages/electron run: bun run build:web-assets + - name: Prepare bundled OpenCode CLI + working-directory: packages/electron + shell: bash + run: | + bun run prepare:opencode-cli + bun run verify:opencode-cli + - name: Bundle main process working-directory: packages/electron run: bun run bundle:main @@ -210,7 +252,9 @@ jobs: - name: Build Windows app working-directory: packages/electron shell: bash - run: node ./scripts/package.mjs --win --${{ matrix.arch }} --publish=never + run: | + node ./scripts/package.mjs --win --${{ matrix.arch }} --publish=never + bun run verify:opencode-cli:packaged - name: Upload Windows installable artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 717be36780..301484269b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -90,7 +90,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '22' registry-url: 'https://registry.npmjs.org' - name: Install dependencies @@ -141,11 +141,26 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '22' - name: Install dependencies run: bun install --frozen-lockfile + - name: Get bundled OpenCode CLI version + id: opencode_cli_version + shell: bash + run: | + VERSION=$(node -p "require('./package.json').dependencies['@opencode-ai/sdk']") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Cache bundled OpenCode CLI artifact + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + with: + path: packages/electron/.cache/opencode-cli + key: opencode-cli-${{ runner.os }}-${{ matrix.arch }}-${{ steps.opencode_cli_version.outputs.version }} + restore-keys: | + opencode-cli-${{ runner.os }}-${{ matrix.arch }}- + - name: Install Apple Certificate env: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} @@ -179,6 +194,8 @@ jobs: ELECTRON_BUILDER_ARCH: ${{ matrix.arch }} run: | bun run build:web-assets + bun run prepare:opencode-cli + bun run verify:opencode-cli bun run bundle:main # npmRebuild=false in package.json, so electron-builder won't # recompile native deps on its own — we must rebuild against the @@ -186,6 +203,7 @@ jobs: # node-pty/bun-pty crash on require inside the packaged app. bun run rebuild:native bunx electron-builder --mac --${{ matrix.arch }} --publish=never + bun run verify:opencode-cli:packaged - name: Verify signature + entitlements + notarization run: | @@ -270,15 +288,37 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '22' - name: Install dependencies run: bun install --frozen-lockfile + - name: Get bundled OpenCode CLI version + id: opencode_cli_version + shell: bash + run: | + VERSION=$(node -p "require('./package.json').dependencies['@opencode-ai/sdk']") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Cache bundled OpenCode CLI artifact + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + with: + path: packages/electron/.cache/opencode-cli + key: opencode-cli-${{ runner.os }}-${{ matrix.arch }}-${{ steps.opencode_cli_version.outputs.version }} + restore-keys: | + opencode-cli-${{ runner.os }}-${{ matrix.arch }}- + - name: Build web assets working-directory: packages/electron run: bun run build:web-assets + - name: Prepare bundled OpenCode CLI + working-directory: packages/electron + shell: bash + run: | + bun run prepare:opencode-cli + bun run verify:opencode-cli + - name: Bundle main process working-directory: packages/electron run: bun run bundle:main @@ -294,7 +334,9 @@ jobs: - name: Build Windows app working-directory: packages/electron shell: bash - run: node ./scripts/package.mjs --win --${{ matrix.arch }} --publish=never + run: | + node ./scripts/package.mjs --win --${{ matrix.arch }} --publish=never + bun run verify:opencode-cli:packaged - name: Upload installer to release uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 @@ -323,7 +365,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '22' - name: Download per-arch latest-mac.yml uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 diff --git a/.github/workflows/vscode-extension.yml b/.github/workflows/vscode-extension.yml index 566549c12d..ef9ac3a2a5 100644 --- a/.github/workflows/vscode-extension.yml +++ b/.github/workflows/vscode-extension.yml @@ -25,7 +25,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '22' - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.gitignore b/.gitignore index f69072c8b9..8a344a6b32 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Agent memory +.graymatter/ + # Logs logs *.log @@ -64,3 +67,4 @@ data/ workspaces/ *.pid .worktrees/ +test-results/ diff --git a/.opencode/agent/pr-review.md b/.opencode/agent/pr-review.md index 07913c1339..c7c1c9244f 100644 --- a/.opencode/agent/pr-review.md +++ b/.opencode/agent/pr-review.md @@ -136,6 +136,13 @@ Merge signal in plain English: safe to merge, safe after a small fix, or not saf Explain the reason in a short paragraph. If there are findings, name the files that need attention. +

Risk Score: X/5

+ +1 is low risk (isolated, reversible, well-contained change), 5 is high risk (touches security, data persistence, shared state, build/release, or broad cross-runtime contracts). + +Explain the score in a short paragraph: which risk dimensions apply (correctness, data loss, security/supply-chain, performance, cross-runtime parity) and what makes the change more or less risky. +
+

Findings

If there are findings, list them like this: diff --git a/AGENTS.md b/AGENTS.md index 263dd21d21..9d21944a35 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -340,6 +340,7 @@ Project skills live under `.agents/skills/*/SKILL.md`. Before editing, agents ** | User-facing UI text: labels, buttons, placeholders, aria labels, empty/error/loading states, toasts, dialogs, settings copy, or navigation labels | `skill({ name: "locale-ui-patterns" })` | | Settings pages, settings dialogs, configuration UI, or visual/layout changes inside Settings | `skill({ name: "settings-ui-patterns" })` | | Drag-to-reorder, sortable lists/chips/grids, or `@dnd-kit` behavior including touch/mobile and wrapping variable-width items | `skill({ name: "drag-to-reorder" })` | +| iOS Simulator preview/control for the mobile app, `serve-sim`, simulator taps/typing/gestures/rotation, or headless install/launch workflows outside Xcode | `skill({ name: "serve-sim" })` | Skill docs are the source of truth for detailed patterns. Do not duplicate their full guidance here; load the skill and follow it before making matching changes. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a57b8b135..5393c00429 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,49 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [1.14.0] - 2026-07-05 + +- Voice: voice input was rebuilt around live streaming transcription — the composer mic shows a live transcript with a volume meter and timer while you speak, and a recording can be cancelled, inserted, or inserted and sent; failed transcriptions keep their audio so you can retry or accept the partial text. +- Voice: local speech-to-text works out of the box — models (Parakeet for English and 25 European languages, Whisper for a lighter multilingual option) download on demand from a new picker in Settings → Voice, or any OpenAI-compatible Whisper endpoint can be used instead; a configurable shortcut (mod+alt+v by default) toggles dictation. +- Voice: read-aloud can now use a local Kokoro voice (11 English voices), and long replies start speaking after roughly a sentence instead of waiting for the whole message. +- Voice: the Voice settings page was simplified — a single read-aloud toggle owns the playback options, and a new "Enable voice input" toggle hides the composer mic entirely. +- Mobile: the composer collapses into a compact input bar while the keyboard is closed, with a round new-session button beside it (hidden on the new-session screen); tapping the bar expands it and opens the keyboard, and the mic starts voice input straight from the compact bar. +- Mobile: the model and agent selectors moved into a row above the message text, the attachment menu and the new-session project/branch pickers open as bottom sheets with search, and a drag handle above the composer swipes it into a fullscreen editor — swiping down shrinks it back or dismisses the keyboard. +- Mobile: long conversations now load older history with a button at the top of the chat, which disappears once everything is loaded; loading older messages keeps your scroll position steady on all platforms. +- Mobile: the branch/worktree picker on the new-session screen lists all worktrees right after a cold start, and the GitHub connection status is recognized without re-running the connect flow. +- Mobile: opening the web app in a phone browser against a password-protected instance shows the password unlock page again (regressed in 1.13.9). +- Mobile: returning to the app no longer briefly flickers the session list. +- Mobile: continued polish ahead of the native app release — the chat and composer ride the keyboard in one smooth motion (including in long conversations), bottom sheets enter cleanly while the keyboard dismisses, the text cursor stays in place when the keyboard opens, starter suggestions on the new-session screen step aside while the keyboard is up, and switching instances no longer leaves the previous instance's sessions in the sessions list. +- UI: lists across the app were moved to one virtualization engine, so long lists scroll more consistently. +- Mobile: the slash-command, file/agent, skill, and snippet autocompletes were tuned for touch — they can grow up to the top of the chat area, the keyboard-hint footer and description lines are gone, row icons line up, list scrolling no longer bounces the page behind, and picking a command keeps the keyboard open. +- Mobile: in phone browsers the composer now keeps itself above the keyboard on the new-session screen and in the fullscreen editor, and opening the app shows the logo while it connects instead of flashing an unreachable-server error. +- Chat: the stop button now aborts sessions running in a different project or worktree than the currently open one — previously those aborts silently did nothing. +- Desktop: a local instance with a UI password and LAN access no longer gets stuck on "Auth required" and an unreachable-server screen (the app's client tokens are now reliably recognized as local, including for 0.0.0.0-bound servers). +- Desktop: the app prefers your own OpenCode install again — the bundled CLI is used only when no OpenCode is installed anywhere on the machine. +- Windows: OpenCode installed via npm now launches from paths with spaces (such as C:\Program Files\nodejs), binary paths pasted with surrounding quotes work, and discovery also checks the system-wide npm prefix and Scoop's shims — in the web/desktop app and the VS Code extension. + +## [1.13.9] - 2026-07-02 + +- Mobile: added the native iOS and Android app projects ahead of the mobile app release, with continued polish for saved connections, password unlock, QR-code connection scanning, push notifications, iOS widgets, app resume, and native layout details. +- Desktop: the app can now use a bundled OpenCode CLI, or you can choose your own CLI path in settings. +- Desktop: added a Keep awake setting for the upcoming desktop app release to prevent the computer from sleeping while the app is running. +- Desktop: you can now specify optional custom headers when adding a remote OpenChamber instance to the desktop app, including for Cloudflare Access-style setups; settings and environment variables can still override them, and the bundled CLI can be replaced by setting a direct OpenCode CLI path. +- Desktop: SSH remote instances with a saved UI password now open directly after the tunnel connects instead of showing the unlock screen again. +- Chat: fixed edge cases where late-loading tool content, subagent content, or streaming Thinking blocks could pull the conversation away from the latest message or fight manual scrolling. +- Chat: embedded JSON examples in messages no longer render as generated-result cards. +- Sync: chat state now recovers after idle reconnects instead of leaving sessions stuck in a stale busy state. +- VSCode: clearing optional agent fields now removes them from agent config instead of saving `null` values. +- VSCode: the extension no longer picks OpenCode desktop app installs when looking for the standalone OpenCode CLI. + +## [1.13.8] - 2026-06-29 + +- Startup: launching the app no longer hangs for around 20 seconds before you can open a session, load a diff, or send a message — GitHub pull request status checks no longer tie up the connection to the server during startup. +- OpenCode: when a separate OpenCode is already running (the TUI, `opencode serve`, or a daemon on the default port 4096), the app now starts its own server instead of attaching to it. This fixes the "OpenChamber could not finish initialization" error and stops the app from opening or closing your separate OpenCode when it starts and quits. Connecting to an external OpenCode now requires setting `OPENCODE_HOST`, `OPENCODE_PORT`, or `OPENCODE_SKIP_START`. +- Chat: a new Follow-up behavior setting (Settings → Chat) controls what happens when you press Enter on a message while the agent is still responding — Steer inserts it into the agent's current turn, or Queue holds it until the turn finishes. Replaces the previous queue-mode toggle (thanks to @bashrusakh). +- Sessions: deleting a worktree group from the sidebar, or permanently deleting an archived session that has subagent sessions, now removes those subagent sessions too instead of leaving them behind (thanks to @bashrusakh). +- Sessions: clicking a session inside a worktree group no longer briefly jumps the selection to the project's first session while the sidebar data catches up (thanks to @bashrusakh). +- Sync: a connected but quiet session (for example an agent running a long tool call) no longer triggers repeated background refreshes every ~15 seconds (thanks to @tomzx). + ## [1.13.7] - 2026-06-28 - Chat: with tool calls (such as Bash and Edit) shown expanded by default, scrolling no longer twitches, and slow scrolling no longer jumps past several messages. diff --git a/Dockerfile b/Dockerfile index bf8a1a3547..928b02fc26 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1 -FROM oven/bun:1.3.5 AS base +FROM oven/bun:1.3.14 AS base WORKDIR /app FROM base AS deps @@ -16,7 +16,7 @@ WORKDIR /app COPY . . RUN bun run build:web -FROM oven/bun:1.3.5 AS runtime +FROM oven/bun:1.3.14 AS runtime WORKDIR /home/openchamber RUN apt-get update && apt-get install -y --no-install-recommends \ @@ -48,7 +48,7 @@ RUN npm config set prefix /home/openchamber/.npm-global && mkdir -p /home/opench npm install -g opencode-ai # cloudflared 2026.3.0 - update digest explicitly when upgrading -COPY --from=cloudflare/cloudflared@sha256:ba461b8aa9c042156dbd39c38657fe7431bafa063220eab8d5330a523863da9f /usr/local/bin/cloudflared /usr/local/bin/cloudflared +COPY --from=cloudflare/cloudflared@sha256:6d91c121b803126f7a5344005d17a9324788fc09d305b6e2560ec6040a7ae283 /usr/local/bin/cloudflared /usr/local/bin/cloudflared ENV NODE_ENV=production diff --git a/README.md b/README.md index e9d870af8b..f01b25bea1 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,6 @@ [![Discord](https://img.shields.io/badge/Discord-join.svg?style=flat&labelColor=100F0F&color=8B7EC8&logo=discord&logoColor=FFFCF0)](https://discord.gg/ZYRSdnwwKA) [![Support the project](https://img.shields.io/badge/Support-Project-black?style=flat&labelColor=100F0F&color=EC8B49&logo=ko-fi&logoColor=FFFCF0)](https://ko-fi.com/G2G41SAWNS) -> [!IMPORTANT] -> 🏖️ I'm on vacation from 18 Jun to 28 Jun. All issues and PRs will continue being reviewed after that. Thanks for the patience. - ## **OpenCode, everywhere.** Desktop. Browser. Phone. ### A rich interface for [OpenCode](https://opencode.ai). Review diffs, manage agents, run dev servers, and keep the big picture while your AI codes. @@ -89,7 +86,7 @@ ## Quick Start -> **Prerequisite:** [OpenCode CLI](https://opencode.ai) installed. +> **Prerequisite:** Desktop bundles the matching OpenCode CLI. CLI/Web and VS Code use your installed [OpenCode CLI](https://opencode.ai). ### **Desktop (macOS + Windows)** Download from [Releases](https://github.com/btriapitsyn/openchamber/releases). diff --git a/bun.lock b/bun.lock index 506dcb99ab..1e4d7467d2 100644 --- a/bun.lock +++ b/bun.lock @@ -25,15 +25,12 @@ "@codemirror/search": "^6.6.0", "@codemirror/state": "^6.5.4", "@codemirror/view": "6.39.13", - "@fontsource/ibm-plex-mono": "^5.2.7", - "@fontsource/ibm-plex-sans": "^5.1.1", "@heroui/scroll-shadow": "^2.3.18", "@heroui/system": "^2.4.23", "@heroui/theme": "^2.4.23", - "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.17.9", + "@opencode-ai/sdk": "1.17.12", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -68,6 +65,7 @@ "zustand": "^5.0.8", }, "devDependencies": { + "@clack/prompts": "^1.1.0", "@eslint/js": "^9.33.0", "@remixicon/react": "^4.7.0", "@tailwindcss/postcss": "^4.0.0", @@ -99,7 +97,7 @@ }, "packages/electron": { "name": "@openchamber/electron", - "version": "1.13.2", + "version": "1.13.9", "dependencies": { "@openchamber/web": "workspace:*", "electron-context-menu": "^4.1.2", @@ -112,11 +110,38 @@ "electron-builder": "^26.0.0", }, }, + "packages/mobile": { + "name": "@openchamber/mobile", + "version": "1.13.2", + "dependencies": { + "@aparajita/capacitor-secure-storage": "^8.0.0", + "@capacitor-mlkit/barcode-scanning": "^8.1.0", + "@capacitor/app": "^8.0.0", + "@capacitor/core": "^8.4.1", + "@capacitor/keyboard": "^8.0.0", + "@capacitor/push-notifications": "^8.1.1", + "@capacitor/status-bar": "^8.0.0", + }, + "devDependencies": { + "@capacitor/android": "^8.4.1", + "@capacitor/cli": "^8.4.1", + "@capacitor/ios": "^8.4.1", + "@types/node": "^24.3.1", + "serve-sim": "^0.1.34", + "typescript": "~5.9.0", + }, + }, "packages/ui": { "name": "@openchamber/ui", - "version": "1.13.2", + "version": "1.13.9", "dependencies": { + "@aparajita/capacitor-secure-storage": "^8.0.0", "@base-ui/react": "^1.4.0", + "@capacitor/app": "^8.0.0", + "@capacitor/core": "^8.4.1", + "@capacitor/keyboard": "^8.0.0", + "@capacitor/push-notifications": "^8.1.1", + "@capacitor/status-bar": "^8.0.0", "@codemirror/autocomplete": "^6.20.0", "@codemirror/commands": "^6.10.1", "@codemirror/lang-cpp": "^6.0.3", @@ -141,14 +166,12 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@fontsource/ibm-plex-mono": "^5.2.7", - "@fontsource/ibm-plex-sans": "^5.1.1", - "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", - "@opencode-ai/sdk": "^1.17.9", - "@pierre/diffs": "1.3.0-beta.4", + "@opencode-ai/sdk": "1.17.12", + "@pierre/diffs": "1.3.0-beta.6", "@replit/codemirror-vim": "^6.3.0", "@simplewebauthn/browser": "13.3.0", + "@tanstack/react-virtual": "3.14.5", "@xenova/transformers": "^2.17.2", "@zumer/snapdom": "^2.12.0", "beautiful-mermaid": "^1.1.3", @@ -214,10 +237,10 @@ }, "packages/vscode": { "name": "openchamber", - "version": "1.13.2", + "version": "1.13.9", "dependencies": { "@openchamber/ui": "workspace:*", - "@opencode-ai/sdk": "^1.17.9", + "@opencode-ai/sdk": "1.17.12", "adm-zip": "^0.5.16", "jsonc-parser": "^3.3.1", "react": "^19.1.1", @@ -237,14 +260,14 @@ }, "packages/web": { "name": "@openchamber/web", - "version": "1.13.2", + "version": "1.13.9", "bin": { "openchamber": "./bin/cli.js", }, "dependencies": { "@clack/prompts": "^1.1.0", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.17.9", + "@opencode-ai/sdk": "1.17.12", "@simplewebauthn/server": "13.3.1", "adm-zip": "^0.5.16", "better-sqlite3": "^12.10.0", @@ -260,6 +283,7 @@ "openai": "^4.79.0", "qrcode-terminal": "^0.12.0", "reflect-metadata": "^0.2.2", + "sherpa-onnx-node": "1.12.28", "simple-git": "^3.28.0", "web-push": "^3.6.7", "ws": "^8.18.3", @@ -270,9 +294,6 @@ "@codemirror/lang-cpp": "^6.0.3", "@codemirror/lang-go": "^6.0.1", "@eslint/js": "^9.33.0", - "@fontsource/ibm-plex-mono": "^5.2.7", - "@fontsource/ibm-plex-sans": "^5.1.1", - "@ibm/plex": "^6.4.1", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -326,6 +347,9 @@ }, }, }, + "patchedDependencies": { + "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch", + }, "overrides": { "@codemirror/language": "6.12.2", "@codemirror/view": "6.39.13", @@ -335,6 +359,8 @@ "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], + "@aparajita/capacitor-secure-storage": ["@aparajita/capacitor-secure-storage@8.0.0", "", { "dependencies": { "@capacitor/android": "^8.0.2", "@capacitor/app": "^8.0.0", "@capacitor/core": "^8.0.2", "@capacitor/ios": "^8.0.2", "@capacitor/keyboard": "^8.0.0" } }, "sha512-oYnwSjdIh23aRNgz8982+TmFvQH/2yZkEdw1iIg+H2ziFJoOVELPTc7u6Ez2HwOuDIW5AGqBX75GvrzQ+D70Qg=="], + "@apideck/better-ajv-errors": ["@apideck/better-ajv-errors@0.3.6", "", { "dependencies": { "json-schema": "^0.4.0", "jsonpointer": "^5.0.0", "leven": "^3.1.0" }, "peerDependencies": { "ajv": ">=8" } }, "sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA=="], "@azu/format-text": ["@azu/format-text@1.0.2", "", {}, "sha512-Swi4N7Edy1Eqq82GxgEECXSSLyn6GOb5htRFPzBDdUkECGXtlf12ynO5oJSpWKPwCaUssOu7NfhDcCWpIC6Ywg=="], @@ -551,6 +577,24 @@ "@base-ui/utils": ["@base-ui/utils@0.2.7", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@floating-ui/utils": "^0.2.11", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-nXYKhiL/0JafyJE8PfcflipGftOftlIwKd72rU15iZ1M5yqgg5J9P8NHU71GReDuXco5MJA/eVQqUT5WRqX9sA=="], + "@capacitor-mlkit/barcode-scanning": ["@capacitor-mlkit/barcode-scanning@8.1.0", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-lhOYHZINLOCT0i5YSbSMkouik3zh0BncJouumNTgXCT0/533Z4733jAX9zv+nEd++bE8QHeUl2g0NrewreumnQ=="], + + "@capacitor/android": ["@capacitor/android@8.4.1", "", { "peerDependencies": { "@capacitor/core": "^8.4.0" } }, "sha512-igtDCJ7QQn0P2qHFD9p4KXaa6V1b2PRNt+MxjVwtjTm/BJvqmiazOJq6rPjwFSZnfHm6iFoZk8TfzHd44pyBGw=="], + + "@capacitor/app": ["@capacitor/app@8.1.0", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-MlmttTOWHDedr/G4SrhNRxsXMqY+R75S4MM4eIgzsgCzOYhb/MpCkA5Q3nuOCfL1oHm26xjUzqZ5aupbOwdfYg=="], + + "@capacitor/cli": ["@capacitor/cli@8.4.1", "", { "dependencies": { "@ionic/cli-framework-output": "^2.2.8", "@ionic/utils-subprocess": "^3.0.1", "@ionic/utils-terminal": "^2.3.5", "commander": "^12.1.0", "debug": "^4.4.0", "env-paths": "^2.2.0", "fs-extra": "^11.2.0", "kleur": "^4.1.5", "native-run": "^2.0.3", "open": "^8.4.0", "plist": "^3.1.0", "prompts": "^2.4.2", "rimraf": "^6.0.1", "semver": "^7.6.3", "tar": "^7.5.3", "tslib": "^2.8.1", "xml2js": "^0.6.2" }, "bin": { "cap": "bin/capacitor", "capacitor": "bin/capacitor" } }, "sha512-t7F2s7fFHCq113xgrggrmK6ctV0/8E5YfLNVLfPHp4GCTDO+tly9fZvWPf2/sOI8lMm18dLT43qbXLRTz/OZgw=="], + + "@capacitor/core": ["@capacitor/core@8.4.1", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-xqhOGLbTAYeOWK+IDUNSjQJAPapQjRHrIcgk9PYp52or9zFTaaMko31uNi16N6W+CRJ8VrRram6fOYILkBG2Hg=="], + + "@capacitor/ios": ["@capacitor/ios@8.4.1", "", { "peerDependencies": { "@capacitor/core": "^8.4.0" } }, "sha512-EgcAk7NYheHMTyP3CUrA65qKs4D2UEAKgw44HI6Uk3dTw6KjLQkdLOQWvbeRncHaZ2gklfOojUoc5DlSw2lhYg=="], + + "@capacitor/keyboard": ["@capacitor/keyboard@8.0.5", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-oFXygC4eKYA5l2MdpTR06L2M/4x6e2SLD5yS1T9+UBDKTkzyvhWKEhbYLUaTIBPpLKqlfGudJw1X73S1H9eUzQ=="], + + "@capacitor/push-notifications": ["@capacitor/push-notifications@8.1.1", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-WqzjPKIbYbARMN+GC0XMAJcxJpUUzqgzS/Ny8RODLrro38pQhm3GXYwX2Mwd+LZlLY39rGImkCkrKyQSNfuikA=="], + + "@capacitor/status-bar": ["@capacitor/status-bar@8.0.2", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-WXs8YB8B9eEaPZz+bcdY6t2nForF1FLoj/JU0Dl9RRgQnddnS98FEEyDooQhaY7wivr000j4+SC1FyeJkrFO7A=="], + "@clack/core": ["@clack/core@1.1.0", "", { "dependencies": { "sisteransi": "^1.0.5" } }, "sha512-SVcm4Dqm2ukn64/8Gub2wnlA5nS2iWJyCkdNHcvNHPIeBTGojpdJ+9cZKwLfmqy7irD4N5qLteSilJlE0WLAtA=="], "@clack/prompts": ["@clack/prompts@1.1.0", "", { "dependencies": { "@clack/core": "1.1.0", "sisteransi": "^1.0.5" } }, "sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g=="], @@ -725,10 +769,6 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], - "@fontsource/ibm-plex-mono": ["@fontsource/ibm-plex-mono@5.2.7", "", {}, "sha512-MKAb8qV+CaiMQn2B0dIi1OV3565NYzp3WN5b4oT6LTkk+F0jR6j0ZN+5BKJiIhffDC3rtBULsYZE65+0018z9w=="], - - "@fontsource/ibm-plex-sans": ["@fontsource/ibm-plex-sans@5.2.8", "", {}, "sha512-eztSXjDhPhcpxNIiGTgMebdLP9qS4rWkysuE1V7c+DjOR0qiezaiDaTwQE7bTnG5HxAY/8M43XKDvs3cYq6ZYQ=="], - "@formatjs/ecma402-abstract": ["@formatjs/ecma402-abstract@2.3.6", "", { "dependencies": { "@formatjs/fast-memoize": "2.2.7", "@formatjs/intl-localematcher": "0.6.2", "decimal.js": "^10.4.3", "tslib": "^2.8.0" } }, "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw=="], "@formatjs/fast-memoize": ["@formatjs/fast-memoize@2.2.7", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ=="], @@ -769,10 +809,6 @@ "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], - "@ibm/plex": ["@ibm/plex@6.4.1", "", { "dependencies": { "@ibm/telemetry-js": "^1.5.1" } }, "sha512-fnsipQywHt3zWvsnlyYKMikcVI7E2fEwpiPnIHFqlbByXVfQfANAAeJk1IV4mNnxhppUIDlhU0TzwYwL++Rn2g=="], - - "@ibm/telemetry-js": ["@ibm/telemetry-js@1.11.0", "", { "bin": { "ibmtelemetry": "dist/collect.js" } }, "sha512-RO/9j+URJnSfseWg9ZkEX9p+a3Ousd33DBU7rOafoZB08RqdzxFVYJ2/iM50dkBuD0o7WX7GYt1sLbNgCoE+pA=="], - "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.1" }, "os": "darwin", "cpu": "arm64" }, "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg=="], @@ -835,6 +871,22 @@ "@internationalized/string": ["@internationalized/string@3.2.7", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-D4OHBjrinH+PFZPvfCXvG28n2LSykWcJ7GIioQL+ok0LON15SdfoUssoHzzOUmVZLbRoREsQXVzA6r8JKsbP6A=="], + "@ionic/cli-framework-output": ["@ionic/cli-framework-output@2.2.8", "", { "dependencies": { "@ionic/utils-terminal": "2.3.5", "debug": "^4.0.0", "tslib": "^2.0.1" } }, "sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g=="], + + "@ionic/utils-array": ["@ionic/utils-array@2.1.6", "", { "dependencies": { "debug": "^4.0.0", "tslib": "^2.0.1" } }, "sha512-0JZ1Zkp3wURnv8oq6Qt7fMPo5MpjbLoUoa9Bu2Q4PJuSDWM8H8gwF3dQO7VTeUj3/0o1IB1wGkFWZZYgUXZMUg=="], + + "@ionic/utils-fs": ["@ionic/utils-fs@3.1.7", "", { "dependencies": { "@types/fs-extra": "^8.0.0", "debug": "^4.0.0", "fs-extra": "^9.0.0", "tslib": "^2.0.1" } }, "sha512-2EknRvMVfhnyhL1VhFkSLa5gOcycK91VnjfrTB0kbqkTFCOXyXgVLI5whzq7SLrgD9t1aqos3lMMQyVzaQ5gVA=="], + + "@ionic/utils-object": ["@ionic/utils-object@2.1.6", "", { "dependencies": { "debug": "^4.0.0", "tslib": "^2.0.1" } }, "sha512-vCl7sl6JjBHFw99CuAqHljYJpcE88YaH2ZW4ELiC/Zwxl5tiwn4kbdP/gxi2OT3MQb1vOtgAmSNRtusvgxI8ww=="], + + "@ionic/utils-process": ["@ionic/utils-process@2.1.12", "", { "dependencies": { "@ionic/utils-object": "2.1.6", "@ionic/utils-terminal": "2.3.5", "debug": "^4.0.0", "signal-exit": "^3.0.3", "tree-kill": "^1.2.2", "tslib": "^2.0.1" } }, "sha512-Jqkgyq7zBs/v/J3YvKtQQiIcxfJyplPgECMWgdO0E1fKrrH8EF0QGHNJ9mJCn6PYe2UtHNS8JJf5G21e09DfYg=="], + + "@ionic/utils-stream": ["@ionic/utils-stream@3.1.7", "", { "dependencies": { "debug": "^4.0.0", "tslib": "^2.0.1" } }, "sha512-eSELBE7NWNFIHTbTC2jiMvh1ABKGIpGdUIvARsNPMNQhxJB3wpwdiVnoBoTYp+5a6UUIww4Kpg7v6S7iTctH1w=="], + + "@ionic/utils-subprocess": ["@ionic/utils-subprocess@3.0.1", "", { "dependencies": { "@ionic/utils-array": "2.1.6", "@ionic/utils-fs": "3.1.7", "@ionic/utils-process": "2.1.12", "@ionic/utils-stream": "3.1.7", "@ionic/utils-terminal": "2.3.5", "cross-spawn": "^7.0.3", "debug": "^4.0.0", "tslib": "^2.0.1" } }, "sha512-cT4te3AQQPeIM9WCwIg8ohroJ8TjsYaMb2G4ZEgv9YzeDqHZ4JpeIKqG2SoaA3GmVQ3sOfhPM6Ox9sxphV/d1A=="], + + "@ionic/utils-terminal": ["@ionic/utils-terminal@2.3.5", "", { "dependencies": { "@types/slice-ansi": "^4.0.0", "debug": "^4.0.0", "signal-exit": "^3.0.3", "slice-ansi": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0", "tslib": "^2.0.1", "untildify": "^4.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-3cKScz9Jx2/Pr9ijj1OzGlBDfcmx7OMVBt4+P1uRR0SSW4cm1/y3Mo4OY3lfkuaYifMNBW8Wz6lQHbs1bihr7A=="], + "@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="], "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], @@ -937,11 +989,13 @@ "@openchamber/electron": ["@openchamber/electron@workspace:packages/electron"], + "@openchamber/mobile": ["@openchamber/mobile@workspace:packages/mobile"], + "@openchamber/ui": ["@openchamber/ui@workspace:packages/ui"], "@openchamber/web": ["@openchamber/web@workspace:packages/web"], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.17.9", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-MHmXEpGPHkg14v1p+cUlIOUxd6DQdSElfau9nqY7tcDI0x5r4Y8D0dKXcyAh0Gc73ptaGW67Vg84nkcV6O27Pw=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.17.12", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-N8kazWO0ZLCHWYFuZQt1UJM+bWxY6g1auSG6SvD1+K3+W+nw2qIhDAUGNCD0KVW3bY2LCwvfWvpG2ZbVGCHC0Q=="], "@paralleldrive/cuid2": ["@paralleldrive/cuid2@2.3.1", "", { "dependencies": { "@noble/hashes": "^1.1.5" } }, "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw=="], @@ -969,11 +1023,11 @@ "@peculiar/x509": ["@peculiar/x509@1.14.3", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.0", "@peculiar/asn1-csr": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.0", "@peculiar/asn1-pkcs9": "^2.6.0", "@peculiar/asn1-rsa": "^2.6.0", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "pvtsutils": "^1.3.6", "reflect-metadata": "^0.2.2", "tslib": "^2.8.1", "tsyringe": "^4.10.0" } }, "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA=="], - "@pierre/diffs": ["@pierre/diffs@1.3.0-beta.4", "", { "dependencies": { "@pierre/theme": "1.0.3", "@pierre/theming": "0.0.1", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-poFcsvhcQt9lH/InzAPaGs47WYHMidnFCjuYGNU41HiVLJP4mkIQDSdvcnPIkBuh/cYbPOQg/YE3T1kSpr01GA=="], + "@pierre/diffs": ["@pierre/diffs@1.3.0-beta.6", "", { "dependencies": { "@pierre/theme": "1.1.0", "@pierre/theming": "0.0.2", "@shikijs/transformers": "^3.0.0 || ^4.0.0", "diff": "9.0.0", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0 || ^4.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-SGxpOvuPeAq2sIMYqCokt8pVJ9UAZ6P5/qR4RYlcEVGRXOfGszbNMbrHs+IfRKnk6AufPNqVkIQWTwLC77x85A=="], - "@pierre/theme": ["@pierre/theme@1.0.3", "", {}, "sha512-sWHv11TMoqKxKDgTIk5VbhQjdPhs8DCcBxbjh3mRlS3YOM/OcrWoGX6MM8eBGn9cUu3M46Py0JnxsG2nJaFTuA=="], + "@pierre/theme": ["@pierre/theme@1.1.0", "", {}, "sha512-GC2OWTAfTIIWWYhPCygwG8t2EtePQkRfON4MI2rwIkJylmiyqIttJID2dCL8sUD8cNdEvYkEyfEHHKMeCiDLoQ=="], - "@pierre/theming": ["@pierre/theming@0.0.1", "", { "peerDependencies": { "@pierre/theme": "^1.0.0", "@shikijs/themes": "^3.0.0 || ^4.0.0", "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0", "shiki": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@pierre/theme", "@shikijs/themes", "react", "react-dom", "shiki"] }, "sha512-1thlEtJbqdyLzc1ZS2KQa1q7FzDGHT4dTEdKHoyQjOMeWWOmbVG5/ndEfOKfAb5Fzkz8cNJrOjFLiZoDH/A03A=="], + "@pierre/theming": ["@pierre/theming@0.0.2", "", { "peerDependencies": { "@pierre/theme": "^1.1.0", "@shikijs/themes": "^3.0.0 || ^4.0.0", "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0", "shiki": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@pierre/theme", "@shikijs/themes", "react", "react-dom", "shiki"] }, "sha512-QM1M4stXfnzfaE8I8YbjXSApV8c+2dBsXJj8eYg9WTpBR/cTmCZIcfGnN4p13iRrYu2Br/R/OJfEL7uR8Qjctw=="], "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], @@ -1247,6 +1301,10 @@ "@tailwindcss/postcss": ["@tailwindcss/postcss@4.2.1", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "postcss": "^8.5.6", "tailwindcss": "4.2.1" } }, "sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw=="], + "@tanstack/react-virtual": ["@tanstack/react-virtual@3.14.5", "", { "dependencies": { "@tanstack/virtual-core": "3.17.3" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-4EKRXh7zBLkbKbFmG3AUVkircuHd+7OdT1pocJSepxtfBd3qnrJgJ5rtPkRYyo9fmyVb2+pI2xPy5oYvMLQy6A=="], + + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.3", "", {}, "sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw=="], + "@textlint/ast-node-types": ["@textlint/ast-node-types@15.5.2", "", {}, "sha512-fCaOxoup5LIyBEo7R1oYWE7V4bSX0KQeHh66twon9e9usaLE3ijgF8QjYsR6joCssdeCHVd0wHm7ppsEyTr6vg=="], "@textlint/linter-formatter": ["@textlint/linter-formatter@15.5.2", "", { "dependencies": { "@azu/format-text": "^1.0.2", "@azu/style-format": "^1.0.1", "@textlint/module-interop": "15.5.2", "@textlint/resolver": "15.5.2", "@textlint/types": "15.5.2", "chalk": "^4.1.2", "debug": "^4.4.3", "js-yaml": "^4.1.1", "lodash": "^4.17.23", "pluralize": "^2.0.0", "string-width": "^4.2.3", "strip-ansi": "^6.0.1", "table": "^6.9.0", "text-table": "^0.2.0" } }, "sha512-jAw7jWM8+wU9cG6Uu31jGyD1B+PAVePCvnPKC/oov+2iBPKk3ao30zc/Itmi7FvXo4oPaL9PmzPPQhyniPVgVg=="], @@ -1329,6 +1387,8 @@ "@types/sarif": ["@types/sarif@2.1.7", "", {}, "sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ=="], + "@types/slice-ansi": ["@types/slice-ansi@4.0.0", "", {}, "sha512-+OpjSaq85gvlZAYINyzKpLeiFkSC4EsC6IIiT6v6TLSU5k5U83fHGj9Lel8oKEXM0HqgrMVCjXPDPVICtxF7EQ=="], + "@types/superagent": ["@types/superagent@8.1.9", "", { "dependencies": { "@types/cookiejar": "^2.1.5", "@types/methods": "^1.1.4", "@types/node": "*", "form-data": "^4.0.0" } }, "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ=="], "@types/supertest": ["@types/supertest@7.2.0", "", { "dependencies": { "@types/methods": "^1.1.4", "@types/superagent": "^8.1.0" } }, "sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw=="], @@ -1519,6 +1579,8 @@ "better-sqlite3": ["better-sqlite3@12.10.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ=="], + "big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="], + "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], "binaryextensions": ["binaryextensions@6.11.0", "", { "dependencies": { "editions": "^6.21.0" } }, "sha512-sXnYK/Ij80TO3lcqZVV2YgfKN5QjUWIRk/XSm2J/4bd/lPko3lvk0O4ZppH6m+6hB2/GTu+ptNwVFe1xh+QLQw=="], @@ -1537,6 +1599,8 @@ "boundary": ["boundary@2.0.0", "", {}, "sha512-rJKn5ooC9u8q13IMCrW0RSp31pxBCHE3y9V/tp3TdWSLf8Em3p6Di4NBpfzbJge9YjjFEsD0RtFEjtvHL5VyEA=="], + "bplist-parser": ["bplist-parser@0.3.2", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ=="], + "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], @@ -1643,7 +1707,7 @@ "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], - "commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], + "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], "common-tags": ["common-tags@1.8.2", "", {}, "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA=="], @@ -1731,7 +1795,7 @@ "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], - "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], + "define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="], "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], @@ -1751,7 +1815,7 @@ "dezalgo": ["dezalgo@1.0.4", "", { "dependencies": { "asap": "^2.0.0", "wrappy": "1" } }, "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig=="], - "diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], + "diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], "dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="], @@ -1809,6 +1873,8 @@ "electron-winstaller": ["electron-winstaller@5.4.0", "", { "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", "fs-extra": "^7.0.1", "lodash": "^4.17.21", "temp": "^0.9.0" }, "optionalDependencies": { "@electron/windows-sign": "^1.1.2" } }, "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg=="], + "elementtree": ["elementtree@0.1.7", "", { "dependencies": { "sax": "1.1.4" } }, "sha512-wkgGT6kugeQk/P6VZ/f4T+4HB41BVgNBq5CDIZVbQ02nvTVqAiVTbskxxu3eA/X96lMlfYOwnLQpN2v5E1zDEg=="], + "elkjs": ["elkjs@0.11.0", "", {}, "sha512-u4J8h9mwEDaYMqo0RYJpqNMFDoMK7f+pu4GjcV+N8jIC7TRdORgzkfSjTJemhqONFfH6fBI3wpysgWbhgVWIXw=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -2135,10 +2201,12 @@ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + "ini": ["ini@4.1.3", "", {}, "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg=="], "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], + "inspect-webkit": ["inspect-webkit@0.0.5", "", { "peerDependencies": { "typescript": "^5" }, "bin": { "inspect-webkit": "dist/cli.js" } }, "sha512-584wP/2nJO1LX74nqHP2j0tQzlK9ZTi+D0Z9qeLQjtUR/LCMXQHGX8M0vrsqBwTeGakF7q5GMFpg81nxUYlCvw=="], + "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], "intl-messageformat": ["intl-messageformat@10.7.18", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/fast-memoize": "2.2.7", "@formatjs/icu-messageformat-parser": "2.11.4", "tslib": "^2.8.0" } }, "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g=="], @@ -2297,6 +2365,8 @@ "klaw-sync": ["klaw-sync@6.0.0", "", { "dependencies": { "graceful-fs": "^4.1.11" } }, "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ=="], + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + "lazy-val": ["lazy-val@1.0.5", "", {}, "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q=="], "leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="], @@ -2543,6 +2613,8 @@ "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], + "native-run": ["native-run@2.0.3", "", { "dependencies": { "@ionic/utils-fs": "^3.1.7", "@ionic/utils-terminal": "^2.3.4", "bplist-parser": "^0.3.2", "debug": "^4.3.4", "elementtree": "^0.1.7", "ini": "^4.1.1", "plist": "^3.1.0", "split2": "^4.2.0", "through2": "^4.0.2", "tslib": "^2.6.2", "yauzl": "^2.10.0" }, "bin": { "native-run": "bin/native-run" } }, "sha512-U1PllBuzW5d1gfan+88L+Hky2eZx+9gv3Pf6rNBxKbORxi7boHzqiA6QFGSnqMem4j0A9tZ08NMIs5+0m/VS1Q=="], + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], "negotiator": ["negotiator@0.6.4", "", {}, "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w=="], @@ -2707,6 +2779,8 @@ "promise-retry": ["promise-retry@2.0.1", "", { "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" } }, "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g=="], + "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], + "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], @@ -2843,7 +2917,7 @@ "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], - "rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], + "rimraf": ["rimraf@6.1.3", "", { "dependencies": { "glob": "^13.0.3", "package-json-from-dist": "^1.0.1" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA=="], "roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="], @@ -2885,6 +2959,8 @@ "serialize-javascript": ["serialize-javascript@6.0.2", "", { "dependencies": { "randombytes": "^2.1.0" } }, "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g=="], + "serve-sim": ["serve-sim@0.1.43", "", { "dependencies": { "inspect-webkit": "^0.0.5", "ws": "^8.21.0" }, "bin": { "serve-sim": "dist/serve-sim.js" } }, "sha512-kLcWWucVZxPD2+73EAhku6iThATYXUTYlt8M4+sw1ZHZYkfFNhqCuhy8g+Z5JHCNUTf93t2qwCHPOPqvbYKRrw=="], + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], @@ -2905,6 +2981,20 @@ "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], + "sherpa-onnx-darwin-arm64": ["sherpa-onnx-darwin-arm64@1.13.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9x86Cbf+BDFONdtCPM3cnjvtAW0ER8tMaHK5pVfz+SHPt8GeuwRXaiR/BzcByFBUyxCgmceO09/WMZOCi44P/g=="], + + "sherpa-onnx-darwin-x64": ["sherpa-onnx-darwin-x64@1.13.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-TVQ35g7JIpDPB1lUDdcog+JtI0cI45ZzOnvHXm0DtWs/dgxnJXtWMY3uLRtBbLnysV9j5ljffwZ1IX9VDHsCzQ=="], + + "sherpa-onnx-linux-arm64": ["sherpa-onnx-linux-arm64@1.13.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-uDtZkkoP6QQ/3DHOscCpEZ2WpaiHUQsDpbyYaHURrJ7DbsjqGnS6G8l+R589Ro5Bf282QElzBy3okwxXbt3Kxw=="], + + "sherpa-onnx-linux-x64": ["sherpa-onnx-linux-x64@1.13.3", "", { "os": "linux", "cpu": "x64" }, "sha512-OFVK0GYwKwKNsjxbPmfcLQm/dfA0IwAoiIQJ96s+eFYcDqhlapcY06ocdb7SNluGBcM7xgU5jEW2QXBkMIOEvQ=="], + + "sherpa-onnx-node": ["sherpa-onnx-node@1.12.28", "", { "optionalDependencies": { "sherpa-onnx-darwin-arm64": "^1.12.28", "sherpa-onnx-darwin-x64": "^1.12.28", "sherpa-onnx-linux-arm64": "^1.12.28", "sherpa-onnx-linux-x64": "^1.12.28", "sherpa-onnx-win-ia32": "^1.12.28", "sherpa-onnx-win-x64": "^1.12.28" } }, "sha512-EHSB3EG6hKyXaTNh6GU/bwh6i3dncCH6ZCU2mScNzkxRbVStZ7QmNj0Oo4E9XrGGo9jX9pKg9MPHEPyjdK+ApA=="], + + "sherpa-onnx-win-ia32": ["sherpa-onnx-win-ia32@1.13.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-VDZh1M7Ccx/bkP3WwBCFoJzwAwq+b5nR1KRkYRz5p1w5bfhzfa3ACBGr7vpUt5AGUge4qSLe0MSKXyKtSmy1uA=="], + + "sherpa-onnx-win-x64": ["sherpa-onnx-win-x64@1.13.3", "", { "os": "win32", "cpu": "x64" }, "sha512-ZQzcSmFvZK4jzmtWckqxocDUuEjYnBV2MHrDD21HPTeUMfGdE9yfvuSPpesIVfdzKbQzIQY42RAcfZEGWu0FbQ=="], + "shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="], "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], @@ -2967,6 +3057,8 @@ "spdx-license-ids": ["spdx-license-ids@3.0.23", "", {}, "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw=="], + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], "ssri": ["ssri@9.0.1", "", { "dependencies": { "minipass": "^3.1.1" } }, "sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q=="], @@ -3065,6 +3157,8 @@ "textextensions": ["textextensions@6.11.0", "", { "dependencies": { "editions": "^6.21.0" } }, "sha512-tXJwSr9355kFJI3lbCkPpUH5cP8/M0GGy2xLO34aZCjMXBaK3SoPnZwr/oWmo1FdCnELcs4npdCIOFtq9W3ruQ=="], + "through2": ["through2@4.0.2", "", { "dependencies": { "readable-stream": "3" } }, "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw=="], + "tiny-async-pool": ["tiny-async-pool@1.3.0", "", { "dependencies": { "semver": "^5.5.0" } }, "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA=="], "tiny-typed-emitter": ["tiny-typed-emitter@2.1.0", "", {}, "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA=="], @@ -3181,6 +3275,8 @@ "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + "untildify": ["untildify@4.0.0", "", {}, "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw=="], + "unused-filename": ["unused-filename@4.0.1", "", { "dependencies": { "escape-string-regexp": "^5.0.0", "path-exists": "^5.0.0" } }, "sha512-ZX6U1J04K1FoSUeoX1OicAhw4d0aro2qo+L8RhJkiGTNtBNkd/Fi1Wxoc9HzcVu6HfOzm0si/N15JjxFmD1z6A=="], "upath": ["upath@1.2.0", "", {}, "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg=="], @@ -3297,13 +3393,13 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], - "xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="], + "xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="], - "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + "xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], @@ -3345,6 +3441,14 @@ "@babel/preset-env/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@capacitor/cli/fs-extra": ["fs-extra@11.3.3", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg=="], + + "@capacitor/cli/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], + + "@capacitor/cli/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "@capacitor/cli/tar": ["tar@7.5.13", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng=="], + "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], "@electron/asar/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], @@ -3375,6 +3479,12 @@ "@heroui/theme/tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="], + "@ionic/utils-fs/@types/fs-extra": ["@types/fs-extra@8.1.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ=="], + + "@ionic/utils-fs/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], + + "@ionic/utils-terminal/slice-ansi": ["slice-ansi@4.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ=="], + "@isaacs/fs-minipass/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], "@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], @@ -3383,6 +3493,8 @@ "@npmcli/agent/socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="], + "@npmcli/move-file/rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], + "@openchamber/web/cron-parser": ["cron-parser@4.9.0", "", { "dependencies": { "luxon": "^3.2.1" } }, "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q=="], "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], @@ -3437,15 +3549,13 @@ "@textlint/linter-formatter/pluralize": ["pluralize@2.0.0", "", {}, "sha512-TqNZzQCD4S42De9IfnnBvILN7HAW7riLqsCyp8lgjXeysyPlX5HhqKAcJHHHb9XskE4/a+7VGC9zzx8Ls0jOAw=="], - "@types/plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], - "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - "@vscode/vsce/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + "@vscode/vsce/xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="], "@xenova/transformers/sharp": ["sharp@0.32.6", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.2", "node-addon-api": "^6.1.0", "prebuild-install": "^7.1.1", "semver": "^7.5.4", "simple-get": "^4.0.1", "tar-fs": "^3.0.4", "tunnel-agent": "^0.6.0" } }, "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w=="], @@ -3475,6 +3585,8 @@ "cacache/p-map": ["p-map@4.0.0", "", { "dependencies": { "aggregate-error": "^3.0.0" } }, "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ=="], + "cacache/rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], + "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], @@ -3499,6 +3611,8 @@ "electron-winstaller/fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="], + "elementtree/sax": ["sax@1.1.4", "", {}, "sha512-5f3k2PbGGp+YtKJjOItpg3P99IMD84E4HOvcfleTb5joCHNXYLsR9yWFPOYGgaeMPDubQILTCMdsFb2OMeOjtg=="], + "encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "encoding-sniffer/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], @@ -3539,6 +3653,8 @@ "is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], + "keytar/node-addon-api": ["node-addon-api@4.3.0", "", {}, "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ=="], "make-fetch-happen/http-proxy-agent": ["http-proxy-agent@5.0.0", "", { "dependencies": { "@tootallnate/once": "2", "agent-base": "6", "debug": "4" } }, "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w=="], @@ -3601,14 +3717,16 @@ "path-scurry/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], - "plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], - "postject/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="], "prebuild-install/tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], + "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + "qrcode/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], + "rc/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + "rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], "react-syntax-highlighter/@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="], @@ -3623,7 +3741,7 @@ "rehype-katex/katex": ["katex@0.16.45", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA=="], - "rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "rimraf/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], @@ -3685,10 +3803,22 @@ "wsl-utils/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], + "xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + "@apideck/better-ajv-errors/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "@azure/identity/open/define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], + "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "@capacitor/cli/tar/chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + + "@capacitor/cli/tar/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "@capacitor/cli/tar/minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], + + "@capacitor/cli/tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + "@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], "@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], @@ -3697,6 +3827,8 @@ "@electron/universal/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "@npmcli/move-file/rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "@rollup/plugin-node-resolve/@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "@secretlint/config-loader/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], @@ -3705,6 +3837,8 @@ "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], + "@vscode/vsce/xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + "@xenova/transformers/sharp/node-addon-api": ["node-addon-api@6.1.0", "", {}, "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA=="], "app-builder-lib/@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], @@ -3727,6 +3861,8 @@ "cacache/glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], + "cacache/rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "cli-truncate/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "cli-truncate/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], @@ -3767,6 +3903,8 @@ "mdast-util-mdx-jsx/parse-entities/is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], + "micromark-extension-math/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], + "node-gyp/make-fetch-happen/cacache": ["cacache@19.0.1", "", { "dependencies": { "@npmcli/fs": "^4.0.0", "fs-minipass": "^3.0.0", "glob": "^10.2.2", "lru-cache": "^10.0.1", "minipass": "^7.0.3", "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "p-map": "^7.0.2", "ssri": "^12.0.0", "tar": "^7.4.3", "unique-filename": "^4.0.0" } }, "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ=="], "node-gyp/make-fetch-happen/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], @@ -3809,6 +3947,12 @@ "qrcode/yargs/yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="], + "rehype-katex/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], + + "rimraf/glob/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], + + "rimraf/glob/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + "source-map/whatwg-url/tr46": ["tr46@1.0.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA=="], "source-map/whatwg-url/webidl-conversions": ["webidl-conversions@4.0.2", "", {}, "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="], @@ -3961,6 +4105,8 @@ "qrcode/yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + "rimraf/glob/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], + "node-gyp/make-fetch-happen/cacache/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], "node-gyp/make-fetch-happen/cacache/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], @@ -3971,6 +4117,8 @@ "qrcode/yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + "rimraf/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "node-gyp/make-fetch-happen/cacache/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], "node-gyp/make-fetch-happen/cacache/glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], diff --git a/package.json b/package.json index 3c10dabec2..7946b91459 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openchamber-monorepo", - "version": "1.13.7", + "version": "1.14.0", "description": "OpenChamber monorepo workspace for web, ui, and desktop runtimes", "private": true, "type": "module", @@ -22,18 +22,22 @@ "license": "MIT", "scripts": { "dev": "node ./scripts/dev-web-hmr.mjs", + "oc-dev": "node scripts/oc-dev.mjs", "build": "bun run --filter '*' build", "build:web": "bun run --cwd packages/web build", "build:ui": "bun run --cwd packages/ui build", "build:electron": "bun run --cwd packages/electron build", + "build:mobile": "bun run --cwd packages/mobile build", "type-check": "bun run --filter '*' type-check", "type-check:web": "bun run --cwd packages/web type-check", "type-check:ui": "bun run --cwd packages/ui type-check", "type-check:electron": "bun run --cwd packages/electron type-check", + "type-check:mobile": "bun run --cwd packages/mobile type-check", "lint": "bun run --filter '*' lint", "lint:web": "bun run --cwd packages/web lint", "lint:ui": "bun run --cwd packages/ui lint", "lint:electron": "bun run --cwd packages/electron lint", + "lint:mobile": "bun run --cwd packages/mobile lint", "clean": "bun run --filter '*' clean", "changelog-card": "node scripts/changelog-card/generate.mjs", "postinstall": "node ./fix-deprecation.js && patch-package", @@ -46,6 +50,21 @@ "electron:dev": "node ./packages/electron/scripts/electron-dev.mjs", "electron:dev:bundled": "OPENCHAMBER_ELECTRON_USE_BUNDLED_UI=1 node ./packages/electron/scripts/electron-dev.mjs", "electron:build": "bun run --cwd packages/electron package", + "mobile:build": "bun run --cwd packages/mobile build", + "mobile:sync": "bun run --cwd packages/mobile sync", + "mobile:add:ios": "bun run --cwd packages/mobile add:ios", + "mobile:add:android": "bun run --cwd packages/mobile add:android", + "mobile:build:android:debug": "bun run --cwd packages/mobile build:android:debug", + "mobile:build:ios:simulator": "bun run --cwd packages/mobile build:ios:simulator", + "mobile:sim:boot": "bun run --cwd packages/mobile sim:boot", + "mobile:sim:install": "bun run --cwd packages/mobile sim:install", + "mobile:sim:launch": "bun run --cwd packages/mobile sim:launch", + "mobile:sim:run": "bun run --cwd packages/mobile sim:run", + "mobile:sim:serve": "bun run --cwd packages/mobile sim:serve", + "mobile:sim:list": "bun run --cwd packages/mobile sim:list", + "mobile:sim:kill": "bun run --cwd packages/mobile sim:kill", + "mobile:open:ios": "bun run --cwd packages/mobile open:ios", + "mobile:open:android": "bun run --cwd packages/mobile open:android", "vscode:dev": "node ./scripts/dev-vscode.mjs", "vscode:build": "bun run --cwd packages/vscode build", "vscode:package": "bun run --cwd packages/vscode package", @@ -83,15 +102,12 @@ "@codemirror/search": "^6.6.0", "@codemirror/state": "^6.5.4", "@codemirror/view": "6.39.13", - "@fontsource/ibm-plex-mono": "^5.2.7", - "@fontsource/ibm-plex-sans": "^5.1.1", "@heroui/scroll-shadow": "^2.3.18", "@heroui/system": "^2.4.23", "@heroui/theme": "^2.4.23", - "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.17.9", + "@opencode-ai/sdk": "1.17.12", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -130,6 +146,7 @@ "@codemirror/view": "6.39.13" }, "devDependencies": { + "@clack/prompts": "^1.1.0", "@eslint/js": "^9.33.0", "@tailwindcss/postcss": "^4.0.0", "@types/dom-speech-recognition": "^0.0.12", @@ -157,5 +174,8 @@ "typescript": "~5.9.0", "typescript-eslint": "^8.39.1", "vite": "^7.1.2" + }, + "patchedDependencies": { + "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch" } } diff --git a/packages/electron/.gitignore b/packages/electron/.gitignore index 2f8dd8d380..9827bb507f 100644 --- a/packages/electron/.gitignore +++ b/packages/electron/.gitignore @@ -8,6 +8,9 @@ dist-bundle/ # Generated packaging resources resources/web-dist/ resources/sidecar/ +resources/opencode-cli/* +!resources/opencode-cli/.gitkeep +.cache/ # OS-specific .DS_Store diff --git a/packages/electron/README.md b/packages/electron/README.md index d722c87805..5939a916e7 100644 --- a/packages/electron/README.md +++ b/packages/electron/README.md @@ -21,6 +21,7 @@ The preload bridge exposes desktop-only APIs to the web UI through `window.__OPE | `ssh-manager.mjs` | SSH host import, connection lifecycle, tunnel/port forwarding helpers | | `scripts/electron-dev.mjs` | Desktop dev launcher with Vite HMR support | | `scripts/build-web-assets.mjs` | Builds `packages/web` and stages UI assets into `resources/web-dist` | +| `scripts/prepare-opencode-cli.mjs` | Downloads and stages the pinned OpenCode CLI into `resources/opencode-cli` | | `scripts/bundle-main.mjs` | Bundles Electron main code into `dist-bundle/main.mjs` for packaging | | `scripts/rebuild-native.mjs` | Rebuilds native modules against the Electron runtime | | `scripts/package.mjs` | Runs `electron-builder`, with unsigned Windows builds when signing env is missing | @@ -58,9 +59,10 @@ bun run electron:build That runs, in order: 1. `build:web-assets` to build the web UI and copy it into `packages/electron/resources/web-dist`. -2. `bundle:main` to create `packages/electron/dist-bundle/main.mjs`. -3. `rebuild:native` to rebuild native modules for Electron. -4. `package.mjs` to run `electron-builder`. +2. `prepare:opencode-cli` to download/cache the pinned OpenCode CLI and copy it into `packages/electron/resources/opencode-cli`. +3. `bundle:main` to create `packages/electron/dist-bundle/main.mjs`. +4. `rebuild:native` to rebuild native modules for Electron. +5. `package.mjs` to run `electron-builder`. Build output goes to `packages/electron/dist`. @@ -74,6 +76,18 @@ Windows packaging needs NSIS support through `electron-builder`. If no Windows s The package supports macOS and Windows desktop features. Some native discovery helpers are platform-specific. For example, app icon fetching and app filtering currently only work on macOS, while opening files in installed apps works on macOS and Windows. +## Bundled OpenCode CLI + +Packaged Desktop builds include the official OpenCode CLI that matches the pinned `@opencode-ai/sdk` version in the root `package.json`. `prepare:opencode-cli` downloads the platform-specific release artifact, caches it under `packages/electron/.cache/opencode-cli`, stages `opencode` or `opencode.exe` into `resources/opencode-cli`, and verifies `opencode --version` before packaging. Re-running the step is fast when the staged binary already matches the pinned version. + +Managed local Desktop startup prefers OpenCode binaries in this order: + +1. Explicit overrides: `settings.opencodeBinary`, `OPENCODE_BINARY`, `OPENCODE_PATH`, `OPENCHAMBER_OPENCODE_PATH`, or `OPENCHAMBER_OPENCODE_BIN`. +2. The bundled Desktop CLI in `process.resourcesPath/opencode-cli`. +3. System installs discovered from PATH and known npm/Bun/Scoop/Chocolatey locations. + +Use an explicit override when testing a different OpenCode CLI build or when a user needs to point Desktop at a custom binary. The configured path must point to the standalone CLI, not the OpenCode Desktop app executable. + ## Common Env Vars | Variable | Use | @@ -83,6 +97,7 @@ The package supports macOS and Windows desktop features. Some native discovery h | `OPENCHAMBER_HMR_UI_PORT` | Preferred Vite UI port for desktop dev, default `5173` | | `OPENCHAMBER_HMR_API_PORT` | Preferred API port for desktop dev, default `3901` | | `OPENCHAMBER_RUNTIME=desktop` | Set by Electron before starting the web server | +| `OPENCHAMBER_OPENCODE_CLI_VERSION` | Optional packaging override for the bundled OpenCode CLI version; defaults to the pinned root `@opencode-ai/sdk` version | | `OPENCHAMBER_DESKTOP_NOTIFY=true` | Enables desktop notification flow in the web server | | `OPENCHAMBER_SKIP_API_COMPRESSION=true` | Defaulted by Desktop to reduce local CPU overhead | | `OPENCODE_HOST` / `OPENCODE_PORT` / `OPENCODE_SKIP_START` | Connect Desktop to an external OpenCode server instead of starting one locally | diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index 27312ef4f0..3e97f541bb 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -1,4 +1,4 @@ -import { app, BrowserWindow, dialog, ipcMain, Menu, nativeTheme, net as electronNet, Notification, powerMonitor, protocol, screen, session, shell, webContents } from 'electron'; +import { app, BrowserWindow, dialog, ipcMain, Menu, nativeTheme, net as electronNet, Notification, powerMonitor, powerSaveBlocker, protocol, screen, session, shell, webContents } from 'electron'; import contextMenu from 'electron-context-menu'; import log from 'electron-log/main.js'; import dgram from 'node:dgram'; @@ -13,6 +13,7 @@ import updaterPkg from 'electron-updater'; import { ElectronSshManager } from './ssh-manager.mjs'; import { createTrayController } from './tray.mjs'; import { resolveManagedOpenCodeCwd } from './opencode-cwd.mjs'; +import { sanitizeRuntimeRequestHeaders } from './runtime-request-headers.mjs'; import { mintOutsideFileGrant } from '@openchamber/web/server/lib/fs/routes.js'; const execFileAsync = promisify(execFile); @@ -170,6 +171,7 @@ const state = { localOrigin: null, apiBaseUrl: null, clientToken: null, + requestHeaders: {}, bootOutcome: null, initScript: null, mainWindow: null, @@ -191,6 +193,32 @@ const state = { sshLogs: new Map(), trayController: null, lastFocusedWindowId: null, + keepAwakeBlockerId: null, +}; + +const setDesktopKeepAwakeActive = (enabled) => { + const currentId = state.keepAwakeBlockerId; + const isActive = Number.isInteger(currentId) && powerSaveBlocker.isStarted(currentId); + + if (enabled) { + if (!isActive) { + state.keepAwakeBlockerId = powerSaveBlocker.start('prevent-app-suspension'); + } + return Number.isInteger(state.keepAwakeBlockerId) && powerSaveBlocker.isStarted(state.keepAwakeBlockerId); + } + + if (isActive) { + powerSaveBlocker.stop(currentId); + } + state.keepAwakeBlockerId = null; + return false; +}; + +const readDesktopKeepAwakeStatus = () => { + const enabled = readSettingsRoot().desktopKeepAwakeEnabled === true; + const currentId = state.keepAwakeBlockerId; + const active = Number.isInteger(currentId) && powerSaveBlocker.isStarted(currentId); + return { supported: true, enabled, active }; }; const quitRisk = { @@ -226,6 +254,7 @@ const quitConfirmationMessage = () => { const shutdownBackgroundServices = () => { if (state.backgroundShutdownComplete) return; state.backgroundShutdownComplete = true; + setDesktopKeepAwakeActive(false); if (state.installingUpdate) return; killSidecar(); setImmediate(() => { @@ -269,6 +298,8 @@ const prepareForQuit = ({ installingUpdate = false } = {}) => { } } + setDesktopKeepAwakeActive(false); + if (installingUpdate) { state.backgroundShutdownComplete = true; return; @@ -495,19 +526,48 @@ const shouldUseSameOriginDevProxy = (uiUrl, apiBaseUrl) => ( const buildRendererRuntimeConfig = (uiUrl, runtimeConfig = {}) => { const apiBaseUrl = typeof runtimeConfig.apiBaseUrl === 'string' ? runtimeConfig.apiBaseUrl : (state.apiBaseUrl || ''); const clientToken = typeof runtimeConfig.clientToken === 'string' ? runtimeConfig.clientToken : (state.clientToken || ''); + const requestHeaders = sanitizeRuntimeRequestHeaders(runtimeConfig.requestHeaders || state.requestHeaders || {}); if (shouldUseSameOriginDevProxy(uiUrl, apiBaseUrl)) { - return { apiBaseUrl: '', clientToken: '' }; + return { apiBaseUrl: '', clientToken: '', requestHeaders: {} }; } - return { apiBaseUrl, clientToken }; + return { apiBaseUrl, clientToken, requestHeaders }; }; const readDesktopLocalClientToken = () => { return sanitizeClientTokenForStorage(readSettingsRoot().desktopLocalClientToken) || ''; }; +const isMachineLocalHostname = (hostname) => { + const clean = String(hostname || '').replace(/^\[|\]$/g, ''); + if (!clean) return false; + if (clean === 'localhost' || clean === '127.0.0.1' || clean === '::1' || clean === '0.0.0.0' || clean === '::') { + return true; + } + try { + return Object.values(os.networkInterfaces()).some((entries) => + (entries || []).some((entry) => entry?.address === clean)); + } catch { + return false; + } +}; + const isLocalRuntimeUrl = (targetUrl) => { const localUrl = state.sidecarUrl || state.localOrigin || ''; - return Boolean(localUrl && sameOrigin(targetUrl, localUrl)); + if (!localUrl) return false; + if (sameOrigin(targetUrl, localUrl)) return true; + // The embedded server bound to 0.0.0.0 for LAN access is still THIS + // machine's server when addressed via any of its own interfaces on the same + // port — the minted client token must carry the desktop-local kind, or the + // server's client-create gate rejects it (the "Local — Auth required" + + // unreachable-screen regression). + try { + const target = new URL(targetUrl); + const local = new URL(localUrl); + const portOf = (url) => url.port || (url.protocol === 'https:' ? '443' : '80'); + return portOf(target) === portOf(local) && isMachineLocalHostname(target.hostname); + } catch { + return false; + } }; const readDesktopHostsConfig = () => { @@ -520,8 +580,9 @@ const readDesktopHostsConfig = () => { if (!id || id === LOCAL_HOST_ID || !url) return null; const apiUrl = sanitizeHostUrlForStorage(entry?.apiUrl) || url; const clientToken = sanitizeClientTokenForStorage(entry?.clientToken); + const requestHeaders = sanitizeRuntimeRequestHeaders(entry?.requestHeaders); const label = typeof entry?.label === 'string' && entry.label.trim() ? entry.label.trim() : url; - return { id, label, url, apiUrl, ...(clientToken ? { clientToken } : {}) }; + return { id, label, url, apiUrl, ...(clientToken ? { clientToken } : {}), ...(Object.keys(requestHeaders).length > 0 ? { requestHeaders } : {}) }; }) .filter(Boolean); @@ -544,12 +605,14 @@ const writeDesktopHostsConfig = async (config) => { if (!id || id === LOCAL_HOST_ID || !url) return null; const apiUrl = sanitizeHostUrlForStorage(entry?.apiUrl) || url; const clientToken = sanitizeClientTokenForStorage(entry?.clientToken); + const requestHeaders = sanitizeRuntimeRequestHeaders(entry?.requestHeaders); return { id, label: typeof entry?.label === 'string' && entry.label.trim() ? entry.label.trim() : url, url, apiUrl, ...(clientToken ? { clientToken } : {}), + ...(Object.keys(requestHeaders).length > 0 ? { requestHeaders } : {}), }; }) .filter(Boolean) @@ -704,7 +767,7 @@ const fetchVersionPayload = async (versionUrl, { headers, timeoutMs }) => { } }; -const probeHostWithTimeout = async (url, timeoutMs, clientToken = '') => { +const probeHostWithTimeout = async (url, timeoutMs, clientToken = '', requestHeaders = {}) => { const versionUrl = buildVersionUrl(url); if (!versionUrl) { throw new Error('Invalid URL'); @@ -712,7 +775,7 @@ const probeHostWithTimeout = async (url, timeoutMs, clientToken = '') => { const started = Date.now(); try { - const headers = { Accept: 'application/json' }; + const headers = { ...sanitizeRuntimeRequestHeaders(requestHeaders), Accept: 'application/json' }; const token = typeof clientToken === 'string' ? clientToken.trim() : ''; if (token) { headers.Authorization = `Bearer ${token}`; @@ -1135,6 +1198,7 @@ const spawnLocalServer = async () => { // so phones/tablets on the same Wi-Fi can reach the app. UI shows a clear // warning and persists the flag via /api/config/settings. const lanAccessEnabled = settings.desktopLanAccessEnabled === true; + setDesktopKeepAwakeActive(settings.desktopKeepAwakeEnabled === true); const desktopUiPassword = typeof settings.desktopUiPassword === 'string' ? settings.desktopUiPassword.trim() : ''; const lanAccessBlockedByMissingPassword = lanAccessEnabled && !desktopUiPassword; const effectiveLanAccessEnabled = lanAccessEnabled && !lanAccessBlockedByMissingPassword; @@ -1198,6 +1262,10 @@ const spawnLocalServer = async () => { apiOnly: false, onDesktopNotification: (payload) => maybeShowNativeNotification(payload), getIsWindowFocused: isAnyWindowFocused, + getDesktopRuntimeConfig: () => ({ + apiBaseUrl: state.apiBaseUrl || '', + requestHeaders: sanitizeRuntimeRequestHeaders(state.requestHeaders || {}), + }), }); const port = handle.getPort(); @@ -1317,17 +1385,18 @@ const macosMajorVersion = () => { return major === 10 ? minor : major; }; -const buildInitScript = (localOrigin, bootOutcome, apiBaseUrl = '', clientToken = '') => { +const buildInitScript = (localOrigin, bootOutcome, apiBaseUrl = '', clientToken = '', requestHeaders = {}) => { const home = JSON.stringify(os.homedir() || ''); const local = JSON.stringify(localOrigin || ''); const apiBase = JSON.stringify(apiBaseUrl || ''); const token = JSON.stringify(clientToken || ''); + const headers = JSON.stringify(sanitizeRuntimeRequestHeaders(requestHeaders)); const packagedOrigin = JSON.stringify(packagedUiOrigin()); const macVersion = macosMajorVersion(); const outcome = JSON.stringify(bootOutcome ?? null); return [ '(function(){', - `try{var __oc_local=${local};var __oc_api=${apiBase};var __oc_packaged=${packagedOrigin};var __oc_origin=window.location&&window.location.origin||'';var __oc_is_packaged=__oc_origin===__oc_packaged;var __oc_is_local=__oc_local&&__oc_origin===new URL(__oc_local).origin;window.__OPENCHAMBER_MACOS_MAJOR__=${macVersion};window.__OPENCHAMBER_LOCAL_ORIGIN__=__oc_local;window.__OPENCHAMBER_API_BASE_URL__=__oc_api;if(__oc_is_local||__oc_is_packaged){window.__OPENCHAMBER_HOME__=${home};}if((__oc_is_local||__oc_is_packaged)&&${token}){window.__OPENCHAMBER_CLIENT_TOKEN__=${token};}var __oc_bo=${outcome};if(__oc_bo){window.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__=__oc_bo;}}catch(_e){}`, + `try{var __oc_local=${local};var __oc_api=${apiBase};var __oc_headers=${headers};var __oc_packaged=${packagedOrigin};var __oc_origin=window.location&&window.location.origin||'';var __oc_is_packaged=__oc_origin===__oc_packaged;var __oc_is_local=__oc_local&&__oc_origin===new URL(__oc_local).origin;window.__OPENCHAMBER_MACOS_MAJOR__=${macVersion};window.__OPENCHAMBER_LOCAL_ORIGIN__=__oc_local;window.__OPENCHAMBER_API_BASE_URL__=__oc_api;if(__oc_is_local||__oc_is_packaged){window.__OPENCHAMBER_HOME__=${home};window.__OPENCHAMBER_RUNTIME_HEADERS__=__oc_headers;}if((__oc_is_local||__oc_is_packaged)&&${token}){window.__OPENCHAMBER_CLIENT_TOKEN__=${token};}var __oc_bo=${outcome};if(__oc_bo){window.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__=__oc_bo;}}catch(_e){}`, '}())', ].join(''); }; @@ -1393,7 +1462,7 @@ const buildStartupSplashHtml = () => { } body { margin: 0; - font-family: "IBM Plex Sans", sans-serif; + font-family: "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; display: grid; place-items: center; height: 100vh; @@ -1506,9 +1575,10 @@ const extractCookieHeader = (response) => { .join('; '); }; -const loginRemoteAndIssueClientToken = async ({ url, password, trustDevice }) => { +const loginRemoteAndIssueClientToken = async ({ url, password, trustDevice, requestHeaders }) => { const baseUrl = normalizeHostUrl(String(url || '')); const candidatePassword = typeof password === 'string' ? password : ''; + const safeRequestHeaders = sanitizeRuntimeRequestHeaders(requestHeaders || {}); if (!baseUrl) throw new Error('Invalid URL'); if (!candidatePassword) throw new Error('Password is required'); @@ -1516,6 +1586,7 @@ const loginRemoteAndIssueClientToken = async ({ url, password, trustDevice }) => method: 'POST', signal: AbortSignal.timeout(10_000), headers: { + ...safeRequestHeaders, Accept: 'application/json', 'Content-Type': 'application/json', }, @@ -1548,6 +1619,7 @@ const loginRemoteAndIssueClientToken = async ({ url, password, trustDevice }) => method: 'POST', signal: AbortSignal.timeout(10_000), headers: { + ...safeRequestHeaders, Accept: 'application/json', 'Content-Type': 'application/json', Cookie: cookie, @@ -1694,10 +1766,12 @@ const switchToHostById = async (rawId) => { let targetUrl = null; let apiBaseUrl = null; let clientToken = ''; + let requestHeaders = {}; if (id === LOCAL_HOST_ID) { targetUrl = shouldUsePackagedUi() ? buildPackagedUiUrl('/index.html') : (state.sidecarUrl || state.localOrigin); apiBaseUrl = state.sidecarUrl; clientToken = readDesktopLocalClientToken(); + requestHeaders = {}; } else { const host = config.hosts.find((entry) => entry.id === id); if (!host) { @@ -1707,6 +1781,7 @@ const switchToHostById = async (rawId) => { targetUrl = shouldUsePackagedUi() ? buildPackagedUiUrl('/index.html') : host.url; apiBaseUrl = host.apiUrl || host.url; clientToken = host.clientToken || ''; + requestHeaders = sanitizeRuntimeRequestHeaders(host.requestHeaders || {}); } if (!targetUrl || !apiBaseUrl) { log.warn('[electron] deep-link host has no target URL:', id); @@ -1716,7 +1791,7 @@ const switchToHostById = async (rawId) => { ? { target: 'local', status: 'ok' } : { target: 'remote', status: 'ok', hostId: id, url: apiBaseUrl }; log.info('[electron] switching to host', { id, bootOutcome }); - await activateMainWindow(targetUrl, state.localOrigin, bootOutcome, { apiBaseUrl, clientToken }); + await activateMainWindow(targetUrl, state.localOrigin, bootOutcome, { apiBaseUrl, clientToken, requestHeaders }); }; const confirmConnectDeepLink = async (payload) => { @@ -1913,6 +1988,7 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} } const rendererRuntimeConfig = buildRendererRuntimeConfig(url, runtimeConfig); const desktopApiBaseUrl = rendererRuntimeConfig.apiBaseUrl; const desktopClientToken = rendererRuntimeConfig.clientToken; + const desktopRequestHeaders = rendererRuntimeConfig.requestHeaders || {}; const desktopHome = os.homedir() || ''; const desktopMacosMajor = String(macosMajorVersion()); const usesCustomTitleBar = process.platform === 'darwin' || process.platform === 'win32'; @@ -1949,6 +2025,7 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} } `--openchamber-local-origin=${desktopLocalOrigin}`, `--openchamber-api-base-url=${desktopApiBaseUrl}`, `--openchamber-client-token=${desktopClientToken}`, + `--openchamber-runtime-headers=${JSON.stringify(desktopRequestHeaders)}`, `--openchamber-home=${desktopHome}`, `--openchamber-macos-major=${desktopMacosMajor}`, `--openchamber-mac-vibrancy=${useVibrancy ? '1' : '0'}`, @@ -1969,8 +2046,8 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} } const browserWindow = new BrowserWindow(options); browserWindow.__ocLabel = label || nextWindowLabel(); - browserWindow.__ocRuntimeConfig = { apiBaseUrl: desktopApiBaseUrl, clientToken: desktopClientToken }; - browserWindow.__ocInitScript = buildInitScript(desktopLocalOrigin, state.bootOutcome, desktopApiBaseUrl, desktopClientToken); + browserWindow.__ocRuntimeConfig = { apiBaseUrl: desktopApiBaseUrl, clientToken: desktopClientToken, requestHeaders: desktopRequestHeaders }; + browserWindow.__ocInitScript = buildInitScript(desktopLocalOrigin, state.bootOutcome, desktopApiBaseUrl, desktopClientToken, desktopRequestHeaders); browserWindow.__ocTitleBarOverlayEnabled = titleBarOverlayEnabled; if (useSaved && saved.maximized) { @@ -2156,16 +2233,19 @@ const activateMainWindow = async (url, localOrigin, bootOutcome, runtimeConfig = state.localOrigin = localOrigin; state.apiBaseUrl = typeof runtimeConfig.apiBaseUrl === 'string' ? runtimeConfig.apiBaseUrl : state.apiBaseUrl; state.clientToken = typeof runtimeConfig.clientToken === 'string' ? runtimeConfig.clientToken : ''; + state.requestHeaders = sanitizeRuntimeRequestHeaders(runtimeConfig.requestHeaders || {}); state.bootOutcome = bootOutcome ?? null; const rendererRuntimeConfig = buildRendererRuntimeConfig(url, { apiBaseUrl: state.apiBaseUrl || '', clientToken: state.clientToken || '', + requestHeaders: state.requestHeaders || {}, }); state.initScript = buildInitScript( localOrigin, state.bootOutcome, rendererRuntimeConfig.apiBaseUrl, rendererRuntimeConfig.clientToken, + rendererRuntimeConfig.requestHeaders, ); const mainWindow = state.mainWindow; @@ -2189,8 +2269,8 @@ const activateMainWindow = async (url, localOrigin, bootOutcome, runtimeConfig = const openMainWindow = async () => { if (!state.localOrigin) { - const { initialUrl, localOrigin, bootOutcome, apiBaseUrl, clientToken } = await resolveInitialUrl(); - return activateMainWindow(initialUrl, localOrigin, bootOutcome, { apiBaseUrl, clientToken }); + const { initialUrl, localOrigin, bootOutcome, apiBaseUrl, clientToken, requestHeaders } = await resolveInitialUrl(); + return activateMainWindow(initialUrl, localOrigin, bootOutcome, { apiBaseUrl, clientToken, requestHeaders }); } const config = readDesktopHostsConfig(); @@ -2200,10 +2280,11 @@ const openMainWindow = async () => { : null; const apiBaseUrl = host?.apiUrl || host?.url || state.sidecarUrl || state.apiBaseUrl || ''; const clientToken = host?.clientToken || resolveStoredClientTokenForUrl(apiBaseUrl, config) || state.clientToken || ''; + const requestHeaders = sanitizeRuntimeRequestHeaders(host?.requestHeaders || {}); const targetUrl = host?.url && apiBaseUrl && !state.unreachableHosts.has(apiBaseUrl) ? (shouldUsePackagedUi() ? buildPackagedUiUrl('/index.html') : host.url) : localUiUrl; - return activateMainWindow(targetUrl, state.localOrigin, state.bootOutcome, { apiBaseUrl, clientToken }); + return activateMainWindow(targetUrl, state.localOrigin, state.bootOutcome, { apiBaseUrl, clientToken, requestHeaders }); }; const createAdditionalWindow = async (url, runtimeConfig = {}) => { @@ -2242,12 +2323,14 @@ const getWindowRuntimeConfig = (browserWindow) => { const fallback = { apiBaseUrl: state.apiBaseUrl || state.localOrigin || state.sidecarUrl || '', clientToken: state.clientToken || '', + requestHeaders: state.requestHeaders || {}, }; if (!browserWindow || browserWindow.isDestroyed()) return fallback; const config = browserWindow.__ocRuntimeConfig; return { apiBaseUrl: typeof config?.apiBaseUrl === 'string' ? config.apiBaseUrl : fallback.apiBaseUrl, clientToken: typeof config?.clientToken === 'string' ? config.clientToken : fallback.clientToken, + requestHeaders: sanitizeRuntimeRequestHeaders(config?.requestHeaders || fallback.requestHeaders), }; }; @@ -2255,6 +2338,7 @@ const createMiniChatWindow = async ({ mode, sessionId = '', directory = '', proj const effectiveRuntimeConfig = { apiBaseUrl: normalizeHostUrl(runtimeConfig.apiBaseUrl || state.apiBaseUrl || state.localOrigin || state.sidecarUrl || ''), clientToken: sanitizeClientTokenForStorage(runtimeConfig.clientToken || state.clientToken || ''), + requestHeaders: sanitizeRuntimeRequestHeaders(runtimeConfig.requestHeaders || state.requestHeaders || {}), }; const sessionWindowKey = mode === 'session' && sessionId ? miniChatSessionWindowKey(effectiveRuntimeConfig, sessionId) : ''; if (mode === 'session' && sessionId) { @@ -2271,6 +2355,7 @@ const createMiniChatWindow = async ({ mode, sessionId = '', directory = '', proj const desktopLocalOrigin = state.localOrigin || ''; const desktopApiBaseUrl = effectiveRuntimeConfig.apiBaseUrl || ''; const desktopClientToken = effectiveRuntimeConfig.clientToken || ''; + const desktopRequestHeaders = effectiveRuntimeConfig.requestHeaders || {}; const desktopHome = os.homedir() || ''; const desktopMacosMajor = String(macosMajorVersion()); // macOS vibrancy, on by default; users can disable it (Appearance settings). @@ -2297,6 +2382,7 @@ const createMiniChatWindow = async ({ mode, sessionId = '', directory = '', proj `--openchamber-local-origin=${desktopLocalOrigin}`, `--openchamber-api-base-url=${desktopApiBaseUrl}`, `--openchamber-client-token=${desktopClientToken}`, + `--openchamber-runtime-headers=${JSON.stringify(desktopRequestHeaders)}`, `--openchamber-home=${desktopHome}`, `--openchamber-macos-major=${desktopMacosMajor}`, ], @@ -2311,7 +2397,7 @@ const createMiniChatWindow = async ({ mode, sessionId = '', directory = '', proj }); browserWindow.__ocLabel = nextWindowLabel(); browserWindow.__ocRuntimeConfig = effectiveRuntimeConfig; - browserWindow.__ocInitScript = buildInitScript(desktopLocalOrigin, state.bootOutcome, desktopApiBaseUrl, desktopClientToken); + browserWindow.__ocInitScript = buildInitScript(desktopLocalOrigin, state.bootOutcome, desktopApiBaseUrl, desktopClientToken, desktopRequestHeaders); browserWindow.__ocMiniChat = true; browserWindow.__ocMiniChatSessionId = sessionWindowKey; browserWindow.__ocPinned = false; @@ -2402,9 +2488,11 @@ const resolveMiniChatRuntimeConfig = (browserWindow, args = {}) => { const providedToken = sanitizeClientTokenForStorage(args.clientToken); const storedToken = targetUrl ? resolveStoredClientTokenForUrl(targetUrl) : ''; const windowToken = targetUrl && sameOrigin(windowConfig.apiBaseUrl, targetUrl) ? windowConfig.clientToken : ''; + const windowHeaders = targetUrl && sameOrigin(windowConfig.apiBaseUrl, targetUrl) ? windowConfig.requestHeaders : {}; return { apiBaseUrl: targetUrl, clientToken: providedToken || windowToken || storedToken || '', + requestHeaders: sanitizeRuntimeRequestHeaders(args.requestHeaders || windowHeaders || {}), }; }; @@ -2430,6 +2518,7 @@ const resolveInitialUrl = async () => { let initialUrl = localUiUrl; let apiBaseUrl = localUrl; let clientToken = readDesktopLocalClientToken(); + let requestHeaders = {}; let remoteProbe = null; const envTarget = normalizeHostUrl(process.env.OPENCHAMBER_SERVER_URL || ''); @@ -2437,25 +2526,28 @@ const resolveInitialUrl = async () => { if (envTarget) { apiBaseUrl = envTarget; clientToken = ''; + requestHeaders = {}; initialUrl = shouldUsePackagedUi() ? localUiUrl : envTarget; } else if (config.defaultHostId && config.defaultHostId !== LOCAL_HOST_ID) { const host = config.hosts.find((entry) => entry.id === config.defaultHostId); if (host?.url) { apiBaseUrl = host.apiUrl || host.url; clientToken = host.clientToken || ''; + requestHeaders = sanitizeRuntimeRequestHeaders(host.requestHeaders || {}); initialUrl = shouldUsePackagedUi() ? localUiUrl : host.url; } } if (apiBaseUrl && apiBaseUrl !== localUrl) { - remoteProbe = await probeHostWithTimeout(apiBaseUrl, 2_000); + remoteProbe = await probeHostWithTimeout(apiBaseUrl, 2_000, clientToken, requestHeaders); if (remoteProbe.status === 'unreachable') { - remoteProbe = await probeHostWithTimeout(apiBaseUrl, 10_000); + remoteProbe = await probeHostWithTimeout(apiBaseUrl, 10_000, clientToken, requestHeaders); } if (remoteProbe.status === 'unreachable') { state.unreachableHosts.add(apiBaseUrl); apiBaseUrl = localUrl; clientToken = readDesktopLocalClientToken(); + requestHeaders = {}; initialUrl = localUiUrl; } } @@ -2467,7 +2559,7 @@ const resolveInitialUrl = async () => { localAvailable, }); - return { initialUrl, localOrigin, localUiUrl, bootOutcome, apiBaseUrl, clientToken }; + return { initialUrl, localOrigin, localUiUrl, bootOutcome, apiBaseUrl, clientToken, requestHeaders }; }; const compareSemver = (left, right) => { @@ -3132,6 +3224,19 @@ const handleInvoke = async (browserWindow, command, args = {}) => { return { supported: true, enabled: settings.openAtLogin === true }; } + case 'desktop_get_keep_awake': { + return readDesktopKeepAwakeStatus(); + } + + case 'desktop_set_keep_awake': { + const enabled = args.enabled === true; + await mutateSettingsRoot((root) => { + root.desktopKeepAwakeEnabled = enabled; + }); + const active = setDesktopKeepAwakeActive(enabled); + return { supported: true, enabled, active }; + } + case 'desktop_browser_capture_page': { const wcId = Number.isFinite(args.webContentsId) ? Math.trunc(args.webContentsId) : null; if (wcId === null || wcId < 0) throw new Error('webContentsId is required'); @@ -3459,7 +3564,7 @@ const handleInvoke = async (browserWindow, command, args = {}) => { config: updatedConfig, localAvailable: Boolean(state.sidecarUrl || state.localOrigin), }); - state.initScript = buildInitScript(state.localOrigin, state.bootOutcome, state.apiBaseUrl, state.clientToken); + state.initScript = buildInitScript(state.localOrigin, state.bootOutcome, state.apiBaseUrl, state.clientToken, state.requestHeaders || {}); log.info('[electron] hosts config updated, recomputed bootOutcome', state.bootOutcome); return null; } @@ -3468,13 +3573,14 @@ const handleInvoke = async (browserWindow, command, args = {}) => { return readDesktopLocalClientToken(); case 'desktop_host_probe': - return probeHostWithTimeout(String(args.url || ''), 2_000, String(args.clientToken || '')); + return probeHostWithTimeout(String(args.url || ''), 2_000, String(args.clientToken || ''), args.requestHeaders || {}); case 'desktop_remote_password_login': return loginRemoteAndIssueClientToken({ url: args.url, password: args.password, trustDevice: args.trustDevice === true, + requestHeaders: args.requestHeaders || {}, }); case 'desktop_set_window_theme': { @@ -3658,6 +3764,7 @@ const handleInvoke = async (browserWindow, command, args = {}) => { let runtimeConfig = { apiBaseUrl: state.sidecarUrl || state.localOrigin || '', clientToken: readDesktopLocalClientToken(), + requestHeaders: {}, }; if (config.defaultHostId && config.defaultHostId !== LOCAL_HOST_ID) { const host = config.hosts.find((entry) => entry.id === config.defaultHostId); @@ -3667,6 +3774,7 @@ const handleInvoke = async (browserWindow, command, args = {}) => { runtimeConfig = { apiBaseUrl: normalizeHostUrl(apiUrl), clientToken: sanitizeClientTokenForStorage(host.clientToken), + requestHeaders: sanitizeRuntimeRequestHeaders(host.requestHeaders), }; } } @@ -3682,8 +3790,9 @@ const handleInvoke = async (browserWindow, command, args = {}) => { const config = readDesktopHostsConfig(); const providedToken = typeof args.clientToken === 'string' ? args.clientToken : ''; const clientToken = sanitizeClientTokenForStorage(providedToken) || resolveStoredClientTokenForUrl(targetUrl, config); + const requestHeaders = sanitizeRuntimeRequestHeaders(args.requestHeaders || config.hosts.find((host) => normalizeHostUrl(host.apiUrl || host.url) === targetUrl)?.requestHeaders || {}); let windowUrl = targetUrl; - const runtimeConfig = { apiBaseUrl: targetUrl, clientToken }; + const runtimeConfig = { apiBaseUrl: targetUrl, clientToken, requestHeaders }; if (shouldUsePackagedUi()) { windowUrl = buildPackagedUiUrl('/index.html'); } @@ -4468,10 +4577,11 @@ app.whenReady().then(async () => { } if (isBackgroundStart) { - const { localOrigin, bootOutcome } = await resolveInitialUrl(); + const { localOrigin, bootOutcome, requestHeaders } = await resolveInitialUrl(); state.localOrigin = localOrigin; state.bootOutcome = bootOutcome ?? null; - state.initScript = buildInitScript(localOrigin, state.bootOutcome); + state.requestHeaders = sanitizeRuntimeRequestHeaders(requestHeaders || {}); + state.initScript = buildInitScript(localOrigin, state.bootOutcome, '', '', state.requestHeaders); log.info('[electron] started in background without window'); return; } @@ -4485,8 +4595,8 @@ app.whenReady().then(async () => { const initial = extractInitialDeepLinks(); if (initial.length > 0) handleDeepLinks(initial); - const { initialUrl, localOrigin, bootOutcome, apiBaseUrl, clientToken } = await resolveInitialUrl(); - await activateMainWindow(initialUrl, localOrigin, bootOutcome, { apiBaseUrl, clientToken }); + const { initialUrl, localOrigin, bootOutcome, apiBaseUrl, clientToken, requestHeaders } = await resolveInitialUrl(); + await activateMainWindow(initialUrl, localOrigin, bootOutcome, { apiBaseUrl, clientToken, requestHeaders }); // Notify renderer on OS wake-from-sleep so the SSE event pipeline can // reconnect immediately instead of waiting for the heartbeat watchdog. diff --git a/packages/electron/package.json b/packages/electron/package.json index bf44e0139a..ce9df05346 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -1,6 +1,6 @@ { "name": "@openchamber/electron", - "version": "1.13.7", + "version": "1.14.0", "private": true, "description": "Electron desktop runtime for OpenChamber", "author": "OpenChamber", @@ -27,10 +27,13 @@ "dev": "node ./scripts/electron-dev.mjs", "build:web-assets": "node ./scripts/build-web-assets.mjs", "build": "bun -e \"process.exit(0)\"", + "prepare:opencode-cli": "node ./scripts/prepare-opencode-cli.mjs", + "verify:opencode-cli": "node ./scripts/verify-opencode-cli.mjs --staged", + "verify:opencode-cli:packaged": "node ./scripts/verify-opencode-cli.mjs --packaged", "bundle:main": "bun ./scripts/bundle-main.mjs", "generate:macos-icon": "node ./scripts/generate-macos-icon-assets.cjs", "rebuild:native": "node ./scripts/rebuild-native.mjs", - "package": "bun run build:web-assets && bun run bundle:main && bun run rebuild:native && node ./scripts/package.mjs", + "package": "bun run build:web-assets && bun run prepare:opencode-cli && bun run bundle:main && bun run rebuild:native && node ./scripts/package.mjs", "finalize:latest-yml": "node ./scripts/finalize-latest-yml.mjs", "type-check": "node --check ./main.mjs && node --check ./preload.mjs", "lint": "node -e \"process.exit(0)\"" @@ -54,6 +57,10 @@ { "from": "resources/icons/tray", "to": "icons/tray" + }, + { + "from": "resources/opencode-cli", + "to": "opencode-cli" } ], "afterPack": "scripts/after-pack.cjs", diff --git a/packages/electron/preload.mjs b/packages/electron/preload.mjs index bddbe344b1..6f7c792317 100644 --- a/packages/electron/preload.mjs +++ b/packages/electron/preload.mjs @@ -14,6 +14,7 @@ const readArgValue = (name) => { const localOrigin = readArgValue('--openchamber-local-origin'); const apiBaseUrl = readArgValue('--openchamber-api-base-url'); const clientToken = readArgValue('--openchamber-client-token'); +const runtimeHeadersRaw = readArgValue('--openchamber-runtime-headers'); const homeDirectory = readArgValue('--openchamber-home'); const macosMajorRaw = readArgValue('--openchamber-macos-major'); const macosMajor = Number.parseInt(macosMajorRaw, 10); @@ -61,6 +62,16 @@ if (clientToken && isLocalPage) { contextBridge.exposeInMainWorld('__OPENCHAMBER_CLIENT_TOKEN__', clientToken); } +if (runtimeHeadersRaw && isLocalPage) { + try { + const runtimeHeaders = JSON.parse(runtimeHeadersRaw); + if (runtimeHeaders && typeof runtimeHeaders === 'object') { + contextBridge.exposeInMainWorld('__OPENCHAMBER_RUNTIME_HEADERS__', runtimeHeaders); + } + } catch { + } +} + // Home directory leaks the OS username — keep local-only. Remote pages // operate on the REMOTE server's filesystem, local home is irrelevant // (and would be misleading if consumed as a workspace hint). diff --git a/packages/electron/resources/opencode-cli/.gitkeep b/packages/electron/resources/opencode-cli/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/electron/runtime-request-headers.mjs b/packages/electron/runtime-request-headers.mjs new file mode 100644 index 0000000000..4fb143bb98 --- /dev/null +++ b/packages/electron/runtime-request-headers.mjs @@ -0,0 +1,16 @@ +const isReservedRuntimeRequestHeaderName = (name) => { + return String(name || '').trim().toLowerCase() === 'authorization'; +}; + +export const sanitizeRuntimeRequestHeaders = (headers) => { + if (!headers || typeof headers !== 'object') return {}; + const next = {}; + for (const [rawName, rawValue] of Object.entries(headers)) { + const name = typeof rawName === 'string' ? rawName.trim() : ''; + const value = typeof rawValue === 'string' ? rawValue.trim() : ''; + if (!name || !value || /[\r\n:]/.test(name) || /[\r\n]/.test(value)) continue; + if (isReservedRuntimeRequestHeaderName(name)) continue; + next[name] = value; + } + return next; +}; diff --git a/packages/electron/runtime-request-headers.test.mjs b/packages/electron/runtime-request-headers.test.mjs new file mode 100644 index 0000000000..9945a6b4cd --- /dev/null +++ b/packages/electron/runtime-request-headers.test.mjs @@ -0,0 +1,26 @@ +import { describe, expect, test } from 'bun:test'; +import { sanitizeRuntimeRequestHeaders } from './runtime-request-headers.mjs'; + +describe('sanitizeRuntimeRequestHeaders', () => { + test('preserves safe custom headers', () => { + expect(sanitizeRuntimeRequestHeaders({ + ' CF-Access-Client-Id ': ' client-id ', + 'X-Custom-Header': 'value', + })).toEqual({ + 'CF-Access-Client-Id': 'client-id', + 'X-Custom-Header': 'value', + }); + }); + + test('drops invalid and reserved headers', () => { + expect(sanitizeRuntimeRequestHeaders({ + Authorization: 'Bearer proxy-token', + 'authorization': 'Bearer lower-token', + 'Bad:Name': 'value', + 'Bad\nName': 'value', + 'Bad-Value': 'line\nbreak', + Empty: '', + Good: 'ok', + })).toEqual({ Good: 'ok' }); + }); +}); diff --git a/packages/electron/scripts/prepare-opencode-cli.mjs b/packages/electron/scripts/prepare-opencode-cli.mjs new file mode 100644 index 0000000000..d7f5ede5c8 --- /dev/null +++ b/packages/electron/scripts/prepare-opencode-cli.mjs @@ -0,0 +1,181 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const electronRoot = path.resolve(__dirname, '..'); +const workspaceRoot = path.resolve(electronRoot, '../..'); +const outputDir = path.join(electronRoot, 'resources', 'opencode-cli'); +const cacheRoot = path.join(electronRoot, '.cache', 'opencode-cli'); +const rootPackagePath = path.join(workspaceRoot, 'package.json'); + +const run = (command, args, options = {}) => { + const result = spawnSync(command, args, { + encoding: 'utf8', + stdio: options.stdio || 'pipe', + windowsHide: true, + ...options, + }); + if (result.status !== 0) { + const stderr = result.stderr ? `\n${result.stderr.trim()}` : ''; + const stdout = result.stdout ? `\n${result.stdout.trim()}` : ''; + throw new Error(`Command failed: ${command} ${args.join(' ')}${stderr}${stdout}`); + } + return result; +}; + +const readPinnedSdkVersion = () => { + const pkg = JSON.parse(fs.readFileSync(rootPackagePath, 'utf8')); + const version = pkg.dependencies?.['@opencode-ai/sdk']; + if (typeof version !== 'string' || !version.trim()) { + throw new Error('Missing @opencode-ai/sdk dependency in root package.json'); + } + const trimmed = version.trim(); + if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(trimmed)) { + throw new Error(`@opencode-ai/sdk must be pinned to an exact version for desktop CLI bundling, got: ${trimmed}`); + } + return trimmed; +}; + +const artifactForCurrentPlatform = () => { + const { platform, arch } = process; + if (platform === 'darwin') { + if (arch === 'arm64') return { name: 'opencode-darwin-arm64.zip', binary: 'opencode' }; + if (arch === 'x64') return { name: 'opencode-darwin-x64-baseline.zip', binary: 'opencode' }; + } + if (platform === 'win32') { + if (arch === 'arm64') return { name: 'opencode-windows-arm64.zip', binary: 'opencode.exe' }; + if (arch === 'x64') return { name: 'opencode-windows-x64-baseline.zip', binary: 'opencode.exe' }; + } + if (platform === 'linux') { + if (arch === 'arm64') return { name: 'opencode-linux-arm64.tar.gz', binary: 'opencode' }; + if (arch === 'x64') return { name: 'opencode-linux-x64-baseline.tar.gz', binary: 'opencode' }; + } + throw new Error(`No OpenCode CLI artifact mapping for ${platform}/${arch}`); +}; + +const outputBinaryPath = (binaryName) => path.join(outputDir, binaryName); + +const readBinaryVersion = (binaryPath) => { + if (!fs.existsSync(binaryPath)) return null; + const result = spawnSync(binaryPath, ['--version'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 15000, + windowsHide: true, + }); + if (result.status !== 0) return null; + return (result.stdout || '').trim().split(/\s+/)[0] || null; +}; + +const ensureExecutable = (filePath) => { + if (process.platform !== 'win32') { + fs.chmodSync(filePath, 0o755); + } +}; + +const download = async (url, destination) => { + fs.mkdirSync(path.dirname(destination), { recursive: true }); + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`); + } + const temp = `${destination}.tmp`; + fs.writeFileSync(temp, Buffer.from(await response.arrayBuffer())); + fs.renameSync(temp, destination); +}; + +const extractArchive = (archivePath, destination) => { + fs.rmSync(destination, { recursive: true, force: true }); + fs.mkdirSync(destination, { recursive: true }); + if (archivePath.endsWith('.zip')) { + if (process.platform === 'win32') { + run('powershell.exe', [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-Command', + `Expand-Archive -LiteralPath ${JSON.stringify(archivePath)} -DestinationPath ${JSON.stringify(destination)} -Force`, + ]); + return; + } + run('unzip', ['-q', archivePath, '-d', destination]); + return; + } + if (archivePath.endsWith('.tar.gz')) { + run('tar', ['-xzf', archivePath, '-C', destination]); + return; + } + throw new Error(`Unsupported OpenCode CLI archive: ${archivePath}`); +}; + +const findBinary = (root, binaryName) => { + const entries = fs.readdirSync(root, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(root, entry.name); + if (entry.isFile() && entry.name.toLowerCase() === binaryName.toLowerCase()) { + return fullPath; + } + if (entry.isDirectory()) { + const found = findBinary(fullPath, binaryName); + if (found) return found; + } + } + return null; +}; + +const main = async () => { + const version = process.env.OPENCHAMBER_OPENCODE_CLI_VERSION || readPinnedSdkVersion(); + if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) { + throw new Error(`Invalid OpenCode CLI version: ${version}`); + } + + const artifact = artifactForCurrentPlatform(); + const outputBinary = outputBinaryPath(artifact.binary); + const existingVersion = readBinaryVersion(outputBinary); + if (existingVersion === version) { + console.log(`[electron] bundled OpenCode CLI already prepared: ${outputBinary} (${version})`); + return; + } + + const cacheDir = path.join(cacheRoot, version, `${process.platform}-${process.arch}`); + const archivePath = path.join(cacheDir, artifact.name); + const url = `https://github.com/anomalyco/opencode/releases/download/v${version}/${artifact.name}`; + if (!fs.existsSync(archivePath)) { + console.log(`[electron] downloading OpenCode CLI ${version}: ${artifact.name}`); + await download(url, archivePath); + } else { + console.log(`[electron] using cached OpenCode CLI archive: ${archivePath}`); + } + + const extractDir = path.join(cacheDir, 'extract'); + extractArchive(archivePath, extractDir); + const extractedBinary = findBinary(extractDir, artifact.binary); + if (!extractedBinary) { + throw new Error(`Archive ${archivePath} did not contain ${artifact.binary}`); + } + + fs.mkdirSync(outputDir, { recursive: true }); + for (const entry of fs.readdirSync(outputDir)) { + if (entry === '.gitkeep') continue; + fs.rmSync(path.join(outputDir, entry), { recursive: true, force: true }); + } + fs.copyFileSync(extractedBinary, outputBinary); + ensureExecutable(outputBinary); + + const preparedVersion = readBinaryVersion(outputBinary); + if (preparedVersion !== version) { + throw new Error(`Prepared OpenCode CLI version mismatch: expected ${version}, got ${preparedVersion || 'unknown'}`); + } + + console.log(`[electron] prepared OpenCode CLI ${version}: ${outputBinary}`); +}; + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/packages/electron/scripts/verify-opencode-cli.mjs b/packages/electron/scripts/verify-opencode-cli.mjs new file mode 100644 index 0000000000..5d9da4f1ac --- /dev/null +++ b/packages/electron/scripts/verify-opencode-cli.mjs @@ -0,0 +1,107 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const electronRoot = path.resolve(__dirname, '..'); +const workspaceRoot = path.resolve(electronRoot, '../..'); + +const readExpectedVersion = () => { + const pkg = JSON.parse(fs.readFileSync(path.join(workspaceRoot, 'package.json'), 'utf8')); + const version = pkg.dependencies?.['@opencode-ai/sdk']; + if (typeof version !== 'string' || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) { + throw new Error(`Expected root @opencode-ai/sdk to be pinned to an exact version, got: ${version || '(missing)'}`); + } + return version; +}; + +const binaryName = () => process.platform === 'win32' ? 'opencode.exe' : 'opencode'; + +const runVersion = (binaryPath) => { + const result = spawnSync(binaryPath, ['--version'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 15000, + windowsHide: true, + }); + if (result.status !== 0) { + const stderr = result.stderr ? `\n${result.stderr.trim()}` : ''; + const stdout = result.stdout ? `\n${result.stdout.trim()}` : ''; + throw new Error(`Failed to run bundled OpenCode CLI: ${binaryPath}${stderr}${stdout}`); + } + return (result.stdout || '').trim().split(/\s+/)[0] || ''; +}; + +const assertBinary = (binaryPath, expectedVersion) => { + if (!fs.existsSync(binaryPath)) { + throw new Error(`Bundled OpenCode CLI not found: ${binaryPath}`); + } + const stat = fs.statSync(binaryPath); + if (!stat.isFile()) { + throw new Error(`Bundled OpenCode CLI is not a file: ${binaryPath}`); + } + if (process.platform !== 'win32' && (stat.mode & 0o111) === 0) { + throw new Error(`Bundled OpenCode CLI is not executable: ${binaryPath}`); + } + const actualVersion = runVersion(binaryPath); + if (actualVersion !== expectedVersion) { + throw new Error(`Bundled OpenCode CLI version mismatch at ${binaryPath}: expected ${expectedVersion}, got ${actualVersion || '(empty)'}`); + } + console.log(`[electron] verified bundled OpenCode CLI ${actualVersion}: ${binaryPath}`); +}; + +const findPackagedBinaries = () => { + const distDir = path.join(electronRoot, 'dist'); + if (!fs.existsSync(distDir)) return []; + + const candidates = []; + const targetBinary = binaryName().toLowerCase(); + const visit = (dir) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + visit(fullPath); + continue; + } + if (!entry.isFile() || entry.name.toLowerCase() !== targetBinary) continue; + const parent = path.basename(path.dirname(fullPath)).toLowerCase(); + if (parent === 'opencode-cli') { + candidates.push(fullPath); + } + } + }; + visit(distDir); + return candidates; +}; + +const usage = () => { + console.error('Usage: node scripts/verify-opencode-cli.mjs --staged|--packaged'); + process.exit(2); +}; + +const main = () => { + const mode = process.argv[2]; + if (mode !== '--staged' && mode !== '--packaged') usage(); + + const expectedVersion = readExpectedVersion(); + if (mode === '--staged') { + assertBinary(path.join(electronRoot, 'resources', 'opencode-cli', binaryName()), expectedVersion); + return; + } + + const packagedBinaries = findPackagedBinaries(); + if (packagedBinaries.length === 0) { + throw new Error('No packaged OpenCode CLI found under packages/electron/dist'); + } + for (const packagedBinary of packagedBinaries) { + assertBinary(packagedBinary, expectedVersion); + } +}; + +try { + main(); +} catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +} diff --git a/packages/electron/ssh-manager.mjs b/packages/electron/ssh-manager.mjs index 38c14bae0a..7075b5524f 100644 --- a/packages/electron/ssh-manager.mjs +++ b/packages/electron/ssh-manager.mjs @@ -700,19 +700,84 @@ export class ElectronSshManager { } async updateHostUrl(instanceId, label, localUrl) { + return this.updateHostRuntime(instanceId, label, localUrl, ''); + } + + async updateHostRuntime(instanceId, label, localUrl, clientToken = '') { const root = readJsonRoot(this.settingsFilePath); const hosts = Array.isArray(root.desktopHosts) ? root.desktopHosts : []; const existing = hosts.find((entry) => entry?.id === instanceId); + const token = typeof clientToken === 'string' ? clientToken.trim() : ''; if (existing) { existing.label = label; existing.url = localUrl; + existing.apiUrl = localUrl; + if (token) existing.clientToken = token; } else { - hosts.push({ id: instanceId, label, url: localUrl }); + hosts.push({ id: instanceId, label, url: localUrl, apiUrl: localUrl, ...(token ? { clientToken: token } : {}) }); } root.desktopHosts = hosts; await writeJsonRoot(this.settingsFilePath, root); } + async issueClientToken(localUrl, openchamberPassword) { + const password = typeof openchamberPassword === 'string' ? openchamberPassword.trim() : ''; + if (!password) return ''; + + const loginResponse = await fetch(new URL('/auth/session', `${localUrl}/`).toString(), { + method: 'POST', + signal: AbortSignal.timeout(10_000), + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + password, + trustDevice: true, + issueClientToken: true, + clientLabel: 'OpenChamber Desktop SSH', + }), + }); + if (!loginResponse.ok) { + throw new Error(`Configured OpenChamber UI password was rejected by forwarded server (status ${loginResponse.status})`); + } + + const payload = await loginResponse.json().catch(() => null); + const token = typeof payload?.clientToken === 'string' ? payload.clientToken.trim() : ''; + if (token) return token; + + const cookie = this.extractCookieHeader(loginResponse); + if (!cookie) return ''; + + const tokenResponse = await fetch(new URL('/api/client-auth/clients', `${localUrl}/`).toString(), { + method: 'POST', + signal: AbortSignal.timeout(10_000), + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Cookie: cookie, + }, + body: JSON.stringify({ label: 'OpenChamber Desktop SSH' }), + }); + if (!tokenResponse.ok) return ''; + const tokenPayload = await tokenResponse.json().catch(() => null); + return typeof tokenPayload?.token === 'string' ? tokenPayload.token.trim() : ''; + } + + extractCookieHeader(response) { + const getSetCookie = typeof response.headers?.getSetCookie === 'function' + ? response.headers.getSetCookie.bind(response.headers) + : null; + const cookies = getSetCookie ? getSetCookie() : []; + const rawCookies = cookies.length > 0 + ? cookies + : String(response.headers?.get?.('set-cookie') || '').split(/,(?=\s*[^;,=]+=[^;,]+)/); + return rawCookies + .map((cookie) => String(cookie || '').split(';')[0].trim()) + .filter(Boolean) + .join('; '); + } + async persistLocalPort(instanceId, localPort) { const root = readJsonRoot(this.settingsFilePath); const instances = Array.isArray(root.desktopSshInstances) ? root.desktopSshInstances : []; @@ -1090,7 +1155,8 @@ export class ElectronSshManager { const localUrl = `http://127.0.0.1:${localPort}`; const label = instance.nickname?.trim() || parsed.destination || id; - await this.updateHostUrl(id, label, localUrl); + const clientToken = await this.issueClientToken(localUrl, this.configuredOpenChamberPassword(instance)); + await this.updateHostRuntime(id, label, localUrl, clientToken); if (instance.localForward?.preferredLocalPort !== localPort) { await this.persistLocalPort(id, localPort); } diff --git a/packages/electron/ssh-manager.test.mjs b/packages/electron/ssh-manager.test.mjs new file mode 100644 index 0000000000..f31c90f877 --- /dev/null +++ b/packages/electron/ssh-manager.test.mjs @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; + +import { ElectronSshManager } from './ssh-manager.mjs'; + +const servers = []; +const tempDirs = []; + +const listen = async (server) => { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + servers.push(server); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Expected TCP server address'); + return `http://127.0.0.1:${address.port}`; +}; + +const readBody = async (req) => { + let body = ''; + for await (const chunk of req) body += chunk.toString(); + return body; +}; + +afterEach(async () => { + while (servers.length > 0) { + const server = servers.pop(); + await new Promise((resolve) => server.close(() => resolve())); + } + while (tempDirs.length > 0) { + await fsp.rm(tempDirs.pop(), { recursive: true, force: true }); + } +}); + +describe('ElectronSshManager', () => { + test('stores a client token for forwarded OpenChamber hosts when UI password is configured', async () => { + let loginPayload = null; + const server = http.createServer(async (req, res) => { + if (req.method === 'POST' && req.url === '/auth/session') { + loginPayload = JSON.parse(await readBody(req)); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ authenticated: true, clientToken: 'ssh-client-token' })); + return; + } + res.writeHead(404).end(); + }); + const localUrl = await listen(server); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-ssh-manager-test-')); + tempDirs.push(tempDir); + const settingsFilePath = path.join(tempDir, 'settings.json'); + const manager = new ElectronSshManager({ + settingsFilePath, + appVersion: '0.0.0-test', + emit: () => undefined, + }); + + const token = await manager.issueClientToken(localUrl, 'ui-secret'); + await manager.updateHostRuntime('ssh-1', 'SSH Host', localUrl, token); + + const settings = JSON.parse(fs.readFileSync(settingsFilePath, 'utf8')); + expect(loginPayload).toMatchObject({ + password: 'ui-secret', + trustDevice: true, + issueClientToken: true, + }); + expect(settings.desktopHosts).toEqual([{ id: 'ssh-1', label: 'SSH Host', url: localUrl, apiUrl: localUrl, clientToken: 'ssh-client-token' }]); + }); +}); diff --git a/packages/mobile/HANDOFF.md b/packages/mobile/HANDOFF.md new file mode 100644 index 0000000000..92fa01e743 --- /dev/null +++ b/packages/mobile/HANDOFF.md @@ -0,0 +1,218 @@ +# OpenChamber Mobile Handoff + +Status and process reference for the native iOS/Android apps. Written so work can continue after +merge — either by finishing CI/release automation, or by adding features as follow-up fixes. The +apps are feature-complete for a first public/TestFlight-style test; CI/signing is the main gap. + +## What this package is + +`packages/mobile` is a Capacitor workspace that wraps the **hosted mobile web UI** (the `MobileApp` +renderer), not the desktop shell. The native app is a WKWebView (iOS) / Android WebView loading a +bundled copy of the web build; native capabilities are added via Capacitor plugins and two iOS app +extensions. + +- App id / package: `com.openchamber.app`; app name `OpenChamber`. +- Capacitor config: `capacitor.config.ts` (Keyboard `resize: 'none'`, StatusBar overlay, Push + `presentationOptions: []`). +- Renderer: the web build's `mobile.html` entry (`MobileApp`), copied into `dist/` and served by + Capacitor. Mobile-only surfaces (connection onboarding, `Instances`, QR pairing, widgets) exist + only in the Capacitor shell — hosted `mobile.html` in a plain browser does not expose them. + +## Build pipeline (how a native build is produced) + +``` +bun run --cwd packages/web build # web/dist + → scripts/prepare-web-assets.mjs # copy web/dist → mobile/dist, mobile.html → index.html + → cap sync # copy dist → native, sync plugins/config + → xcodebuild / gradle assembleDebug # native binary +``` + +`sync` (in `packages/mobile/package.json`) runs `bun run build && cap sync` inside the mobile env +wrapper. Everything native-facing goes through `scripts/with-mobile-env.mjs`. + +### `with-mobile-env.mjs` (toolchain wrapper — read this before debugging build env issues) + +Every build/deploy script runs through it. It sets, with env overrides honored first: + +- `DEVELOPER_DIR` — `$DEVELOPER_DIR` → `xcode-select -p` → `/Applications/Xcode.app/...`. It + intentionally honors `xcode-select` so an Xcode beta / non-default install is used (hardcoding + the path previously forced builds onto the wrong Xcode / Command Line Tools). +- `JAVA_HOME` — `$JAVA_HOME` → `/opt/homebrew/opt/openjdk@21`. +- `ANDROID_HOME` / `ANDROID_SDK_ROOT` — `$ANDROID_HOME` → `/opt/homebrew/share/android-commandlinetools`. +- `PATH` — prepends `$JAVA_HOME/bin` and `$ANDROID_HOME/platform-tools` (so `adb` resolves). + +On another machine, override these env vars rather than editing the script. `xcode-select` may +point at Command Line Tools; the wrapper's `DEVELOPER_DIR` handling covers that for mobile commands. + +## Commands + +Root aliases (from repo root): + +```sh +bun run mobile:build # web build + prepare-web-assets +bun run mobile:sync # build + cap sync +bun run mobile:build:android:debug # sync + gradle assembleDebug +bun run mobile:build:ios:simulator # simulator build (strips MLKit pod, see quirks) +bun run mobile:open:ios # open in Xcode +bun run mobile:open:android # open in Android Studio +bun run type-check:mobile +bun run lint:mobile +``` + +Android physical-device deploy (adb-based, in `scripts/android-device.mjs`) — **not aliased at +root**, run from the package: + +```sh +bun run --cwd packages/mobile android:devices # list adb devices (want `device`, not `unauthorized`) +bun run --cwd packages/mobile android:install # adb install -r the debug APK +bun run --cwd packages/mobile android:launch # am start MainActivity +bun run --cwd packages/mobile android:run # install + launch +bun run --cwd packages/mobile android:logcat # app logs +``` + +Typical device iteration: `bun run --cwd packages/mobile build:android:debug` then +`android:run`. APK path: `android/app/build/outputs/apk/debug/app-debug.apk`. + +iOS Simulator helpers: `mobile:sim:{boot,install,launch,run,serve,list,kill}` (see +`scripts/ios-sim.mjs`; `serve-sim` for a browser preview of the simulator). + +## Native capabilities implemented + +- **Connection onboarding** — server URL entry, password unlock for locked servers, client-token + issuance, saved connections, `Instances` management sheet, auto-connect to the last instance on + launch. Deleting the active instance resets the runtime to the connect screen. +- **QR pairing** — `@capacitor-mlkit/barcode-scanning`. Android's Google code scanner module is + downloaded on first scan (needs Play Services + network); `mobileQrScan.ts` installs/awaits it + and retries. CAMERA permission + `NSCameraUsageDescription` declared. +- **Secure storage** — `@aparajita/capacitor-secure-storage` for connection tokens. +- **Deep links** — `openchamber://` URL scheme; a reusable intent vocabulary (`apps/deepLinks.ts`) + used by notification taps, widgets, and Control Center. Cold-launch intents are stashed. +- **Push notifications** — iOS APNs + Android FCM (see below). Presence-aware routing suppresses a + device's push when an interactive (desktop/web) client is visible. +- **iOS widgets + Control Center + Notification Service Extension** — WidgetKit extension + (`OpenChamberWidget`), a Control Center control, and an NSE (`OpenChamberNotificationService`) + that refreshes widgets from push. All share the App Group `group.com.openchamber.app`. +- **Native chrome** — status bar (iOS overlay + safe-area; Android inset + themed background), + keyboard handling (iOS CSS inset; Android native `adjustResize`), edge-swipe session switch, + back-button handling, app-icon badge. +- **App icons** — iOS `AppIcon`; Android adaptive launcher icon; notification small icon + (`ic_stat_notify`). + +## Push / notifications architecture + +- Registration: on launch the app registers a device token — **iOS → APNs, Android → FCM** — and + sends it to the connected server tagged with `platform` (`ios`/`android`). +- The server forwards notification-worthy events to a signed **relay**; the relay routes each token + to APNs or FCM by its bound platform. The app itself only needs to obtain and register the token. +- **Presence-aware suppression**: each client reports foreground visibility + its platform; a + mobile push is skipped while an interactive (desktop/web/vscode) client is visible (it already + shows the in-app notification). Gated on the desktop's visibility, never the phone's own. +- Foreground behavior: iOS suppresses the banner via `presentationOptions: []`; the web/PWA service + worker suppresses when a window is focused. + +## Platform config specifics + +### iOS (`ios/App`) + +- Extensions: `OpenChamberWidget` (WidgetKit, deployment 17.0) and `OpenChamberNotificationService` + (NSE, 15.5), both hand-wired into `App.xcodeproj/project.pbxproj` and embedded via a copy phase. +- App Group `group.com.openchamber.app` in all three targets' entitlements (app + widget + NSE). +- `Info.plist`: `CFBundleURLTypes` scheme `openchamber`, `NSCameraUsageDescription`. +- Push entitlement (aps-environment) required. +- APNs `mutable-content: 1` (set server/relay side) wakes the NSE to refresh widgets. + +### Android (`android/app`) + +- `google-services.json` (committed; Firebase project `openchamber-8bf7e`). The Google Services + Gradle plugin is applied conditionally when the file exists; `@capacitor/push-notifications` + brings `firebase-messaging`. +- Manifest: permissions `INTERNET`, `CAMERA` (+ optional camera feature), `POST_NOTIFICATIONS` + (Android 13+; older versions allow notifications by default). `windowSoftInputMode=adjustResize`. + ML Kit `com.google.mlkit.vision.DEPENDENCIES=barcode_ui` meta (preloads the code scanner). FCM + `default_notification_icon=@drawable/ic_stat_notify`. +- Adaptive launcher icon: full-bleed color background + `ic_launcher_foreground` (sources under + `packages/mobile/assets/`, regenerable with `@capacitor/assets`). + +## Quirks / gotchas + +- **iOS Simulator + MLKit**: `GoogleMLKit` barcode has no arm64-simulator slice, so + `scripts/ios-sim-build.mjs` temporarily strips the `CapacitorMlkitBarcodeScanning` pod, builds, + then restores it. Device builds include it normally. +- **Android WebView version**: the UI uses `color-mix()` (Tailwind v4 + theme) which needs + Chromium **111+**. An outdated Android System WebView renders translucency/selection wrong — tell + testers to keep Android System WebView updated (or use a device with a current one). +- **Capacitor stream transport is locked to SSE** on the native apps (native WebSocket streaming is + unreliable on Android). The Chat transport setting shows SSE selected and disables the others in + the Capacitor shell. +- **Android push needs the app rebuilt with `google-services.json`**; without it `register()` used + to crash ("Default FirebaseApp is not initialized"). Registration is gated to iOS/Android natives. + +## Validation + +```sh +bun run type-check:mobile +bun run lint:mobile +bun run mobile:build:android:debug +bun run mobile:build:ios:simulator +``` + +Web-inherited build warnings (KaTeX font URLs, `onnxruntime-web` eval, chunk-size) are expected and +non-fatal. + +## The gap: CI / release automation (next work) + +The apps build and deploy locally; there is no CI/signing/publishing yet. To take them to +TestFlight / Play internal testing: + +### iOS + +- Apple Developer account; App IDs for the app **and** both extensions + (`com.openchamber.app`, `.OpenChamberWidget`, `.OpenChamberNotificationService`), each enabled for + the **App Group** and (app) **Push**. +- Signing certificate + provisioning profiles for all three targets (extensions need their own). +- App Store Connect API key for non-interactive TestFlight upload (`xcodebuild archive` + + `notarytool`/`altool`, or fastlane `gym`+`pilot`). +- Runner: macOS with the same Xcode as `DEVELOPER_DIR`. + +### Android + +- Release keystore (kept as a CI secret); build a signed **AAB** (`bundleRelease`) — the debug + scripts here produce an unsigned debug APK. +- Play Console app + internal testing track; a Play service account for automated upload (fastlane + `supply` or the Play Developer API). +- `google-services.json` is committed, so FCM builds in CI without extra setup. +- Runner: Linux with the Android SDK + `openjdk@21`. + +### Notes for CI + +- Reuse `with-mobile-env.mjs`'s env contract (`DEVELOPER_DIR`, `JAVA_HOME`, `ANDROID_HOME`) — set + them in the workflow instead of relying on local Homebrew paths. +- Relay/push secrets (APNs key, FCM service account) live in the relay infrastructure, not app CI. +- Version/build-number bumping is not automated yet. + +## Store review readiness + +Xcode build warnings do not block review; the concrete items are store requirements, not code +quality. Done in-repo vs. to-do at release time: + +**Done in-repo (this branch):** + +- iOS app **Privacy Manifest** (`ios/App/App/PrivacyInfo.xcprivacy`) — declares no tracking and the + required-reason UserDefaults API (App Group snapshot). Bundled SDKs ship their own manifests. +- iOS **`ITSAppUsesNonExemptEncryption = false`** in `Info.plist` (skips the per-build export- + compliance prompt). +- iOS camera + local-network usage strings; Android SDK levels (`target/compile 35`, `min 24`) meet + Play's current requirements. + +**To-do at release (console / infra, not code):** + +- **Privacy policy URL** — required by both stores because the app uses camera + notifications. +- iOS **App Privacy nutrition label** (App Store Connect) and Android **Data Safety** form — declare + what's collected (device push token; the app otherwise talks only to the user's own server). +- **Production APNs** for App Store / TestFlight builds: the app's `aps-environment` must be + `production` in the release build, and the relay must send to production APNs (not sandbox). +- **Demo instance + credentials** for reviewers — the app connects to a user's server, so review + needs a reachable test instance (App Store 2.1 / Play). +- **Guideline 4.2 (minimum functionality)** — WebView-wrapper apps can be scrutinized; cite the + native features (push, widgets, Control Center, QR pairing) in the review notes. +- Signing/upload as covered in the CI section above (all three iOS targets; signed Android AAB). diff --git a/packages/mobile/README.md b/packages/mobile/README.md new file mode 100644 index 0000000000..6f21cb5f70 --- /dev/null +++ b/packages/mobile/README.md @@ -0,0 +1,71 @@ +# OpenChamber Mobile + +Capacitor shell for the dedicated OpenChamber mobile web surface. + +The mobile package reuses the web build, then rewrites `mobile.html` to `index.html` in `packages/mobile/dist` so native iOS/Android always launch `MobileApp` instead of the hosted surface selector. + +## Runtime Model + +- The native app bundles the mobile UI only; it does not embed the OpenChamber web server or OpenCode server. +- On first launch in Capacitor, the app shows a connection screen for an existing OpenChamber server. +- Connections are saved locally in the app and can be managed from the mobile overflow menu under `Instances`. +- The connection screen and `Instances` menu item are Capacitor-only. Hosted `mobile.html` in a normal browser keeps the regular web behavior. +- Password-protected OpenChamber servers can be unlocked from the mobile app. The app stores the issued client token with the saved connection. + +## Commands + +Run these from `packages/mobile`, or use the root `mobile:*` aliases. + +- `bun run build`: builds `packages/web` and prepares mobile web assets. +- `bun run sync`: prepares assets and runs `cap sync`. +- `bun run add:ios`: creates the native iOS project. +- `bun run add:android`: creates the native Android project. +- `bun run build:android:debug`: builds a debug Android APK without launching an emulator. +- `bun run build:ios:simulator`: builds an iOS Simulator app without launching Xcode or Simulator. +- `bun run sim:run`: boots a simulator if needed, installs the built iOS app, and launches it. +- `bun run sim:serve`: starts `serve-sim` in detached JSON mode and prints the browser preview URL. +- `bun run sim:list`: lists running `serve-sim` streams. +- `bun run sim:kill`: stops running `serve-sim` streams. +- `bun run open:ios`: opens the iOS project. +- `bun run open:android`: opens the Android project. + +## Headless Quickstart + +```sh +bun run build +bun run sync +bun run build:ios:simulator +bun run build:android:debug +``` + +These commands build and sync the native projects without launching Xcode, Android Studio, Simulator, or an emulator. + +## Local Tooling + +The default scripts assume the local Homebrew/Xcode paths prepared for this workspace: + +- Xcode: `/Applications/Xcode.app/Contents/Developer` +- JDK 21: `/opt/homebrew/opt/openjdk@21` +- Android SDK: `/opt/homebrew/share/android-commandlinetools` + +Override `DEVELOPER_DIR`, `JAVA_HOME`, `ANDROID_HOME`, or `ANDROID_SDK_ROOT` when using a different local setup. + +Required local tools: + +- Xcode with iOS Simulator support. +- CocoaPods for iOS dependency installation. +- JDK 21 for Android Gradle builds. +- Android SDK command-line tools with platform/build-tools 35. + +## Troubleshooting + +- If `xcodebuild` reports that the active developer directory is Command Line Tools, keep using the provided scripts or set `DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer`. +- If Android builds fail with `Unable to locate a Java Runtime` or `source release: 21`, install/use JDK 21 and set `JAVA_HOME` accordingly. +- If Android SDK packages are missing, install `platform-tools`, `platforms;android-35`, and `build-tools;35.0.0`, then accept SDK licenses. +- If CocoaPods cannot find Capacitor pods after reinstalling dependencies, run `bun install` from the workspace root, then rerun `bun run sync`. +- If connecting to a remote OpenChamber server fails from the app while `/health` works in curl, check that the server build includes the packaged-client CORS allowlist for `capacitor://localhost` and local dev origins. +- If `serve-sim` preview says the stream is not producing frames, check the raw MJPEG stream before assuming the simulator stopped. In prior testing the raw stream worked while the browser preview UI stayed stale. + +## Generated Assets + +The native projects currently use Capacitor-generated launcher and splash assets. Replace them before release branding work. diff --git a/packages/mobile/android/.gitignore b/packages/mobile/android/.gitignore new file mode 100644 index 0000000000..48354a3dfc --- /dev/null +++ b/packages/mobile/android/.gitignore @@ -0,0 +1,101 @@ +# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore + +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +# Uncomment the following line in case you need and you don't have the release build type files in your app +# release/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea/workspace.xml +.idea/tasks.xml +.idea/gradle.xml +.idea/assetWizardSettings.xml +.idea/dictionaries +.idea/libraries +# Android Studio 3 in .gitignore file. +.idea/caches +.idea/modules.xml +# Comment next line if keeping position of elements in Navigation Editor is relevant for you +.idea/navEditor.xml + +# Keystore files +# Uncomment the following lines if you do not want to check your keystore files in. +#*.jks +#*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +# lint/reports/ + +# Android Profiling +*.hprof + +# Cordova plugins for Capacitor +capacitor-cordova-android-plugins + +# Copied web assets +app/src/main/assets/public + +# Generated Config files +app/src/main/assets/capacitor.config.json +app/src/main/assets/capacitor.plugins.json +app/src/main/res/xml/config.xml diff --git a/packages/mobile/android/app/.gitignore b/packages/mobile/android/app/.gitignore new file mode 100644 index 0000000000..043df802a2 --- /dev/null +++ b/packages/mobile/android/app/.gitignore @@ -0,0 +1,2 @@ +/build/* +!/build/.npmkeep diff --git a/packages/mobile/android/app/build.gradle b/packages/mobile/android/app/build.gradle new file mode 100644 index 0000000000..d86e8dd7ba --- /dev/null +++ b/packages/mobile/android/app/build.gradle @@ -0,0 +1,50 @@ +apply plugin: 'com.android.application' + +android { + namespace "com.openchamber.app" + compileSdk rootProject.ext.compileSdkVersion + defaultConfig { + applicationId "com.openchamber.app" + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode 1 + versionName "1.0" + aaptOptions { + // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. + // Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61 + ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' + } + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } +} + +repositories { + flatDir{ + dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs' + } +} + +dependencies { + implementation fileTree(include: ['*.jar'], dir: 'libs') + implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" + implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" + implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion" + implementation project(':capacitor-android') + implementation project(':capacitor-cordova-android-plugins') +} + +apply from: 'capacitor.build.gradle' + +try { + def servicesJSON = file('google-services.json') + if (servicesJSON.text) { + apply plugin: 'com.google.gms.google-services' + } +} catch(Exception e) { + logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work") +} diff --git a/packages/mobile/android/app/capacitor.build.gradle b/packages/mobile/android/app/capacitor.build.gradle new file mode 100644 index 0000000000..32b2e26bc3 --- /dev/null +++ b/packages/mobile/android/app/capacitor.build.gradle @@ -0,0 +1,24 @@ +// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN + +android { + compileOptions { + sourceCompatibility JavaVersion.VERSION_21 + targetCompatibility JavaVersion.VERSION_21 + } +} + +apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" +dependencies { + implementation project(':aparajita-capacitor-secure-storage') + implementation project(':capacitor-mlkit-barcode-scanning') + implementation project(':capacitor-app') + implementation project(':capacitor-keyboard') + implementation project(':capacitor-push-notifications') + implementation project(':capacitor-status-bar') + +} + + +if (hasProperty('postBuildExtras')) { + postBuildExtras() +} diff --git a/packages/mobile/android/app/google-services.json b/packages/mobile/android/app/google-services.json new file mode 100644 index 0000000000..3d2719119e --- /dev/null +++ b/packages/mobile/android/app/google-services.json @@ -0,0 +1,29 @@ +{ + "project_info": { + "project_number": "519320768353", + "project_id": "openchamber-8bf7e", + "storage_bucket": "openchamber-8bf7e.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:519320768353:android:e70fd113d740a86c233f20", + "android_client_info": { + "package_name": "com.openchamber.app" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyADEQ6yRHXBMlwbaG6y8Vb1elC2q7mx6-A" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/packages/mobile/android/app/proguard-rules.pro b/packages/mobile/android/app/proguard-rules.pro new file mode 100644 index 0000000000..f1b424510d --- /dev/null +++ b/packages/mobile/android/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/packages/mobile/android/app/src/main/AndroidManifest.xml b/packages/mobile/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..9ef6ac5cd1 --- /dev/null +++ b/packages/mobile/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mobile/android/app/src/main/java/com/openchamber/app/MainActivity.java b/packages/mobile/android/app/src/main/java/com/openchamber/app/MainActivity.java new file mode 100644 index 0000000000..0d8e2a721a --- /dev/null +++ b/packages/mobile/android/app/src/main/java/com/openchamber/app/MainActivity.java @@ -0,0 +1,5 @@ +package com.openchamber.app; + +import com.getcapacitor.BridgeActivity; + +public class MainActivity extends BridgeActivity {} diff --git a/packages/mobile/android/app/src/main/res/drawable-land-hdpi/splash.png b/packages/mobile/android/app/src/main/res/drawable-land-hdpi/splash.png new file mode 100644 index 0000000000..e31573b4fc Binary files /dev/null and b/packages/mobile/android/app/src/main/res/drawable-land-hdpi/splash.png differ diff --git a/packages/mobile/android/app/src/main/res/drawable-land-mdpi/splash.png b/packages/mobile/android/app/src/main/res/drawable-land-mdpi/splash.png new file mode 100644 index 0000000000..f7a64923ea Binary files /dev/null and b/packages/mobile/android/app/src/main/res/drawable-land-mdpi/splash.png differ diff --git a/packages/mobile/android/app/src/main/res/drawable-land-xhdpi/splash.png b/packages/mobile/android/app/src/main/res/drawable-land-xhdpi/splash.png new file mode 100644 index 0000000000..807725501b Binary files /dev/null and b/packages/mobile/android/app/src/main/res/drawable-land-xhdpi/splash.png differ diff --git a/packages/mobile/android/app/src/main/res/drawable-land-xxhdpi/splash.png b/packages/mobile/android/app/src/main/res/drawable-land-xxhdpi/splash.png new file mode 100644 index 0000000000..14c6c8fe39 Binary files /dev/null and b/packages/mobile/android/app/src/main/res/drawable-land-xxhdpi/splash.png differ diff --git a/packages/mobile/android/app/src/main/res/drawable-land-xxxhdpi/splash.png b/packages/mobile/android/app/src/main/res/drawable-land-xxxhdpi/splash.png new file mode 100644 index 0000000000..244ca2506d Binary files /dev/null and b/packages/mobile/android/app/src/main/res/drawable-land-xxxhdpi/splash.png differ diff --git a/packages/mobile/android/app/src/main/res/drawable-port-hdpi/splash.png b/packages/mobile/android/app/src/main/res/drawable-port-hdpi/splash.png new file mode 100644 index 0000000000..74faaa583c Binary files /dev/null and b/packages/mobile/android/app/src/main/res/drawable-port-hdpi/splash.png differ diff --git a/packages/mobile/android/app/src/main/res/drawable-port-mdpi/splash.png b/packages/mobile/android/app/src/main/res/drawable-port-mdpi/splash.png new file mode 100644 index 0000000000..e944f4ad4e Binary files /dev/null and b/packages/mobile/android/app/src/main/res/drawable-port-mdpi/splash.png differ diff --git a/packages/mobile/android/app/src/main/res/drawable-port-xhdpi/splash.png b/packages/mobile/android/app/src/main/res/drawable-port-xhdpi/splash.png new file mode 100644 index 0000000000..564a82ff95 Binary files /dev/null and b/packages/mobile/android/app/src/main/res/drawable-port-xhdpi/splash.png differ diff --git a/packages/mobile/android/app/src/main/res/drawable-port-xxhdpi/splash.png b/packages/mobile/android/app/src/main/res/drawable-port-xxhdpi/splash.png new file mode 100644 index 0000000000..bfabe6871a Binary files /dev/null and b/packages/mobile/android/app/src/main/res/drawable-port-xxhdpi/splash.png differ diff --git a/packages/mobile/android/app/src/main/res/drawable-port-xxxhdpi/splash.png b/packages/mobile/android/app/src/main/res/drawable-port-xxxhdpi/splash.png new file mode 100644 index 0000000000..6929071268 Binary files /dev/null and b/packages/mobile/android/app/src/main/res/drawable-port-xxxhdpi/splash.png differ diff --git a/packages/mobile/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/packages/mobile/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000000..c7bd21dbd8 --- /dev/null +++ b/packages/mobile/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + diff --git a/packages/mobile/android/app/src/main/res/drawable/ic_launcher_background.xml b/packages/mobile/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000000..d5fccc538c --- /dev/null +++ b/packages/mobile/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mobile/android/app/src/main/res/drawable/ic_stat_notify.xml b/packages/mobile/android/app/src/main/res/drawable/ic_stat_notify.xml new file mode 100644 index 0000000000..e984b381b2 --- /dev/null +++ b/packages/mobile/android/app/src/main/res/drawable/ic_stat_notify.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/packages/mobile/android/app/src/main/res/drawable/splash.png b/packages/mobile/android/app/src/main/res/drawable/splash.png new file mode 100644 index 0000000000..f7a64923ea Binary files /dev/null and b/packages/mobile/android/app/src/main/res/drawable/splash.png differ diff --git a/packages/mobile/android/app/src/main/res/layout/activity_main.xml b/packages/mobile/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000000..b5ad138701 --- /dev/null +++ b/packages/mobile/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/packages/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/packages/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000000..24335ca31f --- /dev/null +++ b/packages/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/packages/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/packages/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000000..24335ca31f --- /dev/null +++ b/packages/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/packages/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/packages/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000..0d83782768 Binary files /dev/null and b/packages/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/packages/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png b/packages/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png new file mode 100644 index 0000000000..91a97489b3 Binary files /dev/null and b/packages/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png differ diff --git a/packages/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/packages/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000..48f80b576e Binary files /dev/null and b/packages/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/packages/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/packages/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000000..8366f56aa2 Binary files /dev/null and b/packages/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/packages/mobile/android/app/src/main/res/mipmap-ldpi/ic_launcher.png b/packages/mobile/android/app/src/main/res/mipmap-ldpi/ic_launcher.png new file mode 100644 index 0000000000..dd12e40904 Binary files /dev/null and b/packages/mobile/android/app/src/main/res/mipmap-ldpi/ic_launcher.png differ diff --git a/packages/mobile/android/app/src/main/res/mipmap-ldpi/ic_launcher_background.png b/packages/mobile/android/app/src/main/res/mipmap-ldpi/ic_launcher_background.png new file mode 100644 index 0000000000..df35134c31 Binary files /dev/null and b/packages/mobile/android/app/src/main/res/mipmap-ldpi/ic_launcher_background.png differ diff --git a/packages/mobile/android/app/src/main/res/mipmap-ldpi/ic_launcher_foreground.png b/packages/mobile/android/app/src/main/res/mipmap-ldpi/ic_launcher_foreground.png new file mode 100644 index 0000000000..79528e9333 Binary files /dev/null and b/packages/mobile/android/app/src/main/res/mipmap-ldpi/ic_launcher_foreground.png differ diff --git a/packages/mobile/android/app/src/main/res/mipmap-ldpi/ic_launcher_round.png b/packages/mobile/android/app/src/main/res/mipmap-ldpi/ic_launcher_round.png new file mode 100644 index 0000000000..9f6912e5cf Binary files /dev/null and b/packages/mobile/android/app/src/main/res/mipmap-ldpi/ic_launcher_round.png differ diff --git a/packages/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/packages/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000..5c1290e0e8 Binary files /dev/null and b/packages/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/packages/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png b/packages/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png new file mode 100644 index 0000000000..e91ef4c160 Binary files /dev/null and b/packages/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png differ diff --git a/packages/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/packages/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000..9549e8877e Binary files /dev/null and b/packages/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/packages/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/packages/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000000..1b4e07684f Binary files /dev/null and b/packages/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/packages/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/packages/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000..b7a7ad7d6f Binary files /dev/null and b/packages/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/packages/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png b/packages/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png new file mode 100644 index 0000000000..10c3ebc671 Binary files /dev/null and b/packages/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png differ diff --git a/packages/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/packages/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000..39fa8bfe87 Binary files /dev/null and b/packages/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/packages/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/packages/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000000..72cf0b1a6a Binary files /dev/null and b/packages/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/packages/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/packages/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000..f6bb54d687 Binary files /dev/null and b/packages/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/packages/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png b/packages/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png new file mode 100644 index 0000000000..44c226b1b0 Binary files /dev/null and b/packages/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png differ diff --git a/packages/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/packages/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000..c01ebf996f Binary files /dev/null and b/packages/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/packages/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/packages/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000..12cb54d06b Binary files /dev/null and b/packages/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/packages/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/packages/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000..9238f65470 Binary files /dev/null and b/packages/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/packages/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png b/packages/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png new file mode 100644 index 0000000000..8a72324c90 Binary files /dev/null and b/packages/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png differ diff --git a/packages/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/packages/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000..9d7e548680 Binary files /dev/null and b/packages/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/packages/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/packages/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000..3399199238 Binary files /dev/null and b/packages/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/packages/mobile/android/app/src/main/res/values/ic_launcher_background.xml b/packages/mobile/android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000000..c5d5899fdf --- /dev/null +++ b/packages/mobile/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/packages/mobile/android/app/src/main/res/values/strings.xml b/packages/mobile/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000000..75f28bfd42 --- /dev/null +++ b/packages/mobile/android/app/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + OpenChamber + OpenChamber + com.openchamber.app + com.openchamber.app + diff --git a/packages/mobile/android/app/src/main/res/values/styles.xml b/packages/mobile/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000000..be874e54a4 --- /dev/null +++ b/packages/mobile/android/app/src/main/res/values/styles.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/packages/mobile/android/app/src/main/res/xml/file_paths.xml b/packages/mobile/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000000..bd0c4d80d0 --- /dev/null +++ b/packages/mobile/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/packages/mobile/android/build.gradle b/packages/mobile/android/build.gradle new file mode 100644 index 0000000000..9183d42f9b --- /dev/null +++ b/packages/mobile/android/build.gradle @@ -0,0 +1,36 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.7.2' + classpath 'com.google.gms:google-services:4.4.2' + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +apply from: "variables.gradle" + +allprojects { + repositories { + google() + mavenCentral() + } + configurations.all { + resolutionStrategy { + force 'org.jetbrains.kotlin:kotlin-stdlib:1.8.22' + force 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.22' + force 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.22' + } + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/packages/mobile/android/capacitor.settings.gradle b/packages/mobile/android/capacitor.settings.gradle new file mode 100644 index 0000000000..4516c4b12c --- /dev/null +++ b/packages/mobile/android/capacitor.settings.gradle @@ -0,0 +1,21 @@ +// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN +include ':capacitor-android' +project(':capacitor-android').projectDir = new File('../../../node_modules/.bun/@capacitor+android@8.4.1+767ac80cbab8ae50/node_modules/@capacitor/android/capacitor') + +include ':aparajita-capacitor-secure-storage' +project(':aparajita-capacitor-secure-storage').projectDir = new File('../../../node_modules/.bun/@aparajita+capacitor-secure-storage@8.0.0/node_modules/@aparajita/capacitor-secure-storage/android') + +include ':capacitor-mlkit-barcode-scanning' +project(':capacitor-mlkit-barcode-scanning').projectDir = new File('../../../node_modules/.bun/@capacitor-mlkit+barcode-scanning@8.1.0+767ac80cbab8ae50/node_modules/@capacitor-mlkit/barcode-scanning/android') + +include ':capacitor-app' +project(':capacitor-app').projectDir = new File('../../../node_modules/.bun/@capacitor+app@8.1.0+767ac80cbab8ae50/node_modules/@capacitor/app/android') + +include ':capacitor-keyboard' +project(':capacitor-keyboard').projectDir = new File('../../../node_modules/.bun/@capacitor+keyboard@8.0.5+767ac80cbab8ae50/node_modules/@capacitor/keyboard/android') + +include ':capacitor-push-notifications' +project(':capacitor-push-notifications').projectDir = new File('../../../node_modules/.bun/@capacitor+push-notifications@8.1.1+767ac80cbab8ae50/node_modules/@capacitor/push-notifications/android') + +include ':capacitor-status-bar' +project(':capacitor-status-bar').projectDir = new File('../../../node_modules/.bun/@capacitor+status-bar@8.0.2+767ac80cbab8ae50/node_modules/@capacitor/status-bar/android') diff --git a/packages/mobile/android/gradle.properties b/packages/mobile/android/gradle.properties new file mode 100644 index 0000000000..2e87c52f83 --- /dev/null +++ b/packages/mobile/android/gradle.properties @@ -0,0 +1,22 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx1536m + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true + +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true diff --git a/packages/mobile/android/gradle/wrapper/gradle-wrapper.jar b/packages/mobile/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000..a4b76b9530 Binary files /dev/null and b/packages/mobile/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/packages/mobile/android/gradle/wrapper/gradle-wrapper.properties b/packages/mobile/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000..c1d5e01859 --- /dev/null +++ b/packages/mobile/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/packages/mobile/android/gradlew b/packages/mobile/android/gradlew new file mode 100755 index 0000000000..f5feea6d6b --- /dev/null +++ b/packages/mobile/android/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/packages/mobile/android/gradlew.bat b/packages/mobile/android/gradlew.bat new file mode 100644 index 0000000000..9b42019c79 --- /dev/null +++ b/packages/mobile/android/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/packages/mobile/android/settings.gradle b/packages/mobile/android/settings.gradle new file mode 100644 index 0000000000..3b4431d772 --- /dev/null +++ b/packages/mobile/android/settings.gradle @@ -0,0 +1,5 @@ +include ':app' +include ':capacitor-cordova-android-plugins' +project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/') + +apply from: 'capacitor.settings.gradle' \ No newline at end of file diff --git a/packages/mobile/android/variables.gradle b/packages/mobile/android/variables.gradle new file mode 100644 index 0000000000..fefc245150 --- /dev/null +++ b/packages/mobile/android/variables.gradle @@ -0,0 +1,13 @@ +ext { + minSdkVersion = 24 + compileSdkVersion = 35 + targetSdkVersion = 35 + androidxActivityVersion = '1.9.2' + androidxAppCompatVersion = '1.7.0' + androidxCoordinatorLayoutVersion = '1.2.0' + androidxCoreVersion = '1.15.0' + androidxFragmentVersion = '1.8.4' + coreSplashScreenVersion = '1.0.1' + androidxWebkitVersion = '1.12.1' + cordovaAndroidVersion = '13.0.0' +} diff --git a/packages/mobile/assets/icon-background.png b/packages/mobile/assets/icon-background.png new file mode 100644 index 0000000000..00751d9f0b Binary files /dev/null and b/packages/mobile/assets/icon-background.png differ diff --git a/packages/mobile/assets/icon-foreground.png b/packages/mobile/assets/icon-foreground.png new file mode 100644 index 0000000000..1e56611099 Binary files /dev/null and b/packages/mobile/assets/icon-foreground.png differ diff --git a/packages/mobile/assets/icon-only.png b/packages/mobile/assets/icon-only.png new file mode 100644 index 0000000000..2171b7dc5c Binary files /dev/null and b/packages/mobile/assets/icon-only.png differ diff --git a/packages/mobile/capacitor.config.ts b/packages/mobile/capacitor.config.ts new file mode 100644 index 0000000000..d397caffeb --- /dev/null +++ b/packages/mobile/capacitor.config.ts @@ -0,0 +1,33 @@ +import type { CapacitorConfig } from '@capacitor/cli'; + +const config: CapacitorConfig = { + appId: 'com.openchamber.app', + appName: 'OpenChamber', + webDir: 'dist', + server: { + androidScheme: 'https', + }, + plugins: { + Keyboard: { + // 'none' leaves the WebView at full height; the UI follows the keyboard + // itself via the --oc-keyboard-inset CSS variable driven by keyboardWillShow + // (see useNativeMobileChrome). The built-in 'native' resize lands only after + // the keyboard animation finishes, which looked like a ~1.5s lag. + resize: 'none', + resizeOnFullScreen: true, + autoBackdropColor: 'dom', + }, + StatusBar: { + overlaysWebView: true, + style: 'DEFAULT', + }, + PushNotifications: { + // Never display an APNs alert while the app is foreground. The server always sends + // (no racy visibility gate); iOS suppresses the foreground banner, so there is no + // notification when the app is active. Background pushes are shown by iOS as usual. + presentationOptions: [], + }, + }, +}; + +export default config; diff --git a/packages/mobile/ios/.gitignore b/packages/mobile/ios/.gitignore new file mode 100644 index 0000000000..f47029973b --- /dev/null +++ b/packages/mobile/ios/.gitignore @@ -0,0 +1,13 @@ +App/build +App/Pods +App/output +App/App/public +DerivedData +xcuserdata + +# Cordova plugins for Capacitor +capacitor-cordova-ios-plugins + +# Generated Config files +App/App/capacitor.config.json +App/App/config.xml diff --git a/packages/mobile/ios/App/App.xcodeproj/project.pbxproj b/packages/mobile/ios/App/App.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..94498243eb --- /dev/null +++ b/packages/mobile/ios/App/App.xcodeproj/project.pbxproj @@ -0,0 +1,738 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; }; + 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; }; + 504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; }; + 504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; }; + 504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; }; + D0C2000000000000000000B1 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = D0C1000000000000000000A1 /* PrivacyInfo.xcprivacy */; }; + 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; }; + 50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; }; + 8E7A4F1A2C4B4C749E0A1001 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 8E7A4F192C4B4C749E0A1001 /* AppIcon.icon */; }; + A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */; }; + D0A2000000000000000000B1 /* WidgetShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1000000000000000000A1 /* WidgetShared.swift */; }; + D0A2000000000000000000B2 /* OpenChamberWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1000000000000000000A2 /* OpenChamberWidgets.swift */; }; + D0A2000000000000000000B3 /* OpenChamberControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1000000000000000000A3 /* OpenChamberControl.swift */; }; + D0A2000000000000000000B6 /* OpenChamberControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1000000000000000000A3 /* OpenChamberControl.swift */; }; + D0A2000000000000000000B4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D0A1000000000000000000A4 /* Assets.xcassets */; }; + D0A2000000000000000000B5 /* OpenChamberWidget.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = D0A1000000000000000000A7 /* OpenChamberWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + D0B2000000000000000000B1 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B1000000000000000000A1 /* NotificationService.swift */; }; + D0B2000000000000000000B5 /* OpenChamberNotificationService.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = D0B1000000000000000000A7 /* OpenChamberNotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = ""; }; + 50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = ""; }; + 504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + D0C1000000000000000000A1 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; + 504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = ""; }; + 8E7A4F192C4B4C749E0A1001 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; name = AppIcon.icon; path = ../../../electron/resources/icons/AppIcon.icon; sourceTree = SOURCE_ROOT; }; + AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = ""; }; + FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = ""; }; + D0A1000000000000000000A1 /* WidgetShared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetShared.swift; sourceTree = ""; }; + D0A1000000000000000000A2 /* OpenChamberWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenChamberWidgets.swift; sourceTree = ""; }; + D0A1000000000000000000A3 /* OpenChamberControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenChamberControl.swift; sourceTree = ""; }; + D0A1000000000000000000A4 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + D0A1000000000000000000A5 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + D0A1000000000000000000A6 /* OpenChamberWidget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = OpenChamberWidget.entitlements; sourceTree = ""; }; + D0A1000000000000000000A7 /* OpenChamberWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = OpenChamberWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + D0B1000000000000000000A1 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; + D0B1000000000000000000A2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + D0B1000000000000000000A3 /* OpenChamberNotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = OpenChamberNotificationService.entitlements; sourceTree = ""; }; + D0B1000000000000000000A7 /* OpenChamberNotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = OpenChamberNotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 504EC3011FED79650016851F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D0A3000000000000000000C2 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D0B3000000000000000000C2 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXContainerItemProxy section */ + D0A4000000000000000000D3 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 504EC2FC1FED79650016851F /* Project object */; + proxyType = 1; + remoteGlobalIDString = D0A4000000000000000000D1; + remoteInfo = OpenChamberWidget; + }; + D0B4000000000000000000D3 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 504EC2FC1FED79650016851F /* Project object */; + proxyType = 1; + remoteGlobalIDString = D0B4000000000000000000D1; + remoteInfo = OpenChamberNotificationService; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + D0A3000000000000000000C4 /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + D0A2000000000000000000B5 /* OpenChamberWidget.appex in Embed App Extensions */, + D0B2000000000000000000B5 /* OpenChamberNotificationService.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXGroup section */ + 27E2DDA53C4D2A4D1A88CE4A /* Frameworks */ = { + isa = PBXGroup; + children = ( + AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 504EC2FB1FED79650016851F = { + isa = PBXGroup; + children = ( + 504EC3061FED79650016851F /* App */, + D0A6000000000000000000F1 /* OpenChamberWidget */, + D0B6000000000000000000F1 /* OpenChamberNotificationService */, + 504EC3051FED79650016851F /* Products */, + 7F8756D8B27F46E3366F6CEA /* Pods */, + 27E2DDA53C4D2A4D1A88CE4A /* Frameworks */, + ); + sourceTree = ""; + }; + 504EC3051FED79650016851F /* Products */ = { + isa = PBXGroup; + children = ( + 504EC3041FED79650016851F /* App.app */, + D0A1000000000000000000A7 /* OpenChamberWidget.appex */, + D0B1000000000000000000A7 /* OpenChamberNotificationService.appex */, + ); + name = Products; + sourceTree = ""; + }; + D0A6000000000000000000F1 /* OpenChamberWidget */ = { + isa = PBXGroup; + children = ( + D0A1000000000000000000A1 /* WidgetShared.swift */, + D0A1000000000000000000A2 /* OpenChamberWidgets.swift */, + D0A1000000000000000000A3 /* OpenChamberControl.swift */, + D0A1000000000000000000A4 /* Assets.xcassets */, + D0A1000000000000000000A5 /* Info.plist */, + D0A1000000000000000000A6 /* OpenChamberWidget.entitlements */, + ); + path = OpenChamberWidget; + sourceTree = ""; + }; + D0B6000000000000000000F1 /* OpenChamberNotificationService */ = { + isa = PBXGroup; + children = ( + D0B1000000000000000000A1 /* NotificationService.swift */, + D0B1000000000000000000A2 /* Info.plist */, + D0B1000000000000000000A3 /* OpenChamberNotificationService.entitlements */, + ); + path = OpenChamberNotificationService; + sourceTree = ""; + }; + 504EC3061FED79650016851F /* App */ = { + isa = PBXGroup; + children = ( + 50379B222058CBB4000EE86E /* capacitor.config.json */, + 504EC3071FED79650016851F /* AppDelegate.swift */, + 504EC30B1FED79650016851F /* Main.storyboard */, + 504EC30E1FED79650016851F /* Assets.xcassets */, + D0C1000000000000000000A1 /* PrivacyInfo.xcprivacy */, + 8E7A4F192C4B4C749E0A1001 /* AppIcon.icon */, + 504EC3101FED79650016851F /* LaunchScreen.storyboard */, + 504EC3131FED79650016851F /* Info.plist */, + 2FAD9762203C412B000D30F8 /* config.xml */, + 50B271D01FEDC1A000F3C39B /* public */, + ); + path = App; + sourceTree = ""; + }; + 7F8756D8B27F46E3366F6CEA /* Pods */ = { + isa = PBXGroup; + children = ( + FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */, + AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */, + ); + name = Pods; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 504EC3031FED79650016851F /* App */ = { + isa = PBXNativeTarget; + buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */; + buildPhases = ( + 6634F4EFEBD30273BCE97C65 /* [CP] Check Pods Manifest.lock */, + 504EC3001FED79650016851F /* Sources */, + 504EC3011FED79650016851F /* Frameworks */, + 504EC3021FED79650016851F /* Resources */, + 9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */, + D0A3000000000000000000C4 /* Embed App Extensions */, + ); + buildRules = ( + ); + dependencies = ( + D0A4000000000000000000D2 /* PBXTargetDependency */, + D0B4000000000000000000D2 /* PBXTargetDependency */, + ); + name = App; + productName = App; + productReference = 504EC3041FED79650016851F /* App.app */; + productType = "com.apple.product-type.application"; + }; + D0A4000000000000000000D1 /* OpenChamberWidget */ = { + isa = PBXNativeTarget; + buildConfigurationList = D0A5000000000000000000E1 /* Build configuration list for PBXNativeTarget "OpenChamberWidget" */; + buildPhases = ( + D0A3000000000000000000C1 /* Sources */, + D0A3000000000000000000C2 /* Frameworks */, + D0A3000000000000000000C3 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = OpenChamberWidget; + productName = OpenChamberWidget; + productReference = D0A1000000000000000000A7 /* OpenChamberWidget.appex */; + productType = "com.apple.product-type.app-extension"; + }; + D0B4000000000000000000D1 /* OpenChamberNotificationService */ = { + isa = PBXNativeTarget; + buildConfigurationList = D0B5000000000000000000E1 /* Build configuration list for PBXNativeTarget "OpenChamberNotificationService" */; + buildPhases = ( + D0B3000000000000000000C1 /* Sources */, + D0B3000000000000000000C2 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = OpenChamberNotificationService; + productName = OpenChamberNotificationService; + productReference = D0B1000000000000000000A7 /* OpenChamberNotificationService.appex */; + productType = "com.apple.product-type.app-extension"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 504EC2FC1FED79650016851F /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 2700; + TargetAttributes = { + 504EC3031FED79650016851F = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + }; + D0A4000000000000000000D1 = { + CreatedOnToolsVersion = 16.0; + ProvisioningStyle = Automatic; + }; + D0B4000000000000000000D1 = { + CreatedOnToolsVersion = 16.0; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */; + compatibilityVersion = "Xcode 8.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 504EC2FB1FED79650016851F; + packageReferences = ( + ); + productRefGroup = 504EC3051FED79650016851F /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 504EC3031FED79650016851F /* App */, + D0A4000000000000000000D1 /* OpenChamberWidget */, + D0B4000000000000000000D1 /* OpenChamberNotificationService */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 504EC3021FED79650016851F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */, + 50B271D11FEDC1A000F3C39B /* public in Resources */, + 504EC30F1FED79650016851F /* Assets.xcassets in Resources */, + D0C2000000000000000000B1 /* PrivacyInfo.xcprivacy in Resources */, + 8E7A4F1A2C4B4C749E0A1001 /* AppIcon.icon in Resources */, + 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */, + 504EC30D1FED79650016851F /* Main.storyboard in Resources */, + 2FAD9763203C412B000D30F8 /* config.xml in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D0A3000000000000000000C3 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D0A2000000000000000000B4 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 6634F4EFEBD30273BCE97C65 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-App-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "[CP] Embed Pods Frameworks"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-App/Pods-App-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 504EC3001FED79650016851F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 504EC3081FED79650016851F /* AppDelegate.swift in Sources */, + D0A2000000000000000000B6 /* OpenChamberControl.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D0A3000000000000000000C1 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D0A2000000000000000000B1 /* WidgetShared.swift in Sources */, + D0A2000000000000000000B2 /* OpenChamberWidgets.swift in Sources */, + D0A2000000000000000000B3 /* OpenChamberControl.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D0B3000000000000000000C1 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D0B2000000000000000000B1 /* NotificationService.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + D0A4000000000000000000D2 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D0A4000000000000000000D1 /* OpenChamberWidget */; + targetProxy = D0A4000000000000000000D3 /* PBXContainerItemProxy */; + }; + D0B4000000000000000000D2 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D0B4000000000000000000D1 /* OpenChamberNotificationService */; + targetProxy = D0B4000000000000000000D3 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 504EC30B1FED79650016851F /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 504EC30C1FED79650016851F /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 504EC3101FED79650016851F /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 504EC3111FED79650016851F /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 504EC3141FED79650016851F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = 5J7WJGPA2Q; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 504EC3151FED79650016851F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = 5J7WJGPA2Q; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 504EC3171FED79650016851F /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CODE_SIGN_ENTITLEMENTS = App/App.entitlements; + CURRENT_PROJECT_VERSION = 1; + INFOPLIST_FILE = App/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = OpenChamber; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; + IPHONEOS_DEPLOYMENT_TARGET = "$(RECOMMENDED_IPHONEOS_DEPLOYMENT_TARGET)"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; + PRODUCT_BUNDLE_IDENTIFIER = com.openchamber.app; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 504EC3181FED79650016851F /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CODE_SIGN_ENTITLEMENTS = App/App.entitlements; + CURRENT_PROJECT_VERSION = 1; + INFOPLIST_FILE = App/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = OpenChamber; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; + IPHONEOS_DEPLOYMENT_TARGET = "$(RECOMMENDED_IPHONEOS_DEPLOYMENT_TARGET)"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.openchamber.app; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + D0A5000000000000000000E2 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = OpenChamberWidget/OpenChamberWidget.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 5J7WJGPA2Q; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = OpenChamberWidget/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.openchamber.app.OpenChamberWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + D0A5000000000000000000E3 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = OpenChamberWidget/OpenChamberWidget.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 5J7WJGPA2Q; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = OpenChamberWidget/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.openchamber.app.OpenChamberWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + D0B5000000000000000000E2 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = OpenChamberNotificationService/OpenChamberNotificationService.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 5J7WJGPA2Q; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = OpenChamberNotificationService/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.openchamber.app.OpenChamberNotificationService; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + D0B5000000000000000000E3 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = OpenChamberNotificationService/OpenChamberNotificationService.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 5J7WJGPA2Q; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = OpenChamberNotificationService/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.openchamber.app.OpenChamberNotificationService; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 504EC3141FED79650016851F /* Debug */, + 504EC3151FED79650016851F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 504EC3171FED79650016851F /* Debug */, + 504EC3181FED79650016851F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + D0A5000000000000000000E1 /* Build configuration list for PBXNativeTarget "OpenChamberWidget" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D0A5000000000000000000E2 /* Debug */, + D0A5000000000000000000E3 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + D0B5000000000000000000E1 /* Build configuration list for PBXNativeTarget "OpenChamberNotificationService" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D0B5000000000000000000E2 /* Debug */, + D0B5000000000000000000E3 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 504EC2FC1FED79650016851F /* Project object */; +} diff --git a/packages/mobile/ios/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme b/packages/mobile/ios/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme new file mode 100644 index 0000000000..f57acce6a6 --- /dev/null +++ b/packages/mobile/ios/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mobile/ios/App/App.xcodeproj/xcshareddata/xcschemes/OpenChamberWidget.xcscheme b/packages/mobile/ios/App/App.xcodeproj/xcshareddata/xcschemes/OpenChamberWidget.xcscheme new file mode 100644 index 0000000000..427e1b9067 --- /dev/null +++ b/packages/mobile/ios/App/App.xcodeproj/xcshareddata/xcschemes/OpenChamberWidget.xcscheme @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mobile/ios/App/App.xcworkspace/contents.xcworkspacedata b/packages/mobile/ios/App/App.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..b301e824b3 --- /dev/null +++ b/packages/mobile/ios/App/App.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/packages/mobile/ios/App/App.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/mobile/ios/App/App.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/packages/mobile/ios/App/App.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/mobile/ios/App/App/App.entitlements b/packages/mobile/ios/App/App/App.entitlements new file mode 100644 index 0000000000..14e33ec302 --- /dev/null +++ b/packages/mobile/ios/App/App/App.entitlements @@ -0,0 +1,18 @@ + + + + + + aps-environment + development + + com.apple.security.application-groups + + group.com.openchamber.app + + + diff --git a/packages/mobile/ios/App/App/AppDelegate.swift b/packages/mobile/ios/App/App/AppDelegate.swift new file mode 100644 index 0000000000..d93645550a --- /dev/null +++ b/packages/mobile/ios/App/App/AppDelegate.swift @@ -0,0 +1,162 @@ +import UIKit +import Capacitor +import UserNotifications +import WidgetKit + +@UIApplicationMain +class AppDelegate: UIResponder, UIApplicationDelegate { + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Override point for customization after application launch. + return true + } + + func applicationWillResignActive(_ application: UIApplication) { + // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. + // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. + } + + func applicationDidEnterBackground(_ application: UIApplication) { + // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. + // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. + } + + func applicationWillEnterForeground(_ application: UIApplication) { + // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. + } + + func applicationDidBecomeActive(_ application: UIApplication) { + // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. + } + + func applicationWillTerminate(_ application: UIApplication) { + // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. + } + + func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { + // Called when the app was launched with a url. Feel free to add additional processing here, + // but if you want the App API to support tracking app url opens, make sure to keep this call + return ApplicationDelegateProxy.shared.application(app, open: url, options: options) + } + + func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { + // Called when the app was launched with an activity, including Universal Links. + // Feel free to add additional processing here, but if you want the App API to support + // tracking app url opens, make sure to keep this call + return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler) + } + + // Forward APNs registration to Capacitor so @capacitor/push-notifications can + // deliver the device token / error to the JS `registration` / `registrationError` + // listeners. Required because this app uses a custom AppDelegate (not the stock + // Capacitor template, which already posts these notifications). + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + NotificationCenter.default.post(name: .capacitorDidRegisterForRemoteNotifications, object: deviceToken) + } + + func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { + NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error) + } + +} + +// iOS 26 (TN3187) requires apps built with the latest SDK to adopt the UIScene +// lifecycle. Capacitor 7's template still uses the legacy window setup, so we host a +// minimal scene delegate here that loads the Main storyboard (CAPBridgeViewController) +// and forwards deep links / universal links into Capacitor's delegate proxy. +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + var window: UIWindow? + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + guard let windowScene = scene as? UIWindowScene else { return } + let window = UIWindow(windowScene: windowScene) + let storyboard = UIStoryboard(name: "Main", bundle: nil) + window.rootViewController = storyboard.instantiateInitialViewController() + self.window = window + window.makeKeyAndVisible() + + configureWebViewChrome() + + if let urlContext = connectionOptions.urlContexts.first { + _ = ApplicationDelegateProxy.shared.application(UIApplication.shared, open: urlContext.url, options: [:]) + } + if let userActivity = connectionOptions.userActivities.first { + _ = ApplicationDelegateProxy.shared.application(UIApplication.shared, continue: userActivity) { _ in } + } + } + + func sceneDidBecomeActive(_ scene: UIScene) { + // Re-assert in case the WebView wasn't ready at scene-connect time, or the + // effect was re-enabled while backgrounded. + configureWebViewChrome() + + // Clear the app-icon badge whenever the app becomes active. The server sends + // an absolute badge count (sessions needing attention) on each push; once the + // user is looking at the app, the in-app indicators take over, so reset to 0. + if #available(iOS 17.0, *) { + UNUserNotificationCenter.current().setBadgeCount(0) + } else { + UIApplication.shared.applicationIconBadgeNumber = 0 + } + + // Refresh the widgets' session overview now that the WebView is loaded and state is fresh. + writeWidgetSnapshot() + } + + func sceneWillResignActive(_ scene: UIScene) { + // Capture the latest session overview before the app leaves the foreground, so the + // home/lock-screen/Control Center widgets reflect what the user just saw. + writeWidgetSnapshot() + } + + private static let widgetAppGroup = "group.com.openchamber.app" + private static let widgetSnapshotKey = "widgetSnapshot" + + /// Pulls the session overview JSON from the web layer (window.__OPENCHAMBER_WIDGET_SNAPSHOT__), + /// stores it in the shared App Group, and reloads the widget timelines. localStorage/stores + /// aren't reachable from the widget process, so this is how the bundled UI feeds the widgets — + /// no server involved. Failures are ignored so a transient read never clobbers a good snapshot. + private func writeWidgetSnapshot() { + guard let bridge = window?.rootViewController as? CAPBridgeViewController, + let webView = bridge.webView else { return } + let js = "(typeof window.__OPENCHAMBER_WIDGET_SNAPSHOT__ === 'function') ? window.__OPENCHAMBER_WIDGET_SNAPSHOT__() : null" + webView.evaluateJavaScript(js) { result, _ in + guard let json = result as? String, !json.isEmpty, + let defaults = UserDefaults(suiteName: SceneDelegate.widgetAppGroup) else { return } + // Only write + reload when the overview actually changed. We write this on every + // scene activate/resign; reloading WidgetCenter every time burns the WidgetKit + // reload budget and leaves some widgets stale (the snapshot no longer contains a + // per-call timestamp, so identical overviews compare equal). + if defaults.string(forKey: SceneDelegate.widgetSnapshotKey) == json { return } + defaults.set(json, forKey: SceneDelegate.widgetSnapshotKey) + WidgetCenter.shared.reloadAllTimelines() + } + } + + /// iOS 26 (Liquid Glass) automatically applies a "scroll edge effect" — a blur + + /// appearance-coloured dim — to the top/bottom of a scroll view beneath the system + /// bars. On the full-screen WKWebView that renders as a dark band behind the status + /// bar in Dark Mode (independent of the in-app theme). Hide it so the web content + /// (which paints its own themed background) is what shows under the status bar. + private func configureWebViewChrome() { + guard let bridge = window?.rootViewController as? CAPBridgeViewController, + let webView = bridge.webView else { return } + webView.isOpaque = false + webView.backgroundColor = .clear + webView.scrollView.backgroundColor = .clear + if #available(iOS 26.0, *) { + webView.scrollView.topEdgeEffect.isHidden = true + webView.scrollView.bottomEdgeEffect.isHidden = true + } + } + + func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + guard let urlContext = URLContexts.first else { return } + _ = ApplicationDelegateProxy.shared.application(UIApplication.shared, open: urlContext.url, options: [:]) + } + + func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { + _ = ApplicationDelegateProxy.shared.application(UIApplication.shared, continue: userActivity) { _ in } + } +} diff --git a/packages/mobile/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png b/packages/mobile/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png new file mode 100644 index 0000000000..adf6ba01db Binary files /dev/null and b/packages/mobile/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png differ diff --git a/packages/mobile/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json b/packages/mobile/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..9b7d382dce --- /dev/null +++ b/packages/mobile/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-512@2x.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/packages/mobile/ios/App/App/Assets.xcassets/Contents.json b/packages/mobile/ios/App/App/Assets.xcassets/Contents.json new file mode 100644 index 0000000000..da4a164c91 --- /dev/null +++ b/packages/mobile/ios/App/App/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/packages/mobile/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json b/packages/mobile/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json new file mode 100644 index 0000000000..d7d96a67c0 --- /dev/null +++ b/packages/mobile/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "splash-2732x2732-2.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "splash-2732x2732-1.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "splash-2732x2732.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/packages/mobile/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png b/packages/mobile/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png new file mode 100644 index 0000000000..33ea6c970f Binary files /dev/null and b/packages/mobile/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png differ diff --git a/packages/mobile/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png b/packages/mobile/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png new file mode 100644 index 0000000000..33ea6c970f Binary files /dev/null and b/packages/mobile/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png differ diff --git a/packages/mobile/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png b/packages/mobile/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png new file mode 100644 index 0000000000..33ea6c970f Binary files /dev/null and b/packages/mobile/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png differ diff --git a/packages/mobile/ios/App/App/Base.lproj/LaunchScreen.storyboard b/packages/mobile/ios/App/App/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000000..e7ae5d7802 --- /dev/null +++ b/packages/mobile/ios/App/App/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mobile/ios/App/App/Base.lproj/Main.storyboard b/packages/mobile/ios/App/App/Base.lproj/Main.storyboard new file mode 100644 index 0000000000..b44df7be8f --- /dev/null +++ b/packages/mobile/ios/App/App/Base.lproj/Main.storyboard @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/packages/mobile/ios/App/App/Info.plist b/packages/mobile/ios/App/App/Info.plist new file mode 100644 index 0000000000..2d96c87434 --- /dev/null +++ b/packages/mobile/ios/App/App/Info.plist @@ -0,0 +1,94 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + OpenChamber + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + ITSAppUsesNonExemptEncryption + + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSAllowsArbitraryLoadsInWebContent + + NSAllowsLocalNetworking + + + NSLocalNetworkUsageDescription + OpenChamber connects to OpenChamber servers on your local network. + NSCameraUsageDescription + OpenChamber uses the camera to scan a server's pairing QR code. + NSMicrophoneUsageDescription + OpenChamber uses the microphone for voice dictation in the chat composer. + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + com.openchamber.app.deeplink + CFBundleURLSchemes + + openchamber + + + + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + UILaunchStoryboardName + LaunchScreen + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/packages/mobile/ios/App/App/PrivacyInfo.xcprivacy b/packages/mobile/ios/App/App/PrivacyInfo.xcprivacy new file mode 100644 index 0000000000..71a204d67e --- /dev/null +++ b/packages/mobile/ios/App/App/PrivacyInfo.xcprivacy @@ -0,0 +1,30 @@ + + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + + NSPrivacyCollectedDataTypes + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + C56D.1 + + + + + diff --git a/packages/mobile/ios/App/OpenChamberNotificationService/Info.plist b/packages/mobile/ios/App/OpenChamberNotificationService/Info.plist new file mode 100644 index 0000000000..51328fd309 --- /dev/null +++ b/packages/mobile/ios/App/OpenChamberNotificationService/Info.plist @@ -0,0 +1,29 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + OpenChamberNotificationService + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.usernotifications.service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).NotificationService + + + diff --git a/packages/mobile/ios/App/OpenChamberNotificationService/NotificationService.swift b/packages/mobile/ios/App/OpenChamberNotificationService/NotificationService.swift new file mode 100644 index 0000000000..ed29076a21 --- /dev/null +++ b/packages/mobile/ios/App/OpenChamberNotificationService/NotificationService.swift @@ -0,0 +1,71 @@ +import UserNotifications +import WidgetKit + +/// Runs on every incoming push that carries `mutable-content: 1` — even when the app is closed +/// — and refreshes the widgets' shared snapshot so the home/lock-screen attention count and +/// unread dot stay current without the app having to foreground. It makes NO network calls: +/// it reads the count the server already put in `aps.badge` and the `sessionId` from the push, +/// updates the App Group snapshot the app wrote, and reloads the widget timelines. The app +/// still overwrites the snapshot with the authoritative full list on its next foreground. +class NotificationService: UNNotificationServiceExtension { + private static let appGroup = "group.com.openchamber.app" + private static let snapshotKey = "widgetSnapshot" + + private var contentHandler: ((UNNotificationContent) -> Void)? + private var bestAttempt: UNMutableNotificationContent? + + override func didReceive( + _ request: UNNotificationRequest, + withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void + ) { + self.contentHandler = contentHandler + self.bestAttempt = request.content.mutableCopy() as? UNMutableNotificationContent + + refreshWidgetSnapshot(from: request) + + // Deliver the notification unchanged (we only used the push to refresh widgets). + contentHandler(bestAttempt ?? request.content) + } + + override func serviceExtensionTimeWillExpire() { + if let handler = contentHandler { + handler(bestAttempt ?? UNNotificationContent()) + } + } + + private func refreshWidgetSnapshot(from request: UNNotificationRequest) { + guard let defaults = UserDefaults(suiteName: Self.appGroup) else { return } + + var snapshot: [String: Any] = [ + "attentionCount": 0, + "recentSessions": [], + ] + if let json = defaults.string(forKey: Self.snapshotKey), + let data = json.data(using: .utf8), + let stored = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + snapshot = stored + } + + // Attention count: authoritative server value carried in aps.badge. + if let badge = request.content.badge as? Int { + snapshot["attentionCount"] = badge + } + + // Mark the pushed session unread in the existing recent list (best-effort; the full + // list/titles only refresh when the app next foregrounds). + if let sessionId = request.content.userInfo["sessionId"] as? String, + var sessions = snapshot["recentSessions"] as? [[String: Any]] { + for index in sessions.indices where sessions[index]["id"] as? String == sessionId { + sessions[index]["unread"] = true + } + snapshot["recentSessions"] = sessions + } + + if let data = try? JSONSerialization.data(withJSONObject: snapshot), + let json = String(data: data, encoding: .utf8) { + defaults.set(json, forKey: Self.snapshotKey) + } + + WidgetCenter.shared.reloadAllTimelines() + } +} diff --git a/packages/mobile/ios/App/OpenChamberNotificationService/OpenChamberNotificationService.entitlements b/packages/mobile/ios/App/OpenChamberNotificationService/OpenChamberNotificationService.entitlements new file mode 100644 index 0000000000..149617ae03 --- /dev/null +++ b/packages/mobile/ios/App/OpenChamberNotificationService/OpenChamberNotificationService.entitlements @@ -0,0 +1,11 @@ + + + + + + com.apple.security.application-groups + + group.com.openchamber.app + + + diff --git a/packages/mobile/ios/App/OpenChamberWidget/Assets.xcassets/Contents.json b/packages/mobile/ios/App/OpenChamberWidget/Assets.xcassets/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/packages/mobile/ios/App/OpenChamberWidget/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/packages/mobile/ios/App/OpenChamberWidget/Assets.xcassets/OCLogoSymbol.symbolset/Contents.json b/packages/mobile/ios/App/OpenChamberWidget/Assets.xcassets/OCLogoSymbol.symbolset/Contents.json new file mode 100644 index 0000000000..03c3f191f8 --- /dev/null +++ b/packages/mobile/ios/App/OpenChamberWidget/Assets.xcassets/OCLogoSymbol.symbolset/Contents.json @@ -0,0 +1,13 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "symbols" : [ + { + "filename" : "oclogo-symbol.svg", + "idiom" : "universal", + "rendering-intent" : "template" + } + ] +} diff --git a/packages/mobile/ios/App/OpenChamberWidget/Assets.xcassets/OCLogoSymbol.symbolset/oclogo-symbol.svg b/packages/mobile/ios/App/OpenChamberWidget/Assets.xcassets/OCLogoSymbol.symbolset/oclogo-symbol.svg new file mode 100644 index 0000000000..e7ac3b374f --- /dev/null +++ b/packages/mobile/ios/App/OpenChamberWidget/Assets.xcassets/OCLogoSymbol.symbolset/oclogo-symbol.svg @@ -0,0 +1,55 @@ + + + + + + Small + Medium + Large + + + Ultralight + Regular + Black + Template v.3.0 + + https://github.com/swhitty/SwiftDraw + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/mobile/ios/App/OpenChamberWidget/Info.plist b/packages/mobile/ios/App/OpenChamberWidget/Info.plist new file mode 100644 index 0000000000..3eb8c038e4 --- /dev/null +++ b/packages/mobile/ios/App/OpenChamberWidget/Info.plist @@ -0,0 +1,27 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + OpenChamber + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/packages/mobile/ios/App/OpenChamberWidget/OpenChamberControl.swift b/packages/mobile/ios/App/OpenChamberWidget/OpenChamberControl.swift new file mode 100644 index 0000000000..93f345f8b9 --- /dev/null +++ b/packages/mobile/ios/App/OpenChamberWidget/OpenChamberControl.swift @@ -0,0 +1,38 @@ +import AppIntents +import SwiftUI +import WidgetKit + +// Control Center control (iOS 18+): tap the OpenChamber logo to start a new session. +// +// IMPORTANT: this file is a member of BOTH the app target and the widget extension target. +// iOS requires the control's AppIntent to exist in the app target too, otherwise tapping the +// control can't open the app (the tap does nothing). It's kept self-contained (inline URL, no +// dependency on the widget's shared code) so it compiles cleanly in the app target. +@available(iOS 18.0, *) +struct OpenChamberNewSessionControl: ControlWidget { + var body: some ControlWidgetConfiguration { + StaticControlConfiguration(kind: "OpenChamberNewSessionControl") { + ControlWidgetButton(action: OpenNewSessionIntent()) { + // Custom symbol is referenced via `image:` (the asset-catalog symbol path; + // `systemImage:` only finds Apple's system SF Symbols → shows a "?"). The glyph + // uses bold strokes so it stays visible at the control's small, tinted size — + // thin strokes rendered blank. + Label("New Session", image: "OCLogoSymbol") + } + } + .displayName("New Session") + .description("Start a new OpenChamber session.") + } +} + +@available(iOS 18.0, *) +struct OpenNewSessionIntent: AppIntent { + static let title: LocalizedStringResource = "New OpenChamber Session" + static let openAppWhenRun: Bool = true + static let isDiscoverable: Bool = true + + @MainActor + func perform() async throws -> some IntentResult & OpensIntent { + return .result(opensIntent: OpenURLIntent(URL(string: "openchamber://new")!)) + } +} diff --git a/packages/mobile/ios/App/OpenChamberWidget/OpenChamberWidget.entitlements b/packages/mobile/ios/App/OpenChamberWidget/OpenChamberWidget.entitlements new file mode 100644 index 0000000000..3fc5495fd1 --- /dev/null +++ b/packages/mobile/ios/App/OpenChamberWidget/OpenChamberWidget.entitlements @@ -0,0 +1,11 @@ + + + + + + com.apple.security.application-groups + + group.com.openchamber.app + + + diff --git a/packages/mobile/ios/App/OpenChamberWidget/OpenChamberWidgets.swift b/packages/mobile/ios/App/OpenChamberWidget/OpenChamberWidgets.swift new file mode 100644 index 0000000000..51122f3ce8 --- /dev/null +++ b/packages/mobile/ios/App/OpenChamberWidget/OpenChamberWidgets.swift @@ -0,0 +1,321 @@ +import SwiftUI +import WidgetKit + +// MARK: - Medium home-screen widget: recent sessions (left) + quick actions (right) + +struct OverviewWidgetView: View { + let entry: OverviewEntry + + var body: some View { + HStack(alignment: .center, spacing: 16) { + sessionsColumn + actionsGrid + } + } + + private var sessionsColumn: some View { + VStack(alignment: .leading, spacing: 0) { + if entry.snapshot.recentSessions.isEmpty { + Text("No sessions yet") + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } else { + ForEach(entry.snapshot.recentSessions.prefix(4)) { session in + Link(destination: WidgetDeepLink.session(session.id)) { + HStack(spacing: 8) { + // Every row shows a same-size dot so titles align: a filled orange + // dot for unread, a hollow grey ring for read. + unreadIndicator(session.unread) + Text(session.title.isEmpty ? "Untitled" : session.title) + .font(.subheadline) + .fontWeight(session.unread ? .semibold : .regular) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 0) + } + // Each row claims an equal share of the height → even distribution, no gap. + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } + .foregroundStyle(.primary) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } + + @ViewBuilder + private func unreadIndicator(_ unread: Bool) -> some View { + if unread { + Circle() + .fill(Color.orange) + .frame(width: 7, height: 7) + } else { + Circle() + .strokeBorder(Color.secondary.opacity(0.4), lineWidth: 1.5) + .frame(width: 7, height: 7) + } + } + + private var actionsGrid: some View { + VStack(spacing: 16) { + HStack(spacing: 16) { + actionButton(systemImage: "plus", url: WidgetDeepLink.newSession()) + actionButton(systemImage: "square.stack.3d.up", url: WidgetDeepLink.status()) + } + HStack(spacing: 16) { + actionButton(systemImage: "server.rack", url: WidgetDeepLink.instances()) + actionButton(systemImage: "gearshape", url: WidgetDeepLink.settings()) + } + } + .frame(maxHeight: .infinity) + } + + private func actionButton(systemImage: String, url: URL) -> some View { + Link(destination: url) { + Image(systemName: systemImage) + .font(.system(size: 22, weight: .medium)) + .frame(width: 56, height: 56) + .background(.quaternary, in: Circle()) + } + .foregroundStyle(.primary) + } +} + +struct OverviewWidget: Widget { + let kind = "OpenChamberOverview" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: OverviewProvider()) { entry in + OverviewWidgetView(entry: entry) + .containerBackground(.fill.tertiary, for: .widget) + } + .configurationDisplayName("OpenChamber") + .description("Recent sessions and quick actions.") + .supportedFamilies([.systemMedium]) + } +} + +// MARK: - Small home-screen widget: New Session + quick actions + +struct QuickActionsWidgetView: View { + var body: some View { + VStack(spacing: 10) { + // Wide primary button: New Session. + Link(destination: WidgetDeepLink.newSession()) { + HStack(spacing: 8) { + CubeLogoView() + .frame(width: 26, height: 26) + Text("Chat") + .font(.title3) + .fontWeight(.semibold) + .lineLimit(1) + Spacer(minLength: 0) + } + .padding(.horizontal, 14) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.quaternary, in: Capsule()) + } + .foregroundStyle(.primary) + + // Two round secondary actions. + HStack(spacing: 10) { + quickCircle(systemImage: "square.stack.3d.up", url: WidgetDeepLink.status()) + quickCircle(systemImage: "server.rack", url: WidgetDeepLink.instances()) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + private func quickCircle(systemImage: String, url: URL) -> some View { + Link(destination: url) { + Image(systemName: systemImage) + .font(.system(size: 20, weight: .medium)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.quaternary, in: Circle()) + } + .foregroundStyle(.primary) + } +} + +struct QuickActionsWidget: Widget { + let kind = "OpenChamberQuickActions" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: OverviewProvider()) { _ in + QuickActionsWidgetView() + .containerBackground(.fill.tertiary, for: .widget) + } + .configurationDisplayName("Quick Actions") + .description("New session, status and instances.") + .supportedFamilies([.systemSmall]) + } +} + +// MARK: - Large home-screen widget: full session list with project labels + +struct SessionsWidgetView: View { + let entry: OverviewEntry + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + header + if entry.snapshot.recentSessions.isEmpty { + Text("No sessions yet") + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } else { + VStack(spacing: 0) { + ForEach(entry.snapshot.recentSessions.prefix(6)) { session in + row(session) + } + } + .frame(maxHeight: .infinity, alignment: .top) + } + } + } + + private var header: some View { + HStack(spacing: 8) { + CubeLogoView() + .frame(width: 20, height: 20) + Text("Sessions") + .font(.headline) + Spacer(minLength: 0) + if entry.snapshot.attentionCount > 0 { + Text("\(entry.snapshot.attentionCount)") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.orange) + } + Link(destination: WidgetDeepLink.newSession()) { + Image(systemName: "plus") + .font(.system(size: 15, weight: .semibold)) + .frame(width: 30, height: 30) + .background(.quaternary, in: Circle()) + } + .foregroundStyle(.primary) + } + } + + private func row(_ session: WidgetSession) -> some View { + Link(destination: WidgetDeepLink.session(session.id)) { + HStack(spacing: 10) { + Group { + if session.unread { + Circle().fill(Color.orange) + } else { + Circle().strokeBorder(Color.secondary.opacity(0.4), lineWidth: 1.5) + } + } + .frame(width: 8, height: 8) + VStack(alignment: .leading, spacing: 2) { + Text(session.title.isEmpty ? "Untitled" : session.title) + .font(.subheadline) + .fontWeight(session.unread ? .semibold : .regular) + .lineLimit(1) + if let project = session.project, !project.isEmpty { + Text(project) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 7) + } + .foregroundStyle(.primary) + } +} + +struct SessionsWidget: Widget { + let kind = "OpenChamberSessions" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: OverviewProvider()) { entry in + SessionsWidgetView(entry: entry) + .containerBackground(.fill.tertiary, for: .widget) + } + .configurationDisplayName("Sessions") + .description("Recent sessions with their project.") + .supportedFamilies([.systemLarge]) + } +} + +// MARK: - Lock Screen: logo → new session + +struct LockNewSessionView: View { + var body: some View { + ZStack { + AccessoryWidgetBackground() + CubeLogoView() + .padding(7) + } + .widgetURL(WidgetDeepLink.newSession()) + } +} + +struct LockNewSessionWidget: Widget { + let kind = "OpenChamberLockNew" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: OverviewProvider()) { _ in + LockNewSessionView() + .containerBackground(.clear, for: .widget) + } + .configurationDisplayName("New Session") + .description("Start a new OpenChamber session.") + .supportedFamilies([.accessoryCircular]) + } +} + +// MARK: - Lock Screen: attention counter + +struct LockAttentionView: View { + let entry: OverviewEntry + + var body: some View { + ZStack { + AccessoryWidgetBackground() + VStack(spacing: 0) { + Text("\(entry.snapshot.attentionCount)") + .font(.system(size: 22, weight: .semibold, design: .rounded)) + Image(systemName: "bell.badge") + .font(.system(size: 10)) + } + } + .widgetURL(WidgetDeepLink.attention()) + } +} + +struct LockAttentionWidget: Widget { + let kind = "OpenChamberLockAttention" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: OverviewProvider()) { entry in + LockAttentionView(entry: entry) + .containerBackground(.clear, for: .widget) + } + .configurationDisplayName("Needs Attention") + .description("How many sessions need attention.") + .supportedFamilies([.accessoryCircular]) + } +} + +// MARK: - Bundle + +@main +struct OpenChamberWidgetBundle: WidgetBundle { + var body: some Widget { + OverviewWidget() + SessionsWidget() + QuickActionsWidget() + LockNewSessionWidget() + LockAttentionWidget() + if #available(iOS 18.0, *) { + OpenChamberNewSessionControl() + } + } +} diff --git a/packages/mobile/ios/App/OpenChamberWidget/WidgetShared.swift b/packages/mobile/ios/App/OpenChamberWidget/WidgetShared.swift new file mode 100644 index 0000000000..e98e08e5ab --- /dev/null +++ b/packages/mobile/ios/App/OpenChamberWidget/WidgetShared.swift @@ -0,0 +1,137 @@ +import SwiftUI +import WidgetKit + +// MARK: - Shared model + App Group reader + +/// One row of the session overview the app writes to the shared App Group. +/// Mirrors MobileWidgetSession in packages/ui/src/apps/mobileWidgetSnapshot.ts. +struct WidgetSession: Codable, Identifiable, Hashable { + let id: String + let title: String + let unread: Bool + /// Project label for the session's directory. Optional so snapshots written before this + /// field existed still decode. + var project: String? +} + +/// The session overview snapshot. Mirrors MobileWidgetSnapshot (same field names) so the +/// JSON the app stores decodes directly. +struct WidgetSnapshot: Codable { + let attentionCount: Int + let recentSessions: [WidgetSession] + + static let empty = WidgetSnapshot(attentionCount: 0, recentSessions: []) +} + +enum WidgetStore { + static let appGroup = "group.com.openchamber.app" + static let snapshotKey = "widgetSnapshot" + + /// Reads the latest snapshot the app persisted. Returns `.empty` when nothing has been + /// written yet (fresh install / app never foregrounded) so widgets render a clean state. + static func load() -> WidgetSnapshot { + guard let defaults = UserDefaults(suiteName: appGroup), + let json = defaults.string(forKey: snapshotKey), + let data = json.data(using: .utf8), + let snapshot = try? JSONDecoder().decode(WidgetSnapshot.self, from: data) else { + return .empty + } + return snapshot + } +} + +// MARK: - Deep links (mirror packages/ui/src/apps/deepLinks.ts) + +enum WidgetDeepLink { + static func newSession() -> URL { URL(string: "openchamber://new")! } + static func attention() -> URL { URL(string: "openchamber://sessions?filter=attention")! } + static func status() -> URL { URL(string: "openchamber://status")! } + static func settings() -> URL { URL(string: "openchamber://settings")! } + static func changes() -> URL { URL(string: "openchamber://changes")! } + static func files() -> URL { URL(string: "openchamber://view/files")! } + static func instances() -> URL { URL(string: "openchamber://view/instances")! } + static func session(_ id: String) -> URL { + let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id + return URL(string: "openchamber://session/\(encoded)") ?? newSession() + } +} + +// MARK: - Timeline provider + +struct OverviewEntry: TimelineEntry { + let date: Date + let snapshot: WidgetSnapshot +} + +struct OverviewProvider: TimelineProvider { + func placeholder(in context: Context) -> OverviewEntry { + OverviewEntry(date: Date(), snapshot: .empty) + } + + func getSnapshot(in context: Context, completion: @escaping (OverviewEntry) -> Void) { + completion(OverviewEntry(date: Date(), snapshot: WidgetStore.load())) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + // The app/NSE reload timelines (WidgetCenter) when the snapshot changes, but with several + // widgets sharing the app's WidgetKit reload budget iOS can refresh them unevenly and + // leave one stale. Ask for a periodic refresh too so every widget independently re-reads + // the shared snapshot and converges to the latest state (budget permitting). + let entry = OverviewEntry(date: Date(), snapshot: WidgetStore.load()) + let nextRefresh = Date().addingTimeInterval(10 * 60) + completion(Timeline(entries: [entry], policy: .after(nextRefresh))) + } +} + +// MARK: - Logo (full OpenChamber mark drawn from the SVG) + +/// The OpenChamber logo, drawn to match packages/web/public/logo-dark-512x512.svg: an +/// isometric cube with translucent face fills, stroked edges, and the OpenCode mark on the +/// top face. Faces use low-opacity `.primary` so the system tint on the Lock Screen / Control +/// Center reads as a translucent fill (no colour) rather than a flat wireframe. Coordinates are +/// the SVG inner group (range x:-41.568…41.568, y:-48…48). +struct CubeLogoView: View { + var body: some View { + Canvas { context, size in + let halfW: CGFloat = 41.568 + let halfH: CGFloat = 48 + let scale = min(size.width / (halfW * 2), size.height / (halfH * 2)) + let cx = size.width / 2 + let cy = size.height / 2 + let lineWidth = max(1.5, 3 * scale) + + // Cube coordinate → canvas point. + func p(_ x: CGFloat, _ y: CGFloat) -> CGPoint { CGPoint(x: cx + x * scale, y: cy + y * scale) } + // OpenCode-mark local coordinate → canvas point (SVG: matrix(0.866,0.5,-0.866,0.5,0,-24) · scale(0.75)). + func m(_ x: CGFloat, _ y: CGFloat) -> CGPoint { + let s: CGFloat = 0.75 + let mx = 0.866 * s * x - 0.866 * s * y + let my = 0.5 * s * x + 0.5 * s * y - 24 + return p(mx, my) + } + + var left = Path() + left.move(to: p(0, 0)); left.addLine(to: p(-halfW, -24)); left.addLine(to: p(-halfW, 24)); left.addLine(to: p(0, 48)); left.closeSubpath() + var right = Path() + right.move(to: p(0, 0)); right.addLine(to: p(halfW, -24)); right.addLine(to: p(halfW, 24)); right.addLine(to: p(0, 48)); right.closeSubpath() + var top = Path() + top.move(to: p(0, -48)); top.addLine(to: p(-halfW, -24)); top.addLine(to: p(0, 0)); top.addLine(to: p(halfW, -24)); top.closeSubpath() + + context.fill(left, with: .color(.primary.opacity(0.2))) + context.fill(right, with: .color(.primary.opacity(0.35))) + context.stroke(left, with: .color(.primary), style: StrokeStyle(lineWidth: lineWidth, lineJoin: .round)) + context.stroke(right, with: .color(.primary), style: StrokeStyle(lineWidth: lineWidth, lineJoin: .round)) + context.stroke(top, with: .color(.primary), style: StrokeStyle(lineWidth: lineWidth, lineJoin: .round)) + + // OpenCode mark: square ring (even-odd) + a partial inner fill. + var ring = Path() + ring.move(to: m(-16, -20)); ring.addLine(to: m(16, -20)); ring.addLine(to: m(16, 20)); ring.addLine(to: m(-16, 20)); ring.closeSubpath() + ring.move(to: m(-8, -12)); ring.addLine(to: m(-8, 12)); ring.addLine(to: m(8, 12)); ring.addLine(to: m(8, -12)); ring.closeSubpath() + context.fill(ring, with: .color(.primary), style: FillStyle(eoFill: true)) + + var inner = Path() + inner.move(to: m(-8, -4)); inner.addLine(to: m(8, -4)); inner.addLine(to: m(8, 12)); inner.addLine(to: m(-8, 12)); inner.closeSubpath() + context.fill(inner, with: .color(.primary.opacity(0.4))) + } + } +} diff --git a/packages/mobile/ios/App/Podfile b/packages/mobile/ios/App/Podfile new file mode 100644 index 0000000000..cb62cfa534 --- /dev/null +++ b/packages/mobile/ios/App/Podfile @@ -0,0 +1,40 @@ +require_relative '../../../../node_modules/.bun/@capacitor+ios@8.4.1+767ac80cbab8ae50/node_modules/@capacitor/ios/scripts/pods_helpers' + +platform :ios, '15.5' +use_frameworks! + +# workaround to avoid Xcode caching of Pods that requires +# Product -> Clean Build Folder after new Cordova plugins installed +# Requires CocoaPods 1.6 or newer +install! 'cocoapods', :disable_input_output_paths => true + +def capacitor_pods + pod 'Capacitor', :path => '../../../../node_modules/.bun/@capacitor+ios@8.4.1+767ac80cbab8ae50/node_modules/@capacitor/ios' + pod 'CapacitorCordova', :path => '../../../../node_modules/.bun/@capacitor+ios@8.4.1+767ac80cbab8ae50/node_modules/@capacitor/ios' + pod 'AparajitaCapacitorSecureStorage', :path => '../../../../node_modules/.bun/@aparajita+capacitor-secure-storage@8.0.0/node_modules/@aparajita/capacitor-secure-storage' + pod 'CapacitorMlkitBarcodeScanning', :path => '../../../../node_modules/.bun/@capacitor-mlkit+barcode-scanning@8.1.0+767ac80cbab8ae50/node_modules/@capacitor-mlkit/barcode-scanning' + pod 'CapacitorApp', :path => '../../../../node_modules/.bun/@capacitor+app@8.1.0+767ac80cbab8ae50/node_modules/@capacitor/app' + pod 'CapacitorKeyboard', :path => '../../../../node_modules/.bun/@capacitor+keyboard@8.0.5+767ac80cbab8ae50/node_modules/@capacitor/keyboard' + pod 'CapacitorPushNotifications', :path => '../../../../node_modules/.bun/@capacitor+push-notifications@8.1.1+767ac80cbab8ae50/node_modules/@capacitor/push-notifications' + pod 'CapacitorStatusBar', :path => '../../../../node_modules/.bun/@capacitor+status-bar@8.0.2+767ac80cbab8ae50/node_modules/@capacitor/status-bar' +end + +target 'App' do + capacitor_pods + # Add your Pods here +end + +post_install do |installer| + assertDeploymentTarget(installer) + # Xcode 16+/iOS 26 SDK rejects deployment targets below 15.0, and GoogleMLKit + # (pulled in by the barcode scanner) requires iOS 15.5+. Force every Pods target + # up so the Capacitor/Cordova/MLKit pods build for a real device. + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.5' + # Capacitor's Cordova compatibility headers use quoted includes; newer Xcode + # treats those as errors in framework headers. Keep it a (non-fatal) warning. + config.build_settings['CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER'] = 'NO' + end + end +end diff --git a/packages/mobile/ios/App/Podfile.lock b/packages/mobile/ios/App/Podfile.lock new file mode 100644 index 0000000000..cd65e1a551 --- /dev/null +++ b/packages/mobile/ios/App/Podfile.lock @@ -0,0 +1,134 @@ +PODS: + - AparajitaCapacitorSecureStorage (8.0.0): + - Capacitor + - KeychainSwift (~> 21.0) + - Capacitor (8.4.1): + - CapacitorCordova + - CapacitorApp (8.1.0): + - Capacitor + - CapacitorCordova (8.4.1) + - CapacitorKeyboard (8.0.5): + - Capacitor + - CapacitorMlkitBarcodeScanning (8.1.0): + - Capacitor + - GoogleMLKit/BarcodeScanning (~> 8.0.0) + - CapacitorPushNotifications (8.1.1): + - Capacitor + - CapacitorStatusBar (8.0.2): + - Capacitor + - GoogleDataTransport (10.1.0): + - nanopb (~> 3.30910.0) + - PromisesObjC (~> 2.4) + - GoogleMLKit/BarcodeScanning (8.0.0): + - GoogleMLKit/MLKitCore + - MLKitBarcodeScanning (~> 7.0.0) + - GoogleMLKit/MLKitCore (8.0.0): + - MLKitCommon (~> 13.0.0) + - GoogleToolboxForMac/Defines (4.2.1) + - GoogleToolboxForMac/Logger (4.2.1): + - GoogleToolboxForMac/Defines (= 4.2.1) + - "GoogleToolboxForMac/NSData+zlib (4.2.1)": + - GoogleToolboxForMac/Defines (= 4.2.1) + - GoogleUtilities/Environment (8.1.1): + - GoogleUtilities/Privacy + - GoogleUtilities/Logger (8.1.1): + - GoogleUtilities/Environment + - GoogleUtilities/Privacy + - GoogleUtilities/Privacy (8.1.1) + - GoogleUtilities/UserDefaults (8.1.1): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GTMSessionFetcher/Core (3.5.0) + - KeychainSwift (21.0.0) + - MLImage (1.0.0-beta7) + - MLKitBarcodeScanning (7.0.0): + - MLKitCommon (~> 13.0) + - MLKitVision (~> 9.0) + - MLKitCommon (13.0.0): + - GoogleDataTransport (~> 10.0) + - GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1) + - "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)" + - GoogleUtilities/Logger (~> 8.0) + - GoogleUtilities/UserDefaults (~> 8.0) + - GTMSessionFetcher/Core (< 4.0, >= 3.3.2) + - MLKitVision (9.0.0): + - GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1) + - "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)" + - GTMSessionFetcher/Core (< 4.0, >= 3.3.2) + - MLImage (= 1.0.0-beta7) + - MLKitCommon (~> 13.0) + - nanopb (3.30910.0): + - nanopb/decode (= 3.30910.0) + - nanopb/encode (= 3.30910.0) + - nanopb/decode (3.30910.0) + - nanopb/encode (3.30910.0) + - PromisesObjC (2.4.1) + +DEPENDENCIES: + - "AparajitaCapacitorSecureStorage (from `../../../../node_modules/.bun/@aparajita+capacitor-secure-storage@8.0.0/node_modules/@aparajita/capacitor-secure-storage`)" + - "Capacitor (from `../../../../node_modules/.bun/@capacitor+ios@8.4.1+767ac80cbab8ae50/node_modules/@capacitor/ios`)" + - "CapacitorApp (from `../../../../node_modules/.bun/@capacitor+app@8.1.0+767ac80cbab8ae50/node_modules/@capacitor/app`)" + - "CapacitorCordova (from `../../../../node_modules/.bun/@capacitor+ios@8.4.1+767ac80cbab8ae50/node_modules/@capacitor/ios`)" + - "CapacitorKeyboard (from `../../../../node_modules/.bun/@capacitor+keyboard@8.0.5+767ac80cbab8ae50/node_modules/@capacitor/keyboard`)" + - "CapacitorMlkitBarcodeScanning (from `../../../../node_modules/.bun/@capacitor-mlkit+barcode-scanning@8.1.0+767ac80cbab8ae50/node_modules/@capacitor-mlkit/barcode-scanning`)" + - "CapacitorPushNotifications (from `../../../../node_modules/.bun/@capacitor+push-notifications@8.1.1+767ac80cbab8ae50/node_modules/@capacitor/push-notifications`)" + - "CapacitorStatusBar (from `../../../../node_modules/.bun/@capacitor+status-bar@8.0.2+767ac80cbab8ae50/node_modules/@capacitor/status-bar`)" + +SPEC REPOS: + trunk: + - GoogleDataTransport + - GoogleMLKit + - GoogleToolboxForMac + - GoogleUtilities + - GTMSessionFetcher + - KeychainSwift + - MLImage + - MLKitBarcodeScanning + - MLKitCommon + - MLKitVision + - nanopb + - PromisesObjC + +EXTERNAL SOURCES: + AparajitaCapacitorSecureStorage: + :path: "../../../../node_modules/.bun/@aparajita+capacitor-secure-storage@8.0.0/node_modules/@aparajita/capacitor-secure-storage" + Capacitor: + :path: "../../../../node_modules/.bun/@capacitor+ios@8.4.1+767ac80cbab8ae50/node_modules/@capacitor/ios" + CapacitorApp: + :path: "../../../../node_modules/.bun/@capacitor+app@8.1.0+767ac80cbab8ae50/node_modules/@capacitor/app" + CapacitorCordova: + :path: "../../../../node_modules/.bun/@capacitor+ios@8.4.1+767ac80cbab8ae50/node_modules/@capacitor/ios" + CapacitorKeyboard: + :path: "../../../../node_modules/.bun/@capacitor+keyboard@8.0.5+767ac80cbab8ae50/node_modules/@capacitor/keyboard" + CapacitorMlkitBarcodeScanning: + :path: "../../../../node_modules/.bun/@capacitor-mlkit+barcode-scanning@8.1.0+767ac80cbab8ae50/node_modules/@capacitor-mlkit/barcode-scanning" + CapacitorPushNotifications: + :path: "../../../../node_modules/.bun/@capacitor+push-notifications@8.1.1+767ac80cbab8ae50/node_modules/@capacitor/push-notifications" + CapacitorStatusBar: + :path: "../../../../node_modules/.bun/@capacitor+status-bar@8.0.2+767ac80cbab8ae50/node_modules/@capacitor/status-bar" + +SPEC CHECKSUMS: + AparajitaCapacitorSecureStorage: 8128d05cafcb13b00448e20fb388a0edccd44b12 + Capacitor: 35242afe195b1e53c58ca1b827d1b444c5e6602b + CapacitorApp: 449ffe26375e96f8aaaee625ac6e01e5c57c8650 + CapacitorCordova: eebe6bcf807b1b06f3f48237650f96bbcd0eef09 + CapacitorKeyboard: b6b0744890cdb1d9a96e2cafcc9253fdcc55de3b + CapacitorMlkitBarcodeScanning: 31c6af9f39873ff69e16ed5b39ebe2e1915f16fe + CapacitorPushNotifications: ec08d589c226a2c0db7c032ec1bf5b044ec85f8e + CapacitorStatusBar: 01d5763b4ed720de5ce2edbc938de6a98f4c8f32 + GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 + GoogleMLKit: ddd51d7dff36ff28defa69afedd9cdce684fd857 + GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8 + GoogleUtilities: 4f2618a4a1e762a1ee134a1e2323bba9843e06da + GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 + KeychainSwift: 4a71a45c802fd9e73906457c2dcbdbdc06c9419d + MLImage: 2ab9c968e75f57911c16f4c9d9e8a8e9604a86a1 + MLKitBarcodeScanning: 72c6437f13a900833b400136be53a8a5d86f42fa + MLKitCommon: 26b779f072a182c1603d4c88a101c350cac837b1 + MLKitVision: fa8dea9012ac59497c79ddbe9ebf32051047ac4c + nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 + PromisesObjC: 752c3227f599e3467650e47ea36f433eeb10c273 + +PODFILE CHECKSUM: 99c62a30e73aa8ec805869506d2b1969ee4fb92d + +COCOAPODS: 1.16.2 diff --git a/packages/mobile/package.json b/packages/mobile/package.json new file mode 100644 index 0000000000..7559624083 --- /dev/null +++ b/packages/mobile/package.json @@ -0,0 +1,47 @@ +{ + "name": "@openchamber/mobile", + "version": "1.13.2", + "private": true, + "type": "module", + "scripts": { + "build": "bun run --cwd ../web build && node scripts/prepare-web-assets.mjs", + "sync": "node scripts/with-mobile-env.mjs \"bun run build && cap sync\"", + "add:ios": "cap add ios", + "add:android": "cap add android", + "build:android:debug": "node scripts/with-mobile-env.mjs \"bun run sync && ./android/gradlew -p android assembleDebug\"", + "android:devices": "node scripts/with-mobile-env.mjs \"node scripts/android-device.mjs devices\"", + "android:install": "node scripts/with-mobile-env.mjs \"node scripts/android-device.mjs install\"", + "android:launch": "node scripts/with-mobile-env.mjs \"node scripts/android-device.mjs launch\"", + "android:run": "node scripts/with-mobile-env.mjs \"node scripts/android-device.mjs run\"", + "android:logcat": "node scripts/with-mobile-env.mjs \"node scripts/android-device.mjs logcat\"", + "build:ios:simulator": "node scripts/with-mobile-env.mjs \"node scripts/ios-sim-build.mjs\"", + "sim:boot": "node scripts/with-mobile-env.mjs \"node scripts/ios-sim.mjs boot\"", + "sim:install": "node scripts/with-mobile-env.mjs \"node scripts/ios-sim.mjs install\"", + "sim:launch": "node scripts/with-mobile-env.mjs \"node scripts/ios-sim.mjs launch\"", + "sim:run": "node scripts/with-mobile-env.mjs \"node scripts/ios-sim.mjs run\"", + "sim:serve": "node scripts/with-mobile-env.mjs \"serve-sim --detach -q\"", + "sim:list": "node scripts/with-mobile-env.mjs \"serve-sim --list -q\"", + "sim:kill": "node scripts/with-mobile-env.mjs \"serve-sim --kill\"", + "open:ios": "cap open ios", + "open:android": "cap open android", + "type-check": "tsc --noEmit", + "lint": "eslint \"./**/*.{ts,tsx,js,mjs}\" --config ../../eslint.config.js --ignore-pattern dist --ignore-pattern ios --ignore-pattern android" + }, + "dependencies": { + "@aparajita/capacitor-secure-storage": "^8.0.0", + "@capacitor-mlkit/barcode-scanning": "^8.1.0", + "@capacitor/app": "^8.0.0", + "@capacitor/core": "^8.4.1", + "@capacitor/keyboard": "^8.0.0", + "@capacitor/push-notifications": "^8.1.1", + "@capacitor/status-bar": "^8.0.0" + }, + "devDependencies": { + "@capacitor/android": "^8.4.1", + "@capacitor/cli": "^8.4.1", + "@capacitor/ios": "^8.4.1", + "@types/node": "^24.3.1", + "serve-sim": "^0.1.34", + "typescript": "~5.9.0" + } +} diff --git a/packages/mobile/scripts/android-device.mjs b/packages/mobile/scripts/android-device.mjs new file mode 100644 index 0000000000..82aab84c41 --- /dev/null +++ b/packages/mobile/scripts/android-device.mjs @@ -0,0 +1,93 @@ +// Install / launch the debug APK on a connected Android device via adb. +// +// Mirrors scripts/ios-sim.mjs for the iOS simulator. Run through with-mobile-env.mjs so adb +// (ANDROID_HOME/platform-tools) and the JDK are on PATH. Build the APK first with +// `bun run build:android:debug`; `run` installs + launches it. + +import { spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const mobileRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); +const APK_PATH = join(mobileRoot, 'android', 'app', 'build', 'outputs', 'apk', 'debug', 'app-debug.apk'); +const APP_ID = 'com.openchamber.app'; +const LAUNCH_ACTIVITY = `${APP_ID}/.MainActivity`; + +const adb = (args, { capture = false, allowFail = false } = {}) => { + const result = spawnSync('adb', args, { stdio: capture ? 'pipe' : 'inherit', encoding: 'utf8' }); + if (!allowFail && result.status !== 0) { + throw new Error(`adb ${args.join(' ')} exited with ${result.status ?? result.signal}`); + } + return result; +}; + +const connectedDevices = () => { + const output = adb(['devices'], { capture: true, allowFail: true }).stdout || ''; + return output + .split('\n') + .slice(1) + .map((line) => line.trim()) + .filter((line) => line.endsWith('\tdevice')) + .map((line) => line.split('\t')[0]); +}; + +const requireDevice = () => { + const devices = connectedDevices(); + if (devices.length === 0) { + console.error( + 'No authorized Android device found. Enable Developer options + USB debugging on the device, ' + + 'connect it, and accept the "Allow USB debugging" prompt. Check with: bun run android:devices', + ); + process.exit(1); + } + return devices; +}; + +const requireApk = () => { + if (!existsSync(APK_PATH)) { + throw new Error(`Debug APK not found at ${APK_PATH}. Build it first: bun run build:android:debug`); + } +}; + +const install = () => { + requireDevice(); + requireApk(); + adb(['install', '-r', APK_PATH]); +}; + +const launch = () => { + requireDevice(); + adb(['shell', 'am', 'start', '-n', LAUNCH_ACTIVITY]); +}; + +const command = process.argv[2]; +switch (command) { + case 'devices': + adb(['devices', '-l']); + break; + case 'install': + install(); + break; + case 'launch': + launch(); + break; + case 'run': + install(); + launch(); + break; + case 'logcat': { + requireDevice(); + const pid = (adb(['shell', 'pidof', APP_ID], { capture: true, allowFail: true }).stdout || '').trim().split(/\s+/)[0]; + if (pid) { + adb(['logcat', `--pid=${pid}`]); + } else { + console.warn(`[android] ${APP_ID} is not running; streaming Capacitor/Chromium logs. Launch the app to see its logs.`); + adb(['logcat', '-s', 'Capacitor:V', 'Capacitor/Console:V', 'chromium:V']); + } + break; + } + default: + console.error('Usage: node scripts/android-device.mjs '); + process.exit(1); +} diff --git a/packages/mobile/scripts/ios-sim-build.mjs b/packages/mobile/scripts/ios-sim-build.mjs new file mode 100644 index 0000000000..78b8a1fd97 --- /dev/null +++ b/packages/mobile/scripts/ios-sim-build.mjs @@ -0,0 +1,69 @@ +// Builds the iOS app for the Apple-Silicon simulator. +// +// Why this is special: the barcode scanner (`@capacitor-mlkit/barcode-scanning` → +// GoogleMLKit) ships only device-arm64 + simulator-x86_64 slices — there is NO +// arm64-simulator slice. CocoaPods therefore adds `EXCLUDED_ARCHS[sdk=iphonesimulator*] = +// arm64`, so a normal build produces an x86_64-only binary that can't install on an +// arm64-only iOS 26+ simulator ("does not contain code for ... arm64"). +// +// QR scanning needs a camera, which the simulator doesn't have, so dropping the scanner for +// simulator builds loses nothing: this script temporarily removes the MLKit pod, builds an +// arm64 simulator binary, then restores the Podfile + Pods so device/TestFlight builds keep +// the scanner. The JS side already degrades cleanly when the native plugin is absent +// (mobileQrScan: getScannerPlugin() → null → isQrScanSupported() false). + +import { spawnSync } from 'node:child_process'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const mobileRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); +const iosAppDir = join(mobileRoot, 'ios', 'App'); +const podfilePath = join(iosAppDir, 'Podfile'); + +const run = (command, args, cwd = mobileRoot) => { + const result = spawnSync(command, args, { stdio: 'inherit', cwd, env: process.env }); + if (result.status !== 0) { + throw new Error(`${command} ${args.join(' ')} exited with ${result.status ?? result.signal}`); + } +}; + +// 1. Build the web bundle and copy it into the iOS project (no pod regen — `copy`, not `sync`). +run('bun', ['run', 'build']); +run('cap', ['copy', 'ios']); + +// 2. Strip the MLKit barcode-scanning pod, then reinstall pods without it. +const originalPodfile = readFileSync(podfilePath, 'utf8'); +const strippedPodfile = originalPodfile + .split('\n') + .filter((line) => !line.includes('CapacitorMlkitBarcodeScanning')) + .join('\n'); + +if (strippedPodfile === originalPodfile) { + console.warn('[ios-sim-build] CapacitorMlkitBarcodeScanning not found in Podfile — building as-is.'); +} + +try { + writeFileSync(podfilePath, strippedPodfile); + run('pod', ['install'], iosAppDir); + + // 3. Build for the simulator. With MLKit gone the arm64 simulator slice builds cleanly. + run('xcodebuild', [ + '-workspace', 'ios/App/App.xcworkspace', + '-scheme', 'App', + '-configuration', 'Debug', + '-sdk', 'iphonesimulator', + '-destination', 'generic/platform=iOS Simulator', + 'CODE_SIGNING_ALLOWED=NO', + 'build', + ]); +} finally { + // 4. Always restore the Podfile + Pods so device/TestFlight builds keep the scanner. Pods/ + // and Podfile.lock return to their original state (a no-op for git once this completes). + if (strippedPodfile !== originalPodfile) { + writeFileSync(podfilePath, originalPodfile); + run('pod', ['install'], iosAppDir); + } +} + +console.log('[ios-sim-build] Simulator build complete. Run `bun run sim:run` to install + launch.'); diff --git a/packages/mobile/scripts/ios-sim.mjs b/packages/mobile/scripts/ios-sim.mjs new file mode 100644 index 0000000000..c5fe3a0e55 --- /dev/null +++ b/packages/mobile/scripts/ios-sim.mjs @@ -0,0 +1,95 @@ +import { spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; + +const BUNDLE_ID = 'com.openchamber.app'; +const DEFAULT_DEVICE = 'iPhone 17 Pro'; + +const run = (command, args, options = {}) => { + const result = spawnSync(command, args, { + env: process.env, + stdio: options.capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', + encoding: 'utf8', + }); + + if (result.status !== 0) { + if (options.capture && result.stderr) process.stderr.write(result.stderr); + process.exit(result.status ?? 1); + } + + return result.stdout?.trim() ?? ''; +}; + +const getBootedDevice = () => { + const json = run('xcrun', ['simctl', 'list', 'devices', 'booted', '--json'], { capture: true }); + const data = JSON.parse(json); + for (const devices of Object.values(data.devices ?? {})) { + const device = devices.find((item) => item.state === 'Booted'); + if (device) return device; + } + return null; +}; + +const bootDevice = (name = DEFAULT_DEVICE) => { + const booted = getBootedDevice(); + if (booted) return booted.udid; + + const json = run('xcrun', ['simctl', 'list', 'devices', 'available', '--json'], { capture: true }); + const data = JSON.parse(json); + for (const devices of Object.values(data.devices ?? {})) { + const match = devices.find((device) => device.name === name && device.isAvailable !== false); + if (!match) continue; + run('xcrun', ['simctl', 'boot', match.udid]); + return match.udid; + } + + throw new Error(`No available simulator named "${name}" found.`); +}; + +const getBuiltAppPath = () => { + const appPath = run('xcodebuild', [ + '-workspace', 'ios/App/App.xcworkspace', + '-scheme', 'App', + '-configuration', 'Debug', + '-sdk', 'iphonesimulator', + '-showBuildSettings', + ], { capture: true }) + .split('\n') + .map((line) => line.trim()) + .find((line) => line.startsWith('TARGET_BUILD_DIR = ')) + ?.replace('TARGET_BUILD_DIR = ', ''); + + if (!appPath) throw new Error('Unable to resolve iOS simulator build output directory.'); + const fullPath = path.join(appPath, 'App.app'); + if (!existsSync(fullPath)) throw new Error(`Built app not found at ${fullPath}. Run bun run build:ios:simulator first.`); + return fullPath; +}; + +const command = process.argv[2]; + +switch (command) { + case 'boot': { + const udid = bootDevice(process.argv.slice(3).join(' ') || DEFAULT_DEVICE); + console.log(udid); + break; + } + case 'install': { + const udid = bootDevice(); + run('xcrun', ['simctl', 'install', udid, getBuiltAppPath()]); + break; + } + case 'launch': { + const udid = bootDevice(); + run('xcrun', ['simctl', 'launch', udid, BUNDLE_ID]); + break; + } + case 'run': { + const udid = bootDevice(); + run('xcrun', ['simctl', 'install', udid, getBuiltAppPath()]); + run('xcrun', ['simctl', 'launch', udid, BUNDLE_ID]); + break; + } + default: + console.error('Usage: node scripts/ios-sim.mjs [device name]'); + process.exit(1); +} diff --git a/packages/mobile/scripts/prepare-web-assets.mjs b/packages/mobile/scripts/prepare-web-assets.mjs new file mode 100644 index 0000000000..13103ff1f7 --- /dev/null +++ b/packages/mobile/scripts/prepare-web-assets.mjs @@ -0,0 +1,16 @@ +import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const mobileRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const webDist = path.resolve(mobileRoot, '../web/dist'); +const mobileDist = path.resolve(mobileRoot, 'dist'); +const mobileHtml = path.join(mobileDist, 'mobile.html'); +const indexHtml = path.join(mobileDist, 'index.html'); + +await rm(mobileDist, { recursive: true, force: true }); +await mkdir(mobileDist, { recursive: true }); +await cp(webDist, mobileDist, { recursive: true }); + +const html = await readFile(mobileHtml, 'utf8'); +await writeFile(indexHtml, html); diff --git a/packages/mobile/scripts/with-mobile-env.mjs b/packages/mobile/scripts/with-mobile-env.mjs new file mode 100644 index 0000000000..100c0f8b62 --- /dev/null +++ b/packages/mobile/scripts/with-mobile-env.mjs @@ -0,0 +1,48 @@ +import { spawn, spawnSync } from 'node:child_process'; + +const command = process.argv.slice(2).join(' '); + +if (!command) { + console.error('Usage: node scripts/with-mobile-env.mjs '); + process.exit(1); +} + +// Respect an explicit DEVELOPER_DIR, then fall back to whatever the user selected via +// `xcode-select` (so an Xcode beta / non-default install is honoured). Hardcoding +// /Applications/Xcode.app overrode `xcode-select` and forced builds onto the wrong Xcode, +// whose simulator runtimes may not match — xcodebuild then can't find the chosen simulator. +const selectedDeveloperDir = () => { + try { + const result = spawnSync('xcode-select', ['-p'], { encoding: 'utf8' }); + const path = result.status === 0 ? result.stdout.trim() : ''; + return path.length > 0 ? path : null; + } catch { + return null; + } +}; + +const developerDir = + process.env.DEVELOPER_DIR || selectedDeveloperDir() || '/Applications/Xcode.app/Contents/Developer'; +const javaHome = process.env.JAVA_HOME || '/opt/homebrew/opt/openjdk@21'; +const androidHome = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT || '/opt/homebrew/share/android-commandlinetools'; + +const child = spawn(command, { + env: { + ...process.env, + DEVELOPER_DIR: developerDir, + JAVA_HOME: javaHome, + ANDROID_HOME: androidHome, + ANDROID_SDK_ROOT: androidHome, + PATH: `${javaHome}/bin:${androidHome}/platform-tools:${process.env.PATH || ''}`, + }, + shell: true, + stdio: 'inherit', +}); + +child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 1); +}); diff --git a/packages/mobile/tsconfig.json b/packages/mobile/tsconfig.json new file mode 100644 index 0000000000..80af150cd3 --- /dev/null +++ b/packages/mobile/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "moduleDetection": "force", + "strict": true, + "skipLibCheck": true, + "types": ["node"], + "noEmit": true + }, + "include": ["capacitor.config.ts"] +} diff --git a/packages/ui/package.json b/packages/ui/package.json index 156cf183da..e728247352 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@openchamber/ui", - "version": "1.13.7", + "version": "1.14.0", "private": true, "type": "module", "main": "src/main.tsx", @@ -11,7 +11,13 @@ "lint": "eslint \"./src/**/*.{ts,tsx}\" --config ../../eslint.config.js" }, "dependencies": { + "@aparajita/capacitor-secure-storage": "^8.0.0", "@base-ui/react": "^1.4.0", + "@capacitor/app": "^8.0.0", + "@capacitor/core": "^8.4.1", + "@capacitor/keyboard": "^8.0.0", + "@capacitor/push-notifications": "^8.1.1", + "@capacitor/status-bar": "^8.0.0", "@codemirror/autocomplete": "^6.20.0", "@codemirror/commands": "^6.10.1", "@codemirror/lang-cpp": "^6.0.3", @@ -36,14 +42,12 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@fontsource/ibm-plex-mono": "^5.2.7", - "@fontsource/ibm-plex-sans": "^5.1.1", - "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", - "@opencode-ai/sdk": "^1.17.9", - "@pierre/diffs": "1.3.0-beta.4", + "@opencode-ai/sdk": "1.17.12", + "@pierre/diffs": "1.3.0-beta.6", "@replit/codemirror-vim": "^6.3.0", "@simplewebauthn/browser": "13.3.0", + "@tanstack/react-virtual": "3.14.5", "@xenova/transformers": "^2.17.2", "@zumer/snapdom": "^2.12.0", "beautiful-mermaid": "^1.1.3", diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 8ce5f1b68c..9159a9e00a 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -34,7 +34,6 @@ import { markSessionViewed } from '@/sync/notification-store'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { opencodeClient } from '@/lib/opencode/client'; -import { disposeTerminalInputTransport } from '@/lib/terminalApi'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { getRuntimeKey, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch'; import { useAutoReviewStore } from '@/stores/useAutoReviewStore'; @@ -45,7 +44,6 @@ import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay'; import { AboutDialog } from '@/components/ui/AboutDialog'; import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider'; import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; -import { VoiceProvider } from '@/components/voice'; import { useUIStore } from '@/stores/useUIStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore'; @@ -57,8 +55,8 @@ import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; import { useI18n } from '@/lib/i18n'; import { applyMobileKeyboardMode } from '@/lib/mobileKeyboardMode'; import { SyncAppEffects } from '@/apps/AppEffects'; +import { resetAppForRuntimeEndpointChange } from '@/apps/runtimeEndpointReset'; import { useAppFontEffects } from '@/apps/useAppFontEffects'; -import { resetStreamingState } from '@/sync/streaming'; import { OpenCodeUpdateToast } from '@/components/update/OpenCodeUpdateToast'; import { markStartupTrace, startupTraceEnabled } from '@/lib/startupTrace'; @@ -268,25 +266,7 @@ function App({ apis }: AppProps) { React.useEffect(() => { return subscribeRuntimeEndpointChanged((detail) => { - useSessionUIStore.getState().prepareForRuntimeSwitch(detail.previousRuntimeKey); - useUIStore.getState().prepareForRuntimeSwitch(detail.previousRuntimeKey); - if (detail.previousRuntimeKey) { - useAutoReviewStore.getState().stopRunningRunsForRuntime(detail.previousRuntimeKey); - } - disposeTerminalInputTransport(); - opencodeClient.reconnectToRuntimeBaseUrl(); - useConfigStore.setState({ - providers: [], - agents: [], - isConnected: false, - isInitialized: false, - connectionPhase: 'connecting', - lastDisconnectReason: null, - }); - useProjectsStore.getState().resetForRuntimeSwitch(); - useSessionUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey); - useUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey); - resetStreamingState(); + resetAppForRuntimeEndpointChange(detail); setRuntimeEndpointEpoch((epoch) => epoch + 1); setInitRetryExhausted(false); setInitRetryEpoch((epoch) => epoch + 1); @@ -947,8 +927,8 @@ function App({ apis }: AppProps) { } // Always mount the full provider tree to avoid remounts when isInitialized - // flips from false → true. FireworksProvider and VoiceProvider are lightweight - // shells; their heavy children are only activated when actually needed. + // flips from false → true. FireworksProvider is a lightweight shell; its + // heavy children are only activated when actually needed. const isBootShell = !isInitialized && !isDesktopRuntime; return ( @@ -956,7 +936,6 @@ function App({ apis }: AppProps) { -
@@ -974,7 +953,6 @@ function App({ apis }: AppProps) { )}
-
diff --git a/packages/ui/src/apps/MobileApp.tsx b/packages/ui/src/apps/MobileApp.tsx index 1dc9831873..bc7f9a2c4b 100644 --- a/packages/ui/src/apps/MobileApp.tsx +++ b/packages/ui/src/apps/MobileApp.tsx @@ -7,6 +7,8 @@ import { McpDropdownContent } from '@/components/mcp/McpDropdown'; import { AboutSettings } from '@/components/sections/openchamber/AboutSettings'; import { OpenCodeUpdateToast } from '@/components/update/OpenCodeUpdateToast'; import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay'; +import { Button } from '@/components/ui/button'; +import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo'; import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { ChatView } from '@/components/views/ChatView'; import { SettingsView } from '@/components/views/SettingsView'; @@ -29,6 +31,7 @@ import { resolveProjectForDirectory, resolveProjectForSessionDirectory } from '@ import { clampPercent, formatQuotaResetLabel, formatQuotaValueLabel, formatWindowLabel, QUOTA_PROVIDERS, resolveUsageTone } from '@/lib/quota'; import { getDisplayModelName } from '@/lib/quota/model-families'; import { runtimeFetch } from '@/lib/runtime-fetch'; +import { getRuntimeApiBaseUrl, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch'; import { sessionEvents } from '@/lib/sessionEvents'; import { cn } from '@/lib/utils'; import { useConfigStore } from '@/stores/useConfigStore'; @@ -42,7 +45,6 @@ import { useProjectsStore } from '@/stores/useProjectsStore'; import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore'; import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager'; import type { QuotaProviderId, UsageWindow } from '@/types'; -import type { WorktreeMetadata } from '@/types/worktree'; import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore'; import { useUpdateStore } from '@/stores/useUpdateStore'; import { useSelectionStore } from '@/sync/selection-store'; @@ -55,7 +57,14 @@ import { MobileFilesSurface } from './MobileFilesSurface'; import { MobileSessionsSheet } from './MobileSessionsSheet'; import { MobileSurfaceShell } from './MobileSurfaceShell'; import { DedicatedMobileAppProvider, type MobileAppActions } from './mobileAppContext'; +import { autoConnectLastInstance, isSameConnectionUrl, useMobileConnection, validateMobileConnectionSession } from './mobileConnections'; +import { isQrScanSupported, parseConnectionPayload, scanConnectionQr } from './mobileQrScan'; +import { resetAppForRuntimeEndpointChange } from './runtimeEndpointReset'; import { useAppFontEffects } from './useAppFontEffects'; +import { useFontsReady } from './useFontsReady'; +import { useDeepLinkHandlers, useDeepLinkSource } from './deepLinkNavigation'; +import { useEdgeSwipeSessionSwitch } from './useEdgeSwipeSessionSwitch'; +import { useNativePushRegistration } from './useNativePushRegistration'; const MOBILE_SETTINGS_PAGES = [ 'appearance', @@ -76,6 +85,410 @@ type MobileAppProps = { apis: RuntimeAPIs; }; +const isCapacitorMobileApp = (): boolean => { + if (typeof window === 'undefined') return false; + const maybeCapacitor = (window as typeof window & { + Capacitor?: { isNativePlatform?: () => boolean; getPlatform?: () => string }; + }).Capacitor; + if (maybeCapacitor?.isNativePlatform?.() === true) return true; + return window.location.protocol === 'capacitor:'; +}; + +const useNativeMobileChrome = (): void => { + React.useEffect(() => { + if (!isCapacitorMobileApp()) return; + + let disposed = false; + const cleanup: Array<() => void> = []; + const root = document.documentElement; + // Marks the Capacitor shell so keyboard-inset CSS only applies here, not in + // the browser-hosted PWA (which handles the keyboard via dvh / interactive-widget). + root.classList.add('oc-capacitor-app'); + // Platform marker: Android resizes the window for the keyboard natively (no manual + // inset/choreography — the keyboard listeners below skip Android entirely). + const capacitorPlatform = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.(); + if (capacitorPlatform === 'android') { + root.classList.add('oc-platform-android'); + } + + const setInset = (px: number) => { + root.style.setProperty('--oc-keyboard-inset', `${Math.max(0, Math.round(px))}px`); + }; + + void import('@capacitor/status-bar').then(async ({ StatusBar, Style }) => { + if (disposed) return; + // Keep the status bar transparent over the WebView. A custom UIScene lifecycle + // (iOS 26) plus returning from background can silently drop the overlay state, + // letting an opaque status-bar background flash in at the top — so re-assert it + // on mount, once shortly after (startup race), and whenever the app re-activates. + const platform = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.(); + const applyStatusBar = async () => { + if (platform === 'android') { + // Android doesn't feed env(safe-area-inset-top) to CSS, so overlaying the status bar + // makes content render under it. Inset the WebView below the bar instead and paint the + // bar with the resolved theme background (the splash colours the theme system persists). + const isDark = document.documentElement.classList.contains('dark'); + const themeBg = + (isDark ? localStorage.getItem('splashBgDark') : localStorage.getItem('splashBgLight')) || + (isDark ? '#171515' : '#fffdf4'); + await StatusBar.setOverlaysWebView({ overlay: false }).catch(() => undefined); + await StatusBar.setBackgroundColor({ color: themeBg }).catch(() => undefined); + // Capacitor Style is named for the CONTENT: Style.Light = dark text (light bg), + // Style.Dark = light text (dark bg). So dark theme → Style.Dark, light theme → Style.Light. + await StatusBar.setStyle({ style: isDark ? Style.Dark : Style.Light }).catch(() => undefined); + await StatusBar.show().catch(() => undefined); + return; + } + await StatusBar.setStyle({ style: Style.Default }).catch(() => undefined); + await StatusBar.setOverlaysWebView({ overlay: true }).catch(() => undefined); + await StatusBar.show().catch(() => undefined); + }; + await applyStatusBar(); + const retry = window.setTimeout(() => void applyStatusBar(), 400); + cleanup.push(() => window.clearTimeout(retry)); + + const { App } = await import('@capacitor/app'); + const stateHandle = await App.addListener('appStateChange', ({ isActive }) => { + if (isActive) void applyStatusBar(); + }); + if (disposed) { + void stateHandle.remove(); + return; + } + cleanup.push(() => void stateHandle.remove()); + }).catch(() => undefined); + + void import('@capacitor/keyboard').then(async ({ Keyboard }) => { + if (disposed) return; + // iOS (WKWebView, resize: 'none') keeps 100dvh at full height with the keyboard + // overlaying, so we lift the UI manually via --oc-keyboard-inset. Android resizes the + // window for the keyboard (dvh already shrinks), so applying the inset on top would + // double-count — Android gets only the class/event signals below. + const platform = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.(); + if (platform === 'android') { + // Android resizes the WebView natively, so no inset/transform + // choreography — but the UI still needs the open/closed signal: + // oc-keyboard-open drives CSS (draft starters, composer padding), and + // the settled event gives the chat its one deterministic re-pin after + // the native resize (the auto-follow idle gate ignores it otherwise). + const willShowHandle = await Keyboard.addListener('keyboardWillShow', () => { + root.classList.add('oc-keyboard-open'); + // The composer already expanded on tap — re-pin the chat to it now, + // so the native resize that follows is the only remaining movement. + window.dispatchEvent(new CustomEvent('oc:keyboard-settled', { detail: { open: true } })); + }); + const didShowHandle = await Keyboard.addListener('keyboardDidShow', () => { + window.dispatchEvent(new CustomEvent('oc:keyboard-settled', { detail: { open: true } })); + }); + const willHideHandle = await Keyboard.addListener('keyboardWillHide', () => { + // Same single-motion trick as iOS: collapse the composer into the + // pill synchronously (flushSync in ChatInput) so the native window + // growth and the composer shrink land together, not as two steps. + window.dispatchEvent(new CustomEvent('oc:keyboard-intent', { detail: { open: false } })); + root.classList.remove('oc-keyboard-open'); + }); + const didHideHandle = await Keyboard.addListener('keyboardDidHide', () => { + window.dispatchEvent(new CustomEvent('oc:keyboard-settled', { detail: { open: false } })); + }); + const removeAll = () => { + void willShowHandle.remove(); + void didShowHandle.remove(); + void willHideHandle.remove(); + void didHideHandle.remove(); + }; + if (disposed) { + removeAll(); + return; + } + cleanup.push(removeAll); + return; + } + // No WebKit form accessory bar (prev/next arrows + Done) above the keyboard — + // there's a single input, so it only eats vertical space. + await Keyboard.setAccessoryBarVisible({ isVisible: false }).catch(() => undefined); + + // Keyboard slide choreography (see the "Native (Capacitor) keyboard handling" + // block in mobile.css for the full picture). `keyboardWillShow` fires at the + // START of the iOS keyboard animation and carries the final height; the + // visible motion is transform-only (inline styles on the kb-movers), and the shell's layout + // height (--oc-kb-layout) snaps exactly once per open/close at the moment the + // resize is invisible. visualViewport tracking was tried but doesn't shrink + // under WKWebView's `resize: 'none'`, so these events are the reliable signal. + const KB_ANIM_MS = 250; + // Dismissal reads faster than the rise — run the hide leg shorter (kept in + // sync with the .oc-kb-hide transition-duration override in mobile.css). + const KB_HIDE_MS = 200; + const KB_ANIM_EASING = 'cubic-bezier(0.38, 0.7, 0.125, 1)'; + let settleTimer: number | null = null; + let caretTimer: number | null = null; + let keyboardHeight = 0; + let layoutApplied = false; + let safeBottomPx = 0; + let keyboardOpen = false; + + const setVar = (name: string, px: number) => { + root.style.setProperty(name, `${Math.max(0, Math.round(px))}px`); + }; + const clearSettle = () => { + if (settleTimer !== null) { + window.clearTimeout(settleTimer); + settleTimer = null; + } + }; + const dispatchKb = (type: 'oc:keyboard-intent' | 'oc:keyboard-anim' | 'oc:keyboard-settled', detail: Record) => { + window.dispatchEvent(new CustomEvent(type, { detail })); + }; + // Elements that ride the keyboard slide, with their travel factor. Driven + // by INLINE styles from here: WebKit does not reliably start a transition + // when the transform's value changes via a CSS custom property, which + // left the composer parked until the keyboard finished. + const getKbMovers = (): Array<{ el: HTMLElement; factor: number }> => { + const movers: Array<{ el: HTMLElement; factor: number }> = []; + const composer = document.querySelector('.oc-mobile-composer'); + if (composer) movers.push({ el: composer, factor: 1 }); + // The centered draft title moves half the shift — exactly where the + // center lands after the shell snap (see mobile.css notes). + const draftCenter = document.querySelector('.oc-draft-center'); + if (draftCenter) movers.push({ el: draftCenter, factor: 0.5 }); + return movers; + }; + const clearKbMovers = () => { + for (const { el } of getKbMovers()) { + el.style.transition = ''; + el.style.transform = ''; + } + }; + + const showHandle = await Keyboard.addListener('keyboardWillShow', (info) => { + clearSettle(); + keyboardOpen = true; + keyboardHeight = info.keyboardHeight; + if (!layoutApplied) { + // The shell's resolved padding-bottom while the keyboard is down IS the + // bottom safe padding it gives up when open — measure it so the slide + // distance lands the composer exactly where the final layout puts it. + const shell = document.querySelector('.oc-mobile-app-shell'); + safeBottomPx = shell ? parseFloat(getComputedStyle(shell).paddingBottom) || 0 : 0; + } + const slide = Math.max(0, keyboardHeight - safeBottomPx); + root.classList.remove('oc-kb-hide'); + // WKWebView renders the caret as a native layer that doesn't ride CSS + // transforms — after the rise it visibly "flies" from the pre-keyboard + // position to the final one. Hide it for the transition (plus the lag + // window where UIKit animates it into place) and pop it back in. + if (caretTimer !== null) { + window.clearTimeout(caretTimer); + caretTimer = null; + } + root.classList.add('oc-keyboard-open', 'oc-kb-animating', 'oc-kb-caret-hold'); + setInset(keyboardHeight); + for (const { el, factor } of getKbMovers()) { + el.style.transition = `transform ${KB_ANIM_MS}ms ${KB_ANIM_EASING}`; + el.style.transform = `translateY(${-slide * factor}px)`; + } + // Reserve the keyboard strip inside the chat scroller NOW and re-pin + // immediately (settled = one cheap scrollTop write over already-mounted + // rows), so the chat bottom moves as the keyboard STARTS rising instead + // of waiting for it to finish. `slide` (keyboard minus the safe inset + // the shell gives up) is exactly the strip the scroller loses at + // settle, so pin position and settle stay geometry-neutral. + setVar('--oc-kb-scroll-inset', slide); + dispatchKb('oc:keyboard-settled', { open: true }); + dispatchKb('oc:keyboard-anim', { phase: 'show', slide, durationMs: KB_ANIM_MS, easing: KB_ANIM_EASING }); + settleTimer = window.setTimeout(() => { + settleTimer = null; + // Invisible swap: transition off, layout takes the keyboard height (one + // reflow), shift returns to 0 in the same frame. + root.classList.remove('oc-kb-animating'); + setVar('--oc-kb-layout', keyboardHeight); + layoutApplied = true; + clearKbMovers(); + dispatchKb('oc:keyboard-settled', { open: true }); + // Reveal the caret only after UIKit's own caret reposition window. + caretTimer = window.setTimeout(() => { + caretTimer = null; + root.classList.remove('oc-kb-caret-hold'); + }, 250); + }, KB_ANIM_MS + 20); + }); + + // Shared hide choreography. The bridge's `keyboardWillHide` can arrive a + // beat AFTER the native dismiss animation has already started (WKWebView + + // resize: 'none'), which made the composer begin its down-slide only once + // the keyboard was gone. The earliest reliable signal for the common + // dismissal path (tap outside the input) is the textarea's focusout — so + // both trigger this, and `keyboardOpen` makes the second call a no-op. + const runHide = () => { + if (!keyboardOpen) return; + keyboardOpen = false; + clearSettle(); + // Fired BEFORE any layout change: lets the composer collapse into its + // pill synchronously (flushSync in ChatInput), so the keyboard hide + // compensation below measures keyboard + composer shrink as ONE delta + // instead of two staggered steps. + dispatchKb('oc:keyboard-intent', { open: false }); + if (caretTimer !== null) { + window.clearTimeout(caretTimer); + caretTimer = null; + } + root.classList.remove('oc-kb-caret-hold'); + const slide = Math.max(0, keyboardHeight - safeBottomPx); + root.classList.remove('oc-keyboard-open'); + setInset(0); + setVar('--oc-kb-scroll-inset', 0); + if (layoutApplied) { + // Settled-open → restore the full-height layout NOW (still hidden behind + // the keyboard) and FLIP the movers to their raised position without + // transitioning, so the next frame looks unchanged. + root.classList.remove('oc-kb-animating'); + setVar('--oc-kb-layout', 0); + layoutApplied = false; + for (const { el, factor } of getKbMovers()) { + el.style.transition = 'none'; + el.style.transform = `translateY(${-slide * factor}px)`; + } + // Force the style/layout flush so the transition below starts from the + // FLIP position instead of coalescing both writes into one frame. + void (document.querySelector('.oc-mobile-app-shell') as HTMLElement | null)?.offsetHeight; + } + // If the hide interrupted a show mid-animation (layout not applied yet), + // the movers transition back down from wherever they currently are. + dispatchKb('oc:keyboard-anim', { phase: 'hide', slide, durationMs: KB_HIDE_MS, easing: KB_ANIM_EASING }); + root.classList.add('oc-kb-animating', 'oc-kb-hide'); + for (const { el } of getKbMovers()) { + el.style.transition = `transform ${KB_HIDE_MS}ms ${KB_ANIM_EASING}`; + el.style.transform = 'translateY(0px)'; + } + settleTimer = window.setTimeout(() => { + settleTimer = null; + root.classList.remove('oc-kb-animating', 'oc-kb-hide'); + clearKbMovers(); + dispatchKb('oc:keyboard-settled', { open: false }); + }, KB_HIDE_MS + 20); + }; + + const hideHandle = await Keyboard.addListener('keyboardWillHide', runHide); + + // Early hide trigger: blurring the focused text field is what starts the + // native dismiss animation, and it happens in-page — no bridge latency. + // Deferred a task so a synchronous refocus (focus moving to another text + // input, or a control that restores focus) doesn't false-trigger; in that + // case the keyboard never hides and `keyboardWillHide` never fires either. + const isTextInput = (node: unknown): boolean => + node instanceof HTMLElement + && (node.tagName === 'TEXTAREA' || node.tagName === 'INPUT' || node.isContentEditable); + const handleFocusOut = (event: FocusEvent) => { + if (!keyboardOpen) return; + if (!isTextInput(event.target)) return; + if (isTextInput(event.relatedTarget)) return; + window.setTimeout(() => { + if (!keyboardOpen) return; + if (isTextInput(document.activeElement)) return; + runHide(); + }, 0); + }; + document.addEventListener('focusout', handleFocusOut, true); + + if (disposed) { + clearSettle(); + document.removeEventListener('focusout', handleFocusOut, true); + void showHandle.remove(); + void hideHandle.remove(); + return; + } + cleanup.push( + clearSettle, + () => { + if (caretTimer !== null) { + window.clearTimeout(caretTimer); + caretTimer = null; + } + }, + () => document.removeEventListener('focusout', handleFocusOut, true), + () => void showHandle.remove(), + () => void hideHandle.remove(), + ); + }).catch(() => undefined); + + return () => { + disposed = true; + cleanup.forEach((remove) => remove()); + root.classList.remove('oc-capacitor-app', 'oc-keyboard-open', 'oc-kb-animating', 'oc-kb-hide', 'oc-kb-caret-hold', 'oc-platform-android'); + root.style.removeProperty('--oc-keyboard-inset'); + root.style.removeProperty('--oc-kb-shift'); + root.style.removeProperty('--oc-kb-layout'); + root.style.removeProperty('--oc-kb-scroll-inset'); + }; + }, []); +}; + +const useNativeMobileLifecycle = (onResume: () => void): void => { + const wasInactiveRef = React.useRef(false); + + React.useEffect(() => { + if (!isCapacitorMobileApp()) return; + + let disposed = false; + const cleanup: Array<() => void> = []; + const resumeAfterInactive = () => { + if (!wasInactiveRef.current) return; + wasInactiveRef.current = false; + onResume(); + }; + + void import('@capacitor/app').then(async ({ App }) => { + if (disposed) return; + const state = await App.addListener('appStateChange', ({ isActive }) => { + document.documentElement.classList.toggle('oc-native-app-active', isActive); + if (!isActive) { + wasInactiveRef.current = true; + return; + } + resumeAfterInactive(); + }); + const resume = await App.addListener('resume', resumeAfterInactive); + if (disposed) { + void state.remove(); + void resume.remove(); + return; + } + cleanup.push(() => void state.remove(), () => void resume.remove()); + }).catch(() => undefined); + + return () => { + disposed = true; + cleanup.forEach((remove) => remove()); + }; + }, [onResume]); +}; + +const useNativeAndroidBackButton = (onBack: () => boolean): void => { + React.useEffect(() => { + if (!isCapacitorMobileApp()) return; + + let disposed = false; + let remove: (() => void) | null = null; + + void import('@capacitor/app').then(async ({ App }) => { + if (disposed) return; + const listener = await App.addListener('backButton', () => { + if (onBack()) return; + void App.minimizeApp().catch(() => undefined); + }); + if (disposed) { + void listener.remove(); + return; + } + remove = () => void listener.remove(); + }).catch(() => undefined); + + return () => { + disposed = true; + remove?.(); + }; + }, [onBack]); +}; + const normalizePath = (value?: string | null): string => (value || '').replace(/\\/g, '/').replace(/\/+$/g, ''); @@ -95,6 +508,20 @@ const formatTokens = (value: number): string => { return String(value); }; +const mobileInputKeyboardProps = { + autoComplete: 'off', + autoCorrect: 'off', + spellCheck: false, +} as const; + +const NATIVE_RESUME_SYNC_EVENT_THROTTLE_MS = 1_000; + +const getRuntimeClientToken = (): string => { + if (typeof window === 'undefined') return ''; + const token = (window as typeof window & { __OPENCHAMBER_CLIENT_TOKEN__?: string }).__OPENCHAMBER_CLIENT_TOKEN__; + return typeof token === 'string' ? token.trim() : ''; +}; + const getProjectLabel = (path: string): string => { const normalized = normalizePath(path); if (!normalized) return ''; @@ -103,7 +530,7 @@ const getProjectLabel = (path: string): string => { }; type OverflowItem = { - key: 'files' | 'changes' | 'mcp' | 'update' | 'settings'; + key: 'files' | 'changes' | 'mcp' | 'instances' | 'update' | 'settings'; icon?: IconName; iconNode?: React.ReactNode; label: string; @@ -122,6 +549,540 @@ const getProjectDisplayLabel = (project: ProjectEntry | null, fallbackDirectory: return getProjectLabel(fallbackDirectory); }; +const MobileConnectionWelcome: React.FC<{ onConnected: () => void }> = ({ onConnected }) => { + const { t } = useI18n(); + const conn = useMobileConnection(onConnected); + const { connections, isBusy, isPasswordBusy, error, pendingConnection } = conn; + const [serverUrl, setServerUrl] = React.useState(''); + const [connectionName, setConnectionName] = React.useState(''); + const [clientToken, setClientToken] = React.useState(''); + const [isScanning, setIsScanning] = React.useState(false); + const [advancedOpen, setAdvancedOpen] = React.useState(false); + const qrScanSupported = React.useMemo(() => isQrScanSupported(), []); + const [password, setPassword] = React.useState(''); + + const handleSubmit = React.useCallback((event: React.FormEvent) => { + event.preventDefault(); + void conn.connect({ url: serverUrl, clientToken, label: connectionName }); + }, [clientToken, conn, connectionName, serverUrl]); + + // Accept a pasted pairing link (openchamber://connect?...) in the URL field and + // split it back into the server URL + token, revealing the token field when present. + const handleUrlChange = React.useCallback((value: string) => { + if (/^openchamber:\/\//i.test(value.trim())) { + const payload = parseConnectionPayload(value); + if (payload) { + setServerUrl(payload.url); + if (payload.label) setConnectionName(payload.label); + if (payload.clientToken) setClientToken(payload.clientToken); + if (payload.label || payload.clientToken) setAdvancedOpen(true); + return; + } + } + setServerUrl(value); + }, []); + + const handleScanQr = React.useCallback(async () => { + if (isScanning || isBusy) return; + conn.setError(null); + setIsScanning(true); + try { + const result = await scanConnectionQr(); + switch (result.status) { + case 'ok': + setServerUrl(result.url); + if (result.label) setConnectionName(result.label); + if (result.clientToken) setClientToken(result.clientToken); + if (result.label || result.clientToken) setAdvancedOpen(true); + await conn.connect({ url: result.url, clientToken: result.clientToken, label: result.label }); + break; + case 'permission-denied': + conn.setError(t('mobile.connect.scan.permissionDenied')); + break; + case 'invalid': + conn.setError(t('mobile.connect.scan.invalid')); + break; + case 'unsupported': + conn.setError(t('mobile.connect.scan.unsupported')); + break; + case 'failed': + conn.setError(t('mobile.connect.scan.failed')); + break; + case 'cancelled': + default: + break; + } + } finally { + setIsScanning(false); + } + }, [conn, isBusy, isScanning, t]); + + const handlePasswordSubmit = React.useCallback((event: React.FormEvent) => { + event.preventDefault(); + void conn.submitPassword(password); + }, [conn, password]); + + const cancelPassword = React.useCallback(() => { + setPassword(''); + conn.cancelPassword(); + }, [conn]); + + return ( +
+
+
+ +

{t('mobile.connect.welcome.title')}

+
+ + {pendingConnection ? ( +
+
+ + + +
+

{pendingConnection.label}

+

{pendingConnection.url}

+
+
+ setPassword(event.target.value)} + placeholder={t('mobile.connect.password.placeholder')} + aria-label={t('mobile.connect.password.label')} + type="password" + autoFocus + className="h-12 w-full rounded-[16px] border border-border/70 bg-surface-elevated px-4 text-[16px] text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary focus:ring-2 focus:ring-primary/20" + /> + {error ?

{error}

: null} + + +
+ ) : ( +
+
+ setConnectionName(event.target.value)} + placeholder={t('mobile.instances.label.placeholder')} + aria-label={t('mobile.instances.label.label')} + autoComplete="off" + autoCapitalize="words" + autoCorrect="off" + spellCheck={false} + className="h-12 w-full rounded-[16px] border border-border/70 bg-surface-elevated px-4 text-center text-[16px] text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary focus:ring-2 focus:ring-primary/20" + /> + handleUrlChange(event.target.value)} + placeholder={t('mobile.connect.url.placeholder')} + aria-label={t('mobile.connect.url.label')} + type="url" + inputMode="url" + autoCapitalize="none" + className="h-12 w-full rounded-[16px] border border-border/70 bg-surface-elevated px-4 text-center text-[16px] text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary focus:ring-2 focus:ring-primary/20" + /> + +
+ +
+
+
+ +

{t('mobile.connect.token.hint')}

+
+
+
+
+ + {error ?

{error}

: null} + + +
+ + + {!qrScanSupported ? ( +

+ {t('mobile.connect.scan.unsupported')} +

+ ) : null} +
+ )} + + {!pendingConnection && connections.length > 0 ? ( +
+

+ {t('mobile.connect.saved.title')} +

+
+ {connections.map((connection) => ( + + ))} +
+
+ ) : null} +
+
+ ); +}; + +const MobileInstancesSurface: React.FC<{ + onConnect: () => void; + onActiveConnectionDeleted: () => void; +}> = ({ onActiveConnectionDeleted, onConnect }) => { + const { t } = useI18n(); + const conn = useMobileConnection(onConnect); + const { + connections, isBusy, isPasswordBusy, error, pendingConnection, + connect, submitPassword, cancelPassword, saveConnection, removeConnection, setError, + } = conn; + const [editingId, setEditingId] = React.useState(null); + const editingConnection = editingId ? connections.find((connection) => connection.id === editingId) ?? null : null; + const [confirmingDeleteId, setConfirmingDeleteId] = React.useState(null); + const [url, setUrl] = React.useState(''); + const [label, setLabel] = React.useState(''); + const [clientToken, setClientToken] = React.useState(''); + const [password, setPassword] = React.useState(''); + const [isScanning, setIsScanning] = React.useState(false); + const qrScanSupported = React.useMemo(() => isQrScanSupported(), []); + + // Populate/clear the form imperatively (on edit tap / cancel / save) rather than via + // an effect keyed on the derived connection object. With an effect, any churn of the + // connections list re-fires it and overwrites what the user is typing — the keyboard + // "resets" mid-edit. Imperative population is immune to that. + const resetForm = React.useCallback(() => { + setEditingId(null); + setUrl(''); + setLabel(''); + setClientToken(''); + setError(null); + }, [setError]); + + const saveInstance = React.useCallback((event: React.FormEvent) => { + event.preventDefault(); + void saveConnection({ url, label, clientToken }).then((saved) => { + if (saved) resetForm(); + }); + }, [clientToken, label, resetForm, saveConnection, url]); + + // Scan a pairing QR into the add/edit form fields (does not change edit mode, so + // the form-reset effect doesn't wipe the scanned values). The user reviews + saves. + const handleScanInstance = React.useCallback(async () => { + if (isScanning) return; + setError(null); + setIsScanning(true); + try { + const result = await scanConnectionQr(); + switch (result.status) { + case 'ok': + setUrl(result.url); + if (result.label) setLabel(result.label); + if (result.clientToken) setClientToken(result.clientToken); + break; + case 'permission-denied': + setError(t('mobile.connect.scan.permissionDenied')); + break; + case 'invalid': + setError(t('mobile.connect.scan.invalid')); + break; + case 'unsupported': + setError(t('mobile.connect.scan.unsupported')); + break; + case 'failed': + setError(t('mobile.connect.scan.failed')); + break; + case 'cancelled': + default: + break; + } + } finally { + setIsScanning(false); + } + }, [isScanning, setError, t]); + + const handlePasswordSubmit = React.useCallback((event: React.FormEvent) => { + event.preventDefault(); + void submitPassword(password); + }, [password, submitPassword]); + + const cancelPasswordPrompt = React.useCallback(() => { + setPassword(''); + cancelPassword(); + }, [cancelPassword]); + + // Two-step delete (mirrors the session sheet): the trash icon arms the row, a + // second tap on the destructive button confirms, the X disarms. No hover relied on. + const toggleConfirmDelete = React.useCallback((id: string) => { + setConfirmingDeleteId((current) => (current === id ? null : id)); + }, []); + + const confirmDelete = React.useCallback((id: string) => { + setConfirmingDeleteId(null); + if (editingId === id) resetForm(); + void removeConnection(id).then((removed) => { + if (removed && isSameConnectionUrl(removed.url, getRuntimeApiBaseUrl())) { + onActiveConnectionDeleted(); + } + }); + }, [editingId, onActiveConnectionDeleted, removeConnection, resetForm]); + + const inputClass = 'h-12 w-full rounded-[16px] border border-border/70 bg-surface-elevated px-4 text-[16px] text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary focus:ring-2 focus:ring-primary/20'; + + if (pendingConnection) { + return ( +
+
+
+
+ + + +
+

{pendingConnection.label}

+

{pendingConnection.url}

+
+
+ setPassword(event.target.value)} + placeholder={t('mobile.connect.password.placeholder')} + aria-label={t('mobile.connect.password.label')} + type="password" + autoFocus + className={inputClass} + /> + {error ?

{error}

: null} + + +
+
+
+ ); + } + + return ( +
+
+
+ {connections.length > 0 ? ( +
+ {connections.map((connection) => { + const confirming = confirmingDeleteId === connection.id; + return ( +
+ +
+ {confirming ? ( + + ) : ( + + )} + +
+
+ ); + })} +
+ ) : ( +

+ {t('mobile.connect.saved.empty')} +

+ )} + +
+
+

+ {editingConnection ? t('mobile.instances.editTitle') : t('mobile.instances.addTitle')} +

+ {editingConnection ? ( + + ) : null} +
+
+ + {!qrScanSupported ? ( +

{t('mobile.connect.scan.unsupported')}

+ ) : null} +
+ + + + {error ?

{error}

: null} + +
+
+
+
+ ); +}; + type MobileUsageLimitRow = { key: string; label: string; @@ -664,21 +1625,24 @@ const MobileSessionMetadataButton = React.memo(function MobileSessionMetadataBut return ( <> +
+ + {primaryLabel} + {secondaryLabel ? ( + {secondaryLabel} + ) : null} + +
@@ -758,7 +1722,7 @@ const MobileHeader: React.FC<{ onClick={handleOpenSessions} style={{ touchAction: 'manipulation' }} > - + { +const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onActiveConnectionDeleted }) => { const { t } = useI18n(); const [sessionsSheetOpen, setSessionsSheetOpen] = React.useState(false); const [filesOpen, setFilesOpen] = React.useState(false); const [changesOpen, setChangesOpen] = React.useState(false); const [mcpOpen, setMcpOpen] = React.useState(false); + const [instancesOpen, setInstancesOpen] = React.useState(false); const [isMcpRefreshing, setIsMcpRefreshing] = React.useState(false); const [settingsOpen, setSettingsOpen] = React.useState(false); const [updateOpen, setUpdateOpen] = React.useState(false); @@ -804,6 +1769,7 @@ const MobileShell: React.FC = () => { const setSettingsPage = useUIStore((state) => state.setSettingsPage); const updateAvailable = useUpdateStore((state) => state.available); const updateRuntimeType = useUpdateStore((state) => state.runtimeType); + const showCapacitorOnlyFeatures = React.useMemo(() => isCapacitorMobileApp(), []); const mcpServers = useMcpConfigStore((state) => state.mcpServers); const setMcpDraft = useMcpConfigStore((state) => state.setMcpDraft); const setSelectedMcp = useMcpConfigStore((state) => state.setSelectedMcp); @@ -832,6 +1798,101 @@ const MobileShell: React.FC = () => { setPendingChangesDiff(null); }, []); + // Expose the shell's panel-opening actions to the deep-link layer so openchamber:// URLs + // (and notification taps / widgets) can navigate to these surfaces. Session and + // new-session intents resolve directly against the store, so they aren't wired here. + const deepLinkHandlers = React.useMemo( + () => ({ + openSessions: () => setSessionsSheetOpen(true), + openView: (target: 'files' | 'mcp' | 'instances' | 'update') => { + if (target === 'files') setFilesOpen(true); + else if (target === 'mcp') setMcpOpen(true); + else if (target === 'instances') setInstancesOpen(true); + else if (target === 'update') setUpdateOpen(true); + }, + openChanges: ({ path, staged }: { path?: string; staged?: boolean } = {}) => { + setPendingChangesDiff(path ? { path, staged: staged === true } : null); + setChangesOpen(true); + }, + openSettings: (section?: string) => { + if (section) setSettingsPage(section as Parameters[0]); + setSettingsInitialMobileStage(section ? 'page-content' : 'nav'); + setSettingsOpen(true); + }, + }), + [setSettingsPage], + ); + useDeepLinkHandlers(deepLinkHandlers); + + // Edge swipe (left/right screen edge → centre) switches between sessions, with a directional + // slide+fade on the chat content so it's obvious the session changed. + const chatMainRef = React.useRef(null); + const chatAnimRef = React.useRef(null); + const swipeDirectionRef = React.useRef<'prev' | 'next' | null>(null); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + // Record the swipe direction; the animation itself runs in the layout effect below, once the + // new session's content has committed — running it inline in the swipe callback raced the + // re-render and dropped the animation on roughly every other switch. + const recordSwipeDirection = React.useCallback((direction: 'prev' | 'next') => { + swipeDirectionRef.current = direction; + }, []); + useEdgeSwipeSessionSwitch(chatMainRef, { onSwitch: recordSwipeDirection }); + + React.useLayoutEffect(() => { + const direction = swipeDirectionRef.current; + swipeDirectionRef.current = null; + if (!direction) return; // only animate swipe-driven switches + const element = chatAnimRef.current; + if (!element || typeof element.animate !== 'function') return; + element.getAnimations().forEach((animation) => animation.cancel()); + const fromX = direction === 'prev' ? -70 : 70; + element.animate( + [ + { opacity: 0.1, transform: `translateX(${fromX}px)` }, + { opacity: 1, transform: 'translateX(0)' }, + ], + { duration: 300, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' }, + ); + }, [currentSessionId]); + + const handleNativeBack = React.useCallback(() => { + if (overflowOpen) { + setOverflowOpen(false); + return true; + } + if (sessionsSheetOpen) { + setSessionsSheetOpen(false); + return true; + } + if (filesOpen) { + setFilesOpen(false); + return true; + } + if (changesOpen) { + closeChanges(); + return true; + } + if (mcpOpen) { + setMcpOpen(false); + return true; + } + if (instancesOpen) { + setInstancesOpen(false); + return true; + } + if (settingsOpen) { + setSettingsOpen(false); + return true; + } + if (updateOpen) { + setUpdateOpen(false); + return true; + } + return false; + }, [changesOpen, closeChanges, filesOpen, instancesOpen, mcpOpen, overflowOpen, sessionsSheetOpen, settingsOpen, updateOpen]); + + useNativeAndroidBackButton(handleNativeBack); + const showUpdateItem = updateAvailable && (updateRuntimeType === 'desktop' || updateRuntimeType === 'web'); const openMcpCreateSettings = React.useCallback(() => { @@ -881,7 +1942,8 @@ const MobileShell: React.FC = () => { }, [currentDirectory, isMcpRefreshing, loadMcpConfigs, refreshMcpStatus]); const overflowItems: OverflowItem[] = React.useMemo( - () => [ + () => { + const items: OverflowItem[] = [ { key: 'files', icon: 'file-text', @@ -901,13 +1963,24 @@ const MobileShell: React.FC = () => { label: t('mobile.menu.mcp'), onSelect: () => setMcpOpen(true), }, - ...(showUpdateItem ? [{ - key: 'update' as const, - icon: 'download' as const, - label: t('mobile.menu.update'), - onSelect: () => setUpdateOpen(true), - }] : []), - { + ]; + if (showCapacitorOnlyFeatures) { + items.push({ + key: 'instances', + icon: 'server', + label: t('mobile.menu.instances'), + onSelect: () => setInstancesOpen(true), + }); + } + if (showUpdateItem) { + items.push({ + key: 'update', + icon: 'download', + label: t('mobile.menu.update'), + onSelect: () => setUpdateOpen(true), + }); + } + items.push({ key: 'settings', icon: 'settings-3', label: t('mobile.menu.settings'), @@ -915,25 +1988,28 @@ const MobileShell: React.FC = () => { setSettingsInitialMobileStage('nav'); setSettingsOpen(true); }, - }, - ], - [dirtyChangeCount, showUpdateItem, t], + }); + return items; + }, + [dirtyChangeCount, showCapacitorOnlyFeatures, showUpdateItem, t], ); return (
setSessionsSheetOpen(true)} onOpenMenu={() => setOverflowOpen(true)} /> -
- - - +
+
+ + + +
{ ) : null} + {instancesOpen && showCapacitorOnlyFeatures ? ( + setInstancesOpen(false)} + ariaLabel={t('mobile.menu.instances')} + title={t('mobile.menu.instances')} + > + setInstancesOpen(false)} + onActiveConnectionDeleted={onActiveConnectionDeleted} + /> + + ) : null} + {settingsOpen ? ( { }; export function MobileApp({ apis }: MobileAppProps) { + const { t } = useI18n(); const initializeApp = useConfigStore((state) => state.initializeApp); const isInitialized = useConfigStore((state) => state.isInitialized); const isConnected = useConfigStore((state) => state.isConnected); + const connectionPhase = useConfigStore((state) => state.connectionPhase); const providersCount = useConfigStore((state) => state.providers.length); const agentsCount = useConfigStore((state) => state.agents.length); const loadProviders = useConfigStore((state) => state.loadProviders); @@ -1089,19 +2181,96 @@ export function MobileApp({ apis }: MobileAppProps) { const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus); const setPlanModeEnabled = useFeatureFlagsStore((state) => state.setPlanModeEnabled); const projects = useProjectsStore((state) => state.projects); + const [connectionEpoch, setConnectionEpoch] = React.useState(0); + const [runtimeEndpointEpoch, setRuntimeEndpointEpoch] = React.useState(0); + const [showConnectionRecovery, setShowConnectionRecovery] = React.useState(false); + // Cold-launch auto-connect to the last instance: 'pending'/'attempting' hold the + // splash so we don't flash the connect screen; 'done' means we either connected or + // exhausted the attempt (then the connect screen shows). + const [autoConnectPhase, setAutoConnectPhase] = React.useState<'pending' | 'attempting' | 'done'>('pending'); + const isNativeMobileApp = React.useMemo(() => isCapacitorMobileApp(), []); + const lastNativeResumeSyncEventAtRef = React.useRef(0); + const nativeResumeValidationSeqRef = React.useRef(0); + + const handleNativeResume = React.useCallback(() => { + const apiBaseUrl = getRuntimeApiBaseUrl(); + if (!apiBaseUrl) return; + const validationSeq = nativeResumeValidationSeqRef.current + 1; + nativeResumeValidationSeqRef.current = validationSeq; + + void validateMobileConnectionSession({ url: apiBaseUrl, clientToken: getRuntimeClientToken() }).then((isValid) => { + if (nativeResumeValidationSeqRef.current !== validationSeq) return; + if (!isValid) { + switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' }); + setConnectionEpoch((value) => value + 1); + return; + } + + void initializeApp(); + void refreshGitHubAuthStatus(apis.github, { force: true }); + if (providersCount === 0) void loadProviders({ source: 'mobileApp:nativeResume' }); + if (agentsCount === 0) void loadAgents({ source: 'mobileApp:nativeResume' }); + }); + + const now = Date.now(); + if (now - lastNativeResumeSyncEventAtRef.current >= NATIVE_RESUME_SYNC_EVENT_THROTTLE_MS) { + lastNativeResumeSyncEventAtRef.current = now; + window.dispatchEvent(new Event('openchamber:system-resume')); + } + }, [agentsCount, apis.github, initializeApp, loadAgents, loadProviders, providersCount, refreshGitHubAuthStatus]); + + useNativeMobileChrome(); + useNativeMobileLifecycle(handleNativeResume); React.useEffect(() => { registerRuntimeAPIs(apis); return () => registerRuntimeAPIs(null); }, [apis]); + // Switching instances (or disconnecting) only changes the runtime endpoint; the + // stores still hold the previous instance's data. Mirror the web App.tsx reset + // sequence so the UI fully re-bootstraps against the new server instead of going + // stale. The SyncProvider is keyed by runtimeEndpointEpoch so it remounts too. + React.useEffect(() => { + return subscribeRuntimeEndpointChanged((detail) => { + resetAppForRuntimeEndpointChange(detail); + setRuntimeEndpointEpoch((epoch) => epoch + 1); + setConnectionEpoch((epoch) => epoch + 1); + }); + }, []); + + // On cold launch, silently reconnect to the most-recent saved instance so a + // returning user — and notification deep-links — land in the app instead of the + // connect screen. The splash is held while we try (see render below). If there's + // no saved instance, it's unreachable, or it needs a (re)login, we fall through + // to the connect screen. A successful switchRuntimeEndpoint fires the endpoint- + // changed subscription above, which bumps the epochs and bootstraps the app. + React.useEffect(() => { + if (!isNativeMobileApp || isConnected || getRuntimeApiBaseUrl()) { + setAutoConnectPhase('done'); + return; + } + let cancelled = false; + setAutoConnectPhase('attempting'); + void autoConnectLastInstance() + .catch(() => false) + .then(() => { + if (!cancelled) setAutoConnectPhase('done'); + }); + return () => { + cancelled = true; + }; + // Run once on mount — auto-connect is a cold-launch concern only. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + React.useEffect(() => { setIsMobile(true); }, [setIsMobile]); React.useEffect(() => { void initializeApp(); - }, [initializeApp]); + }, [connectionEpoch, initializeApp]); React.useEffect(() => { if (!isConnected) return; @@ -1114,20 +2283,27 @@ export function MobileApp({ apis }: MobileAppProps) { opencodeClient.setDirectory(currentDirectory); }, [currentDirectory, isConnected]); + // Gated on isConnected (and re-run on reconnect/instance switch): probing the + // GitHub auth status before the runtime is reachable cached a "not connected" + // answer that stuck until something else forced a re-check. React.useEffect(() => { + if (!isConnected) return; void refreshGitHubAuthStatus(apis.github, { force: true }); - }, [apis.github, refreshGitHubAuthStatus]); + }, [apis.github, isConnected, refreshGitHubAuthStatus]); // Discover all worktrees for every known project so the draft session's // worktree/branch dropdown can list every available branch — not only the // current one. Mirrors ElectronMiniChatApp + desktop SessionSidebar. + // Gated on isConnected: running before the runtime is reachable made every + // per-project probe fail silently, leaving the map empty until some later + // projects-store update happened to re-run this effect (the "switch projects + // back and forth to see worktrees" bug). React.useEffect(() => { - if (projects.length === 0) return; + if (!isConnected || projects.length === 0) return; let cancelled = false; const run = async () => { - const worktreesByProject = new Map(); - const allWorktrees: WorktreeMetadata[] = []; + const worktreesByProject = new Map(useSessionUIStore.getState().availableWorktreesByProject); await Promise.all( projects.map(async (project) => { @@ -1139,16 +2315,18 @@ export function MobileApp({ apis }: MobileAppProps) { cachedIsGitRepo ?? (await import('@/lib/gitApi').then((m) => m.checkIsGitRepository(projectPath))); if (!isGitRepo) return; const worktrees = await listProjectWorktrees({ id: project.id, path: projectPath }); - if (cancelled || worktrees.length === 0) return; + if (cancelled) return; worktreesByProject.set(projectPath, worktrees); - allWorktrees.push(...worktrees); } catch { - // Worktree discovery is best-effort; draft selector falls back to the project root. + // Worktree discovery is best-effort per project: a failed probe keeps + // that project's previously known (persisted) worktrees instead of + // wiping the whole map. } }), ); if (cancelled) return; + const allWorktrees = Array.from(worktreesByProject.values()).flat(); useSessionUIStore.setState({ availableWorktrees: allWorktrees, availableWorktreesByProject: worktreesByProject, @@ -1160,7 +2338,7 @@ export function MobileApp({ apis }: MobileAppProps) { return () => { cancelled = true; }; - }, [projects]); + }, [isConnected, projects]); React.useEffect(() => { let cancelled = false; @@ -1187,22 +2365,131 @@ export function MobileApp({ apis }: MobileAppProps) { return () => window.clearTimeout(timeout); }, [clearError, error]); + React.useEffect(() => { + // Native: only while an instance is selected and reconnecting. Browser: the + // runtime is same-origin (no explicit base URL), so any not-connected spell + // counts — the splash holds until this fires, then the error screen shows. + const waitingOnConnection = !isConnected && (isNativeMobileApp ? Boolean(getRuntimeApiBaseUrl()) : true); + if (!waitingOnConnection) { + setShowConnectionRecovery(false); + return; + } + const timeout = window.setTimeout(() => setShowConnectionRecovery(true), 8000); + return () => window.clearTimeout(timeout); + }, [isConnected, isNativeMobileApp, connectionEpoch, runtimeEndpointEpoch]); + useAppFontEffects(); usePushVisibilityBeacon({ enabled: true }); useUpdatePolling(); useWindowTitle(); useRouter(); + // APNs is the only notification channel on the native app (background-capable, + // focus-suppressed server-side via the visibility beacon). Local notifications are + // intentionally disabled — they can't tell foreground from background in a WKWebView + // (document.hasFocus() is unreliable) and leaked while the app was open; the in-app SSE + // notification dispatch is no-op'd for native in renderMobileApp. + useNativePushRegistration({ enabled: isNativeMobileApp && isConnected }); + // Single native deep-link entry point: notification taps AND the openchamber:// URL + // scheme (widgets, Live Activities, external links). Registered unconditionally so a + // cold-launch tap/open isn't lost on the connect/splash screen; intents stash until + // the app is ready (connected + initialized) and shell handlers are registered. + useDeepLinkSource({ ready: isNativeMobileApp && isConnected && isInitialized }); + const fontsReady = useFontsReady(); + + // `isConnected` is a LIVE flag that flips false on every transient SSE/WS drop and + // back true on reconnect. We must NOT blank the whole app to a loader on those — + // only on the initial connect / instance switch (connectionPhase 'connecting'). + // While 'reconnecting' (we were connected before), keep MobileShell mounted so the + // UI doesn't reload on every network blip. + const isReconnecting = !isConnected && connectionPhase === 'reconnecting'; + + // Hold a logo splash until the UI web font is loaded, so the first UI the user sees + // already uses the real font instead of flashing the fallback and reflowing (FOUT). + if (!fontsReady) { + return ( +
+ +
+ ); + } + + if (!isConnected && !isReconnecting && isNativeMobileApp) { + // A runtime endpoint is already selected (first connect or switching instances): + // show a loader while it re-bootstraps instead of flashing the onboarding screen. + if (getRuntimeApiBaseUrl()) { + return ( +
+
+ + {showConnectionRecovery ? ( + <> +
+

{t('sessionAuth.error.networkTitle')}

+

{t('sessionAuth.error.networkDescription')}

+
+ + + ) : null} +
+
+ ); + } + // Cold-launch auto-connect is still resolving — hold the splash instead of + // flashing the connect screen. Only show the connect screen once we've finished + // (no saved instance, unreachable, or needs re-login). + if (autoConnectPhase !== 'done') { + return ( +
+ +
+ ); + } + return setConnectionEpoch((value) => value + 1)} />; + } + + if (!isConnected && !isReconnecting) { + // Browser: the initial connect takes a beat — hold the logo splash instead + // of flashing the unreachable-server error while it resolves. The error + // only shows once the recovery delay has expired (genuinely unreachable). + if (!showConnectionRecovery) { + return ( +
+ +
+ ); + } + return ( +
+
+

{t('sessionAuth.error.networkTitle')}

+

{t('sessionAuth.error.networkDescription')}

+
+
+ ); + } return ( - +
- - + { + switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' }); + setConnectionEpoch((value) => value + 1); + }} /> + {isInitialized ? : null}
diff --git a/packages/ui/src/apps/MobileSurfaceShell.tsx b/packages/ui/src/apps/MobileSurfaceShell.tsx index 5ba246e53c..ad9f4af1bd 100644 --- a/packages/ui/src/apps/MobileSurfaceShell.tsx +++ b/packages/ui/src/apps/MobileSurfaceShell.tsx @@ -67,6 +67,15 @@ export const MobileSurfaceShell: React.FC = ({ const isDraggingRef = React.useRef(false); const surfaceRef = React.useRef(null); const previousFocusRef = React.useRef(null); + // Keep onClose in a ref so the focus/keydown effect below depends only on `open`. + // The parent passes a fresh inline onClose on every render; if the effect depended + // on it, each parent re-render (e.g. an SSE store update) would re-run it and + // refocus the first element — stealing focus from whatever input the user is in + // and collapsing the keyboard mid-edit. + const onCloseRef = React.useRef(onClose); + React.useEffect(() => { + onCloseRef.current = onClose; + }, [onClose]); if (typeof document !== 'undefined' && !rootRef.current) { rootRef.current = ensureSurfaceRoot(); @@ -112,7 +121,7 @@ export const MobileSurfaceShell: React.FC = ({ const focusTimer = window.setTimeout(focusFirstElement, ENTER_DELAY_MS); const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { - onClose(); + onCloseRef.current(); return; } if (event.key !== 'Tab') return; @@ -145,7 +154,7 @@ export const MobileSurfaceShell: React.FC = ({ previousFocusRef.current?.focus?.({ preventScroll: true }); previousFocusRef.current = null; }; - }, [onClose, open]); + }, [open]); const handleDragStart = (event: React.TouchEvent) => { if (disableSwipeDismiss) return; @@ -208,7 +217,7 @@ export const MobileSurfaceShell: React.FC = ({ return createPortal(
- +
@@ -125,7 +125,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
- +
diff --git a/packages/ui/src/apps/appBootReady.ts b/packages/ui/src/apps/appBootReady.ts new file mode 100644 index 0000000000..e527d17ee6 --- /dev/null +++ b/packages/ui/src/apps/appBootReady.ts @@ -0,0 +1,17 @@ +// Resolves once the one-time app boot work that affects layout has been applied — +// notably persisted appearance/typography preferences (font size, spacing), which are +// loaded asynchronously and would otherwise reflow the UI a frame after first paint. +// The mobile splash gate (useFontsReady) awaits this so the first UI shown is final. + +let resolveBoot: (() => void) | null = null; +let resolved = false; + +export const appBootReadyPromise = new Promise((resolve) => { + resolveBoot = resolve; +}); + +export function markAppBootReady(): void { + if (resolved) return; + resolved = true; + resolveBoot?.(); +} diff --git a/packages/ui/src/apps/deepLinkNavigation.ts b/packages/ui/src/apps/deepLinkNavigation.ts new file mode 100644 index 0000000000..cff0835990 --- /dev/null +++ b/packages/ui/src/apps/deepLinkNavigation.ts @@ -0,0 +1,198 @@ +import React from 'react'; + +import { isCapacitorApp } from '@/lib/platform'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useUIStore } from '@/stores/useUIStore'; + +import { buildDeepLink, parseDeepLink, type DeepLinkIntent, type SessionsFilter, type ViewTarget } from './deepLinks'; + +/** + * Navigation layer for {@link DeepLinkIntent}s — the only place that knows how to *apply* a + * deep link. Producers (notification taps, widget `widgetURL`, Live Activities) feed intents + * in via {@link useDeepLinkSource}; the surfaces that can satisfy them register imperative + * handlers via {@link useDeepLinkHandlers}. Session/new-session navigation goes straight to + * the session store (always available), so those resolve even before the shell has mounted. + * + * Intents that arrive before the app is ready (cold launch from a tap/widget) or before their + * handler is registered are stashed in a module-level holder that survives the connect flow + * and SyncProvider remount, then applied as soon as the app becomes ready / the handler + * appears. Only the most recent intent is kept (newest wins) — a burst of taps shouldn't queue. + */ + +export interface DeepLinkHandlers { + /** Open the sessions sheet, optionally pre-filtered (filter support is best-effort for now). */ + openSessions?: (filter?: SessionsFilter) => void; + /** Open a non-session surface (files / mcp / instances / update). */ + openView?: (target: ViewTarget) => void; + /** Open the Changes surface, optionally jumping straight to a file diff. */ + openChanges?: (options?: { path?: string; staged?: boolean }) => void; + /** Open Settings, optionally at a specific section. */ + openSettings?: (section?: string) => void; +} + +let handlers: DeepLinkHandlers = {}; +let ready = false; +let pending: DeepLinkIntent | null = null; + +const execute = (intent: DeepLinkIntent): boolean => { + switch (intent.type) { + case 'session': + void useSessionUIStore.getState().setCurrentSession(intent.sessionId, intent.directory ?? null); + return true; + + case 'new-session': { + const store = useSessionUIStore.getState(); + store.openNewSessionDraft(); + if (intent.directory || intent.projectId) { + store.setNewSessionDraftTarget({ + directoryOverride: intent.directory ?? null, + projectId: intent.projectId ?? null, + selectedProjectId: intent.projectId ?? null, + }); + } + return true; + } + + case 'sessions': + if (!handlers.openSessions) return false; + handlers.openSessions(intent.filter); + return true; + + case 'status': + // The session status panel is store-backed (useUIStore.mobileSessionPanelOpen), + // so it opens without a shell handler — like session/new-session. + useUIStore.getState().setMobileSessionPanelOpen(true); + return true; + + case 'view': + if (!handlers.openView) return false; + handlers.openView(intent.target); + return true; + + case 'changes': + if (!handlers.openChanges) return false; + handlers.openChanges({ path: intent.path, staged: intent.staged }); + return true; + + case 'settings': + if (!handlers.openSettings) return false; + handlers.openSettings(intent.section); + return true; + } +}; + +const flush = (): void => { + if (!ready || !pending) return; + const intent = pending; + // Drop the stash before executing; if the handler isn't registered yet, execute() returns + // false and we re-stash so a later registerDeepLinkHandlers() flush can retry it. + pending = null; + if (!execute(intent)) { + pending = intent; + } +}; + +/** Apply an intent now if possible, otherwise stash it until the app is ready / a handler appears. */ +export const applyDeepLinkIntent = (intent: DeepLinkIntent): void => { + pending = intent; + flush(); +}; + +/** Convenience: parse a raw `openchamber://…` URL and apply it. No-op for unrecognised URLs. */ +export const applyDeepLinkUrl = (raw: string | null | undefined): void => { + const intent = parseDeepLink(raw); + if (intent) { + applyDeepLinkIntent(intent); + } +}; + +const setReady = (value: boolean): void => { + ready = value; + flush(); +}; + +/** + * Register the surfaces that can satisfy shell-scoped intents (sessions/settings/views/changes). + * Call from the component that owns those panels; the handlers are torn down on unmount. + * Registering also flushes any pending intent that was waiting for these handlers. + */ +export const useDeepLinkHandlers = (next: DeepLinkHandlers): void => { + React.useEffect(() => { + handlers = next; + flush(); + return () => { + if (handlers === next) { + handlers = {}; + } + }; + }, [next]); +}; + +/** + * Single native entry point for deep links. Subscribes to both the custom URL scheme + * (`App.appUrlOpen` — widgets, Live Activities, external links) and notification taps + * (`pushNotificationActionPerformed`), normalising each into a {@link DeepLinkIntent}. + * Both listeners are registered UNCONDITIONALLY so a cold-launch tap/open isn't lost while + * the app is still connecting; intents stash until `ready` (connected + initialized). + */ +export const useDeepLinkSource = (options: { ready: boolean }): void => { + const { ready: isReady } = options; + + React.useEffect(() => { + setReady(isReady); + }, [isReady]); + + React.useEffect(() => { + if (!isCapacitorApp()) return; + let disposed = false; + const cleanup: Array<() => void> = []; + + void import('@capacitor/app') + .then(async ({ App }) => { + if (disposed) return; + const handle = await App.addListener('appUrlOpen', (event) => { + applyDeepLinkUrl(event?.url); + }); + if (disposed) { + void handle.remove(); + return; + } + cleanup.push(() => void handle.remove()); + }) + .catch(() => undefined); + + void import('@capacitor/push-notifications') + .then(async ({ PushNotifications }) => { + if (disposed) return; + const handle = await PushNotifications.addListener('pushNotificationActionPerformed', (action) => { + const data = action?.notification?.data as Record | undefined; + // Prefer an explicit deep link in the payload (richest); fall back to a bare + // sessionId for backwards compatibility with existing push senders. + const url = typeof data?.url === 'string' ? data.url : typeof data?.deeplink === 'string' ? data.deeplink : undefined; + if (url) { + applyDeepLinkUrl(url); + return; + } + const sessionId = typeof data?.sessionId === 'string' ? data.sessionId : undefined; + if (sessionId) { + applyDeepLinkIntent({ type: 'session', sessionId }); + } + }); + if (disposed) { + void handle.remove(); + return; + } + cleanup.push(() => void handle.remove()); + }) + .catch(() => undefined); + + return () => { + disposed = true; + cleanup.forEach((remove) => remove()); + }; + }, []); +}; + +// Re-export so producers (notifications, future widgets) have one import for the whole vocabulary. +export { buildDeepLink, parseDeepLink }; +export type { DeepLinkIntent, SessionsFilter, ViewTarget }; diff --git a/packages/ui/src/apps/deepLinks.ts b/packages/ui/src/apps/deepLinks.ts new file mode 100644 index 0000000000..f4c3f21394 --- /dev/null +++ b/packages/ui/src/apps/deepLinks.ts @@ -0,0 +1,169 @@ +/** + * OpenChamber deep-link vocabulary — the single source of truth for the `openchamber://` + * URL scheme used across every native entry point: notification taps, home-screen / lock- + * screen widgets, and (later) Live Activities. Anything that wants to drive navigation + * builds a URL with {@link buildDeepLink} and anything that receives one parses it with + * {@link parseDeepLink} into a typed {@link DeepLinkIntent}; the navigation layer + * (deepLinkNavigation) is the only place that knows how to *apply* an intent. + * + * Keep this file pure (no React, no stores, no Capacitor) so it can be imported from any + * context — including, eventually, a tiny encoder shared with the native widget/extension. + */ + +export const DEEP_LINK_SCHEME = 'openchamber'; + +export type SessionsFilter = 'all' | 'attention' | 'recent'; +export type ViewTarget = 'files' | 'mcp' | 'instances' | 'update'; + +/** + * Every navigable destination the app exposes to the outside world. New widget/notification + * ideas should add a variant here first, then teach deepLinkNavigation how to apply it — + * that keeps the "blocks" composable without leaking ad-hoc URL parsing into features. + */ +export type DeepLinkIntent = + | { type: 'session'; sessionId: string; directory?: string } + | { type: 'new-session'; directory?: string; projectId?: string; agent?: string; model?: string } + | { type: 'sessions'; filter?: SessionsFilter } + | { type: 'status' } + | { type: 'settings'; section?: string } + | { type: 'changes'; path?: string; staged?: boolean } + | { type: 'view'; target: ViewTarget }; + +const trimSlashes = (value: string): string => value.replace(/^\/+|\/+$/g, ''); + +const segmentsOf = (url: URL): string[] => { + // Custom-scheme URLs put the first route token in `host` (openchamber://session/), + // but be tolerant of authority-less forms (openchamber:/session/) where it lands in + // the pathname instead. + const pathSegments = trimSlashes(url.pathname).split('/').filter(Boolean); + if (url.host) { + return [url.host, ...pathSegments]; + } + return pathSegments; +}; + +/** + * Parse a raw `openchamber://…` string into a typed intent, or `null` if it isn't a + * recognised OpenChamber deep link. Tolerant by design: unknown routes return `null` + * rather than throwing, so callers can fall back without a try/catch. + */ +export function parseDeepLink(raw: string | null | undefined): DeepLinkIntent | null { + if (typeof raw !== 'string' || raw.length === 0) { + return null; + } + + let url: URL; + try { + url = new URL(raw); + } catch { + return null; + } + + if (url.protocol !== `${DEEP_LINK_SCHEME}:`) { + return null; + } + + const segments = segmentsOf(url); + const route = (segments[0] ?? '').toLowerCase(); + const rest = segments.slice(1); + const query = url.searchParams; + + switch (route) { + case 'session': { + const sessionId = rest[0] || query.get('id') || ''; + if (!sessionId) { + return null; + } + return { type: 'session', sessionId, directory: query.get('dir') ?? undefined }; + } + + case 'new': + case 'new-session': + return { + type: 'new-session', + directory: query.get('dir') ?? undefined, + projectId: query.get('project') ?? undefined, + agent: query.get('agent') ?? undefined, + model: query.get('model') ?? undefined, + }; + + case 'sessions': { + const filter = query.get('filter'); + return { + type: 'sessions', + filter: filter === 'attention' || filter === 'recent' || filter === 'all' ? filter : undefined, + }; + } + + case 'status': + return { type: 'status' }; + + case 'settings': + return { type: 'settings', section: rest[0] || query.get('section') || undefined }; + + case 'changes': + return { + type: 'changes', + path: rest.join('/') || query.get('path') || undefined, + staged: query.get('staged') === 'true', + }; + + case 'view': { + const target = (rest[0] || '').toLowerCase(); + // `changes` has its own richer intent (diff path); route the bare view token to it. + if (target === 'changes') { + return { type: 'changes' }; + } + if (target === 'files' || target === 'mcp' || target === 'instances' || target === 'update') { + return { type: 'view', target }; + } + return null; + } + + default: + return null; + } +} + +/** + * Build a canonical `openchamber://…` URL for an intent. Used by anything that needs to hand + * a deep link to iOS — notification payloads, `widgetURL(...)`, Live Activity tap targets — + * so every producer emits the exact shape {@link parseDeepLink} understands. + */ +export function buildDeepLink(intent: DeepLinkIntent): string { + const base = `${DEEP_LINK_SCHEME}://`; + const withQuery = (path: string, params: Record): string => { + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (typeof value === 'string' && value.length > 0) { + search.set(key, value); + } + } + const query = search.toString(); + return query ? `${base}${path}?${query}` : `${base}${path}`; + }; + + switch (intent.type) { + case 'session': + return withQuery(`session/${encodeURIComponent(intent.sessionId)}`, { dir: intent.directory }); + case 'new-session': + return withQuery('new', { + dir: intent.directory, + project: intent.projectId, + agent: intent.agent, + model: intent.model, + }); + case 'sessions': + return withQuery('sessions', { filter: intent.filter }); + case 'status': + return `${base}status`; + case 'settings': + return intent.section ? `${base}settings/${encodeURIComponent(intent.section)}` : `${base}settings`; + case 'changes': + return withQuery(intent.path ? `changes/${intent.path}` : 'changes', { + staged: intent.staged ? 'true' : undefined, + }); + case 'view': + return `${base}view/${intent.target}`; + } +} diff --git a/packages/ui/src/apps/mobileConnections.test.ts b/packages/ui/src/apps/mobileConnections.test.ts new file mode 100644 index 0000000000..0f16f3c577 --- /dev/null +++ b/packages/ui/src/apps/mobileConnections.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, mock, test } from 'bun:test'; + +import { validateMobileConnectionSession } from './mobileConnections'; + +const originalFetch = globalThis.fetch; +const originalWindow = globalThis.window; + +const installTestWindow = () => { + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + setTimeout: globalThis.setTimeout.bind(globalThis), + clearTimeout: globalThis.clearTimeout.bind(globalThis), + location: { protocol: 'https:' }, + }, + }); +}; + +const restoreGlobals = () => { + globalThis.fetch = originalFetch; + Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow }); +}; + +describe('validateMobileConnectionSession', () => { + test('accepts a reachable authenticated runtime', async () => { + const fetchMock = mock(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith('/health')) return Response.json({ ok: true }); + if (url.endsWith('/auth/session')) return Response.json({ authenticated: true, scope: 'client' }); + return new Response(null, { status: 404 }); + }); + try { + installTestWindow(); + globalThis.fetch = fetchMock as typeof fetch; + + const result = await validateMobileConnectionSession({ url: 'https://runtime.example', clientToken: 'token' }); + expect(result).toBe(true); + } finally { + restoreGlobals(); + } + }); + + test('rejects unreachable runtimes', async () => { + try { + installTestWindow(); + globalThis.fetch = mock(async () => new Response(null, { status: 503 })) as typeof fetch; + + const result = await validateMobileConnectionSession({ url: 'https://runtime.example', clientToken: 'token' }); + expect(result).toBe(false); + } finally { + restoreGlobals(); + } + }); + + test('rejects invalid or unauthenticated sessions', async () => { + const fetchMock = mock(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith('/health')) return Response.json({ ok: true }); + return Response.json({ authenticated: false }, { status: 401 }); + }); + try { + installTestWindow(); + globalThis.fetch = fetchMock as typeof fetch; + + const result = await validateMobileConnectionSession({ url: 'https://runtime.example', clientToken: 'expired' }); + expect(result).toBe(false); + } finally { + restoreGlobals(); + } + }); +}); diff --git a/packages/ui/src/apps/mobileConnections.ts b/packages/ui/src/apps/mobileConnections.ts new file mode 100644 index 0000000000..a2307cf0f4 --- /dev/null +++ b/packages/ui/src/apps/mobileConnections.ts @@ -0,0 +1,705 @@ +// Saved-connection storage + the shared connect/unlock flow for the dedicated +// mobile app. Both the onboarding welcome screen and the Instances sheet drive +// connections through `useMobileConnection` so the health-check + progressive +// password unlock + client-token issuance + runtime switch all behave identically. +// +// Persistence model (deliberately simple so it is correct-by-inspection): +// - Instance *metadata* (id/label/url/lastUsedAt + a `hasToken` flag) lives in +// localStorage. On native it NEVER contains the client token. +// - The client token lives in the OS secure store (iOS Keychain / Android +// Keystore) via @aparajita/capacitor-secure-storage, keyed per instance URL. +// - On web (browser-hosted mobile.html) there is no secure store, so the token +// stays inline in localStorage — that surface is not the native security target. +// +// Token writes are AWAITED before we switch the runtime endpoint, so a successful +// unlock guarantees the token is actually persisted (no fire-and-forget). + +import { SecureStorage } from '@aparajita/capacitor-secure-storage'; +import React from 'react'; + +import { useI18n } from '@/lib/i18n'; +import { isCapacitorApp } from '@/lib/platform'; +import { switchRuntimeEndpoint } from '@/lib/runtime-switch'; + +const MOBILE_CONNECTIONS_STORAGE_KEY = 'openchamber.mobile.connections.v1'; +const MOBILE_SECURE_STORAGE_PREFIX = 'openchamber.mobile.'; +const MOBILE_CONNECTIONS_LIMIT = 12; +const MOBILE_CONNECT_TIMEOUT_MS = 8000; +const MOBILE_NATIVE_HTTP_TIMEOUT_MS = 2500; +const MOBILE_SECURE_TIMEOUT_MS = 3000; + +export type MobileSavedConnection = { + id: string; + label: string; + url: string; + lastUsedAt: number; + // Native: indicates a token exists in the secure store. Web: unused. + hasToken?: boolean; + // Web only: the token stored inline. On native this stays undefined in the list. + clientToken?: string; +}; + +export type MobilePendingConnection = { + label: string; + url: string; +}; + +export type MobileConnectInput = { + url: string; + clientToken?: string; + label?: string; +}; + +type MobileFetchResponse = { + ok: boolean; + status: number; + source: 'native-http' | 'browser-fetch'; + json: () => Promise; +}; + +type MobileSessionStatus = { + authenticated?: boolean; + disabled?: boolean; + scope?: string; +}; + +// --------------------------------------------------------------------------- +// URL helpers +// --------------------------------------------------------------------------- + +export const normalizeConnectionUrl = (value: string): string => { + const trimmed = value.trim(); + if (!trimmed) return ''; + const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`; + const url = new URL(withScheme); + url.hash = ''; + url.search = ''; + url.pathname = url.pathname.replace(/\/+$/, ''); + return url.toString().replace(/\/+$/, ''); +}; + +export const getConnectionLabel = (url: string): string => { + try { + return new URL(url).host; + } catch { + return url; + } +}; + +const getConnectionStorageKey = (url: string): string => { + try { + return normalizeConnectionUrl(url); + } catch { + return url.trim().replace(/\/+$/g, ''); + } +}; + +export const isSameConnectionUrl = (left: string, right: string): boolean => + getConnectionStorageKey(left) === getConnectionStorageKey(right); + +// --------------------------------------------------------------------------- +// Request helpers (native CapacitorHttp first — needed to reach plain-http LAN +// servers the secure webview cannot fetch — then a browser-fetch fallback). +// --------------------------------------------------------------------------- + +const logConnect = (step: string, detail: Record = {}): void => { + console.info('[mobile-connect]', step, detail); +}; + +const logStorage = (step: string, detail: Record = {}): void => { + console.info('[mobile-storage]', step, detail); +}; + +const parseMaybeJson = (value: unknown): unknown => { + if (typeof value !== 'string') return value; + try { + return JSON.parse(value) as unknown; + } catch { + return value; + } +}; + +const getJsonRequestData = (body: BodyInit | null | undefined): unknown => { + if (typeof body !== 'string') return body ?? undefined; + try { + return JSON.parse(body) as unknown; + } catch { + return body; + } +}; + +const nativeHttpRequest = async (url: string, init?: RequestInit): Promise => { + if (!isCapacitorApp()) return null; + try { + const { CapacitorHttp } = await import('@capacitor/core'); + const headers = Object.fromEntries(new Headers(init?.headers).entries()); + const response = await CapacitorHttp.request({ + url, + method: init?.method || 'GET', + headers, + data: getJsonRequestData(init?.body), + }); + return { + ok: response.status >= 200 && response.status < 300, + status: response.status, + source: 'native-http', + json: async () => parseMaybeJson(response.data), + }; + } catch (error) { + console.warn('[mobile-connect] native-http failed', { url, error }); + return null; + } +}; + +const browserFetchRequest = async (url: string, init?: RequestInit): Promise => { + const response = await fetch(url, init).catch((error) => { + console.warn('[mobile-connect] browser-fetch failed', { url, error }); + return null; + }); + if (!response) return null; + return { ok: response.ok, status: response.status, source: 'browser-fetch', json: () => response.json() }; +}; + +const raceWithTimeout = async (timeoutMs: number, operation: Promise, onTimeout?: () => void): Promise => { + let timeoutId: number | undefined; + const timeout = new Promise((resolve) => { + timeoutId = window.setTimeout(() => { + onTimeout?.(); + resolve(null); + }, timeoutMs); + }); + try { + return await Promise.race([operation, timeout]); + } catch { + return null; + } finally { + if (timeoutId !== undefined) window.clearTimeout(timeoutId); + } +}; + +const requestWithTimeout = async (url: string, init?: RequestInit): Promise => { + const startedAt = Date.now(); + const native = await raceWithTimeout( + Math.min(MOBILE_NATIVE_HTTP_TIMEOUT_MS, MOBILE_CONNECT_TIMEOUT_MS), + nativeHttpRequest(url, init), + ); + if (native) return native; + + const controller = new AbortController(); + const remainingMs = Math.max(1000, MOBILE_CONNECT_TIMEOUT_MS - (Date.now() - startedAt)); + return raceWithTimeout( + remainingMs, + browserFetchRequest(url, { ...init, signal: controller.signal }), + () => controller.abort(), + ); +}; + +const readSessionStatus = async (response: MobileFetchResponse | null): Promise => { + if (!response) return null; + const payload = await response.json().catch(() => null); + if (!payload || typeof payload !== 'object') return null; + const record = payload as Record; + return { + authenticated: typeof record.authenticated === 'boolean' ? record.authenticated : undefined, + disabled: typeof record.disabled === 'boolean' ? record.disabled : undefined, + scope: typeof record.scope === 'string' ? record.scope : undefined, + }; +}; + +// --------------------------------------------------------------------------- +// Metadata storage (localStorage) — never holds the token on native. +// --------------------------------------------------------------------------- + +const readConnections = (): MobileSavedConnection[] => { + if (typeof window === 'undefined') return []; + let parsed: unknown; + try { + parsed = JSON.parse(window.localStorage.getItem(MOBILE_CONNECTIONS_STORAGE_KEY) || '[]'); + } catch { + return []; + } + if (!Array.isArray(parsed)) return []; + const native = isCapacitorApp(); + return parsed + .flatMap((item): MobileSavedConnection[] => { + if (!item || typeof item !== 'object') return []; + const c = item as Partial; + if (typeof c.id !== 'string' || typeof c.url !== 'string') return []; + const inlineToken = typeof c.clientToken === 'string' && c.clientToken.trim() ? c.clientToken : undefined; + const base: MobileSavedConnection = { + id: c.id, + label: typeof c.label === 'string' && c.label.trim() ? c.label : getConnectionLabel(c.url), + url: c.url, + lastUsedAt: typeof c.lastUsedAt === 'number' ? c.lastUsedAt : 0, + }; + if (native) return [{ ...base, hasToken: Boolean(c.hasToken) || Boolean(inlineToken) }]; + return [{ ...base, clientToken: inlineToken, hasToken: Boolean(inlineToken) }]; + }) + .sort((a, b) => b.lastUsedAt - a.lastUsedAt); +}; + +const writeConnections = (connections: MobileSavedConnection[]): void => { + if (typeof window === 'undefined') return; + const native = isCapacitorApp(); + const serialized = connections.slice(0, MOBILE_CONNECTIONS_LIMIT).map((c) => ( + native + ? { id: c.id, label: c.label, url: c.url, lastUsedAt: c.lastUsedAt, hasToken: Boolean(c.hasToken || c.clientToken) } + : { id: c.id, label: c.label, url: c.url, lastUsedAt: c.lastUsedAt, clientToken: c.clientToken } + )); + try { + window.localStorage.setItem(MOBILE_CONNECTIONS_STORAGE_KEY, JSON.stringify(serialized)); + } catch (error) { + console.warn('[mobile-storage] failed to persist connection metadata', error); + } +}; + +const upsertConnectionInList = ( + connections: MobileSavedConnection[], + draft: { label: string; url: string; clientToken?: string; hasToken?: boolean }, +): MobileSavedConnection[] => { + const key = getConnectionStorageKey(draft.url); + const existing = connections.find((item) => getConnectionStorageKey(item.url) === key); + const native = isCapacitorApp(); + const next: MobileSavedConnection = { + id: existing?.id || crypto.randomUUID(), + label: draft.label, + url: draft.url, + lastUsedAt: Date.now(), + ...(native + ? { hasToken: draft.hasToken ?? (Boolean(draft.clientToken) || existing?.hasToken || false) } + : { clientToken: draft.clientToken ?? existing?.clientToken, hasToken: Boolean(draft.clientToken ?? existing?.clientToken) }), + }; + return [ + next, + ...connections.filter((item) => item.id !== next.id && getConnectionStorageKey(item.url) !== key), + ].slice(0, MOBILE_CONNECTIONS_LIMIT); +}; + +// --------------------------------------------------------------------------- +// Secure token storage (native only), per-instance URL. Every call is bounded +// so a hung/unavailable Keychain can never block the connect flow. +// --------------------------------------------------------------------------- + +// We call the plugin's NATIVE methods (`internalSetItem`/`internalGetItem`/ +// `internalRemoveItem`) directly. Capacitor routes native methods straight to the +// iOS/Android plugin via the bridge — unlike the high-level `setItem`/`setKeyPrefix` +// JS methods, which make the `registerPlugin` proxy lazy-load its platform JS module +// (the step that stalls in this webview). We also build the prefixed key ourselves +// so we never touch the JS-only `setKeyPrefix`. +type NativeSecureStorage = { + internalSetItem: (options: { prefixedKey: string; data: string; sync: boolean; access: number }) => Promise; + internalGetItem: (options: { prefixedKey: string; sync: boolean }) => Promise<{ data: string | null }>; + internalRemoveItem: (options: { prefixedKey: string; sync: boolean }) => Promise<{ success: boolean }>; +}; + +const nativeSecure = SecureStorage as unknown as NativeSecureStorage; +const KEYCHAIN_ACCESS_WHEN_UNLOCKED = 0; // KeychainAccess.whenUnlocked + +const prefixedTokenKey = (url: string): string => + `${MOBILE_SECURE_STORAGE_PREFIX}token.${encodeURIComponent(getConnectionStorageKey(url))}`; + +const withTimeout = async (operation: Promise, fallback: T): Promise => { + let timeoutId: number | undefined; + const timeout = new Promise((resolve) => { + timeoutId = window.setTimeout(() => resolve(fallback), MOBILE_SECURE_TIMEOUT_MS); + }); + try { + return await Promise.race([operation.catch(() => fallback), timeout]); + } finally { + if (timeoutId !== undefined) window.clearTimeout(timeoutId); + } +}; + +// Bound a native Keychain call so a stalled/failed bridge can never hang the flow. +const boundedSecure = async (label: string, run: () => Promise, fallback: T): Promise => { + if (!isCapacitorApp()) return fallback; + return withTimeout( + run().catch((error) => { + console.warn(`[mobile-storage] ${label} failed`, error); + return fallback; + }), + fallback, + ); +}; + +const readSecureToken = async (url: string): Promise => { + logStorage('secure:read-start', { url }); + const value = await boundedSecure( + 'secure:read', + async () => (await nativeSecure.internalGetItem({ prefixedKey: prefixedTokenKey(url), sync: false })).data, + null, + ); + const token = typeof value === 'string' && value.trim() ? value : undefined; + logStorage('secure:read', { url, hasToken: Boolean(token) }); + return token; +}; + +const writeSecureToken = async (url: string, token: string): Promise => { + logStorage('secure:write-start', { url }); + const ok = await boundedSecure('secure:write', async () => { + await nativeSecure.internalSetItem({ + prefixedKey: prefixedTokenKey(url), + data: token, + sync: false, + access: KEYCHAIN_ACCESS_WHEN_UNLOCKED, + }); + return true; + }, false); + logStorage('secure:write', { url, ok }); + return ok; +}; + +const deleteSecureToken = async (url: string): Promise => { + await boundedSecure('secure:delete', async () => { + await nativeSecure.internalRemoveItem({ prefixedKey: prefixedTokenKey(url), sync: false }); + return true; + }, false); +}; + +// --------------------------------------------------------------------------- +// Public storage API +// --------------------------------------------------------------------------- + +// One-time migration: a legacy localStorage record on native might still carry an +// inline `clientToken`. Move it into the secure store and strip the metadata. +const migrateLegacyInlineTokens = async (): Promise => { + if (typeof window === 'undefined' || !isCapacitorApp()) return; + let parsed: unknown; + try { + parsed = JSON.parse(window.localStorage.getItem(MOBILE_CONNECTIONS_STORAGE_KEY) || '[]'); + } catch { + return; + } + if (!Array.isArray(parsed)) return; + const legacy = parsed.filter((item): item is { url: string; clientToken: string } => + Boolean(item) && typeof item === 'object' + && typeof (item as { url?: unknown }).url === 'string' + && typeof (item as { clientToken?: unknown }).clientToken === 'string' + && Boolean((item as { clientToken: string }).clientToken.trim())); + if (legacy.length === 0) return; + logStorage('secure:migrate-start', { count: legacy.length }); + for (const { url, clientToken } of legacy) { + await writeSecureToken(url, clientToken); + } + writeConnections(readConnections()); + logStorage('secure:migrate-done', { count: legacy.length }); +}; + +export const loadMobileConnections = async (): Promise => { + await migrateLegacyInlineTokens(); + return readConnections(); +}; + +export const upsertMobileConnection = async ( + connection: { label: string; url: string; clientToken?: string }, +): Promise => { + const next = upsertConnectionInList(readConnections(), connection); + writeConnections(next); + if (isCapacitorApp() && connection.clientToken) { + await writeSecureToken(connection.url, connection.clientToken); + } + return next; +}; + +export const deleteMobileConnection = async (id: string): Promise => { + const connections = readConnections(); + const removed = connections.find((connection) => connection.id === id) ?? null; + const next = connections.filter((connection) => connection.id !== id); + writeConnections(next); + if (removed && isCapacitorApp()) await deleteSecureToken(removed.url); + return next; +}; + +// Cold-launch auto-connect: silently reconnect to the most-recently-used saved +// instance so a returning user (and notification deep-links) land straight in the +// app instead of the connect screen. Returns true and switches the runtime endpoint +// when the instance is reachable AND we already have a usable bearer token; returns +// false — caller shows the connect screen — when there is no saved instance, it's +// unreachable, or it needs a (re)login. Mirrors the success path of +// `useMobileConnection.connect`, with no prompts or UI state. +export const autoConnectLastInstance = async (): Promise => { + await migrateLegacyInlineTokens(); + const candidate = readConnections()[0]; // sorted most-recent-first + if (!candidate) return false; + + const url = normalizeConnectionUrl(candidate.url); + if (!url) return false; + + // The native runtime transport needs a bearer token; only auto-connect when one is + // already saved. A missing/expired token must go through the login UI, not silently. + let token: string | undefined; + if (isCapacitorApp()) { + if (!candidate.hasToken) return false; + token = await readSecureToken(url); + if (!token) return false; + } else { + token = candidate.clientToken; + } + + const headers = token ? { Authorization: `Bearer ${token}` } : undefined; + + const health = await requestWithTimeout(`${url}/health`, { method: 'GET', headers }); + if (!health?.ok) return false; + + const session = await requestWithTimeout(`${url}/auth/session`, { method: 'GET', credentials: 'include', headers }); + // Token rejected / session invalid → fall back to the login screen. + if (!session || (!session.ok && session.status !== 404)) return false; + const status = await readSessionStatus(session); + if (status && status.disabled !== true && status.authenticated === false) return false; + + await upsertMobileConnection({ label: candidate.label, url }); // bump lastUsedAt (keeps hasToken) + switchRuntimeEndpoint({ apiBaseUrl: url, clientToken: token ?? null }); + return true; +}; + +export const validateMobileConnectionSession = async (input: { + url: string; + clientToken?: string | null; +}): Promise => { + let url = ''; + try { + url = normalizeConnectionUrl(input.url); + } catch { + return false; + } + if (!url) return false; + + const token = input.clientToken?.trim() || undefined; + const headers = token ? { Authorization: `Bearer ${token}` } : undefined; + + const health = await requestWithTimeout(`${url}/health`, { method: 'GET', headers }); + if (!health?.ok) return false; + + const session = await requestWithTimeout(`${url}/auth/session`, { method: 'GET', credentials: 'include', headers }); + if (!session || (!session.ok && session.status !== 404)) return false; + + const status = await readSessionStatus(session); + return !(status && status.disabled !== true && status.authenticated === false); +}; + +// --------------------------------------------------------------------------- +// Shared connection controller +// --------------------------------------------------------------------------- + +export type UseMobileConnection = { + connections: MobileSavedConnection[]; + isBusy: boolean; + isPasswordBusy: boolean; + error: string | null; + pendingConnection: MobilePendingConnection | null; + connect: (input: MobileConnectInput) => Promise; + submitPassword: (password: string) => Promise; + cancelPassword: () => void; + saveConnection: (input: MobileConnectInput) => Promise; + removeConnection: (id: string) => Promise; + setError: (message: string | null) => void; +}; + +// `onConnected` fires once the runtime endpoint is switched (the caller navigates +// away / closes its surface from there). +export const useMobileConnection = (onConnected: () => void): UseMobileConnection => { + const { t } = useI18n(); + const [connections, setConnections] = React.useState(() => readConnections()); + const [busyOperation, setBusyOperation] = React.useState<'connect' | 'password' | null>(null); + const [error, setError] = React.useState(null); + const [pendingConnection, setPendingConnection] = React.useState(null); + const connectionsRef = React.useRef(connections); + const busyRef = React.useRef<'connect' | 'password' | null>(null); + + const applyConnections = React.useCallback((next: MobileSavedConnection[]) => { + connectionsRef.current = next; + setConnections(next); + }, []); + + const beginBusy = React.useCallback((operation: 'connect' | 'password') => { + busyRef.current = operation; + setBusyOperation(operation); + }, []); + + const endBusy = React.useCallback((operation: 'connect' | 'password') => { + if (busyRef.current !== operation) return; + busyRef.current = null; + setBusyOperation(null); + }, []); + + // Refresh from storage on mount (runs the legacy-token migration too). + React.useEffect(() => { + let disposed = false; + void loadMobileConnections().then((loaded) => { + if (!disposed) applyConnections(loaded); + }); + return () => { disposed = true; }; + }, [applyConnections]); + + // Persist metadata for a connection and reflect it in state immediately. + const persistMetadata = React.useCallback((draft: { label: string; url: string; clientToken?: string }) => { + const next = upsertConnectionInList(connectionsRef.current, draft); + applyConnections(next); + writeConnections(next); + return next; + }, [applyConnections]); + + const connect = React.useCallback(async (input: MobileConnectInput) => { + setError(null); + beginBusy('connect'); + try { + const url = normalizeConnectionUrl(input.url); + if (!url) { + setError(t('mobile.connect.error.urlRequired')); + return; + } + + const label = input.label?.trim() + || connectionsRef.current.find((c) => isSameConnectionUrl(c.url, url))?.label + || getConnectionLabel(url); + + // Resolve a token: explicit input wins, otherwise read the saved one from + // the secure store (single bounded read — never blocks the flow). + let token = input.clientToken?.trim() || undefined; + const tokenIsNew = Boolean(token); + if (!token && isCapacitorApp()) { + const saved = connectionsRef.current.find((c) => isSameConnectionUrl(c.url, url)); + if (saved?.hasToken) token = await readSecureToken(url); + } + + const headers = token ? { Authorization: `Bearer ${token}` } : undefined; + + logConnect('health:start', { url }); + const health = await requestWithTimeout(`${url}/health`, { method: 'GET', headers }); + logConnect('health:done', { ok: health?.ok === true, source: health?.source ?? null, status: health?.status ?? null }); + if (!health?.ok) { + setError(t('mobile.connect.error.unreachable')); + return; + } + + logConnect('session:start', { url, hasToken: Boolean(token) }); + const session = await requestWithTimeout(`${url}/auth/session`, { method: 'GET', credentials: 'include', headers }); + const status = await readSessionStatus(session); + logConnect('session:done', { ok: session?.ok === true, status: session?.status ?? null, scope: status?.scope ?? null, disabled: status?.disabled === true }); + + // A cookie-only native session (authenticated, but not a `client` bearer + // scope and not auth-disabled) is not enough — the runtime transport needs a + // bearer token, so fall through to the password flow to mint one. + const cookieOnlyNeedsToken = isCapacitorApp() + && session?.ok === true + && !token + && status?.authenticated === true + && status.disabled !== true + && status.scope !== 'client'; + + if (!token && (session?.status === 401 || cookieOnlyNeedsToken)) { + persistMetadata({ label, url }); + setPendingConnection({ label, url }); + return; + } + + if (!session || (!session.ok && session.status !== 404)) { + setError(t('mobile.connect.error.authRequired')); + return; + } + + // Connected. If the token came from the user (not the secure store), persist + // it first so a cold restart won't re-prompt. + if (token && tokenIsNew && isCapacitorApp()) { + await writeSecureToken(url, token); + } + persistMetadata({ label, url, clientToken: token }); + switchRuntimeEndpoint({ apiBaseUrl: url, clientToken: token ?? null }); + onConnected(); + } catch (error) { + console.warn('[mobile-connect] connect threw', error); + setError(t('mobile.connect.error.invalidUrl')); + } finally { + endBusy('connect'); + } + }, [beginBusy, endBusy, onConnected, persistMetadata, t]); + + const submitPassword = React.useCallback(async (password: string) => { + if (!pendingConnection || !password.trim() || busyRef.current === 'password') return; + setError(null); + beginBusy('password'); + const { url, label } = pendingConnection; + try { + logConnect('password:start', { url }); + const response = await requestWithTimeout(`${url}/auth/session`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ password, trustDevice: true, issueClientToken: true, clientLabel: 'OpenChamber Mobile' }), + }); + logConnect('password:done', { ok: response?.ok === true, status: response?.status ?? null }); + if (!response?.ok) { + setError(t('mobile.connect.error.passwordFailed')); + return; + } + + const payload = await response.json().catch(() => null) as { clientToken?: unknown } | null; + const issuedToken = typeof payload?.clientToken === 'string' ? payload.clientToken.trim() : ''; + logConnect('password:token', { issued: Boolean(issuedToken) }); + + // Native runtime transport needs a bearer token; a cookie-only success is + // not acceptable for a saved protected instance. + if (isCapacitorApp() && !issuedToken) { + setError(t('mobile.connect.error.authRequired')); + return; + } + + // Guarantee the token is persisted BEFORE switching (no fire-and-forget). + if (isCapacitorApp() && issuedToken) { + await writeSecureToken(url, issuedToken); + } + persistMetadata({ label, url, clientToken: issuedToken || undefined }); + setPendingConnection(null); + switchRuntimeEndpoint({ apiBaseUrl: url, clientToken: issuedToken || null }); + onConnected(); + } catch (error) { + console.warn('[mobile-connect] password threw', error); + setError(t('mobile.connect.error.passwordFailed')); + } finally { + endBusy('password'); + } + }, [beginBusy, endBusy, onConnected, pendingConnection, persistMetadata, t]); + + const cancelPassword = React.useCallback(() => { + setPendingConnection(null); + setError(null); + }, []); + + const saveConnection = React.useCallback(async (input: MobileConnectInput): Promise => { + setError(null); + const url = normalizeConnectionUrl(input.url); + if (!url) { + setError(t('mobile.connect.error.urlRequired')); + return null; + } + const clientToken = input.clientToken?.trim() || undefined; + const label = input.label?.trim() || getConnectionLabel(url); + // Awaited token write so "Save" truly persisted the secret before returning. + if (isCapacitorApp() && clientToken) { + await writeSecureToken(url, clientToken); + } + const next = persistMetadata({ label, url, clientToken }); + return next.find((connection) => isSameConnectionUrl(connection.url, url)) ?? null; + }, [persistMetadata, t]); + + const removeConnection = React.useCallback(async (id: string): Promise => { + const removed = connectionsRef.current.find((connection) => connection.id === id) ?? null; + const next = await deleteMobileConnection(id); + applyConnections(next); + return removed; + }, [applyConnections]); + + return { + connections, + isBusy: busyOperation !== null, + isPasswordBusy: busyOperation === 'password', + error, + pendingConnection, + connect, + submitPassword, + cancelPassword, + saveConnection, + removeConnection, + setError, + }; +}; diff --git a/packages/ui/src/apps/mobileQrScan.ts b/packages/ui/src/apps/mobileQrScan.ts new file mode 100644 index 0000000000..15aa52bb4b --- /dev/null +++ b/packages/ui/src/apps/mobileQrScan.ts @@ -0,0 +1,181 @@ +// Connection payload parsing + native QR scanning for the dedicated mobile app. +// +// The pairing link format is produced by `openchamber connect-url --qr`: +// openchamber://connect?v=1&server=&token=&label=