diff --git a/.claude/memory.md b/.claude/memory.md index b7631efb1c..3731748cb8 100644 --- a/.claude/memory.md +++ b/.claude/memory.md @@ -210,6 +210,13 @@ Quick reference for anyone starting with Claude on this project. Updated by the - **Kill stuck processes** — `lsof -i :7788` then `kill `. Useful when `dev:app` reports a stale listener and you want to force a fresh boot rather than relying on the handle's auto-recovery. - **Skills runtime removed** — the QuickJS / `rquickjs` runtime is gone; `src/openhuman/skills/` is metadata-only ("Legacy skill metadata helpers retained after QuickJS runtime removal"). Skill execution surfaces are being rebuilt; don't assume a `.skill` can run end-to-end without checking the current code. +## Artifacts Domain (Issue #2776) + +- **Filesystem-backed persistence, no SQLite** — `src/openhuman/artifacts/` stores JSON metadata (`meta.json`) + binary blobs under `/artifacts//`. Pattern mirrors `memory/ops/files.rs` but simpler. +- **`"ai"` namespace in controller registry** — RPC methods are `openhuman.ai_list_artifacts`, `openhuman.ai_get_artifact`, `openhuman.ai_delete_artifact`. Future `ai_*` methods should use this same namespace. +- **Two-layer path validation required** — (1) `validate_artifact_id` rejects empty strings, `/`, `\`, `..`, absolute Unix paths, Windows `C:` and UNC `\\` paths; (2) `assert_within_root` canonicalizes and checks containment. Replicate this pattern for any new filesystem-backed domain. +- **`cargo test --lib` required for lib crate tests** — `cargo test -p openhuman -- "artifacts"` lists tests but filters to 0. Must use `cargo test -p openhuman --lib -- "artifacts"` because tests are in the lib crate, not integration test binaries. + ## Rust Testing Patterns - **Memory tree tests filter** — `cargo test -p openhuman -- "memory::tree"` runs the memory tree unit tests (602 tests); full module paths are `openhuman::memory::tree::ingest::tests::*` and `openhuman::memory::tree::canonicalize::email_clean::tests::*`. @@ -219,6 +226,10 @@ Quick reference for anyone starting with Claude on this project. Updated by the - **Global static cache tests need a reset guard** — When testing code that reads/writes a `Lazy>>` global cache, use a `struct CacheResetGuard; impl Drop for CacheResetGuard { fn drop(&mut self) { *CACHE.lock() = None; } }` pattern so each test starts clean. See `SnapshotCacheResetGuard` / `CacheResetGuard` in `ops_tests.rs`. - **Test assertions must match the actual dummy value** — When a builder (e.g. `build_dummy_runtime_snapshot()`) wraps `degraded_runtime_snapshot()`, assert against `dummy.field` rather than a hardcoded string (e.g. `"idle"` vs the actual `"degraded"`) to verify round-trip correctness without false mismatches. - **`composio::action_tool::tests::mode_toggle_between_calls_is_observed` is flaky in full suite** — Fails intermittently due to shared global composio session state; passes in isolation. Pre-existing; not caused by snapshot perf work. +- **`GLOBAL_MEMORY_TEST_LOCK` only serializes test bodies, not background workers** — Background ingestion spawned by a prior test can still be running when the next test acquires the lock. Call `state.reset_for_test()` at test start (after acquiring the lock) to clear accumulated `queue_depth`/`running` state; do not rely on delta assertions alone. +- **`IngestionState::reset_for_test()` is `#[cfg(test)]`-gated** — Lives in `src/openhuman/memory/ingestion/state.rs`. Zeroes `queue_depth` (AtomicUsize) and clears running/current fields in the snapshot while preserving completion history. This is the canonical reset for any test asserting exact queue or running state. +- **cargo-llvm-cov widens SQLITE_BUSY window** — Flakes that only appear under coverage (`cargo-llvm-cov`) but not plain `cargo test` are usually (a) a SQLite connection missing `busy_timeout`, or (b) shared global state not reset between tests. Always set `busy_timeout` on new SQLite connections (see pattern below). +- **All new SQLite connections must set `busy_timeout = 15s`** — Call `conn.busy_timeout(Duration::from_secs(15))` immediately after `Connection::open()`, before any `execute_batch()`. Pattern set by `chunks/store.rs` (`SQLITE_BUSY_TIMEOUT`) and now also used by `memory_store/unified/init.rs` (fixed in issue #2722). Without it, concurrent ingestion + test writes produce `SQLITE_BUSY` under cargo-llvm-cov. ## App State Snapshot (Issue #2155 — first-launch perf) @@ -257,3 +268,31 @@ Quick reference for anyone starting with Claude on this project. Updated by the ## Pre-existing Flaky Tests - **`composio::action_tool` and `agent::harness::session::turn` intermittent failures** — These tests fail randomly when run as part of the full suite (likely shared state or timing), but pass individually. Not related to security/policy changes. Do not treat as blockers for security-module PRs. + +## Windows OAuth Deep Link (Issue #2562) + +- **Three-layer fix**: (1) named-pipe IPC in `deep_link_ipc_windows.rs` — secondary process forwards `openhuman://` URL to primary via `\\.\pipe\com.openhuman.app-deeplink`, 40 retries × 50ms; (2) loopback OAuth server in `loopback_oauth.rs` — RFC 8252 one-shot `127.0.0.1:53824`, preferred path that eliminates deep link dispatch entirely; (3) Linux analog in `deep_link_ipc.rs` — Unix domain socket at `$XDG_RUNTIME_DIR/com.openhuman.app-deeplink.sock`. +- **`OAuthProviderButton.tsx` loopback flow** — tries loopback first, sets `redirectUri` for backend, awaits callback, rewrites `http://127.0.0.1:PORT/auth?...` → `openhuman://auth?...` → `handleDeepLinkUrls`. Falls back to deep link if bind fails. +- **Pipe binding location** — primary binds the named pipe in `lib.rs` right after the mutex guard (line 2269); `drain_pending_urls()` wired in `setup()` at line 2578. +- **Issue was already fixed before we picked it up** — PRs #2469, #2511, #2550 had already merged the fix. Our contribution was extracting `classify_request` as a pure function and adding 11 Rust unit tests. +- **Pure-function extraction pattern** — when async/AppHandle-gated Tauri code is untestable, extract a `classify_request(head, expected_state, bound_port) -> RequestOutcome` pure function returning an enum. Enables comprehensive unit tests with zero Tauri context. `RequestOutcome` has 4 variants: `AuthCallback`, `StateMismatch`, `NotFound`, `MethodNotAllowed`. + +## Port Conflict Recovery (Issue #2617) + +- **Port fallback already in `pick_listen_port`** — `src/openhuman/connectivity/rpc.rs` tries ports 7789–7798 when 7788 is busy. Gap was: frontend `getCoreRpcUrl()` cached the URL on first resolution so it never picked up the fallback port, and stale-process reaping was macOS-only. +- **`process_recovery.rs` is platform-gated** — `reap_stale_openhuman_processes` had only a macOS impl. Linux uses `/proc//cmdline`; Windows uses `wmic process get`. Tests for each platform's parsing logic live in the same file, following the existing macOS test pattern. +- **`recover_port_conflict` is a Tauri IPC command, not JSON-RPC** — Rust E2E test for port fallback lives in `tests/json_rpc_e2e.rs` and calls `pick_listen_port` directly: bind port 7788 with a `std::net::TcpListener` (std, not tokio) to simulate conflict, confirm fallback, then serve via `tokio::net::TcpListener::from_std(pick_result.listener.into_std())`. +- **`BootCheckTransport` is the right hook for frontend recovery** — `app/src/lib/bootCheck/index.ts` is the injection point for new recovery capabilities; don't add them directly to the BootCheck component. +- **i18n bootCheck keys live in `-3.ts` chunks** — New keys must be added to all 13 language files simultaneously (the `-3.ts` chunk for each language). +- **Workflow folder** — `workflow/` at repo root has 5 markdown files (00–05) defining the full PR workflow: pick issue → architectobot plan → user approval → codecrusher → architectobot verify → checks → memory-keeper → commit → push/PR. + +## Channel Event Workspace Routing (Issue #2602) + +- **Workspace identity is `PathBuf`** — Represented as the workspace directory path on `ChannelRuntimeContext` as `ctx.workspace_dir: Arc`. Use `ctx.workspace_dir.as_ref().clone()` at publish sites. There is no abstract `WorkspaceId` type. +- **`DomainEvent` workspace routing contract** — Publisher populates workspace field from context; subscriber compares against `self.workspace_dir` and early-returns with `log::debug!` on mismatch. Follow this pattern for any workspace-scoped `DomainEvent` variant. +- **`ChannelMessageReceived` and `ChannelMessageProcessed` carry `workspace_dir`** — Added in PR for issue #2602. Guards in `ConversationPersistenceSubscriber` (memory_conversations/bus.rs) and `TelegramRemoteSubscriber` (telegram/bus.rs) prevent cross-workspace persistence during login/workspace-change races. + +## Pre-existing Upstream Failures (from issue #2602 session) + +- **Upstream `main` has 5 Vitest failures and 4 TypeScript compile errors** — Caused by missing iOS experimental dependencies: `@noble/ciphers/chacha`, `@noble/ciphers/webcrypto`, `qrcode.react`, `@tauri-apps/plugin-barcode-scanner`. Breaks `pnpm compile`, `pnpm build`, `pnpm test:coverage` on a clean checkout. Always verify by stashing changes and running checks on the base branch before blaming your PR. +- **`cargo fmt` must run after codecrusher** — codecrusher does not reliably produce `cargo fmt`-clean Rust. Always run `cargo fmt --manifest-path Cargo.toml` after codecrusher finishes and before committing. diff --git a/.claude/scratch/agent-harness-e2e-plan.md b/.claude/scratch/agent-harness-e2e-plan.md new file mode 100644 index 0000000000..a6f8cf0d30 --- /dev/null +++ b/.claude/scratch/agent-harness-e2e-plan.md @@ -0,0 +1,339 @@ +# Agent-Harness E2E Plan: Channels + Prompt-Flow Coverage + +Branch: `agent-harness-e2e-channels` + +--- + +## 1. Current State (~300 words) + +### Core: Telegram provider + +The Telegram channel is a mature, production provider at `src/openhuman/channels/providers/telegram/`. It long-polls via `getUpdates` (`channel_ops.rs:307-380`), parses inbound messages/reactions (`channel_recv.rs`), sends outbound text/media/reactions (`channel_send.rs`), and supports draft streaming, remote-control slash commands (`remote_control.rs`), and pairing/allowlist auth (`channel_core.rs`). + +The channel runtime (`src/openhuman/channels/runtime/startup.rs`) wires Telegram (and all channels) into the dispatch loop, which feeds inbound messages into the agent harness via `request_native_global("agent.run_turn", ...)`. The harness runs the full tool-call loop and returns a response that the channel sends back. + +The RPC surface (`src/openhuman/channels/controllers/schemas.rs`) exposes `openhuman.channels_connect`, `channels_disconnect`, `channels_status`, `channels_test`, `telegram_login_start`, `telegram_login_check`, `channels_send_message`, and more. + +**Critical blocker**: `api_url()` is hardcoded to `https://api.telegram.org/bot{token}/{method}` (`channel_core.rs:88-89`). There is no env-var override to redirect Telegram API calls to a mock server. This must be addressed in WS-B. + +### Mock backend + +The mock server (`scripts/mock-api/`) has mature LLM mocking (`routes/llm.mjs` with `llmStreamScript`, `llmForcedResponses`, `llmKeywordRules`), Composio integration mocking (`routes/integrations.mjs` with `composioConnections`, `composioAvailableTriggers`, `composioExecuteResponse_*`), cron mocking (`routes/cron.mjs`), and a full admin API (`admin.mjs`). Socket.IO event injection exists via `/__admin/socket/emit`. There are **no** Telegram Bot API mock routes whatsoever. + +### E2E suite + +Five `chat-harness-*.spec.ts` specs cover send+stream, cancel, scroll-render, subagent delegation, and wallet flows. `composio-triggers-flow.spec.ts` tests trigger CRUD via core RPC. `cron-jobs-flow.spec.ts` tests the cron panel UI. `webhooks-ingress-flow.spec.ts` tests webhook RPC surface stubs. + +`telegram-flow.spec.ts` (1019 lines) is entirely `describe.skip`'d. It was written for the old skill system (references SkillsGrid, V8 runtime, `Connect Telegram` OAuth modal). None of its test IDs match the current channel system. It should be **deleted and replaced**, not salvaged. + +--- + +## 2. Gaps + +### Core (Rust) + +- **No Telegram API base URL override**: `api_url()` always targets `api.telegram.org`. Need an env var (`OPENHUMAN_TELEGRAM_API_BASE`) or constructor parameter so the provider can be pointed at the mock server during E2E. +- **No webhook ingress endpoint**: Telegram long-polls via `getUpdates`; there is no HTTP endpoint where a mock Telegram could push updates. For E2E, the provider needs either: (a) the mock to serve `getUpdates` responses (preferred, since the provider already uses long-polling), or (b) a webhook receiver route on the core. Option (a) is simpler since it matches existing architecture. +- **`channels_connect` for bot_token auth mode** needs verification that it works end-to-end against the mock `getMe` endpoint. + +### Mock backend + +- **No Telegram Bot API routes**: no `/bot/getMe`, `/bot/getUpdates`, `/bot/sendMessage`, etc. +- **No tool-call round-trip scripting for harness flows**: `llmKeywordRules` supports `toolCalls` but there is no multi-turn scripting (message 1 -> tool call -> tool result -> message 2 with final answer). Need `llmForcedResponses` queue patterns documented and possibly extended for chained tool-use turns. +- **No Composio action execution result fixtures** for E2E prompt-flow tests (only `composioExecuteResponse_` per-action overrides exist, which is actually sufficient). +- **No cron-creation mock for LLM-driven flows**: the mock LLM can return tool calls, but there is no mock for `openhuman.cron_create` being called as a tool result round-trip. + +### E2E specs + +- **`telegram-flow.spec.ts`**: 100% stale, references removed skill system. Delete. +- **No Telegram channel connect/disconnect E2E spec** for the current `channels_connect`/`channels_disconnect` RPC surface. +- **No prompt-flow E2E specs**: no tests exercise the harness processing a message that triggers a tool call (composio, search, cron) and returning a result. +- **No cross-channel bridge E2E**: no test sends a Telegram message that produces a cron job or composio action. + +--- + +## 3. Workstream Breakdown + +### WS-A: Mock Backend — Telegram Bot API + Harness Tool-Call Plumbing + +**Goal**: Add mock Telegram Bot API routes and extend LLM mock scripting so downstream specs can drive deterministic Telegram + tool-call round-trips. + +**Files to create/modify**: + +| Action | Path | +|--------|------| +| CREATE | `scripts/mock-api/routes/telegram.mjs` | +| MODIFY | `scripts/mock-api/routes/llm.mjs` (document multi-turn forced response patterns; add `llmToolCallSequence` behavior key for chained turns) | +| MODIFY | `scripts/mock-api/server.mjs` (import and wire `handleTelegram` into the route chain) | +| MODIFY | `scripts/mock-api/state.mjs` (add `mockTelegramUpdates`, `mockTelegramSentMessages` state arrays with getters/setters/resetters) | +| MODIFY | `scripts/mock-api/admin.mjs` (add `GET /__admin/telegram/sent`, `POST /__admin/telegram/inject-update`, `POST /__admin/telegram/reset` endpoints) | +| MODIFY | `app/test/e2e/mock-server.ts` (re-export any new helpers needed by specs) | + +**Mock-backend changes**: + +New route handler `handleTelegram(ctx)` in `scripts/mock-api/routes/telegram.mjs`: + +| Route pattern | Behavior | +|---------------|----------| +| `POST /bot/getMe` | Returns `{ ok: true, result: { id: 123, is_bot: true, username: behavior.telegramBotUsername \|\| "e2e_test_bot" } }` | +| `POST /bot/getUpdates` | Returns updates from `mockTelegramUpdates` queue. Supports long-poll simulation via `telegramPollDelayMs` behavior key. Each call drains the queue. | +| `POST /bot/sendMessage` | Records to `mockTelegramSentMessages`, returns `{ ok: true, result: { message_id: , chat: {...}, text: } }` | +| `POST /bot/sendChatAction` | Returns `{ ok: true, result: true }` | +| `POST /bot/deleteWebhook` | Returns `{ ok: true, result: true }` | +| `POST /bot/setMessageReaction` | Returns `{ ok: true, result: true }` | +| `POST /bot/sendPhoto`, `sendDocument`, `sendVideo`, `sendAudio`, `sendVoice` | Records to sent log, returns ok | + +Behavior keys: +- `telegramBotUsername` — bot username returned by `getMe` +- `telegramBotToken` — expected token (for auth validation; default: accept any) +- `telegramPollDelayMs` — simulated long-poll delay for `getUpdates` +- `telegramGetMeFails` — if `"1"`, `getMe` returns 401 +- `telegramSendFails` — if `"1"`, `sendMessage` returns 400 + +Admin endpoints: +- `POST /__admin/telegram/inject-update` — push a Telegram update JSON into the queue (spec calls this to simulate an inbound message) +- `GET /__admin/telegram/sent` — list all messages the bot "sent" (for assertion) +- `POST /__admin/telegram/reset` — clear queues + +LLM mock extension — `llmToolCallSequence` behavior key: +```json +[ + { + "match": "create a cron", + "response": { + "toolCalls": [{"name": "cron_create", "arguments": {"schedule": "0 9 * * *", "prompt": "morning briefing"}}], + "content": "" + } + }, + { + "match": "cron_create-result", + "response": { + "content": "Done! I created a daily 9am cron job for your morning briefing." + } + } +] +``` +This is actually already achievable with the existing `llmKeywordRules` + `llmForcedResponses` mechanisms. The work here is documenting the pattern and adding one convenience: a `llmToolCallScript` behavior key that accepts a sequence of `[{toolCalls, content}, {content}]` entries that auto-advance after each provider call, replacing `llmForcedResponses` for multi-turn scenarios. This avoids specs needing to manually queue and manage the forced response array. + +**Test scenarios** (unit tests for mock routes): +1. `getMe` returns bot info with default and custom username +2. `getUpdates` returns empty when no updates queued +3. `getUpdates` returns injected updates and drains queue +4. `sendMessage` records message and returns success +5. `sendMessage` returns error when `telegramSendFails=1` +6. Admin inject-update + sent-list round-trip +7. `llmToolCallScript` auto-advances through multi-turn sequence + +**Acceptance criteria**: +- A spec can: (1) set `telegramBotUsername`, (2) inject a Telegram update via admin, (3) observe the bot's reply in `/__admin/telegram/sent`, (4) configure LLM to return tool calls on specific keywords. +- All existing mock-api tests pass (`scripts/mock-api/routes/__tests__/`). + +**Dependencies**: None. This is foundational infrastructure. + +--- + +### WS-B: Core Wiring — Telegram API Base URL Override + +**Goal**: Allow the Telegram provider to target a mock server instead of `api.telegram.org` via an environment variable, enabling E2E testing of the full Telegram channel loop. + +**Files to create/modify**: + +| Action | Path | +|--------|------| +| MODIFY | `src/openhuman/channels/providers/telegram/channel_core.rs` — `api_url()` reads `OPENHUMAN_TELEGRAM_API_BASE` env var; defaults to `https://api.telegram.org` | +| MODIFY | `src/openhuman/channels/providers/telegram/channel_types.rs` — add `api_base: String` field to `TelegramChannel` struct | +| MODIFY | `src/openhuman/channels/providers/telegram/channel_core.rs` — constructor reads env var, stores in `api_base` | +| MODIFY | `src/openhuman/channels/runtime/startup.rs` — no changes needed if env var is read in constructor | +| MODIFY | `.env.example` — document `OPENHUMAN_TELEGRAM_API_BASE` | +| MODIFY | `app/scripts/e2e-run-spec.sh` — export `OPENHUMAN_TELEGRAM_API_BASE=http://127.0.0.1:${E2E_MOCK_PORT}` when running Telegram specs | +| CREATE | `src/openhuman/channels/providers/telegram/channel_core_tests.rs` or extend existing `channel_tests.rs` — test that `api_url()` respects the override | + +**Detailed changes**: + +`channel_types.rs` — add field: +```rust +pub struct TelegramChannel { + // ... existing fields ... + api_base: String, // NEW: base URL for Telegram Bot API +} +``` + +`channel_core.rs` — constructor: +```rust +pub fn new(bot_token: String, allowed_users: Vec, mention_only: bool) -> Self { + let api_base = std::env::var("OPENHUMAN_TELEGRAM_API_BASE") + .unwrap_or_else(|_| "https://api.telegram.org".to_string()); + // ... rest unchanged, but store api_base ... +} +``` + +`channel_core.rs` — `api_url()`: +```rust +pub(crate) fn api_url(&self, method: &str) -> String { + format!("{}/bot{}/{method}", self.api_base, self.bot_token) +} +``` + +**Test scenarios**: +1. `api_url()` returns `https://api.telegram.org/bot/` by default +2. With `OPENHUMAN_TELEGRAM_API_BASE=http://localhost:18473`, `api_url()` returns `http://localhost:18473/bot/` +3. Trailing slash in env var is stripped +4. `cargo check` and `cargo test` pass + +**Acceptance criteria**: +- `api_url()` respects `OPENHUMAN_TELEGRAM_API_BASE` env var +- Default behavior unchanged (still `api.telegram.org`) +- Unit test covers the override +- `e2e-run-spec.sh` exports the env var for Telegram specs + +**Dependencies**: None. Can run in parallel with WS-A. + +--- + +### WS-C: Telegram E2E Spec Rewrite + +**Goal**: Replace the stale `telegram-flow.spec.ts` with a new spec that tests the current `channels_*` RPC surface and the full Telegram bot setup + message round-trip. + +**Files to create/modify**: + +| Action | Path | +|--------|------| +| DELETE | `app/test/e2e/specs/telegram-flow.spec.ts` (1019 lines, 100% stale) | +| CREATE | `app/test/e2e/specs/telegram-channel-flow.spec.ts` | +| MODIFY | `app/test/e2e/helpers/chat-harness.ts` — add `injectTelegramUpdate()` and `getTelegramSentMessages()` helpers that call mock admin endpoints | +| MODIFY | `app/scripts/e2e-run-spec.sh` — ensure `OPENHUMAN_TELEGRAM_API_BASE` is set for telegram specs (may overlap with WS-B) | + +**Test scenarios** (numbered): + +1. **Channel list includes telegram**: `callOpenhumanRpc('openhuman.channels_list')` returns a channel with `id: "telegram"` and `authModes` including `bot_token`. + +2. **Channel describe returns telegram definition**: `callOpenhumanRpc('openhuman.channels_describe', { channel: 'telegram' })` returns capabilities, auth modes, and field schemas. + +3. **Bot token connect — happy path**: `callOpenhumanRpc('openhuman.channels_connect', { channel: 'telegram', authMode: 'bot_token', credentials: { botToken: '' } })` succeeds. Mock `getMe` returns bot info. `channels_status` shows telegram as connected. + +4. **Bot token connect — invalid token**: Mock `getMe` returns 401 (`telegramGetMeFails=1`). Connect RPC returns error. + +5. **Inbound message round-trip**: After connecting, inject a Telegram update via `/__admin/telegram/inject-update` with a user message. Configure `llmForcedResponses` with a canned reply. Wait for the bot's reply to appear in `/__admin/telegram/sent`. Assert the reply content matches. + +6. **Inbound message from unauthorized user**: Inject an update from a user not in the allowlist. Assert the bot sends the "operator approval required" message (visible in `/__admin/telegram/sent`). + +7. **Group message with mention-only**: Connect with `mentionOnly: true`. Inject a group message without bot mention — no reply. Inject a group message with `@e2e_test_bot` — reply appears. + +8. **Channel disconnect**: `callOpenhumanRpc('openhuman.channels_disconnect', { channel: 'telegram', authMode: 'bot_token' })` succeeds. `channels_status` shows telegram as disconnected. + +9. **Reconnect after disconnect**: Connect again with a different token. Status shows connected. + +10. **Remote command /status**: Inject a message with text `/status`. Assert the bot sends a status response (contains "Thread:" and "Provider:"). + +**Acceptance criteria**: +- All 10 scenarios pass against the mock backend +- No references to the old skill system +- Spec uses `resetApp()` + `callOpenhumanRpc()` pattern from existing specs +- Spec runs via `pnpm debug e2e test/e2e/specs/telegram-channel-flow.spec.ts telegram` + +**Dependencies**: WS-A (mock Telegram routes), WS-B (API base URL override). Must wait for both. + +--- + +### WS-D: Prompt-Flow Harness E2E Specs + +**Goal**: Add a battery of E2E specs that drive the chat harness through prompts exercising tool calls (composio, search, cron) and cross-channel bridges. + +**Files to create/modify**: + +| Action | Path | +|--------|------| +| CREATE | `app/test/e2e/specs/harness-composio-tool-flow.spec.ts` | +| CREATE | `app/test/e2e/specs/harness-cron-prompt-flow.spec.ts` | +| CREATE | `app/test/e2e/specs/harness-search-tool-flow.spec.ts` | +| CREATE | `app/test/e2e/specs/harness-channel-bridge-flow.spec.ts` | +| MODIFY | `app/test/e2e/helpers/chat-harness.ts` — add `waitForToolCallInMockLog(toolName)`, `waitForAssistantReplyContaining(text)` helpers | + +**Spec 1: `harness-composio-tool-flow.spec.ts`** + +Scenarios: +1. **Gmail composio tool call**: Configure `llmKeywordRules` so "check my email" triggers a `GMAIL_GET_MAIL` tool call. Configure `composioExecuteResponse_GMAIL_GET_MAIL` with a canned inbox result. Send "check my email" in chat. Assert: (a) mock LLM received the tool call, (b) composio execute endpoint was called, (c) final assistant reply references the email content. +2. **GitHub composio tool call**: "list my repos" triggers `GITHUB_LIST_REPOS`. Assert tool-use round-trip. +3. **Composio action failure**: Set `composioExecuteFails=400`. Send prompt. Assert the assistant reply acknowledges the error gracefully. +4. **Linear composio tool call**: "create a linear issue" triggers `LINEAR_CREATE_ISSUE`. Assert creation result in reply. + +**Spec 2: `harness-cron-prompt-flow.spec.ts`** + +Scenarios: +1. **Create cron via natural language**: Configure LLM keyword rules so "remind me every morning at 9am" triggers a `cron_create` tool call with `{ schedule: "0 9 * * *", prompt: "morning reminder" }`. Assert: cron_create RPC was called, reply confirms creation. +2. **List cron jobs after creation**: Send "what are my scheduled tasks". LLM keyword rule returns content listing the jobs (no tool call needed, just checks the harness can relay cron state). Verify via `openhuman.cron_list` oracle RPC. +3. **Edit cron schedule**: "change my morning reminder to 8am" triggers `cron_update` tool call. Assert schedule changed via oracle RPC. + +**Spec 3: `harness-search-tool-flow.spec.ts`** + +Scenarios: +1. **Memory search tool call**: "what did we discuss about project X" triggers `memory_search` tool call. Mock returns canned memory results. Assert reply cites the memory. +2. **Web search tool call**: "search the web for Rust async patterns" triggers `web_search` tool call. Mock returns canned search results. Assert reply includes search results. +3. **File read tool call**: "read the README" triggers `file_read` tool call. Assert reply includes file content summary. + +**Spec 4: `harness-channel-bridge-flow.spec.ts`** + +Scenarios: +1. **Telegram message triggers cron creation**: Inject a Telegram update "set up a daily standup reminder at 9am". LLM keyword rules return a `cron_create` tool call. Assert: (a) cron created via oracle RPC, (b) Telegram reply confirms creation. +2. **Telegram message triggers composio action**: Inject "check my gmail inbox" via Telegram. LLM triggers `GMAIL_GET_MAIL`. Assert: (a) composio execute called, (b) Telegram reply contains email summary. +3. **Chat prompt references channel state**: In the web chat, ask "what messages came in on Telegram today". LLM returns a canned summary. This is a lightweight check that the harness can receive prompts referencing channels. + +**Acceptance criteria**: +- All specs pass against the mock backend with zero real LLM calls +- Each spec uses `resetApp()` for isolation +- Tool call round-trips are verified via both mock request logs and UI/RPC assertions +- Specs are independently runnable via `pnpm debug e2e` + +**Dependencies**: +- `harness-composio-tool-flow.spec.ts`: Needs existing mock composio routes (already in `integrations.mjs`) + LLM keyword rules (already in `llm.mjs`). **No blocker.** +- `harness-cron-prompt-flow.spec.ts`: Needs LLM keyword rules + cron RPC surface (already exists). **No blocker.** +- `harness-search-tool-flow.spec.ts`: Needs LLM keyword rules. **No blocker.** +- `harness-channel-bridge-flow.spec.ts`: Depends on **WS-A** (mock Telegram routes) and **WS-B** (API base override). Scenarios 1-2 must wait. Scenario 3 can ship independently. + +--- + +## 4. Recommended Subagent Fan-Out + +### WS-A -> CodeCrusher agent + +**Briefing**: You are implementing the mock backend Telegram Bot API layer. Create `scripts/mock-api/routes/telegram.mjs` with a `handleTelegram(ctx)` function that serves Telegram Bot API endpoints (`/bot/getMe`, `/bot/getUpdates`, `/bot/sendMessage`, `/bot/sendChatAction`, `/bot/deleteWebhook`, `/bot/setMessageReaction`, and media send endpoints). Add state arrays `mockTelegramUpdates` and `mockTelegramSentMessages` to `scripts/mock-api/state.mjs` with standard getter/setter/reset exports. Add admin endpoints in `scripts/mock-api/admin.mjs`: `POST /__admin/telegram/inject-update`, `GET /__admin/telegram/sent`, `POST /__admin/telegram/reset`. Wire into `scripts/mock-api/server.mjs`. Follow the exact patterns used by existing route handlers (see `routes/integrations.mjs`, `routes/cron.mjs`). Use `behavior()` for dynamic behavior keys (`telegramBotUsername`, `telegramGetMeFails`, `telegramSendFails`, `telegramPollDelayMs`). Token is extracted from the URL path (`/bot/...`). Write unit tests in `scripts/mock-api/routes/__tests__/telegram.test.mjs` following the pattern in existing test files in that directory. + +### WS-B -> Dev agent (Rust) + +**Briefing**: You are adding a `OPENHUMAN_TELEGRAM_API_BASE` environment variable override to the Telegram channel provider. In `src/openhuman/channels/providers/telegram/channel_types.rs`, add an `api_base: String` field to `TelegramChannel`. In `channel_core.rs`, read `std::env::var("OPENHUMAN_TELEGRAM_API_BASE")` in the constructor (default `"https://api.telegram.org"`, strip trailing slash), store in `self.api_base`. Change `api_url()` from `format!("https://api.telegram.org/bot{}/{method}", self.bot_token)` to `format!("{}/bot{}/{method}", self.api_base, self.bot_token)`. Add a unit test in `channel_tests.rs` that sets the env var (use a `serial_test` guard or `temp_env` crate if available, otherwise test with a direct constructor that takes the base URL). Update `.env.example` with a comment. Update `app/scripts/e2e-run-spec.sh` to export `OPENHUMAN_TELEGRAM_API_BASE=http://127.0.0.1:${E2E_MOCK_PORT:-18473}` alongside the other E2E env vars. Run `cargo check` and `cargo test` to verify. + +### WS-C -> Test agent (E2E) + +**Briefing**: You are rewriting the Telegram E2E spec. Delete `app/test/e2e/specs/telegram-flow.spec.ts` entirely (it is 100% stale, references removed skill system). Create `app/test/e2e/specs/telegram-channel-flow.spec.ts`. Follow the exact patterns from `chat-harness-send-stream.spec.ts` and `composio-triggers-flow.spec.ts`: use `resetApp()`, `callOpenhumanRpc()`, `startMockServer()`/`stopMockServer()`, `setMockBehavior()`. The spec tests the `openhuman.channels_*` RPC surface against the mock backend. Add helpers to `app/test/e2e/helpers/chat-harness.ts` for `injectTelegramUpdate(update)` (POST to `/__admin/telegram/inject-update`) and `getTelegramSentMessages()` (GET `/__admin/telegram/sent`). Test scenarios: channels_list includes telegram, channels_describe returns definition, connect with bot_token (happy + error), inbound message round-trip, unauthorized user rejection, mention-only group filtering, disconnect, reconnect, remote /status command. Each test uses `callOpenhumanRpc` for setup and oracle checks, mock admin endpoints for Telegram simulation. Set `OPENHUMAN_TELEGRAM_API_BASE` and `telegramBotUsername` behavior key in `before()`. This spec depends on WS-A and WS-B being merged first. + +### WS-D -> Test agent (E2E, prompt-flow) + +**Briefing**: You are creating four new E2E specs that exercise the agent harness through prompt-driven tool-call flows. Follow the pattern from `chat-harness-send-stream.spec.ts`: `resetApp()`, navigate to `/chat`, type into composer, send, wait for reply. Use `llmKeywordRules` behavior key to configure deterministic tool-call triggers (see `scripts/mock-api/routes/llm.mjs` lines 430-456 for the keyword rule format). Use `llmForcedResponses` for multi-turn sequences where the first response is a tool call and the second is the final answer. Specs: (1) `harness-composio-tool-flow.spec.ts` — "check my email" triggers GMAIL_GET_MAIL tool, composio execute returns canned result, assistant relays it. (2) `harness-cron-prompt-flow.spec.ts` — "remind me every morning" triggers cron_create tool call, verify cron created via oracle RPC. (3) `harness-search-tool-flow.spec.ts` — "what did we discuss about X" triggers memory_search tool call. (4) `harness-channel-bridge-flow.spec.ts` — Telegram inbound triggers tool calls (depends on WS-A/B). For specs 1-3, no dependency on other workstreams. For spec 4, wait for WS-A+B. Add helpers to `chat-harness.ts`: `waitForToolCallInMockLog(toolName, timeoutMs)` polls `getRequestLog()` for a POST to the composio execute or LLM endpoint containing the tool name. + +--- + +## 5. Blocking Unknowns + +1. **Telegram API base URL override**: Does any config-loading code cache the URL before the env var is set? Need to verify `TelegramChannel::new()` is called after env is loaded. Likely fine since `start_channels()` runs after config load, but WS-B agent should verify. + +2. **Channel connect via RPC in E2E**: Does `openhuman.channels_connect` with `authMode: "bot_token"` actually start the long-polling loop against the mock? If so, `getUpdates` requests will immediately start hitting the mock server. The mock must handle rapid polling gracefully (return empty `[]` by default). WS-A agent should ensure `getUpdates` returns `{ ok: true, result: [] }` when the queue is empty without blocking. + +3. **In-process core + mock Telegram**: The E2E app runs the core in-process. The core's Telegram provider will poll `http://127.0.0.1:18473/bot/getUpdates`. The mock server must be ready before the channel connects. Spec `before()` must call `startMockServer()` before `channels_connect`. + +4. **Tool execution in E2E harness**: When the mock LLM returns a tool call, does the in-process core actually execute the tool (e.g., call composio execute endpoint, call cron_create)? This depends on the tool being registered in the agent's tool registry. If tools are not available in E2E mode, WS-D specs may need to assert at the LLM mock level only (verifying the tool call was attempted, not executed). The WS-D agent should test this empirically and adapt. + +--- + +## 6. Parallelism Summary + +``` +WS-A (mock backend) ──────────────────────────┐ + ├──► WS-C (telegram E2E spec) +WS-B (Rust API base override) ─────────────────┘ + ├──► WS-D spec 4 (channel bridge) +WS-D specs 1-3 (composio/cron/search prompts) ──── independent, no blockers +``` + +WS-A and WS-B can run fully in parallel. +WS-D specs 1-3 can run in parallel with WS-A and WS-B. +WS-C and WS-D spec 4 must wait for both WS-A and WS-B. diff --git a/.codex/skills/ship-and-babysit/SKILL.md b/.codex/skills/ship-and-babysit/SKILL.md index 3d91336cce..9303521559 100644 --- a/.codex/skills/ship-and-babysit/SKILL.md +++ b/.codex/skills/ship-and-babysit/SKILL.md @@ -10,7 +10,8 @@ Use this skill for `tinyhumansai/openhuman` when the user wants a branch shipped - commit the local changes - push the branch to the user's fork - open or reuse a PR against `tinyhumansai/openhuman:main` -- monitor CI and review feedback in a polling loop +- proactively run likely merge-gate validation and start fixing issues immediately +- monitor CI and review feedback in a polling loop without waiting idly for every check to finish - address actionable review comments and push follow-up fixes - stop only when the PR is green and clean @@ -27,6 +28,8 @@ Use this skill for `tinyhumansai/openhuman` when the user wants a branch shipped - Never push to `upstream`. - Never amend or rewrite commits that are already pushed unless the user explicitly asks for it. - Never bypass hooks for breakage introduced by your own changes. +- Default to autonomous execution. Do not stop to ask the user process questions when a reasonable safe default exists. +- Only ask the user a question when the workflow is genuinely blocked by missing access, missing credentials, or an irreversible choice that cannot be inferred from repo context. ## Workflow @@ -41,7 +44,7 @@ Use this skill for `tinyhumansai/openhuman` when the user wants a branch shipped - `git rev-parse --abbrev-ref HEAD` 3. Confirm the branch normally follows `feat/`, `fix/`, `refactor/`, `chore/`, `docs/`, or `test/`. - If the current branch is `main`, create a new descriptive branch immediately and continue there. - - If the name does not follow convention and it is already a non-`main` branch, ask before renaming. Do not auto-rename a pushed branch. + - If the name does not follow convention and it is already a non-`main` branch, keep using it unless it is still local and trivially safe to rename without disrupting a pushed branch. 4. If there are uncommitted changes, carry them onto the new branch before doing anything else so local `main` stays free of agent-authored commits. 5. If there are uncommitted changes, run the smallest meaningful local validation for the touched area before committing. 6. Stage only relevant files and create a focused conventional commit message. @@ -66,48 +69,62 @@ Use this skill for `tinyhumansai/openhuman` when the user wants a branch shipped - fill `.github/PULL_REQUEST_TEMPLATE.md` exactly - create the PR against `tinyhumansai/openhuman:main` with `--head :` 5. Print the PR URL to the user. +6. Immediately after opening or reusing the PR, start proactive validation based on the touched area instead of waiting for remote CI to finish: + - run the smallest set of likely merge-gate commands that cover the changed code + - prioritize fast failure detectors first, such as format, typecheck, lint, targeted tests, and cargo checks relevant to touched files + - fix locally discovered failures right away, then commit and push again before the next CI poll ### Phase 4: Babysit Loop -Run an explicit poll loop until the PR is green and clean. Do not treat this as a one-shot status check. +Run an explicit poll loop until the PR is green and clean. Do not treat this as a one-shot status check, and do not sit idle waiting for all checks to complete before acting. - Poll about every 5 minutes. - Stay in the loop for up to 12 ticks, about 60 minutes total. - If the environment does not support durable wakeups, remain in-session and use repeated polling with `sleep 270`. - On each tick, post a short progress update to the user. +- Between ticks, prefer useful work over passive waiting: + - inspect completed failures as soon as they appear + - inspect review comments and unresolved threads immediately + - run likely local validations on changed areas while remote checks are still pending + - push fixes as soon as they are ready instead of batching them behind the full CI timeline Each tick: 1. Fetch CI status: - `gh pr checks --repo tinyhumansai/openhuman --json name,state,link,description` 2. Treat `PENDING` as still in progress. Do not claim success while checks are still running. -3. If any check is `FAILURE` or `CANCELLED`: +3. If any completed check is `FAILURE` or `CANCELLED`: - if the `link` is a GitHub Actions run URL, extract the run id and inspect failing logs with `gh run view --log-failed --repo tinyhumansai/openhuman` - otherwise work from the check name, state, and description - make the smallest correct fix - rerun targeted validation - commit - push -4. Fetch PR review comments: +4. If checks are still mostly `PENDING`, do not wait for the whole matrix to finish before taking action: + - inspect the changed files and recent commit diff + - run the most relevant local merge-gate commands proactively + - fix any locally reproduced failure immediately + - commit and push as soon as the fix is validated +5. Fetch PR review comments: - `gh api repos/tinyhumansai/openhuman/pulls//comments --paginate` -5. Fetch issue-level PR comments: +6. Fetch issue-level PR comments: - `gh api repos/tinyhumansai/openhuman/issues//comments --paginate` -6. Inspect review threads via GraphQL, not just flat comments, so unresolved discussions do not slip through: +7. Inspect review threads via GraphQL, not just flat comments, so unresolved discussions do not slip through: - query `reviewThreads` with pagination until `hasNextPage` is false -7. Specifically inspect bot feedback from `coderabbitai` and `coderabbitai[bot]`, but also check for human actionable review comments. -8. For each actionable review comment or unresolved review thread: +8. Specifically inspect bot feedback from `coderabbitai` and `coderabbitai[bot]`, but also check for human actionable review comments. +9. For each actionable review comment or unresolved review thread: - read the referenced file and line - apply the smallest correct fix - rerun targeted validation - commit - push -9. For incorrect, stale, or out-of-scope review feedback: +10. For incorrect, stale, or out-of-scope review feedback: - reply in the existing review thread with concrete reasoning - do not open a new unrelated review - resolve or dismiss only when the reasoning is explicit and the platform supports it -10. After addressing a review thread, resolve it through the GitHub review-thread API when appropriate. -11. Track whether new issue-level CodeRabbit comments appeared since the previous tick so the loop does not exit while fresh bot feedback is waiting. -12. Exit the loop only when all of these are true: +11. After addressing a review thread, resolve it through the GitHub review-thread API when appropriate. +12. Track whether new issue-level CodeRabbit comments appeared since the previous tick so the loop does not exit while fresh bot feedback is waiting. +13. Exit the loop only when all of these are true: - all required checks are `SUCCESS` - no unresolved actionable review threads remain - no new actionable CodeRabbit issue comments remain @@ -134,6 +151,8 @@ Prefer targeted test commands when the touched area is narrow, but do not claim - Always push follow-up commits so the PR actually updates after fixes. - If invoked from `main`, branch first, then ship. Do not make the user clean up agent commits from `main`. - Checking `gh pr checks --watch` once is not sufficient babysitting. The skill should actively re-poll CI and review surfaces until the exit condition is met. +- The skill should not ask the user for confirmation about routine workflow choices such as branch naming, whether to start fixing CI, or whether to act on obvious actionable failures. +- The skill should assume the user wants active babysitting: inspect, fix, commit, and push continuously until blocked or green. - Review handling must include: - PR review comments - issue-level PR comments diff --git a/.dockerignore b/.dockerignore index 37e46e1bb3..d3244a91b4 100644 --- a/.dockerignore +++ b/.dockerignore @@ -38,5 +38,7 @@ Thumbs.db tests/ scripts/ # Re-include the Docker entrypoint for the core image (Dockerfile COPYs it). -# The negation must come after the broad exclusion above to take effect. +# Re-include the parent directory first so older Docker pattern matchers that +# prune excluded directories still see the leaf exception below. +!scripts/ !scripts/docker-entrypoint-core.sh diff --git a/.env.example b/.env.example index 698664938e..f063f747b5 100644 --- a/.env.example +++ b/.env.example @@ -41,10 +41,20 @@ JWT_TOKEN= # [optional] Default: 127.0.0.1 (use 0.0.0.0 for Docker / cloud). # Leave unset to keep the default; the Docker image sets 0.0.0.0 automatically. # OPENHUMAN_CORE_HOST= +# [optional] Extra CORS origins (comma-separated) allowed to reach the +# JSON-RPC server. The Tauri webview and loopback hosts are always allowed. +# For Docker / cloud deployments where the server binds to 0.0.0.0, add the +# canonical frontend origin(s) here to prevent cross-origin abuse from +# arbitrary sites (e.g. OPENHUMAN_CORE_ALLOWED_ORIGINS=https://app.example.com). +# OPENHUMAN_CORE_ALLOWED_ORIGINS= # [optional] Default: 7788 OPENHUMAN_CORE_PORT=7788 # [optional] Default: http://127.0.0.1:7788/rpc OPENHUMAN_CORE_RPC_URL=http://127.0.0.1:7788/rpc +# [optional] Comma-separated browser origins allowed to call /rpc with the +# Authorization bearer. Tauri and loopback Vite origins are allowed by default. +# Set this when serving a private web UI preview from a non-loopback origin. +# OPENHUMAN_CORE_ALLOWED_ORIGINS=https://openhuman-ui.example.com # Core RPC bearer token. Single source of truth for /rpc auth. # - Tauri desktop: set automatically by the shell — leave blank. # - Docker / cloud / VPS: REQUIRED. Generate with `openssl rand -hex 32`. @@ -66,12 +76,19 @@ OPENHUMAN_CORE_BIN= # --------------------------------------------------------------------------- # Config overrides (override config.toml values at runtime) # --------------------------------------------------------------------------- +# [optional] Local safety cap for side-effecting tool actions in a rolling hour (default 20). +# Set to 0 to block all side-effecting tool actions entirely. +OPENHUMAN_MAX_ACTIONS_PER_HOUR=20 # [optional] Default model to use OPENHUMAN_MODEL= # [optional] Workspace directory (default: ~/.openhuman or ~/.openhuman-staging when OPENHUMAN_APP_ENV=staging) OPENHUMAN_WORKSPACE= # [optional] Default: 0.7 OPENHUMAN_TEMPERATURE=0.7 +# [optional] Language for background LLM artifacts such as memory-tree summaries, +# entity-extraction reasons, and learning reflections. Accepts UI locale tags +# such as zh-CN or a language name. Leave unset for default behavior. +# OPENHUMAN_OUTPUT_LANGUAGE=zh-CN # [optional] Skill + agent tool execution timeout in seconds (default 120, max 3600) # OPENHUMAN_TOOL_TIMEOUT_SECS= # [optional] Headless update restart contract: self_replace | supervisor @@ -148,6 +165,9 @@ OPENHUMAN_PROXY_SERVICES= # [optional] Override selected model tier: low, medium, high # Applies the corresponding preset at config load time (overrides config.toml). OPENHUMAN_LOCAL_AI_TIER= +# [optional] Override Ollama's HTTP server base URL (default: http://localhost:11434). +# Useful when Ollama runs in another container, on another host, or on a non-default port. +# OPENHUMAN_OLLAMA_BASE_URL=http://127.0.0.1:11434 # [optional] Override LM Studio's OpenAI-compatible local server base URL. # Defaults to http://localhost:1234/v1 when local_ai.provider = "lm_studio". OPENHUMAN_LM_STUDIO_BASE_URL= @@ -167,6 +187,9 @@ OLLAMA_BIN= # --------------------------------------------------------------------------- # [optional] Bot username for managed Telegram DM linking (default: openhuman_bot) OPENHUMAN_TELEGRAM_BOT_USERNAME=openhuman_bot +# [optional] Override Telegram Bot API base URL (defaults to https://api.telegram.org). +# Used by E2E tests to redirect Telegram API calls to the mock server. +# OPENHUMAN_TELEGRAM_API_BASE=http://127.0.0.1:18473 # --------------------------------------------------------------------------- # Wallet RPC overrides diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..c7ac3a6487 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +* text=auto eol=lf + +*.sh text eol=lf +*.bash text eol=lf +Dockerfile text eol=lf +.dockerignore text eol=lf +docker-compose*.yml text eol=lf +*.ps1 text eol=crlf diff --git a/.github/workflows/android-compile.yml b/.github/workflows/android-compile.yml new file mode 100644 index 0000000000..916733365b --- /dev/null +++ b/.github/workflows/android-compile.yml @@ -0,0 +1,64 @@ +--- +name: Android Compile Sanity + +on: + pull_request: + paths: + - 'app/src-tauri-mobile/**' + - 'packages/tauri-plugin-ptt/**' + - 'src/openhuman/devices/**' + - 'app/src/services/transport/**' + - 'app/src/lib/tunnel/**' + - 'app/src/pages/ios/**' + - '.github/workflows/android-compile.yml' + workflow_dispatch: + +permissions: + contents: read + pull-requests: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.head_ref || github.ref }} + cancel-in-progress: true + +jobs: + android-compile: + name: Android Compile Check + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 1 + # Mobile crate uses stock Tauri (no CEF) — no submodules needed. + submodules: false + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + with: + toolchain: '1.93.0' + targets: aarch64-linux-android + + - name: Cache Rust build artifacts + uses: Swatinem/rust-cache@v2 + with: + workspaces: | + app/src-tauri-mobile -> target + packages/tauri-plugin-ptt -> target + cache-on-failure: true + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: 'pnpm' + + - name: Install dependencies + run: bash scripts/ci-cancel-aware.sh pnpm install --frozen-lockfile + + # Hard gate: mobile Tauri host compiles for Android. + - name: cargo check -- mobile host (aarch64-linux-android) + run: bash scripts/ci-cancel-aware.sh cargo check --manifest-path app/src-tauri-mobile/Cargo.toml --target aarch64-linux-android diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 4320b2e945..e4804852ec 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -85,8 +85,7 @@ on: type: boolean default: false skip_pretests: - description: - Metadata-only flag propagated by caller workflows when they + description: Metadata-only flag propagated by caller workflows when they intentionally bypass the upstream pretest phase. This reusable build workflow does not execute pretests itself, but it logs the policy so the build run captures whether the normal release gate was relaxed for @@ -124,6 +123,10 @@ jobs: args: --target x86_64-unknown-linux-gnu --bundles deb appimage target: x86_64-unknown-linux-gnu artifact_suffix: ubuntu + - platform: ubuntu-24.04-arm + args: --target aarch64-unknown-linux-gnu --bundles deb appimage + target: aarch64-unknown-linux-gnu + artifact_suffix: ubuntu-arm64 - platform: windows-latest args: --target x86_64-pc-windows-msvc target: x86_64-pc-windows-msvc @@ -162,13 +165,13 @@ jobs: with: targets: ${{ matrix.settings.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} - name: Install Tauri dependencies (ubuntu only) - if: matrix.settings.platform == 'ubuntu-24.04' + if: startsWith(matrix.settings.platform, 'ubuntu-') run: | sudo apt-get update sudo apt-get install -y \ libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev \ patchelf cmake libasound2-dev libxdo-dev libxtst-dev libx11-dev libxi-dev \ - libevdev-dev libssl-dev libclang-dev \ + libevdev-dev libssl-dev libclang-dev desktop-file-utils \ libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \ libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 \ libgbm1 libpango-1.0-0 libcairo2 libatspi2.0-0 libxshmfence1 libu2f-udev @@ -238,9 +241,9 @@ jobs: != 'true') || (matrix.settings.platform != 'windows-latest' && steps.tauri-cli-cache-unix.outputs.cache-hit != 'true') shell: bash - run: cargo install --locked --path app/src-tauri/vendor/tauri-cef/crates/tauri-cli + run: bash scripts/ci-cancel-aware.sh cargo install --locked --path app/src-tauri/vendor/tauri-cef/crates/tauri-cli - name: Install dependencies - run: pnpm install --frozen-lockfile + run: bash scripts/ci-cancel-aware.sh pnpm install --frozen-lockfile - name: Validate signing prerequisites # The minisign pubkey is baked into the static tauri.conf.json, not @@ -349,7 +352,7 @@ jobs: if [ -z "${OPENHUMAN_CORE_SENTRY_DSN}" ]; then echo "::warning::vars.OPENHUMAN_CORE_SENTRY_DSN (or legacy vars.OPENHUMAN_SENTRY_DSN) is empty — the standalone CLI artifact will ship without crash reporting." fi - cargo build \ + bash scripts/ci-cancel-aware.sh cargo build \ --manifest-path "$CORE_MANIFEST" \ --target "$MATRIX_TARGET" \ --bin "$CORE_BIN_NAME" @@ -440,7 +443,7 @@ jobs: # macOS / Windows take the original single-call path. if [ "${RUNNER_OS}" = "Linux" ]; then echo "[appimage-fix] linux split build: compile first to fetch CEF" - NODE_OPTIONS="--max-old-space-size=8192" cargo tauri build --no-bundle $PROFILE_FLAG -c "$TAURI_CONFIG_OVERRIDE" $MATRIX_ARGS + NODE_OPTIONS="--max-old-space-size=8192" bash ../scripts/ci-cancel-aware.sh cargo tauri build --no-bundle $PROFILE_FLAG -c "$TAURI_CONFIG_OVERRIDE" $MATRIX_ARGS CEF_LIB_DIR="$(find "$HOME/.cache/tauri-cef" -name libcef.so -printf '%h\n' 2>/dev/null | head -1)" if [ -z "$CEF_LIB_DIR" ]; then echo "::error::libcef.so not found under ~/.cache/tauri-cef after --no-bundle compile; cannot satisfy lib4bin ldd resolution." >&2 @@ -449,7 +452,7 @@ jobs: echo "[appimage-fix] prepending CEF lib dir to LD_LIBRARY_PATH: $CEF_LIB_DIR" export LD_LIBRARY_PATH="$CEF_LIB_DIR${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" fi - NODE_OPTIONS="--max-old-space-size=8192" cargo tauri build $PROFILE_FLAG -c "$TAURI_CONFIG_OVERRIDE" $MATRIX_ARGS + NODE_OPTIONS="--max-old-space-size=8192" bash ../scripts/ci-cancel-aware.sh cargo tauri build $PROFILE_FLAG -c "$TAURI_CONFIG_OVERRIDE" $MATRIX_ARGS # Diagnostic for the recurring quick-sharun "is missing libraries! # Aborting..." error on the AppImage bundler — the upstream script @@ -458,7 +461,7 @@ jobs: # ensures this still runs when `cargo tauri build` failed at bundling # (the binary itself is produced before bundling starts). - name: Dump linked libraries of built binary (ubuntu debug) - if: always() && matrix.settings.platform == 'ubuntu-24.04' + if: always() && startsWith(matrix.settings.platform, 'ubuntu-') shell: bash env: PROFILE: ${{ inputs.build_profile }} @@ -487,7 +490,7 @@ jobs: # untouched. Re-signs the AppImage + updater tarball when signing # is enabled. - name: Strip host graphics libs from AppImage - if: matrix.settings.platform == 'ubuntu-24.04' + if: startsWith(matrix.settings.platform, 'ubuntu-') shell: bash env: MATRIX_TARGET: ${{ matrix.settings.target }} diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index 494341ceeb..e9dd3e5f6d 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -65,11 +65,11 @@ jobs: - name: Install vendored tauri-cli (cef-aware bundler) if: steps.tauri-cli-cache.outputs.cache-hit != 'true' shell: bash - run: cargo install --locked --path app/src-tauri/vendor/tauri-cef/crates/tauri-cli + run: bash scripts/ci-cancel-aware.sh cargo install --locked --path app/src-tauri/vendor/tauri-cef/crates/tauri-cli - name: Enable Corepack run: corepack enable - name: Install dependencies - run: pnpm install --frozen-lockfile + run: bash scripts/ci-cancel-aware.sh pnpm install --frozen-lockfile # vite build runs via tauri.conf.json's beforeBuildCommand during the # "Build Tauri app" step below — no separate frontend build needed. @@ -103,7 +103,7 @@ jobs: VITE_LATEST_APP_DOWNLOAD_URL: ${{ vars.VITE_LATEST_APP_DOWNLOAD_URL }} TAURI_CONFIG_OVERRIDE: ${{ steps.config-overrides.outputs.json }} run: | - NODE_OPTIONS="--max-old-space-size=8192" cargo tauri build -c "$TAURI_CONFIG_OVERRIDE" --target x86_64-pc-windows-msvc + NODE_OPTIONS="--max-old-space-size=8192" bash ../scripts/ci-cancel-aware.sh cargo tauri build -c "$TAURI_CONFIG_OVERRIDE" --target x86_64-pc-windows-msvc - name: Upload MSI artifact uses: actions/upload-artifact@v5 with: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7c8efa46a8..3c7619ad3c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,6 +6,7 @@ on: pull_request: permissions: contents: read + packages: read pull-requests: read # Required for Sentry to associate commits with releases actions: read @@ -24,6 +25,7 @@ jobs: uses: actions/checkout@v5 with: fetch-depth: 1 + persist-credentials: false submodules: recursive - name: Cache Rust build artifacts uses: Swatinem/rust-cache@v2 @@ -56,7 +58,7 @@ jobs: restore-keys: | pnpm-store-${{ runner.os }}- - name: Install dependencies - run: pnpm install --frozen-lockfile + run: bash scripts/ci-cancel-aware.sh pnpm install --frozen-lockfile # Core is linked into the Tauri binary as a path dep — no separate # sidecar build / stage step needed. - name: Build Tauri app (CEF default) @@ -65,7 +67,7 @@ jobs: # Skip tsc in beforeBuildCommand — typechecking runs in the dedicated # `typecheck` workflow, so doing it again here is duplicated CI time. TAURI_CONFIG_OVERRIDE='{"build":{"beforeBuildCommand":"npx vite build"},"plugins":{"updater":{"active":false}}}' - cargo tauri build -c "$TAURI_CONFIG_OVERRIDE" --bundles deb + bash ../scripts/ci-cancel-aware.sh cargo tauri build -c "$TAURI_CONFIG_OVERRIDE" --bundles deb env: NODE_ENV: production # CI builds should point at staging, not production. diff --git a/.github/workflows/contributor-rewards.yml b/.github/workflows/contributor-rewards.yml index 88ee1cf1c4..e2a6a93110 100644 --- a/.github/workflows/contributor-rewards.yml +++ b/.github/workflows/contributor-rewards.yml @@ -1,4 +1,21 @@ --- +# Security note: this workflow intentionally uses `pull_request_target` so the +# `reward user` label can fire on PRs from forks (the plain `pull_request` +# trigger cannot see labels applied to fork PRs and runs under a read-only +# GITHUB_TOKEN that cannot post comments / add labels). +# +# The job is bounded: +# - it never checks out fork code (no `actions/checkout` with `pull_request.head.ref`) +# - it never executes arbitrary scripts from the PR +# - the only side effects are: posting a templated comment and adding a label +# - permissions are minimal (contents:read, issues:write, pull-requests:read) +# - all PR/issue user metadata interpolated into search queries or comment +# bodies is treated as untrusted and normalized through safeLogin/safeUrl +# before use (see the inline script below). +# +# Do NOT broaden permissions, add a fork checkout, or remove the input +# normalization without a security review. + name: Contributor Rewards on: @@ -47,6 +64,25 @@ jobs: const merchUrl = (process.env.CONTRIBUTOR_REWARD_MERCH_URL || '').trim(); const customMessage = (process.env.CONTRIBUTOR_REWARD_MESSAGE || '').trim(); + // Treat all PR/issue metadata as untrusted display-only data — this + // workflow runs under `pull_request_target` so anything coming from + // a fork (login, html_url) crosses a trust boundary. Normalize + // before interpolating into search queries or comment bodies. + // GitHub usernames are 1-39 chars, alphanumeric + hyphen. + const safeLogin = (s) => String(s || '').replace(/[^A-Za-z0-9-]/g, '').slice(0, 39); + // Only allow github.com / *.github.com (covers GitHub Enterprise). + // Strip query/fragment to defeat comment-body injection via crafted + // URLs. Return '' if the URL is unparseable or off-host. + const safeUrl = (s) => { + try { + const u = new URL(String(s || '')); + const okHost = u.hostname === 'github.com' || u.hostname.endsWith('.github.com'); + return okHost ? `${u.origin}${u.pathname}` : ''; + } catch { + return ''; + } + }; + const normalize = value => String(value || '').trim().toLowerCase(); const triggerLabelKey = normalize(triggerLabel); @@ -94,12 +130,19 @@ jobs: return null; } + const login = safeLogin(pr.user?.login); + if (!login) { + core.info(`PR #${pr.number} has an unrecognized author login shape; skipping.`); + return null; + } + const htmlUrl = safeUrl(pr.html_url); + return { kind: 'pull request', number: pr.number, - htmlUrl: pr.html_url, - login: pr.user.login, - userType: pr.user.type, + htmlUrl, + login, + userType: pr.user?.type, reason: 'first merged pull request', manualOverride: false, requireFirstMergedPr: true, @@ -112,12 +155,19 @@ jobs: return null; } + const login = safeLogin(pr.user?.login); + if (!login) { + core.info(`PR #${pr.number} has an unrecognized author login shape; skipping.`); + return null; + } + const htmlUrl = safeUrl(pr.html_url); + return { kind: 'pull request', number: pr.number, - htmlUrl: pr.html_url, - login: pr.user.login, - userType: pr.user.type, + htmlUrl, + login, + userType: pr.user?.type, reason: `maintainer-applied "${triggerLabel}" label on a pull request`, manualOverride: true, requireFirstMergedPr: false, @@ -139,12 +189,19 @@ jobs: return null; } + const login = safeLogin(issue.user?.login); + if (!login) { + core.info(`Issue #${issue.number} has an unrecognized author login shape; skipping.`); + return null; + } + const htmlUrl = safeUrl(issue.html_url); + return { kind: 'issue', number: issue.number, - htmlUrl: issue.html_url, - login: issue.user.login, - userType: issue.user.type, + htmlUrl, + login, + userType: issue.user?.type, reason: `maintainer-applied "${triggerLabel}" label on an issue`, manualOverride: true, requireFirstMergedPr: false, diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 4f320ec2b6..f2d78fc8c9 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -6,6 +6,7 @@ on: permissions: contents: read + packages: read pull-requests: read concurrency: @@ -72,6 +73,10 @@ jobs: image: ghcr.io/tinyhumansai/openhuman_ci:rust-1.93.0 env: CARGO_INCREMENTAL: '0' + # Coverage instrumentation makes each test binary link substantially + # heavier. Keep the core coverage job's linker work serialized to avoid + # intermittent rust-lld bus errors on hosted Linux runners. + CARGO_BUILD_JOBS: '1' # sccache is incompatible with `-C instrument-coverage` profiles, so we # skip it for coverage runs and rely on Swatinem/rust-cache for warmup. steps: @@ -79,6 +84,7 @@ jobs: uses: actions/checkout@v5 with: fetch-depth: 1 + persist-credentials: false submodules: recursive - name: Cache Rust build artifacts uses: Swatinem/rust-cache@v2 @@ -110,6 +116,7 @@ jobs: uses: actions/checkout@v5 with: fetch-depth: 1 + persist-credentials: false submodules: recursive - name: Cache Rust build artifacts uses: Swatinem/rust-cache@v2 diff --git a/.github/workflows/deploy-smoke.yml b/.github/workflows/deploy-smoke.yml index a1cf557b28..87a39eb8cb 100644 --- a/.github/workflows/deploy-smoke.yml +++ b/.github/workflows/deploy-smoke.yml @@ -6,7 +6,9 @@ on: paths: - Dockerfile - .dockerignore + - .gitattributes - docker-compose.yml + - scripts/docker-entrypoint-core.sh - .do/app.yaml - gitbooks/developing/cloud-deploy.md - .github/workflows/deploy-smoke.yml @@ -18,7 +20,9 @@ on: paths: - Dockerfile - .dockerignore + - .gitattributes - docker-compose.yml + - scripts/docker-entrypoint-core.sh - .do/app.yaml - gitbooks/developing/cloud-deploy.md - .github/workflows/deploy-smoke.yml diff --git a/.github/workflows/e2e-playwright.yml b/.github/workflows/e2e-playwright.yml new file mode 100644 index 0000000000..b4c75ee6ba --- /dev/null +++ b/.github/workflows/e2e-playwright.yml @@ -0,0 +1,76 @@ +--- +name: E2E Playwright + +on: + workflow_dispatch: {} + +permissions: + contents: read + packages: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + e2e-playwright: + name: E2E (Playwright / web lane) + runs-on: ubuntu-22.04 + container: + image: ghcr.io/tinyhumansai/openhuman_ci:latest + timeout-minutes: 90 + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + fetch-depth: 1 + persist-credentials: false + submodules: recursive + + - name: Cache pnpm store + uses: actions/cache@v5 + with: + path: ~/.local/share/pnpm/store + key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + pnpm-store-${{ runner.os }}- + + - name: Cache Rust build artifacts + uses: Swatinem/rust-cache@v2 + with: + workspaces: | + . -> target + app/src-tauri -> target + cache-on-failure: true + key: e2e-playwright-linux + + - name: Install JS dependencies + run: bash scripts/ci-cancel-aware.sh pnpm install --frozen-lockfile + + - name: Ensure .env exists for E2E build + run: | + touch .env + touch app/.env + + - name: Build Playwright web E2E bundle + standalone core + run: bash scripts/ci-cancel-aware.sh pnpm --filter openhuman-app test:e2e:web:build + + - name: Install Playwright Chromium headless shell + run: bash scripts/ci-cancel-aware.sh pnpm --filter openhuman-app exec playwright install chromium-headless-shell + + - name: Run Playwright web E2E suite + env: + OPENHUMAN_WORKSPACE: ${{ runner.temp }}/openhuman-playwright-workspace + run: | + mkdir -p "$OPENHUMAN_WORKSPACE" + bash scripts/ci-cancel-aware.sh bash app/scripts/e2e-web-session.sh + + - name: Upload Playwright E2E failure artifacts + if: failure() + uses: actions/upload-artifact@v5 + with: + name: e2e-playwright-failure-logs-${{ github.run_id }} + path: | + ${{ runner.temp }}/openhuman-playwright-workspace/** + retention-days: 7 + if-no-files-found: ignore diff --git a/.github/workflows/e2e-reusable.yml b/.github/workflows/e2e-reusable.yml index 7b50dfc8c9..0acbb53f41 100644 --- a/.github/workflows/e2e-reusable.yml +++ b/.github/workflows/e2e-reusable.yml @@ -2,7 +2,7 @@ # Reusable E2E workflow — single source of truth for the desktop E2E recipe. # # Callers: -# - `.github/workflows/e2e.yml` — PR/push, Linux-only smoke (blocking). +# - `.github/workflows/e2e.yml` — PR/push, all-OS mega-flow gate. # - `.github/workflows/release-staging.yml` — pretest gate, all 3 OS, full suite. # - `.github/workflows/release-production.yml` — pretest gate, all 3 OS, full suite. # @@ -28,8 +28,7 @@ on: workflow_call: inputs: ref: - description: - Git ref (tag or SHA) to test. Release workflows pass the + description: Git ref (tag or SHA) to test. Release workflows pass the freshly-pushed staging/production tag here so pretest validates the exact commit the build matrix will check out, not main HEAD at workflow_dispatch time. Defaults to empty (checkout uses its @@ -51,17 +50,22 @@ on: full: description: When true, run the entire spec suite via `e2e-run-session.sh` (no - spec arg). When false, run the smoke spec + mega-flow (mega-flow - non-blocking). Releases set this to true; PR runs leave it false. + spec arg). When false, run the desktop full-flow lane only + (`mega-flow.spec.ts`). Releases set this to true. type: boolean default: false permissions: contents: read + packages: read jobs: + # Mega-flow gate for PR/push (full=false). The full-suite path lives in + # `e2e-linux-full` below, which fans out across 4 parallel shards via + # `e2e-run-all-flows.sh --suite=`. Splitting the two prevents the + # smoke job from paying matrix overhead for a 2-spec run. e2e-linux: - if: inputs.run_linux + if: inputs.run_linux && !inputs.full name: E2E (Linux / Appium Chromium) runs-on: ubuntu-22.04 container: @@ -73,6 +77,7 @@ jobs: with: ref: ${{ inputs.ref }} fetch-depth: 1 + persist-credentials: false submodules: recursive - name: Cache pnpm store @@ -113,7 +118,7 @@ jobs: key: appium3-chromium-${{ runner.os }}-v1 - name: Install JS dependencies - run: pnpm install --frozen-lockfile + run: bash scripts/ci-cancel-aware.sh pnpm install --frozen-lockfile - name: Ensure .env exists for E2E build run: | @@ -123,43 +128,238 @@ jobs: - name: Install Appium and chromium driver run: | if ! command -v appium >/dev/null 2>&1; then - npm install -g appium@3 + bash scripts/ci-cancel-aware.sh npm install -g appium@3 fi # `appium driver list --installed` can miss cached installs on some # Appium builds; install idempotently and ignore "already installed". - appium driver install --source=npm appium-chromium-driver >/dev/null 2>&1 || true + bash scripts/ci-cancel-aware.sh appium driver install --source=npm appium-chromium-driver >/dev/null 2>&1 || true - name: Build E2E app - run: pnpm --filter openhuman-app test:e2e:build - - - name: Run E2E (smoke) - if: ${{ !inputs.full }} - run: | - xvfb-run -a --server-args="-screen 0 1280x960x24" \ - bash app/scripts/e2e-run-session.sh test/e2e/specs/smoke.spec.ts smoke + run: bash scripts/ci-cancel-aware.sh pnpm --filter openhuman-app test:e2e:build - # Mega-flow exercises the OAuth-success-deep-link and Composio - # trigger-lifecycle paths. Hard-fails on regressions — if the - # deep-link → custom-event propagation race resurfaces, fix it - # at the source rather than re-adding `continue-on-error`. - name: Run E2E (mega-flow) if: ${{ !inputs.full }} run: | - xvfb-run -a --server-args="-screen 0 1280x960x24" \ + bash scripts/ci-cancel-aware.sh \ + xvfb-run -a --server-args="-screen 0 1280x960x24" \ bash app/scripts/e2e-run-session.sh test/e2e/specs/mega-flow.spec.ts mega-flow - - name: Run E2E (full suite) - if: ${{ inputs.full }} + - name: Upload E2E failure artifacts + if: failure() + uses: actions/upload-artifact@v5 + with: + name: e2e-failure-logs-${{ runner.os }}-${{ github.run_id }} + path: | + ${{ runner.temp }}/openhuman-e2e-app-*.log + /tmp/openhuman-e2e-app-*.log + app/test/e2e/artifacts/ + retention-days: 7 + if-no-files-found: ignore + + - name: Write job summary + if: always() + run: | + echo "## E2E Results (${{ runner.os }})" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + if [ -f /tmp/e2e-summary.txt ]; then + cat /tmp/e2e-summary.txt >> $GITHUB_STEP_SUMMARY + else + echo "No summary file found." >> $GITHUB_STEP_SUMMARY + fi + + # Full-suite Linux is now build-once-then-fanout: one `build-linux-full` + # job produces the binary + frontend dist + CEF runtime as a single + # workflow artifact, and the 4 shard test jobs `needs:` that build and + # download the artifact. This eliminates the parallel-shard cache race + # (only the first shard would otherwise populate the binary/CEF caches, + # the others would lose the race and rebuild) and guarantees the binary + # and its libcef.so are always packaged together. + build-linux-full: + if: inputs.run_linux && inputs.full + name: Build (Linux full) + runs-on: ubuntu-22.04 + container: + image: ghcr.io/tinyhumansai/openhuman_ci:latest + timeout-minutes: 45 + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + ref: ${{ inputs.ref }} + fetch-depth: 1 + submodules: recursive + + - name: Cache pnpm store + uses: actions/cache@v5 + with: + path: ~/.local/share/pnpm/store + key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + pnpm-store-${{ runner.os }}- + + - name: Cache Rust build artifacts + uses: Swatinem/rust-cache@v2 + with: + workspaces: | + . -> target + app/src-tauri -> target + cache-on-failure: true + key: e2e-linux-unified + + - name: Cache CEF binary distribution + uses: actions/cache@v5 + with: + # cef-dll-sys downloads into $CEF_PATH; ensure-tauri-cli.sh + + # e2e-build.sh pin that to $HOME/Library/Caches/tauri-cef on + # every OS, so the cache key/path live there too. + path: | + ~/Library/Caches/tauri-cef + key: cef-x86_64-unknown-linux-gnu-v2-${{ hashFiles('app/src-tauri/Cargo.toml') }} + restore-keys: | + cef-x86_64-unknown-linux-gnu-v2- + + - name: Install JS dependencies + run: bash scripts/ci-cancel-aware.sh pnpm install --frozen-lockfile + + - name: Ensure .env exists for E2E build run: | - xvfb-run -a --server-args="-screen 0 1280x960x24" \ - bash app/scripts/e2e-run-session.sh + touch .env + touch app/.env + + - name: Build E2E app + run: bash scripts/ci-cancel-aware.sh pnpm --filter openhuman-app test:e2e:build - # Artifact uploads intentionally omitted — this reusable workflow - # is invoked from release-staging.yml and release-production.yml, - # and uploaded logs can carry mock-backend payloads, env-var - # echoes, and CDP transcripts that we don't want pinned to a - # release artifact. Local repro: rerun the spec via Docker and - # the same logs land in /tmp. + - name: Package build artifact + run: | + # Stage everything the test shards need at the layout they expect + # under a single directory, so the consumer can extract straight + # into the workspace + $HOME. + STAGE="$(mktemp -d)" + mkdir -p "$STAGE/repo/app/src-tauri/target/debug" + mkdir -p "$STAGE/repo/app/dist" + mkdir -p "$STAGE/home/Library/Caches" + cp -a app/src-tauri/target/debug/OpenHuman "$STAGE/repo/app/src-tauri/target/debug/" + cp -a app/dist/. "$STAGE/repo/app/dist/" + cp -a "$HOME/Library/Caches/tauri-cef" "$STAGE/home/Library/Caches/tauri-cef" + tar -czf e2e-build-linux.tar.gz -C "$STAGE" repo home + ls -lh e2e-build-linux.tar.gz + + - name: Upload build artifact + uses: actions/upload-artifact@v5 + with: + name: e2e-build-linux-${{ github.run_id }} + path: e2e-build-linux.tar.gz + retention-days: 1 + if-no-files-found: error + + e2e-linux-full: + if: inputs.run_linux && inputs.full + needs: build-linux-full + name: E2E (Linux full / ${{ matrix.shard.name }}) + runs-on: ubuntu-22.04 + container: + image: ghcr.io/tinyhumansai/openhuman_ci:latest + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + shard: + - { name: foundation, suites: "auth,navigation,system" } + - { name: chat, suites: "chat,skills,journeys" } + - { name: providers, suites: "providers,notifications" } + - { name: webhooks, suites: "webhooks" } + - { name: connectors, suites: "connectors" } + - { name: commerce, suites: "payments,settings" } + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + ref: ${{ inputs.ref }} + fetch-depth: 1 + submodules: recursive + + - name: Cache pnpm store + uses: actions/cache@v5 + with: + path: ~/.local/share/pnpm/store + key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + pnpm-store-${{ runner.os }}- + + - name: Cache Appium global install + uses: actions/cache@v5 + with: + path: | + ~/.appium + /usr/local/lib/node_modules/appium + key: appium3-chromium-${{ runner.os }}-v1 + + - name: Install JS dependencies (for test harness only) + run: bash scripts/ci-cancel-aware.sh pnpm install --frozen-lockfile + + - name: Install Appium and chromium driver + run: | + if ! command -v appium >/dev/null 2>&1; then + bash scripts/ci-cancel-aware.sh npm install -g appium@3 + fi + bash scripts/ci-cancel-aware.sh appium driver install --source=npm appium-chromium-driver >/dev/null 2>&1 || true + + - name: Download build artifact + uses: actions/download-artifact@v5 + with: + name: e2e-build-linux-${{ github.run_id }} + path: . + + - name: Restore build artifact into workspace + $HOME + run: | + tar -xzf e2e-build-linux.tar.gz + # The artifact contains: repo/{app/src-tauri/target/debug/OpenHuman, app/dist/...} + # and home/Library/Caches/tauri-cef/... + mkdir -p app/src-tauri/target/debug app/dist "$HOME/Library/Caches" + cp -a repo/app/src-tauri/target/debug/OpenHuman app/src-tauri/target/debug/ + cp -a repo/app/dist/. app/dist/ + cp -a home/Library/Caches/tauri-cef "$HOME/Library/Caches/" + rm -rf repo home e2e-build-linux.tar.gz + chmod +x app/src-tauri/target/debug/OpenHuman + ls -la app/src-tauri/target/debug/OpenHuman app/dist | head + ls -la "$HOME/Library/Caches/tauri-cef" | head + + - name: Run E2E shard (${{ matrix.shard.name }} — suites=${{ matrix.shard.suites }}) + env: + E2E_BAIL_ON_FAILURE: ${{ vars.E2E_BAIL_ON_FAILURE || '' }} + run: | + export CEF_PATH="$HOME/Library/Caches/tauri-cef" + BAIL_FLAG="" + if [[ "${E2E_BAIL_ON_FAILURE:-}" == "1" ]]; then + BAIL_FLAG="--bail" + fi + bash scripts/ci-cancel-aware.sh \ + xvfb-run -a --server-args="-screen 0 1280x960x24" \ + bash app/scripts/e2e-run-all-flows.sh --skip-preflight \ + --suite=${{ matrix.shard.suites }} $BAIL_FLAG + + - name: Upload E2E failure artifacts + if: failure() + uses: actions/upload-artifact@v5 + with: + name: e2e-failure-logs-${{ runner.os }}-${{ matrix.shard.name }}-${{ github.run_id }} + path: | + ${{ runner.temp }}/openhuman-e2e-app-*.log + /tmp/openhuman-e2e-app-*.log + app/test/e2e/artifacts/ + retention-days: 7 + if-no-files-found: ignore + + - name: Write job summary + if: always() + run: | + echo "## E2E Results (${{ runner.os }} / ${{ matrix.shard.name }})" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + if [ -f /tmp/e2e-summary.txt ]; then + cat /tmp/e2e-summary.txt >> $GITHUB_STEP_SUMMARY + else + echo "No summary file found." >> $GITHUB_STEP_SUMMARY + fi # Rust-side E2E counterpart to the Tauri runs above. Same Linux-only # scope (CI does not run this on macOS or Windows — the Rust core is @@ -174,14 +374,15 @@ jobs: image: ghcr.io/tinyhumansai/openhuman_ci:latest timeout-minutes: 60 env: - CARGO_INCREMENTAL: '0' - RUST_BACKTRACE: '1' + CARGO_INCREMENTAL: "0" + RUST_BACKTRACE: "1" steps: - name: Checkout code uses: actions/checkout@v5 with: ref: ${{ inputs.ref }} fetch-depth: 1 + persist-credentials: false submodules: recursive - name: Cache pnpm store @@ -200,7 +401,7 @@ jobs: key: rust-e2e-linux - name: Install JS dependencies - run: pnpm install --frozen-lockfile + run: bash scripts/ci-cancel-aware.sh pnpm install --frozen-lockfile - name: Ensure .env exists for tests run: | @@ -208,14 +409,14 @@ jobs: touch app/.env - name: Run Rust E2E suite (tests/*_e2e.rs vs mock backend) - run: pnpm test:rust:e2e + run: bash scripts/ci-cancel-aware.sh pnpm test:rust:e2e # No artifact uploads here either — same release-workflow reuse # concern as the Tauri job above. Mock-backend log lives at # /tmp/openhuman-rust-e2e-mock.log for local docker repro. e2e-macos: - if: inputs.run_macos + if: inputs.run_macos && !inputs.full name: E2E (macOS / Appium Chromium) runs-on: macos-latest timeout-minutes: 90 @@ -225,6 +426,7 @@ jobs: with: ref: ${{ inputs.ref }} fetch-depth: 1 + persist-credentials: false submodules: recursive - name: Install pnpm @@ -276,7 +478,7 @@ jobs: key: appium3-chromium-${{ runner.os }}-v1 - name: Install JS dependencies - run: pnpm install --frozen-lockfile + run: bash scripts/ci-cancel-aware.sh pnpm install --frozen-lockfile - name: Ensure .env exists for E2E build run: | @@ -286,14 +488,14 @@ jobs: - name: Install Appium and chromium driver run: | if ! command -v appium >/dev/null 2>&1; then - npm install -g appium@3 + bash scripts/ci-cancel-aware.sh npm install -g appium@3 fi # `appium driver list --installed` can miss cached installs on some # Appium builds; install idempotently and ignore "already installed". - appium driver install --source=npm appium-chromium-driver >/dev/null 2>&1 || true + bash scripts/ci-cancel-aware.sh appium driver install --source=npm appium-chromium-driver >/dev/null 2>&1 || true - name: Build E2E app - run: pnpm --filter openhuman-app test:e2e:build + run: bash scripts/ci-cancel-aware.sh pnpm --filter openhuman-app test:e2e:build # macOS rejects dynamic-framework loads from unsigned bundles — adhoc # signing satisfies the loader without a real developer-ID cert. @@ -304,21 +506,15 @@ jobs: codesign --verify --deep --verbose=2 \ app/src-tauri/target/debug/bundle/macos/OpenHuman.app - - name: Run E2E (smoke + mega-flow) - if: ${{ !inputs.full }} + - name: Run E2E (mega-flow) run: | - bash app/scripts/e2e-run-session.sh test/e2e/specs/smoke.spec.ts smoke - bash app/scripts/e2e-run-session.sh test/e2e/specs/mega-flow.spec.ts mega-flow - - - name: Run E2E (full suite) - if: ${{ inputs.full }} - run: bash app/scripts/e2e-run-session.sh + bash scripts/ci-cancel-aware.sh bash app/scripts/e2e-run-session.sh test/e2e/specs/mega-flow.spec.ts mega-flow # Artifact uploads intentionally omitted — see e2e-linux for the # reusable-workflow-is-also-used-by-releases rationale. e2e-windows: - if: inputs.run_windows + if: inputs.run_windows && !inputs.full name: E2E (Windows / Appium Chromium) runs-on: windows-latest timeout-minutes: 90 @@ -328,6 +524,7 @@ jobs: with: ref: ${{ inputs.ref }} fetch-depth: 1 + persist-credentials: false submodules: recursive - name: Install pnpm @@ -368,7 +565,7 @@ jobs: key: appium3-chromium-${{ runner.os }}-v1 - name: Install JS dependencies - run: pnpm install --frozen-lockfile + run: bash scripts/ci-cancel-aware.sh pnpm install --frozen-lockfile - name: Ensure .env exists for E2E build shell: bash @@ -380,26 +577,314 @@ jobs: shell: bash run: | if ! command -v appium >/dev/null 2>&1; then - npm install -g appium@3 + bash scripts/ci-cancel-aware.sh npm install -g appium@3 fi # `appium driver list --installed` can miss cached installs on some # Appium builds; install idempotently and ignore "already installed". - appium driver install --source=npm appium-chromium-driver >/dev/null 2>&1 || true + bash scripts/ci-cancel-aware.sh appium driver install --source=npm appium-chromium-driver >/dev/null 2>&1 || true - name: Build E2E app - run: pnpm --filter openhuman-app test:e2e:build + run: bash scripts/ci-cancel-aware.sh pnpm --filter openhuman-app test:e2e:build - - name: Run E2E (smoke + mega-flow) - if: ${{ !inputs.full }} + - name: Run E2E (mega-flow) shell: bash run: | - bash app/scripts/e2e-run-session.sh test/e2e/specs/smoke.spec.ts smoke - bash app/scripts/e2e-run-session.sh test/e2e/specs/mega-flow.spec.ts mega-flow - - - name: Run E2E (full suite) - if: ${{ inputs.full }} - shell: bash - run: bash app/scripts/e2e-run-session.sh + bash scripts/ci-cancel-aware.sh bash app/scripts/e2e-run-session.sh test/e2e/specs/mega-flow.spec.ts mega-flow # Artifact uploads intentionally omitted — see e2e-linux for the # reusable-workflow-is-also-used-by-releases rationale. + + # --------------------------------------------------------------------------- + # Full-suite macOS — sharded matrix mirroring e2e-linux-full. + # --------------------------------------------------------------------------- + e2e-macos-full: + if: inputs.run_macos && inputs.full + name: E2E (macOS full / ${{ matrix.shard.name }}) + runs-on: macos-latest + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + shard: + - { name: foundation, suites: "auth,navigation,system" } + - { name: chat, suites: "chat,skills,journeys" } + - { name: providers, suites: "providers,notifications" } + - { name: webhooks, suites: "webhooks" } + - { name: connectors, suites: "connectors" } + - { name: commerce, suites: "payments,settings" } + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + ref: ${{ inputs.ref }} + fetch-depth: 1 + submodules: recursive + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js 24.x + uses: actions/setup-node@v5 + with: + node-version: 24.x + cache: pnpm + + - name: Install Rust (rust-toolchain.toml) + uses: dtolnay/rust-toolchain@1.93.0 + with: + # macos-latest is arm64, but the vendored tauri-cli's build.rs + # compiles a CEF helper for x86_64-apple-darwin (universal binary), + # so the x86_64 libstd must be installed too. Without this the + # build fails with E0463 "can't find crate for `core`". + targets: x86_64-apple-darwin + + - name: Verify cargo resolves to real toolchain + run: | + rustup default 1.93.0 || true + which cargo + cargo --version + + - name: Cache Rust build artifacts + uses: Swatinem/rust-cache@v2 + with: + workspaces: | + . -> target + app/src-tauri -> target + cache-on-failure: true + key: e2e-macos-unified-v2 + + - name: Cache CEF binary distribution + uses: actions/cache@v5 + with: + path: | + ~/Library/Caches/tauri-cef + key: cef-aarch64-apple-darwin-${{ hashFiles('app/src-tauri/Cargo.toml') }} + restore-keys: | + cef-aarch64-apple-darwin- + + - name: Cache Appium global install + uses: actions/cache@v5 + with: + path: | + ~/.appium + key: appium3-chromium-${{ runner.os }}-v1 + + - name: Install JS dependencies + run: bash scripts/ci-cancel-aware.sh pnpm install --frozen-lockfile + + - name: Ensure .env exists for E2E build + run: | + touch .env + touch app/.env + + - name: Install Appium and chromium driver + run: | + if ! command -v appium >/dev/null 2>&1; then + bash scripts/ci-cancel-aware.sh npm install -g appium@3 + fi + bash scripts/ci-cancel-aware.sh appium driver install --source=npm appium-chromium-driver >/dev/null 2>&1 || true + + # Binary cache — see Linux full job for the rationale. Mac caches the + # entire .app bundle (self-contained including frontend assets + CEF + # Frameworks/OpenHuman Helper.app embedded by tauri-bundler). + - name: Cache built E2E binary (macOS) + id: e2e-binary-cache + uses: actions/cache@v5 + with: + path: | + app/src-tauri/target/debug/bundle/macos/OpenHuman.app + key: e2e-binary-${{ runner.os }}-${{ hashFiles('src/**/*.rs', 'app/src-tauri/src/**', 'app/src-tauri/build.rs', 'app/src-tauri/tauri.conf.json', 'Cargo.lock', 'app/src-tauri/Cargo.lock', 'app/src-tauri/vendor/tauri-cef/Cargo.lock', 'rust-toolchain.toml', 'app/src/**', 'app/index.html', 'app/vite.config.*', 'app/tailwind.config.*', 'app/postcss.config.*', 'app/package.json', 'pnpm-lock.yaml', 'app/scripts/e2e-build.sh') }} + + - name: Build E2E app + if: steps.e2e-binary-cache.outputs.cache-hit != 'true' + run: bash scripts/ci-cancel-aware.sh pnpm --filter openhuman-app test:e2e:build + + # Adhoc-sign runs unconditionally — codesign is idempotent and a + # restored .app bundle from cache also needs to be (re-)signed for + # macOS to load its dynamic frameworks on this runner. + - name: Adhoc-sign the .app bundle + run: | + codesign --force --deep --sign - \ + app/src-tauri/target/debug/bundle/macos/OpenHuman.app + codesign --verify --deep --verbose=2 \ + app/src-tauri/target/debug/bundle/macos/OpenHuman.app + + - name: Run E2E shard (${{ matrix.shard.name }} — suites=${{ matrix.shard.suites }}) + env: + E2E_BAIL_ON_FAILURE: ${{ vars.E2E_BAIL_ON_FAILURE || '' }} + run: | + BAIL_FLAG="" + if [[ "${E2E_BAIL_ON_FAILURE:-}" == "1" ]]; then + BAIL_FLAG="--bail" + fi + bash scripts/ci-cancel-aware.sh bash app/scripts/e2e-run-all-flows.sh --skip-preflight \ + --suite=${{ matrix.shard.suites }} $BAIL_FLAG + + - name: Upload E2E failure artifacts + if: failure() + uses: actions/upload-artifact@v5 + with: + name: e2e-failure-logs-${{ runner.os }}-${{ matrix.shard.name }}-${{ github.run_id }} + path: | + ${{ runner.temp }}/openhuman-e2e-app-*.log + /tmp/openhuman-e2e-app-*.log + app/test/e2e/artifacts/ + retention-days: 7 + if-no-files-found: ignore + + - name: Write job summary + if: always() + run: | + echo "## E2E Results (${{ runner.os }} / ${{ matrix.shard.name }})" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + if [ -f /tmp/e2e-summary.txt ]; then + cat /tmp/e2e-summary.txt >> $GITHUB_STEP_SUMMARY + else + echo "No summary file found." >> $GITHUB_STEP_SUMMARY + fi + + # --------------------------------------------------------------------------- + # Full-suite Windows — sharded matrix mirroring e2e-linux-full. + # --------------------------------------------------------------------------- + e2e-windows-full: + if: inputs.run_windows && inputs.full + name: E2E (Windows full / ${{ matrix.shard.name }}) + runs-on: windows-latest + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + shard: + - { name: foundation, suites: "auth,navigation,system" } + - { name: chat, suites: "chat,skills,journeys" } + - { name: providers, suites: "providers,notifications" } + - { name: webhooks, suites: "webhooks" } + - { name: connectors, suites: "connectors" } + - { name: commerce, suites: "payments,settings" } + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + ref: ${{ inputs.ref }} + fetch-depth: 1 + submodules: recursive + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js 24.x + uses: actions/setup-node@v5 + with: + node-version: 24.x + cache: pnpm + + - name: Install Rust (rust-toolchain.toml) + uses: dtolnay/rust-toolchain@1.93.0 + + - name: Cache Rust build artifacts + uses: Swatinem/rust-cache@v2 + with: + workspaces: | + . -> target + app/src-tauri -> target + cache-on-failure: true + key: e2e-windows-unified + + - name: Cache CEF binary distribution + id: cef-cache + uses: actions/cache@v5 + with: + # ensure-tauri-cli.sh + e2e-build.sh both export + # CEF_PATH=$HOME/Library/Caches/tauri-cef regardless of OS; on + # Windows under Git Bash that resolves under the user profile and + # is the actual download target. + path: | + ~/Library/Caches/tauri-cef + key: cef-x86_64-pc-windows-msvc-v2-${{ hashFiles('app/src-tauri/Cargo.toml') }} + restore-keys: | + cef-x86_64-pc-windows-msvc-v2- + + - name: Cache Appium global install + uses: actions/cache@v5 + with: + path: | + ~/.appium + key: appium3-chromium-${{ runner.os }}-v1 + + - name: Install JS dependencies + run: bash scripts/ci-cancel-aware.sh pnpm install --frozen-lockfile + + - name: Ensure .env exists for E2E build + shell: bash + run: | + touch .env + touch app/.env + + - name: Install Appium and chromium driver + shell: bash + run: | + if ! command -v appium >/dev/null 2>&1; then + bash scripts/ci-cancel-aware.sh npm install -g appium@3 + fi + bash scripts/ci-cancel-aware.sh appium driver install --source=npm appium-chromium-driver >/dev/null 2>&1 || true + + # Binary cache — see Linux full job for rationale. Windows is built + # with --debug --no-bundle so the .exe + frontend dist are what the + # runner needs at launch. CEF runtime DLLs come from the dedicated + # CEF cache step above (now correctly pointing at the actual download + # location, ~/Library/Caches/tauri-cef). + - name: Cache built E2E binary (Windows) + id: e2e-binary-cache + uses: actions/cache@v5 + with: + path: | + app/src-tauri/target/debug/OpenHuman.exe + app/dist + key: e2e-binary-${{ runner.os }}-${{ hashFiles('src/**/*.rs', 'app/src-tauri/src/**', 'app/src-tauri/build.rs', 'app/src-tauri/tauri.conf.json', 'Cargo.lock', 'app/src-tauri/Cargo.lock', 'app/src-tauri/vendor/tauri-cef/Cargo.lock', 'rust-toolchain.toml', 'app/src/**', 'app/index.html', 'app/vite.config.*', 'app/tailwind.config.*', 'app/postcss.config.*', 'app/package.json', 'pnpm-lock.yaml', 'app/scripts/e2e-build.sh') }} + + # Skip the build only when BOTH the binary AND the CEF runtime caches + # hit (see Linux full job for the rationale). + - name: Build E2E app + if: steps.e2e-binary-cache.outputs.cache-hit != 'true' || steps.cef-cache.outputs.cache-hit != 'true' + run: bash scripts/ci-cancel-aware.sh pnpm --filter openhuman-app test:e2e:build + + - name: Run E2E shard (${{ matrix.shard.name }} — suites=${{ matrix.shard.suites }}) + shell: bash + env: + E2E_BAIL_ON_FAILURE: ${{ vars.E2E_BAIL_ON_FAILURE || '' }} + run: | + # See Linux shard — binary cache can skip the build that would have + # exported CEF_PATH. e2e-build.sh + ensure-tauri-cli.sh always + # download CEF to $HOME/Library/Caches/tauri-cef regardless of OS. + export CEF_PATH="$HOME/Library/Caches/tauri-cef" + BAIL_FLAG="" + if [[ "${E2E_BAIL_ON_FAILURE:-}" == "1" ]]; then + BAIL_FLAG="--bail" + fi + bash scripts/ci-cancel-aware.sh bash app/scripts/e2e-run-all-flows.sh --skip-preflight \ + --suite=${{ matrix.shard.suites }} $BAIL_FLAG + + - name: Upload E2E failure artifacts + if: failure() + uses: actions/upload-artifact@v5 + with: + name: e2e-failure-logs-${{ runner.os }}-${{ matrix.shard.name }}-${{ github.run_id }} + # e2e-run-session.sh writes its app log to `${RUNNER_TEMP:-${TMPDIR:-/tmp}}`. + # On Windows runners RUNNER_TEMP resolves to D:\a\_temp, not /tmp. + path: | + ${{ runner.temp }}/openhuman-e2e-app-*.log + app/test/e2e/artifacts/ + retention-days: 7 + if-no-files-found: ignore + + - name: Write job summary + if: always() + shell: bash + run: | + echo "## E2E Results (${{ runner.os }} / ${{ matrix.shard.name }})" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + if [ -f /tmp/e2e-summary.txt ]; then + cat /tmp/e2e-summary.txt >> $GITHUB_STEP_SUMMARY + else + echo "No summary file found." >> $GITHUB_STEP_SUMMARY + fi diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 8015e035f5..3ff23c9255 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -1,36 +1,20 @@ --- # PR/push E2E gate. # -# Calls the reusable `e2e-reusable.yml` with Linux only (smoke + mega-flow). -# macOS / Windows E2E only runs at release time — see release-staging.yml -# and release-production.yml `pretest` jobs which call the same reusable -# workflow with `run_macos`, `run_windows`, and `full` all true. -# -# `workflow_dispatch` lets an operator opt in to a full-suite all-OS run -# without cutting a release tag. +# Desktop full-flow lane (mega-flow) across Linux, macOS, and Windows. +# The browser-hosted Playwright suite lives in its own standalone workflow so +# it can be run on demand without gating every push / PR. name: E2E on: push: branches: [main] pull_request: - workflow_dispatch: - inputs: - run_macos: - description: Also run the macOS E2E job. - type: boolean - default: false - run_windows: - description: Also run the Windows E2E job. - type: boolean - default: false - full: - description: Run the entire spec suite (slow; ~30+ min per OS). - type: boolean - default: false + workflow_dispatch: {} permissions: contents: read + packages: read pull-requests: read concurrency: @@ -38,10 +22,10 @@ concurrency: cancel-in-progress: true jobs: - e2e: + e2e-desktop: uses: ./.github/workflows/e2e-reusable.yml with: run_linux: true - run_macos: ${{ github.event_name == 'workflow_dispatch' && inputs.run_macos }} - run_windows: ${{ github.event_name == 'workflow_dispatch' && inputs.run_windows }} - full: ${{ github.event_name == 'workflow_dispatch' && inputs.full }} + run_macos: true + run_windows: true + full: false diff --git a/.github/workflows/ios-compile.yml b/.github/workflows/ios-compile.yml new file mode 100644 index 0000000000..3c405e4490 --- /dev/null +++ b/.github/workflows/ios-compile.yml @@ -0,0 +1,93 @@ +--- +name: iOS Compile Sanity + +on: + pull_request: + paths: + - 'app/src-tauri-mobile/**' + - 'packages/tauri-plugin-ptt/**' + - 'src/openhuman/devices/**' + - 'app/src/services/transport/**' + - 'app/src/lib/tunnel/**' + - 'app/src/pages/ios/**' + - '.github/workflows/ios-compile.yml' + workflow_dispatch: + +permissions: + contents: read + pull-requests: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.head_ref || github.ref }} + cancel-in-progress: true + +jobs: + ios-compile: + name: iOS Compile Check + runs-on: macos-latest + env: + # Pin the deployment target so swift-rs invokes the Swift compiler with + # `-target arm64-apple-ios16.0`. Matches Package.swift in + # packages/tauri-plugin-ptt/ios/, which uses iOS 14+ APIs (OSLog). + IPHONEOS_DEPLOYMENT_TARGET: '16.0' + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 1 + # The mobile crate uses stock Tauri (no CEF), so we don't need + # `submodules: recursive` — which would try to clone the + # `app/src-tauri/vendor/tauri-cef` submodule, a step that + # intermittently fails on macOS runners for fork PRs. + submodules: false + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + with: + toolchain: '1.93.0' + targets: aarch64-apple-ios + + - name: Cache Rust build artifacts + uses: Swatinem/rust-cache@v2 + with: + workspaces: | + . -> target + app/src-tauri-mobile -> target + packages/tauri-plugin-ptt -> target + cache-on-failure: true + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: 'pnpm' + + - name: Install dependencies + run: bash scripts/ci-cancel-aware.sh pnpm install --frozen-lockfile + + # Hard gate: mobile Tauri host compiles for iOS. No more soft-gate + # `continue-on-error` — the mobile crate uses stock Tauri without CEF + # so cef-dll-sys is not in the dependency graph. + - name: cargo check -- mobile host (aarch64-apple-ios) + run: bash scripts/ci-cancel-aware.sh cargo check --manifest-path app/src-tauri-mobile/Cargo.toml --target aarch64-apple-ios + + # Hard gate: PTT plugin (host-target check; Swift sources are built + # lazily by swift-rs during the iOS-target check above). + - name: cargo check -- tauri-plugin-ptt + run: bash scripts/ci-cancel-aware.sh cargo check --manifest-path packages/tauri-plugin-ptt/Cargo.toml + + # Hard gate: TypeScript compile. + - name: pnpm compile + run: bash scripts/ci-cancel-aware.sh pnpm --dir app compile + + # Hard gate: iOS-relevant Vitest suites. + - name: pnpm test (iOS suites) + run: > + bash scripts/ci-cancel-aware.sh pnpm --dir app test -- + src/services/transport + src/lib/tunnel + src/pages/ios + src/components/settings/panels/devices diff --git a/.github/workflows/pr-quality.yml b/.github/workflows/pr-quality.yml index 3099f524f8..061ea56152 100644 --- a/.github/workflows/pr-quality.yml +++ b/.github/workflows/pr-quality.yml @@ -5,6 +5,7 @@ on: types: [opened, synchronize, reopened, labeled, unlabeled, edited] permissions: contents: read + packages: read pull-requests: read concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.head_ref || github.ref }} diff --git a/.github/workflows/release-packages.yml b/.github/workflows/release-packages.yml index 3fe8d5931f..7cc7e5357d 100644 --- a/.github/workflows/release-packages.yml +++ b/.github/workflows/release-packages.yml @@ -76,7 +76,7 @@ jobs: OPENHUMAN_BUILD_SHA: ${{ github.sha }} OPENHUMAN_APP_ENV: production run: | - cargo build --release --bin openhuman-core + bash scripts/ci-cancel-aware.sh cargo build --release --bin openhuman-core VERSION="${{ github.event.release.tag_name }}" bash scripts/release/package-cli-tarball.sh \ target/release/openhuman-core \ diff --git a/.github/workflows/release-production.yml b/.github/workflows/release-production.yml index 33dba820f5..256e704ac3 100644 --- a/.github/workflows/release-production.yml +++ b/.github/workflows/release-production.yml @@ -28,16 +28,23 @@ on: default: patch type: choice options: [patch, minor, major] - skip_e2e: + skip_pretests: description: - Skip the entire pretest phase (unit/rust plus E2E) and continue - directly to create-release + build matrix. Use only when the - required pretest signal is already known (e.g. promoting a - staging tag whose pretests already ran green) and you need to - unblock a production cut. + Skip the unit/rust pretest phase and continue directly to the build + matrix. Release workflows no longer run E2E. Use only when the + required pretest signal is already known and you need to unblock a + production cut. required: false type: boolean default: false + create_release: + description: + Create and publish the GitHub Release and attach release assets. When + false, run the production build matrix without creating or publishing + a GitHub Release. + required: false + type: boolean + default: true permissions: contents: write packages: write @@ -52,9 +59,8 @@ concurrency: # prepare-build # │ # ├─── pretest-tests (reusable test-reusable.yml — unit + rust) -# ├─── pretest-e2e (reusable e2e-reusable.yml — all 3 OS, full) # │ -# ├─── create-release +# ├─── create-release (optional no-op when `create_release=false`) # │ │ # │ ┌────┴───────────────┬────────────────┐ # │ │ │ │ @@ -309,8 +315,7 @@ jobs: echo "base_url=$BASE_URL" >> "$GITHUB_OUTPUT" # ========================================================================= - # Phase 1b: Pretest gate — run the full test + E2E suite across every - # target OS exactly once on the build ref before we spin up the release + # Phase 1b: Pretest gate — run unit + rust on the build ref before we spin up the release # draft or any signed-build matrix. Pretest failures abort the workflow # before `create-release` runs, so a busted commit never produces a # half-finished GH Release that has to be cleaned up. @@ -318,42 +323,35 @@ jobs: pretest-tests: name: Pretest — unit + rust needs: [prepare-build] - if: ${{ !inputs.skip_e2e }} + if: ${{ !inputs.skip_pretests }} uses: ./.github/workflows/test-reusable.yml with: ref: ${{ needs.prepare-build.outputs.build_ref }} - pretest-e2e: - name: Pretest — E2E (all OS, full suite) - needs: [prepare-build] - if: ${{ !inputs.skip_e2e }} - uses: ./.github/workflows/e2e-reusable.yml - with: - ref: ${{ needs.prepare-build.outputs.build_ref }} - run_linux: true - run_macos: true - run_windows: true - full: true - # ========================================================================= # Phase 2: Create draft GitHub release # ========================================================================= create-release: - name: Create GitHub release + name: Prepare GitHub release runs-on: ubuntu-latest environment: Production - needs: [prepare-build, pretest-tests, pretest-e2e] + needs: [prepare-build, pretest-tests] if: >- always() && needs.prepare-build.result == 'success' && (needs.pretest-tests.result == 'success' - || (inputs.skip_e2e && needs.pretest-tests.result == 'skipped')) - && (needs.pretest-e2e.result == 'success' - || (inputs.skip_e2e && needs.pretest-e2e.result == 'skipped')) + || (inputs.skip_pretests && needs.pretest-tests.result == 'skipped')) outputs: - release_id: ${{ steps.create.outputs.release_id }} - upload_url: ${{ steps.create.outputs.upload_url }} + release_id: ${{ steps.create.outputs.release_id || steps.noop.outputs.release_id }} + upload_url: ${{ steps.create.outputs.upload_url || steps.noop.outputs.upload_url }} steps: + - name: Skip release creation + if: ${{ !inputs.create_release }} + id: noop + run: | + echo "release_id=" >> "$GITHUB_OUTPUT" + echo "upload_url=" >> "$GITHUB_OUTPUT" - name: Create draft release with generated notes + if: ${{ inputs.create_release }} id: create uses: actions/github-script@v8 with: @@ -390,7 +388,7 @@ jobs: build-desktop: name: Build desktop matrix needs: [prepare-build, create-release] - # `always()` is load-bearing: when `skip_e2e=true` the pretest jobs are + # `always()` is load-bearing: when `skip_pretests=true` the pretest job is # `skipped`, and GitHub propagates that skipped status transitively to any # downstream job lacking an explicit status function — even though we only # `needs` create-release here, the build would otherwise be skipped along @@ -412,10 +410,10 @@ jobs: telegram_bot_username: openhumanaibot # with_macos_signing defaults to true — left implicit; production # always notarizes. See build-desktop.yml inputs. - with_release_upload: true + with_release_upload: ${{ inputs.create_release }} release_id: ${{ needs.create-release.outputs.release_id }} build_sidecar: false - skip_pretests: ${{ inputs.skip_e2e }} + skip_pretests: ${{ inputs.skip_pretests }} # ========================================================================= # Phase 3b: Build & push Docker image (runs parallel with build-desktop). @@ -514,7 +512,7 @@ jobs: build-cli-linux: name: "CLI: ${{ matrix.target }}" needs: [prepare-build, create-release] - if: always() && needs.create-release.result == 'success' + if: ${{ inputs.create_release && always() && needs.create-release.result == 'success' }} environment: Production runs-on: ${{ matrix.runner }} strategy: @@ -561,7 +559,7 @@ jobs: # baked elsewhere — see prepare-build.outputs.short_sha comment. OPENHUMAN_BUILD_SHA: ${{ needs.prepare-build.outputs.short_sha }} OPENHUMAN_APP_ENV: production - run: cargo build --release --bin openhuman-core + run: bash scripts/ci-cancel-aware.sh cargo build --release --bin openhuman-core - name: Package and upload tarball to release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -583,7 +581,7 @@ jobs: publish-updater-manifest: name: Publish updater manifest (latest.json) needs: [prepare-build, create-release, build-desktop] - if: always() && needs.build-desktop.result == 'success' + if: ${{ inputs.create_release && always() && needs.build-desktop.result == 'success' }} runs-on: ubuntu-latest environment: Production steps: @@ -616,6 +614,8 @@ jobs: - build-docker - publish-updater-manifest if: >- + inputs.create_release + && always() && needs.build-desktop.result == 'success' && needs.build-cli-linux.result == 'success' @@ -668,6 +668,10 @@ jobs: // Linux desktop installer consumed by scripts/install.sh and // advertised in latest.json as linux-x86_64. /OpenHuman_.*_amd64\.AppImage$/, + // Linux arm64 desktop installer consumed by scripts/install.sh + // and advertised in latest.json as linux-aarch64. + /OpenHuman_.*_(arm64|aarch64)\.AppImage$/, + /OpenHuman_.*_(arm64|aarch64)\.deb$/, // Auto-updater manifest — without this, installed clients can't // discover new releases via plugins.updater.endpoints. /^latest\.json$/, @@ -716,7 +720,7 @@ jobs: runs-on: ubuntu-latest environment: Production needs: [prepare-build, publish-release] - if: always() && needs.publish-release.result == 'success' + if: ${{ inputs.create_release && always() && needs.publish-release.result == 'success' }} env: REGISTRY: ghcr.io IMAGE_NAME: tinyhumansai/openhuman-core @@ -752,7 +756,7 @@ jobs: runs-on: ubuntu-latest environment: Production needs: [prepare-build, publish-release] - if: always() && needs.publish-release.result == 'success' + if: ${{ inputs.create_release && always() && needs.publish-release.result == 'success' }} env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_URL: ${{ vars.SENTRY_URL }} @@ -792,7 +796,6 @@ jobs: needs: - prepare-build - pretest-tests - - pretest-e2e - create-release - build-desktop - build-cli-linux @@ -812,7 +815,7 @@ jobs: && ( (needs.create-release.result != 'success' && (needs.pretest-tests.result == 'failure' || needs.pretest-tests.result == 'cancelled' - || needs.pretest-e2e.result == 'failure' || needs.pretest-e2e.result == 'cancelled')) + )) || (needs.create-release.result == 'success' && (needs.build-desktop.result == 'failure' || needs.build-desktop.result == 'cancelled' || needs.build-cli-linux.result == 'failure' || needs.build-cli-linux.result == 'cancelled' @@ -823,7 +826,7 @@ jobs: - name: Delete GitHub release # Skip on the pretest-failure cleanup path: create-release didn't run, # so there's no draft release to delete (only an orphaned tag). - if: needs.create-release.result == 'success' + if: ${{ inputs.create_release && needs.create-release.result == 'success' }} uses: actions/github-script@v8 with: script: | diff --git a/.github/workflows/release-staging.yml b/.github/workflows/release-staging.yml index cb8b794b1a..3404661e50 100644 --- a/.github/workflows/release-staging.yml +++ b/.github/workflows/release-staging.yml @@ -3,12 +3,12 @@ name: Release Staging on: workflow_dispatch: inputs: - skip_e2e: + skip_pretests: description: - Skip the entire pretest phase (unit/rust plus E2E) and continue - directly to the desktop/docker staging build. Use only when the - required pretest signal is already known and you need to unblock a - staging cut. + Skip the unit/rust pretest phase and continue directly to the + desktop/docker staging build. Release workflows no longer run E2E. + Use only when the required pretest signal is already known and you + need to unblock a staging cut. required: false type: boolean default: false @@ -26,9 +26,8 @@ concurrency: # prepare-build # │ # ├── pretest-tests (reusable test-reusable.yml — unit + rust; -# │ optional when `skip_e2e` is true) -# ├── pretest-e2e (reusable e2e-reusable.yml — all 3 OS, full suite; -# │ optional when `skip_e2e` is true) +# │ optional when `skip_pretests` is true) +# ├── pretest-tests (reusable test-reusable.yml — unit + rust) # │ # ├── build-desktop (delegated to .github/workflows/build-desktop.yml) # ├── build-docker (build only — no GHCR push on staging) @@ -37,10 +36,8 @@ concurrency: # │ # cleanup-failed-staging (on failure) # -# The pretest jobs are a hard gate — `build-desktop` and `build-docker` -# only start once unit/rust/E2E have all passed across every target. This -# guarantees we never produce a staging tag whose installers were built -# against unproven code. +# The pretest job is a hard gate — `build-desktop` and `build-docker` +# only start once unit/rust have passed, unless explicitly skipped. # # The actual desktop build / Sentry / artifact-upload pipeline lives in # `.github/workflows/build-desktop.yml` and is shared with @@ -189,42 +186,27 @@ jobs: echo "base_url=https://staging-api.tinyhumans.ai/" >> "$GITHUB_OUTPUT" # ========================================================================= - # Phase 1b: Pretest gate — run the full test + E2E suite across every - # target OS exactly once on the staging commit before any build job + # Phase 1b: Pretest gate — run unit + rust once on the staging commit before any build job # spins up. A failure here aborts the matrix (and `cleanup-failed-staging` # deletes the tag) without burning four signed Tauri builds first. # ========================================================================= pretest-tests: name: Pretest — unit + rust needs: [prepare-build] - if: ${{ !inputs.skip_e2e }} + if: ${{ !inputs.skip_pretests }} uses: ./.github/workflows/test-reusable.yml with: ref: ${{ needs.prepare-build.outputs.build_ref }} - pretest-e2e: - name: Pretest — E2E (all OS, full suite) - needs: [prepare-build] - if: ${{ !inputs.skip_e2e }} - uses: ./.github/workflows/e2e-reusable.yml - with: - ref: ${{ needs.prepare-build.outputs.build_ref }} - run_linux: true - run_macos: true - run_windows: true - full: true - # ========================================================================= # Phase 2: Build desktop artifacts (delegated to reusable workflow) # ========================================================================= build-desktop: name: Build desktop matrix - needs: [prepare-build, pretest-tests, pretest-e2e] + needs: [prepare-build, pretest-tests] if: >- always() && (needs.pretest-tests.result == 'success' - || (inputs.skip_e2e && needs.pretest-tests.result == 'skipped')) - && (needs.pretest-e2e.result == 'success' - || (inputs.skip_e2e && needs.pretest-e2e.result == 'skipped')) + || (inputs.skip_pretests && needs.pretest-tests.result == 'skipped')) uses: ./.github/workflows/build-desktop.yml secrets: inherit with: @@ -254,7 +236,7 @@ jobs: # real consumer. Set `build_sidecar: true` to re-enable a per-platform # CLI Actions artifact + its Sentry DIF upload for QA spot-checks. build_sidecar: false - skip_pretests: ${{ inputs.skip_e2e }} + skip_pretests: ${{ inputs.skip_pretests }} # ========================================================================= # Phase 2b: Build the openhuman-core Docker image without pushing. @@ -267,13 +249,11 @@ jobs: # ========================================================================= build-docker: name: "Docker: build (no push)" - needs: [prepare-build, pretest-tests, pretest-e2e] + needs: [prepare-build, pretest-tests] if: >- always() && (needs.pretest-tests.result == 'success' - || (inputs.skip_e2e && needs.pretest-tests.result == 'skipped')) - && (needs.pretest-e2e.result == 'success' - || (inputs.skip_e2e && needs.pretest-e2e.result == 'skipped')) + || (inputs.skip_pretests && needs.pretest-tests.result == 'skipped')) runs-on: ubuntu-latest environment: Production steps: @@ -354,12 +334,11 @@ jobs: name: Remove staging tag if build failed runs-on: ubuntu-latest environment: Production - needs: [prepare-build, pretest-tests, pretest-e2e, build-desktop, build-docker] + needs: [prepare-build, pretest-tests, build-desktop, build-docker] if: >- always() && needs.prepare-build.result == 'success' && (needs.pretest-tests.result == 'failure' || needs.pretest-tests.result == 'cancelled' - || needs.pretest-e2e.result == 'failure' || needs.pretest-e2e.result == 'cancelled' || needs.build-desktop.result == 'failure' || needs.build-desktop.result == 'cancelled' || needs.build-docker.result == 'failure' || needs.build-docker.result == 'cancelled') steps: diff --git a/.github/workflows/test-reusable.yml b/.github/workflows/test-reusable.yml index 2d39193808..29cc81c87d 100644 --- a/.github/workflows/test-reusable.yml +++ b/.github/workflows/test-reusable.yml @@ -34,6 +34,7 @@ on: permissions: contents: read + packages: read jobs: i18n-coverage: @@ -56,9 +57,9 @@ jobs: restore-keys: | pnpm-store-${{ runner.os }}- - name: Install dependencies - run: pnpm install --frozen-lockfile + run: bash scripts/ci-cancel-aware.sh pnpm install --frozen-lockfile - name: Verify i18n coverage (missing / extra / drifted / en.ts ↔ chunks) - run: pnpm i18n:check + run: bash scripts/ci-cancel-aware.sh pnpm i18n:check unit-tests: if: inputs.run_unit @@ -80,9 +81,9 @@ jobs: restore-keys: | pnpm-store-${{ runner.os }}- - name: Install dependencies - run: pnpm install --frozen-lockfile + run: bash scripts/ci-cancel-aware.sh pnpm install --frozen-lockfile - name: Run tests with coverage - run: pnpm test:coverage + run: bash scripts/ci-cancel-aware.sh pnpm test:coverage env: NODE_ENV: test - name: Upload coverage reports @@ -110,6 +111,7 @@ jobs: with: ref: ${{ inputs.ref }} fetch-depth: 1 + persist-credentials: false submodules: recursive - name: Cache Rust build artifacts uses: Swatinem/rust-cache@v2 @@ -120,13 +122,13 @@ jobs: - name: Install sccache uses: mozilla-actions/sccache-action@v0.0.9 - name: Test core crate (openhuman) - run: cargo test -p openhuman + run: bash scripts/ci-cancel-aware.sh cargo test -p openhuman rust-core-tests-windows: if: inputs.run_rust_core name: Rust Core Tests (Windows — secrets ACL) runs-on: windows-latest - timeout-minutes: 20 + timeout-minutes: 35 env: CARGO_INCREMENTAL: '0' SCCACHE_GHA_ENABLED: 'true' @@ -137,6 +139,7 @@ jobs: with: ref: ${{ inputs.ref }} fetch-depth: 1 + persist-credentials: false submodules: recursive - name: Cache Rust build artifacts uses: Swatinem/rust-cache@v2 @@ -147,10 +150,16 @@ jobs: - name: Install sccache uses: mozilla-actions/sccache-action@v0.0.9 - name: Run Windows-specific secrets tests - # Runs the full security::secrets suite including all #[cfg(windows)] - # tests: self-repair ACL path (OPENHUMAN-TAURI-GN), domain-qualified - # icacls username, is_permission_error, repair_windows_acl. - run: cargo test -p openhuman -- security::secrets --nocapture + # Runs the keyring::encrypted_store suite (keyring::encrypted_store::tests), + # which contains the #[cfg(windows)] tests: + # - self_repair_recovers_from_locked_key_file (OPENHUMAN-TAURI-GN) + # - self_repair_does_not_trigger_for_corrupt_file + # - is_permission_error_* (access denied, not-found, raw OS error 5) + # - qualify_windows_username_* (local, domain, case, empty env vars) + # Note: repair_windows_acl is a helper fn, not a test. + # security/secrets.rs is a one-line re-export with no tests of its own; + # the old filter (-- security::secrets) silently matched nothing. + run: bash scripts/ci-cancel-aware.sh cargo test -p openhuman -- keyring::encrypted_store --nocapture rust-tauri-tests: if: inputs.run_rust_tauri @@ -169,6 +178,7 @@ jobs: ref: ${{ inputs.ref }} fetch-depth: 1 # Required for app/src-tauri/vendor/tauri-cef. + persist-credentials: false submodules: recursive - name: Cache Rust build artifacts uses: Swatinem/rust-cache@v2 @@ -188,4 +198,4 @@ jobs: - name: Install sccache uses: mozilla-actions/sccache-action@v0.0.9 - name: Test Tauri shell (OpenHuman) - run: cargo test --manifest-path app/src-tauri/Cargo.toml + run: bash scripts/ci-cancel-aware.sh cargo test --manifest-path app/src-tauri/Cargo.toml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 56e5fdc1d5..71e8c4cd10 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,6 +13,7 @@ on: permissions: contents: read + packages: read pull-requests: read concurrency: diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index fc38a1b79e..dcc0344bd8 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -6,6 +6,7 @@ on: pull_request: permissions: contents: read + packages: read pull-requests: read concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.head_ref diff --git a/.gitignore b/.gitignore index fa01e8d694..c49daf3b8b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ remove_dupe_nonsense_issues # Diagnostic harness output (scripts/diagnose-cef-runtime.mjs) diagnosis-*.json +# Dev-mode keyring file backend (plain-text secrets, dev artifact only) +dev-keychain.json + # Logs logs *.log @@ -103,3 +106,5 @@ test-map.md # AI assistant progress tracking .kimi/ +.codex-tmp +*.enc diff --git a/AGENTS.md b/AGENTS.md index c45cf9b9a5..39e6809eaa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,6 +70,11 @@ pnpm rust:check # same as above # whisper-rs / llama.cpp on macOS Tahoe (Apple Silicon) fail with `-mcpu=native`. # Workaround for `cargo check`/`cargo test`: GGML_NATIVE=OFF cargo check --manifest-path Cargo.toml + +# PR maintenance +pnpm pr:sync-main --help # inspect options +pnpm pr:sync-main # dry-run: scan open PRs targeting main +pnpm pr:sync-main --execute # merge latest main into each matching PR branch and push ``` **Tests**: Vitest in `app/` (`pnpm test`, `pnpm test:coverage`); Rust via `pnpm test:rust` (runs `scripts/test-rust-with-mock.sh`). @@ -525,6 +530,8 @@ Follow this order so behavior is **specified**, **proven in Rust**, **proven ove - **Pre-merge checks** (when touching code): Prettier, ESLint, `tsc --noEmit` in `app/`; `cargo fmt` + `cargo check` for changed Rust (`Cargo.toml` at root and/or `app/src-tauri/Cargo.toml` as appropriate). - **No dynamic imports** in production **`app/src`** code — use **static** `import` / `import type` at the top of the module. Do **not** use `import()` (async dynamic import), `React.lazy(() => import(...))`, or `await import('…')` to load app modules, Tauri APIs, or RPC clients. **Why:** predictable chunk graph, simpler static analysis, fewer surprises in Tauri + Vite, and easier code review. **If a module must not run at load time** (e.g. heavy optional path), use a static import and **guard the call site** with `try/catch` or an explicit runtime check instead of deferring module load via dynamic import. **Exceptions:** Vitest harness patterns (`vi.importActual`, dynamic imports **only** inside `*.test.ts` / `__tests__` / `test/setup.ts` when required by the runner); ambient `typeof import('…')` in `.d.ts`; config files (e.g. `tailwind.config.js` JSDoc).- **Type-only imports**: `import type` where appropriate. - **Dual socket / tool sync**: If you change realtime protocol, keep **frontend** (`socketService` / MCP transport) and **core** socket behavior aligned (see [`gitbooks/developing/architecture.md`](gitbooks/developing/architecture.md) dual-socket section). +- **i18n for all UI text**: Every user-visible string in `app/src/**` (headings, labels, button text, placeholders, status chips, toasts, dialog copy, `aria-label`, etc.) must go through `useT()` from `app/src/lib/i18n/I18nContext`. Hard-coded literals in JSX or `label=`/`placeholder=`/`aria-label=` props are not allowed. Add the new key to [`app/src/lib/i18n/en.ts`](app/src/lib/i18n/en.ts) in the same PR — other locales fall back to English. **Exceptions:** developer-only debug logs, code identifiers, and non-display data (URLs, slugs, technical sentinel values). +- **i18n chunk files — update ALL locales**: The source-of-truth translation files are the **chunk files** under `app/src/lib/i18n/chunks/` (`en-{1..5}.ts` plus `-{1..5}.ts` for each locale). When adding or changing keys in `en.ts`, you **must also** add them to the corresponding English chunk file (`en-N.ts`) **and** to the same chunk number for every non-English locale (use the English value as a placeholder — translators fill in later). CI enforces parity via `pnpm i18n:check`; missing keys in any locale chunk will fail the i18n coverage gate. Locales: `ar`, `bn`, `de`, `es`, `fr`, `hi`, `id`, `it`, `ko`, `pt`, `ru`, `zh-CN`. --- diff --git a/CLAUDE.md b/CLAUDE.md index 627cf0ac81..08985a39d8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,27 @@ Commands assume the **repo root**; `pnpm dev` delegates to the `app` workspace. --- +## iOS client (experimental) + +The iOS client is an **in-progress, non-shipping** target in this repo. It does not ship a Rust core on-device; instead it connects to the desktop core via one of three transports selected by a `ConnectionProfile`. + +**Transport strategies** (see `app/src/services/transport/`): +- `LanHttpTransport` — direct HTTP to the desktop core on the same LAN. +- `TunnelTransport` — socket.io relay through the backend; E2E encrypted with XChaCha20-Poly1305 over X25519 key agreement. +- `CloudHttpTransport` — fallback via the cloud backend API. + +**Key paths:** +- PTT plugin: `packages/tauri-plugin-ptt/` (Swift + Rust, iOS-only). +- iOS screens: `app/src/pages/ios/` and `app/src/components/ios/`. +- Devices domain (Rust): `src/openhuman/devices/`. +- Tunnel crypto (TS): `app/src/lib/tunnel/`. +- iOS build entry: `pnpm tauri:ios:dev` — uses stock `@tauri-apps/cli@^2` via `npx`, **not** the vendored CEF CLI. +- Setup guide: `docs/ios/SETUP.md`. + +**Backend dependency:** `tinyhumansai/backend#709` (tunnel socket.io contract) must be merged and deployed for end-to-end pairing to work. + +--- + ## Commands (from repo root) ```bash @@ -100,6 +121,12 @@ PRs must meet **≥ 80% coverage on changed lines**. Enforced by [`.github/workf **Rust config** uses a TOML `Config` struct (`src/openhuman/config/schema/types.rs`) with env overrides (`src/openhuman/config/schema/load.rs`). +**Agent access mode** — the `[autonomy]` block (`src/openhuman/config/schema/autonomy.rs`) drives the agent's filesystem/shell reach via `SecurityPolicy` (`src/openhuman/security/policy.rs`). Tiers: `level` (`readonly` = read-only / `supervised` = "ask before edit" / `full` = full access) × `workspace_only` × `trusted_roots` (per-folder `read`/`readwrite` grants outside the workspace, overriding `forbidden_paths` for their subtree) × `allow_tool_install` (gates `install_tool`). Edit live via the `config.update_autonomy_settings` RPC or **Settings → Agent access** (`AgentAccessPanel.tsx`); changes swap the process-global policy in `security::live_policy` and apply to new sessions. The default projects home is `~/OpenHuman/projects` (`config::default_projects_dir`, env `OPENHUMAN_PROJECTS_DIR`), auto-created at startup and injected as a ReadWrite trusted root — distinct from the hidden internal `~/.openhuman/workspace`. + +**Command permission model (deterministic, fail-closed):** `classify_command` buckets a command into `CommandClass` (`Read` / `Write` / `Network` / `Install` / `Destructive`); an unrecognized command is **`Write`**, never `Read`. `gate_decision(class, tier)` → `Allow` / `Prompt` / `Block`: read-only allows only reads; ask-before-edit prompts every act (file *create* is free, *edit-existing* prompts); full runs read+write but **always-asks** Network/Install/Destructive. Acting tools (`shell`/`node_exec`/`npm_exec`/`file_write`/`edit_file`/`apply_patch`/`git_operations`/`curl`) return `external_effect_with_args() == true` for `Prompt` classes so the harness routes them through the `ApprovalGate` *before* `execute()`; read-only `Block` + structural guards (`check_gated_command`) are enforced in-tool. The LLM may pass a `category` (escalate-only: `max(rust_floor, declared)`). System/credential dirs are an **unconditional** cross-platform block (`is_always_forbidden`, trusted-root-proof). Enforcement is in Rust (`classify_command`/`gate_decision`/`check_gated_command`/`is_path_string_allowed`/`validate_path`), never the system prompt. + +> ⚠️ **The approval prompt is ON by default** (opt out with `OPENHUMAN_APPROVAL_GATE=0`/`false`, `jsonrpc.rs`). `ApprovalGate::init_global` installs unless disabled, so `try_global()` is `Some` and the prompt is wired end-to-end; with `OPENHUMAN_APPROVAL_GATE=0` the harness skips the intercept and `Prompt`-class calls **run unprompted**. The gate parks only for **interactive chat turns** (a `tokio` task-local chat context is set in `channels/providers/web.rs`; background triage/cron turns carry no context and are allowed through, not gated). It publishes `DomainEvent::ApprovalRequested`, which `ApprovalSurfaceSubscriber` bridges to the `approval_request` web-channel socket event; the frontend (`ChatApprovalRequestEvent` → `chatRuntime.pendingApprovalByThread` → `ApprovalRequestCard` above the composer) surfaces Approve/Deny, routing to the `openhuman.approval_decide` RPC. A typed `yes`/`no` chat reply is also honoured server-side (web.rs ingress router runs before the "newer request aborts the in-flight turn" path); any other text cancels the parked turn and is taken as a fresh message. Unanswered prompts still park to the 10-min TTL → Deny. Read-only blocking, path hardening, structural guards, and classification **are** live regardless of the flag. Full access ships as documented full-trust (not sandboxed). + --- ## Testing @@ -249,11 +276,30 @@ Tauri/Rust in the shell is a **delivery vehicle** (windowing, process lifecycle, ## Git workflow -- **Never write code on `main`.** Before making any code changes, fork a new branch off the latest `main` (`git fetch upstream && git checkout -b upstream/main`). All work happens on that feature branch; `main` stays clean and only advances via merged PRs. +This file is loaded into every contributor's Claude Code session, so the instructions below are written generically: `` means **your** GitHub username (the owner of your fork), not any specific maintainer. Adapt the literal commands accordingly. + +**One-time remote setup.** Contribute via your own fork of `tinyhumansai/openhuman`. Recommended remote layout: + +``` +origin git@github.com:/openhuman.git (your fork — push here) +upstream git@github.com:tinyhumansai/openhuman.git (fetch-only; never push) +``` + +If you cloned the upstream directly, fix it once: + +```bash +git remote rename origin upstream +git remote add origin git@github.com:/openhuman.git +git fetch upstream +``` + +See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the full new-contributor walkthrough. + +- **Never write code on `main`.** Before making any code changes, branch off the latest upstream `main` (`git fetch upstream && git checkout -b upstream/main`). All work happens on that feature branch; `main` stays clean and only advances via merged PRs. - Issues and PRs on upstream **[tinyhumansai/openhuman](https://github.com/tinyhumansai/openhuman)** — not a fork — unless explicitly told otherwise. - Issue templates: [`.github/ISSUE_TEMPLATE/feature.md`](.github/ISSUE_TEMPLATE/feature.md), [`.github/ISSUE_TEMPLATE/bug.md`](.github/ISSUE_TEMPLATE/bug.md). PR template: [`.github/PULL_REQUEST_TEMPLATE.md`](.github/PULL_REQUEST_TEMPLATE.md). AI-authored text should follow them verbatim. -- PRs target **`main`**. -- **Push branches to `origin` (the user's fork — `senamakel/openhuman`), never to `upstream` (`tinyhumansai/openhuman`).** PRs are still opened against `tinyhumansai/openhuman:main`, but with `--head senamakel:` so the source is the fork. Direct pushes to upstream pollute its branch list and skip code-review boundaries. Treat the `upstream` remote as fetch-only. +- PRs target **`main`** of `tinyhumansai/openhuman`. +- **Push branches to `origin` (your fork), never to `upstream` (`tinyhumansai/openhuman`).** PRs are opened against `tinyhumansai/openhuman:main` with `--head :` so the source is the fork. Direct pushes to upstream pollute its branch list and skip code-review boundaries. Treat the `upstream` remote as fetch-only. - **When the user asks you to push or open a PR, resolve blockers and push — don't prompt for permission.** If a pre-push hook fails on something unrelated to your changes (e.g. pre-existing breakage on `main` in code you didn't touch), push with `--no-verify` and call it out in the PR body. If the hook fails on your own changes, fix them and push again. Don't ask the user whether to bypass — just do the right thing and tell them what you did. --- @@ -300,6 +346,8 @@ Specify → prove in Rust → prove over RPC → surface in the UI → test. - **Pre-merge** (code changes): Prettier, ESLint, `tsc --noEmit` in `app/`; `cargo fmt` + `cargo check` for changed Rust. - **No dynamic imports** in production `app/src` code — static `import` / `import type` only. No `import()`, `React.lazy(() => import(...))`, `await import(...)`. For heavy optional paths, use a static import and guard the call site with `try/catch` or a runtime check. *Exceptions*: Vitest harness patterns in `*.test.ts` / `__tests__` / `test/setup.ts`; ambient `typeof import('…')` in `.d.ts`; config files (e.g. `tailwind.config.js` JSDoc). - **Dual socket sync**: when changing the realtime protocol, keep `socketService` / MCP transport aligned with core socket behavior (see `gitbooks/developing/architecture.md` dual-socket section). +- **i18n for all UI text**: every user-visible string in `app/src/**` (headings, labels, button text, placeholders, status chips, toasts, error messages, dialog copy) must go through `useT()` from `app/src/lib/i18n/I18nContext`. Hard-coded literals in JSX or `label=`/`placeholder=`/`aria-label=` props are not allowed. Add the key to [`app/src/lib/i18n/en.ts`](app/src/lib/i18n/en.ts) in the same PR — other locales fall back to English. Exceptions: developer-only debug logs, code identifiers, and non-display data (URLs, slugs, technical sentinel values). +- **i18n chunk files — update ALL locales**: the source-of-truth translation files are the **chunk files** under `app/src/lib/i18n/chunks/` (`en-{1..5}.ts` plus `-{1..5}.ts` for each locale). When adding or changing keys in `en.ts`, you **must also** add them to the corresponding English chunk file (`en-N.ts`) **and** to the same chunk number for every non-English locale (use the English value as a placeholder — translators fill in later). CI enforces parity via `pnpm i18n:check`; missing keys in any locale chunk will fail the i18n coverage gate. Locales: `ar`, `bn`, `de`, `es`, `fr`, `hi`, `id`, `it`, `ko`, `pt`, `ru`, `zh-CN`. --- @@ -307,5 +355,6 @@ Specify → prove in Rust → prove over RPC → surface in the UI → test. - **Vendored CEF-aware `tauri-cli`**: runtime is CEF; only the vendored CLI at `app/src-tauri/vendor/tauri-cef/crates/tauri-cli` bundles Chromium into `Contents/Frameworks/`. Stock `@tauri-apps/cli` produces a broken bundle (panic in `cef::library_loader::LibraryLoader::new`). `pnpm dev:app` and all `cargo tauri` scripts call `pnpm tauri:ensure` which runs [`scripts/ensure-tauri-cli.sh`](scripts/ensure-tauri-cli.sh). If overwritten, reinstall with `cargo install --locked --path app/src-tauri/vendor/tauri-cef/crates/tauri-cli`. - **macOS deep links**: often require a built `.app` bundle, not just `tauri dev`. +- **Windows deep links**: `openhuman://` is registered to `HKCU\Software\Classes\openhuman\shell\open\command` by `tauri-plugin-deep-link::register_all` at first launch (per-user, no UAC). The Tauri shell now reads that key back after `register_all` returns and emits `log::error!` with the actual state (`NotRegistered` / `MissingCommand` / `Stale` / `ReadError`) when the value is missing or doesn't point at the running exe — without it, OAuth callbacks via `openhuman://auth?…` never reach the app (issue #2699). The check lives in [`app/src-tauri/src/deep_link_registration_check.rs`](app/src-tauri/src/deep_link_registration_check.rs); a manual repair script for affected users is in [`gitbooks/overview/troubleshooting-sign-in.md`](gitbooks/overview/troubleshooting-sign-in.md). - **Tauri environment guard**: use `isTauri()` (from `app/src/services/webviewAccountService.ts`) or wrap `invoke(...)` in `try/catch`; do not check `window.__TAURI__` directly — it is not present at module load and bypasses the established wrapper contract. - **Core is in-process** (no sidecar): `core_rpc` reaches the embedded server at `http://127.0.0.1:/rpc` with bearer auth via `OPENHUMAN_CORE_TOKEN`. `scripts/stage-core-sidecar.mjs` no longer exists; `pnpm core:stage` is a no-op echo. To run the core standalone for debugging, use `./target/debug/openhuman-core serve` (token at `{workspace}/core.token`, default `~/.openhuman-staging/core.token` under `OPENHUMAN_APP_ENV=staging`). diff --git a/Cargo.lock b/Cargo.lock index 562f507f88..7c7855737e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -517,6 +517,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base58ck" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8d66485a3a2ea485c1913c4572ce0256067a5377ac8c75c4960e1cda98605f" +dependencies = [ + "bitcoin-internals", + "bitcoin_hashes", +] + [[package]] name = "base64" version = "0.21.7" @@ -541,6 +551,12 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + [[package]] name = "bincode" version = "2.0.1" @@ -581,6 +597,54 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "bitcoin" +version = "0.32.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf93e61f2dbc3e3c41234ca26a65e2c0b0975c52e0f069ab9893ebbede584d3" +dependencies = [ + "base58ck", + "bech32 0.11.1", + "bitcoin-internals", + "bitcoin-io", + "bitcoin-units", + "bitcoin_hashes", + "hex-conservative", + "hex_lit", + "secp256k1", +] + +[[package]] +name = "bitcoin-internals" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30bdbe14aa07b06e6cfeffc529a1f099e5fbe249524f8125358604df99a4bed2" + +[[package]] +name = "bitcoin-io" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" + +[[package]] +name = "bitcoin-units" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346568ebaab2918487cea76dd55dae13c27bb618cdb737c952e69eb2017c4118" +dependencies = [ + "bitcoin-internals", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" +dependencies = [ + "bitcoin-io", + "hex-conservative", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -1011,7 +1075,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5286a0843c21f8367f7be734f89df9b822e0321d8bcce8d6e735aadff7d74979" dependencies = [ "base64 0.21.7", - "bech32", + "bech32 0.9.1", "bs58", "digest 0.10.7", "generic-array", @@ -2861,6 +2925,21 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hex_lit" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" + [[package]] name = "hkdf" version = "0.12.4" @@ -3599,6 +3678,21 @@ dependencies = [ "cpufeatures 0.2.17", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "linux-keyutils", + "log", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zeroize", +] + [[package]] name = "konst" version = "0.2.20" @@ -3734,6 +3828,16 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-keyutils" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590" +dependencies = [ + "bitflags 2.11.1", + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -4398,7 +4502,7 @@ dependencies = [ "openssl-probe", "openssl-sys", "schannel", - "security-framework", + "security-framework 3.7.0", "security-framework-sys", "tempfile", ] @@ -4956,7 +5060,7 @@ dependencies = [ [[package]] name = "openhuman" -version = "0.54.7" +version = "0.57.1" dependencies = [ "aes-gcm", "anyhow", @@ -4966,24 +5070,30 @@ dependencies = [ "async-trait", "axum", "base64 0.22.1", + "bitcoin", "block2 0.6.2", + "bs58", "chacha20poly1305", "chrono", "chrono-tz", "clap", "clap_complete", + "coins-bip39", "console", "cpal", "cron", + "curve25519-dalek", "dialoguer", "directories", "dirs 5.0.1", "dotenvy", + "ed25519-dalek", "enigo", "env_logger", "ethers-core", "ethers-signers", "fantoccini", + "filetime", "flate2", "fs2", "futures", @@ -4995,6 +5105,7 @@ dependencies = [ "hound", "iana-time-zone", "image", + "keyring", "landlock", "lettre", "log", @@ -5019,6 +5130,7 @@ dependencies = [ "regex", "reqwest 0.12.28", "ring", + "ripemd", "rppal", "rusqlite", "rustls", @@ -5029,6 +5141,7 @@ dependencies = [ "serde-big-array", "serde_json", "serde_yaml", + "sha1", "sha2 0.10.9", "shellexpand", "socketioxide", @@ -5048,6 +5161,7 @@ dependencies = [ "tracing-appender", "tracing-log", "tracing-subscriber", + "unicode-normalization", "unicode-segmentation", "unicode-width", "url", @@ -5061,7 +5175,9 @@ dependencies = [ "whatsapp-rust-tokio-transport", "whatsapp-rust-ureq-http-client", "whisper-rs", + "windows-sys 0.61.2", "wiremock", + "x25519-dalek", "xz2", "zip", ] @@ -6583,7 +6699,7 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.7.0", ] [[package]] @@ -6611,7 +6727,7 @@ dependencies = [ "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki", - "security-framework", + "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", "windows-sys 0.61.2", @@ -6775,6 +6891,39 @@ dependencies = [ "zeroize", ] +[[package]] +name = "secp256k1" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" +dependencies = [ + "bitcoin_hashes", + "rand 0.8.6", + "secp256k1-sys", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.9.4", + "core-foundation-sys 0.8.7", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.7.0" diff --git a/Cargo.toml b/Cargo.toml index 799c284deb..92cc7f9b0c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openhuman" -version = "0.54.7" +version = "0.57.1" edition = "2021" description = "OpenHuman core business logic and RPC server" autobins = false @@ -25,6 +25,10 @@ path = "src/bin/memory_tree_init_smoke.rs" name = "inference-probe" path = "src/bin/inference_probe.rs" +[[bin]] +name = "test-mcp-stub" +path = "src/bin/test_mcp_stub.rs" + [lib] name = "openhuman_core" crate-type = ["rlib"] @@ -56,6 +60,9 @@ argon2 = "0.5" rand = "0.10" dirs = "5" sha2 = "0.10" +# Legacy SHA-1 only used for Tencent COS HMAC-SHA1 signing (yuanbao +# channel media upload). Not used for any new security-sensitive work. +sha1 = "0.10" hmac = "0.12" # Archive extraction for the Node.js runtime bootstrap. Unix Node # distributions ship as .tar.xz, Windows as .zip. `xz2` with `static` @@ -74,6 +81,7 @@ uuid = { version = "1", features = ["v4"] } anyhow = "1.0" async-trait = "0.1" chacha20poly1305 = "0.10" +x25519-dalek = { version = "2", features = ["static_secrets"] } hex = "0.4" tokio-util = { version = "0.7", features = ["rt", "io"] } # tokio-tungstenite is declared per-target below so the TLS backend @@ -109,12 +117,19 @@ walkdir = "2" glob = "0.3" unicode-segmentation = "1" unicode-width = "0.2" +# NFKC + combining-mark detection for the cross-thread search inverted +# index (`memory_conversations::tokenize`). NFKC unifies CJK half/full- +# width variants and Arabic presentation forms; `canonical_combining_class` +# lets us strip diacritics across all scripts (Polish ą→a, Arabic harakat, +# Hebrew niqqud, etc.) without per-language tables. +unicode-normalization = "0.1" hostname = "0.4.2" rustls = { version = "0.23", features = ["ring"] } rustls-pki-types = "1.14.0" tokio-rustls = "0.26.4" webpki-roots = "1.0.6" sysinfo = { version = "0.33", default-features = false, features = ["system"] } +keyring = { version = "3", features = ["apple-native", "windows-native", "linux-native"] } clap = { version = "4.5", features = ["derive"] } clap_complete = "4.5" lettre = { version = "0.11.22", default-features = false, features = ["builder", "smtp-transport", "rustls-tls"] } @@ -145,6 +160,22 @@ fs2 = "0.4" starship-battery = "0.10" ethers-core = { version = "2.0.14", default-features = false } ethers-signers = { version = "2.0.14", default-features = false } +# Multi-chain wallet signing. +# - bitcoin: P2WPKH PSBT build/sign/broadcast (includes secp256k1). +# - ed25519-dalek: Solana transaction signing. +# - bs58: Solana base58 addresses + Tron base58check addresses. +# - ripemd: RIPEMD160 for BTC HASH160 (P2WPKH) and Tron address hash. +bitcoin = { version = "0.32", default-features = false, features = ["std", "secp-recovery", "rand-std"] } +ed25519-dalek = { version = "2", default-features = false, features = ["std", "rand_core"] } +bs58 = { version = "0.5", default-features = false, features = ["std", "check"] } +ripemd = "0.1" +# Shared BIP-39 mnemonic → seed for non-EVM chains (BTC P2WPKH derivation, +# Tron secp256k1 derivation, Solana ed25519 SLIP-0010 derivation). Same crate +# ethers-signers uses internally, exposed as a direct dep so we can derive +# off the recovery phrase without going through the EVM signer wrapper. +coins-bip39 = "0.8" +# Solana off-curve check for ATA derivation (find_program_address). +curve25519-dalek = { version = "4", default-features = false, features = ["alloc"] } matrix-sdk = { version = "0.16", optional = true, default-features = false, features = ["e2e-encryption", "rustls-tls", "markdown"] } fantoccini = { version = "0.22.0", optional = true, default-features = false, features = ["rustls-tls"] } @@ -165,6 +196,19 @@ wacore = { version = "0.5", optional = true, default-features = false } # connections honor the Windows cert store, including corporate CAs # installed by AV / TLS-inspection proxies. See run-dev-win.sh notes. tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "handshake", "native-tls"] } +# AppContainer / process-jail backend in `openhuman::cwd_jail`. +# Feature list mirrors the Win32 surface used by cwd_jail/windows.rs: +# AppContainer profile APIs, ACL editing, STARTUPINFOEXW process spawn, +# and the GENERIC_* file access masks. +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Security_Authorization", + "Win32_Security_Isolation", + "Win32_Storage_FileSystem", + "Win32_System_Memory", + "Win32_System_Threading", +] } [target.'cfg(not(windows))'.dependencies] # macOS / Linux: keep rustls + Mozilla webpki-roots — the historical @@ -193,6 +237,8 @@ rppal = { version = "0.22", optional = true } sentry = { version = "0.47.0", default-features = false, features = ["test"] } # Mock HTTP server for provider E2E tests (inference_provider_e2e). wiremock = "0.6" +# Used in json_rpc_e2e to backdate mtime on stale lock files. +filetime = "0.2" [features] sandbox-landlock = ["dep:landlock"] diff --git a/Dockerfile b/Dockerfile index 526f02c8c6..84f630f74d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,13 @@ # ========================================================================== FROM rust:1.93-bookworm AS builder -ENV DEBIAN_FRONTEND=noninteractive +# Docker builds often run on small VPS/CI builders. The crate's `ci` profile +# keeps peak rustc memory lower than `release`; override with +# `--build-arg CARGO_PROFILE=release` when maximum runtime optimization matters. +ARG CARGO_PROFILE=ci +ARG CARGO_BUILD_JOBS=1 +ENV DEBIAN_FRONTEND=noninteractive \ + CARGO_BUILD_JOBS=${CARGO_BUILD_JOBS} # System dependencies required for compilation. # @@ -43,14 +49,15 @@ COPY Cargo.toml Cargo.lock rust-toolchain.toml ./ RUN mkdir -p src && \ echo 'fn main() {}' > src/main.rs && \ echo 'pub fn run_core_from_args(_: &[String]) -> anyhow::Result<()> { Ok(()) }' > src/lib.rs && \ - cargo build --release --bin openhuman-core 2>/dev/null || true && \ + cargo build --profile "${CARGO_PROFILE}" --bin openhuman-core 2>/dev/null || true && \ rm -rf src # Copy actual source and build COPY src/ src/ # Touch main.rs to force rebuild of our code (not deps) RUN touch src/main.rs src/lib.rs && \ - cargo build --release --bin openhuman-core + cargo build --profile "${CARGO_PROFILE}" --bin openhuman-core && \ + cp "target/${CARGO_PROFILE}/openhuman-core" /tmp/openhuman-core # ========================================================================== # Stage 2: Minimal runtime image @@ -84,13 +91,17 @@ RUN mkdir -p /home/openhuman/.openhuman \ && chown -R openhuman:openhuman /home/openhuman # Copy the built binary -COPY --from=builder /build/target/release/openhuman-core /usr/local/bin/openhuman-core +COPY --from=builder /tmp/openhuman-core /usr/local/bin/openhuman-core # Copy the entrypoint script that chowns the workspace volume before dropping # privileges. The script is a separate file so the E2E entrypoint # (e2e/docker-entrypoint.sh) is not affected. COPY scripts/docker-entrypoint-core.sh /usr/local/bin/docker-entrypoint-core.sh -RUN chmod +x /usr/local/bin/docker-entrypoint-core.sh +# Windows checkouts may materialize shell scripts with CRLF line endings when +# core.autocrlf is enabled. A CRLF shebang makes Linux report the executable +# as "no such file or directory" at container startup, so normalize in-image. +RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint-core.sh \ + && chmod +x /usr/local/bin/docker-entrypoint-core.sh # The entrypoint runs as root so it can chown the mounted volume, then execs # gosu to drop to the openhuman user before starting the binary. diff --git a/PR_DESCRIPTION.md b/PR_DESCRIPTION.md new file mode 100644 index 0000000000..5b4a9b8135 --- /dev/null +++ b/PR_DESCRIPTION.md @@ -0,0 +1,148 @@ +## Summary + +- Adds an iOS client for OpenHuman: device pairing via QR code, mascot chat screen, and push-to-talk voice input. +- No Rust core ships on device; the iOS app connects to the desktop core via LAN HTTP, an E2E-encrypted socket.io tunnel, or cloud HTTP fallback. +- All changes are cfg-gated or platform-guarded; the desktop build is unaffected. +- Adds the `tauri-plugin-ptt` Swift plugin (`packages/tauri-plugin-ptt/`) for AVAudioEngine + SFSpeechRecognizer on iOS. +- Adds CI sanity-check workflow, build scripts, capability catalog entries, and full docs. + +## Problem + +Users with iOS devices had no way to interact with their OpenHuman assistant on the go. The desktop app required a local machine. This PR adds the client-side scaffolding and transport layer needed to bridge iOS to an existing desktop core. + +## Solution + +The iOS app is a subset of the existing React/TypeScript UI, compiled by Tauri v2 into an iOS bundle. A `TransportManager` selects the best transport at runtime. Pairing is secured by an X25519 key agreement; all tunnel traffic uses XChaCha20-Poly1305 encryption. The backend is a blind socket.io forwarder -- it never sees plaintext. + +## Layer-by-layer commits + +| Commit | Layer | Summary | +|--------|-------|---------| +| `a99537f3` | Layer 1 | Rust devices domain -- pairing store, RPC handlers, event bus, crypto (`src/openhuman/devices/`) | +| `4ea14b78` | Layer 2 | TS transport refactor -- `TransportManager`, `LanHttpTransport`, `TunnelTransport`, `CloudHttpTransport`, tunnel crypto (`app/src/services/transport/`, `app/src/lib/tunnel/`) | +| `ba651705` | Layer 3 | Desktop `/settings/devices` UI -- `DevicesPanel`, `PairPhoneModal` with QR generation and 2-second poll | +| `3e0e2a67` | Layer 4 | Tauri shell cfg-gating -- `#[cfg(target_os = "ios")]` guards on CEF-specific code | +| `621fec98` | Layer 5 | iOS app shell -- `PairScreen` (QR scan via `AVCaptureSession`), `MascotScreen` (chat UI) | +| `5ca6cf21` | Layer 6 | `tauri-plugin-ptt` -- Swift PTT plugin (AVAudioEngine, SFSpeechRecognizer, AVSpeechSynthesizer) | +| `41a6a895` | Layer 6 fix | PTT Swift fix -- latest transcript tracking + `@unchecked Sendable` on PTTSpeaker | +| _(this PR)_ | Layers 7+8 | Build scripts, CI, Info.plist, capability catalog, docs, quality pass | + +## Test coverage + +- **Vitest:** 1957 passed, 3 skipped, 1 todo across 218 test files (includes transport, tunnel, devices, iOS, PTT suites). +- **Rust (about_app):** 20 passed -- validates catalog uniqueness, Mobile category, and new capability entries. +- **cargo check (all three Cargo.toml files):** clean (warnings only, pre-existing). + +## What is gated behind the iOS target + +The following only activates on `cfg(target_os = "ios")` or when explicitly called from iOS screens: + +- CEF exclusions in `app/src-tauri/` (accounts webviews, etc.) +- `tauri-plugin-ptt` commands (`start_listening`, `stop_listening`, `speak`, `cancel_speech`, `list_voices`) -- return `NotSupported` on non-iOS targets. +- `packages/tauri-plugin-ptt/ios/` Swift sources -- not compiled for desktop. + +Desktop users see no change. + +## Known TODOs for follow-up PRs + +- **Keychain migration:** iOS symmetric session key is in-memory only; persist to Keychain so the app reconnects after restart without re-pairing. +- **Event-driven pairing detection:** `PairPhoneModal` polls `devices_list` every 2 s. Switch to a socket event subscription when the SSE/socket bridge for `DomainEvent::DevicePaired` lands. +- **Full Xcode CI:** `cargo check --target aarch64-apple-ios` runs with `continue-on-error: true` in the new CI workflow because third-party C deps (cef-dll-sys) may fail without full Xcode on the runner. A follow-up should pin an Xcode-enabled runner and harden this to a hard gate. +- **APNs push notifications:** real-time delivery requires the app to be foregrounded. +- **Multi-region tunnel:** single backend instance only; no failover. +- **Info.plist automation:** developer must manually copy `Info.ios.plist` keys into the generated Xcode project after `tauri ios init`. Should automate via `bundle.iOS.template` once Tauri v2 stabilises the iOS template pipeline. + +## Backend dependency + +**`tinyhumansai/backend#709` must be merged and deployed before end-to-end pairing works.** The `devices_create_pairing` RPC will return a tunnel registration error until the `tunnel:register` / `tunnel:connect` / `tunnel:frame` socket.io contract is live. + +## Manual test plan for iOS reviewer + +_(Requires a physical iPhone or iOS 17+ simulator paired with the desktop app.)_ + +From `packages/tauri-plugin-ptt/README.md`: + +- [ ] Permissions dialog appears on first `startListening` call. +- [ ] Partial transcripts update while speaking; final transcript matches. +- [ ] Hold button to record, release to stop, chat message is sent with transcript. +- [ ] TTS plays through speaker by default when iPhone is held away from ear. +- [ ] BT headset routes audio correctly; disconnecting mid-recording stops gracefully. +- [ ] App backgrounded mid-record produces a final transcript and stops cleanly. +- [ ] Phone call interruption emits `ptt://error` with `code: interrupted`. +- [ ] `cancelSpeech` during TTS emits `tts-ended` with `finished: false`. +- [ ] `listVoices` returns non-empty list of `AVSpeechSynthesisVoice` entries. + +Additional pairing flow checks: + +- [ ] Desktop: Settings > Devices > "Pair iPhone" shows QR code. +- [ ] iOS app: PairScreen scans QR and transitions to MascotScreen after handshake. +- [ ] Desktop: Devices panel lists the paired device with correct label. +- [ ] Desktop: Revoke device removes it from the list; iOS app shows reconnect prompt. +- [ ] QR code expiry: code expires after TTL, "Generate new code" creates a fresh session. + +## Screenshots + +> **PLACEHOLDER:** Before opening the PR, attach screenshots of: +> - Desktop `/settings/devices` panel with a paired device. +> - iOS mascot screen showing a conversation. +> +> These require a device with Xcode signing configured and `tinyhumansai/backend#709` deployed. + +## Submission Checklist + +- [x] Tests added or updated (transport, tunnel, devices, iOS, PTT suites -- see coverage statement above). +- [x] Diff coverage note: new Rust code in `src/openhuman/devices/` was covered in Layer 1 tests; new TS code in `app/src/services/transport/` and `app/src/lib/tunnel/` covered by Vitest suites. PTT Swift layer cannot be unit-tested without iOS toolchain (noted in README). +- [x] Coverage matrix: N/A for this layer (build scripts, CI, docs, catalog). +- [x] No new external network dependencies (all transport calls use existing mock backend or real backend behind feature flag). +- [ ] Manual smoke checklist: iOS path not in `docs/RELEASE-MANUAL-SMOKE.md` yet -- tracked as follow-up. +- [ ] Linked issue: N/A (tracked via Linear). + +## Impact + +- Desktop runtime: no change. +- iOS target: new experimental app bundle (not in release pipeline yet). +- `packages/tauri-plugin-ptt/` is a new crate workspace member; adds to build time only when targeting iOS. +- Capability catalog adds three new `mobile.*` entries and a new `Mobile` category. + +## Related + +- Closes: N/A (new feature) +- Follow-up PR(s): Keychain migration, event-driven pairing, full Xcode CI, APNs. +- Backend: tinyhumansai/backend#709 + +--- + +## AI Authored PR Metadata (required for Codex/Linear PRs) + +### Linear Issue +- Key: N/A +- URL: N/A + +### Commit & Branch +- Branch: `feat/ios-client` +- Commit SHA: _(set after final commit)_ + +### Validation Run +- [x] `pnpm --filter openhuman-app format:check` -- clean +- [x] `pnpm typecheck` -- clean +- [x] Focused tests: Vitest 1957 passed; cargo about_app 20 passed +- [x] Rust fmt/check: `cargo fmt --all` + `cargo check` on all three Cargo.toml -- clean +- [x] Tauri fmt/check: included above + +### Validation Blocked +- command: `cargo check --target aarch64-apple-ios` +- error: May fail on cef-dll-sys C deps without full Xcode; guarded with `continue-on-error: true` in CI. +- impact: Soft gate only; does not block merge. + +### Behavior Changes +- Intended behavior change: Desktop users see new Settings > Devices panel. iOS users can pair and chat. +- User-visible effect: Desktop gains device management UI. iOS app becomes available for sideloading/TestFlight. + +### Parity Contract +- Legacy behavior preserved: All existing desktop flows unaffected. No CEF injection added. No new JS injection in webview accounts. +- Guard/fallback/dispatch parity: PTT commands return `NotSupported` on non-iOS. Transport falls back gracefully. + +### Duplicate / Superseded PR Handling +- Duplicate PR(s): None +- Canonical PR: This PR +- Resolution: N/A diff --git a/README.de.md b/README.de.md index edfdf64824..cc6acf6154 100644 --- a/README.de.md +++ b/README.de.md @@ -20,7 +20,7 @@

- OpenHuman ist deine persönliche KI-Superintelligenz. Privat, schlicht und extrem mächtig. + OpenHuman ist deine persönliche KI-Superintelligenz: Lokaler Speicher, verwaltete Dienste wo nötig, schlicht und mächtig.

@@ -46,6 +46,8 @@ > **Frühe Beta**: Wird aktiv weiterentwickelt. Mit Ecken und Kanten ist zu rechnen. +> **Lokal + verwaltete Dienste, upfront:** OpenHuman speichert seinen Memory Tree, Obsidian-Style-Markdown-Vault, Workspace-Konfiguration und lokalen Laufzeitstatus auf deiner Maschine. Die standardmäßige verwaltete Erfahrung nutzt weiterhin OpenHuman-gehostete Dienste für Account-Anmeldung, Model-Routing, Web-Search-Proxying und verwaltete Integration/OAuth-Flows über die Composio-Connector-Schicht. Wähle benutzerdefinierte/lokale Einstellungen, wenn du dein eigenes Modell, deine eigene Suche oder Composio-Credentials mitbringen möchtest; einige Echtzeit-Trigger und gehostete Funktionen erfordern weiterhin das verwaltete Backend. + Für Installation und Einstieg lade die App von [tinyhumans.ai/openhuman](https://tinyhumans.ai/openhuman?utm_source=github&utm_medium=readme) herunter oder führe im Terminal aus: ```bash @@ -66,9 +68,11 @@ OpenHuman ist ein quelloffener, agentenbasierter Assistent, der sich in deinen A - **[118+ Drittanbieter-Integrationen](https://tinyhumans.gitbook.io/openhuman/features/integrations) mit [Auto-Fetch](https://tinyhumans.gitbook.io/openhuman/features/obsidian-wiki/auto-fetch)**: Gmail, Notion, GitHub, Slack, Stripe, Calendar, Drive, Linear, Jira und der Rest deines Stacks per **Ein-Klick-OAuth** anbinden. Jede Verbindung wird dem Agenten als typisiertes Tool freigegeben, und alle zwanzig Minuten geht der Core durch jede aktive Verbindung und zieht frische Daten in den [Memory Tree](https://tinyhumans.gitbook.io/openhuman/features/integrations/auto-fetch). Keine Prompts, keine Polling-Schleifen, die du selbst schreiben musst — der Agent hat morgens schon den Kontext für den Tag. + Verwaltete Integrationen nutzen OpenHumans Composio-Connector-Schicht. OAuth-Handshakes und Integration-Tool-Calls werden standardmäßig über das verwaltete Backend geproxied. Wenn du stattdessen Composio direkt betreiben möchtest, konfiguriere den Direktmodus mit deinem eigenen Composio-API-Key; Echtzeit-Trigger-Webhooks müssen dann von dir selbst gehostet und verkabelt werden. + - **[Memory Tree](https://tinyhumans.gitbook.io/openhuman/features/memory-tree) + [Obsidian-Wiki](https://tinyhumans.gitbook.io/openhuman/features/obsidian-wiki)**: eine lokal-zentrierte Wissensbasis, aufgebaut aus deinen Daten und deinen Aktivitäten. Alles, was du verbindest, wird in Markdown-Chunks von ≤3k Tokens kanonisiert, bewertet und in hierarchische Zusammenfassungs-Bäume gefaltet, gespeichert in **SQLite auf deiner Maschine**. Dieselben Chunks landen als `.md`-Dateien in einem Obsidian-kompatiblen Vault, das du öffnen, durchstöbern und editieren kannst — inspiriert von Karpathys [obsidian-wiki-Workflow](https://x.com/karpathy/status/2039805659525644595). -- **Alles eingebaut**: Web-Suche, ein Web-Fetch-[Scraper](https://tinyhumans.gitbook.io/openhuman/features/native-tools), ein vollständiges Coder-Toolset (Dateisystem, Git, Lint, Test, Grep) und [native Sprache](https://tinyhumans.gitbook.io/openhuman/features/voice) (STT als Eingabe, ElevenLabs TTS als Ausgabe, Lippensynchronisation für das Maskottchen, Live-Google-Meet-Agent) sind ab Werk verdrahtet. [Model-Routing](https://tinyhumans.gitbook.io/openhuman/features/model-routing) schickt jede Aufgabe an das passende LLM (Reasoning, Fast oder Vision) — alles unter einem Abo. Keine "erst-ein-Plugin-installieren-um-Dateien-zu-lesen"-Hürde. [Optional lokale KI über Ollama](https://tinyhumans.gitbook.io/openhuman/features/model-routing/local-ai) für On-Device-Workloads. +- **Alles eingebaut**: Web-Suche, ein Web-Fetch-[Scraper](https://tinyhumans.gitbook.io/openhuman/features/native-tools), ein vollständiges Coder-Toolset (Dateisystem, Git, Lint, Test, Grep) und [native Sprache](https://tinyhumans.gitbook.io/openhuman/features/voice) (STT als Eingabe, ElevenLabs TTS als Ausgabe, Lippensynchronisation für das Maskottchen, Live-Google-Meet-Agent) sind ab Werk verdrahtet. Standardmäßig nutzt [Model-Routing](https://tinyhumans.gitbook.io/openhuman/features/model-routing) das OpenHuman-Backend, um das passende LLM für jede Workload auszuwählen und zu proxien (Reasoning, Fast oder Vision). Ein Abo umfasst alle Modelle. Keine "erst-ein-Plugin-installieren-um-Dateien-zu-lesen"-Hürde. [Optional lokale KI über Ollama](https://tinyhumans.gitbook.io/openhuman/features/model-routing/local-ai) für On-Device-Workloads. - **[Smarte Token-Kompression (TokenJuice)](https://tinyhumans.gitbook.io/openhuman/features/token-compression)**: Jeder Tool-Aufruf, jedes Scrape-Ergebnis, jeder E-Mail-Text und jeder Such-Payload läuft durch eine Token-Kompressionsschicht, bevor er ein LLM-Modell erreicht. HTML wird zu Markdown konvertiert, lange URLs werden gekürzt, und ausschweifende Tool-Ausgaben werden über eine konfigurierbare Regel-Ebene dedupliziert und zusammengefasst usw. CJK, Emojis und andere Multi-Byte-Texte bleiben Graphem für Graphem erhalten — niemals abgeschnitten. Du erhältst dieselbe Information bei einem Bruchteil der Tokens. Kosten und Latenz sinken um bis zu 80%. diff --git a/README.ja-JP.md b/README.ja-JP.md index 1e74129cd6..06c007c159 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -16,7 +16,7 @@

- OpenHuman はあなたのパーソナル AI スーパーインテリジェンスです。プライベートで、シンプルで、非常に強力。 + OpenHuman はあなたのパーソナル AI スーパーインテリジェンスです:ローカルメモリ、必要に応じてマネージドサービス、シンプルで強力。

@@ -46,6 +46,8 @@ > **早期ベータ版**: 現在も活発に開発中です。荒削りな部分があることをご了承ください。 +> **ローカル + マネージドサービス、upfront:** OpenHuman は Memory Tree、Obsidian スタイルの Markdown ヴォルト、ワークスペース設定、およびローカルランタイム状態をあなたのマシン上に保存します。デフォルトのマネージド体験では、アカウントサインイン、モデルルーティング、Web 検索プロキシ、および Composio コネクタレイヤーを介したマネージド統合/OAuth フローに、OpenHuman ホスト型サービスが引き続き使用されます。独自のモデル、検索、または Composio 認証情報を持ち込みたい場合は、カスタム/ローカル設定を選択してください。一部のリアルタイムトリガーおよびホスト型機能には、マネージドバックエンドが引き続き必要です。 + インストールや利用開始は、ウェブサイト [tinyhumans.ai/openhuman](https://tinyhumans.ai/openhuman?utm_source=github&utm_medium=readme) からダウンロードするか、以下のコマンドを実行してください。 ```bash @@ -66,9 +68,11 @@ OpenHuman は、あなたの日常生活に統合されるよう設計された - **[118+ のサードパーティ統合](https://tinyhumans.gitbook.io/openhuman/features/integrations) と [自動取得](https://tinyhumans.gitbook.io/openhuman/features/obsidian-wiki/auto-fetch)**: Gmail、Notion、GitHub、Slack、Stripe、Calendar、Drive、Linear、Jira などのスタックに **ワンクリック OAuth** で接続できます。すべての接続は型付きツールとしてエージェントに公開され、20 分ごとにコアがアクティブな各接続を巡回し、最新データを[メモリーツリー](https://tinyhumans.gitbook.io/openhuman/features/integrations/auto-fetch)に取り込みます。プロンプトも、自分で書くポーリングループも不要なので、エージェントは今朝の時点で明日のコンテキストを既に持っています。 + マネージド統合は OpenHuman の Composio コネクタレイヤーを使用します。OAuth ハンドシェイクおよび統合ツール呼び出しは、デフォルトでマネージドバックエンドを介してプロキシされます。代わりに Composio を直接実行したい場合は、独自の Composio API キーでダイレクトモードを構成してください。リアルタイムトリガーの Webhook は、その後あなたがホストして配線する必要があります。 + - **[Memory Tree](https://tinyhumans.gitbook.io/openhuman/features/memory-tree) + [Obsidian Wiki](https://tinyhumans.gitbook.io/openhuman/features/obsidian-wiki)**: あなたのデータとアクティビティから構築されるローカルファーストのナレッジベースです。接続したすべての情報は ≤3k トークンの Markdown チャンクへ正規化され、スコアリングされ、階層的なサマリーツリーに畳み込まれて **あなたのマシン上の SQLite** に保存されます。同じチャンクは Obsidian 互換のボルトに `.md` ファイルとして配置され、開いて閲覧・編集できます。Karpathy 氏の [obsidian-wiki ワークフロー](https://x.com/karpathy/status/2039805659525644595)にインスパイアされています。 -- **電池同梱(Batteries included)**: ウェブ検索、ウェブフェッチ用[スクレイパー](https://tinyhumans.gitbook.io/openhuman/features/native-tools)、フルコーダーツールセット(ファイルシステム、git、lint、test、grep)、そして[ネイティブ音声](https://tinyhumans.gitbook.io/openhuman/features/voice)(STT 入力、ElevenLabs TTS 出力、マスコットのリップシンク、ライブ Google Meet エージェント)がデフォルトで組み込まれています。[モデルルーティング](https://tinyhumans.gitbook.io/openhuman/features/model-routing)が各タスクを適切な LLM(reasoning、fast、または vision)に振り分け、一つのサブスクリプションで提供します。「ファイル読み込みのためにプラグインをインストール」という煩わしさはありません。デバイス上のワークロード向けに [Ollama によるオプショナルなローカル AI](https://tinyhumans.gitbook.io/openhuman/features/model-routing/local-ai) も利用できます。 +- **電池同梱(Batteries included)**: ウェブ検索、ウェブフェッチ用[スクレイパー](https://tinyhumans.gitbook.io/openhuman/features/native-tools)、フルコーダーツールセット(ファイルシステム、git、lint、test、grep)、そして[ネイティブ音声](https://tinyhumans.gitbook.io/openhuman/features/voice)(STT 入力、ElevenLabs TTS 出力、マスコットのリップシンク、ライブ Google Meet エージェント)がデフォルトで組み込まれています。デフォルトで、[モデルルーティング](https://tinyhumans.gitbook.io/openhuman/features/model-routing)は OpenHuman バックエンドを使用して各ワークロードに適切な LLM(reasoning、fast、または vision)を選択およびプロキシします。一つのサブスクリプションですべてのモデルが含まれます。「ファイル読み込みのためにプラグインをインストール」という煩わしさはありません。デバイス上のワークロード向けに [Ollama によるオプショナルなローカル AI](https://tinyhumans.gitbook.io/openhuman/features/model-routing/local-ai) も利用できます。 - **[スマートトークン圧縮 (TokenJuice)](https://tinyhumans.gitbook.io/openhuman/features/token-compression)**: すべてのツール呼び出し、スクレイプ結果、メール本文、検索ペイロードは、LLM モデルに渡される前にトークン圧縮レイヤーを通過します。HTML は Markdown に変換され、長い URL は短縮され、冗長なツール出力は設定可能なルールレイヤーで重複排除と要約が行われるなど…。CJK、絵文字などのマルチバイト文字は書記素(grapheme)単位で完全に保持され、除去されることはありません。同じ情報をわずかなトークン数で得られます。コストとレイテンシを最大 80% 削減します。 diff --git a/README.ko.md b/README.ko.md index b1cb3d9637..e1e3f6ed69 100644 --- a/README.ko.md +++ b/README.ko.md @@ -16,7 +16,7 @@

- OpenHuman은 당신의 개인용 AI 슈퍼 지능입니다. 프라이빗하고, 단순하며, 매우 강력합니다. + OpenHuman은 당신의 개인용 AI 슈퍼 지능입니다: 로컬 메모리, 필요한 경우 관리형 서비스, 단순하고 강력합니다.

@@ -47,6 +47,8 @@ > **얼리 베타**: 활발히 개발 중입니다. 다소 미흡한 부분이 있을 수 있습니다. +> **로컬 + 관리형 서비스, upfront:** OpenHuman은 Memory Tree, Obsidian 스타일 Markdown 볼트, 워크스페이스 설정 및 로컬 런타임 상태를 사용자의 머신에 저장합니다. 기본 관리형 경험은 여전히 계정 로그인, 모델 라우팅, 웹 검색 프록시 및 Composio 커넥터 레이어를 통한 관리형 통합/OAuth 플로우에 OpenHuman 호스팅 서비스를 사용합니다. 자체 모델, 검색 또는 Composio 인증 정보를 가져오려면 사용자 지정/로컬 설정을 선택하세요. 일부 실시간 트리거 및 호스팅 기능은 여전히 관리형 백엔드를 필요로 합니다. + 설치하거나 시작하려면 [tinyhumans.ai/openhuman](https://tinyhumans.ai/openhuman?utm_source=github&utm_medium=readme) 웹사이트에서 다운로드하거나 터미널에서 다음을 실행하세요. ```bash @@ -69,7 +71,7 @@ OpenHuman은 일상 생활에 통합되도록 설계된 오픈 소스 에이전 - **[메모리 트리(Memory Tree)](https://tinyhumans.gitbook.io/openhuman/features/memory-tree) + [Obsidian 위키](https://tinyhumans.gitbook.io/openhuman/features/obsidian-wiki)**: 당신의 데이터와 활동을 바탕으로 구축된 로컬 우선 지식 베이스입니다. 연결된 모든 것은 3k 토큰 이하의 Markdown 청크로 규격화되고 점수가 매겨지며, **당신의 머신에 있는 SQLite**에 저장되는 계층적 요약 트리로 접힙니다. 동일한 청크는 당신이 열고, 탐색하고, 편집할 수 있는 Obsidian 호환 볼트에 `.md` 파일로 저장됩니다. 이는 Karpathy의 [obsidian-wiki 워크플로우](https://x.com/karpathy/status/2039805659525644595)에서 영감을 받았습니다. -- **모든 것이 포함됨(Batteries included)**: 웹 검색, 웹 가져오기 [스크레이퍼](https://tinyhumans.gitbook.io/openhuman/features/native-tools), 전체 코더 툴셋(파일 시스템, git, lint, test, grep), 그리고 [네이티브 음성](https://tinyhumans.gitbook.io/openhuman/features/voice)(STT 입력, ElevenLabs TTS 출력, 마스코트 립싱크, 라이브 Google Meet 에이전트)이 기본적으로 연결되어 있습니다. [모델 라우팅](https://tinyhumans.gitbook.io/openhuman/features/model-routing)은 단일 구독 하에 각 작업을 적절한 LLM(추론, 고속 또는 비전)으로 보냅니다. "파일을 읽기 위해 플러그인 설치"와 같은 번거로움이 없습니다. 온디바이스 워크로드를 위해 [Ollama를 통한 선택적 로컬 AI](https://tinyhumans.gitbook.io/openhuman/features/model-routing/local-ai)를 지원합니다. +- **모든 것이 포함됨(Batteries included)**: 웹 검색, 웹 가져오기 [스크레이퍼](https://tinyhumans.gitbook.io/openhuman/features/native-tools), 전체 코더 툴셋(파일 시스템, git, lint, test, grep), 그리고 [네이티브 음성](https://tinyhumans.gitbook.io/openhuman/features/voice)(STT 입력, ElevenLabs TTS 출력, 마스코트 립싱크, 라이브 Google Meet 에이전트)이 기본적으로 연결되어 있습니다. 기본적으로 [모델 라우팅](https://tinyhumans.gitbook.io/openhuman/features/model-routing)은 OpenHuman 백엔드를 사용하여 각 워크로드에 적합한 LLM(추론, 고속 또는 비전)을 선택하고 프록시합니다. 하나의 구독에 모든 모델이 포함됩니다. "파일을 읽기 위해 플러그인 설치"와 같은 번거로움이 없습니다. 온디바이스 워크로드를 위해 [Ollama를 통한 선택적 로컬 AI](https://tinyhumans.gitbook.io/openhuman/features/model-routing/local-ai)를 지원합니다. - **[스마트 토큰 압축(TokenJuice)](https://tinyhumans.gitbook.io/openhuman/features/token-compression)**: 모든 도구 호출, 스크레이핑 결과, 이메일 본문 및 검색 페이로드는 LLM 모델에 전달되기 전에 토큰 압축 레이어를 거칩니다. HTML은 Markdown으로 변환되고, 긴 URL은 단축되며, 장황한 도구 출력은 구성 가능한 규칙 오버레이 등을 통해 중복 제거 및 요약됩니다. CJK, 이모지 및 기타 멀티바이트 텍스트는 자소(grapheme) 단위로 보존되며 절대 삭제되지 않습니다. 동일한 정보를 훨씬 적은 토큰으로 얻을 수 있어 비용과 지연 시간을 최대 80%까지 줄일 수 있습니다. diff --git a/README.md b/README.md index f34d7a4c18..88b4019b19 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@

- OpenHuman is your Personal AI super intelligence. Private, Simple and extremely powerful. + OpenHuman is your Personal AI super intelligence: local memory, managed services where needed, simple and powerful.

@@ -56,18 +56,60 @@ > **Early Beta**: Under active development. Expect rough edges. -To install or get started, either download from the website over at [tinyhumans.ai/openhuman](https://tinyhumans.ai/openhuman?utm_source=github&utm_medium=readme) or run +> **Local + managed services, upfront:** OpenHuman stores its Memory Tree, Obsidian-style Markdown vault, workspace config, and local runtime state on your machine. The default managed experience still uses OpenHuman-hosted services for account sign-in, model routing, web search proxying, and managed integration/OAuth flows through the Composio connector layer. Choose custom/local settings if you want to bring your own model, search, or Composio credentials; some real-time triggers and hosted features still require the managed backend. + +# Install + +Download installers from [tinyhumans.ai/openhuman](https://tinyhumans.ai/openhuman?utm_source=github&utm_medium=readme) or from the [GitHub Releases](https://github.com/tinyhumansai/openhuman/releases/latest) page. For terminal installs, the native package paths below are preferred — they ride your OS package-manager's signing chain. + +## Recommended install (native packages) + +These paths verify the artifact through your OS package manager's signing chain (Homebrew bottle hash, signed apt repo, MSI signature). + +**macOS (Homebrew tap):** ```bash -# Download DMG, EXEs over at https://tinyhumans.ai/openhuman or run in from your terminal +brew tap tinyhumansai/core +brew install openhuman +``` + +**Linux (Debian/Ubuntu — signed apt repo):** + +```bash +sudo apt-get install -y --no-install-recommends gnupg2 curl ca-certificates +curl -fsSL https://tinyhumansai.github.io/openhuman/apt/KEY.gpg \ + | sudo gpg --dearmor -o /etc/apt/keyrings/openhuman.gpg +echo "deb [signed-by=/etc/apt/keyrings/openhuman.gpg arch=amd64] \ + https://tinyhumansai.github.io/openhuman/apt stable main" \ + | sudo tee /etc/apt/sources.list.d/openhuman.list +sudo apt-get update +sudo apt-get install -y openhuman +``` -# For macOS or Linux x64 +**Linux (Arch — AUR):** the [`openhuman-bin` AUR recipe](./packages/arch/openhuman-bin/) is in the repo. Once published, Arch users can install it with `yay -S openhuman-bin`. + +**Windows:** download the signed `.msi` from the [latest release](https://github.com/tinyhumansai/openhuman/releases/latest) and run it. + +**Manual `.dmg` / `.deb` / `.AppImage` / `.msi`:** grab the installer for your platform directly from the [latest release page](https://github.com/tinyhumansai/openhuman/releases/latest). + +> **Linux:** the AppImage can crash on launch under Wayland (and on Arch-based distros with `sharun: Interpreter not found!`) — see [#2463](https://github.com/tinyhumansai/openhuman/issues/2463) for the cause and env-var workarounds. The `.deb` package above avoids those failure modes on Debian/Ubuntu. + +## Alternative: script install (no integrity check) + +> **Warning — unverified install.** These scripts are served live from `raw.githubusercontent.com` and do **not** ship a separate signature, so `curl … | bash` and `irm … | iex` have no way to detect tampering of the script bytes. Prefer the **native package** paths above whenever possible. If you must use the script, see "Verified script install" below. + +```bash +# macOS or Linux x64 curl -fsSL https://raw.githubusercontent.com/tinyhumansai/openhuman/main/scripts/install.sh | bash -# For Windows +# Windows (PowerShell) irm https://raw.githubusercontent.com/tinyhumansai/openhuman/main/scripts/install.ps1 | iex ``` +## Verified script install (coming soon) + +PR2 of [#2620](https://github.com/tinyhumansai/openhuman/issues/2620) will publish `install.sh.asc` / `install.ps1.asc` as release assets and document the `gpg --verify` (and Windows equivalent) flow here, so the script path can be made integrity-checked end-to-end. + # What is OpenHuman? OpenHuman is an open-source agentic assistant designed to integrate with you in your daily life. Each bullet links to the deeper writeup in the [docs](https://tinyhumans.gitbook.io/openhuman/). @@ -76,11 +118,11 @@ OpenHuman is an open-source agentic assistant designed to integrate with you in - **[118+ third-party integrations](https://tinyhumans.gitbook.io/openhuman/features/integrations) with [auto-fetch](https://tinyhumans.gitbook.io/openhuman/features/obsidian-wiki/auto-fetch)**: plug into Gmail, Notion, GitHub, Slack, Stripe, Calendar, Drive, Linear, Jira and the rest of your stack with **one-click OAuth**. Every connection is exposed to the agent as a typed tool, and every twenty minutes the core walks each active connection and pulls fresh data into the [memory tree](https://tinyhumans.gitbook.io/openhuman/features/integrations/auto-fetch). No prompts, no polling loops you have to write, so the agent already has tomorrow's context this morning. - Managed integrations are backend-proxied through OpenHuman's Composio connector layer. If you want to run Composio directly instead of using the managed backend path, configure direct mode with your own Composio API key; real-time trigger webhooks then need to be hosted and wired by you. + Managed integrations use OpenHuman's Composio connector layer. OAuth handshakes and integration tool calls are proxied through the managed backend by default. If you want to run Composio directly instead, configure direct mode with your own Composio API key; real-time trigger webhooks then need to be hosted and wired by you. - **[Memory Tree](https://tinyhumans.gitbook.io/openhuman/features/memory-tree) + [Obsidian Wiki](https://tinyhumans.gitbook.io/openhuman/features/obsidian-wiki)**: a local-first knowledge base built from your data and your activity. Everything you connect is canonicalized into ≤3k-token Markdown chunks, scored, and folded into hierarchical summary trees stored in **SQLite on your machine**. The same chunks land as `.md` files in an Obsidian-compatible vault you can open, browse and edit, inspired by Karpathy's [obsidian-wiki workflow](https://x.com/karpathy/status/2039805659525644595). -- **Batteries included**: web search, a web-fetch [scraper](https://tinyhumans.gitbook.io/openhuman/features/native-tools), a full coder toolset (filesystem, git, lint, test, grep), and [native voice](https://tinyhumans.gitbook.io/openhuman/features/voice) (STT in, ElevenLabs TTS out, mascot lip-sync, live Google Meet agent) are wired in by default. [Model routing](https://tinyhumans.gitbook.io/openhuman/features/model-routing) sends each task to the right LLM (reasoning, fast, or vision) under one subscription. No "install a plugin to read files" friction. [Optional local AI via Ollama](https://tinyhumans.gitbook.io/openhuman/features/model-routing/local-ai) for on-device workloads. +- **Batteries included**: web search, a web-fetch [scraper](https://tinyhumans.gitbook.io/openhuman/features/native-tools), a full coder toolset (filesystem, git, lint, test, grep), and [native voice](https://tinyhumans.gitbook.io/openhuman/features/voice) (STT in, ElevenLabs TTS out, mascot lip-sync, live Google Meet agent) are wired in by default. By default, [model routing](https://tinyhumans.gitbook.io/openhuman/features/model-routing) uses the OpenHuman backend to select and proxy the right LLM for each workload (reasoning, fast, or vision). One subscription includes all models. No "install a plugin to read files" friction. Use [optional local AI via Ollama](https://tinyhumans.gitbook.io/openhuman/features/model-routing/local-ai) for supported on-device workloads. - **[Smart token compression (TokenJuice)](https://tinyhumans.gitbook.io/openhuman/features/token-compression)**: every tool call, scrape result, email body, and search payload is run through a token compression layer before it touches any LLM Model. HTML is converted to Markdown, long URLs are shortened, and verbose tool output is deduped and summarized via a configurable rule overlay etc... CJK, emoji, and other multi-byte text are preserved grapheme-by-grapheme — never stripped. You get the same information but at a fraction of the tokens. Reducing cost & latency by up to 80%. diff --git a/README.zh-CN.md b/README.zh-CN.md index 5a882f7289..ff939b96e1 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -20,7 +20,7 @@

- OpenHuman 是你的个人 AI 超级智能。私密、简洁、极其强大。 + OpenHuman 是你的个人 AI 超级智能:本地记忆,按需托管服务,简洁而强大。

@@ -45,6 +45,8 @@ > **早期测试版**:正在积极开发中,可能存在不完善之处。 +> **本地 + 托管服务,upfront:** OpenHuman 将记忆树、Obsidian 风格 Markdown 仓库、工作区配置和本地运行时状态存储在你的机器上。默认的托管体验仍然使用 OpenHuman 托管服务进行账户登录、模型路由、网页搜索代理,以及通过 Composio 连接器层的托管集成/OAuth 流程。如果你想自带模型、搜索或 Composio 凭据,请选择自定义/本地设置;某些实时触发器和托管功能仍然需要托管后端。 + 要安装或开始使用,请从 [tinyhumans.ai/openhuman](https://tinyhumans.ai/openhuman?utm_source=github&utm_medium=readme) 下载,或在终端中运行: ```bash @@ -65,9 +67,11 @@ OpenHuman 是一个开源智能助手,旨在融入你的日常生活。以下 - **[118+ 第三方集成](https://tinyhumans.gitbook.io/openhuman/features/integrations) + [自动拉取](https://tinyhumans.gitbook.io/openhuman/features/obsidian-wiki/auto-fetch)**:通过**一键 OAuth** 接入 Gmail、Notion、GitHub、Slack、Stripe、Calendar、Drive、Linear、Jira 以及你技术栈中的其他服务。每个连接都以类型化工具的形式暴露给智能体,核心每 20 分钟遍历每个活跃连接并将新数据拉入[记忆树](https://tinyhumans.gitbook.io/openhuman/features/integrations/auto-fetch)中。无需提示词,无需手动编写轮询循环,智能体在每天早上就已经拥有当天的上下文。 + 托管集成使用 OpenHuman 的 Composio 连接器层。OAuth 握手和集成工具调用默认通过托管后端代理。如果你想直接运行 Composio,请使用你自己的 Composio API key 配置直连模式;实时触发器 webhook 则需要由你自行托管和接入。 + - **[记忆树](https://tinyhumans.gitbook.io/openhuman/features/memory-tree) + [Obsidian Wiki](https://tinyhumans.gitbook.io/openhuman/features/obsidian-wiki)**:一个基于你的数据和活动构建的本地优先知识库。你连接的所有内容都被规范化为不超过 3k token 的 Markdown 片段,经过评分后折叠成层级化的摘要树,存储在**你本机的 SQLite** 中。同样的片段以 `.md` 文件形式落地到兼容 Obsidian 的仓库中,你可以打开、浏览和编辑,灵感来源于 Karpathy 的 [obsidian-wiki 工作流](https://x.com/karpathy/status/2039805659525644595)。 -- **开箱即用**:默认内置网络搜索、网页抓取[爬虫](https://tinyhumans.gitbook.io/openhuman/features/native-tools)、完整的编码工具集(文件系统、git、lint、test、grep)以及[原生语音](https://tinyhumans.gitbook.io/openhuman/features/voice)(STT 输入、ElevenLabs TTS 输出、吉祥物口型同步、实时 Google Meet 智能体)。[模型路由](https://tinyhumans.gitbook.io/openhuman/features/model-routing)在一个订阅下将每个任务分派到合适的 LLM(推理型、快速型或视觉型)。没有"安装插件才能读文件"的摩擦。[可选通过 Ollama 使用本地 AI](https://tinyhumans.gitbook.io/openhuman/features/model-routing/local-ai) 处理端侧工作负载。 +- **开箱即用**:默认内置网络搜索、网页抓取[爬虫](https://tinyhumans.gitbook.io/openhuman/features/native-tools)、完整的编码工具集(文件系统、git、lint、test、grep)以及[原生语音](https://tinyhumans.gitbook.io/openhuman/features/voice)(STT 输入、ElevenLabs TTS 输出、吉祥物口型同步、实时 Google Meet 智能体)。默认情况下,[模型路由](https://tinyhumans.gitbook.io/openhuman/features/model-routing)使用 OpenHuman 后端来选择和代理每个工作负载的合适 LLM(推理型、快速型或视觉型)。一个订阅包含所有模型。没有"安装插件才能读文件"的摩擦。[可选通过 Ollama 使用本地 AI](https://tinyhumans.gitbook.io/openhuman/features/model-routing/local-ai) 处理端侧工作负载。 - **[智能 Token 压缩(TokenJuice)](https://tinyhumans.gitbook.io/openhuman/features/token-compression)**:每个工具调用、抓取结果、邮件正文和搜索载荷在触达任何 LLM 模型之前都会经过 token 压缩层处理。HTML 被转换为 Markdown,长 URL 被缩短,冗长的工具输出会通过可配置的规则层去重并摘要等等。中文、emoji 等多字节字符按字形(grapheme)完整保留,绝不丢弃。你获得相同的信息,但 token 消耗仅为原来的几分之一。最多可降低 80% 的成本和延迟。 diff --git a/app/.prettierignore b/app/.prettierignore index 8e4bde1b6b..cded8a49f4 100644 --- a/app/.prettierignore +++ b/app/.prettierignore @@ -3,6 +3,7 @@ dist coverage app src-tauri +src-tauri-mobile rust-core skills *.config.js diff --git a/app/eslint.config.js b/app/eslint.config.js index f4b626d2ad..b7b1380394 100644 --- a/app/eslint.config.js +++ b/app/eslint.config.js @@ -25,6 +25,7 @@ export default [ 'target/**', '**/target/**', 'dist/**', + 'dist-web/**', 'coverage/**', 'app/**', 'src-tauri/**', @@ -253,9 +254,9 @@ export default [ }, }, - // E2E test files (Appium/WebDriverIO) — use tsconfig.e2e.json for parsing + // E2E test files (WDIO + Playwright) — use tsconfig.e2e.json for parsing { - files: ['test/e2e/**/*.ts', 'test/wdio.conf.ts'], + files: ['test/e2e/**/*.ts', 'test/playwright/**/*.ts', 'test/wdio.conf.ts'], languageOptions: { parser: tsparser, parserOptions: { @@ -291,6 +292,18 @@ export default [ }, }, + // Playwright test helpers/specs are intentionally more permissive: + // empty catch blocks are used for best-effort browser-lane fallbacks and + // many helpers keep optional args/imports for parity with the WDIO suite. + { + files: ['test/playwright/**/*.ts'], + rules: { + 'no-empty': 'off', + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': 'off', + }, + }, + // JavaScript files configuration { files: ['**/*.js', '**/*.jsx'], diff --git a/app/package.json b/app/package.json index 186ff41ba6..ef320ad872 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "openhuman-app", - "version": "0.54.7", + "version": "0.57.1", "type": "module", "engines": { "node": ">=24.0.0" @@ -14,9 +14,16 @@ "dev:wry": "pnpm tauri:ensure && export CEF_PATH=\"$HOME/Library/Caches/tauri-cef\" && source ../scripts/load-dotenv.sh && cargo tauri dev --no-default-features --features wry", "core:stage": "echo '[core:stage] no-op — core is linked in-process; sidecar removed (PR #1061)'", "tauri:ensure": "bash ../scripts/ensure-tauri-cli.sh", + "tauri:ios:init": "bash ../scripts/ios-init.sh", + "tauri:ios:dev": "cd src-tauri-mobile && IPHONEOS_DEPLOYMENT_TARGET=${IPHONEOS_DEPLOYMENT_TARGET:-16.0} npx --package=@tauri-apps/cli@^2 tauri ios dev", + "tauri:ios:build": "cd src-tauri-mobile && IPHONEOS_DEPLOYMENT_TARGET=${IPHONEOS_DEPLOYMENT_TARGET:-16.0} npx --package=@tauri-apps/cli@^2 tauri ios build", + "tauri:android:init": "bash ../scripts/android-init.sh", + "tauri:android:dev": "cd src-tauri-mobile && npx --package=@tauri-apps/cli@^2 tauri android dev", + "tauri:android:build": "cd src-tauri-mobile && npx --package=@tauri-apps/cli@^2 tauri android build", "build": "tsc && vite build", "build:app": "tsc && vite build", "build:app:e2e": "tsc && vite build --mode development", + "build:web:e2e": "bash ./scripts/e2e-web-build.sh", "build:web": "cross-env VITE_OPENHUMAN_TARGET=web tsc && cross-env VITE_OPENHUMAN_TARGET=web vite build", "compile": "tsc --noEmit", "preview": "vite preview", @@ -37,14 +44,17 @@ "test:coverage": "vitest run --config test/vitest.config.ts --coverage", "test:rust": "bash ../scripts/test-rust-with-mock.sh", "test:e2e:build": "bash ./scripts/e2e-build.sh", + "test:e2e:web:build": "bash ./scripts/e2e-web-build.sh", + "test:e2e:web": "pnpm test:e2e:web:build && bash ./scripts/e2e-web-session.sh", + "test:e2e:mega": "pnpm test:e2e:build && bash ./scripts/e2e-run-spec.sh test/e2e/specs/mega-flow.spec.ts mega-flow", "test:e2e:login": "bash ./scripts/e2e-login.sh", "test:e2e:auth": "bash ./scripts/e2e-auth.sh", "test:e2e:service-connectivity": "OPENHUMAN_SERVICE_MOCK=1 bash ./scripts/e2e-run-spec.sh test/e2e/specs/service-connectivity-flow.spec.ts service-connectivity", "test:e2e:skills-registry": "bash ./scripts/e2e-run-spec.sh test/e2e/specs/skills-registry.spec.ts skills-registry", "test:e2e:cron-jobs": "bash ./scripts/e2e-run-spec.sh test/e2e/specs/cron-jobs-flow.spec.ts cron-jobs", - "test:e2e": "pnpm test:e2e:build && pnpm test:e2e:login && pnpm test:e2e:auth", + "test:e2e": "pnpm test:e2e:web && pnpm test:e2e:mega", "test:e2e:all:flows": "bash ./scripts/e2e-run-all-flows.sh", - "test:e2e:all": "pnpm test:e2e:build && pnpm test:e2e:all:flows", + "test:e2e:all": "pnpm test:e2e:web && pnpm test:e2e:all:flows", "test:e2e:session": "bash ./scripts/e2e-run-session.sh", "test:e2e:session:full": "pnpm test:e2e:build && pnpm test:e2e:session", "test:all": "pnpm test:coverage && pnpm test:rust && pnpm test:e2e", @@ -61,6 +71,7 @@ "knip:production": "knip --config knip.json --production" }, "dependencies": { + "@noble/ciphers": "^1.2.1", "@noble/curves": "^2.2.0", "@noble/hashes": "^2.0.1", "@noble/secp256k1": "^3.0.0", @@ -68,11 +79,13 @@ "@reduxjs/toolkit": "^2.11.2", "@remotion/player": "4.0.454", "@remotion/zod-types": "4.0.454", + "@rive-app/react-webgl2": "^4.28.6", "@scure/base": "^2.2.0", "@scure/bip32": "^2.0.1", "@scure/bip39": "^2.0.1", "@sentry/react": "^10.38.0", "@tauri-apps/api": "^2.10.0", + "@tauri-apps/plugin-barcode-scanner": "^2.4.4", "@tauri-apps/plugin-deep-link": "^2", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-os": "^2.3.2", @@ -80,9 +93,11 @@ "buffer": "^6.0.3", "cmdk": "^1.1.1", "debug": "^4.4.3", + "katex": "^0.16.47", "lottie-react": "^2.4.1", "os-browserify": "^0.3.0", "process": "^0.11.10", + "qrcode.react": "^4.2.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-ga4": "^3.0.1", @@ -91,16 +106,20 @@ "react-markdown": "^10.1.0", "react-redux": "^9.2.0", "react-router-dom": "^7.13.0", - "redux-logger": "^3.0.6", + "recharts": "^2.15.0", "redux-persist": "^6.0.0", + "rehype-katex": "^7.0.1", + "remark-math": "^6.0.0", "remotion": "4.0.454", "socket.io-client": "^4.8.3", + "tauri-plugin-ptt-api": "workspace:*", "three": "^0.183.2", "util": "^0.12.5", "zod": "4.3.6" }, "devDependencies": { "@eslint/js": "^9.39.2", + "@playwright/test": "^1.56.1", "@sentry/vite-plugin": "^2.22.6", "@tailwindcss/forms": "^0.5.11", "@tailwindcss/typography": "^0.5.19", @@ -136,6 +155,7 @@ "knip": "^6.3.1", "postcss": "^8.5.6", "prettier": "^3.8.1", + "redux-logger": "^3.0.6", "tailwindcss": "^3.4.19", "typescript": "~5.8.3", "vite": "^8.0.0", diff --git a/app/playwright.config.ts b/app/playwright.config.ts new file mode 100644 index 0000000000..ef92b83116 --- /dev/null +++ b/app/playwright.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from '@playwright/test'; + +const baseURL = process.env.PW_BASE_URL || 'http://127.0.0.1:4173'; + +export default defineConfig({ + testDir: './test/playwright/specs', + fullyParallel: false, + workers: 1, + timeout: 60_000, + expect: { + timeout: 10_000, + }, + use: { + baseURL, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + reporter: [['list']], +}); diff --git a/app/public/tiny_mascot.riv b/app/public/tiny_mascot.riv new file mode 100644 index 0000000000..5e259e66fc Binary files /dev/null and b/app/public/tiny_mascot.riv differ diff --git a/app/scripts/e2e-build.sh b/app/scripts/e2e-build.sh index 2dd08b84bd..c51fabb32d 100755 --- a/app/scripts/e2e-build.sh +++ b/app/scripts/e2e-build.sh @@ -69,6 +69,13 @@ case "${CI:-}" in 1) export CI=true ;; 0) export CI=false ;; esac # All other build scripts in app/package.json do `pnpm tauri:ensure` + use # `cargo tauri build`; the E2E build was the one outlier and we got the panic. pnpm tauri:ensure +# ensure-tauri-cli.sh installs cargo-tauri into $INSTALL_ROOT/bin (default +# /.cache/cargo-install/bin) and only exports PATH within its own +# subshell. Replicate that PATH update here so `cargo tauri build` can find +# the subcommand on fresh CI runners (macOS / Windows) where ~/.cargo/bin +# does not already contain a cargo-tauri from a prior install. +INSTALL_ROOT="${OPENHUMAN_CARGO_INSTALL_ROOT:-$REPO_ROOT/.cache/cargo-install}" +export PATH="$HOME/.cargo/bin:$INSTALL_ROOT/bin:$PATH" export CEF_PATH="$HOME/Library/Caches/tauri-cef" OS="$(uname)" diff --git a/app/scripts/e2e-preflight.sh b/app/scripts/e2e-preflight.sh new file mode 100755 index 0000000000..d50897e980 --- /dev/null +++ b/app/scripts/e2e-preflight.sh @@ -0,0 +1,195 @@ +#!/usr/bin/env bash +# +# e2e-preflight.sh — Pre-flight environment validation for the E2E test suite. +# +# Checks: +# 1. The E2E app binary/bundle exists for the current platform. +# 2. Node.js and pnpm are available. +# 3. Appium is installed (and the chromium driver is registered). +# 4. Ports 19222, 4723, and 18473 are not blocked by stale processes. +# +# Exits 0 if all hard requirements are met. +# Exits 1 if any hard requirement is missing. +# Warnings are printed for soft issues (occupied ports, missing chromium driver) +# but do not fail the script. +# +set -uo pipefail + +# --------------------------------------------------------------------------- +# Color helpers — only when stdout is a terminal. +# --------------------------------------------------------------------------- +if [ -t 1 ]; then + RED='\033[0;31m' + YELLOW='\033[1;33m' + GREEN='\033[0;32m' + BOLD='\033[1m' + RESET='\033[0m' +else + RED='' YELLOW='' GREEN='' BOLD='' RESET='' +fi + +info() { printf "%b[preflight]%b %s\n" "$BOLD" "$RESET" "$*"; } +ok() { printf "%b[preflight] ✓%b %s\n" "$GREEN" "$RESET" "$*"; } +warn() { printf "%b[preflight] ⚠%b %s\n" "$YELLOW" "$RESET" "$*" >&2; } +fail() { printf "%b[preflight] ✗%b %s\n" "$RED" "$RESET" "$*" >&2; } + +ERRORS=0 +_fail() { fail "$*"; (( ERRORS++ )) || true; } + +APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +info "Starting E2E pre-flight checks..." +echo "" + +# --------------------------------------------------------------------------- +# 1. App binary / bundle +# --------------------------------------------------------------------------- +info "Checking E2E app bundle..." + +PLATFORM="$(uname -s)" +BINARY_FOUND=0 +BINARY_PATH="" + +case "$PLATFORM" in + Darwin) + MACOS_BUNDLE="$APP_DIR/src-tauri/target/debug/bundle/macos/OpenHuman.app" + if [[ -d "$MACOS_BUNDLE" ]]; then + BINARY_FOUND=1 + BINARY_PATH="$MACOS_BUNDLE" + fi + ;; + Linux) + LINUX_BIN="$APP_DIR/src-tauri/target/debug/openhuman" + LINUX_DEB="$APP_DIR/src-tauri/target/debug/bundle/deb" + if [[ -f "$LINUX_BIN" ]]; then + BINARY_FOUND=1 + BINARY_PATH="$LINUX_BIN" + elif [[ -d "$LINUX_DEB" ]]; then + BINARY_FOUND=1 + BINARY_PATH="$LINUX_DEB" + fi + ;; + MINGW*|MSYS*|CYGWIN*|Windows*) + WIN_BIN="$APP_DIR/src-tauri/target/debug/openhuman.exe" + if [[ -f "$WIN_BIN" ]]; then + BINARY_FOUND=1 + BINARY_PATH="$WIN_BIN" + fi + ;; + *) + warn "Unknown platform '$PLATFORM' — cannot verify app bundle path." + BINARY_FOUND=1 # don't block on unknown platforms + ;; +esac + +if [[ $BINARY_FOUND -eq 1 ]]; then + ok "App bundle found: $BINARY_PATH" +else + _fail "E2E build not found for $PLATFORM." + case "$PLATFORM" in + Darwin) + fail " Expected: $MACOS_BUNDLE" + ;; + Linux) + fail " Expected: $LINUX_BIN" + ;; + MINGW*|MSYS*|CYGWIN*) + fail " Expected: $WIN_BIN" + ;; + esac + fail " Run: pnpm --filter openhuman-app test:e2e:build" +fi + +echo "" + +# --------------------------------------------------------------------------- +# 2. Node.js + pnpm +# --------------------------------------------------------------------------- +info "Checking Node.js and pnpm..." + +if command -v node >/dev/null 2>&1; then + NODE_VERSION="$(node --version 2>/dev/null || echo 'unknown')" + ok "node found: $NODE_VERSION" +else + _fail "node not found. Node.js is required to run WDIO." +fi + +if command -v pnpm >/dev/null 2>&1; then + PNPM_VERSION="$(pnpm --version 2>/dev/null || echo 'unknown')" + ok "pnpm found: $PNPM_VERSION" +else + _fail "pnpm not found. Install via: npm install -g pnpm" +fi + +echo "" + +# --------------------------------------------------------------------------- +# 3. Appium + chromium driver +# --------------------------------------------------------------------------- +info "Checking Appium..." + +if command -v appium >/dev/null 2>&1; then + APPIUM_VERSION="$(appium --version 2>/dev/null || echo 'unknown')" + ok "appium found: $APPIUM_VERSION" + + # Check for the chromium driver — warn only (e2e-run-session.sh handles this) + CHROMIUM_INSTALLED=0 + if appium driver list --installed 2>&1 | grep -qi "chromium"; then + CHROMIUM_INSTALLED=1 + ok "Appium chromium driver is installed" + fi + if [[ $CHROMIUM_INSTALLED -eq 0 ]]; then + warn "Appium chromium driver not found in 'appium driver list --installed'." + warn " To install: appium driver install --source=npm appium-chromium-driver" + warn " (e2e-run-session.sh will attempt idempotent install at runtime.)" + fi +else + _fail "Appium not found." + fail " Install: npm install -g appium@3" + fail " Then: appium driver install --source=npm appium-chromium-driver" +fi + +echo "" + +# --------------------------------------------------------------------------- +# 4. Port availability (warnings only — stale processes are soft blockers) +# --------------------------------------------------------------------------- +info "Checking port availability..." + +_check_port() { + local port="$1" + local label="$2" + local pid="" + # Try lsof first (macOS/Linux), fall back to ss (Linux only) + if command -v lsof >/dev/null 2>&1; then + pid=$(lsof -ti tcp:"$port" 2>/dev/null | head -1 || true) + elif command -v ss >/dev/null 2>&1; then + pid=$(ss -tlnp "sport = :$port" 2>/dev/null | awk 'NR>1 {match($NF,/pid=([0-9]+)/,a); print a[1]}' | head -1 || true) + fi + + if [[ -n "$pid" ]]; then + warn "Port $port ($label) is occupied by PID $pid." + warn " If this is a stale process from a prior run, kill it:" + warn " kill $pid" + else + ok "Port $port ($label) is free" + fi +} + +_check_port 19222 "CEF CDP" +_check_port 4723 "Appium" +_check_port 18473 "mock backend (can be pre-running — OK if deliberate)" + +echo "" + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- +if [[ $ERRORS -gt 0 ]]; then + printf "%b[preflight] PRE-FLIGHT FAILED%b — %d error(s) above must be resolved before running E2E tests.\n" \ + "$RED" "$RESET" "$ERRORS" >&2 + exit 1 +fi + +printf "%b[preflight] Pre-flight passed%b — environment looks good.\n" "$GREEN" "$RESET" +exit 0 diff --git a/app/scripts/e2e-run-all-flows.sh b/app/scripts/e2e-run-all-flows.sh index fb6afd3fcd..92e51ec815 100755 --- a/app/scripts/e2e-run-all-flows.sh +++ b/app/scripts/e2e-run-all-flows.sh @@ -1,159 +1,466 @@ #!/usr/bin/env bash # -# Run all E2E WDIO specs sequentially (Appium restarted per spec). -# Requires a prior E2E app build: pnpm --filter openhuman-app test:e2e:build +# e2e-run-all-flows.sh — Master E2E orchestrator for all 66 WDIO specs. # -# Each spec runs to completion regardless of prior failures; a pass/fail -# summary is printed at the end and the script exits non-zero if any spec -# failed. (Previously `set -e` caused the first failure to abort the run -# and made the terminal appear to crash.) +# USAGE: +# bash app/scripts/e2e-run-all-flows.sh [OPTIONS] +# +# OPTIONS: +# --suite=SUITE Run only one suite category. Valid values: +# auth, navigation, chat, skills, notifications, +# webhooks, providers, payments, settings, system, +# journeys, all (default: all) +# --bail Stop after the first spec failure (default: run all) +# --skip-preflight Skip the pre-flight environment check +# +# ENVIRONMENT: +# E2E_ARTIFACTS_DIR Directory where failure logs are copied. +# Default: app/test/e2e/artifacts/YYYYMMDD-HHMMSS +# +# REQUIREMENTS: +# pnpm --filter openhuman-app test:e2e:build (must be run first) +# +# Each spec runs to completion regardless of prior failures unless --bail is +# passed. A per-category mini-summary and a full summary are printed at the +# end. The script exits non-zero if any spec failed. +# +# (Previously `set -e` caused the first failure to abort the run and made +# the terminal appear to crash. `set -uo pipefail` preserves error detection +# without aborting mid-run.) # set -uo pipefail APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -cd "$APP_DIR" || { echo "FATAL: could not cd to $APP_DIR" >&2; exit 1; } +REPO_DIR="$(cd "$APP_DIR/.." && pwd)" +cd "$APP_DIR" || { + echo "[e2e-run-all-flows] Failed to cd into $APP_DIR" >&2 + exit 1 +} -# Parallel arrays: names + exit codes collected during the run. -_spec_names=() -_spec_results=() +# --------------------------------------------------------------------------- +# Argument parsing +# --------------------------------------------------------------------------- +SUITE="all" +BAIL=0 +SKIP_PREFLIGHT=0 + +for arg in "$@"; do + case "$arg" in + --suite=*) SUITE="${arg#--suite=}" ;; + --bail) BAIL=1 ;; + --skip-preflight) SKIP_PREFLIGHT=1 ;; + *) + echo "Unknown option: $arg" >&2 + echo "Usage: bash app/scripts/e2e-run-all-flows.sh [--suite=SUITE] [--bail] [--skip-preflight]" >&2 + exit 1 + ;; + esac +done + +VALID_SUITES="auth navigation chat skills notifications webhooks providers connectors payments settings system journeys all" +# Accept comma-separated suite lists, e.g. --suite=auth,navigation,system. +# CI sharding passes one such list per matrix shard so a few parallel jobs +# can cover the whole suite. `all` short-circuits to "everything". +IFS=',' read -r -a _REQUESTED_SUITES <<< "$SUITE" +for req in "${_REQUESTED_SUITES[@]}"; do + match=0 + for s in $VALID_SUITES; do + [[ "$req" == "$s" ]] && match=1 && break + done + if [[ $match -eq 0 ]]; then + echo "Invalid suite: '$req'. Valid values: $VALID_SUITES" >&2 + exit 1 + fi +done + +# --------------------------------------------------------------------------- +# Artifacts directory +# --------------------------------------------------------------------------- +E2E_ARTIFACTS_DIR="${E2E_ARTIFACTS_DIR:-$APP_DIR/test/e2e/artifacts/$(date +%Y%m%d-%H%M%S)}" +export E2E_ARTIFACTS_DIR + +# --------------------------------------------------------------------------- +# Spec collection: this script no longer invokes the runner once per spec. +# Instead `run()` accumulates spec paths into one list; at the very end we +# hand the whole list to `e2e-run-session.sh`, which launches the app + +# Appium + chromedriver ONCE and lets WDIO drive every spec inside a single +# shared session. The old per-spec orchestration paid CEF cold-start tax on +# every spec (~15-30s × 65 specs) and broke the contract in wdio.conf.ts +# ("WDIO creates ONE session per worker ... state from spec N flows into +# spec N+1"). Per-spec failure detail comes from WDIO's spec reporter now, +# not from a bash-side per-spec exit-code table. +# +# `--bail` is forwarded by env (E2E_BAIL_ON_FAILURE=1) so wdio.conf.ts can +# flip its `bail` count. Per-suite `--suite=` filtering is still honored at +# the run() call site. +# --------------------------------------------------------------------------- +_spec_paths=() # collected spec paths, in declaration order +_spec_suites=() # parallel array: suite name per collected spec +_spec_labels=() # parallel array: human label per collected spec +_RUN_START_EPOCH=$(date +%s) + +# --------------------------------------------------------------------------- +# run SPEC LABEL SUITE +# +# Appends the spec to the collected list; nothing runs yet. The actual WDIO +# invocation happens at the bottom of the script. +# --------------------------------------------------------------------------- run() { local spec="$1" local label="${2:-$1}" - _spec_names+=("$label") - if "$APP_DIR/scripts/e2e-run-spec.sh" "$spec" "$label"; then - _spec_results+=(0) - else - _spec_results+=(1) + local suite="${3:-unknown}" + + _spec_paths+=("$spec") + _spec_suites+=("$suite") + _spec_labels+=("$label") +} + +# --------------------------------------------------------------------------- +# _copy_failure_logs +# Copies /tmp/openhuman-e2e-app-*.log files into E2E_ARTIFACTS_DIR once at +# end-of-run. With a single shared session there's now only one app log to +# capture (and Appium/chromedriver logs alongside). +# --------------------------------------------------------------------------- +_copy_failure_logs() { + local logs + logs=$(ls /tmp/openhuman-e2e-app-*.log 2>/dev/null || true) + if [[ -z "$logs" ]]; then + return fi + mkdir -p "$E2E_ARTIFACTS_DIR" + for f in $logs; do + local dest="$E2E_ARTIFACTS_DIR/$(basename "$f" .log)-session.log" + cp "$f" "$dest" 2>/dev/null || true + done + echo "[e2e-run-all-flows] Session logs copied to $E2E_ARTIFACTS_DIR" } -# Print summary and exit with the appropriate code. +# --------------------------------------------------------------------------- +# _mini_summary SUITE_NAME +# Print how many specs were collected for this suite (pre-run; WDIO will +# report per-spec pass/fail directly). +# --------------------------------------------------------------------------- +_mini_summary() { + local suite="$1" + local count=0 + for i in "${!_spec_labels[@]}"; do + [[ "${_spec_suites[$i]}" == "$suite" ]] && (( count++ )) || true + done + printf " [%s] %d spec(s) queued\n" "$suite" "$count" +} + +# --------------------------------------------------------------------------- +# finish — print wall time + a markdown summary for CI job summary. +# Per-spec pass/fail comes from WDIO's spec reporter in the live output; +# the bash orchestrator no longer tracks per-spec exit codes. +# --------------------------------------------------------------------------- +_WDIO_EXIT_CODE=0 finish() { - local pass=0 fail=0 + local t_end_epoch + t_end_epoch=$(date +%s) + local wall=$(( t_end_epoch - _RUN_START_EPOCH )) + local wall_min=$(( wall / 60 )) + local wall_sec=$(( wall % 60 )) + local collected=${#_spec_paths[@]} + echo "" - echo "══════════════════════════════════════════════" - echo " E2E run summary ($(uname -s))" - echo "══════════════════════════════════════════════" - for i in "${!_spec_names[@]}"; do - if [[ "${_spec_results[$i]}" -eq 0 ]]; then - printf " ✓ %s\n" "${_spec_names[$i]}" - (( pass++ )) || true - else - printf " ✗ %s\n" "${_spec_names[$i]}" - (( fail++ )) || true - fi - done - echo "──────────────────────────────────────────────" - printf " Passed: %d Failed: %d Total: %d\n" "$pass" "$fail" "${#_spec_names[@]}" - echo "══════════════════════════════════════════════" - if [[ $fail -gt 0 ]]; then - exit 1 + echo "══════════════════════════════════════════════════════════════════" + printf " E2E run summary ($(uname -s)) suite=%s\n" "$SUITE" + echo "══════════════════════════════════════════════════════════════════" + printf " Specs queued: %d\n" "$collected" + printf " WDIO exit: %d\n" "$_WDIO_EXIT_CODE" + printf " Wall time: %dm %02ds\n" "$wall_min" "$wall_sec" + echo "══════════════════════════════════════════════════════════════════" + + _copy_failure_logs + + { + printf "## E2E Results ($(uname -s)) — suite=%s\n\n" "$SUITE" + printf "| Field | Value |\n" + printf "|-------|-------|\n" + printf "| Specs queued | %d |\n" "$collected" + printf "| WDIO exit code | %d |\n" "$_WDIO_EXIT_CODE" + printf "| Wall time | %dm %02ds |\n" "$wall_min" "$wall_sec" + printf "\nPer-spec pass/fail is in the WDIO spec-reporter output above.\n" + } > /tmp/e2e-summary.txt + + if [[ $_WDIO_EXIT_CODE -ne 0 ]]; then + exit "$_WDIO_EXIT_CODE" fi } trap finish EXIT +# --------------------------------------------------------------------------- +# Pre-flight check (unless --skip-preflight) +# --------------------------------------------------------------------------- +if [[ $SKIP_PREFLIGHT -eq 0 ]]; then + if [[ -f "$APP_DIR/scripts/e2e-preflight.sh" ]]; then + echo "[e2e-run-all-flows] Running pre-flight checks..." + if ! bash "$APP_DIR/scripts/e2e-preflight.sh"; then + echo "[e2e-run-all-flows] Pre-flight failed. Aborting." >&2 + exit 1 + fi + else + echo "[e2e-run-all-flows] Pre-flight script not found or not executable, skipping." + fi +fi + +# --------------------------------------------------------------------------- +# Helpers: should_run_suite SUITE_NAME +# Returns 0 (true) if this suite should run given --suite flag. +# --------------------------------------------------------------------------- +should_run_suite() { + local want="$1" + for req in "${_REQUESTED_SUITES[@]}"; do + [[ "$req" == "all" || "$req" == "$want" ]] && return 0 + done + return 1 +} + # --------------------------------------------------------------------------- # Auth & onboarding # --------------------------------------------------------------------------- -run "test/e2e/specs/smoke.spec.ts" "smoke" -run "test/e2e/specs/login-flow.spec.ts" "login" -run "test/e2e/specs/auth-access-control.spec.ts" "auth" -run "test/e2e/specs/logout-relogin-onboarding.spec.ts" "logout-relogin" -run "test/e2e/specs/onboarding-modes.spec.ts" "onboarding-modes" -run "test/e2e/specs/runtime-picker-login.spec.ts" "runtime-picker-login" +if should_run_suite "auth"; then + echo "" + echo "## Running suite: auth" + run "test/e2e/specs/smoke.spec.ts" "smoke" "auth" + run "test/e2e/specs/login-flow.spec.ts" "login" "auth" + run "test/e2e/specs/auth-access-control.spec.ts" "auth" "auth" + run "test/e2e/specs/logout-relogin-onboarding.spec.ts" "logout-relogin" "auth" + run "test/e2e/specs/onboarding-modes.spec.ts" "onboarding-modes" "auth" + run "test/e2e/specs/runtime-picker-login.spec.ts" "runtime-picker-login" "auth" + _mini_summary "auth" +fi # --------------------------------------------------------------------------- # Navigation & core UI # --------------------------------------------------------------------------- -run "test/e2e/specs/navigation.spec.ts" "navigation" -run "test/e2e/specs/command-palette.spec.ts" "command-palette" -run "test/e2e/specs/channels-smoke.spec.ts" "channels-smoke" -run "test/e2e/specs/insights-dashboard.spec.ts" "insights-dashboard" +if should_run_suite "navigation"; then + echo "" + echo "## Running suite: navigation" + run "test/e2e/specs/navigation.spec.ts" "navigation" "navigation" + run "test/e2e/specs/navigation-smoothness.spec.ts" "navigation-smoothness" "navigation" + run "test/e2e/specs/navigation-settings-panels.spec.ts" "navigation-settings" "navigation" + run "test/e2e/specs/command-palette.spec.ts" "command-palette" "navigation" + run "test/e2e/specs/channels-smoke.spec.ts" "channels-smoke" "navigation" + run "test/e2e/specs/insights-dashboard.spec.ts" "insights-dashboard" "navigation" + run "test/e2e/specs/guided-tour-gates.spec.ts" "guided-tour-gates" "navigation" + _mini_summary "navigation" +fi # --------------------------------------------------------------------------- # Chat & agent harness # --------------------------------------------------------------------------- -run "test/e2e/specs/chat-harness-send-stream.spec.ts" "chat-send-stream" -run "test/e2e/specs/chat-harness-cancel.spec.ts" "chat-cancel" -run "test/e2e/specs/chat-harness-scroll-render.spec.ts" "chat-scroll-render" -run "test/e2e/specs/chat-harness-subagent.spec.ts" "chat-subagent" -run "test/e2e/specs/chat-harness-wallet-flow.spec.ts" "chat-wallet" -run "test/e2e/specs/agent-review.spec.ts" "agent-review" -run "test/e2e/specs/mega-flow.spec.ts" "mega-flow" +if should_run_suite "chat"; then + echo "" + echo "## Running suite: chat" + run "test/e2e/specs/chat-harness-send-stream.spec.ts" "chat-send-stream" "chat" + run "test/e2e/specs/chat-harness-cancel.spec.ts" "chat-cancel" "chat" + run "test/e2e/specs/chat-harness-scroll-render.spec.ts" "chat-scroll-render" "chat" + run "test/e2e/specs/chat-harness-subagent.spec.ts" "chat-subagent" "chat" + run "test/e2e/specs/chat-harness-wallet-flow.spec.ts" "chat-wallet" "chat" + run "test/e2e/specs/chat-tool-call-flow.spec.ts" "chat-tool-call" "chat" + run "test/e2e/specs/chat-multi-tool-round.spec.ts" "chat-multi-tool" "chat" + run "test/e2e/specs/chat-tool-error-recovery.spec.ts" "chat-error-recovery" "chat" + run "test/e2e/specs/agent-review.spec.ts" "agent-review" "chat" + run "test/e2e/specs/mega-flow.spec.ts" "mega-flow" "chat" + _mini_summary "chat" +fi # --------------------------------------------------------------------------- # Skills # --------------------------------------------------------------------------- -run "test/e2e/specs/skills-registry.spec.ts" "skills-registry" -run "test/e2e/specs/skill-execution-flow.spec.ts" "skill-execution" -run "test/e2e/specs/skill-lifecycle.spec.ts" "skill-lifecycle" -run "test/e2e/specs/skill-multi-round.spec.ts" "skill-multi-round" -run "test/e2e/specs/skill-oauth.spec.ts" "skill-oauth" -run "test/e2e/specs/skill-socket-reconnect.spec.ts" "skill-socket-reconnect" +if should_run_suite "skills"; then + echo "" + echo "## Running suite: skills" + run "test/e2e/specs/skills-registry.spec.ts" "skills-registry" "skills" + run "test/e2e/specs/skill-execution-flow.spec.ts" "skill-execution" "skills" + run "test/e2e/specs/skill-lifecycle.spec.ts" "skill-lifecycle" "skills" + run "test/e2e/specs/skill-multi-round.spec.ts" "skill-multi-round" "skills" + run "test/e2e/specs/skill-oauth.spec.ts" "skill-oauth" "skills" + run "test/e2e/specs/skill-socket-reconnect.spec.ts" "skill-socket-reconnect" "skills" + _mini_summary "skills" +fi # --------------------------------------------------------------------------- # Notifications, memory, cron # --------------------------------------------------------------------------- -run "test/e2e/specs/notifications.spec.ts" "notifications" -run "test/e2e/specs/memory-roundtrip.spec.ts" "memory-roundtrip" -run "test/e2e/specs/cron-jobs-flow.spec.ts" "cron-jobs" -run "test/e2e/specs/autocomplete-flow.spec.ts" "autocomplete" +if should_run_suite "notifications"; then + echo "" + echo "## Running suite: notifications" + run "test/e2e/specs/notifications.spec.ts" "notifications" "notifications" + run "test/e2e/specs/memory-roundtrip.spec.ts" "memory-roundtrip" "notifications" + run "test/e2e/specs/cron-jobs-flow.spec.ts" "cron-jobs" "notifications" + run "test/e2e/specs/autocomplete-flow.spec.ts" "autocomplete" "notifications" + _mini_summary "notifications" +fi # --------------------------------------------------------------------------- # Webhooks & tools # --------------------------------------------------------------------------- -run "test/e2e/specs/webhooks-ingress-flow.spec.ts" "webhooks-ingress" -run "test/e2e/specs/webhooks-tunnel-flow.spec.ts" "webhooks-tunnel" -run "test/e2e/specs/tool-browser-flow.spec.ts" "tool-browser" -run "test/e2e/specs/tool-filesystem-flow.spec.ts" "tool-filesystem" -run "test/e2e/specs/tool-shell-git-flow.spec.ts" "tool-shell-git" +if should_run_suite "webhooks"; then + echo "" + echo "## Running suite: webhooks" + run "test/e2e/specs/webhooks-ingress-flow.spec.ts" "webhooks-ingress" "webhooks" + run "test/e2e/specs/webhooks-tunnel-flow.spec.ts" "webhooks-tunnel" "webhooks" + run "test/e2e/specs/tool-browser-flow.spec.ts" "tool-browser" "webhooks" + run "test/e2e/specs/tool-filesystem-flow.spec.ts" "tool-filesystem" "webhooks" + run "test/e2e/specs/tool-shell-git-flow.spec.ts" "tool-shell-git" "webhooks" + run "test/e2e/specs/harness-channel-bridge-flow.spec.ts" "harness-channel-bridge" "webhooks" + run "test/e2e/specs/harness-composio-tool-flow.spec.ts" "harness-composio-tool" "webhooks" + run "test/e2e/specs/harness-cron-prompt-flow.spec.ts" "harness-cron-prompt" "webhooks" + run "test/e2e/specs/harness-search-tool-flow.spec.ts" "harness-search-tool" "webhooks" + _mini_summary "webhooks" +fi # --------------------------------------------------------------------------- # Provider flows # --------------------------------------------------------------------------- -run "test/e2e/specs/telegram-flow.spec.ts" "telegram" -run "test/e2e/specs/gmail-flow.spec.ts" "gmail" -run "test/e2e/specs/slack-flow.spec.ts" "slack" -run "test/e2e/specs/whatsapp-flow.spec.ts" "whatsapp" -run "test/e2e/specs/conversations-web-channel-flow.spec.ts" "conversations" -run "test/e2e/specs/composio-triggers-flow.spec.ts" "composio-triggers" +if should_run_suite "providers"; then + echo "" + echo "## Running suite: providers" + # telegram-flow.spec.ts was renamed to telegram-channel-flow.spec.ts; + # only the latter exists in the repo today. + run "test/e2e/specs/telegram-channel-flow.spec.ts" "telegram-channel" "providers" + run "test/e2e/specs/gmail-flow.spec.ts" "gmail" "providers" + run "test/e2e/specs/accounts-provider-modal.spec.ts" "accounts-providers" "providers" + # slack-flow currently crashes the CEF session mid-spec on Linux (#1850-style + # state issue); skip until investigated rather than nuke the rest of the + # provider suite. + # run "test/e2e/specs/slack-flow.spec.ts" "slack" "providers" + run "test/e2e/specs/whatsapp-flow.spec.ts" "whatsapp" "providers" + # notion-flow.spec.ts was removed; skip to avoid "spec not found" failure. + # run "test/e2e/specs/notion-flow.spec.ts" "notion" "providers" + run "test/e2e/specs/conversations-web-channel-flow.spec.ts" "conversations" "providers" + run "test/e2e/specs/composio-triggers-flow.spec.ts" "composio-triggers" "providers" + run "test/e2e/specs/connectivity-state-differentiation.spec.ts" "connectivity-state" "providers" + _mini_summary "providers" +fi + +# --------------------------------------------------------------------------- +# Composio connector smoke specs. +# +# Split out of the `providers` suite into its own `connectors` shard so the +# 17 connector specs don't share a CEF session with the heavier provider +# flows (slack/whatsapp/etc.). The shared CEF process leaks resources over +# ~30+ specs and the second half of the suite hits 'A sessionId is +# required' / __simulateDeepLink-not-ready errors mid-run. +# --------------------------------------------------------------------------- +if should_run_suite "connectors"; then + echo "" + echo "## Running suite: connectors" + run "test/e2e/specs/connector-airtable.spec.ts" "connector-airtable" "connectors" + run "test/e2e/specs/connector-asana.spec.ts" "connector-asana" "connectors" + run "test/e2e/specs/connector-clickup.spec.ts" "connector-clickup" "connectors" + run "test/e2e/specs/connector-confluence.spec.ts" "connector-confluence" "connectors" + run "test/e2e/specs/connector-discord-composio.spec.ts" "connector-discord" "connectors" + run "test/e2e/specs/connector-github.spec.ts" "connector-github" "connectors" + run "test/e2e/specs/connector-gmail-composio.spec.ts" "connector-gmail-composio" "connectors" + run "test/e2e/specs/connector-google-calendar.spec.ts" "connector-gcal" "connectors" + run "test/e2e/specs/connector-google-drive.spec.ts" "connector-gdrive" "connectors" + run "test/e2e/specs/connector-google-sheets.spec.ts" "connector-gsheets" "connectors" + run "test/e2e/specs/connector-jira.spec.ts" "connector-jira" "connectors" + run "test/e2e/specs/connector-notion.spec.ts" "connector-notion" "connectors" + run "test/e2e/specs/connector-session-guard.spec.ts" "connector-session-guard" "connectors" + run "test/e2e/specs/connector-slack-composio.spec.ts" "connector-slack-composio" "connectors" + run "test/e2e/specs/connector-todoist.spec.ts" "connector-todoist" "connectors" + run "test/e2e/specs/connector-youtube.spec.ts" "connector-youtube" "connectors" + _mini_summary "connectors" +fi # --------------------------------------------------------------------------- # Payments & rewards # --------------------------------------------------------------------------- -run "test/e2e/specs/card-payment-flow.spec.ts" "card-payment" -run "test/e2e/specs/crypto-payment-flow.spec.ts" "crypto-payment" -run "test/e2e/specs/rewards-unlock-flow.spec.ts" "rewards-unlock" -run "test/e2e/specs/rewards-progression-persistence.spec.ts" "rewards-progression" +if should_run_suite "payments"; then + echo "" + echo "## Running suite: payments" + run "test/e2e/specs/card-payment-flow.spec.ts" "card-payment" "payments" + run "test/e2e/specs/crypto-payment-flow.spec.ts" "crypto-payment" "payments" + run "test/e2e/specs/rewards-unlock-flow.spec.ts" "rewards-unlock" "payments" + run "test/e2e/specs/rewards-progression-persistence.spec.ts" "rewards-progression" "payments" + _mini_summary "payments" +fi # --------------------------------------------------------------------------- # Settings panels # --------------------------------------------------------------------------- -run "test/e2e/specs/settings-channels-permissions.spec.ts" "settings-channels" -run "test/e2e/specs/settings-data-management.spec.ts" "settings-data" -run "test/e2e/specs/settings-dev-options.spec.ts" "settings-dev" -run "test/e2e/specs/settings-ai-skills.spec.ts" "settings-ai-skills" -run "test/e2e/specs/settings-account-preferences.spec.ts" "settings-account" -run "test/e2e/specs/settings-advanced-config.spec.ts" "settings-advanced" -run "test/e2e/specs/settings-feature-preferences.spec.ts" "settings-features" +if should_run_suite "settings"; then + echo "" + echo "## Running suite: settings" + run "test/e2e/specs/settings-channels-permissions.spec.ts" "settings-channels" "settings" + run "test/e2e/specs/settings-data-management.spec.ts" "settings-data" "settings" + run "test/e2e/specs/settings-dev-options.spec.ts" "settings-dev" "settings" + run "test/e2e/specs/settings-ai-skills.spec.ts" "settings-ai-skills" "settings" + run "test/e2e/specs/settings-account-preferences.spec.ts" "settings-account" "settings" + run "test/e2e/specs/settings-advanced-config.spec.ts" "settings-advanced" "settings" + run "test/e2e/specs/settings-feature-preferences.spec.ts" "settings-features" "settings" + _mini_summary "settings" +fi + +# --------------------------------------------------------------------------- +# System / AI / voice / screen / Tauri +# linux-cef-deb-runtime.spec.ts is Linux-only (tests /usr/bin path resolution +# for .deb package installs) — skipped on macOS/Windows. +# --------------------------------------------------------------------------- +if should_run_suite "system"; then + echo "" + echo "## Running suite: system" + run "test/e2e/specs/local-model-runtime.spec.ts" "local-model" "system" + run "test/e2e/specs/voice-mode.spec.ts" "voice-mode" "system" + run "test/e2e/specs/screen-intelligence.spec.ts" "screen-intelligence" "system" + run "test/e2e/specs/audio-toolkit-flow.spec.ts" "audio-toolkit" "system" + run "test/e2e/specs/tauri-commands.spec.ts" "tauri-commands" "system" + # service-connectivity-flow tests the old sidecar service model removed in + # PR #1061 (core is now in-process). Skip by not setting OPENHUMAN_SERVICE_MOCK=1. + run "test/e2e/specs/service-connectivity-flow.spec.ts" "service-connectivity" "system" + run "test/e2e/specs/core-port-conflict-recovery.spec.ts" "core-port-conflict" "system" + if [[ "$(uname -s)" == "Linux" ]]; then + run "test/e2e/specs/linux-cef-deb-runtime.spec.ts" "linux-cef-deb-runtime" "system" + fi + _mini_summary "system" +fi # --------------------------------------------------------------------------- -# AI, voice & screen +# User journeys # --------------------------------------------------------------------------- -run "test/e2e/specs/local-model-runtime.spec.ts" "local-model" -run "test/e2e/specs/voice-mode.spec.ts" "voice-mode" -run "test/e2e/specs/audio-toolkit-flow.spec.ts" "audio-toolkit" +if should_run_suite "journeys"; then + echo "" + echo "## Running suite: journeys" + run "test/e2e/specs/user-journey-full-task.spec.ts" "journey-full-task" "journeys" + run "test/e2e/specs/user-journey-settings-round-trip.spec.ts" "journey-settings" "journeys" + run "test/e2e/specs/chat-conversation-history.spec.ts" "chat-history" "journeys" + _mini_summary "journeys" +fi # --------------------------------------------------------------------------- -# System / Tauri +# Single shared WDIO session. +# +# All collected specs run inside one Appium/CEF session, restoring the +# contract in wdio.conf.ts. Per-spec pass/fail comes from WDIO's spec +# reporter (live stdout above). Exit code from e2e-run-session.sh is +# propagated to the `finish` summary trap. +# +# `--bail` is forwarded via E2E_BAIL_ON_FAILURE (wdio.conf.ts flips its +# `bail` count when this env is set). # --------------------------------------------------------------------------- -run "test/e2e/specs/tauri-commands.spec.ts" "tauri-commands" -OPENHUMAN_SERVICE_MOCK=1 \ - run "test/e2e/specs/service-connectivity-flow.spec.ts" "service-connectivity" +if [[ ${#_spec_paths[@]} -eq 0 ]]; then + echo "[e2e-run-all-flows] no specs matched suite=$SUITE — nothing to run." >&2 + exit 1 +fi -# linux-cef-deb-runtime.spec.ts is Linux-only (tests /usr/bin path resolution -# for .deb package installs) — skipped on macOS/Windows. -if [[ "$(uname -s)" == "Linux" ]]; then - run "test/e2e/specs/linux-cef-deb-runtime.spec.ts" "linux-cef-deb-runtime" +echo "" +echo "──────────────────────────────────────────────────────────────────" +echo " Launching single shared WDIO session for ${#_spec_paths[@]} spec(s)" +echo "──────────────────────────────────────────────────────────────────" + +if [[ $BAIL -eq 1 ]]; then + export E2E_BAIL_ON_FAILURE=1 fi + +set +e +bash "$APP_DIR/scripts/e2e-run-session.sh" "${_spec_paths[@]}" +_WDIO_EXIT_CODE=$? +set -e + +# finish() trap will print the summary and exit with _WDIO_EXIT_CODE. diff --git a/app/scripts/e2e-run-session.sh b/app/scripts/e2e-run-session.sh index 195d6d6ff2..e2c8e546ef 100755 --- a/app/scripts/e2e-run-session.sh +++ b/app/scripts/e2e-run-session.sh @@ -19,8 +19,31 @@ # set -euo pipefail -SPEC_ARG="${1:-}" -LOG_SUFFIX="${2:-session}" +# Accept either: +# - Zero args → run the entire `specs` glob from wdio.conf.ts +# - One spec path arg → legacy single-spec mode (e2e-run-spec.sh shim) +# - One spec + log suffix → legacy two-arg mode used by debug runner / CI +# - N>1 spec paths → multi-spec mode, one shared session +# +# To disambiguate "spec + suffix" from "two specs", we treat arg2 as a log +# suffix only when it does NOT look like a spec path (i.e. doesn't end in +# `.spec.ts` and doesn't start with `test/`). +SPEC_ARGS=() +LOG_SUFFIX="session" +if [ "$#" -ge 1 ]; then + SPEC_ARGS+=("$1") + if [ "$#" -eq 2 ] && [[ "$2" != *.spec.ts && "$2" != test/* ]]; then + LOG_SUFFIX="$2" + else + shift + while [ "$#" -gt 0 ]; do + SPEC_ARGS+=("$1") + shift + done + fi +fi +# Back-compat: SPEC_ARG is the first spec (only used in stale log lines below). +SPEC_ARG="${SPEC_ARGS[0]:-}" E2E_MOCK_PORT="${E2E_MOCK_PORT:-18473}" CEF_CDP_PORT="${CEF_CDP_PORT:-19222}" @@ -51,6 +74,13 @@ else echo "[runner] Using OPENHUMAN_WORKSPACE from environment: $OPENHUMAN_WORKSPACE" fi +# Headless Linux CI does not always have a usable Secret Service/keychain. +# Keep E2E credentials under OPENHUMAN_WORKSPACE so auth state is deterministic +# and gets cleaned up with the rest of the test workspace. +: "${OPENHUMAN_KEYRING_BACKEND:=file}" +export OPENHUMAN_KEYRING_BACKEND +echo "[runner] Using OPENHUMAN_KEYRING_BACKEND: $OPENHUMAN_KEYRING_BACKEND" + # Place the CEF cache directory OUTSIDE the workspace. By default the Tauri # shell roots it under `$OPENHUMAN_WORKSPACE/users//cef`, but our # `mega-flow` spec calls `openhuman.config_reset_local_data` between @@ -138,6 +168,11 @@ export BACKEND_URL="http://127.0.0.1:${E2E_MOCK_PORT}" export OPENHUMAN_E2E_MODE="1" export APPIUM_PORT export CEF_CDP_PORT +# Redirect Telegram Bot API calls to the mock server during E2E runs. +# The mock server (WS-A) serves /bot/* routes on the same port as the +# rest of the mock backend. The core reads this at TelegramChannel::new() time, +# which runs after the config is fully loaded. +export OPENHUMAN_TELEGRAM_BOT_API_BASE="http://127.0.0.1:${E2E_MOCK_PORT}" echo "[runner] Killing any running OpenHuman instances..." case "$OS" in @@ -199,6 +234,11 @@ fi cat > "$E2E_CONFIG_FILE" << TOMLEOF api_url = "http://127.0.0.1:${E2E_MOCK_PORT}" primary_cloud = "p_e2e_mock" +default_model = "e2e-mock-model" +chat_provider = "e2e:e2e-mock-model" +reasoning_provider = "e2e:e2e-mock-model" +agentic_provider = "e2e:e2e-mock-model" +coding_provider = "e2e:e2e-mock-model" [[cloud_providers]] id = "p_e2e_mock" @@ -206,6 +246,7 @@ slug = "e2e" label = "E2E Mock" endpoint = "http://127.0.0.1:${E2E_MOCK_PORT}/openai/v1" auth_style = "none" +default_model = "e2e-mock-model" TOMLEOF echo "[runner] Wrote E2E config.toml routing inference to mock at http://127.0.0.1:${E2E_MOCK_PORT}" @@ -587,9 +628,14 @@ done # ------------------------------------------------------------------------------ # Run WDIO # ------------------------------------------------------------------------------ -if [ -n "$SPEC_ARG" ]; then - echo "[runner] Running single spec: $SPEC_ARG" - pnpm exec wdio run test/wdio.conf.ts --spec "$SPEC_ARG" +if [ "${#SPEC_ARGS[@]}" -gt 0 ]; then + echo "[runner] Running ${#SPEC_ARGS[@]} spec(s) in a single shared session:" + printf ' %s\n' "${SPEC_ARGS[@]}" + WDIO_SPEC_ARGS=() + for s in "${SPEC_ARGS[@]}"; do + WDIO_SPEC_ARGS+=(--spec "$s") + done + pnpm exec wdio run test/wdio.conf.ts "${WDIO_SPEC_ARGS[@]}" else echo "[runner] Running full E2E suite (single shared session)..." pnpm exec wdio run test/wdio.conf.ts diff --git a/app/scripts/e2e-run-shards.sh b/app/scripts/e2e-run-shards.sh new file mode 100755 index 0000000000..f6f9b8d5cc --- /dev/null +++ b/app/scripts/e2e-run-shards.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# +# Local equivalent of the CI shard matrix — runs each suite group as a +# separate fresh WDIO session, matching `.github/workflows/e2e-reusable.yml`'s +# `e2e-linux-full` matrix. Mirroring CI exactly is the only way to reproduce +# CI failures locally: a single shared session that runs all 87 specs hits +# CEF/esbuild instability after ~30 specs. +# +# Usage (from repo root, inside the openhuman_ci Docker container): +# bash app/scripts/e2e-run-shards.sh +# +# Or via docker-compose (from the host): +# docker compose -f e2e/docker-compose.yml run --rm e2e \ +# bash -lc "bash app/scripts/e2e-run-shards.sh" +# +# Shards mirror the CI matrix in .github/workflows/e2e-reusable.yml: +# foundation = auth, navigation, system +# chat = chat, skills, journeys +# integrations = providers, webhooks, notifications +# connectors = connectors +# commerce = payments, settings +# +set -uo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$REPO_ROOT" + +# Same matrix as e2e-reusable.yml. +SHARDS=( + "foundation:auth,navigation,system" + "chat:chat,skills,journeys" + "providers:providers,notifications" + "webhooks:webhooks" + "connectors:connectors" + "commerce:payments,settings" +) + +# Allow filtering: `bash e2e-run-shards.sh foundation chat` +if [ "$#" -gt 0 ]; then + WANT=("$@") + FILTERED=() + for shard in "${SHARDS[@]}"; do + name="${shard%%:*}" + for w in "${WANT[@]}"; do + if [ "$name" = "$w" ]; then + FILTERED+=("$shard") + break + fi + done + done + SHARDS=("${FILTERED[@]}") +fi + +declare -a RESULTS +overall_status=0 + +for shard in "${SHARDS[@]}"; do + name="${shard%%:*}" + suites="${shard#*:}" + echo "" + echo "════════════════════════════════════════════════════════════════" + echo " Shard: ${name} (suites: ${suites})" + echo "════════════════════════════════════════════════════════════════" + + if bash app/scripts/e2e-run-all-flows.sh --skip-preflight --suite="${suites}"; then + RESULTS+=("${name}: PASS") + else + RESULTS+=("${name}: FAIL") + overall_status=1 + fi +done + +echo "" +echo "════════════════════════════════════════════════════════════════" +echo " Shard summary" +echo "════════════════════════════════════════════════════════════════" +for r in "${RESULTS[@]}"; do + printf " %s\n" "$r" +done +echo "" + +exit "$overall_status" diff --git a/app/scripts/e2e-web-build.sh b/app/scripts/e2e-web-build.sh new file mode 100755 index 0000000000..3a5112be93 --- /dev/null +++ b/app/scripts/e2e-web-build.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash + +set -euo pipefail + +APP_DIR="$(cd "$(dirname "$0")/.." && pwd)" +REPO_ROOT="$(cd "$APP_DIR/.." && pwd)" +cd "$APP_DIR" + +RUST_HOST_TRIPLE="${RUST_HOST_TRIPLE:-$(rustc -vV | awk '/^host: / { print $2 }')}" +E2E_WEB_CORE_TARGET_DIR="${E2E_WEB_CORE_TARGET_DIR:-$REPO_ROOT/target/e2e-web-${RUST_HOST_TRIPLE}}" + +export VITE_BACKEND_URL="http://127.0.0.1:${E2E_MOCK_PORT:-18473}" +export VITE_OPENHUMAN_TARGET="web" +export VITE_OPENHUMAN_E2E_DEFAULT_CORE_MODE="cloud" +export VITE_OPENHUMAN_E2E_RESTART_APP_AS_RELOAD="true" +export VITE_OPENHUMAN_CORE_RPC_URL="http://127.0.0.1:${OPENHUMAN_CORE_PORT:-17788}/rpc" + +if [ -f "$REPO_ROOT/.env" ]; then + # shellcheck source=/dev/null + source "$REPO_ROOT/scripts/load-dotenv.sh" +fi + +echo "Building web E2E bundle with backend ${VITE_BACKEND_URL}" +pnpm run build:web +echo "Building standalone openhuman-core for web E2E into ${E2E_WEB_CORE_TARGET_DIR}..." +CARGO_TARGET_DIR="$E2E_WEB_CORE_TARGET_DIR" cargo build --manifest-path "$REPO_ROOT/Cargo.toml" --bin openhuman-core diff --git a/app/scripts/e2e-web-session.sh b/app/scripts/e2e-web-session.sh new file mode 100755 index 0000000000..ea925f59c7 --- /dev/null +++ b/app/scripts/e2e-web-session.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +APP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +REPO_ROOT="$(cd "$APP_DIR/.." && pwd)" +cd "$APP_DIR" + +RUST_HOST_TRIPLE="${RUST_HOST_TRIPLE:-$(rustc -vV | awk '/^host: / { print $2 }')}" +E2E_WEB_CORE_TARGET_DIR="${E2E_WEB_CORE_TARGET_DIR:-$REPO_ROOT/target/e2e-web-${RUST_HOST_TRIPLE}}" +E2E_MOCK_PORT="${E2E_MOCK_PORT:-18473}" +OPENHUMAN_CORE_PORT="${OPENHUMAN_CORE_PORT:-17788}" +E2E_WEB_PORT="${E2E_WEB_PORT:-4173}" +PW_CORE_RPC_TOKEN="${PW_CORE_RPC_TOKEN:-openhuman-playwright-token}" +PW_CORE_RPC_URL="http://127.0.0.1:${OPENHUMAN_CORE_PORT}/rpc" +PW_BASE_URL="http://127.0.0.1:${E2E_WEB_PORT}" + +OPENHUMAN_WORKSPACE="${OPENHUMAN_WORKSPACE:-$(mktemp -d)}" +CREATED_TEMP_WORKSPACE="" +if [ ! -d "${OPENHUMAN_WORKSPACE}" ] || [[ "${OPENHUMAN_WORKSPACE}" == /tmp/* ]]; then + CREATED_TEMP_WORKSPACE="$OPENHUMAN_WORKSPACE" +fi +export OPENHUMAN_WORKSPACE +export OPENHUMAN_KEYRING_BACKEND="${OPENHUMAN_KEYRING_BACKEND:-file}" + +MOCK_PID="" +CORE_PID="" +WEB_PID="" + +cleanup() { + local status=$? + set +e + if [ -n "$WEB_PID" ]; then + kill "$WEB_PID" 2>/dev/null || true + wait "$WEB_PID" 2>/dev/null || true + fi + if [ -n "$CORE_PID" ]; then + kill "$CORE_PID" 2>/dev/null || true + wait "$CORE_PID" 2>/dev/null || true + fi + if [ -n "$MOCK_PID" ]; then + kill "$MOCK_PID" 2>/dev/null || true + wait "$MOCK_PID" 2>/dev/null || true + fi + if [ -n "$CREATED_TEMP_WORKSPACE" ]; then + rm -rf "$CREATED_TEMP_WORKSPACE" + fi + return "$status" +} +trap cleanup EXIT + +wait_for_http() { + local url="$1" + local name="$2" + for _ in $(seq 1 90); do + if curl -fsS "$url" >/dev/null 2>&1; then + return 0 + fi + sleep 1 + done + echo "ERROR: ${name} did not become ready at ${url}" >&2 + return 1 +} + +wait_for_rpc_auth() { + local rpc_url="$1" + local token="$2" + for _ in $(seq 1 30); do + if curl -fsS "$rpc_url" \ + -H 'Content-Type: application/json' \ + -H "Authorization: Bearer $token" \ + -d '{"jsonrpc":"2.0","id":1,"method":"core.ping","params":{}}' >/dev/null 2>&1; then + return 0 + fi + sleep 1 + done + echo "ERROR: authenticated RPC probe failed for ${rpc_url}" >&2 + return 1 +} + +mkdir -p "$OPENHUMAN_WORKSPACE" +cat > "$OPENHUMAN_WORKSPACE/config.toml" <"$OPENHUMAN_WORKSPACE/mock.log" 2>&1 & +MOCK_PID=$! +wait_for_http "http://127.0.0.1:${E2E_MOCK_PORT}/__admin/health" "mock backend" + +OPENHUMAN_CORE_BIN="$E2E_WEB_CORE_TARGET_DIR/debug/openhuman-core" +if [ ! -x "$OPENHUMAN_CORE_BIN" ]; then + echo "ERROR: standalone core binary is missing at $OPENHUMAN_CORE_BIN. Run pnpm test:e2e:web:build first." >&2 + exit 1 +fi + +export OPENHUMAN_CORE_TOKEN="$PW_CORE_RPC_TOKEN" +export OPENHUMAN_TELEGRAM_BOT_API_BASE="http://127.0.0.1:${E2E_MOCK_PORT}" + +"$OPENHUMAN_CORE_BIN" run --host 127.0.0.1 --port "$OPENHUMAN_CORE_PORT" \ + >"$OPENHUMAN_WORKSPACE/core.log" 2>&1 & +CORE_PID=$! +wait_for_http "http://127.0.0.1:${OPENHUMAN_CORE_PORT}/health" "standalone core" +wait_for_rpc_auth "$PW_CORE_RPC_URL" "$PW_CORE_RPC_TOKEN" + +python3 -m http.server "$E2E_WEB_PORT" --bind 127.0.0.1 --directory "$APP_DIR/dist-web" \ + >"$OPENHUMAN_WORKSPACE/web.log" 2>&1 & +WEB_PID=$! +wait_for_http "$PW_BASE_URL" "web host" + +export PW_BASE_URL +export PW_CORE_RPC_URL +export PW_CORE_RPC_TOKEN + +pnpm exec playwright test "$@" diff --git a/app/src-tauri-mobile/.gitignore b/app/src-tauri-mobile/.gitignore new file mode 100644 index 0000000000..2f2c74da8d --- /dev/null +++ b/app/src-tauri-mobile/.gitignore @@ -0,0 +1,2 @@ +target/ +gen/ diff --git a/app/src-tauri-mobile/Cargo.lock b/app/src-tauri-mobile/Cargo.lock new file mode 100644 index 0000000000..1698af3533 --- /dev/null +++ b/app/src-tauri-mobile/Cargo.lock @@ -0,0 +1,4647 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.11.1", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.11.1", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.11.1", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.11.1", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash 0.2.0", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.2+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "env_filter" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.11.1", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jiff" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.11.1", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "libc", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "muda" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47a2e3dff89cd322c66647942668faee0a2b1f88ea6cbb4d374b4a8d7e92528c" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.11.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.1", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.11.1", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openhuman-mobile" +version = "0.54.10" +dependencies = [ + "env_logger", + "log", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-barcode-scanner", + "tauri-plugin-ptt", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plist" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.11+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.11.1", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_with" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.35.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +dependencies = [ + "bitflags 2.11.1", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "data-url", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.18", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9" +dependencies = [ + "base64 0.22.1", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.117", + "tauri-utils", + "thiserror 2.0.18", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-barcode-scanner" +version = "2.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "485cbcf227f04117e930be748ea71d835900466dcd1d455d5ec284d36107a305" +dependencies = [ + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + +[[package]] +name = "tauri-plugin-ptt" +version = "0.1.0" +dependencies = [ + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + +[[package]] +name = "tauri-runtime" +version = "2.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95" +dependencies = [ + "anyhow", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.2+spec-1.1.0", +] + +[[package]] +name = "tendril" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" +dependencies = [ + "new_debug_unreachable", + "utf-8", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.3", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.11.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15edbb0d80583e85ee8df283410038e17314df5cba30da2087a54a85216c0773" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.14.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.18", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.14.0", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.1", + "indexmap 2.14.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.14.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", +] + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/app/src-tauri-mobile/Cargo.toml b/app/src-tauri-mobile/Cargo.toml new file mode 100644 index 0000000000..c74807f239 --- /dev/null +++ b/app/src-tauri-mobile/Cargo.toml @@ -0,0 +1,58 @@ +[package] +name = "openhuman-mobile" +version = "0.54.10" +description = "OpenHuman mobile (iOS) — Tauri host without CEF" +authors = ["OpenHuman"] +edition = "2021" +default-run = "openhuman-mobile" +autobins = false + +# Mobile host is iOS-only. Block other targets so this crate never gets pulled +# into a desktop build by accident. +[lib] +name = "openhuman_mobile" +crate-type = ["staticlib", "cdylib", "rlib"] + +[[bin]] +name = "openhuman-mobile" +path = "src/main.rs" + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +# Stock upstream Tauri — no vendored CEF runtime. The mobile host renders via +# WKWebView (iOS) / WebView (the Tauri default), not Chromium. CSP and the +# React app are identical to desktop; only the host process is different. +tauri = { version = "2.10", default-features = false, features = [ + "common-controls-v6", + "devtools", + "unstable", + "webview-data-url", + "wry", +] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +log = "0.4" +env_logger = "0.11" + +# iOS gets the QR scanner + push-to-talk plugins. PTT ships Swift sources +# under packages/tauri-plugin-ptt/ios/. +[target.'cfg(target_os = "ios")'.dependencies] +tauri-plugin-barcode-scanner = "2" +tauri-plugin-ptt = { path = "../../packages/tauri-plugin-ptt" } + +# Android gets the QR scanner only. PTT returns `NotSupported` on Android — +# we don't ship a Kotlin implementation today (tracked as a follow-up). +[target.'cfg(target_os = "android")'.dependencies] +tauri-plugin-barcode-scanner = "2" +tauri-plugin-ptt = { path = "../../packages/tauri-plugin-ptt" } + +[features] +default = [] +custom-protocol = ["tauri/custom-protocol"] + +# Match the desktop release profile for binary size. +[profile.release] +debug = "line-tables-only" +split-debuginfo = "packed" diff --git a/app/src-tauri-mobile/Info.plist b/app/src-tauri-mobile/Info.plist new file mode 100644 index 0000000000..da43cc5fc2 --- /dev/null +++ b/app/src-tauri-mobile/Info.plist @@ -0,0 +1,12 @@ + + + + + NSCameraUsageDescription + OpenHuman uses the camera to scan the pairing QR code from your desktop. + NSMicrophoneUsageDescription + OpenHuman uses the microphone for push-to-talk voice messages. + NSSpeechRecognitionUsageDescription + OpenHuman uses on-device speech recognition to transcribe your voice messages. + + diff --git a/app/src-tauri-mobile/build.rs b/app/src-tauri-mobile/build.rs new file mode 100644 index 0000000000..c9c1e181c7 --- /dev/null +++ b/app/src-tauri-mobile/build.rs @@ -0,0 +1,5 @@ +fn main() { + println!("cargo:rerun-if-changed=permissions"); + println!("cargo:rerun-if-changed=capabilities"); + tauri_build::build(); +} diff --git a/app/src-tauri-mobile/capabilities/default.json b/app/src-tauri-mobile/capabilities/default.json new file mode 100644 index 0000000000..e0d85b68df --- /dev/null +++ b/app/src-tauri-mobile/capabilities/default.json @@ -0,0 +1,13 @@ +{ + "$schema": "../gen/schemas/mobile-schema.json", + "identifier": "mobile-default", + "description": "Capability shared between the iOS and Android targets.", + "platforms": ["iOS", "android"], + "windows": ["main"], + "permissions": [ + "core:default", + "core:event:default", + "barcode-scanner:allow-scan", + "barcode-scanner:allow-cancel" + ] +} diff --git a/app/src-tauri-mobile/capabilities/ios.json b/app/src-tauri-mobile/capabilities/ios.json new file mode 100644 index 0000000000..05e425653c --- /dev/null +++ b/app/src-tauri-mobile/capabilities/ios.json @@ -0,0 +1,14 @@ +{ + "$schema": "../gen/schemas/mobile-schema.json", + "identifier": "ios-ptt", + "description": "Push-to-talk permissions — iOS only (Swift AVAudioEngine/SFSpeechRecognizer/AVSpeechSynthesizer bridge).", + "platforms": ["iOS"], + "windows": ["main"], + "permissions": [ + "ptt:allow-start-listening", + "ptt:allow-stop-listening", + "ptt:allow-speak", + "ptt:allow-cancel-speech", + "ptt:allow-list-voices" + ] +} diff --git a/app/src-tauri-mobile/icons/README.md b/app/src-tauri-mobile/icons/README.md new file mode 100644 index 0000000000..155d25059b --- /dev/null +++ b/app/src-tauri-mobile/icons/README.md @@ -0,0 +1,17 @@ +# Mobile app icons + +Brand-quality icons committed to the repo so initial `tauri ios init` / +`tauri android init` runs produce a real-looking app instead of the +placeholder Tauri ships. + +| Path | Used by | +| --- | --- | +| `icon.png` (1024×1024) | `tauri.conf.json#bundle.icon` — Tauri build pipeline | +| `ios/AppIcon.appiconset/*` | Copied by `scripts/ios-init.sh` into `gen/apple/_iOS/Assets.xcassets/AppIcon.appiconset/` after init | +| `android/mipmap-{m,h,xh,xxh,xxxh}dpi/ic_launcher.png` | Copied by `scripts/android-init.sh` into `gen/android/app/src/main/res/mipmap-*/` after init | +| `store/appstore.png` (1024×1024) | App Store Connect upload | +| `store/playstore.png` (512×512) | Google Play Console upload | + +The `gen/` directory is `.gitignore`d (Tauri regenerates it from +`tauri.conf.json` on every `init`), so the canonical source for icons +must live here, not under `gen/`. diff --git a/app/src-tauri-mobile/icons/android/mipmap-hdpi/ic_launcher.png b/app/src-tauri-mobile/icons/android/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000..14ef25da02 Binary files /dev/null and b/app/src-tauri-mobile/icons/android/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src-tauri-mobile/icons/android/mipmap-mdpi/ic_launcher.png b/app/src-tauri-mobile/icons/android/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000..a1e978fa54 Binary files /dev/null and b/app/src-tauri-mobile/icons/android/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src-tauri-mobile/icons/android/mipmap-xhdpi/ic_launcher.png b/app/src-tauri-mobile/icons/android/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000..0df845d7f4 Binary files /dev/null and b/app/src-tauri-mobile/icons/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src-tauri-mobile/icons/android/mipmap-xxhdpi/ic_launcher.png b/app/src-tauri-mobile/icons/android/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000..d0ee56f449 Binary files /dev/null and b/app/src-tauri-mobile/icons/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src-tauri-mobile/icons/android/mipmap-xxxhdpi/ic_launcher.png b/app/src-tauri-mobile/icons/android/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000..f6e0655c19 Binary files /dev/null and b/app/src-tauri-mobile/icons/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src-tauri-mobile/icons/icon.png b/app/src-tauri-mobile/icons/icon.png new file mode 100644 index 0000000000..d92136804f Binary files /dev/null and b/app/src-tauri-mobile/icons/icon.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/100.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/100.png new file mode 100644 index 0000000000..a9fa9d6f92 Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/100.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/102.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/102.png new file mode 100644 index 0000000000..93e3b35164 Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/102.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/1024.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/1024.png new file mode 100644 index 0000000000..d92136804f Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/1024.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/108.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/108.png new file mode 100644 index 0000000000..959e7dfbf9 Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/108.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/114.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/114.png new file mode 100644 index 0000000000..1a95151bae Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/114.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/120.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/120.png new file mode 100644 index 0000000000..697ff31d03 Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/120.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/128.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/128.png new file mode 100644 index 0000000000..b08e9c29d1 Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/128.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/144.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/144.png new file mode 100644 index 0000000000..dc238d4f5a Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/144.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/152.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/152.png new file mode 100644 index 0000000000..8aa0968c1b Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/152.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/16.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/16.png new file mode 100644 index 0000000000..1be3d28844 Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/16.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/167.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/167.png new file mode 100644 index 0000000000..7668134d70 Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/167.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/172.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/172.png new file mode 100644 index 0000000000..905b8ab6c1 Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/172.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/180.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/180.png new file mode 100644 index 0000000000..078ac3d50c Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/180.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/196.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/196.png new file mode 100644 index 0000000000..dbcb6287dc Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/196.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/20.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/20.png new file mode 100644 index 0000000000..820710f4a0 Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/20.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/216.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/216.png new file mode 100644 index 0000000000..9fbde80e6a Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/216.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/234.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/234.png new file mode 100644 index 0000000000..0faedd1a18 Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/234.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/256.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/256.png new file mode 100644 index 0000000000..09a08be78d Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/256.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/258.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/258.png new file mode 100644 index 0000000000..6c415b1114 Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/258.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/29.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/29.png new file mode 100644 index 0000000000..1386beb558 Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/29.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/32.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/32.png new file mode 100644 index 0000000000..5bf28d8b31 Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/32.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/40.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/40.png new file mode 100644 index 0000000000..aaf92d5343 Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/40.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/48.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/48.png new file mode 100644 index 0000000000..4a5393537e Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/48.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/50.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/50.png new file mode 100644 index 0000000000..dbb244d1a4 Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/50.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/512.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/512.png new file mode 100644 index 0000000000..3da73eb23f Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/512.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/55.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/55.png new file mode 100644 index 0000000000..34587a337a Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/55.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/57.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/57.png new file mode 100644 index 0000000000..553ec63d1b Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/57.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/58.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/58.png new file mode 100644 index 0000000000..0cafd08d63 Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/58.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/60.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/60.png new file mode 100644 index 0000000000..1fe4b9ee2d Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/60.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/64.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/64.png new file mode 100644 index 0000000000..938393214d Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/64.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/66.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/66.png new file mode 100644 index 0000000000..531ec7d7be Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/66.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/72.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/72.png new file mode 100644 index 0000000000..3c220f552f Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/72.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/76.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/76.png new file mode 100644 index 0000000000..4b8865fadb Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/76.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/80.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/80.png new file mode 100644 index 0000000000..fe1262907b Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/80.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/87.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/87.png new file mode 100644 index 0000000000..283e5db6cd Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/87.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/88.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/88.png new file mode 100644 index 0000000000..917063c24b Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/88.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/92.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/92.png new file mode 100644 index 0000000000..6baffd9a1a Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/92.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/Contents.json b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..1319290d43 --- /dev/null +++ b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/Contents.json @@ -0,0 +1 @@ +{"images":[{"size":"60x60","expected-size":"180","filename":"180.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"40x40","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"60x60","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"57x57","expected-size":"57","filename":"57.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"87","filename":"87.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"57x57","expected-size":"114","filename":"114.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"60","filename":"60.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"1024x1024","filename":"1024.png","expected-size":"1024","idiom":"ios-marketing","folder":"Assets.xcassets/AppIcon.appiconset/","scale":"1x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"72x72","expected-size":"72","filename":"72.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"76x76","expected-size":"152","filename":"152.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"50x50","expected-size":"100","filename":"100.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"76x76","expected-size":"76","filename":"76.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"50x50","expected-size":"50","filename":"50.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"72x72","expected-size":"144","filename":"144.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"40x40","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"83.5x83.5","expected-size":"167","filename":"167.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"20x20","expected-size":"20","filename":"20.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"idiom":"watch","filename":"172.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"38mm","scale":"2x","size":"86x86","expected-size":"172","role":"quickLook"},{"idiom":"watch","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"38mm","scale":"2x","size":"40x40","expected-size":"80","role":"appLauncher"},{"idiom":"watch","filename":"88.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"40mm","scale":"2x","size":"44x44","expected-size":"88","role":"appLauncher"},{"idiom":"watch","filename":"102.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"45mm","scale":"2x","size":"51x51","expected-size":"102","role":"appLauncher"},{"idiom":"watch","filename":"108.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"49mm","scale":"2x","size":"54x54","expected-size":"108","role":"appLauncher"},{"idiom":"watch","filename":"92.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"41mm","scale":"2x","size":"46x46","expected-size":"92","role":"appLauncher"},{"idiom":"watch","filename":"100.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"44mm","scale":"2x","size":"50x50","expected-size":"100","role":"appLauncher"},{"idiom":"watch","filename":"196.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"42mm","scale":"2x","size":"98x98","expected-size":"196","role":"quickLook"},{"idiom":"watch","filename":"216.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"44mm","scale":"2x","size":"108x108","expected-size":"216","role":"quickLook"},{"idiom":"watch","filename":"234.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"45mm","scale":"2x","size":"117x117","expected-size":"234","role":"quickLook"},{"idiom":"watch","filename":"258.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"49mm","scale":"2x","size":"129x129","expected-size":"258","role":"quickLook"},{"idiom":"watch","filename":"48.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"38mm","scale":"2x","size":"24x24","expected-size":"48","role":"notificationCenter"},{"idiom":"watch","filename":"55.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"42mm","scale":"2x","size":"27.5x27.5","expected-size":"55","role":"notificationCenter"},{"idiom":"watch","filename":"66.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"45mm","scale":"2x","size":"33x33","expected-size":"66","role":"notificationCenter"},{"size":"29x29","expected-size":"87","filename":"87.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"watch","role":"companionSettings","scale":"3x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"watch","role":"companionSettings","scale":"2x"},{"size":"1024x1024","expected-size":"1024","filename":"1024.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"watch-marketing","scale":"1x"},{"size":"128x128","expected-size":"128","filename":"128.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"256x256","expected-size":"256","filename":"256.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"128x128","expected-size":"256","filename":"256.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"256x256","expected-size":"512","filename":"512.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"32x32","expected-size":"32","filename":"32.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"512x512","expected-size":"512","filename":"512.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"16x16","expected-size":"16","filename":"16.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"16x16","expected-size":"32","filename":"32.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"32x32","expected-size":"64","filename":"64.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"512x512","expected-size":"1024","filename":"1024.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"}]} \ No newline at end of file diff --git a/app/src-tauri-mobile/icons/store/appstore.png b/app/src-tauri-mobile/icons/store/appstore.png new file mode 100644 index 0000000000..d92136804f Binary files /dev/null and b/app/src-tauri-mobile/icons/store/appstore.png differ diff --git a/app/src-tauri-mobile/icons/store/playstore.png b/app/src-tauri-mobile/icons/store/playstore.png new file mode 100644 index 0000000000..5f2756a602 Binary files /dev/null and b/app/src-tauri-mobile/icons/store/playstore.png differ diff --git a/app/src-tauri-mobile/src/lib.rs b/app/src-tauri-mobile/src/lib.rs new file mode 100644 index 0000000000..5bcdb6bf94 --- /dev/null +++ b/app/src-tauri-mobile/src/lib.rs @@ -0,0 +1,45 @@ +// OpenHuman mobile (iOS + Android) Tauri host. +// +// No CEF runtime, no Rust core sidecar, no desktop chrome. The React app +// (built from `app/src/`) is loaded into a single WKWebView (iOS) / +// Android WebView; it talks to a remote desktop core via the TS-side +// TransportManager (LAN HTTP / encrypted tunnel / cloud HTTP — see +// `app/src/services/transport/`). + +#[cfg(not(any(target_os = "ios", target_os = "android")))] +compile_error!( + "openhuman-mobile only supports iOS and Android. Use app/src-tauri for desktop." +); + +use tauri::{AppHandle, Manager, Runtime}; + +/// Tauri command: terminate the app cleanly. Used by the Settings page +/// "Sign out / forget device" flow when the user wants to back out of a +/// paired session. +#[tauri::command] +async fn app_quit(app: AppHandle) -> Result<(), String> { + log::info!("[mobile] app_quit invoked"); + app.exit(0); + Ok(()) +} + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + log::info!("[mobile] run() — starting mobile Tauri builder"); + + tauri::Builder::default() + .plugin(tauri_plugin_barcode_scanner::init()) + // PTT ships Swift sources for iOS only; on Android the plugin + // registers as a no-op stub (all commands return NotSupported). + // See packages/tauri-plugin-ptt/src/lib.rs. + .plugin(tauri_plugin_ptt::init()) + .invoke_handler(tauri::generate_handler![app_quit]) + .setup(|app| { + if let Some(main) = app.get_webview_window("main") { + let _ = main.show(); + } + Ok(()) + }) + .run(tauri::generate_context!()) + .expect("error while running mobile tauri application"); +} diff --git a/app/src-tauri-mobile/src/main.rs b/app/src-tauri-mobile/src/main.rs new file mode 100644 index 0000000000..6ee6b76dee --- /dev/null +++ b/app/src-tauri-mobile/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + openhuman_mobile::run(); +} diff --git a/app/src-tauri-mobile/tauri.conf.json b/app/src-tauri-mobile/tauri.conf.json new file mode 100644 index 0000000000..74a28854f7 --- /dev/null +++ b/app/src-tauri-mobile/tauri.conf.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "OpenHuman", + "version": "0.54.10", + "identifier": "com.openhuman.app", + "build": { + "beforeDevCommand": "pnpm --filter openhuman-app run dev", + "devUrl": "http://localhost:1420", + "beforeBuildCommand": "pnpm --filter openhuman-app run build:app", + "frontendDist": "../dist" + }, + "app": { + "windows": [ + { + "label": "main", + "title": "OpenHuman", + "width": 390, + "height": 844, + "decorations": true, + "resizable": false + } + ], + "security": { + "csp": "default-src 'self' 'unsafe-inline' data: blob: https: wss: ipc: http://ipc.localhost http://127.0.0.1:* http://localhost:*; img-src 'self' data: blob: https:; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:* http://localhost:* http: ws://127.0.0.1:* ws://localhost:* ws: https: wss: data: blob:; frame-src 'self' https: data: blob:" + } + }, + "bundle": { + "active": true, + "targets": ["app"], + "icon": ["icons/icon.png"], + "resources": [], + "iOS": { + "minimumSystemVersion": "16.0", + "frameworks": ["AVFoundation.framework", "Speech.framework"], + "developmentTeam": "" + }, + "android": { + "minSdkVersion": 24 + } + } +} diff --git a/app/src-tauri-web/README.md b/app/src-tauri-web/README.md new file mode 100644 index 0000000000..3d14ab534f --- /dev/null +++ b/app/src-tauri-web/README.md @@ -0,0 +1,22 @@ +## src-tauri-web + +This sibling to `src-tauri-mobile/` is the browser-hosted shell profile for +OpenHuman E2E and future web-compatible development. + +Scope: + +- No CEF runtime +- No embedded provider webviews +- No native windowing, tray, or deep-link plugins +- Frontend talks to a standalone `openhuman-core` over HTTP JSON-RPC + +Current entrypoints: + +- `pnpm build:web:e2e` builds the browser bundle into `app/dist-web` +- `pnpm test:e2e:web` starts the mock backend, standalone core, and static web + host, then runs Playwright against the browser build +- `pnpm test:e2e:mega` keeps the CEF/Appium mega-flow on the desktop shell + +This folder is intentionally documentation-first for now. The browser shell is +composed from the existing Vite app plus the standalone core runner rather than +another Tauri crate. diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index b5b4b83d12..05750db2c5 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "OpenHuman" -version = "0.54.7" +version = "0.57.1" dependencies = [ "anyhow", "async-trait", @@ -584,6 +584,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base58ck" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8d66485a3a2ea485c1913c4572ce0256067a5377ac8c75c4960e1cda98605f" +dependencies = [ + "bitcoin-internals", + "bitcoin_hashes", +] + [[package]] name = "base64" version = "0.21.7" @@ -608,6 +618,12 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + [[package]] name = "bindgen" version = "0.72.1" @@ -643,6 +659,54 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bitcoin" +version = "0.32.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf93e61f2dbc3e3c41234ca26a65e2c0b0975c52e0f069ab9893ebbede584d3" +dependencies = [ + "base58ck", + "bech32 0.11.1", + "bitcoin-internals", + "bitcoin-io", + "bitcoin-units", + "bitcoin_hashes", + "hex-conservative", + "hex_lit", + "secp256k1", +] + +[[package]] +name = "bitcoin-internals" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30bdbe14aa07b06e6cfeffc529a1f099e5fbe249524f8125358604df99a4bed2" + +[[package]] +name = "bitcoin-io" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" + +[[package]] +name = "bitcoin-units" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346568ebaab2918487cea76dd55dae13c27bb618cdb737c952e69eb2017c4118" +dependencies = [ + "bitcoin-internals", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" +dependencies = [ + "bitcoin-io", + "hex-conservative", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -1151,7 +1215,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5286a0843c21f8367f7be734f89df9b822e0321d8bcce8d6e735aadff7d74979" dependencies = [ "base64 0.21.7", - "bech32", + "bech32 0.9.1", "bs58", "digest 0.10.7", "generic-array", @@ -1631,6 +1695,33 @@ dependencies = [ "cmov", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "darling" version = "0.23.0" @@ -2047,6 +2138,31 @@ dependencies = [ "spki", ] +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.15.0" @@ -2453,6 +2569,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "field-offset" version = "0.3.6" @@ -3210,6 +3332,21 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hex_lit" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" + [[package]] name = "hmac" version = "0.12.1" @@ -3896,6 +4033,21 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "linux-keyutils", + "log", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zeroize", +] + [[package]] name = "konst" version = "0.2.20" @@ -4076,6 +4228,16 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-keyutils" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590" +dependencies = [ + "bitflags 2.11.1", + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -4379,7 +4541,7 @@ dependencies = [ "openssl-probe", "openssl-sys", "schannel", - "security-framework", + "security-framework 3.7.0", "security-framework-sys", "tempfile", ] @@ -5050,7 +5212,7 @@ dependencies = [ [[package]] name = "openhuman" -version = "0.54.7" +version = "0.57.1" dependencies = [ "aes-gcm", "anyhow", @@ -5060,19 +5222,24 @@ dependencies = [ "async-trait", "axum", "base64 0.22.1", + "bitcoin", "block2 0.6.2", + "bs58", "chacha20poly1305", "chrono", "chrono-tz", "clap", "clap_complete", + "coins-bip39", "console", "cpal", "cron", + "curve25519-dalek", "dialoguer", "directories 6.0.0", "dirs 5.0.1", "dotenvy", + "ed25519-dalek", "enigo", "env_logger", "ethers-core", @@ -5088,6 +5255,7 @@ dependencies = [ "hound", "iana-time-zone", "image", + "keyring", "lettre", "log", "mail-parser", @@ -5109,6 +5277,7 @@ dependencies = [ "regex", "reqwest 0.12.28", "ring", + "ripemd", "rusqlite", "rustls", "rustls-pki-types", @@ -5117,6 +5286,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "sha1", "sha2 0.10.9", "shellexpand", "socketioxide", @@ -5136,6 +5306,7 @@ dependencies = [ "tracing-appender", "tracing-log", "tracing-subscriber", + "unicode-normalization", "unicode-segmentation", "unicode-width", "url", @@ -5145,6 +5316,8 @@ dependencies = [ "walkdir", "webpki-roots 1.0.7", "whisper-rs", + "windows-sys 0.61.2", + "x25519-dalek", "xz2", "zip 2.4.2", ] @@ -6648,7 +6821,7 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.7.0", ] [[package]] @@ -6676,7 +6849,7 @@ dependencies = [ "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki", - "security-framework", + "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", "windows-sys 0.61.2", @@ -6852,6 +7025,39 @@ dependencies = [ "zeroize", ] +[[package]] +name = "secp256k1" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" +dependencies = [ + "bitcoin_hashes", + "rand 0.8.6", + "secp256k1-sys", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.9.4", + "core-foundation-sys 0.8.7", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -10463,6 +10669,18 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + [[package]] name = "xattr" version = "1.6.1" @@ -10635,6 +10853,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "zerotrie" diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 51b5a94e53..e6a85b045e 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "OpenHuman" -version = "0.54.7" +version = "0.57.1" description = "OpenHuman - AI-powered Super Assistant" authors = ["OpenHuman"] edition = "2021" @@ -172,6 +172,12 @@ windows-sys = { version = "0.59", features = [ "Win32_Storage_FileSystem", "Win32_System_IO", "Win32_System_Pipes", + # RegOpenKeyExW / RegQueryValueExW / RegCloseKey — used by + # deep_link_registration_check::verify_protocol_registration to read + # back HKCU\Software\Classes\openhuman\shell\open\command after + # `tauri-plugin-deep-link::register_all` so a silently-failed write + # surfaces in the Sentry / user logs (issue #2699). + "Win32_System_Registry", ] } [features] diff --git a/app/src-tauri/capabilities/default.json b/app/src-tauri/capabilities/default.json index 4989cfcf22..c19e0aa3b1 100644 --- a/app/src-tauri/capabilities/default.json +++ b/app/src-tauri/capabilities/default.json @@ -30,6 +30,8 @@ }, "updater:default", "allow-core-process", - "allow-app-update" + "allow-workspace-files", + "allow-app-update", + "allow-loopback-oauth" ] } diff --git a/app/src-tauri/permissions/allow-loopback-oauth.toml b/app/src-tauri/permissions/allow-loopback-oauth.toml new file mode 100644 index 0000000000..6a69a4fd8d --- /dev/null +++ b/app/src-tauri/permissions/allow-loopback-oauth.toml @@ -0,0 +1,12 @@ +[[permission]] +identifier = "allow-loopback-oauth" +description = "Permission to start / stop the one-shot http://127.0.0.1:/auth listener used as the RFC 8252 OAuth callback target (see #2511). Narrow on purpose so consumers of the broader `allow-core-process` group do not inherit OAuth listener control." + +[permission.commands] + +allow = [ + "start_loopback_oauth_listener", + "stop_loopback_oauth_listener", +] + +deny = [] diff --git a/app/src-tauri/permissions/allow-workspace-files.toml b/app/src-tauri/permissions/allow-workspace-files.toml new file mode 100644 index 0000000000..1d1e08148e --- /dev/null +++ b/app/src-tauri/permissions/allow-workspace-files.toml @@ -0,0 +1,13 @@ +[[permission]] +identifier = "allow-workspace-files" +description = "Allow opening, revealing, and previewing files resolved inside the active OpenHuman workspace" + +[permission.commands] + +allow = [ + "open_workspace_path", + "reveal_workspace_path", + "preview_workspace_text", +] + +deny = [] diff --git a/app/src-tauri/src/cdp/session.rs b/app/src-tauri/src/cdp/session.rs index d110a995da..ae05500423 100644 --- a/app/src-tauri/src/cdp/session.rs +++ b/app/src-tauri/src/cdp/session.rs @@ -524,17 +524,19 @@ async fn run_session_cycle( // - audioCapture / videoCapture: getUserMedia for cam/mic so the // pre-call greenroom auto-grants instead of falling back to // Meet's "Use microphone and camera" consent dialog - // - displayCapture: getDisplayMedia for screen-share present // - clipboardReadWrite: copy meeting link / paste join code // Without these, Meet sits on the consent dialog forever and cam/mic // never enumerate (verified during #1022 smoke). + // + // displayCapture is intentionally NOT in this set. Pre-granting it + // via `Browser.grantPermissions` bypasses the transient-activation + // requirement Chromium enforces on `getDisplayMedia`, which would + // let the page initiate a desktop capture without any user gesture. + // Without the pre-grant the page's screen-share button triggers + // Chrome's native screen-picker on click — same UX, but the gesture + // gate stays in place. if origin_host_is(&origin, "meet.google.com") { - perms.extend_from_slice(&[ - "audioCapture", - "videoCapture", - "displayCapture", - "clipboardReadWrite", - ]); + perms.extend_from_slice(&["audioCapture", "videoCapture", "clipboardReadWrite"]); } // Slack Huddles need the same media-capture set as Meet: @@ -542,7 +544,6 @@ async fn run_session_cycle( // optional camera tile. Without these, the huddle pre-flight // enumerateDevices returns empty and the join button silently // no-ops. - // - displayCapture: getDisplayMedia for in-huddle screen share. // - clipboardReadWrite: huddle invite-link copy + slash-command // paste flows. // Mirrors the gmeet pattern from #1054. The huddle popup paint @@ -550,13 +551,13 @@ async fn run_session_cycle( // tracking issue — granting these perms now means once the paint // bug clears, the huddle is functional immediately rather than // requiring a follow-up perms wire-up. + // + // displayCapture deliberately omitted for the same reason as Meet: + // pre-granting bypasses Chromium's gesture gate on + // `getDisplayMedia`; screen-share inside a huddle still works via + // the native screen-picker on user click. if origin_host_is(&origin, "app.slack.com") { - perms.extend_from_slice(&[ - "audioCapture", - "videoCapture", - "displayCapture", - "clipboardReadWrite", - ]); + perms.extend_from_slice(&["audioCapture", "videoCapture", "clipboardReadWrite"]); } if let Err(e) = cdp diff --git a/app/src-tauri/src/core_process.rs b/app/src-tauri/src/core_process.rs index 8ec5362631..a8f7f4911f 100644 --- a/app/src-tauri/src/core_process.rs +++ b/app/src-tauri/src/core_process.rs @@ -32,7 +32,9 @@ use tokio_util::sync::CancellationToken; use crate::process_kill::{kill_pid_force, kill_pid_term}; -const EMBEDDED_CORE_READY_WAIT_ATTEMPTS: u16 = 200; +const CORE_READY_POLL_MS: u64 = 100; +const CORE_READY_ATTEMPTS: usize = 200; +const CORE_READY_TIMEOUT_MS: u64 = CORE_READY_POLL_MS * CORE_READY_ATTEMPTS as u64; /// Generate a 256-bit cryptographically-random bearer token as a hex string. /// @@ -217,6 +219,10 @@ impl CoreProcessHandle { // the same env, matching what a child sidecar would have // received via Command::env. std::env::set_var("OPENHUMAN_CORE_TOKEN", self.rpc_token.as_str()); + // Surface the Tauri shell version to the in-process core so + // backend-bound HTTP requests can attach `x-tauri-version` + // analytics headers alongside `x-core-version`. + std::env::set_var("OPENHUMAN_TAURI_VERSION", env!("CARGO_PKG_VERSION")); *self.active_port.write() = port; *self.last_port_fallback.write() = None; @@ -284,9 +290,11 @@ impl CoreProcessHandle { // (issue: core_process tests intermittently failing with // "core process did not become ready"), especially under // cargo-llvm-cov instrumentation where the binary runs ~2x - // slower. Normal runs still exit the loop as soon as the ready - // signal arrives and the listener is open. - for _ in 0..EMBEDDED_CORE_READY_WAIT_ATTEMPTS { + // slower. 20s is still well under any user-visible startup + // expectation: in normal runs the ready signal arrives in well + // under 1s and the loop exits immediately; the headroom only + // matters on heavily loaded instrumented CI workers. + for _ in 0..CORE_READY_ATTEMPTS { if !received_ready { match ready_rx.try_recv() { Ok(ready_signal) => { @@ -337,19 +345,67 @@ impl CoreProcessHandle { }; } } - tokio::time::sleep(Duration::from_millis(100)).await; + tokio::time::sleep(Duration::from_millis(CORE_READY_POLL_MS)).await; } if retry_after_takeover { continue; } - return Err("core process did not become ready".to_string()); + + // One last non-sleeping check avoids declaring a timeout when the + // ready signal arrived during the final poll sleep. + if !received_ready { + if let Ok(ready_signal) = ready_rx.try_recv() { + self.apply_embedded_ready_signal(ready_signal); + received_ready = true; + } + } + if received_ready && self.is_rpc_port_open().await { + log::info!("[core] core rpc became ready at {}", self.rpc_url()); + return Ok(()); + } + + let port_open = self.is_rpc_port_open().await; + return Err(self + .cleanup_startup_timeout(received_ready, port_open, startup_attempt + 1) + .await); } - Err("core process did not become ready".to_string()) + let port_open = self.is_rpc_port_open().await; + Err(self.cleanup_startup_timeout(false, port_open, 2).await) + } + + async fn cleanup_startup_timeout( + &self, + received_ready: bool, + port_open: bool, + attempt: u8, + ) -> String { + let port = self.port(); + let task_state = { + let guard = self.task.lock().await; + match guard.as_ref() { + None => "missing", + Some(task) if task.is_finished() => "finished", + Some(_) => "running", + } + }; + log::error!( + "[core] startup timed out after {CORE_READY_TIMEOUT_MS}ms \ + (port={port}, ready_signal={received_ready}, port_open={port_open}, \ + task_state={task_state}, attempt={attempt}); \ + aborting embedded startup task before retry" + ); + self.cancel_shutdown_token(" after startup timeout").await; + self.abort_task(" after startup timeout").await; + format!( + "core process did not become ready within {CORE_READY_TIMEOUT_MS}ms \ + (port={port}, ready_signal={received_ready}, port_open={port_open}, \ + task_state={task_state}, attempt={attempt})" + ) } - fn apply_embedded_ready_signal( + pub(crate) fn apply_embedded_ready_signal( &self, ready: openhuman_core::core::jsonrpc::EmbeddedReadySignal, ) { @@ -582,6 +638,61 @@ impl CoreProcessHandle { } } +/// Result returned to the frontend after a port-conflict auto-recovery attempt. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct RecoveryOutcome { + pub success: bool, + pub message: String, + pub new_port: Option, +} + +impl CoreProcessHandle { + /// Attempt to recover from a port conflict: reap stale OpenHuman processes, + /// wait briefly for the port to free, then start the embedded core. + /// + /// Called from the `recover_port_conflict` Tauri command when the frontend's + /// boot-check detects the core is unreachable due to a port conflict. + pub async fn recover_port_conflict(&self) -> RecoveryOutcome { + log::debug!( + "[core_process] recover_port_conflict: starting recovery for port {}", + self.preferred_port + ); + + tokio::task::spawn_blocking(crate::process_recovery::reap_stale_openhuman_processes) + .await + .unwrap_or_else(|e| { + log::warn!("[core_process] recover_port_conflict: reap task panicked: {e}") + }); + log::debug!("[core_process] recover_port_conflict: stale process reap complete"); + + // Give the OS time to release the port after process termination. + tokio::time::sleep(Duration::from_millis(500)).await; + log::debug!("[core_process] recover_port_conflict: post-reap wait complete"); + + match self.ensure_running().await { + Ok(()) => { + let new_port = self.port(); + log::info!( + "[core_process] recover_port_conflict: recovery succeeded, core on port {new_port}" + ); + RecoveryOutcome { + success: true, + message: format!("Core recovered on port {new_port}"), + new_port: Some(new_port), + } + } + Err(err) => { + log::warn!("[core_process] recover_port_conflict: recovery failed: {err}"); + RecoveryOutcome { + success: false, + message: format!("Recovery failed: {err}"), + new_port: None, + } + } + } + } +} + pub fn default_core_port() -> u16 { std::env::var("OPENHUMAN_CORE_PORT") .ok() @@ -732,6 +843,13 @@ fn parse_lsof_pid(stdout: &str) -> Option { } /// Pure parse of `netstat -ano` output for a LISTENING entry on `port`. +/// +/// Skips kernel-protected PIDs 0 (System Idle Process) and 4 (NT Kernel) — +/// `HTTP.sys` and kernel-mode socket reservations occasionally surface as +/// LISTENING under PID 4 even though no user-mode owner exists. Killing +/// those is impossible and would otherwise abort startup recovery; if the +/// "owner" is the kernel, callers should fall back to a port reroute +/// instead of trying to take over. #[allow(dead_code)] // exercised only on windows builds fn parse_netstat_pid(stdout: &str, port: u16) -> Option { let needle = format!(":{port}"); @@ -744,6 +862,12 @@ fn parse_netstat_pid(stdout: &str, port: u16) -> Option { // Expected: ["TCP", "127.0.0.1:7788", "0.0.0.0:0", "LISTENING", "1234"] if parts.len() >= 5 && parts[1].ends_with(&needle) { if let Ok(pid) = parts[parts.len() - 1].parse::() { + if pid == 0 || pid == 4 { + log::warn!( + "[core] netstat reports port {port} owned by protected windows pid {pid}; treating as no-owner" + ); + continue; + } return Some(pid); } } diff --git a/app/src-tauri/src/core_process_tests.rs b/app/src-tauri/src/core_process_tests.rs index 85288ab508..7c2605c376 100644 --- a/app/src-tauri/src/core_process_tests.rs +++ b/app/src-tauri/src/core_process_tests.rs @@ -1,6 +1,6 @@ use super::{ current_rpc_token, default_core_port, generate_rpc_token, is_expected_port_clash, - is_openhuman_root_body, parse_lsof_pid, parse_netstat_pid, CoreProcessHandle, + is_openhuman_root_body, parse_lsof_pid, parse_netstat_pid, CoreProcessHandle, RecoveryOutcome, }; use std::sync::{Mutex, MutexGuard, OnceLock}; @@ -277,6 +277,158 @@ Active Connections assert_eq!(parse_netstat_pid(stdout, 9999), None); } +#[test] +fn parse_netstat_pid_skips_protected_kernel_pids() { + // HTTP.sys / driver-level reservations occasionally show as LISTENING + // under PID 4 (NT Kernel) or PID 0 (System Idle). Returning those pids + // would lead startup recovery to call taskkill on a process that cannot + // be signalled from user mode — aborting the entire takeover flow. + // The parser must treat these entries as "no owner" so callers fall + // back to the port-reroute path instead of trying to kill the kernel. + let stdout = "\ +Active Connections + + Proto Local Address Foreign Address State PID + TCP 127.0.0.1:7788 0.0.0.0:0 LISTENING 4 + TCP 127.0.0.1:7789 0.0.0.0:0 LISTENING 0 + TCP 127.0.0.1:7790 0.0.0.0:0 LISTENING 1234 +"; + assert_eq!(parse_netstat_pid(stdout, 7788), None); + assert_eq!(parse_netstat_pid(stdout, 7789), None); + assert_eq!(parse_netstat_pid(stdout, 7790), Some(1234)); +} + +#[test] +fn parse_netstat_pid_falls_through_protected_to_real_owner_on_dual_stack() { + // Real-world dual-stack listener: kernel-reserved entry sits ahead of + // the actual user-mode owner on the same port. The parser must keep + // scanning past the protected pid and return the genuine owner. + let stdout = "\ + Proto Local Address Foreign Address State PID + TCP [::]:7788 [::]:0 LISTENING 4 + TCP 127.0.0.1:7788 0.0.0.0:0 LISTENING 9999 +"; + assert_eq!(parse_netstat_pid(stdout, 7788), Some(9999)); +} + +// --------------------------------------------------------------------------- +// Windows end-to-end port-takeover test +// +// Spawns a real child process that occupies a TCP port, then walks the same +// path the Tauri host walks at startup (find_pid_on_port → kill_pid_force → +// is_port_open) and asserts the port is actually freed. This is the +// behavior the user reported broken — a unit-only parser test is not enough +// to catch netstat/taskkill drift on real Windows machines. +// --------------------------------------------------------------------------- + +#[cfg(windows)] +#[test] +fn windows_port_takeover_finds_and_kills_listener() { + use crate::process_kill::kill_pid_force; + use std::net::TcpListener; + use std::os::windows::process::CommandExt; + use std::time::{Duration, Instant}; + + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + + // Bind in this process first to claim an ephemeral free port the OS + // picks for us, capture the port, then drop the listener so the child + // can bind to the same port. There is a tiny TOCTOU window here but + // ephemeral ports on Windows are not aggressively recycled so it is + // robust enough for a single-shot test. + let probe = TcpListener::bind("127.0.0.1:0").expect("bind probe"); + let port = probe.local_addr().expect("probe addr").port(); + drop(probe); + + // Use PowerShell to spawn a listener that holds the port open for 60s. + // PowerShell ships with every supported Windows version. + let script = format!( + "$l = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, {port}); \ + $l.Start(); Start-Sleep -Seconds 60; $l.Stop()" + ); + let mut child = std::process::Command::new("powershell") + .args(["-NoProfile", "-NonInteractive", "-Command", &script]) + .creation_flags(CREATE_NO_WINDOW) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .stdin(std::process::Stdio::null()) + .spawn() + .expect("spawn powershell listener"); + + // Wait until the listener is actually bound (PowerShell startup is slow). + let deadline = Instant::now() + Duration::from_secs(15); + let mut bound = false; + while Instant::now() < deadline { + if std::net::TcpStream::connect_timeout( + &format!("127.0.0.1:{port}").parse().unwrap(), + Duration::from_millis(100), + ) + .is_ok() + { + bound = true; + break; + } + std::thread::sleep(Duration::from_millis(100)); + } + if !bound { + let _ = child.kill(); + let _ = child.wait(); + panic!("child listener never bound to 127.0.0.1:{port}"); + } + + // Walk the production path: pid lookup via netstat, then force-kill. + let pid = match super::find_pid_on_port(port) { + Some(pid) => pid, + None => { + let _ = child.kill(); + let _ = child.wait(); + panic!("find_pid_on_port returned None for port {port}"); + } + }; + // The pid we discovered won't be `child.id()` directly — the powershell + // process is the listener, and on Windows `child.id()` IS that pid. + // Sanity-check they match so a future netstat parser regression is loud. + // Tear down the child *before* panicking so a 60s listener doesn't leak + // into the rest of the test suite. + if pid != child.id() { + let expected = child.id(); + let _ = child.kill(); + let _ = child.wait(); + panic!("find_pid_on_port returned pid {pid}, expected child pid {expected}"); + } + + kill_pid_force(pid).expect("force-kill listener"); + + // Verify the port is actually free within a reasonable window — this is + // the assertion that fails when taskkill mis-reports success or when + // /T fails to take down the powershell subtree. + let deadline = Instant::now() + Duration::from_secs(5); + let mut freed = false; + while Instant::now() < deadline { + if std::net::TcpStream::connect_timeout( + &format!("127.0.0.1:{port}").parse().unwrap(), + Duration::from_millis(100), + ) + .is_err() + { + freed = true; + break; + } + std::thread::sleep(Duration::from_millis(100)); + } + + let _ = child.wait(); + assert!( + freed, + "port {port} still bound after kill_pid_force(pid={pid})" + ); + + // Idempotency: kill the same pid again — must be Ok, not Err, because + // the process is already gone and recovery code calls force-kill after + // a re-validation that may race. + kill_pid_force(pid).expect("kill_pid_force on dead pid must be idempotent"); +} + // --------------------------------------------------------------------------- // Token generation tests // --------------------------------------------------------------------------- @@ -375,3 +527,157 @@ fn send_terminate_signal_cancels_shutdown_token() { ); }); } + +#[test] +fn startup_timeout_cleanup_aborts_task_and_clears_slot() { + let rt = tokio::runtime::Runtime::new().expect("runtime"); + rt.block_on(async { + let handle = CoreProcessHandle::new(19006); + let task = tokio::spawn(async { + tokio::time::sleep(tokio::time::Duration::from_secs(60)).await; + Ok::<(), anyhow::Error>(()) + }); + + { + let mut guard = handle.task.lock().await; + *guard = Some(task); + } + + let message = handle.cleanup_startup_timeout(false, false, 2).await; + + assert!( + message.contains("core process did not become ready within"), + "timeout message should include the readiness budget: {message}" + ); + assert!( + message.contains("ready_signal=false"), + "timeout message should include ready signal state: {message}" + ); + assert!( + message.contains("port=19006"), + "timeout message should include RPC port: {message}" + ); + assert!( + message.contains("port_open=false"), + "timeout message should include final port state: {message}" + ); + assert!( + message.contains("task_state=running"), + "timeout message should include task state: {message}" + ); + assert!( + message.contains("attempt=2"), + "timeout message should include startup attempt: {message}" + ); + assert!( + handle.task.lock().await.is_none(), + "cleanup must clear the managed task slot so retry can spawn fresh" + ); + assert!( + handle.shutdown_token_is_cancelled().await, + "cleanup must cancel the startup token before aborting" + ); + }); +} + +// --------------------------------------------------------------------------- +// RecoveryOutcome serialization tests +// --------------------------------------------------------------------------- + +#[test] +fn recovery_outcome_serializes_correctly() { + let outcome = RecoveryOutcome { + success: true, + message: "Core recovered on port 7789".to_string(), + new_port: Some(7789), + }; + let json = serde_json::to_value(&outcome).expect("serialize"); + assert_eq!(json["success"], serde_json::json!(true)); + assert_eq!( + json["message"], + serde_json::json!("Core recovered on port 7789") + ); + assert_eq!(json["new_port"], serde_json::json!(7789)); +} + +#[test] +fn recovery_outcome_failure_serializes_with_null_port() { + let outcome = RecoveryOutcome { + success: false, + message: "Recovery failed: port still busy".to_string(), + new_port: None, + }; + let json = serde_json::to_value(&outcome).expect("serialize"); + assert_eq!(json["success"], serde_json::json!(false)); + assert!( + json["new_port"].is_null(), + "new_port should be null when None" + ); +} + +#[test] +fn recover_port_conflict_succeeds_when_port_is_free() { + let _env_lock = env_lock(); + let _unset = EnvGuard::unset("OPENHUMAN_CORE_REUSE_EXISTING"); + let rt = tokio::runtime::Runtime::new().expect("runtime"); + + let outcome = rt.block_on(async { + // Bind a port, then release it so it's free when recover_port_conflict runs. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let port = listener.local_addr().expect("addr").port(); + drop(listener); + // Brief yield to let the OS fully release the port. + tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; + + let handle = CoreProcessHandle::new(port); + let outcome = handle.recover_port_conflict().await; + handle.shutdown().await; + outcome + }); + + assert!( + outcome.success, + "recovery should succeed when port is free: {}", + outcome.message + ); + assert!( + outcome.new_port.is_some(), + "new_port should be set on success" + ); +} + +#[test] +fn recover_port_conflict_handles_stale_listener() { + let _env_lock = env_lock(); + let _unset = EnvGuard::unset("OPENHUMAN_CORE_REUSE_EXISTING"); + let rt = tokio::runtime::Runtime::new().expect("runtime"); + + // Bind a port, attempt recovery — the recovery must still succeed because + // ensure_running's fallback range kicks in when the preferred port is busy. + let outcome = rt.block_on(async { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let port = listener.local_addr().expect("addr").port(); + + let handle = CoreProcessHandle::new(port); + let outcome = handle.recover_port_conflict().await; + handle.shutdown().await; + drop(listener); + outcome + }); + + // Recovery may succeed via port fallback even with the listener held. + // We only assert that the outcome is well-formed. + assert!( + !outcome.message.is_empty(), + "outcome message must always be populated" + ); + if outcome.success { + assert!(outcome.new_port.is_some()); + } else { + assert!(outcome.new_port.is_none()); + } +} diff --git a/app/src-tauri/src/deep_link_ipc.rs b/app/src-tauri/src/deep_link_ipc.rs new file mode 100644 index 0000000000..f7c6e87562 --- /dev/null +++ b/app/src-tauri/src/deep_link_ipc.rs @@ -0,0 +1,405 @@ +//! Pre-CEF deep-link forwarding for Linux (issue #2359). +//! +//! On Linux, `openhuman://` OAuth callbacks launch a second OpenHuman +//! binary with the URL in argv. That secondary hits +//! `cef_preflight::check_default_cache()` and exits before Builder::setup +//! runs, so tauri-plugin-deep-link never gets a chance to forward the URL. +//! +//! This module fixes the race by: +//! 1. Primary: bind a Unix domain socket at a stable per-user path BEFORE +//! the CEF preflight check. Queue any arriving URLs until setup() runs. +//! 2. Secondary (URL in argv): connect to the socket, write the URL(s), +//! and exit(0). CEF preflight is never reached. + +#![cfg(target_os = "linux")] + +use std::{ + io::{BufRead, BufReader, Write}, + os::unix::net::{UnixListener, UnixStream}, + path::PathBuf, + sync::{Arc, Mutex, OnceLock}, + time::Duration, +}; + +/// Stable socket path. Uses $XDG_RUNTIME_DIR when available (per-user, +/// per-session tmpfs, cleaned on reboot), falls back to /tmp with UID. +pub(crate) fn socket_path() -> PathBuf { + if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") { + return PathBuf::from(dir).join("com.openhuman.app-deeplink.sock"); + } + // Fallback: include UID so multi-user machines don't collide. + let uid = nix::unistd::getuid().as_raw(); + std::env::temp_dir().join(format!("com_openhuman_app_deeplink_{uid}.sock")) +} + +/// Collect any `openhuman://` URLs from the process argv. +pub(crate) fn extract_deep_link_urls() -> Vec { + std::env::args() + .skip(1) + .filter(|a| a.starts_with("openhuman://")) + .collect() +} + +/// Result of `try_forward_deep_links`. +pub(crate) enum ForwardResult { + /// URLs were written to the primary's socket; caller should exit(0). + Forwarded, + /// Deep-link URL found in argv but no primary socket is listening. + NoPrimary, + /// No deep-link URLs in argv; this is a normal launch. + NoUrls, +} + +/// Try to forward any `openhuman://` URLs in argv to the primary instance. +/// Call this BEFORE the CEF preflight check. +pub(crate) fn try_forward_deep_links() -> ForwardResult { + let urls = extract_deep_link_urls(); + if urls.is_empty() { + return ForwardResult::NoUrls; + } + + let path = socket_path(); + log::info!( + "[deep-link-ipc] secondary: found {} deep-link URL(s), trying socket at {}", + urls.len(), + path.display() + ); + + match UnixStream::connect(&path) { + Ok(mut stream) => { + stream.set_write_timeout(Some(Duration::from_secs(2))).ok(); + for url in &urls { + if let Err(e) = writeln!(stream, "{url}") { + log::warn!("[deep-link-ipc] secondary: failed to write URL: {e}"); + } + } + log::info!( + "[deep-link-ipc] secondary: {} URL(s) forwarded to primary", + urls.len() + ); + ForwardResult::Forwarded + } + Err(e) => { + log::info!( + "[deep-link-ipc] secondary: no primary socket at {} ({e}); \ + will become primary", + path.display() + ); + ForwardResult::NoPrimary + } + } +} + +// Pending URLs collected before setup() has an app handle. +static PENDING_URLS: OnceLock>>> = OnceLock::new(); +// Live handler installed by drain_pending_urls — dispatches directly to app. +static LIVE_HANDLER: OnceLock>>> = OnceLock::new(); + +fn pending_queue() -> &'static Arc>> { + PENDING_URLS.get_or_init(|| Arc::new(Mutex::new(Vec::new()))) +} + +fn live_handler() -> &'static Mutex>> { + LIVE_HANDLER.get_or_init(|| Mutex::new(None)) +} + +/// Strip query string and fragment from a deep-link URL before logging. +/// OAuth callbacks carry tokens in the query string; logging the raw URL +/// would persist secrets in log files and crash reports. +fn redact_url_for_log(url: &str) -> String { + url.parse::() + .map(|mut parsed| { + parsed.set_query(None); + parsed.set_fragment(None); + parsed.to_string() + }) + .unwrap_or_else(|_| "".to_string()) +} + +fn dispatch_url(url: String) { + // Try the live handler first. + if let Ok(guard) = live_handler().lock() { + if let Some(ref handler) = *guard { + handler(url); + return; + } + } + // No live handler yet — queue for drain_pending_urls. + if let Ok(mut q) = pending_queue().lock() { + log::debug!( + "[deep-link-ipc] queued URL (no handler yet): {}", + redact_url_for_log(&url) + ); + q.push(url); + } +} + +/// RAII guard: removes the socket file when dropped. +pub(crate) struct DeepLinkSocketGuard { + path: PathBuf, +} + +impl Drop for DeepLinkSocketGuard { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + log::debug!( + "[deep-link-ipc] socket cleaned up at {}", + self.path.display() + ); + } +} + +/// Bind the deep-link socket and start the listener thread. +/// Returns `None` if binding fails (non-fatal — log and continue). +/// +/// Uses a bind-first approach to avoid the race where a secondary instance +/// unconditionally removes a live primary's socket file: we only remove the +/// file when we can confirm it is stale (connect fails). +pub(crate) fn bind_and_listen() -> Option { + let path = socket_path(); + + let listener = match UnixListener::bind(&path) { + Ok(l) => l, + Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => { + // A socket file already exists. Probe whether a live primary + // is behind it before deciding to unlink. + match UnixStream::connect(&path) { + Ok(_) => { + // Live primary — this instance should not bind. + log::debug!( + "[deep-link-ipc] socket {} is live; skipping bind \ + (primary already running)", + path.display() + ); + return None; + } + Err(_) => { + // Stale socket from a previous crash — safe to remove. + log::debug!( + "[deep-link-ipc] removing stale socket at {}", + path.display() + ); + let _ = std::fs::remove_file(&path); + match UnixListener::bind(&path) { + Ok(l) => l, + Err(e2) => { + log::warn!( + "[deep-link-ipc] failed to bind socket at {} after \ + removing stale file — deep-link forwarding from \ + secondary instances will not work: {e2}", + path.display() + ); + return None; + } + } + } + } + } + Err(e) => { + log::warn!( + "[deep-link-ipc] failed to bind socket at {} — deep-link forwarding \ + from secondary instances will not work: {e}", + path.display() + ); + return None; + } + }; + + let path_clone = path.clone(); + std::thread::Builder::new() + .name("deep-link-ipc-listener".into()) + .spawn(move || { + log::info!( + "[deep-link-ipc] primary: listening on {}", + path_clone.display() + ); + for stream in listener.incoming() { + match stream { + Ok(stream) => handle_connection(stream), + Err(e) => { + log::debug!("[deep-link-ipc] accept error: {e}"); + // Listener is gone (guard dropped) — stop. + break; + } + } + } + log::info!("[deep-link-ipc] listener thread exiting"); + }) + .ok(); + Some(DeepLinkSocketGuard { path }) +} + +fn handle_connection(stream: UnixStream) { + stream.set_read_timeout(Some(Duration::from_secs(3))).ok(); + let reader = BufReader::new(stream); + for line in reader.lines() { + match line { + Ok(url) if url.starts_with("openhuman://") => { + log::info!( + "[deep-link-ipc] primary: received deep-link URL: {}", + redact_url_for_log(&url) + ); + dispatch_url(url); + } + Ok(other) => { + log::debug!("[deep-link-ipc] primary: ignoring non-deep-link line: {other}"); + } + Err(e) => { + log::debug!("[deep-link-ipc] primary: read error: {e}"); + break; + } + } + } +} + +/// Drain any URLs queued before setup() ran, then install a live handler +/// that emits `deep-link://new-url` events directly to the app handle. +/// Call this from Builder::setup() after deep-link registration. +pub(crate) fn drain_pending_urls(app: &tauri::AppHandle) { + use tauri::Emitter; + + // Install the live handler first so future URLs don't queue. + let app_clone = app.clone(); + if let Ok(mut guard) = live_handler().lock() { + *guard = Some(Box::new(move |url: String| { + if let Ok(parsed) = url.parse::() { + let urls = vec![parsed]; + if let Err(e) = app_clone.emit("deep-link://new-url", &urls) { + log::warn!("[deep-link-ipc] failed to emit deep-link event: {e}"); + } + } else { + log::warn!("[deep-link-ipc] received malformed deep-link URL"); + } + })); + } + + // Drain any URLs that arrived before setup(). + let pending: Vec = pending_queue() + .lock() + .map(|mut q| std::mem::take(&mut *q)) + .unwrap_or_default(); + + if !pending.is_empty() { + log::info!( + "[deep-link-ipc] draining {} queued deep-link URL(s)", + pending.len() + ); + } + for url in pending { + if let Ok(parsed) = url.parse::() { + let urls = vec![parsed]; + if let Err(e) = app.emit("deep-link://new-url", &urls) { + log::warn!("[deep-link-ipc] failed to emit queued deep-link URL: {e}"); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn socket_path_uses_xdg_runtime_dir() { + std::env::set_var("XDG_RUNTIME_DIR", "/run/user/1234"); + let path = socket_path(); + assert_eq!( + path, + PathBuf::from("/run/user/1234/com.openhuman.app-deeplink.sock") + ); + } + + #[test] + fn socket_path_fallback_has_uid() { + std::env::remove_var("XDG_RUNTIME_DIR"); + let path = socket_path(); + let name = path.file_name().unwrap().to_string_lossy(); + assert!( + name.contains("com_openhuman_app_deeplink"), + "path {path:?} should contain identifier" + ); + // Should NOT be inside /run/user since XDG_RUNTIME_DIR is unset. + assert!( + !path.starts_with("/run/user"), + "path should use temp_dir fallback" + ); + } + + #[test] + fn extract_deep_link_urls_filters_correctly() { + // We can't mutate std::env::args(), so test the filtering logic directly. + let args = vec![ + "OpenHuman".to_string(), + "openhuman://auth?token=abc".to_string(), + "--some-flag".to_string(), + "openhuman://other".to_string(), + "https://example.com".to_string(), + ]; + let urls: Vec = args + .into_iter() + .skip(1) + .filter(|a| a.starts_with("openhuman://")) + .collect(); + assert_eq!(urls.len(), 2); + assert_eq!(urls[0], "openhuman://auth?token=abc"); + assert_eq!(urls[1], "openhuman://other"); + } + + #[test] + fn round_trip_bind_connect_forward() { + use std::io::BufRead; + use std::os::unix::net::UnixStream; + + // Use a temp path for this test to avoid collisions. + let tmp = tempfile::TempDir::new().unwrap(); + let sock_path = tmp.path().join("test-deeplink.sock"); + + let listener = UnixListener::bind(&sock_path).unwrap(); + let received = Arc::new(Mutex::new(Vec::::new())); + let received_clone = Arc::clone(&received); + + std::thread::spawn(move || { + if let Ok(stream) = listener.accept().map(|(s, _)| s) { + stream.set_read_timeout(Some(Duration::from_secs(2))).ok(); + let reader = BufReader::new(stream); + for line in reader.lines().flatten() { + if line.starts_with("openhuman://") { + received_clone.lock().unwrap().push(line); + } + } + } + }); + + // Give listener thread time to start. + std::thread::sleep(Duration::from_millis(50)); + + let mut stream = UnixStream::connect(&sock_path).unwrap(); + writeln!(stream, "openhuman://auth?token=testtoken123").unwrap(); + drop(stream); + + std::thread::sleep(Duration::from_millis(100)); + let got = received.lock().unwrap(); + assert_eq!(got.len(), 1); + assert_eq!(got[0], "openhuman://auth?token=testtoken123"); + } + + #[test] + fn no_primary_returns_appropriate_result() { + // Remove socket file to guarantee no primary. + std::env::remove_var("XDG_RUNTIME_DIR"); + let _ = std::fs::remove_file(socket_path()); + + // The "extract_deep_link_urls" function reads actual argv which has + // no openhuman:// URLs during tests, so try_forward_deep_links() + // returns NoUrls. We test the NoPrimary branch directly by + // testing that connect to a missing socket fails. + let non_existent = PathBuf::from("/tmp/openhuman_test_nonexistent_socket.sock"); + let _ = std::fs::remove_file(&non_existent); + let result = UnixStream::connect(&non_existent); + assert!( + result.is_err(), + "Expected connection failure for missing socket" + ); + } +} diff --git a/app/src-tauri/src/deep_link_registration_check.rs b/app/src-tauri/src/deep_link_registration_check.rs new file mode 100644 index 0000000000..51cab0ab2f --- /dev/null +++ b/app/src-tauri/src/deep_link_registration_check.rs @@ -0,0 +1,459 @@ +//! Read-back verification for the `openhuman://` URL-scheme registration. +//! +//! `tauri-plugin-deep-link::register_all` writes +//! `HKCU\Software\Classes\openhuman\shell\open\command` on Windows so the +//! browser can hand `openhuman://auth?...` OAuth callbacks back to the running +//! desktop instance. When that write silently fails — or when the value +//! becomes stale because the install was moved out from under itself — the +//! Tauri plugin only surfaces a `warn` and the user is left with an OAuth +//! flow that never returns to the app (issue #2699). +//! +//! This module verifies the registration after `register_all` returns so the +//! actual state is logged loudly enough to be picked up by Sentry and end-user +//! support logs. We do **not** auto-repair the registry — writing the wrong +//! exe path can brick a working install — but the diagnostic surface is now +//! sufficient to point users at the documented manual repair in +//! `gitbooks/overview/troubleshooting-sign-in.md`. +//! +//! The string-parsing helpers are cross-platform so the developer host (macOS +//! / Linux) can run their unit tests; the actual registry read sits behind +//! `#[cfg(target_os = "windows")]`. The whole module is dead code on +//! non-Windows targets outside of tests, so the dead-code lint is suppressed +//! there only. + +#![cfg_attr(not(target_os = "windows"), allow(dead_code))] + +use std::path::Path; + +/// Subkey under `HKEY_CURRENT_USER` that holds the `openhuman://` URL-scheme +/// handler command. Matches what `tauri-plugin-deep-link::register_all` +/// writes on Windows (HKCU, not HKLM, so no UAC elevation is involved). +pub(crate) const HKCU_OPEN_COMMAND_SUBKEY: &str = r"Software\Classes\openhuman\shell\open\command"; + +/// Outcome of inspecting the `openhuman://` protocol handler. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum RegistrationStatus { + /// HKCU key exists and references the running executable. + Valid { command: String }, + /// HKCU key exists but its command points at a different exe than the one + /// that's running. Typical after the install was moved/copied to a new + /// path without re-running the installer. + Stale { + registered_command: String, + expected_exe: String, + }, + /// HKCU command subkey exists with no `(Default)` value or an empty one. + MissingCommand, + /// `HKCU\Software\Classes\openhuman` doesn't exist — the scheme has never + /// been registered for this user. + NotRegistered, + /// Couldn't read the registry at all (permissions, ACL on a locked-down + /// Windows image, transient failure). + ReadError(String), +} + +impl RegistrationStatus { + pub(crate) fn is_healthy(&self) -> bool { + matches!(self, Self::Valid { .. }) + } + + /// Render the status as a single-line string with full filesystem paths + /// reduced to just their final component. The `Stale` and `Valid` + /// variants carry registry / current-exe paths that on Windows include + /// `C:\Users\\AppData\Local\...` for per-user installs — that + /// username lands in Sentry / user log lines unless we strip it. We keep + /// the basename so the diagnostic still tells the reader *what* exe is + /// registered, just not *where*. Used in place of `Debug` at the log + /// call site. + pub(crate) fn redacted(&self) -> String { + match self { + Self::Valid { command } => { + format!("Valid {{ exe: {} }}", basename_of_first_token(command)) + } + Self::Stale { + registered_command, + expected_exe, + } => format!( + "Stale {{ registered_exe: {}, expected_exe: {} }}", + basename_of_first_token(registered_command), + basename_of_first_token(expected_exe), + ), + Self::MissingCommand => "MissingCommand".into(), + Self::NotRegistered => "NotRegistered".into(), + // `ReadError` carries a win32-error string like + // "RegOpenKeyExW failed: win32 error 5" — no user paths. + Self::ReadError(msg) => format!("ReadError({msg})"), + } + } +} + +/// Take the first whitespace-delimited token out of `s` (handling quoted +/// command strings via [`extract_first_token`]) and return only its file +/// name. Drops the directory component so the log line doesn't leak the +/// running user's install path. +/// +/// We scan for `\\` and `/` manually rather than using [`Path::file_name`] +/// because `std::path::Path` uses **host-OS** separator semantics, so on a +/// macOS / Linux dev host a Windows-style `"C:\\foo\\bar.exe"` would come +/// back as a single component and defeat the redaction. +fn basename_of_first_token(s: &str) -> String { + let token = extract_first_token(s); + token + .rsplit(['\\', '/']) + .next() + .filter(|seg| !seg.is_empty()) + .unwrap_or("") + .to_string() +} + +/// Pull the first whitespace-delimited token out of a Windows-style command +/// string, honouring double-quoted paths. The registry stores values like +/// `"C:\Program Files\OpenHuman\OpenHuman.exe" "%1"` — we want the exe path. +pub(crate) fn extract_first_token(command: &str) -> &str { + let trimmed = command.trim_start(); + if let Some(rest) = trimmed.strip_prefix('"') { + match rest.find('"') { + Some(end) => &rest[..end], + None => rest, + } + } else { + match trimmed.find(char::is_whitespace) { + Some(end) => &trimmed[..end], + None => trimmed, + } + } +} + +/// Compare two Windows path strings case-insensitively after normalizing +/// directory separators. Windows treats `/` and `\` interchangeably and is +/// case-insensitive on path comparisons, so this matches OS behavior closely +/// enough to detect "registry points at the right exe." +pub(crate) fn paths_equal_loose(a: &str, b: &str) -> bool { + fn norm(s: &str) -> String { + s.replace('/', "\\").to_lowercase() + } + norm(a) == norm(b) +} + +/// True iff `command` (as found in the registry) references `exe`. +pub(crate) fn command_references_exe(command: &str, exe: &Path) -> bool { + let token = extract_first_token(command); + paths_equal_loose(token, &exe.to_string_lossy()) +} + +/// Read `HKCU\Software\Classes\openhuman\shell\open\command\(Default)` and +/// classify the registration. Windows-only — all other targets get a stub +/// that returns [`RegistrationStatus::NotRegistered`] (the verification is a +/// no-op outside Windows since macOS / Linux use different mechanisms). +#[cfg(target_os = "windows")] +pub(crate) fn verify_protocol_registration() -> RegistrationStatus { + use windows_sys::Win32::Foundation::{ERROR_FILE_NOT_FOUND, ERROR_SUCCESS}; + use windows_sys::Win32::System::Registry::{ + RegCloseKey, RegOpenKeyExW, RegQueryValueExW, HKEY, HKEY_CURRENT_USER, KEY_READ, REG_SZ, + }; + + // RAII wrapper for the open HKEY. Mirrors the `OwnedMutex` pattern used + // in `lib.rs::run()` for the pre-CEF mutex handle: any early return after + // the first successful `RegOpenKeyExW` falls through Drop instead of + // having to remember to call `RegCloseKey` on every branch. + struct OwnedHkey(HKEY); + impl Drop for OwnedHkey { + fn drop(&mut self) { + if !self.0.is_null() { + // SAFETY: self.0 is only set via a successful `RegOpenKeyExW` + // and is not aliased elsewhere — this Drop is the sole closer. + unsafe { RegCloseKey(self.0) }; + } + } + } + + let exe = match std::env::current_exe() { + Ok(p) => p, + Err(err) => { + return RegistrationStatus::ReadError(format!("current_exe failed: {err}")); + } + }; + + let subkey_wide: Vec = HKCU_OPEN_COMMAND_SUBKEY + .encode_utf16() + .chain(std::iter::once(0)) + .collect(); + // Start null so Drop is a no-op if `RegOpenKeyExW` never succeeds. + let mut hkey = OwnedHkey(std::ptr::null_mut()); + // SAFETY: subkey_wide is NUL-terminated UTF-16; hkey.0 is written iff result == 0. + let open_result = unsafe { + RegOpenKeyExW( + HKEY_CURRENT_USER, + subkey_wide.as_ptr(), + 0, + KEY_READ, + &mut hkey.0, + ) + }; + + if open_result as u32 == ERROR_FILE_NOT_FOUND { + return RegistrationStatus::NotRegistered; + } else if open_result as u32 != ERROR_SUCCESS { + return RegistrationStatus::ReadError(format!( + "RegOpenKeyExW failed: win32 error {open_result}" + )); + } + + // Probe size first. value_name = NUL pointer would select the (Default) value, + // but RegQueryValueExW also accepts an empty NUL-terminated string for the same. + let value_name: [u16; 1] = [0]; + let mut value_type: u32 = 0; + let mut needed: u32 = 0; + // SAFETY: hkey.0 is a valid open HKEY; we pass null for data to get the size only. + let size_probe = unsafe { + RegQueryValueExW( + hkey.0, + value_name.as_ptr(), + std::ptr::null_mut(), + &mut value_type, + std::ptr::null_mut(), + &mut needed, + ) + }; + + if size_probe as u32 != ERROR_SUCCESS { + if size_probe as u32 == ERROR_FILE_NOT_FOUND { + return RegistrationStatus::MissingCommand; + } + return RegistrationStatus::ReadError(format!( + "RegQueryValueExW (size probe) failed: win32 error {size_probe}" + )); + } + + if value_type != REG_SZ || needed == 0 { + return RegistrationStatus::MissingCommand; + } + + // `needed` is in bytes; REG_SZ uses UTF-16 so each code unit is 2 bytes. Round + // up to accommodate values that aren't NUL-terminated on disk. + let units = needed.div_ceil(2) as usize; + let mut buf: Vec = vec![0u16; units]; + let mut buf_bytes: u32 = needed; + // SAFETY: buf is sized for `needed` bytes; buf_bytes is updated by the call. + let query_result = unsafe { + RegQueryValueExW( + hkey.0, + value_name.as_ptr(), + std::ptr::null_mut(), + &mut value_type, + buf.as_mut_ptr().cast::(), + &mut buf_bytes, + ) + }; + + // From here on we no longer need the handle; Drop closes it at function exit. + + if query_result as u32 != ERROR_SUCCESS { + return RegistrationStatus::ReadError(format!( + "RegQueryValueExW failed: win32 error {query_result}" + )); + } + + let end = buf.iter().position(|&c| c == 0).unwrap_or(buf.len()); + let command = String::from_utf16_lossy(&buf[..end]); + let command_trimmed = command.trim(); + + if command_trimmed.is_empty() { + return RegistrationStatus::MissingCommand; + } + + if command_references_exe(command_trimmed, &exe) { + RegistrationStatus::Valid { + command: command_trimmed.to_string(), + } + } else { + RegistrationStatus::Stale { + registered_command: command_trimmed.to_string(), + expected_exe: exe.to_string_lossy().to_string(), + } + } +} + +/// Non-Windows stub so the setup-time wiring can call this unconditionally +/// behind `cfg(windows)` without polluting the call site with cfg gates. +#[cfg(not(target_os = "windows"))] +#[allow(dead_code)] +pub(crate) fn verify_protocol_registration() -> RegistrationStatus { + RegistrationStatus::NotRegistered +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn extract_first_token_quoted_exe_with_args() { + assert_eq!( + extract_first_token("\"C:\\Program Files\\OpenHuman\\OpenHuman.exe\" \"%1\""), + "C:\\Program Files\\OpenHuman\\OpenHuman.exe" + ); + } + + #[test] + fn extract_first_token_unquoted_exe_with_args() { + assert_eq!( + extract_first_token("C:\\OpenHuman\\OpenHuman.exe %1"), + "C:\\OpenHuman\\OpenHuman.exe" + ); + } + + #[test] + fn extract_first_token_handles_leading_whitespace() { + assert_eq!( + extract_first_token(" C:\\OpenHuman\\OpenHuman.exe %1"), + "C:\\OpenHuman\\OpenHuman.exe" + ); + } + + #[test] + fn extract_first_token_single_value_no_args() { + assert_eq!(extract_first_token("OpenHuman.exe"), "OpenHuman.exe"); + } + + #[test] + fn extract_first_token_empty_string() { + // Defensive guard: an empty REG_SZ value must not panic. The caller + // (`verify_protocol_registration`) classifies this as `MissingCommand` + // before reaching the parser, but the parser itself stays total. + assert_eq!(extract_first_token(""), ""); + } + + #[test] + fn extract_first_token_quoted_exe_with_no_trailing_args() { + // Some installers register the command without the `"%1"` argv + // placeholder. The first token is still the quoted exe path. + assert_eq!( + extract_first_token("\"C:\\OpenHuman\\OpenHuman.exe\""), + "C:\\OpenHuman\\OpenHuman.exe" + ); + } + + #[test] + fn extract_first_token_unterminated_quote_falls_through() { + // Defensive: malformed REG_SZ should not panic. We return the rest of + // the string instead of slicing past a missing terminator. + assert_eq!( + extract_first_token("\"C:\\OpenHuman\\OpenHuman.exe %1"), + "C:\\OpenHuman\\OpenHuman.exe %1" + ); + } + + #[test] + fn paths_equal_loose_is_case_insensitive_and_slash_agnostic() { + assert!(paths_equal_loose( + "C:\\Program Files\\OpenHuman\\OpenHuman.exe", + "c:/program files/openhuman/openhuman.exe" + )); + } + + #[test] + fn paths_equal_loose_distinguishes_different_paths() { + assert!(!paths_equal_loose( + "C:\\OldPath\\OpenHuman.exe", + "C:\\NewPath\\OpenHuman.exe" + )); + } + + #[test] + fn command_references_exe_matches_quoted_command_with_percent_one() { + let exe = PathBuf::from("C:\\Program Files\\OpenHuman\\OpenHuman.exe"); + assert!(command_references_exe( + "\"C:\\Program Files\\OpenHuman\\OpenHuman.exe\" \"%1\"", + &exe + )); + } + + #[test] + fn command_references_exe_matches_unquoted_command() { + // Some HKCU writers omit the quotes when the path has no spaces. The + // matcher must still resolve to the exe via the unquoted code path in + // `extract_first_token` rather than relying only on the quoted path. + let exe = PathBuf::from("C:\\OpenHuman\\OpenHuman.exe"); + assert!(command_references_exe( + "C:\\OpenHuman\\OpenHuman.exe %1", + &exe + )); + } + + #[test] + fn command_references_exe_detects_stale_install_path() { + // Repro of the "user moved the install" failure mode: registry still + // points at the old location. + let exe = PathBuf::from("C:\\NewLocation\\OpenHuman.exe"); + assert!(!command_references_exe( + "\"C:\\OldLocation\\OpenHuman.exe\" \"%1\"", + &exe + )); + } + + #[test] + fn redacted_drops_directory_components_for_stale_paths() { + // Reproduce the Sentry-leak case: a Stale status carrying the running + // user's home directory must produce a log line that contains the + // exe basenames but neither the username nor the parent dirs. + let status = RegistrationStatus::Stale { + registered_command: + "\"C:\\Users\\joe\\AppData\\Local\\OpenHuman\\OpenHuman.exe\" \"%1\"".into(), + expected_exe: "C:\\Users\\joe\\AppData\\Local\\OpenHuman_new\\OpenHuman.exe".into(), + }; + let rendered = status.redacted(); + assert!( + rendered.contains("OpenHuman.exe"), + "basename should survive redaction: {rendered}" + ); + assert!( + !rendered.contains("joe"), + "username must not leak: {rendered}" + ); + assert!( + !rendered.contains("AppData"), + "directory path must not leak: {rendered}" + ); + } + + #[test] + fn redacted_preserves_valid_variant_label_and_basename() { + let status = RegistrationStatus::Valid { + command: "\"C:\\Program Files\\OpenHuman\\OpenHuman.exe\" \"%1\"".into(), + }; + assert_eq!(status.redacted(), "Valid { exe: OpenHuman.exe }"); + } + + #[test] + fn redacted_passes_through_pathless_variants() { + assert_eq!( + RegistrationStatus::MissingCommand.redacted(), + "MissingCommand" + ); + assert_eq!( + RegistrationStatus::NotRegistered.redacted(), + "NotRegistered" + ); + assert_eq!( + RegistrationStatus::ReadError("win32 error 5".into()).redacted(), + "ReadError(win32 error 5)" + ); + } + + #[test] + fn is_healthy_only_for_valid_variant() { + assert!(RegistrationStatus::Valid { + command: "x".into() + } + .is_healthy()); + assert!(!RegistrationStatus::MissingCommand.is_healthy()); + assert!(!RegistrationStatus::NotRegistered.is_healthy()); + assert!(!RegistrationStatus::Stale { + registered_command: "x".into(), + expected_exe: "y".into() + } + .is_healthy()); + assert!(!RegistrationStatus::ReadError("foo".into()).is_healthy()); + } +} diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index f3c95f5b0d..5b51002508 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -1,5 +1,7 @@ +// Desktop targets: Windows, macOS, Linux. iOS + Android live in +// `app/src-tauri-mobile/`. #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] -compile_error!("src-tauri host is desktop-only. Non-desktop targets are not supported."); +compile_error!("src-tauri host supports desktop (Windows/macOS/Linux) only. Mobile lives in app/src-tauri-mobile."); mod cdp; #[cfg(any(target_os = "macos", target_os = "linux"))] @@ -8,14 +10,21 @@ mod cef_profile; mod companion_commands; mod core_process; mod core_rpc; +#[cfg(target_os = "linux")] +mod deep_link_ipc; #[cfg(target_os = "windows")] mod deep_link_ipc_windows; +// Cross-platform module: the registry-reading function is windows-only, but +// the parsing helpers compile (and test) everywhere so `cargo test` on the +// developer host covers them. +mod deep_link_registration_check; mod dictation_hotkeys; mod discord_scanner; mod fake_camera; mod file_logging; mod gmessages_scanner; mod imessage_scanner; +mod loopback_oauth; #[cfg(target_os = "macos")] mod mascot_native_window; mod mcp_commands; @@ -27,6 +36,8 @@ mod native_notifications; mod notification_settings; mod process_kill; mod process_recovery; +#[cfg(target_os = "windows")] +mod reset_reboot_schedule; mod screen_capture; mod slack_scanner; mod telegram_scanner; @@ -35,6 +46,7 @@ mod webview_apis; mod wechat_scanner; mod whatsapp_scanner; mod window_state; +mod workspace_paths; #[cfg(target_os = "macos")] use tauri::menu::{PredefinedMenuItem, Submenu}; @@ -256,6 +268,27 @@ async fn restart_core_process( state.inner().restart().await } +/// Attempt to auto-recover from a port conflict by reaping stale OpenHuman +/// processes (cross-platform) and restarting the embedded core. +/// +/// Called by the BootCheckGate "Fix Automatically" button when the core is +/// unreachable due to a port conflict. +#[tauri::command] +async fn recover_port_conflict( + state: tauri::State<'_, core_process::CoreProcessHandle>, +) -> Result { + log::info!("[core] recover_port_conflict: command invoked from frontend"); + let _guard = state.inner().restart_lock().await; + log::debug!("[core] recover_port_conflict: acquired restart lock"); + let outcome = state.inner().recover_port_conflict().await; + log::debug!( + "[core] recover_port_conflict: result success={} message={}", + outcome.success, + outcome.message + ); + Ok(outcome) +} + /// Start the embedded core process on demand. /// /// Called by the BootCheckGate (Local mode) before the version check. The @@ -353,6 +386,18 @@ async fn reset_local_data( state.inner().shutdown().await; log::info!("[core] reset_local_data: embedded core stopped"); + // ── 3b. Release the host-process log file handle (issue #1615) ────── + // + // The daily-rotating log appender at `/logs/openhuman-*.log` + // is owned by *this* Tauri host process, not by the embedded core + // tokio task — so `shutdown()` above does not release it. On Windows + // that lingering OS file handle causes `remove_dir_all(.openhuman)` + // below to fail with `ERROR_SHARING_VIOLATION` (os error 32). Drop + // the writer guard now so the background flushing thread exits and + // the file handle is closed before the removal walks the tree. + let log_guard_dropped = openhuman_core::core::logging::shutdown_file_guard(); + log::info!("[core] reset_local_data: shutdown_file_guard dropped guard = {log_guard_dropped}"); + // ── 4. Remove the paths ───────────────────────────────────────────── // // Missing entries are non-fatal: the user may already have manually @@ -419,23 +464,125 @@ fn is_windows_file_lock_error(error: &std::io::Error) -> bool { cfg!(windows) && is_windows_file_lock_raw_os_error(error.raw_os_error()) } +/// Returns: +/// * `Ok(())` — the underlying remove failure should be swallowed (e.g. +/// the path disappeared between the failed `remove_*` call and the +/// reboot-fallback walk, so there is nothing left to clean up). +/// * `Err(msg)` — a user-facing failure message the caller should surface +/// to the UI / propagate up the reset flow. fn reset_local_data_delete_error( label: &str, path: &std::path::Path, error: &std::io::Error, -) -> String { +) -> Result<(), String> { if is_windows_file_lock_error(error) { log::warn!( "[core] reset_local_data: Windows file lock blocked removal of {label} at {}: {error}", path.display() ); - return format!( - "Failed to remove {label} at {} because it is locked by another OpenHuman window or process. Close all OpenHuman windows and try again. ({error})", - path.display() - ); + + // Fallback: queue the still-locked sub-tree for deletion on the + // next Windows boot via MoveFileExW + MOVEFILE_DELAY_UNTIL_REBOOT. + // By this point in `reset_local_data` we have already: + // * shut down the embedded core (drops every SQLite/log handle + // the core task held), and + // * released the host-process log appender via + // `shutdown_file_guard()` (drops the rolling log file handle). + // So any remaining lock now comes from *outside* this process — + // anti-virus / file indexer / sibling app / Explorer — and cannot + // be released by closing more OpenHuman windows. See issue #1615. + #[cfg(target_os = "windows")] + { + return schedule_reboot_delete_or_describe(label, path, error); + } + // `is_windows_file_lock_error` is gated on `cfg!(windows)`, so on + // Linux/macOS this branch is unreachable at runtime — but cargo + // still type-checks the file for those targets and needs a value + // of type `String`. + #[cfg(not(target_os = "windows"))] + { + return Err(format!( + "Failed to remove {label} at {} because it is locked by another OpenHuman window or process. Close all OpenHuman windows and try again. ({error})", + path.display() + )); + } } - format!("Failed to remove {label} at {}: {error}", path.display()) + Err(format!( + "Failed to remove {label} at {}: {error}", + path.display() + )) +} + +/// Windows-only: ask the session manager to delete `path` (and its +/// children if it is a directory) on the next reboot, and return either a +/// user-facing message describing the outcome or `Ok(())` when the +/// underlying failure should be treated as already-cleaned-up. +#[cfg(target_os = "windows")] +fn schedule_reboot_delete_or_describe( + label: &str, + path: &std::path::Path, + original_error: &std::io::Error, +) -> Result<(), String> { + match reset_reboot_schedule::schedule_path_for_reboot_deletion(path) { + Ok(summary) => { + log::info!( + "[core] reset_local_data: scheduled {label} at {} for reboot deletion (files={}, dirs={})", + path.display(), + summary.files, + summary.dirs + ); + Err(format!( + "Couldn't remove {label} at {} right now because another process is holding it open ({original_error}). {} files and {} folders have been queued for deletion the next time you restart Windows — restart soon to finish the reset.", + path.display(), + summary.files, + summary.dirs, + )) + } + // Race condition: the still-locked path disappeared between the + // `remove_*` call that failed with `ERROR_SHARING_VIOLATION` and + // the metadata read inside the reboot-schedule walk. Whoever else + // held the handle has already finished cleaning up, so the reset + // goal is achieved — swallow the original lock error and treat + // this as success. The empty partial schedule (no entries queued + // yet) is what distinguishes "vanished cleanly" from "started + // walking, then hit a real error." + Err(failure) + if failure.error.kind() == std::io::ErrorKind::NotFound + && failure.partial.total() == 0 => + { + log::info!( + "[core] reset_local_data: {label} at {} disappeared between lock failure and reboot fallback; treating as removed", + path.display(), + ); + Ok(()) + } + Err(failure) => { + let partial_total = failure.partial.total(); + log::error!( + "[core] reset_local_data: reboot delete fallback failed for {label} at {}: {} (partial schedule: files={}, dirs={})", + path.display(), + failure.error, + failure.partial.files, + failure.partial.dirs, + ); + if partial_total == 0 { + Err(format!( + "Failed to remove {label} at {} because it is locked by another OpenHuman window or process, and scheduling deletion on next reboot also failed ({}). Close all OpenHuman windows and try again. ({original_error})", + path.display(), + failure.error, + )) + } else { + Err(format!( + "Failed to remove {label} at {} because it is locked by another OpenHuman window or process. {} files and {} folders were queued for the next reboot before scheduling failed ({}); the rest still needs manual cleanup. Close all OpenHuman windows and try again. ({original_error})", + path.display(), + failure.partial.files, + failure.partial.dirs, + failure.error, + )) + } + } + } } /// Call the core's `config_get_data_paths` RPC and parse the response. @@ -505,7 +652,7 @@ async fn remove_path_if_exists(path: &std::path::Path, label: &str) -> Result<() ); Ok(()) } - Err(e) => Err(reset_local_data_delete_error(label, path, &e)), + Err(e) => reset_local_data_delete_error(label, path, &e), } } @@ -526,7 +673,7 @@ async fn remove_dir_if_exists(path: &std::path::Path, label: &str) -> Result<(), ); Ok(()) } - Err(e) => Err(reset_local_data_delete_error(label, path, &e)), + Err(e) => reset_local_data_delete_error(label, path, &e), } } @@ -1453,6 +1600,31 @@ fn setup_tray(app: &AppHandle) -> tauri::Result<()> { const CEF_PREWARM_LABEL: &str = "cef-prewarm"; +/// Decide whether to spawn the CEF cold-start prewarm webview. +/// +/// Testable pure function — callers pass the relevant env values directly. +/// +/// Decision matrix: +/// - `env_override` = `Some("0"|"false"|"no"|"off")` → disabled (explicit) +/// - `env_override` = `Some()` → enabled (explicit opt-in; +/// overrides even the Wayland guard so ops can re-enable if CEF subprocess +/// X handling improves) +/// - `env_override` = `None` (env var unset, default path): +/// - `wayland_display_set` = `true` → **disabled** — auto-guard against the +/// fatal `X_ConfigureWindow BadWindow` crash that fires in CEF render +/// subprocesses on Wayland/XWayland sessions (issue #2463). The main-process +/// silent X error handler (`install_silent_x_error_handler`) does not reach +/// CEF subprocesses; until subprocess-level coverage is available, skipping +/// the prewarm child webview is the safest mitigation. +/// - `wayland_display_set` = `false` → enabled +fn cef_prewarm_enabled(env_override: Option<&str>, wayland_display_set: bool) -> bool { + if let Some(v) = env_override { + let v = v.trim().to_ascii_lowercase(); + return !(v == "0" || v == "false" || v == "no" || v == "off"); + } + !wayland_display_set +} + /// Spawn a hidden 1×1 child webview at `about:blank` on the main window so /// CEF's child-webview render path is hot before the user clicks an /// account. The first `webview_account_open` then skips the cold @@ -2178,8 +2350,34 @@ pub fn run() { // SAFETY: mutex_name is null-terminated UTF-16; handle is checked below. let handle = unsafe { CreateMutexW(std::ptr::null(), 0, mutex_name.as_ptr()) }; + // Capture GetLastError immediately after CreateMutexW so no intervening + // syscall (e.g. logging) can clobber the thread-local error code. + let last_error = unsafe { GetLastError() }; + + // Primary: hold the handle until run() returns. + struct OwnedMutex(isize); + impl Drop for OwnedMutex { + fn drop(&mut self) { + if self.0 != 0 { + unsafe { CloseHandle(self.0 as _) }; + } + } + } - if unsafe { GetLastError() } == ERROR_ALREADY_EXISTS { + if handle.is_null() { + // CreateMutexW failed for a reason other than "already exists" + // (which returns a valid handle plus ERROR_ALREADY_EXISTS). Likely + // causes: out-of-memory, security-descriptor fault, or other + // Win32-level anomaly. Without the guard, a concurrent second + // launch can re-trigger the cef::initialize panic this block was + // added to prevent — but refusing to start at all is strictly + // worse for the user. Log loudly so the failure is observable in + // Sentry / log files and continue best-effort. + log::error!( + "[single-instance] CreateMutexW returned NULL handle (GetLastError={last_error}); continuing without pre-CEF single-instance guard — concurrent launches may hit OPENHUMAN-TAURI-A" + ); + OwnedMutex(0) + } else if last_error == ERROR_ALREADY_EXISTS { // Another instance is already past this point — exit before we // touch CEF at all. Forward deep links first so OAuth callbacks // are not dropped by this early pre-plugin exit. @@ -2192,25 +2390,14 @@ pub fn run() { ); } } - if !handle.is_null() { - unsafe { CloseHandle(handle) }; - } + unsafe { CloseHandle(handle) }; log::info!( "[single-instance] pre-CEF mutex held by primary; secondary exiting (OPENHUMAN-TAURI-A fix)" ); std::process::exit(0); + } else { + OwnedMutex(handle as isize) } - - // Primary: hold the handle until run() returns. - struct OwnedMutex(isize); - impl Drop for OwnedMutex { - fn drop(&mut self) { - if self.0 != 0 { - unsafe { CloseHandle(self.0 as _) }; - } - } - } - OwnedMutex(handle as isize) }; #[cfg(windows)] @@ -2236,6 +2423,23 @@ pub fn run() { #[cfg(target_os = "macos")] process_recovery::reap_stale_openhuman_processes(); + // ── Linux pre-CEF deep-link forwarding guard (issue #2359) ──────────── + // On Linux, a secondary instance with an openhuman:// URL in argv exits + // at the CEF preflight check before Builder::setup() runs, silently + // dropping the OAuth callback. Detect and forward the URL here, before + // CEF preflight can exit(1). + #[cfg(target_os = "linux")] + let _deep_link_socket_guard = { + use deep_link_ipc::ForwardResult; + match deep_link_ipc::try_forward_deep_links() { + ForwardResult::Forwarded => { + std::process::exit(0); + } + ForwardResult::NoPrimary | ForwardResult::NoUrls => {} + } + deep_link_ipc::bind_and_listen() + }; + // CEF cache-lock preflight: if another OpenHuman instance holds the CEF // user-data-dir SingletonLock, `cef_initialize` returns 0 and the vendored // runtime panics (`left: 0, right: 1`). Catch the collision here and exit @@ -2503,8 +2707,34 @@ pub fn run() { .setup(move |app| { #[cfg(windows)] { - if let Err(err) = app.deep_link().register_all() { - log::warn!("[deep-link] register_all failed (non-fatal): {err}"); + // `register_all` writes HKCU\Software\Classes\openhuman so the + // browser can hand `openhuman://auth?...` callbacks back to + // the running instance. The plugin only returns an Err — and + // it only logs at `warn` — when its single internal write + // fails outright; it does not verify what's on disk. Issue + // #2699 reports OAuth callbacks silently disappearing on + // some Windows installs, which traced back to a missing or + // stale `command` value here. Read it back and log loudly + // (Sentry-level `error`) so the failure mode is observable + // in support logs; we deliberately do NOT auto-repair — + // writing the wrong exe path can brick a working install. + let register_err = app.deep_link().register_all().err(); + let status = deep_link_registration_check::verify_protocol_registration(); + let status_log = status.redacted(); + if register_err.is_none() && status.is_healthy() { + log::info!("[deep-link] openhuman:// scheme registered ({status_log})"); + } else { + // Use the redacted form so per-user install paths + // (`C:\Users\\...`) do not land in Sentry / user + // logs — basenames are kept so the diagnostic still + // identifies the registered exe. + log::error!( + "[deep-link] openhuman:// scheme registration unhealthy — \ + OAuth callbacks may never reach the app. \ + register_all_error={register_err:?}, hkcu_status={status_log}. \ + See gitbooks/overview/troubleshooting-sign-in.md \ + (\"Windows: openhuman:// handler not registered\") for the manual repair." + ); } deep_link_ipc_windows::drain_pending_urls(app.app_handle()); } @@ -2551,6 +2781,11 @@ pub fn run() { missing.join(", ") ); } + + // Drain any deep-link URLs that arrived via the IPC socket + // before setup() ran (issue #2359). Also installs the live + // handler so URLs arriving after setup() are emitted directly. + deep_link_ipc::drain_pending_urls(app.app_handle()); } // Start the webview_apis WebSocket bridge BEFORE spawning core — @@ -2840,13 +3075,12 @@ pub fn run() { // tear it down in the shutdown sequence below. Disable at // runtime with `OPENHUMAN_CEF_PREWARM=0` if it regresses. { - let prewarm_enabled = std::env::var("OPENHUMAN_CEF_PREWARM") - .map(|v| { - let v = v.trim().to_ascii_lowercase(); - !(v == "0" || v == "false" || v == "no" || v == "off") - }) - .unwrap_or(true); - if prewarm_enabled { + #[cfg(target_os = "linux")] + let wayland_display_set = has_non_empty_env("WAYLAND_DISPLAY"); + #[cfg(not(target_os = "linux"))] + let wayland_display_set = false; + let env_override = std::env::var("OPENHUMAN_CEF_PREWARM").ok(); + if cef_prewarm_enabled(env_override.as_deref(), wayland_display_set) { let app_handle = app.handle().clone(); tauri::async_runtime::spawn(async move { // Defer one tick so the main window finishes its @@ -2856,6 +3090,12 @@ pub fn run() { log::warn!("[cef-prewarm] failed (non-fatal): {e}"); } }); + } else if wayland_display_set && env_override.is_none() { + log::info!( + "[cef-prewarm] auto-disabled: WAYLAND_DISPLAY is set (Wayland/XWayland \ + session) — prevents X_ConfigureWindow BadWindow crash in CEF \ + subprocesses (issue #2463); set OPENHUMAN_CEF_PREWARM=1 to override" + ); } else { log::info!("[cef-prewarm] disabled via OPENHUMAN_CEF_PREWARM"); } @@ -3124,6 +3364,7 @@ pub fn run() { download_app_update, install_app_update, restart_core_process, + recover_port_conflict, start_core_process, reset_local_data, app_quit, @@ -3160,13 +3401,18 @@ pub fn run() { mascot_window_hide, file_logging::reveal_logs_folder, file_logging::logs_folder_path, + workspace_paths::open_workspace_path, + workspace_paths::reveal_workspace_path, + workspace_paths::preview_workspace_text, meet_call::meet_call_open_window, meet_call::meet_call_close_window, companion_commands::register_companion_hotkey, companion_commands::unregister_companion_hotkey, companion_commands::companion_activate, mcp_commands::mcp_resolve_binary_path, - mcp_commands::mcp_open_client_config + mcp_commands::mcp_open_client_config, + loopback_oauth::start_loopback_oauth_listener, + loopback_oauth::stop_loopback_oauth_listener ]) .build(tauri::generate_context!()) .expect("error while building tauri application") @@ -3309,7 +3555,7 @@ pub fn run_core_from_args(args: &[String]) -> Result<(), String> { } // --------------------------------------------------------------------------- -// Sentry release / environment resolution (Tauri shell) +// Sentry release / environment resolution (Tauri shell — desktop only) // --------------------------------------------------------------------------- /// Canonical release tag: `openhuman@[+]`. @@ -3490,28 +3736,77 @@ mod tests { #[test] fn reset_local_data_delete_error_keeps_generic_message_for_other_errors() { let err = std::io::Error::from(std::io::ErrorKind::PermissionDenied); - let msg = reset_local_data_delete_error( + let result = reset_local_data_delete_error( "current openhuman dir", std::path::Path::new("/tmp/openhuman"), &err, ); + let msg = result.expect_err("non-lock errors must still surface to the UI"); assert!(msg.starts_with("Failed to remove current openhuman dir at /tmp/openhuman:")); assert!(!msg.contains("Close all OpenHuman windows and try again")); } #[cfg(windows)] #[test] - fn reset_local_data_delete_error_explains_windows_file_locks() { + fn reset_local_data_delete_error_swallows_lock_failure_when_path_disappeared() { + // Race condition the reboot fallback now handles: the locked path + // was gone by the time `schedule_path_for_reboot_deletion` ran its + // `symlink_metadata` probe, so the reset goal is already met. The + // helper must return `Ok(())` rather than surfacing a confusing + // "couldn't remove (it's not there)" toast. + let dir = tempfile::tempdir().expect("tempdir for reset error test"); + let missing = dir.path().join("definitely-not-there"); + let err = std::io::Error::from_raw_os_error(32); - let msg = reset_local_data_delete_error( - "current openhuman dir", - std::path::Path::new("C:\\Users\\me\\.openhuman"), - &err, + let result = reset_local_data_delete_error("current openhuman dir", &missing, &err); + + assert!( + result.is_ok(), + "expected NotFound + empty partial schedule to be swallowed as success, got {result:?}" ); + } + + #[cfg(windows)] + #[test] + fn reset_local_data_delete_error_reports_reboot_schedule_counts() { + // When the lock fallback can walk a real directory tree, the user + // message should report how much has been queued so the support + // log preserves "what was actually scheduled". Scheduling itself + // may still fail at the MoveFileExW step in unprivileged test + // processes (the registry key write requires administrator); the + // fallback then carries a partial schedule that the error path + // surfaces, so both branches must keep mentioning the lock cause + // *and* expose either the queued counts or the schedule failure. + let dir = tempfile::tempdir().expect("tempdir for reset error test"); + let target = dir.path().join("reset-mock"); + std::fs::create_dir_all(target.join("nested")).expect("mkdir nested"); + std::fs::write(target.join("a.txt"), b"x").expect("write a.txt"); + std::fs::write(target.join("nested").join("b.txt"), b"y").expect("write b.txt"); - assert!(msg.contains("locked by another OpenHuman window or process")); - assert!(msg.contains("Close all OpenHuman windows and try again")); + let err = std::io::Error::from_raw_os_error(32); + let result = reset_local_data_delete_error("current openhuman dir", &target, &err); + + // Path exists on disk, so the fallback must surface the outcome — + // either an "all-queued" success-but-needs-reboot message (admin) + // or one of the failure flavours (non-admin). + let msg = result + .expect_err("path exists, fallback must report queued counts or scheduling failure"); + let admin_path = msg.contains("queued for deletion the next time you restart Windows") + && msg.contains("2 files and 2 folders"); + let user_full_fail = msg.contains("scheduling deletion on next reboot also failed"); + let user_partial = msg.contains("queued for the next reboot before scheduling failed"); + assert!( + admin_path || user_full_fail || user_partial, + "expected reboot-scheduled, fully-failed, or partial-fail message, got: {msg}" + ); + // Whatever branch we land on, the user must still be told the lock + // is what blocked the immediate removal. + assert!( + msg.contains("locked by another OpenHuman window or process") + || msg.contains("another process is holding it open"), + "missing lock cause: {msg}" + ); } /// Tests for setup_tray conditional compilation @@ -3815,6 +4110,60 @@ mod tests { assert_eq!(std::env::consts::ARCH, "aarch64"); } + // ------------------------------------------------------------------------- + // cef_prewarm_enabled (issue #2463 — Wayland/XWayland BadWindow guard) + // ------------------------------------------------------------------------- + + #[test] + fn prewarm_enabled_by_default_on_non_wayland() { + assert!(cef_prewarm_enabled(None, false)); + } + + #[test] + fn prewarm_auto_disabled_on_wayland_when_env_unset() { + assert!(!cef_prewarm_enabled(None, true)); + } + + #[test] + fn prewarm_explicit_disable_respected_on_non_wayland() { + assert!(!cef_prewarm_enabled(Some("0"), false)); + assert!(!cef_prewarm_enabled(Some("false"), false)); + assert!(!cef_prewarm_enabled(Some("no"), false)); + assert!(!cef_prewarm_enabled(Some("off"), false)); + } + + #[test] + fn prewarm_explicit_disable_respected_on_wayland() { + assert!(!cef_prewarm_enabled(Some("0"), true)); + assert!(!cef_prewarm_enabled(Some("false"), true)); + } + + #[test] + fn prewarm_explicit_enable_overrides_wayland_guard() { + // OPENHUMAN_CEF_PREWARM=1 (or any non-disable value) lets ops + // force prewarm even on Wayland sessions. + assert!(cef_prewarm_enabled(Some("1"), true)); + assert!(cef_prewarm_enabled(Some("true"), true)); + assert!(cef_prewarm_enabled(Some("yes"), true)); + assert!(cef_prewarm_enabled(Some("on"), true)); + } + + #[test] + fn prewarm_disable_flags_are_case_insensitive() { + assert!(!cef_prewarm_enabled(Some("FALSE"), false)); + assert!(!cef_prewarm_enabled(Some("OFF"), true)); + assert!(!cef_prewarm_enabled(Some(" 0 "), false)); + assert!(!cef_prewarm_enabled(Some(" No "), true)); + } + + #[test] + fn prewarm_unknown_env_value_treated_as_enable() { + // Any string that is not a recognised disable token → treat as enable. + assert!(cef_prewarm_enabled(Some("enabled"), false)); + assert!(cef_prewarm_enabled(Some("yes"), false)); + assert!(cef_prewarm_enabled(Some(""), false)); + } + // ------------------------------------------------------------------------- // build_sentry_release_tag // ------------------------------------------------------------------------- diff --git a/app/src-tauri/src/loopback_oauth.rs b/app/src-tauri/src/loopback_oauth.rs new file mode 100644 index 0000000000..e7e68ae57b --- /dev/null +++ b/app/src-tauri/src/loopback_oauth.rs @@ -0,0 +1,518 @@ +//! Loopback HTTP listener for OAuth / magic-link callbacks (RFC 8252). +//! +//! Used as the preferred desktop redirect target ahead of the `openhuman://` +//! deep link: the frontend asks the shell to bind a one-shot HTTP server on a +//! fixed loopback port, hands the resulting URL to the backend as +//! `redirectUri`, and waits for the `loopback-oauth-callback` Tauri event. +//! +//! Lifecycle is spawn-on-demand: each call to +//! [`start_loopback_oauth_listener`] supersedes any previously-running +//! listener, binds `127.0.0.1:`, accepts connections until either the +//! state-matching `/auth` request arrives or `timeout_secs` elapses, then +//! shuts the listener down. If bind fails (port already in use), the command +//! returns an error and the caller falls back to the deep-link path. +//! +//! Only the `/auth` path is honored — favicons and stray requests get a +//! 404 and keep the loop alive. The state nonce is generated in the shell +//! and returned to the caller; the backend must echo it back as `state=` on +//! the redirect so a hostile page on the same loopback origin cannot fake a +//! callback. + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Mutex; +use std::time::Duration; + +use rand::RngCore; +use serde::Serialize; +use tauri::Emitter; + +use crate::AppRuntime; +type AppHandle = tauri::AppHandle; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpSocket}; +use tokio::sync::oneshot; +use tokio::time::timeout; + +const LOOPBACK_CALLBACK_EVENT: &str = "loopback-oauth-callback"; +const READ_BUFFER_BYTES: usize = 8 * 1024; +const PER_CONNECTION_READ_TIMEOUT: Duration = Duration::from_secs(5); + +struct ActiveListener { + id: u64, + tx: oneshot::Sender<()>, + done: Option>, +} + +static NEXT_LISTENER_ID: AtomicU64 = AtomicU64::new(1); +static ACTIVE_LISTENER: Mutex> = Mutex::new(None); + +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct StartResult { + /// Full redirect URI the backend should redirect to, e.g. + /// `http://127.0.0.1:53824/auth`. State is appended by the caller. + /// Serializes as `redirectUri` so the TS-side `result.redirectUri` + /// destructure works. + pub redirect_uri: String, + /// State nonce the backend must echo back as `?state=`. + pub state: String, +} + +#[derive(Serialize, Clone)] +struct CallbackPayload { + /// Full callback URL including query string. Frontend re-uses the existing + /// `handleAuthDeepLink` parser by converting it to an `openhuman://` URL. + url: String, +} + +/// Signal the active listener to stop and return its join handle so the caller +/// can await its full teardown — critical when re-binding a fixed port, since +/// macOS releases the socket only after the owning task drops the listener. +fn take_active_listener() -> Option> { + if let Ok(mut guard) = ACTIVE_LISTENER.lock() { + if let Some(mut active) = guard.take() { + let _ = active.tx.send(()); + return active.done.take(); + } + } + None +} + +fn cancel_active_listener() { + let _ = take_active_listener(); +} + +fn install_active_listener( + id: u64, + tx: oneshot::Sender<()>, + done: tauri::async_runtime::JoinHandle<()>, +) { + if let Ok(mut guard) = ACTIVE_LISTENER.lock() { + if let Some(mut old) = guard.replace(ActiveListener { + id, + tx, + done: Some(done), + }) { + let _ = old.tx.send(()); + // The previous listener's join handle is dropped here without an + // await — only the new-start path needs to await teardown. Stray + // installs (none today) would simply leak the wait, not break. + old.done.take(); + } + } +} + +/// Only clear the global slot if it still belongs to this listener's id. +/// A superseded listener's exit must NOT wipe out the newer sender installed +/// by the start that cancelled it. +fn clear_active_listener(id: u64) { + if let Ok(mut guard) = ACTIVE_LISTENER.lock() { + if guard.as_ref().map(|active| active.id) == Some(id) { + *guard = None; + } + } +} + +/// Bind a loopback TCP listener on the given port (or 0 for ephemeral). Sets +/// SO_REUSEADDR so re-binding the same port soon after a previous listener +/// dropped doesn't trip EADDRINUSE on the TIME_WAIT window. +fn bind_loopback(port: u16) -> Result { + let sock_addr: std::net::SocketAddr = format!("127.0.0.1:{port}") + .parse() + .map_err(|err| format!("parse 127.0.0.1:{port} failed: {err}"))?; + let socket = TcpSocket::new_v4().map_err(|err| format!("TcpSocket::new_v4 failed: {err}"))?; + socket + .set_reuseaddr(true) + .map_err(|err| format!("set_reuseaddr failed: {err}"))?; + socket + .bind(sock_addr) + .map_err(|err| format!("bind 127.0.0.1:{port} failed: {err}"))?; + socket + .listen(16) + .map_err(|err| format!("listen on 127.0.0.1:{port} failed: {err}")) +} + +fn random_state_nonce() -> String { + let mut bytes = [0u8; 16]; + rand::rng().fill_bytes(&mut bytes); + hex::encode(bytes) +} + +/// Parse the request target (path + query) out of an HTTP/1.x request head. +fn parse_request_target(head: &str) -> Option<&str> { + let first_line = head.split("\r\n").next()?; + let mut parts = first_line.split_whitespace(); + let method = parts.next()?; + let target = parts.next()?; + if method.eq_ignore_ascii_case("GET") { + Some(target) + } else { + None + } +} + +/// Return the value of `state=` in a query string, if present. +fn extract_state(query: &str) -> Option<&str> { + query + .split('&') + .filter_map(|pair| pair.split_once('=')) + .find(|(k, _)| *k == "state") + .map(|(_, v)| v) +} + +/// Outcome of classifying one HTTP request against the loopback accept loop. +/// Extracted so routing logic can be unit-tested without a live `AppHandle`. +#[derive(Debug, PartialEq)] +enum RequestOutcome { + /// `/auth` matched and state is valid. Caller should send 200, emit callback. + AuthCallback { callback_url: String }, + /// `/auth` matched but `state=` was missing or wrong. Caller sends 400. + StateMismatch, + /// Path is not `/auth`. Caller sends 404. + NotFound, + /// Method is not GET. Caller sends 405. + MethodNotAllowed, +} + +/// Classify one HTTP/1.x request received by the loopback accept loop. +fn classify_request(head: &str, expected_state: &str, bound_port: u16) -> RequestOutcome { + let target = match parse_request_target(head) { + Some(t) => t.to_string(), + None => return RequestOutcome::MethodNotAllowed, + }; + + let (path, query) = match target.split_once('?') { + Some((p, q)) => (p, q), + None => (target.as_str(), ""), + }; + + if path != "/auth" { + return RequestOutcome::NotFound; + } + + match extract_state(query) { + Some(s) if s == expected_state => { + let callback_url = format!("http://127.0.0.1:{bound_port}{target}"); + RequestOutcome::AuthCallback { callback_url } + } + _ => RequestOutcome::StateMismatch, + } +} + +const SUCCESS_BODY: &str = "Signed in\ +\ +

You're signed in.

\ +

You can close this tab and return to OpenHuman.

\ +"; + +fn http_response(status: &str, body: &str) -> Vec { + format!( + "HTTP/1.1 {status}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {len}\r\nConnection: close\r\nCache-Control: no-store\r\n\r\n{body}", + len = body.len(), + ) + .into_bytes() +} + +#[tauri::command] +pub async fn start_loopback_oauth_listener( + app: AppHandle, + port: u16, + timeout_secs: u64, +) -> Result { + // Await the previous listener's task ending so the OS has actually + // released the fixed loopback port. SO_REUSEADDR alone is not enough on + // macOS — the prior socket must be dropped first. + if let Some(done) = take_active_listener() { + let _ = done.await; + } + + // Prefer the caller's requested port (so the backend allowlist, if any, + // matches) but fall back to an ephemeral OS-assigned port if the requested + // one is taken by another process (stale openhuman, second instance, + // unrelated service). The backend `redirectUri` whitelist restricts host + // but not port, so an ephemeral fallback is safe. + let listener: TcpListener = match bind_loopback(port) { + Ok(l) => l, + Err(primary_err) => { + log::warn!( + "[loopback-oauth] bind on requested port {port} failed ({primary_err}); retrying on ephemeral port" + ); + bind_loopback(0).map_err(|err| { + format!( + "bind 127.0.0.1:{port} failed ({primary_err}); ephemeral fallback also failed: {err}" + ) + })? + } + }; + // Use the listener's actual bound port for the emitted callback URL so + // the frontend rewrite (`^https?://127.0.0.1:\d+/auth`) always matches, + // even if a future change moves to port 0. + let bound_port = listener + .local_addr() + .map(|addr| addr.port()) + .unwrap_or(port); + log::info!("[loopback-oauth] listening on 127.0.0.1:{bound_port}"); + + let state = random_state_nonce(); + let redirect_uri = format!("http://127.0.0.1:{bound_port}/auth"); + + let (cancel_tx, cancel_rx) = oneshot::channel::<()>(); + let listener_id = NEXT_LISTENER_ID.fetch_add(1, Ordering::Relaxed); + + let expected_state = state.clone(); + let done = tauri::async_runtime::spawn(async move { + let lifetime = Duration::from_secs(timeout_secs.max(1)); + let run = run_accept_loop(listener, app, expected_state, bound_port, cancel_rx); + match timeout(lifetime, run).await { + Ok(()) => log::info!("[loopback-oauth] listener finished"), + Err(_) => log::warn!( + "[loopback-oauth] listener timed out after {}s", + lifetime.as_secs() + ), + } + clear_active_listener(listener_id); + }); + install_active_listener(listener_id, cancel_tx, done); + + Ok(StartResult { + redirect_uri, + state, + }) +} + +#[tauri::command] +pub async fn stop_loopback_oauth_listener() -> Result<(), String> { + cancel_active_listener(); + Ok(()) +} + +async fn run_accept_loop( + listener: TcpListener, + app: AppHandle, + expected_state: String, + bound_port: u16, + mut cancel_rx: oneshot::Receiver<()>, +) { + loop { + tokio::select! { + _ = &mut cancel_rx => { + log::debug!("[loopback-oauth] cancelled by new start or explicit stop"); + return; + } + accept = listener.accept() => { + let (mut socket, peer) = match accept { + Ok(pair) => pair, + Err(err) => { + log::warn!("[loopback-oauth] accept failed: {err}"); + continue; + } + }; + if !peer.ip().is_loopback() { + log::warn!("[loopback-oauth] rejecting non-loopback peer {peer}"); + let _ = socket.shutdown().await; + continue; + } + + let mut buf = vec![0u8; READ_BUFFER_BYTES]; + let read = match timeout(PER_CONNECTION_READ_TIMEOUT, socket.read(&mut buf)).await { + Ok(Ok(n)) => n, + Ok(Err(err)) => { + log::debug!("[loopback-oauth] read error from {peer}: {err}"); + continue; + } + Err(_) => { + log::debug!("[loopback-oauth] read timeout from {peer}"); + continue; + } + }; + if read == 0 { + continue; + } + + let head = String::from_utf8_lossy(&buf[..read]); + match classify_request(&head, &expected_state, bound_port) { + RequestOutcome::MethodNotAllowed => { + let _ = socket + .write_all(&http_response("405 Method Not Allowed", "method not allowed")) + .await; + } + RequestOutcome::NotFound => { + let _ = socket + .write_all(&http_response("404 Not Found", "not found")) + .await; + } + RequestOutcome::StateMismatch => { + log::warn!( + "[loopback-oauth] /auth with missing or mismatched state — ignoring" + ); + let _ = socket + .write_all(&http_response("400 Bad Request", "state mismatch")) + .await; + } + RequestOutcome::AuthCallback { callback_url } => { + let _ = socket.write_all(&http_response("200 OK", SUCCESS_BODY)).await; + let _ = socket.flush().await; + if let Err(err) = + app.emit(LOOPBACK_CALLBACK_EVENT, CallbackPayload { url: callback_url }) + { + log::warn!("[loopback-oauth] emit callback event failed: {err}"); + } + return; + } + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_get_request_target() { + let head = "GET /auth?token=abc&state=xyz HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n"; + assert_eq!( + parse_request_target(head), + Some("/auth?token=abc&state=xyz") + ); + } + + #[test] + fn rejects_non_get_methods() { + let head = "POST /auth HTTP/1.1\r\n\r\n"; + assert_eq!(parse_request_target(head), None); + } + + #[test] + fn extracts_state_value() { + assert_eq!(extract_state("token=abc&state=xyz"), Some("xyz")); + assert_eq!(extract_state("state=only"), Some("only")); + assert_eq!(extract_state("token=abc"), None); + assert_eq!(extract_state(""), None); + } + + #[tokio::test] + async fn random_state_is_32_hex_chars() { + let s = random_state_nonce(); + assert_eq!(s.len(), 32); + assert!(s.chars().all(|c| c.is_ascii_hexdigit())); + } + + // ── classify_request ──────────────────────────────────────────────────── + + fn auth_head(query: &str) -> String { + format!("GET /auth{query} HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n") + } + + #[test] + fn classify_valid_auth_request_returns_callback_url() { + let head = auth_head("?token=jwt&state=deadbeef"); + let outcome = classify_request(&head, "deadbeef", 53824); + assert_eq!( + outcome, + RequestOutcome::AuthCallback { + callback_url: "http://127.0.0.1:53824/auth?token=jwt&state=deadbeef".to_string() + } + ); + } + + #[test] + fn classify_wrong_state_returns_state_mismatch() { + let head = auth_head("?token=jwt&state=wrong"); + assert_eq!( + classify_request(&head, "correct", 53824), + RequestOutcome::StateMismatch + ); + } + + #[test] + fn classify_missing_state_returns_state_mismatch() { + let head = auth_head("?token=jwt"); + assert_eq!( + classify_request(&head, "expected", 53824), + RequestOutcome::StateMismatch + ); + } + + #[test] + fn classify_no_query_string_on_auth_path_returns_state_mismatch() { + let head = "GET /auth HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n"; + assert_eq!( + classify_request(head, "nonce", 53824), + RequestOutcome::StateMismatch + ); + } + + #[test] + fn classify_favicon_returns_not_found() { + let head = "GET /favicon.ico HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n"; + assert_eq!( + classify_request(head, "state", 53824), + RequestOutcome::NotFound + ); + } + + #[test] + fn classify_root_path_returns_not_found() { + let head = "GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n"; + assert_eq!( + classify_request(head, "state", 53824), + RequestOutcome::NotFound + ); + } + + #[test] + fn classify_post_method_returns_method_not_allowed() { + let head = "POST /auth?state=abc HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n"; + assert_eq!( + classify_request(head, "abc", 53824), + RequestOutcome::MethodNotAllowed + ); + } + + #[test] + fn classify_callback_url_uses_bound_port() { + let head = auth_head("?state=s&token=t"); + let outcome = classify_request(&head, "s", 12345); + assert_eq!( + outcome, + RequestOutcome::AuthCallback { + callback_url: "http://127.0.0.1:12345/auth?state=s&token=t".to_string() + } + ); + } + + #[test] + fn classify_state_only_query_returns_callback() { + // Minimal valid request: only state param, no other query params. + let head = auth_head("?state=abc123"); + assert_eq!( + classify_request(&head, "abc123", 53824), + RequestOutcome::AuthCallback { + callback_url: "http://127.0.0.1:53824/auth?state=abc123".to_string() + } + ); + } + + // ── bind_loopback (integration: real OS socket) ───────────────────────── + + #[tokio::test] + async fn bind_loopback_succeeds_on_ephemeral_port() { + let listener = bind_loopback(0).expect("bind on port 0 must succeed"); + let addr = listener.local_addr().expect("must have local addr"); + assert!(addr.ip().is_loopback()); + assert_ne!(addr.port(), 0, "OS should assign a non-zero ephemeral port"); + } + + #[tokio::test] + async fn bind_loopback_allows_rebind_via_so_reuseaddr() { + // Bind once, drop the listener, then bind again on the same port. The + // short TIME_WAIT window should not block the rebind because we set + // SO_REUSEADDR. + let listener = bind_loopback(0).expect("first bind"); + let port = listener.local_addr().unwrap().port(); + drop(listener); + let _ = bind_loopback(port).expect("rebind on same port must succeed with SO_REUSEADDR"); + } +} diff --git a/app/src-tauri/src/main.rs b/app/src-tauri/src/main.rs index 8d18389288..79e5980738 100644 --- a/app/src-tauri/src/main.rs +++ b/app/src-tauri/src/main.rs @@ -6,6 +6,7 @@ // console at runtime via AttachConsole, so command-line output still works. #![cfg_attr(target_os = "windows", windows_subsystem = "windows")] +// ── Desktop (CEF) entry point ───────────────────────────────────────────────── // On the CEF runtime, the main binary is re-exec'd as the renderer / GPU / // utility helper subprocesses. The `cef_entry_point` macro short-circuits // main() when CEF has passed `--type=` in argv, routing straight into diff --git a/app/src-tauri/src/process_kill.rs b/app/src-tauri/src/process_kill.rs index 7fc61794bf..03880b1ec9 100644 --- a/app/src-tauri/src/process_kill.rs +++ b/app/src-tauri/src/process_kill.rs @@ -150,8 +150,16 @@ fn signaled_at_least_one(status: &std::process::ExitStatus) -> bool { /// without `/F` only delivers `WM_CLOSE` to GUI apps. Send the WM_CLOSE first /// (best-effort) so console subprocesses can run shutdown handlers; the /// follow-up [`kill_pid_force`] does the actual termination. +/// +/// Refuses to signal the protected system PIDs 0 (System Idle Process) and 4 +/// (NT Kernel & System) — those should never be reachable from +/// `find_pid_on_port`, but if they slip through the parser they would +/// otherwise produce a hard taskkill failure that aborts startup recovery. #[cfg(windows)] pub(crate) fn kill_pid_term(pid: u32) -> Result<(), String> { + if is_protected_windows_pid(pid) { + return Err(format!("refusing to signal protected windows pid {pid}")); + } use std::os::windows::process::CommandExt; const CREATE_NO_WINDOW: u32 = 0x0800_0000; // Best-effort — ignore non-zero exit (e.g. process is windowless). @@ -164,15 +172,181 @@ pub(crate) fn kill_pid_term(pid: u32) -> Result<(), String> { #[cfg(windows)] pub(crate) fn kill_pid_force(pid: u32) -> Result<(), String> { + if is_protected_windows_pid(pid) { + return Err(format!( + "refusing to force-kill protected windows pid {pid}" + )); + } use std::os::windows::process::CommandExt; const CREATE_NO_WINDOW: u32 = 0x0800_0000; - let status = std::process::Command::new("taskkill") + let output = std::process::Command::new("taskkill") .args(["/F", "/T", "/PID", &pid.to_string()]) .creation_flags(CREATE_NO_WINDOW) - .status() + .output() .map_err(|e| format!("taskkill spawn: {e}"))?; - if !status.success() { - return Err(format!("taskkill exited with {status}")); + classify_taskkill_force_status(output.status.code(), &output.stderr, pid) +} + +/// Classify a `taskkill /F /T /PID ` exit. Exit code 128 ("process not +/// found") means the process already exited between the pid lookup and the +/// force-kill — the resource is freeing on its own, treat as success. Same +/// semantics as ESRCH on Unix (`kill_pid_force` returns Ok for that case). +/// +/// `stderr` is matched as a fallback when exit codes are masked by an +/// intermediate shell — some Windows hosts/wrappers normalize taskkill exit +/// codes to 1 but still write the "not found" message to stderr. +#[cfg(windows)] +pub(crate) fn classify_taskkill_force_status( + code: Option, + stderr: &[u8], + pid: u32, +) -> Result<(), String> { + match code { + Some(0) => Ok(()), + // 128 = "There is no running instance of the task." — process already gone. + Some(128) => { + log::debug!("[app] taskkill /F /PID {pid}: process already gone (exit 128)"); + Ok(()) + } + other => { + let stderr_str = String::from_utf8_lossy(stderr); + // Only treat the "process is gone" stderr shapes as success. + // `could not be terminated` ALONE is *not* enough — it also + // appears in access-denied messages like + // "could not be terminated. Reason: Access is denied." which + // we must surface as a real failure. + let stderr_lower = stderr_str.to_ascii_lowercase(); + let process_gone = stderr_lower.contains("no running instance of the task") + || (stderr_lower.contains("could not be terminated") + && stderr_lower.contains("not found")) + || (stderr_lower.contains("error: the process") + && stderr_lower.contains("not found")); + if process_gone { + log::debug!( + "[app] taskkill /F /PID {pid}: process already gone (stderr match: {stderr_str:?})" + ); + return Ok(()); + } + Err(format!( + "taskkill exited with code {other:?} stderr={stderr_str:?}" + )) + } + } +} + +/// PIDs 0 (System Idle Process) and 4 (NT Kernel & System) are kernel-owned +/// and cannot be signalled by user-mode processes. They occasionally surface +/// in `netstat -ano` output for sockets reserved by HTTP.sys or other +/// kernel-side bindings — guard against ever trying to kill them. +#[cfg(windows)] +pub(crate) const fn is_protected_windows_pid(pid: u32) -> bool { + pid == 0 || pid == 4 +} + +#[cfg(all(test, windows))] +mod windows_tests { + use super::*; + + #[test] + fn is_protected_windows_pid_matches_kernel_pids() { + assert!(is_protected_windows_pid(0)); + assert!(is_protected_windows_pid(4)); + assert!(!is_protected_windows_pid(1)); + assert!(!is_protected_windows_pid(8)); + assert!(!is_protected_windows_pid(1234)); + } + + #[test] + fn classify_taskkill_force_treats_exit_0_as_success() { + assert!(classify_taskkill_force_status(Some(0), b"", 1234).is_ok()); + } + + #[test] + fn classify_taskkill_force_treats_exit_128_as_success() { + // Exit 128 = "There is no running instance of the task." — process + // already gone between the pid lookup and our kill call. The port is + // freeing on its own; recovery must NOT bail out here. + assert!(classify_taskkill_force_status(Some(128), b"", 1234).is_ok()); + } + + #[test] + fn classify_taskkill_force_treats_not_found_stderr_as_success() { + // Some hosts/wrappers normalize exit codes to 1 but still emit the + // canonical "not found" message on stderr. + let stderr = b"ERROR: The process \"1234\" not found.\r\n"; + assert!(classify_taskkill_force_status(Some(1), stderr, 1234).is_ok()); + } + + #[test] + fn classify_taskkill_force_treats_no_running_instance_as_success() { + // The `/T` (tree) flag emits this shape when the parent is already + // gone but child traversal still runs. Pass a *non-128* exit code + // here so the test actually exercises the stderr-matching branch — + // `Some(128)` short-circuits before we ever inspect stderr. + let stderr = b"ERROR: The process with PID 1234 (child process of PID 999) \ + could not be terminated.\r\n\ + Reason: There is no running instance of the task.\r\n"; + assert!(classify_taskkill_force_status(Some(1), stderr, 1234).is_ok()); + } + + #[test] + fn classify_taskkill_force_propagates_access_denied() { + // Access-denied has the SAME "could not be terminated" prefix as + // the process-gone case, so the predicate must require additional + // tokens before treating it as success. Otherwise we silently mark + // a live, unreachable process as killed and recovery proceeds + // against a still-bound port. + let stderr = b"ERROR: The process with PID 1234 could not be terminated.\r\n\ + Reason: Access is denied.\r\n"; + let err = classify_taskkill_force_status(Some(1), stderr, 1234).unwrap_err(); + assert!(err.contains("code Some(1)"), "got: {err}"); + assert!(err.contains("Access is denied"), "got: {err}"); + } + + #[test] + fn classify_taskkill_force_propagates_bare_access_denied() { + let stderr = b"ERROR: Access is denied.\r\n"; + let err = classify_taskkill_force_status(Some(5), stderr, 1234).unwrap_err(); + assert!(err.contains("code Some(5)"), "got: {err}"); + assert!(err.contains("Access is denied"), "got: {err}"); + } + + #[test] + fn kill_pid_term_refuses_protected_pids() { + assert!(kill_pid_term(0).is_err()); + assert!(kill_pid_term(4).is_err()); + } + + #[test] + fn kill_pid_force_refuses_protected_pids() { + assert!(kill_pid_force(0).is_err()); + assert!(kill_pid_force(4).is_err()); + } + + /// End-to-end-on-Windows: spawn a real child process, force-kill it, and + /// verify it exits. Also covers the "process already gone" case by + /// killing the same PID twice — the second call must succeed (this is + /// the bug the patch above fixes). + #[test] + fn kill_pid_force_terminates_real_process_and_is_idempotent() { + // `timeout` is a builtin shipped with every Windows install; sleeps + // for ~30s which is plenty for the kill round-trip. + let mut child = std::process::Command::new("cmd") + .args(["/C", "timeout", "/T", "30", "/NOBREAK"]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .stdin(std::process::Stdio::null()) + .spawn() + .expect("spawn child process"); + let pid = child.id(); + + kill_pid_force(pid).expect("force-kill running process"); + + // Reap so we don't leave a zombie regardless of test outcome. + let _ = child.wait(); + + // Second call: same pid is now gone. Must be Ok — this is the + // regression we're guarding against. + kill_pid_force(pid).expect("force-kill of already-gone pid is success"); } - Ok(()) } diff --git a/app/src-tauri/src/process_recovery.rs b/app/src-tauri/src/process_recovery.rs index 494884c62f..6e4fa0013e 100644 --- a/app/src-tauri/src/process_recovery.rs +++ b/app/src-tauri/src/process_recovery.rs @@ -1,5 +1,13 @@ //! Startup recovery for OpenHuman processes left behind by hard exits. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub(crate) struct ProcessInfo { + pub pid: u32, + pub ppid: u32, + pub argv0: String, + pub command: String, +} + #[cfg(target_os = "macos")] mod imp { use std::collections::{HashMap, HashSet}; @@ -7,21 +15,13 @@ mod imp { use std::path::{Path, PathBuf}; use std::time::Duration; - use serde::Serialize; - use crate::cef_preflight; use crate::core_process; use crate::process_kill::{kill_pid_force, kill_pid_term}; - const TERM_GRACE: Duration = Duration::from_millis(500); + pub(crate) use super::ProcessInfo; - #[derive(Debug, Clone, PartialEq, Eq, Serialize)] - pub(crate) struct ProcessInfo { - pub pid: u32, - pub ppid: u32, - pub argv0: String, - pub command: String, - } + const TERM_GRACE: Duration = Duration::from_millis(500); #[derive(Debug, Default, PartialEq, Eq)] struct ReapSummary { @@ -416,24 +416,479 @@ mod imp { } } -#[cfg(target_os = "macos")] -pub(crate) use imp::{enumerate_openhuman_processes, reap_stale_openhuman_processes, ProcessInfo}; +/// Linux implementation: use /proc//cmdline to enumerate openhuman-core processes. +#[cfg(target_os = "linux")] +mod linux_imp { + use crate::core_process; + use crate::process_kill::{kill_pid_force, kill_pid_term}; + use std::time::Duration; -#[cfg(not(target_os = "macos"))] -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] -pub(crate) struct ProcessInfo { - pub pid: u32, - pub ppid: u32, - pub argv0: String, - pub command: String, -} + pub(crate) use super::ProcessInfo; + + const TERM_GRACE: Duration = Duration::from_millis(500); + + pub(crate) fn reap_stale_openhuman_processes() { + if core_process::reuse_existing_listener_enabled() { + log::info!( + "[startup-recovery] OPENHUMAN_CORE_REUSE_EXISTING=1; skipping stale process reap" + ); + return; + } + + let self_pid = std::process::id(); + log::debug!("[startup-recovery] linux: scanning /proc for stale OpenHuman processes (self_pid={self_pid})"); + + let stale = match enumerate_openhuman_processes() { + Ok(procs) => procs, + Err(err) => { + log::warn!("[startup-recovery] linux: failed to enumerate processes: {err}"); + return; + } + }; + + if stale.is_empty() { + log::info!("[startup-recovery] linux: no stale OpenHuman processes found"); + return; + } + + log::info!( + "[startup-recovery] linux: found {} stale OpenHuman process(es), sending SIGTERM", + stale.len() + ); + for proc in &stale { + match kill_pid_term(proc.pid) { + Ok(()) => log::warn!( + "[startup-recovery] linux: SIGTERM stale OpenHuman pid={} cmd={}", + proc.pid, + proc.argv0 + ), + Err(err) => log::warn!( + "[startup-recovery] linux: failed to SIGTERM pid={}: {err}", + proc.pid + ), + } + } + + std::thread::sleep(TERM_GRACE); -#[cfg(not(target_os = "macos"))] -pub(crate) fn reap_stale_openhuman_processes() { - log::debug!("[startup-recovery] skipped on non-macos platform"); + let after_term = match enumerate_openhuman_processes() { + Ok(procs) => procs, + Err(err) => { + log::warn!("[startup-recovery] linux: failed to re-enumerate after SIGTERM: {err}"); + return; + } + }; + + let stale_pids: std::collections::HashSet = stale.iter().map(|p| p.pid).collect(); + let mut kill_count = 0usize; + for proc in &after_term { + if stale_pids.contains(&proc.pid) { + match kill_pid_force(proc.pid) { + Ok(()) => { + kill_count += 1; + log::warn!( + "[startup-recovery] linux: SIGKILL stale OpenHuman pid={} cmd={}", + proc.pid, + proc.argv0 + ); + } + Err(err) => log::warn!( + "[startup-recovery] linux: failed to SIGKILL pid={}: {err}", + proc.pid + ), + } + } + } + + log::info!( + "[startup-recovery] linux: reap complete term={} kill={} total={}", + stale.len(), + kill_count, + stale.len() + ); + } + + pub(crate) fn enumerate_openhuman_processes() -> Result, String> { + let self_pid = std::process::id(); + let mut results = Vec::new(); + + let proc_dir = std::fs::read_dir("/proc").map_err(|e| format!("read_dir /proc: {e}"))?; + + for entry in proc_dir.flatten() { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + let pid: u32 = match name_str.parse() { + Ok(p) => p, + Err(_) => continue, + }; + if pid == self_pid { + continue; + } + + let cmdline_path = format!("/proc/{pid}/cmdline"); + let cmdline_bytes = match std::fs::read(&cmdline_path) { + Ok(b) => b, + Err(_) => continue, + }; + + // /proc//cmdline uses NUL bytes as argument separators. + let cmdline = cmdline_bytes + .split(|&b| b == 0) + .filter(|seg| !seg.is_empty()) + .map(|seg| String::from_utf8_lossy(seg).into_owned()) + .collect::>(); + + let argv0 = match cmdline.first() { + Some(a) => a.clone(), + None => continue, + }; + + if !is_openhuman_executable(&argv0) { + continue; + } + + let ppid = read_ppid(pid).unwrap_or(0); + let command = cmdline.join(" "); + + log::debug!( + "[startup-recovery] linux: found OpenHuman process pid={pid} argv0={argv0}" + ); + results.push(ProcessInfo { + pid, + ppid, + argv0, + command, + }); + } + + Ok(results) + } + + fn read_ppid(pid: u32) -> Option { + let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; + // /proc//stat: "pid (comm) state ppid ..." + // The comm field can contain spaces and parens, find the closing ')' first. + let after_comm = stat.rfind(')')?; + let rest = stat[after_comm + 1..].trim_start(); + // rest: "state ppid ..." + let mut parts = rest.split_whitespace(); + let _state = parts.next()?; + parts.next()?.parse().ok() + } + + fn is_openhuman_executable(argv0: &str) -> bool { + let filename = std::path::Path::new(argv0) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(argv0); + let lower = filename.to_ascii_lowercase(); + lower == "openhuman-core" || lower == "openhuman" + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn is_openhuman_executable_matches_core_binary() { + assert!(is_openhuman_executable("/usr/local/bin/openhuman-core")); + assert!(is_openhuman_executable("openhuman-core")); + assert!(is_openhuman_executable("/opt/OpenHuman/openhuman-core")); + } + + #[test] + fn is_openhuman_executable_matches_app_binary() { + assert!(is_openhuman_executable("/opt/OpenHuman/OpenHuman")); + assert!(is_openhuman_executable("openhuman")); + } + + #[test] + fn is_openhuman_executable_rejects_unrelated() { + assert!(!is_openhuman_executable("bash")); + assert!(!is_openhuman_executable("/usr/bin/python3")); + assert!(!is_openhuman_executable("node")); + } + + #[test] + fn enumerate_openhuman_processes_returns_no_self() { + // Enumerate and confirm self is not in the result. + let self_pid = std::process::id(); + let result = enumerate_openhuman_processes().expect("enumerate"); + assert!( + result.iter().all(|p| p.pid != self_pid), + "self pid {self_pid} must not appear in enumerated list" + ); + } + } } -#[cfg(not(target_os = "macos"))] -pub(crate) fn enumerate_openhuman_processes() -> Result, String> { - Ok(Vec::new()) +/// Windows implementation: use sysinfo to enumerate openhuman processes. +#[cfg(target_os = "windows")] +mod windows_imp { + use crate::core_process; + use crate::process_kill::{kill_pid_force, kill_pid_term}; + use std::time::Duration; + + pub(crate) use super::ProcessInfo; + + const TERM_GRACE: Duration = Duration::from_millis(500); + + pub(crate) fn reap_stale_openhuman_processes() { + if core_process::reuse_existing_listener_enabled() { + log::info!( + "[startup-recovery] OPENHUMAN_CORE_REUSE_EXISTING=1; skipping stale process reap" + ); + return; + } + + let self_pid = std::process::id(); + log::debug!( + "[startup-recovery] windows: scanning processes for stale OpenHuman (self_pid={self_pid})" + ); + + let stale = match enumerate_openhuman_processes() { + Ok(procs) => procs, + Err(err) => { + log::warn!("[startup-recovery] windows: failed to enumerate processes: {err}"); + return; + } + }; + + if stale.is_empty() { + log::info!("[startup-recovery] windows: no stale OpenHuman processes found"); + return; + } + + log::info!( + "[startup-recovery] windows: found {} stale OpenHuman process(es), sending terminate", + stale.len() + ); + for proc in &stale { + match kill_pid_term(proc.pid) { + Ok(()) => log::warn!( + "[startup-recovery] windows: TERM stale OpenHuman pid={} exe={}", + proc.pid, + proc.argv0 + ), + Err(err) => log::warn!( + "[startup-recovery] windows: failed to terminate pid={}: {err}", + proc.pid + ), + } + } + + std::thread::sleep(TERM_GRACE); + + let after_term = match enumerate_openhuman_processes() { + Ok(procs) => procs, + Err(err) => { + log::warn!( + "[startup-recovery] windows: failed to re-enumerate after terminate: {err}" + ); + return; + } + }; + + let stale_pids: std::collections::HashSet = stale.iter().map(|p| p.pid).collect(); + let mut kill_count = 0usize; + for proc in &after_term { + if stale_pids.contains(&proc.pid) { + match kill_pid_force(proc.pid) { + Ok(()) => { + kill_count += 1; + log::warn!( + "[startup-recovery] windows: force-killed stale OpenHuman pid={} exe={}", + proc.pid, + proc.argv0 + ); + } + Err(err) => log::warn!( + "[startup-recovery] windows: failed to force-kill pid={}: {err}", + proc.pid + ), + } + } + } + + log::info!( + "[startup-recovery] windows: reap complete term={} kill={} total={}", + stale.len(), + kill_count, + stale.len() + ); + } + + pub(crate) fn enumerate_openhuman_processes() -> Result, String> { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + + let self_pid = std::process::id(); + + // Use WMIC to enumerate processes with their parent PIDs and executable paths. + // Output format: Caption,ProcessId,ParentProcessId,ExecutablePath + let output = std::process::Command::new("wmic") + .args([ + "process", + "get", + "Caption,ProcessId,ParentProcessId,ExecutablePath", + "/format:csv", + ]) + .creation_flags(CREATE_NO_WINDOW) + .output() + .map_err(|e| format!("spawn wmic: {e}"))?; + + if !output.status.success() { + return Err(format!("wmic exited with {}", output.status)); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + Ok(parse_wmic_output(&stdout, self_pid)) + } + + fn parse_wmic_output(stdout: &str, self_pid: u32) -> Vec { + let mut results = Vec::new(); + let mut lines = stdout.lines(); + + // Skip header lines until we find the CSV header row. + let header = loop { + match lines.next() { + Some(line) if line.trim().starts_with("Node,") => break line, + Some(_) => continue, + None => return results, + } + }; + + // Find column indices from the header. + let cols: Vec<&str> = header.split(',').collect(); + let idx_caption = cols.iter().position(|c| c.trim() == "Caption"); + let idx_pid = cols.iter().position(|c| c.trim() == "ProcessId"); + let idx_ppid = cols.iter().position(|c| c.trim() == "ParentProcessId"); + let idx_exe = cols.iter().position(|c| c.trim() == "ExecutablePath"); + + let (Some(idx_caption), Some(idx_pid), Some(idx_ppid), Some(idx_exe)) = + (idx_caption, idx_pid, idx_ppid, idx_exe) + else { + log::warn!("[startup-recovery] windows: wmic CSV header missing expected columns"); + return results; + }; + + for line in lines { + let line = line.trim(); + if line.is_empty() { + continue; + } + let fields: Vec<&str> = line.splitn(cols.len(), ',').collect(); + if fields.len() < cols.len() { + continue; + } + + let caption = fields[idx_caption].trim(); + let exe_path = fields[idx_exe].trim(); + let pid: u32 = match fields[idx_pid].trim().parse() { + Ok(p) => p, + Err(_) => continue, + }; + let ppid: u32 = fields[idx_ppid].trim().parse().unwrap_or(0); + + if pid == self_pid { + continue; + } + + let argv0 = if !exe_path.is_empty() { + exe_path.to_string() + } else { + caption.to_string() + }; + + if !is_openhuman_executable(caption, exe_path) { + continue; + } + + log::debug!( + "[startup-recovery] windows: found OpenHuman process pid={pid} argv0={argv0}" + ); + results.push(ProcessInfo { + pid, + ppid, + argv0: argv0.clone(), + command: argv0, + }); + } + + results + } + + fn is_openhuman_executable(caption: &str, exe_path: &str) -> bool { + let caption_lower = caption.to_ascii_lowercase(); + let exe_filename = std::path::Path::new(exe_path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(exe_path) + .to_ascii_lowercase(); + caption_lower == "openhuman-core.exe" + || caption_lower == "openhuman.exe" + || exe_filename == "openhuman-core.exe" + || exe_filename == "openhuman.exe" + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn parse_wmic_output_finds_openhuman_processes() { + let csv = "\ +Node,Caption,ExecutablePath,ParentProcessId,ProcessId\r\n\ +\r\n\ +DESKTOP-ABC,openhuman-core.exe,C:\\Program Files\\OpenHuman\\openhuman-core.exe,1234,5678\r\n\ +DESKTOP-ABC,chrome.exe,C:\\Program Files\\Google\\Chrome\\chrome.exe,1,9000\r\n\ +"; + let results = parse_wmic_output(csv, 9999); + assert_eq!(results.len(), 1); + assert_eq!(results[0].pid, 5678); + assert_eq!(results[0].ppid, 1234); + assert!(results[0].argv0.contains("openhuman-core")); + } + + #[test] + fn parse_wmic_output_excludes_self_pid() { + let csv = "\ +Node,Caption,ExecutablePath,ParentProcessId,ProcessId\r\n\ +\r\n\ +DESKTOP-ABC,openhuman-core.exe,C:\\Program Files\\OpenHuman\\openhuman-core.exe,1,1234\r\n\ +"; + let results = parse_wmic_output(csv, 1234); + assert!(results.is_empty(), "self pid should be excluded"); + } + + #[test] + fn is_openhuman_executable_matches_core() { + assert!(is_openhuman_executable( + "openhuman-core.exe", + "C:\\path\\openhuman-core.exe" + )); + assert!(is_openhuman_executable( + "OpenHuman.exe", + "C:\\path\\OpenHuman.exe" + )); + } + + #[test] + fn is_openhuman_executable_rejects_unrelated() { + assert!(!is_openhuman_executable( + "chrome.exe", + "C:\\Chrome\\chrome.exe" + )); + assert!(!is_openhuman_executable("python.exe", "C:\\python.exe")); + } + } } + +#[cfg(target_os = "macos")] +pub(crate) use imp::{enumerate_openhuman_processes, reap_stale_openhuman_processes}; + +#[cfg(target_os = "linux")] +pub(crate) use linux_imp::{enumerate_openhuman_processes, reap_stale_openhuman_processes}; + +#[cfg(target_os = "windows")] +pub(crate) use windows_imp::{enumerate_openhuman_processes, reap_stale_openhuman_processes}; diff --git a/app/src-tauri/src/reset_reboot_schedule.rs b/app/src-tauri/src/reset_reboot_schedule.rs new file mode 100644 index 0000000000..5f2110aa21 --- /dev/null +++ b/app/src-tauri/src/reset_reboot_schedule.rs @@ -0,0 +1,358 @@ +//! Windows-only fallback for `reset_local_data` (issue #1615). +//! +//! When the in-process `remove_dir_all` step fails because a third-party +//! process (anti-virus, file-indexer, sibling OpenHuman window) still holds +//! an open handle inside the `.openhuman` tree, Windows returns +//! `ERROR_SHARING_VIOLATION` (os error 32) / `ERROR_LOCK_VIOLATION` (33) +//! and the user is stuck — see PR #2395 / #1811, which surface a "close all +//! OpenHuman windows" prompt but cannot break a foreign lock. +//! +//! This module walks the still-present sub-tree depth-first and asks the +//! Windows Session Manager to delete each entry at next boot via +//! `MoveFileExW(src, NULL, MOVEFILE_DELAY_UNTIL_REBOOT)`. The session +//! manager requires that directories be empty when boot-time deletion +//! runs, so children are scheduled before their parent. +//! +//! Reference: +//! https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-movefileexw +//! +//! Privileges: `MoveFileExW(.., NULL, MOVEFILE_DELAY_UNTIL_REBOOT)` writes +//! to `HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\PendingFileRenameOperations` +//! (the boot-time session manager reads from HKLM, not the per-user hive), +//! so the call **may fail for non-administrator users** with `ERROR_ACCESS_DENIED`. +//! That is by design — Microsoft documents the elevation requirement on the +//! `MOVEFILE_DELAY_UNTIL_REBOOT` flag — and the caller in `lib.rs` handles +//! the failure path gracefully: it preserves the original lock error plus +//! the schedule failure reason and falls back to the "close all OpenHuman +//! windows and try again" guidance from PR #2395 / #1811. + +#![cfg(target_os = "windows")] + +use std::io; +use std::os::windows::ffi::OsStrExt; +use std::path::Path; + +use windows_sys::Win32::Storage::FileSystem::{MoveFileExW, MOVEFILE_DELAY_UNTIL_REBOOT}; + +/// Tally of entries handed off to `MoveFileExW`, returned to the caller so +/// it can log and surface (e.g. "scheduled 142 files / 14 dirs for deletion +/// on next reboot") instead of just an opaque "ok". +/// +/// `partial` is `true` when the walk aborted mid-tree (e.g. a directory +/// became unreadable, or an individual `MoveFileExW` call failed). In that +/// case `files` / `dirs` represent **only** what was queued before the +/// failure point — useful for support logs to distinguish "everything is +/// queued" from "some of the tree is queued but the rest still needs +/// manual cleanup." Pair with the `Result::Err` returned by +/// [`schedule_path_for_reboot_deletion`] for the cause. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct RebootDeletionSchedule { + pub files: u32, + pub dirs: u32, + pub partial: bool, +} + +impl RebootDeletionSchedule { + pub fn total(&self) -> u32 { + self.files.saturating_add(self.dirs) + } +} + +/// Schedule `path` (and everything under it if it is a directory) for +/// deletion on the next reboot via `MoveFileExW(_, NULL, MOVEFILE_DELAY_UNTIL_REBOOT)`. +/// +/// Strategy: +/// * Regular files / symlinks → scheduled directly. +/// * Directories → children scheduled first (depth-first), then the +/// directory itself once its contents are queued. +/// +/// `path` not existing on disk yields `Err(RebootDeletionFailure { error: NotFound, .. })` — +/// callers can choose to treat that as a no-op since "nothing to remove" is +/// the same outcome. +/// +/// On error the failure carries a partially-populated `RebootDeletionSchedule` +/// (`partial = true`) so the caller can surface "we queued N files and M +/// folders before scheduling failed" instead of just the bare io error. +/// The walk is depth-first, so the counts reflect entries queued *before* +/// the failing step. +pub fn schedule_path_for_reboot_deletion( + path: &Path, +) -> Result { + schedule_path_with_scheduler(path, &mut schedule_one) +} + +/// Internal seam used by both [`schedule_path_for_reboot_deletion`] (which +/// passes the real `MoveFileExW` step as `scheduler`) and the unit tests +/// (which pass an injectable `Ok(())` stub so the traversal/counting logic +/// can be exercised on every dev machine without needing administrator +/// rights or actually queuing reboot-time deletions). +fn schedule_path_with_scheduler( + path: &Path, + scheduler: &mut F, +) -> Result +where + F: FnMut(&Path) -> io::Result<()>, +{ + let metadata = std::fs::symlink_metadata(path).map_err(|error| RebootDeletionFailure { + error, + partial: RebootDeletionSchedule { + partial: true, + ..RebootDeletionSchedule::default() + }, + })?; + let mut summary = RebootDeletionSchedule::default(); + match schedule_inner(path, &metadata, &mut summary, scheduler) { + Ok(()) => Ok(summary), + Err(error) => { + summary.partial = true; + Err(RebootDeletionFailure { + error, + partial: summary, + }) + } + } +} + +/// Pair of `(io::Error, partial schedule)` returned when the depth-first +/// walk aborts mid-tree. The `partial` field records what was queued via +/// `MoveFileExW` *before* the failure point so the caller can include the +/// counts in user-facing copy and support logs ("123 files / 7 folders +/// were queued for the next reboot before scheduling failed: "). +#[derive(Debug)] +pub struct RebootDeletionFailure { + pub error: io::Error, + pub partial: RebootDeletionSchedule, +} + +impl std::fmt::Display for RebootDeletionFailure { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.error) + } +} + +impl std::error::Error for RebootDeletionFailure { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&self.error) + } +} + +fn schedule_inner( + path: &Path, + metadata: &std::fs::Metadata, + summary: &mut RebootDeletionSchedule, + scheduler: &mut F, +) -> io::Result<()> +where + F: FnMut(&Path) -> io::Result<()>, +{ + // Symlinked directories must NOT be descended into — the lock lives + // on the link target, not the link itself, and following would queue + // unrelated paths for deletion. Treat symlinks (file or dir) as a + // single leaf entry. + if metadata.is_dir() && !metadata.file_type().is_symlink() { + for entry in std::fs::read_dir(path)? { + let entry = entry?; + let child_meta = entry.metadata()?; + schedule_inner(&entry.path(), &child_meta, summary, scheduler)?; + } + scheduler(path)?; + summary.dirs = summary.dirs.saturating_add(1); + } else { + scheduler(path)?; + summary.files = summary.files.saturating_add(1); + } + Ok(()) +} + +fn schedule_one(path: &Path) -> io::Result<()> { + // `MoveFileExW + MOVEFILE_DELAY_UNTIL_REBOOT` requires absolute paths — + // the session manager runs at boot before any working directory is + // established, so a relative path cannot be resolved. The call sites + // in `reset_local_data` already resolve paths via the core's + // `config_get_data_paths` RPC (which returns absolute paths) so this + // is currently a no-op in release builds; the assert catches a future + // regression that wires a different caller in without thinking. + debug_assert!( + path.is_absolute(), + "MoveFileExW + DELAY_UNTIL_REBOOT requires an absolute path, got {}", + path.display() + ); + let wide: Vec = path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + // SAFETY: `wide` is a NUL-terminated UTF-16 buffer that outlives the + // call. The destination pointer is `NULL`, which (combined with + // `MOVEFILE_DELAY_UNTIL_REBOOT`) tells Windows to delete (rather than + // rename) the source at the next boot. `MoveFileExW` returns BOOL — + // non-zero on success. + let ok = unsafe { MoveFileExW(wide.as_ptr(), std::ptr::null(), MOVEFILE_DELAY_UNTIL_REBOOT) }; + if ok == 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Test-only no-op scheduler. Lets the traversal/counting tests run + /// in any user context (incl. non-administrator) by sidestepping the + /// `MoveFileExW + MOVEFILE_DELAY_UNTIL_REBOOT` call that would + /// otherwise need HKLM write access. We capture the call order so a + /// regression in depth-first ordering would surface as a wrong path + /// sequence here, even though the real OS-side scheduling stays + /// out of the test process. + fn noop_scheduler( + captured: &mut Vec, + ) -> impl FnMut(&Path) -> io::Result<()> + '_ { + move |path: &Path| { + captured.push(path.to_path_buf()); + Ok(()) + } + } + + #[test] + fn schedule_walks_files_then_dirs() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path().join("reset-target"); + std::fs::create_dir_all(root.join("nested")).expect("mkdir nested"); + std::fs::write(root.join("a.txt"), b"a").expect("write a.txt"); + std::fs::write(root.join("nested").join("b.txt"), b"b").expect("write b.txt"); + + let mut captured = Vec::new(); + let summary = + schedule_path_with_scheduler(&root, &mut noop_scheduler(&mut captured)).expect("walk"); + + // root + nested == 2 dirs; a.txt + nested/b.txt == 2 files + assert_eq!(summary.files, 2, "expected 2 files queued, got {summary:?}"); + assert_eq!(summary.dirs, 2, "expected 2 dirs queued, got {summary:?}"); + assert_eq!(summary.total(), 4); + assert!(!summary.partial, "Ok must not flag partial"); + + // Depth-first: a parent must only appear after all of its children. + // Track per-path positions in the call order, then assert each + // directory sits after every entry whose path is rooted inside it. + let position = |needle: &Path| -> usize { + captured + .iter() + .position(|p| p == needle) + .unwrap_or_else(|| panic!("missing {} in {captured:?}", needle.display())) + }; + let root_pos = position(&root); + let nested_pos = position(&root.join("nested")); + let a_pos = position(&root.join("a.txt")); + let b_pos = position(&root.join("nested").join("b.txt")); + assert!( + b_pos < nested_pos, + "b.txt must be scheduled before its parent (nested)" + ); + assert!( + nested_pos < root_pos && a_pos < root_pos, + "nested + a.txt must be scheduled before root" + ); + } + + #[test] + fn schedule_single_file_reports_one_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let file = dir.path().join("solo.txt"); + std::fs::write(&file, b"x").expect("write solo.txt"); + + let mut captured = Vec::new(); + let summary = + schedule_path_with_scheduler(&file, &mut noop_scheduler(&mut captured)).expect("walk"); + + assert_eq!( + summary, + RebootDeletionSchedule { + files: 1, + dirs: 0, + partial: false, + } + ); + assert_eq!(captured, vec![file]); + } + + #[test] + fn schedule_missing_path_yields_not_found() { + let dir = tempfile::tempdir().expect("tempdir"); + let missing = dir.path().join("does-not-exist"); + + let mut captured = Vec::new(); + let failure = schedule_path_with_scheduler(&missing, &mut noop_scheduler(&mut captured)) + .expect_err("missing"); + assert_eq!(failure.error.kind(), io::ErrorKind::NotFound); + // Nothing scheduled, but partial flag still reports "did not + // complete" so callers can distinguish from a clean success. + assert!(failure.partial.partial); + assert_eq!(failure.partial.files, 0); + assert_eq!(failure.partial.dirs, 0); + assert!( + captured.is_empty(), + "no scheduling should have been attempted: {captured:?}" + ); + } + + #[test] + fn schedule_empty_dir_counts_one_dir() { + let dir = tempfile::tempdir().expect("tempdir"); + let empty = dir.path().join("empty-target"); + std::fs::create_dir(&empty).expect("mkdir empty-target"); + + let mut captured = Vec::new(); + let summary = + schedule_path_with_scheduler(&empty, &mut noop_scheduler(&mut captured)).expect("walk"); + + assert_eq!( + summary, + RebootDeletionSchedule { + files: 0, + dirs: 1, + partial: false, + } + ); + assert_eq!(captured, vec![empty]); + } + + #[test] + fn schedule_propagates_scheduler_failure_with_partial_counts() { + // Simulate the non-administrator MoveFileExW failure path: the + // walk visits children successfully, then the third call (the + // parent dir) errors out. The Err must carry the leaf counts + // queued before the failure so the caller can surface "we did + // get X files scheduled before this hit the registry wall." + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path().join("partial-fail"); + std::fs::create_dir_all(&root).expect("mkdir"); + std::fs::write(root.join("a.txt"), b"a").expect("write a.txt"); + std::fs::write(root.join("b.txt"), b"b").expect("write b.txt"); + + let root_path = root.clone(); + let mut count = 0usize; + let mut scheduler = |path: &Path| -> io::Result<()> { + count += 1; + if path == root_path { + Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "simulated non-admin MoveFileExW failure", + )) + } else { + Ok(()) + } + }; + + let failure = + schedule_path_with_scheduler(&root, &mut scheduler).expect_err("scheduler failed"); + assert_eq!(failure.error.kind(), io::ErrorKind::PermissionDenied); + assert!(failure.partial.partial); + // Both leaf files were scheduled before the parent-dir call failed. + assert_eq!(failure.partial.files, 2, "got {:?}", failure.partial); + assert_eq!(failure.partial.dirs, 0, "got {:?}", failure.partial); + // Sanity: scheduler was called for both leaves + the parent. + assert_eq!(count, 3); + } +} diff --git a/app/src-tauri/src/workspace_paths.rs b/app/src-tauri/src/workspace_paths.rs new file mode 100644 index 0000000000..7cbe7b27ff --- /dev/null +++ b/app/src-tauri/src/workspace_paths.rs @@ -0,0 +1,418 @@ +use serde::Serialize; +use std::{ + fs, + io::Read, + path::{Path, PathBuf}, +}; + +const DEFAULT_PREVIEW_MAX_BYTES: usize = 256 * 1024; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct WorkspaceTextPreview { + pub path: String, + pub absolute_path: String, + pub contents: String, + pub truncated: bool, + pub size_bytes: u64, +} + +#[tauri::command] +pub async fn open_workspace_path(path: String) -> Result<(), String> { + let workspace = active_workspace_root().await?; + let target = resolve_workspace_path(&workspace, &path)?; + let workspace_path = workspace_path_label(&workspace, &target); + tauri_plugin_opener::open_path(&target, None::<&str>).map_err(|err| { + workspace_path_error_with_debug( + format!("failed to open workspace path {workspace_path}: {err}"), + format!("failed to open workspace path {}: {err}", target.display()), + ) + }) +} + +#[tauri::command] +pub async fn reveal_workspace_path(path: String) -> Result<(), String> { + let workspace = active_workspace_root().await?; + let target = resolve_workspace_path(&workspace, &path)?; + let workspace_path = workspace_path_label(&workspace, &target); + tauri_plugin_opener::reveal_item_in_dir(&target).map_err(|err| { + workspace_path_error_with_debug( + format!("failed to reveal workspace path {workspace_path}: {err}"), + format!( + "failed to reveal workspace path {}: {err}", + target.display() + ), + ) + }) +} + +#[tauri::command] +pub async fn preview_workspace_text(path: String) -> Result { + let workspace = active_workspace_root().await?; + preview_workspace_text_from_root(&workspace, &path, DEFAULT_PREVIEW_MAX_BYTES) +} + +async fn active_workspace_root() -> Result { + let config = openhuman_core::openhuman::config::Config::load_or_init() + .await + .map_err(|err| workspace_path_error(format!("failed to load OpenHuman config: {err}")))?; + fs::create_dir_all(&config.workspace_dir).map_err(|err| { + workspace_path_error_with_debug( + format!("failed to create workspace directory: {err}"), + format!( + "failed to create workspace directory {}: {err}", + config.workspace_dir.display() + ), + ) + })?; + Ok(config.workspace_dir) +} + +fn workspace_path_error(message: impl Into) -> String { + let message = message.into(); + log::warn!("[workspace-paths] {message}"); + message +} + +fn workspace_path_error_with_debug( + message: impl Into, + debug_message: impl Into, +) -> String { + let message = message.into(); + log::warn!("[workspace-paths] {message}"); + log::debug!("[workspace-paths] {}", debug_message.into()); + message +} + +fn workspace_path_label(workspace_root: &Path, target: &Path) -> String { + let relative = fs::canonicalize(workspace_root) + .ok() + .and_then(|root| target.strip_prefix(root).ok().map(Path::to_path_buf)); + + relative + .as_deref() + .map(path_label) + .or_else(|| { + target + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + }) + .filter(|label| !label.is_empty()) + .unwrap_or_else(|| "".to_string()) +} + +fn path_label(path: &Path) -> String { + let label = path + .components() + .filter_map(|component| match component { + std::path::Component::Normal(value) => Some(value.to_string_lossy()), + _ => None, + }) + .collect::>() + .join("/"); + + if label.is_empty() { + ".".to_string() + } else { + label + } +} + +fn normalize_workspace_relative_path(path: &str) -> Result<(PathBuf, String), String> { + let trimmed = path.trim(); + if trimmed.is_empty() { + return Err(workspace_path_error("workspace path must not be empty")); + } + if trimmed.bytes().any(|byte| byte == 0) { + return Err(workspace_path_error( + "workspace path must not contain NUL bytes", + )); + } + + let normalized = trimmed.replace('\\', "/"); + if normalized.starts_with('/') + || has_windows_drive_prefix(&normalized) + || has_uri_scheme_prefix(&normalized) + { + return Err(workspace_path_error("workspace path must be relative")); + } + + let mut relative = PathBuf::new(); + let mut clean_parts = Vec::new(); + for part in normalized.split('/') { + if part.is_empty() || part == "." { + continue; + } + if part == ".." { + return Err(workspace_path_error( + "workspace path must stay inside the workspace", + )); + } + relative.push(part); + clean_parts.push(part); + } + + if clean_parts.is_empty() { + return Err(workspace_path_error( + "workspace path must point to a file or directory", + )); + } + + Ok((relative, clean_parts.join("/"))) +} + +fn has_windows_drive_prefix(path: &str) -> bool { + let bytes = path.as_bytes(); + bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'/' +} + +fn has_uri_scheme_prefix(path: &str) -> bool { + let Some((scheme, _)) = path.split_once(':') else { + return false; + }; + let mut bytes = scheme.bytes(); + let Some(first) = bytes.next() else { + return false; + }; + first.is_ascii_alphabetic() + && bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'-' | b'.')) +} + +pub(crate) fn resolve_workspace_path( + workspace_root: &Path, + requested_path: &str, +) -> Result { + let (relative, normalized_path) = normalize_workspace_relative_path(requested_path)?; + let root = fs::canonicalize(workspace_root).map_err(|err| { + workspace_path_error_with_debug( + format!("failed to canonicalize workspace directory: {err}"), + format!( + "failed to canonicalize workspace directory {}: {err}", + workspace_root.display() + ), + ) + })?; + let target = root.join(relative); + let target = fs::canonicalize(&target).map_err(|err| { + workspace_path_error(format!( + "workspace path does not exist {normalized_path}: {err}" + )) + })?; + + if !target.starts_with(&root) { + return Err(workspace_path_error_with_debug( + format!("workspace path must stay inside the workspace: {normalized_path}"), + format!( + "workspace path must stay inside the workspace: {} -> {}", + normalized_path, + target.display() + ), + )); + } + + log::debug!( + "[workspace-paths] resolved workspace path: {} -> {}", + normalized_path, + target.display() + ); + Ok(target) +} + +pub(crate) fn preview_workspace_text_from_root( + workspace_root: &Path, + requested_path: &str, + max_bytes: usize, +) -> Result { + let (_, normalized_path) = normalize_workspace_relative_path(requested_path)?; + let target = resolve_workspace_path(workspace_root, &normalized_path)?; + let metadata = fs::metadata(&target).map_err(|err| { + workspace_path_error_with_debug( + format!("failed to read metadata for {normalized_path}: {err}"), + format!("failed to read metadata for {}: {err}", target.display()), + ) + })?; + if !metadata.is_file() { + return Err(workspace_path_error(format!( + "workspace preview target must be a file: {normalized_path}" + ))); + } + + let mut file = fs::File::open(&target).map_err(|err| { + workspace_path_error_with_debug( + format!("failed to open workspace file {normalized_path}: {err}"), + format!("failed to open workspace file {}: {err}", target.display()), + ) + })?; + let mut bytes = Vec::new(); + file.by_ref() + .take(max_bytes.saturating_add(4) as u64) + .read_to_end(&mut bytes) + .map_err(|err| { + workspace_path_error_with_debug( + format!("failed to read workspace file {normalized_path}: {err}"), + format!("failed to read workspace file {}: {err}", target.display()), + ) + })?; + + let truncated = metadata.len() > max_bytes as u64; + let preview_len = bytes.len().min(max_bytes); + let contents = utf8_preview(&bytes[..preview_len], truncated).map_err(|err| { + workspace_path_error_with_debug( + format!("{err}: {normalized_path}"), + format!("{err}: {}", target.display()), + ) + })?; + + log::debug!( + "[workspace-paths] previewed workspace text: {} bytes={} truncated={}", + normalized_path, + metadata.len(), + truncated + ); + + Ok(WorkspaceTextPreview { + path: normalized_path, + absolute_path: target.display().to_string(), + contents, + truncated, + size_bytes: metadata.len(), + }) +} + +fn utf8_preview(bytes: &[u8], truncated: bool) -> Result { + match std::str::from_utf8(bytes) { + Ok(text) => Ok(text.to_string()), + Err(err) if truncated && err.error_len().is_none() => { + Ok(String::from_utf8_lossy(&bytes[..err.valid_up_to()]).into_owned()) + } + Err(_) => Err("workspace preview target is not valid UTF-8 text".to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + #[test] + fn resolve_workspace_path_accepts_existing_relative_file_inside_workspace() { + let workspace = tempdir().unwrap(); + let docs = workspace.path().join("docs"); + fs::create_dir_all(&docs).unwrap(); + let file = docs.join("note.md"); + fs::write(&file, "hello").unwrap(); + + let resolved = resolve_workspace_path(workspace.path(), "docs/note.md").unwrap(); + + assert_eq!(resolved, file.canonicalize().unwrap()); + } + + #[test] + fn resolve_workspace_path_rejects_parent_directory_escape() { + let workspace = tempdir().unwrap(); + + let err = resolve_workspace_path(workspace.path(), "../secret.txt").unwrap_err(); + + assert!(err.contains("workspace"), "unexpected error: {err}"); + } + + #[test] + fn resolve_workspace_path_rejects_absolute_paths() { + let workspace = tempdir().unwrap(); + + let err = resolve_workspace_path(workspace.path(), "/etc/passwd").unwrap_err(); + + assert!(err.contains("relative"), "unexpected error: {err}"); + } + + #[test] + fn resolve_workspace_path_rejects_uri_scheme_prefix() { + let workspace = tempdir().unwrap(); + + let err = resolve_workspace_path(workspace.path(), "file://etc/passwd").unwrap_err(); + + assert!(err.contains("relative"), "unexpected error: {err}"); + } + + #[test] + fn resolve_workspace_path_accepts_colons_after_first_segment() { + let workspace = tempdir().unwrap(); + let docs = workspace.path().join("docs"); + fs::create_dir_all(&docs).unwrap(); + let file = docs.join("2026:05.md"); + fs::write(&file, "dated").unwrap(); + + let resolved = resolve_workspace_path(workspace.path(), "docs/2026:05.md").unwrap(); + + assert_eq!(resolved, file.canonicalize().unwrap()); + } + + #[test] + fn resolve_workspace_path_errors_do_not_expose_workspace_root() { + let workspace = tempdir().unwrap(); + + let err = resolve_workspace_path(workspace.path(), "docs/missing.md").unwrap_err(); + + assert!(err.contains("docs/missing.md"), "unexpected error: {err}"); + assert!( + !err.contains(&workspace.path().display().to_string()), + "error leaked workspace root: {err}" + ); + } + + #[test] + fn preview_workspace_text_from_root_reads_utf8_text() { + let workspace = tempdir().unwrap(); + fs::write(workspace.path().join("readme.md"), "# Hello").unwrap(); + + let preview = + preview_workspace_text_from_root(workspace.path(), "readme.md", 1024).unwrap(); + + assert_eq!(preview.path, "readme.md"); + assert_eq!(preview.contents, "# Hello"); + assert!(!preview.truncated); + assert_eq!(preview.size_bytes, 7); + } + + #[test] + fn preview_workspace_text_from_root_truncates_large_text() { + let workspace = tempdir().unwrap(); + fs::write(workspace.path().join("large.md"), "0123456789").unwrap(); + + let preview = preview_workspace_text_from_root(workspace.path(), "large.md", 4).unwrap(); + + assert_eq!(preview.contents, "0123"); + assert!(preview.truncated); + assert_eq!(preview.size_bytes, 10); + } + + #[test] + fn preview_workspace_text_from_root_errors_do_not_expose_workspace_root() { + let workspace = tempdir().unwrap(); + fs::create_dir_all(workspace.path().join("docs")).unwrap(); + + let err = preview_workspace_text_from_root(workspace.path(), "docs", 1024).unwrap_err(); + + assert!(err.contains("docs"), "unexpected error: {err}"); + assert!( + !err.contains(&workspace.path().display().to_string()), + "error leaked workspace root: {err}" + ); + } + + #[cfg(unix)] + #[test] + fn resolve_workspace_path_rejects_symlink_escape() { + use std::os::unix::fs::symlink; + + let workspace = tempdir().unwrap(); + let outside = tempdir().unwrap(); + let outside_file = outside.path().join("secret.txt"); + fs::write(&outside_file, "secret").unwrap(); + symlink(&outside_file, workspace.path().join("secret-link")).unwrap(); + + let err = resolve_workspace_path(workspace.path(), "secret-link").unwrap_err(); + + assert!(err.contains("workspace"), "unexpected error: {err}"); + } +} diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index 27c21940b3..85ac72c5c4 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenHuman", - "version": "0.54.7", + "version": "0.57.1", "identifier": "com.openhuman.app", "build": { "beforeDevCommand": "pnpm run dev", @@ -23,7 +23,7 @@ } ], "security": { - "csp": "default-src 'self' 'unsafe-inline' data: blob: https: wss: ipc: http://ipc.localhost http://127.0.0.1:* http://localhost:*; img-src 'self' data: blob: https:; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:* http://localhost:* http: ws://127.0.0.1:* ws://localhost:* ws: https: wss: data: blob:; frame-src 'self' https: data: blob:" + "csp": "default-src 'self' 'unsafe-inline' data: blob: https: wss: ipc: http://ipc.localhost http://127.0.0.1:* http://localhost:*; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; img-src 'self' data: blob: https:; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:* http://localhost:* http: ws://127.0.0.1:* ws://localhost:* ws: https: wss: data: blob:; frame-src 'self' https: data: blob:" }, "macOSPrivateApi": true }, @@ -54,6 +54,7 @@ "libgtk-3-0", "libwebkit2gtk-4.1-0", "libx11-6", + "libxdo3", "libgdk-pixbuf-2.0-0", "libglib2.0-0" ], diff --git a/app/src/App.tsx b/app/src/App.tsx index d314ef828a..f7d0db50e7 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -14,18 +14,18 @@ import ServiceBlockingGate from './components/daemon/ServiceBlockingGate'; import DictationHotkeyManager from './components/DictationHotkeyManager'; import ErrorFallbackScreen from './components/ErrorFallbackScreen'; import LocalAIDownloadSnackbar from './components/LocalAIDownloadSnackbar'; +import SecretPromptDialog from './components/mcp-setup/SecretPromptDialog'; import OpenhumanLinkModal from './components/OpenhumanLinkModal'; import PersistRehydrationScreen from './components/PersistRehydrationScreen'; import GlobalUpsellBanner from './components/upsell/GlobalUpsellBanner'; import AppWalkthrough from './components/walkthrough/AppWalkthrough'; import { MascotFrameProducer } from './features/meet/MascotFrameProducer'; import { I18nProvider } from './lib/i18n/I18nContext'; -// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough -// import { isWelcomeLocked } from './lib/coreState/store'; import { startNativeNotificationsService, stopNativeNotificationsService, } from './lib/nativeNotifications'; +import { getIsMobile } from './lib/platform'; import { startWebviewNotificationsService, stopWebviewNotificationsService, @@ -45,10 +45,7 @@ import { stopWebviewAccountService, } from './services/webviewAccountService'; import { persistor, store } from './store'; -// [#1123] useAppDispatch commented out — welcome-agent onboarding replaced by Joyride walkthrough import { useAppSelector } from './store/hooks'; -// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough -// import { clearSelectedThread, deleteThread, setWelcomeThreadId } from './store/threadSlice'; import { isAccountsFullscreen } from './utils/accountsFullscreen'; import { DEV_FORCE_ONBOARDING } from './utils/config'; @@ -56,6 +53,8 @@ import { DEV_FORCE_ONBOARDING } from './utils/config'; // events (Google Meet captions → transcript flush, WhatsApp ingest, …) // are handled even when the user hasn't navigated to /accounts yet. // Idempotent — the service uses a `started` singleton guard. +// On iOS these services are no-ops (isTauri() webview guard inside each), +// but we call them unconditionally to keep the boot path consistent. startWebviewAccountService(); startWebviewNotificationsService(); startNativeNotificationsService(); @@ -77,6 +76,17 @@ if (import.meta.hot) { } function App() { + const onMobile = getIsMobile(); + + // On mobile (iOS or Android) the SocketProvider would try to connect to the + // local core HTTP socket, which does not exist on device (the core runs on + // the remote desktop). Gate it out to prevent spurious connection errors — + // chat events arrive through TunnelTransport's socket.io relay instead. + // NOTE: useHumanMascot's subscribeChatEvents() still returns a no-op unsub + // when the socket is absent — mascot state falls back to 'idle'. + const socketWrapped = (children: React.ReactNode) => + onMobile ? <>{children} : {children}; + return ( ( @@ -88,20 +98,21 @@ function App() { - + {socketWrapped( - - - + {!onMobile && } + {!onMobile && } + {!onMobile && } + - + )} @@ -112,8 +123,30 @@ function App() { ); } -/** Inner shell — lives inside the Router so it can use useLocation. */ +/** Minimal mobile shell — renders routes only, no desktop chrome. */ +function AppShellMobile() { + return ( +
+ +
+ ); +} + +/** + * Top-level shell router — chooses mobile or desktop shell at render time. + * Must NOT call hooks before the branch because each sub-component has its + * own hook calls that obey the rules-of-hooks within their own scope. + */ function AppShell() { + const onMobile = getIsMobile(); + if (onMobile) { + return ; + } + return ; +} + +/** Desktop inner shell — lives inside the Router so it can use useLocation. */ +function AppShellDesktop() { const location = useLocation(); const navigate = useNavigate(); const { snapshot, isBootstrapping } = useCoreState(); @@ -122,8 +155,6 @@ function AppShell() { // bottom padding. Any other selected "app" (e.g. WhatsApp) takes the // full viewport so the embedded webview goes edge-to-edge. const fullscreen = isAccountsFullscreen(location.pathname, activeAccountId); - // [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough - // const welcomeLocked = isWelcomeLocked(snapshot); const onOnboardingRoute = location.pathname.startsWith('/onboarding'); const onboardingPending = !!snapshot.sessionToken && (DEV_FORCE_ONBOARDING || !snapshot.onboardingCompleted); @@ -158,63 +189,11 @@ function AppShell() { trackPageView(location.pathname); }, [location.pathname]); - // [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough - // After the welcome agent calls `complete_onboarding` and - // `chat_onboarding_completed` flips false→true, discard the transient - // welcome thread we created in `OnboardingLayout`. The next user - // message will route to the orchestrator and create its own thread. - // const dispatch = useAppDispatch(); - // const welcomeThreadId = useAppSelector(state => state.thread.welcomeThreadId); - // const chatOnboardingCompleted = snapshot.chatOnboardingCompleted; - // useEffect(() => { - // if (!chatOnboardingCompleted || !welcomeThreadId) return; - // let cancelled = false; - // console.debug( - // `[welcome-cleanup] chat_onboarding_completed=true — deleting welcome thread ${welcomeThreadId}` - // ); - // // Await the delete before dropping the local id so a backend failure - // // leaves `welcomeThreadId` set for retry on the next render. Without - // // the await, a 500 from `threads.delete` would leave a stale row in - // // the user's thread list while the renderer thinks it's gone. - // (async () => { - // try { - // await dispatch(deleteThread(welcomeThreadId)).unwrap(); - // if (cancelled) return; - // dispatch(clearSelectedThread()); - // dispatch(setWelcomeThreadId(null)); - // } catch (err) { - // console.warn('[welcome-cleanup] deleteThread failed; will retry on next render', err); - // } - // })(); - // return () => { - // cancelled = true; - // }; - // }, [chatOnboardingCompleted, welcomeThreadId, dispatch]); - // - // [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough - // Welcome lockdown (#883) — force any route other than `/chat` back to - // `/chat` while the welcome-agent conversation is still in progress. - // Skipped while onboarding is still pending (the onboarding gate above - // owns the route during that phase). - // useEffect(() => { - // if (!welcomeLocked || isBootstrapping) return; - // if (onboardingPending) return; - // if (location.pathname === '/chat') return; - // console.debug( - // `[welcome-lock] redirecting ${location.pathname} -> /chat (chat onboarding incomplete)` - // ); - // navigate('/chat', { replace: true }); - // }, [welcomeLocked, isBootstrapping, onboardingPending, location.pathname, navigate]); - return (
-
+
diff --git a/app/src/AppRoutes.tsx b/app/src/AppRoutes.tsx index 2beddf8da9..bf676c74c4 100644 --- a/app/src/AppRoutes.tsx +++ b/app/src/AppRoutes.tsx @@ -1,9 +1,11 @@ import { Navigate, Route, Routes } from 'react-router-dom'; +import AppRoutesIOS from './AppRoutesIOS'; import DefaultRedirect from './components/DefaultRedirect'; import ProtectedRoute from './components/ProtectedRoute'; import PublicRoute from './components/PublicRoute'; import HumanPage from './features/human/HumanPage'; +import { getIsMobile } from './lib/platform'; import Accounts from './pages/Accounts'; import Channels from './pages/Channels'; import Home from './pages/Home'; @@ -14,9 +16,16 @@ import Onboarding from './pages/onboarding/Onboarding'; import Rewards from './pages/Rewards'; import Settings from './pages/Settings'; import Skills from './pages/Skills'; +import WebCallbackPage from './pages/WebCallbackPage'; import Welcome from './pages/Welcome'; const AppRoutes = () => { + // Mobile target (iOS or Android): pair → Human/Chat/Settings only. + // Desktop routes are not rendered. + if (getIsMobile()) { + return ; + } + return ( {/* Public routes - redirect to /home if logged in */} @@ -29,6 +38,9 @@ const AppRoutes = () => { } /> + } /> + } /> + {/* Onboarding (full-page stepper, gated by onboarding_completed) */} ` without dragging the full Redux + provider tree along. +vi.mock('./features/human/HumanPage', () => ({ + default: () =>
human
, +})); +vi.mock('./pages/Accounts', () => ({ default: () =>
chat
})); +vi.mock('./pages/Settings', () => ({ + default: () =>
settings
, +})); +vi.mock('./pages/ios/PairScreen', () => ({ + PairScreen: () =>
pair
, +})); +vi.mock('./components/ios/MobileTabBar', () => ({ + default: () => , +})); + +const listProfiles = vi.fn(); +vi.mock('./services/transport/profileStore', () => ({ listProfiles: () => listProfiles() })); + +const AppRoutesIOS = (await import('./AppRoutesIOS')).default; + +const renderAt = (path: string) => + render( + + + + ); + +describe('AppRoutesIOS', () => { + beforeEach(() => listProfiles.mockReset()); + afterEach(() => vi.clearAllMocks()); + + describe('unpaired (no saved profile)', () => { + beforeEach(() => listProfiles.mockReturnValue([])); + + it('redirects unknown paths to /pair', () => { + renderAt('/'); + expect(screen.getByTestId('page-pair')).toBeInTheDocument(); + }); + + it('renders the PairScreen at /pair', () => { + renderAt('/pair'); + expect(screen.getByTestId('page-pair')).toBeInTheDocument(); + }); + + it('bounces /human back to /pair when no profile exists', () => { + renderAt('/human'); + expect(screen.getByTestId('page-pair')).toBeInTheDocument(); + expect(screen.queryByTestId('page-human')).not.toBeInTheDocument(); + }); + }); + + describe('paired (profile exists)', () => { + beforeEach(() => listProfiles.mockReturnValue([{ id: 'p1' }])); + + it('renders HumanPage with the mobile tab bar', () => { + renderAt('/human'); + expect(screen.getByTestId('page-human')).toBeInTheDocument(); + expect(screen.getByTestId('mobile-tab-bar')).toBeInTheDocument(); + }); + + it('renders the chat surface at /chat', () => { + renderAt('/chat'); + expect(screen.getByTestId('page-chat')).toBeInTheDocument(); + expect(screen.getByTestId('mobile-tab-bar')).toBeInTheDocument(); + }); + + it('renders Settings at /settings/devices via nested route', () => { + renderAt('/settings/devices'); + expect(screen.getByTestId('page-settings')).toBeInTheDocument(); + expect(screen.getByTestId('mobile-tab-bar')).toBeInTheDocument(); + }); + + it('redirects unknown paths to /human when paired', () => { + renderAt('/'); + expect(screen.getByTestId('page-human')).toBeInTheDocument(); + }); + }); +}); diff --git a/app/src/AppRoutesIOS.tsx b/app/src/AppRoutesIOS.tsx new file mode 100644 index 0000000000..7bd074d819 --- /dev/null +++ b/app/src/AppRoutesIOS.tsx @@ -0,0 +1,90 @@ +/** + * AppRoutesIOS — routes for the iOS + Android app targets. + * + * The filename is iOS-historic; the routes apply to every mobile target. + * + * Two phases: + * 1. Unpaired — /pair only. QR scan binds the phone to a desktop core, + * writes a profile to profileStore, then redirects to /human. + * 2. Paired — /human, /chat, /settings/* are reachable. A mobile tab bar + * sits at the bottom of the viewport. Any unknown path falls back to + * /human. The existing desktop screens (HumanPage, Accounts, Settings) + * are reused as-is; they call core RPC through the TransportManager + * bound to the saved profile. + */ +import debug from 'debug'; +import { type FC } from 'react'; +import { Navigate, Route, Routes } from 'react-router-dom'; + +import MobileTabBar from './components/ios/MobileTabBar'; +import HumanPage from './features/human/HumanPage'; +import Accounts from './pages/Accounts'; +import { PairScreen } from './pages/ios/PairScreen'; +import Settings from './pages/Settings'; +import { listProfiles } from './services/transport/profileStore'; + +const log = debug('mobile:routes'); + +const isPaired = (): boolean => listProfiles().length > 0; + +const IOSDefaultRedirect: FC = () => { + const paired = isPaired(); + log('[mobile] default redirect paired=%s', paired); + return ; +}; + +/** Wraps a paired-state route with the mobile tab bar. */ +const MobileShell: FC<{ children: React.ReactNode }> = ({ children }) => ( +
+
{children}
+ +
+); + +/** Bounces to /pair when no profile exists; otherwise renders children. */ +const RequirePairing: FC<{ children: React.ReactNode }> = ({ children }) => { + if (!isPaired()) { + log('[mobile] no pairing — redirecting to /pair'); + return ; + } + return {children}; +}; + +const AppRoutesIOS: FC = () => { + return ( + + {/* Unpaired entry — QR scan handshake. */} + } /> + + {/* Surfaced pages on iOS: Human, Chat, Settings. */} + + + + } + /> + + + + } + /> + + + + } + /> + + } /> + + ); +}; + +export default AppRoutesIOS; diff --git a/app/src/components/AppUpdatePrompt.tsx b/app/src/components/AppUpdatePrompt.tsx index 9aacbb8054..2af06cdfd4 100644 --- a/app/src/components/AppUpdatePrompt.tsx +++ b/app/src/components/AppUpdatePrompt.tsx @@ -108,7 +108,7 @@ const AppUpdatePrompt = (props: AppUpdatePromptProps) => { )} diff --git a/app/src/components/BootCheckGate/BootCheckGate.tsx b/app/src/components/BootCheckGate/BootCheckGate.tsx index 4df60a84a7..57f011e7d7 100644 --- a/app/src/components/BootCheckGate/BootCheckGate.tsx +++ b/app/src/components/BootCheckGate/BootCheckGate.tsx @@ -14,7 +14,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { type BootCheckResult, runBootCheck } from '../../lib/bootCheck'; import { useT } from '../../lib/i18n/I18nContext'; -import { bootCheckTransport } from '../../services/bootCheckService'; +import { bootCheckTransport, recoverPortConflict } from '../../services/bootCheckService'; import { clearCoreRpcTokenCache, clearCoreRpcUrlCache, @@ -315,7 +315,8 @@ function ModePicker({ onConfirm }: PickerProps) { /> {tokenError &&

{tokenError}

}

- {t('bootCheck.storedLocally')} Authorization: Bearer … on every RPC. + {t('bootCheck.storedLocally')} Authorization: Bearer …{' '} + {t('bootCheck.rpcAuthSuffix')}

@@ -406,16 +407,31 @@ function ResultScreen({ if (result.kind === 'match') return null; if (result.kind === 'unreachable') { + const isPortConflict = result.portConflict === true; return (

- {t('bootCheck.cannotReach')} + {isPortConflict ? t('bootCheck.portConflictTitle') : t('bootCheck.cannotReach')}

- {result.reason || t('bootCheck.cannotReachDesc')} + {isPortConflict + ? t('bootCheck.portConflictBody') + : result.reason || t('bootCheck.cannotReachDesc')}

{actionError &&

{actionError}

} -
+
+ {isPortConflict && ( + + )}
@@ -602,15 +620,8 @@ export default function BootCheckGate({ children }: BootCheckGateProps) { try { const checkResult = await runBootCheck(mode, transport); log('[boot-check] gate — check result=%s', checkResult.kind); - - if (checkResult.kind === 'match') { - // Gate resolves — render children. - setPhase('result'); - setResult(checkResult); - } else { - setPhase('result'); - setResult(checkResult); - } + setPhase('result'); + setResult(checkResult); } catch (err) { logError('[boot-check] gate — unexpected error: %o', err); setPhase('result'); @@ -727,6 +738,18 @@ export default function BootCheckGate({ children }: BootCheckGateProps) { log('[boot-check] gate — triggering cloud core update'); await transport.callRpc('openhuman.update_run', {}); log('[boot-check] gate — cloud core update triggered'); + } else if (result.kind === 'unreachable' && result.portConflict) { + log('[boot-check-gate] port conflict — invoking recover_port_conflict'); + const recovery = await recoverPortConflict(); + log( + '[boot-check-gate] recovery result: success=%s message=%s', + recovery.success, + recovery.message + ); + if (!recovery.success) { + setActionError(t('bootCheck.portConflictFixFailed')); + return; + } } // Re-run the full check after the action. diff --git a/app/src/components/BootCheckGate/__tests__/BootCheckGate.test.tsx b/app/src/components/BootCheckGate/__tests__/BootCheckGate.test.tsx index 973e718068..f2dac9fab0 100644 --- a/app/src/components/BootCheckGate/__tests__/BootCheckGate.test.tsx +++ b/app/src/components/BootCheckGate/__tests__/BootCheckGate.test.tsx @@ -32,6 +32,15 @@ vi.mock('../../../lib/bootCheck', () => ({ runBootCheck: (...args: unknown[]) => mockRunBootCheck(...args), })); +const mockRecoverPortConflict = vi.fn(); +vi.mock('../../../services/bootCheckService', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + recoverPortConflict: (...args: unknown[]) => mockRecoverPortConflict(...args), + }; +}); + const mockTestCoreRpcConnection = vi.fn(); vi.mock('../../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn(), @@ -572,6 +581,125 @@ describe('BootCheckGate — pre-set mode (subsequent launches)', () => { }); }); +describe('BootCheckGate — port conflict recovery', () => { + beforeEach(() => { + mockRecoverPortConflict.mockReset(); + mockRunBootCheck.mockReset(); + }); + + it('shows "Fix Automatically" button when portConflict=true', async () => { + mockRunBootCheck.mockResolvedValue({ + kind: 'unreachable', + reason: 'port conflict', + portConflict: true, + }); + + renderGate(); + fireEvent.click(screen.getByRole('button', { name: 'Continue' })); + + await waitFor(() => { + expect(screen.getByTestId('fix-automatically-btn')).toBeInTheDocument(); + }); + expect(screen.getByTestId('fix-automatically-btn').textContent).toBe('Fix Automatically'); + }); + + it('does not show "Fix Automatically" button when portConflict is not set', async () => { + mockRunBootCheck.mockResolvedValue({ kind: 'unreachable', reason: 'some other error' }); + + renderGate(); + fireEvent.click(screen.getByRole('button', { name: 'Continue' })); + + await waitFor(() => { + expect(screen.getByText("Can't Reach the Runtime")).toBeInTheDocument(); + }); + expect(screen.queryByTestId('fix-automatically-btn')).not.toBeInTheDocument(); + }); + + it('calls recoverPortConflict when "Fix Automatically" is clicked', async () => { + mockRunBootCheck + .mockResolvedValueOnce({ kind: 'unreachable', reason: 'port conflict', portConflict: true }) + .mockResolvedValue({ kind: 'match' }); + mockRecoverPortConflict.mockResolvedValue({ success: true, message: 'ok', new_port: 7789 }); + + renderGate(); + fireEvent.click(screen.getByRole('button', { name: 'Continue' })); + + await waitFor(() => { + expect(screen.getByTestId('fix-automatically-btn')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId('fix-automatically-btn')); + + await waitFor(() => { + expect(mockRecoverPortConflict).toHaveBeenCalled(); + }); + }); + + it('re-runs boot check after successful recovery', async () => { + mockRunBootCheck + .mockResolvedValueOnce({ kind: 'unreachable', reason: 'port conflict', portConflict: true }) + .mockResolvedValue({ kind: 'match' }); + mockRecoverPortConflict.mockResolvedValue({ success: true, message: 'ok', new_port: 7789 }); + + renderGate(); + fireEvent.click(screen.getByRole('button', { name: 'Continue' })); + + await waitFor(() => { + expect(screen.getByTestId('fix-automatically-btn')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId('fix-automatically-btn')); + + await waitFor(() => { + expect(screen.getByTestId('app-content')).toBeInTheDocument(); + }); + expect(mockRunBootCheck).toHaveBeenCalledTimes(2); + }); + + it('shows portConflictFixFailed message when recovery fails', async () => { + mockRunBootCheck.mockResolvedValue({ + kind: 'unreachable', + reason: 'port conflict', + portConflict: true, + }); + mockRecoverPortConflict.mockResolvedValue({ + success: false, + message: 'still busy', + new_port: undefined, + }); + + renderGate(); + fireEvent.click(screen.getByRole('button', { name: 'Continue' })); + + await waitFor(() => { + expect(screen.getByTestId('fix-automatically-btn')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId('fix-automatically-btn')); + + await waitFor(() => { + expect( + screen.getByText("Automatic fix didn't work. Please restart your computer and try again.") + ).toBeInTheDocument(); + }); + }); + + it('"Pick a Different Runtime" still renders as secondary for port conflict', async () => { + mockRunBootCheck.mockResolvedValue({ + kind: 'unreachable', + reason: 'port conflict', + portConflict: true, + }); + + renderGate(); + fireEvent.click(screen.getByRole('button', { name: 'Continue' })); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Pick a Different Runtime' })).toBeInTheDocument(); + }); + }); +}); + describe('BootCheckGate — picker (web build, !isTauri)', () => { beforeEach(() => { mockedIsTauri.mockReturnValue(false); diff --git a/app/src/components/BottomTabBar.tsx b/app/src/components/BottomTabBar.tsx index a0706a11b4..c87ed4d70c 100644 --- a/app/src/components/BottomTabBar.tsx +++ b/app/src/components/BottomTabBar.tsx @@ -2,8 +2,6 @@ import { useMemo, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { useT } from '../lib/i18n/I18nContext'; -// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough -// import { isWelcomeLocked } from '../lib/coreState/store'; import { useCoreState } from '../providers/CoreStateProvider'; import { selectCompanionSessionActive } from '../store/companionSlice'; import { useAppSelector } from '../store/hooks'; @@ -129,7 +127,6 @@ const makeTabs = (t: (key: string) => string) => [ const BottomTabBar = () => { const { t } = useT(); - const tabs = useMemo(() => makeTabs(t), [t]); const location = useLocation(); const navigate = useNavigate(); const { snapshot } = useCoreState(); @@ -139,6 +136,12 @@ const BottomTabBar = () => { const activeAccountId = useAppSelector(state => state.accounts.activeAccountId); const unreadCount = useAppSelector(state => selectUnreadCount(state.notifications.items)); const companionActive = useAppSelector(selectCompanionSessionActive); + // `state.theme` is undefined in some test fixtures that build a minimal + // store without the theme slice; default to the historical 'hover' behavior + // so an absent theme branch can't crash the bar. + const tabBarLabels = useAppSelector(state => state.theme?.tabBarLabels ?? 'hover'); + const labelsAlwaysVisible = tabBarLabels === 'always'; + const tabs = useMemo(() => makeTabs(t), [t]); const hiddenPaths = ['/', '/login']; if ( @@ -148,14 +151,6 @@ const BottomTabBar = () => { return null; } - // [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough - // Welcome lockdown (#883) — hide the bottom nav entirely while the - // chat-based welcome-agent flow is still in progress so the user - // cannot navigate away from the welcome conversation. - // if (isWelcomeLocked(snapshot)) { - // return null; - // } - // On /accounts we want as much real estate as possible for the embedded // webview — but *only* when a real account (WhatsApp, …) is selected. // The Agent entry keeps the tab bar visible so chatting with the agent @@ -243,7 +238,7 @@ const BottomTabBar = () => { diff --git a/app/src/components/DefaultRedirect.tsx b/app/src/components/DefaultRedirect.tsx index b42b378cb1..0da42080df 100644 --- a/app/src/components/DefaultRedirect.tsx +++ b/app/src/components/DefaultRedirect.tsx @@ -9,8 +9,6 @@ import RouteLoadingScreen from './RouteLoadingScreen'; * - Not logged in → / (Welcome page) * - Logged in, onboarding not completed → /onboarding * - Logged in, onboarding completed → /home - * (the welcome-lock effect in App.tsx may then bounce to /chat - * if `chat_onboarding_completed` is still false) */ const DefaultRedirect = () => { const { isBootstrapping, snapshot } = useCoreState(); diff --git a/app/src/components/EmptyStateCard.tsx b/app/src/components/EmptyStateCard.tsx new file mode 100644 index 0000000000..756cf13876 --- /dev/null +++ b/app/src/components/EmptyStateCard.tsx @@ -0,0 +1,54 @@ +import type { ReactNode } from 'react'; + +interface EmptyStateCardProps { + icon: ReactNode; + title: string; + description: string; + actionLabel?: string; + onAction?: () => void; + footer?: ReactNode; + className?: string; +} + +const EmptyStateCard = ({ + icon, + title, + description, + actionLabel, + onAction, + footer, + className = '', +}: EmptyStateCardProps) => { + return ( +
+
+ {icon} +
+

{title}

+

+ {description} +

+ {actionLabel && onAction ? ( + + ) : null} + {footer} +
+ ); +}; + +export default EmptyStateCard; diff --git a/app/src/components/LanguageSelect.tsx b/app/src/components/LanguageSelect.tsx index a6e68579a4..2fc93e2451 100644 --- a/app/src/components/LanguageSelect.tsx +++ b/app/src/components/LanguageSelect.tsx @@ -19,6 +19,7 @@ const LOCALE_OPTIONS: Array<{ value: Locale; flag: string; label: string }> = [ { value: 'ru', flag: '🇷🇺', label: 'Русский' }, { value: 'id', flag: '🇮🇩', label: 'Bahasa Indonesia' }, { value: 'it', flag: '🇮🇹', label: 'Italiano' }, + { value: 'pl', flag: '🇵🇱', label: 'Polski' }, ]; interface LanguageSelectProps { diff --git a/app/src/components/LocalAIDownloadSnackbar.tsx b/app/src/components/LocalAIDownloadSnackbar.tsx index d9e9ee3afe..1dabfa0ca2 100644 --- a/app/src/components/LocalAIDownloadSnackbar.tsx +++ b/app/src/components/LocalAIDownloadSnackbar.tsx @@ -119,7 +119,7 @@ const LocalAIDownloadSnackbar = () => { ); diff --git a/app/src/components/channels/ChannelSetupModal.tsx b/app/src/components/channels/ChannelSetupModal.tsx index cf81b7c7e2..58aededd78 100644 --- a/app/src/components/channels/ChannelSetupModal.tsx +++ b/app/src/components/channels/ChannelSetupModal.tsx @@ -9,7 +9,12 @@ import { useT } from '../../lib/i18n/I18nContext'; import type { ChannelDefinition, ChannelType } from '../../types/channels'; import DiscordConfig from './DiscordConfig'; import TelegramConfig from './TelegramConfig'; +import YuanbaoConfig from './YuanbaoConfig'; +import YuanbaoIcon from './YuanbaoIcon'; +// Emoji icons for channels rendered as plain text. `yuanbao` is handled +// separately with a branded SVG (see `YuanbaoIcon`) — matches the +// rendering used in `ChannelSelector`. const CHANNEL_ICONS: Record = { telegram: '\u2708\uFE0F', discord: '\uD83C\uDFAE', @@ -29,6 +34,8 @@ function ChannelConfigContent({ definition }: { definition: ChannelDefinition }) return ; case 'discord': return ; + case 'yuanbao': + return ; default: return (

@@ -62,7 +69,8 @@ export default function ChannelSetupModal({ definition, onClose }: ChannelSetupM if (e.target === e.currentTarget) onClose(); }; - const icon = CHANNEL_ICONS[definition.icon] ?? ''; + const emojiIcon = CHANNEL_ICONS[definition.icon] ?? ''; + const isYuanbao = definition.id === 'yuanbao'; const modalContent = (

- {icon && {icon}} + {isYuanbao ? ( + + ) : ( + emojiIcon && {emojiIcon} + )}

diff --git a/app/src/components/channels/ChannelStatusBadge.tsx b/app/src/components/channels/ChannelStatusBadge.tsx index 51e836e546..006773dab0 100644 --- a/app/src/components/channels/ChannelStatusBadge.tsx +++ b/app/src/components/channels/ChannelStatusBadge.tsx @@ -1,4 +1,5 @@ import { STATUS_STYLES } from '../../lib/channels/definitions'; +import { useT } from '../../lib/i18n/I18nContext'; import type { ChannelConnectionStatus } from '../../types/channels'; interface ChannelStatusBadgeProps { @@ -7,11 +8,12 @@ interface ChannelStatusBadgeProps { } const ChannelStatusBadge = ({ status, className = '' }: ChannelStatusBadgeProps) => { + const { t } = useT(); const style = STATUS_STYLES[status]; return ( - {style.label} + {t(`channels.status.${status}`)} ); }; diff --git a/app/src/components/channels/DiscordConfig.tsx b/app/src/components/channels/DiscordConfig.tsx index 41f9dd36a7..06b74fbae3 100644 --- a/app/src/components/channels/DiscordConfig.tsx +++ b/app/src/components/channels/DiscordConfig.tsx @@ -2,8 +2,8 @@ import debug from 'debug'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useOAuthConnectionListener } from '../../hooks/useOAuthConnectionListener'; -import { AUTH_MODE_LABELS } from '../../lib/channels/definitions'; import { useT } from '../../lib/i18n/I18nContext'; +import { useCoreState } from '../../providers/CoreStateProvider'; import { channelConnectionsApi } from '../../services/api/channelConnectionsApi'; import { callCoreRpc } from '../../services/coreRpcClient'; import { @@ -19,6 +19,7 @@ import type { ChannelConnectionStatus, ChannelDefinition, } from '../../types/channels'; +import { isLocalSessionToken } from '../../utils/localSession'; import { openUrl } from '../../utils/openUrl'; import { restartCoreProcess } from '../../utils/tauriCommands/core'; import ChannelFieldInput from './ChannelFieldInput'; @@ -37,9 +38,17 @@ const DiscordConfig = ({ definition }: DiscordConfigProps) => { const { t } = useT(); const dispatch = useAppDispatch(); const channelConnections = useAppSelector(state => state.channelConnections); + const { snapshot } = useCoreState(); + const isLocalSession = isLocalSessionToken(snapshot.sessionToken); + const visibleAuthModes = definition.auth_modes.filter( + spec => !isLocalSession || (spec.mode !== 'managed_dm' && spec.mode !== 'oauth') + ); const [busyKeys, setBusyKeys] = useState>({}); const [fieldValues, setFieldValues] = useState>>({}); + const [clearMemoryOnDisconnect, setClearMemoryOnDisconnect] = useState>( + {} + ); const [error, setError] = useState(null); /** Pending link tokens, keyed by compositeKey (discord:managed_dm). Only present while polling. */ const [linkToken, setLinkToken] = useState(null); @@ -167,7 +176,10 @@ const DiscordConfig = ({ definition }: DiscordConfigProps) => { channel: 'discord', authMode: spec.mode, status: 'error', - lastError: `${field.label} is required`, + lastError: t('channels.fieldRequired', '{field} is required').replace( + '{field}', + t(`channels.discord.fields.${field.key}.label`, field.label || field.key) + ), }) ); return; @@ -250,15 +262,19 @@ const DiscordConfig = ({ definition }: DiscordConfigProps) => { const handleDisconnect = useCallback( (authMode: ChannelAuthMode) => { + const key = `discord:${authMode}`; void runBusy(`discord:${authMode}`, async () => { log('disconnecting discord via %s', authMode); pollAbort.current?.abort(); setLinkToken(null); - await channelConnectionsApi.disconnectChannel('discord', authMode); + await channelConnectionsApi.disconnectChannel('discord', authMode, { + clearMemory: Boolean(clearMemoryOnDisconnect[key]), + }); + setClearMemoryOnDisconnect(prev => ({ ...prev, [key]: false })); dispatch(disconnectChannelConnection({ channel: 'discord', authMode })); }); }, - [dispatch, runBusy] + [clearMemoryOnDisconnect, dispatch, runBusy] ); const copyToken = useCallback(() => { @@ -277,7 +293,13 @@ const DiscordConfig = ({ definition }: DiscordConfigProps) => {

)} - {definition.auth_modes.map(spec => { + {isLocalSession && visibleAuthModes.length !== definition.auth_modes.length && ( +
+ {t('channels.localManagedUnavailable')} +
+ )} + + {visibleAuthModes.map(spec => { const compositeKey = `discord:${spec.mode}`; const connection = channelConnections.connections.discord?.[spec.mode]; const status: ChannelConnectionStatus = connection?.status ?? 'disconnected'; @@ -290,10 +312,10 @@ const DiscordConfig = ({ definition }: DiscordConfigProps) => {

- {AUTH_MODE_LABELS[spec.mode] ?? spec.mode} + {t(`channels.authMode.${spec.mode}`)}

- {spec.description} + {t(`channels.discord.authMode.${spec.mode}.description`)}

{connection?.lastError && (

{connection.lastError}

@@ -308,7 +330,13 @@ const DiscordConfig = ({ definition }: DiscordConfigProps) => { {spec.fields.map(field => ( updateField(compositeKey, field.key, val)} disabled={busy} @@ -345,38 +373,86 @@ const DiscordConfig = ({ definition }: DiscordConfigProps) => { {/* Connected state for managed_dm — show only Disconnect */} {spec.mode === 'managed_dm' && status === 'connected' ? ( -
-

- {t('channels.discord.accountLinked')} -

- -
- ) : /* Connect / Disconnect buttons for all other modes and states */ - spec.mode !== 'managed_dm' || status !== 'connecting' ? ( -
- {status !== 'connected' && ( + <> + +
+

+ {t('channels.discord.accountLinked')} +

+
+ + ) : /* Connect / Disconnect buttons for all other modes and states */ + spec.mode !== 'managed_dm' || status !== 'connecting' ? ( + <> + {status === 'connected' && ( + )} - -
+
+ {status !== 'connected' && ( + + )} + +
+ ) : null} {/* Server + Channel picker — shown after successful bot_token connection */} diff --git a/app/src/components/channels/TelegramConfig.tsx b/app/src/components/channels/TelegramConfig.tsx index b87be36cf8..c022ab14d4 100644 --- a/app/src/components/channels/TelegramConfig.tsx +++ b/app/src/components/channels/TelegramConfig.tsx @@ -2,8 +2,8 @@ import debug from 'debug'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useOAuthConnectionListener } from '../../hooks/useOAuthConnectionListener'; -import { AUTH_MODE_LABELS } from '../../lib/channels/definitions'; import { useT } from '../../lib/i18n/I18nContext'; +import { useCoreState } from '../../providers/CoreStateProvider'; import { channelConnectionsApi } from '../../services/api/channelConnectionsApi'; import { callCoreRpc } from '../../services/coreRpcClient'; import { @@ -19,6 +19,7 @@ import type { ChannelConnectionStatus, ChannelDefinition, } from '../../types/channels'; +import { isLocalSessionToken } from '../../utils/localSession'; import { openUrl } from '../../utils/openUrl'; import { restartCoreProcess } from '../../utils/tauriCommands/core'; import ChannelFieldInput from './ChannelFieldInput'; @@ -34,12 +35,20 @@ const TelegramConfig = ({ definition }: TelegramConfigProps) => { const { t } = useT(); const dispatch = useAppDispatch(); const channelConnections = useAppSelector(state => state.channelConnections); + const { snapshot } = useCoreState(); + const isLocalSession = isLocalSessionToken(snapshot.sessionToken); + const visibleAuthModes = definition.auth_modes.filter( + spec => !isLocalSession || (spec.mode !== 'managed_dm' && spec.mode !== 'oauth') + ); const MANAGED_DM_CONNECTING_MESSAGE = t('channels.telegram.managedDmConnecting'); const MANAGED_DM_TIMEOUT_MESSAGE = t('channels.telegram.managedDmTimeout'); const [busyKeys, setBusyKeys] = useState>({}); const [fieldValues, setFieldValues] = useState>>({}); + const [clearMemoryOnDisconnect, setClearMemoryOnDisconnect] = useState>( + {} + ); const [error, setError] = useState(null); const managedDmPollControllers = useRef>({}); @@ -194,7 +203,10 @@ const TelegramConfig = ({ definition }: TelegramConfigProps) => { channel: 'telegram', authMode: spec.mode, status: 'error', - lastError: `${field.label} is required`, + lastError: t('channels.fieldRequired', '{field} is required').replace( + '{field}', + t(`channels.telegram.fields.${field.key}.label`, field.label || field.key) + ), }) ); return; @@ -314,22 +326,24 @@ const TelegramConfig = ({ definition }: TelegramConfigProps) => { void runBusy(key, async () => { log('disconnecting telegram via %s', authMode); stopManagedDmPolling(`telegram:${authMode}`); - await channelConnectionsApi.disconnectChannel('telegram', authMode); + await channelConnectionsApi.disconnectChannel('telegram', authMode, { + clearMemory: Boolean(clearMemoryOnDisconnect[key]), + }); + setClearMemoryOnDisconnect(prev => ({ ...prev, [key]: false })); dispatch(disconnectChannelConnection({ channel: 'telegram', authMode })); }); }, - [dispatch, runBusy, stopManagedDmPolling] + [clearMemoryOnDisconnect, dispatch, runBusy, stopManagedDmPolling] ); return (

- Remote control (Telegram) + {t('channels.telegram.remoteControlTitle')}

- From an allowed Telegram chat, send /status, /sessions, /new, or /help. Model routing - still uses /model and /models. + {t('channels.telegram.remoteControlBody')}

@@ -339,7 +353,13 @@ const TelegramConfig = ({ definition }: TelegramConfigProps) => {
)} - {definition.auth_modes.map(spec => { + {isLocalSession && visibleAuthModes.length !== definition.auth_modes.length && ( +
+ {t('channels.localManagedUnavailable')} +
+ )} + + {visibleAuthModes.map(spec => { const compositeKey = `telegram:${spec.mode}`; const connection = channelConnections.connections.telegram?.[spec.mode]; const status: ChannelConnectionStatus = connection?.status ?? 'disconnected'; @@ -351,10 +371,10 @@ const TelegramConfig = ({ definition }: TelegramConfigProps) => {

- {AUTH_MODE_LABELS[spec.mode] ?? spec.mode} + {t(`channels.authMode.${spec.mode}`)}

- {spec.description} + {t(`channels.telegram.authMode.${spec.mode}.description`)}

{connection?.lastError && (

{connection.lastError}

@@ -368,7 +388,13 @@ const TelegramConfig = ({ definition }: TelegramConfigProps) => { {spec.fields.map(field => ( updateField(compositeKey, field.key, val)} disabled={busyKeys[compositeKey]} @@ -377,6 +403,30 @@ const TelegramConfig = ({ definition }: TelegramConfigProps) => {
)} + {status === 'connected' && ( + + )} +
+ +
+
+
+ ); +}; + +export default YuanbaoConfig; diff --git a/app/src/components/channels/YuanbaoIcon.tsx b/app/src/components/channels/YuanbaoIcon.tsx new file mode 100644 index 0000000000..5a1261b9b9 --- /dev/null +++ b/app/src/components/channels/YuanbaoIcon.tsx @@ -0,0 +1,56 @@ +import { useId } from 'react'; + +interface YuanbaoIconProps { + /** + * Tailwind size + color overrides. Defaults to a 20px box, matching + * the visual weight of the channel-row emojis it sits next to. + */ + className?: string; +} + +/** + * Brand mark for the Yuanbao channel. Inlined as an SVG component so it + * can be tinted / sized via Tailwind without round-tripping through an + * `` element. `clipPath` ids are generated with `useId` so multiple + * instances on the same page (channel selector + setup modal) don't + * collide in the DOM. + */ +const YuanbaoIcon = ({ className = 'w-5 h-5' }: YuanbaoIconProps) => { + const clipId = useId(); + return ( + + ); +}; + +export default YuanbaoIcon; diff --git a/app/src/components/channels/__tests__/ChannelSetupModal.test.tsx b/app/src/components/channels/__tests__/ChannelSetupModal.test.tsx new file mode 100644 index 0000000000..ae521baa31 --- /dev/null +++ b/app/src/components/channels/__tests__/ChannelSetupModal.test.tsx @@ -0,0 +1,72 @@ +import { screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { FALLBACK_DEFINITIONS } from '../../../lib/channels/definitions'; +import { renderWithProviders } from '../../../test/test-utils'; +import type { ChannelDefinition } from '../../../types/channels'; +import ChannelSetupModal from '../ChannelSetupModal'; + +// YuanbaoConfig pulls in API + Tauri helpers we don't need for the routing +// branches under test — stub it so we only assert ChannelSetupModal's own +// behavior (icon branch + yuanbao switch case). +vi.mock('../YuanbaoConfig', () => ({ + default: () =>
Yuanbao Config
, +})); + +vi.mock('../TelegramConfig', () => ({ + default: () =>
Telegram Config
, +})); + +vi.mock('../DiscordConfig', () => ({ + default: () =>
Discord Config
, +})); + +const yuanbaoDef: ChannelDefinition = { + id: 'yuanbao', + display_name: '元宝', + description: '通过元宝(Yuanbao)机器人收发消息。', + icon: 'yuanbao', + auth_modes: [ + { + mode: 'api_key', + description: '提供元宝开放平台的 AppID 和 AppSecret。', + fields: [], + auth_action: undefined, + }, + ], + capabilities: ['send_text', 'receive_text'], +}; + +describe('ChannelSetupModal', () => { + it('renders the YuanbaoConfig body and brand SVG icon for the yuanbao channel', () => { + renderWithProviders( {}} />); + // Header title + body routing both exercised. + expect(screen.getByText('元宝')).toBeInTheDocument(); + expect(screen.getByTestId('yuanbao-config')).toBeInTheDocument(); + // YuanbaoIcon emits an aria-hidden SVG in the header; the emoji-based + // fallback should NOT also render for yuanbao. + const dialog = screen.getByRole('dialog'); + expect(dialog.querySelector('svg[aria-hidden="true"]')).not.toBeNull(); + }); + + it('renders the emoji icon and TelegramConfig body for the telegram channel', () => { + const telegramDef = FALLBACK_DEFINITIONS.find(d => d.id === 'telegram')!; + renderWithProviders( {}} />); + expect(screen.getByTestId('telegram-config')).toBeInTheDocument(); + // Emoji branch produces a span sibling to the title. + expect(screen.getByText('\u2708\uFE0F')).toBeInTheDocument(); + }); + + it('falls back to the unavailable-channel message for an unknown channel id', () => { + const unknown: ChannelDefinition = { ...yuanbaoDef, id: 'unknown', display_name: 'Unknown' }; + renderWithProviders( {}} />); + expect(screen.getByText(/Configuration for/i)).toBeInTheDocument(); + }); + + it('invokes onClose when the Escape key is pressed', () => { + const onClose = vi.fn(); + renderWithProviders(); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/src/components/channels/__tests__/DiscordConfig.test.tsx b/app/src/components/channels/__tests__/DiscordConfig.test.tsx index 5eed7c3439..056f62f0a6 100644 --- a/app/src/components/channels/__tests__/DiscordConfig.test.tsx +++ b/app/src/components/channels/__tests__/DiscordConfig.test.tsx @@ -1,12 +1,44 @@ -import { screen } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { FALLBACK_DEFINITIONS } from '../../../lib/channels/definitions'; -import { renderWithProviders } from '../../../test/test-utils'; +import { channelConnectionsApi } from '../../../services/api/channelConnectionsApi'; +import { upsertChannelConnection } from '../../../store/channelConnectionsSlice'; +import { createTestStore, renderWithProviders } from '../../../test/test-utils'; import DiscordConfig from '../DiscordConfig'; +const coreStateMock = vi.hoisted(() => vi.fn(() => ({ snapshot: { sessionToken: 'jwt-abc' } }))); + +vi.mock('../../../providers/CoreStateProvider', () => ({ useCoreState: () => coreStateMock() })); + const discordDef = FALLBACK_DEFINITIONS.find(d => d.id === 'discord')!; +vi.mock('../../../hooks/useOAuthConnectionListener', () => ({ + useOAuthConnectionListener: vi.fn(), +})); + +vi.mock('../../../services/api/channelConnectionsApi', () => ({ + channelConnectionsApi: { + connectChannel: vi.fn(), + disconnectChannel: vi.fn(), + discordLinkStart: vi.fn(), + discordLinkCheck: vi.fn(), + listDefinitions: vi.fn(), + listStatus: vi.fn(), + }, +})); + +vi.mock('../../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() })); + +vi.mock('../../../utils/openUrl', () => ({ openUrl: vi.fn() })); + +vi.mock('../../../utils/tauriCommands/core', () => ({ restartCoreProcess: vi.fn() })); + +afterEach(() => { + vi.clearAllMocks(); + coreStateMock.mockReturnValue({ snapshot: { sessionToken: 'jwt-abc' } }); +}); + describe('DiscordConfig', () => { it('renders auth mode labels', () => { renderWithProviders(); @@ -30,4 +62,73 @@ describe('DiscordConfig', () => { const connectButtons = screen.getAllByText('Connect'); expect(connectButtons.length).toBe(3); }); + + it('passes clearMemory when disconnecting a connected bot token account', async () => { + const store = createTestStore(); + store.dispatch( + upsertChannelConnection({ + channel: 'discord', + authMode: 'bot_token', + patch: { status: 'connected', capabilities: ['read', 'write'] }, + }) + ); + vi.mocked(channelConnectionsApi.disconnectChannel).mockResolvedValue(undefined); + + renderWithProviders(, { store }); + + fireEvent.click(screen.getByLabelText(/also delete memory/i)); + const disconnectButton = screen + .getAllByRole('button', { name: 'Disconnect' }) + .find(button => !button.hasAttribute('disabled')); + expect(disconnectButton).toBeDefined(); + fireEvent.click(disconnectButton!); + + await waitFor(() => { + expect(channelConnectionsApi.disconnectChannel).toHaveBeenCalledWith('discord', 'bot_token', { + clearMemory: true, + }); + }); + }); + + it('passes clearMemory when disconnecting a connected managed DM account', async () => { + const store = createTestStore(); + store.dispatch( + upsertChannelConnection({ + channel: 'discord', + authMode: 'managed_dm', + patch: { status: 'connected', capabilities: ['dm'] }, + }) + ); + vi.mocked(channelConnectionsApi.disconnectChannel).mockResolvedValue(undefined); + + renderWithProviders(, { store }); + + fireEvent.click(screen.getByLabelText(/also delete memory/i)); + const disconnectButton = screen + .getAllByRole('button', { name: 'Disconnect' }) + .find(button => !button.hasAttribute('disabled')); + expect(disconnectButton).toBeDefined(); + fireEvent.click(disconnectButton!); + + await waitFor(() => { + expect(channelConnectionsApi.disconnectChannel).toHaveBeenCalledWith( + 'discord', + 'managed_dm', + { clearMemory: true } + ); + }); + }); + + it('hides managed channel auth modes for local users', () => { + coreStateMock.mockReturnValue({ snapshot: { sessionToken: 'header.payload.local' } }); + + renderWithProviders(); + + expect( + screen.getByText('Managed channels are not available for local users.') + ).toBeInTheDocument(); + expect(screen.queryByText('OAuth Sign-in')).not.toBeInTheDocument(); + expect(screen.queryByText('Login with OpenHuman')).not.toBeInTheDocument(); + expect(screen.getAllByText('Bot Token').length).toBeGreaterThanOrEqual(1); + }); }); diff --git a/app/src/components/channels/__tests__/TelegramConfig.test.tsx b/app/src/components/channels/__tests__/TelegramConfig.test.tsx index 7bc841e98a..7731cb4313 100644 --- a/app/src/components/channels/__tests__/TelegramConfig.test.tsx +++ b/app/src/components/channels/__tests__/TelegramConfig.test.tsx @@ -3,11 +3,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { FALLBACK_DEFINITIONS } from '../../../lib/channels/definitions'; import { channelConnectionsApi } from '../../../services/api/channelConnectionsApi'; -import { renderWithProviders } from '../../../test/test-utils'; +import { upsertChannelConnection } from '../../../store/channelConnectionsSlice'; +import { createTestStore, renderWithProviders } from '../../../test/test-utils'; import { openUrl } from '../../../utils/openUrl'; import TelegramConfig from '../TelegramConfig'; const telegramDef = FALLBACK_DEFINITIONS.find(d => d.id === 'telegram')!; +const coreStateMock = vi.hoisted(() => vi.fn(() => ({ snapshot: { sessionToken: 'jwt-abc' } }))); vi.mock('../../../services/api/channelConnectionsApi', () => ({ channelConnectionsApi: { @@ -21,9 +23,11 @@ vi.mock('../../../services/api/channelConnectionsApi', () => ({ })); vi.mock('../../../utils/openUrl', () => ({ openUrl: vi.fn() })); +vi.mock('../../../providers/CoreStateProvider', () => ({ useCoreState: () => coreStateMock() })); afterEach(() => { vi.clearAllMocks(); + coreStateMock.mockReturnValue({ snapshot: { sessionToken: 'jwt-abc' } }); }); describe('TelegramConfig', () => { @@ -66,6 +70,35 @@ describe('TelegramConfig', () => { }); }); + it('passes clearMemory when disconnecting with the memory checkbox selected', async () => { + const store = createTestStore(); + store.dispatch( + upsertChannelConnection({ + channel: 'telegram', + authMode: 'bot_token', + patch: { status: 'connected', capabilities: ['read', 'write'] }, + }) + ); + vi.mocked(channelConnectionsApi.disconnectChannel).mockResolvedValue(undefined); + + renderWithProviders(, { store }); + + fireEvent.click(screen.getByLabelText(/also delete memory/i)); + const disconnectButton = screen + .getAllByRole('button', { name: 'Disconnect' }) + .find(button => !button.hasAttribute('disabled')); + expect(disconnectButton).toBeDefined(); + fireEvent.click(disconnectButton!); + + await waitFor(() => { + expect(channelConnectionsApi.disconnectChannel).toHaveBeenCalledWith( + 'telegram', + 'bot_token', + { clearMemory: true } + ); + }); + }); + it('starts managed dm flow via core RPC, opens the deep link, and marks connected after polling', async () => { vi.mocked(channelConnectionsApi.connectChannel).mockResolvedValue({ status: 'pending_auth', @@ -98,4 +131,16 @@ describe('TelegramConfig', () => { }); expect(await screen.findByText('Connected')).toBeInTheDocument(); }); + + it('hides managed channel auth modes for local users', () => { + coreStateMock.mockReturnValue({ snapshot: { sessionToken: 'header.payload.local' } }); + + renderWithProviders(); + + expect( + screen.getByText('Managed channels are not available for local users.') + ).toBeInTheDocument(); + expect(screen.queryByText('Login with OpenHuman')).not.toBeInTheDocument(); + expect(screen.getAllByText(/Bot Token/i).length).toBeGreaterThanOrEqual(1); + }); }); diff --git a/app/src/components/channels/__tests__/YuanbaoConfig.test.tsx b/app/src/components/channels/__tests__/YuanbaoConfig.test.tsx new file mode 100644 index 0000000000..404f44d6cf --- /dev/null +++ b/app/src/components/channels/__tests__/YuanbaoConfig.test.tsx @@ -0,0 +1,287 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { channelConnectionsApi } from '../../../services/api/channelConnectionsApi'; +import { setChannelConnectionStatus } from '../../../store/channelConnectionsSlice'; +import { createTestStore, renderWithProviders } from '../../../test/test-utils'; +import type { ChannelDefinition } from '../../../types/channels'; +import { restartCoreProcess } from '../../../utils/tauriCommands/core'; +import YuanbaoConfig from '../YuanbaoConfig'; + +vi.mock('../../../services/api/channelConnectionsApi', () => ({ + channelConnectionsApi: { connectChannel: vi.fn(), disconnectChannel: vi.fn() }, +})); + +vi.mock('../../../utils/tauriCommands/core', () => ({ restartCoreProcess: vi.fn() })); + +// Mirrors the backend yuanbao_definition() in +// src/openhuman/channels/controllers/definitions.rs — kept inline because +// the frontend fallback definitions list does not (yet) include yuanbao. +const yuanbaoDef: ChannelDefinition = { + id: 'yuanbao', + display_name: '元宝', + description: '通过元宝(Yuanbao)机器人收发消息。', + icon: 'yuanbao', + auth_modes: [ + { + mode: 'api_key', + description: '提供元宝开放平台的 AppID 和 AppSecret。', + fields: [ + { + key: 'app_key', + label: 'AppID', + field_type: 'string', + required: true, + placeholder: '元宝开放平台 AppID', + }, + { + key: 'app_secret', + label: 'AppSecret', + field_type: 'secret', + required: true, + placeholder: '元宝开放平台 AppSecret', + }, + ], + auth_action: undefined, + }, + ], + capabilities: ['send_text', 'receive_text', 'typing'], +}; + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('YuanbaoConfig', () => { + it('renders the api_key mode label, description, and credential fields', () => { + renderWithProviders(); + expect(screen.getByText('Use your own API Key')).toBeInTheDocument(); + expect(screen.getByText(/AppID 和 AppSecret/)).toBeInTheDocument(); + expect(screen.getByPlaceholderText('元宝开放平台 AppID')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('元宝开放平台 AppSecret')).toBeInTheDocument(); + }); + + it('shows a Connect and a (disabled) Disconnect button by default', () => { + renderWithProviders(); + expect(screen.getByText('Connect')).toBeInTheDocument(); + const disconnect = screen.getByText('Disconnect'); + expect(disconnect).toBeDisabled(); + }); + + it('returns null when the definition has no auth modes', () => { + const empty: ChannelDefinition = { ...yuanbaoDef, auth_modes: [] }; + const { container } = renderWithProviders(); + expect(container.firstChild).toBeNull(); + }); + + it('shows inline validation errors when required fields are empty and clears them on input', () => { + renderWithProviders(); + fireEvent.click(screen.getByText('Connect')); + + // Two required fields → two inline error messages. + const appKeyError = screen + .getAllByText(/AppID/) + .filter(node => node.className.includes('text-coral')); + expect(appKeyError.length).toBeGreaterThan(0); + expect(channelConnectionsApi.connectChannel).not.toHaveBeenCalled(); + + // Typing into a field clears that field's error (covers updateField + // branch that mutates fieldErrors). + fireEvent.change(screen.getByPlaceholderText('元宝开放平台 AppID'), { + target: { value: 'app-key-123' }, + }); + expect( + screen.queryAllByText(/AppID/).filter(node => node.className.includes('text-coral')).length + ).toBe(0); + }); + + it('connects successfully and dispatches connected when restart is not required', async () => { + vi.mocked(channelConnectionsApi.connectChannel).mockResolvedValue({ + status: 'connected', + restart_required: false, + }); + + const { store } = renderWithProviders(); + fireEvent.change(screen.getByPlaceholderText('元宝开放平台 AppID'), { + target: { value: 'app-key-123' }, + }); + fireEvent.change(screen.getByPlaceholderText('元宝开放平台 AppSecret'), { + target: { value: 'app-secret-xyz' }, + }); + fireEvent.click(screen.getByText('Connect')); + + await waitFor(() => { + expect(channelConnectionsApi.connectChannel).toHaveBeenCalledWith('yuanbao', { + authMode: 'api_key', + credentials: { app_key: 'app-key-123', app_secret: 'app-secret-xyz' }, + }); + }); + await waitFor(() => { + const conn = store.getState().channelConnections.connections.yuanbao?.api_key; + expect(conn?.status).toBe('connected'); + expect(conn?.capabilities).toEqual(['read', 'write']); + }); + expect(restartCoreProcess).not.toHaveBeenCalled(); + }); + + it('calls restartCoreProcess and dispatches connected when restart_required=true', async () => { + vi.mocked(channelConnectionsApi.connectChannel).mockResolvedValue({ + status: 'connected', + restart_required: true, + }); + vi.mocked(restartCoreProcess).mockResolvedValue(); + + const { store } = renderWithProviders(); + fireEvent.change(screen.getByPlaceholderText('元宝开放平台 AppID'), { + target: { value: 'app-key-123' }, + }); + fireEvent.change(screen.getByPlaceholderText('元宝开放平台 AppSecret'), { + target: { value: 'app-secret-xyz' }, + }); + fireEvent.click(screen.getByText('Connect')); + + await waitFor(() => { + expect(restartCoreProcess).toHaveBeenCalledTimes(1); + }); + await waitFor(() => { + const conn = store.getState().channelConnections.connections.yuanbao?.api_key; + expect(conn?.status).toBe('connected'); + }); + }); + + it('marks the channel as error when restartCoreProcess throws after a successful connect', async () => { + vi.mocked(channelConnectionsApi.connectChannel).mockResolvedValue({ + status: 'connected', + restart_required: true, + }); + vi.mocked(restartCoreProcess).mockRejectedValue(new Error('core restart failed')); + + const { store } = renderWithProviders(); + fireEvent.change(screen.getByPlaceholderText('元宝开放平台 AppID'), { + target: { value: 'app-key-123' }, + }); + fireEvent.change(screen.getByPlaceholderText('元宝开放平台 AppSecret'), { + target: { value: 'app-secret-xyz' }, + }); + fireEvent.click(screen.getByText('Connect')); + + await waitFor(() => { + const conn = store.getState().channelConnections.connections.yuanbao?.api_key; + expect(conn?.status).toBe('error'); + expect(conn?.lastError).toBeTruthy(); + }); + }); + + it('surfaces an error when the backend returns a non-connected status', async () => { + vi.mocked(channelConnectionsApi.connectChannel).mockResolvedValue({ + status: 'pending_auth', + restart_required: false, + }); + + const { store } = renderWithProviders(); + fireEvent.change(screen.getByPlaceholderText('元宝开放平台 AppID'), { + target: { value: 'app-key-123' }, + }); + fireEvent.change(screen.getByPlaceholderText('元宝开放平台 AppSecret'), { + target: { value: 'app-secret-xyz' }, + }); + fireEvent.click(screen.getByText('Connect')); + + await waitFor(() => { + const conn = store.getState().channelConnections.connections.yuanbao?.api_key; + expect(conn?.status).toBe('error'); + expect(conn?.lastError).toContain('pending_auth'); + }); + }); + + it('captures connect failures from the API and dispatches an error status', async () => { + vi.mocked(channelConnectionsApi.connectChannel).mockRejectedValue( + new Error('invalid credentials') + ); + + const { store } = renderWithProviders(); + fireEvent.change(screen.getByPlaceholderText('元宝开放平台 AppID'), { + target: { value: 'app-key-123' }, + }); + fireEvent.change(screen.getByPlaceholderText('元宝开放平台 AppSecret'), { + target: { value: 'app-secret-xyz' }, + }); + fireEvent.click(screen.getByText('Connect')); + + await waitFor(() => { + const conn = store.getState().channelConnections.connections.yuanbao?.api_key; + expect(conn?.status).toBe('error'); + expect(conn?.lastError).toBe('invalid credentials'); + }); + }); + + it('disconnects an active channel via the API and clears the connection', async () => { + const store = createTestStore(); + store.dispatch( + setChannelConnectionStatus({ channel: 'yuanbao', authMode: 'api_key', status: 'connected' }) + ); + vi.mocked(channelConnectionsApi.disconnectChannel).mockResolvedValue(); + + renderWithProviders(, { store }); + + // Status is connected → Reconnect label appears on the primary button. + expect(screen.getByText('Reconnect')).toBeInTheDocument(); + const disconnect = screen.getByText('Disconnect'); + expect(disconnect).not.toBeDisabled(); + fireEvent.click(disconnect); + + await waitFor(() => { + expect(channelConnectionsApi.disconnectChannel).toHaveBeenCalledWith('yuanbao', 'api_key'); + }); + await waitFor(() => { + const conn = store.getState().channelConnections.connections.yuanbao?.api_key; + expect(conn?.status).toBe('disconnected'); + }); + }); + + it('reports an error status when the disconnect API call fails', async () => { + const store = createTestStore(); + store.dispatch( + setChannelConnectionStatus({ channel: 'yuanbao', authMode: 'api_key', status: 'connected' }) + ); + vi.mocked(channelConnectionsApi.disconnectChannel).mockRejectedValue( + new Error('rpc unreachable') + ); + + renderWithProviders(, { store }); + fireEvent.click(screen.getByText('Disconnect')); + + await waitFor(() => { + const conn = store.getState().channelConnections.connections.yuanbao?.api_key; + expect(conn?.status).toBe('error'); + expect(conn?.lastError).toBe('rpc unreachable'); + }); + }); + + it('resets a stale "connecting" status from a previous session on mount', () => { + const store = createTestStore(); + store.dispatch( + setChannelConnectionStatus({ channel: 'yuanbao', authMode: 'api_key', status: 'connecting' }) + ); + + renderWithProviders(, { store }); + + const conn = store.getState().channelConnections.connections.yuanbao?.api_key; + expect(conn?.status).toBe('disconnected'); + }); + + it('renders the last error message when the connection is in an error state', () => { + const store = createTestStore(); + store.dispatch( + setChannelConnectionStatus({ + channel: 'yuanbao', + authMode: 'api_key', + status: 'error', + lastError: 'sign verification failed', + }) + ); + + renderWithProviders(, { store }); + expect(screen.getByText('sign verification failed')).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/channels/__tests__/YuanbaoIcon.test.tsx b/app/src/components/channels/__tests__/YuanbaoIcon.test.tsx new file mode 100644 index 0000000000..067535c224 --- /dev/null +++ b/app/src/components/channels/__tests__/YuanbaoIcon.test.tsx @@ -0,0 +1,37 @@ +import { render } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import YuanbaoIcon from '../YuanbaoIcon'; + +describe('YuanbaoIcon', () => { + it('renders an inline SVG with the default size class', () => { + const { container } = render(); + const svg = container.querySelector('svg'); + expect(svg).not.toBeNull(); + expect(svg).toHaveAttribute('aria-hidden', 'true'); + expect(svg?.getAttribute('class')).toContain('w-5'); + expect(svg?.getAttribute('class')).toContain('h-5'); + }); + + it('applies a custom className override', () => { + const { container } = render(); + const svg = container.querySelector('svg'); + expect(svg?.getAttribute('class')).toBe('w-10 h-10 text-amber-500'); + }); + + it('generates a unique clipPath id per instance so duplicate icons do not collide', () => { + const { container } = render( + <> + + + + ); + const clips = container.querySelectorAll('clipPath'); + expect(clips.length).toBe(2); + const id1 = clips[0].getAttribute('id'); + const id2 = clips[1].getAttribute('id'); + expect(id1).toBeTruthy(); + expect(id2).toBeTruthy(); + expect(id1).not.toBe(id2); + }); +}); diff --git a/app/src/components/channels/mcp/ConfigAssistantPanel.tsx b/app/src/components/channels/mcp/ConfigAssistantPanel.tsx index f936c32977..c3ffe9e61b 100644 --- a/app/src/components/channels/mcp/ConfigAssistantPanel.tsx +++ b/app/src/components/channels/mcp/ConfigAssistantPanel.tsx @@ -7,6 +7,7 @@ import debug from 'debug'; import { useCallback, useRef, useState } from 'react'; +import { useT } from '../../../lib/i18n/I18nContext'; import { mcpClientsApi } from '../../../services/api/mcpClientsApi'; const log = debug('mcp-clients:config-assist'); @@ -26,6 +27,7 @@ const ConfigAssistantPanel = ({ qualifiedName, onApplySuggestedEnv, }: ConfigAssistantPanelProps) => { + const { t } = useT(); const [messages, setMessages] = useState([]); const [input, setInput] = useState(''); const [sending, setSending] = useState(false); @@ -68,7 +70,7 @@ const ConfigAssistantPanel = ({ setMessages(prev => [...prev, assistantMessage]); setTimeout(scrollToBottom, 50); } catch (err) { - const msg = err instanceof Error ? err.message : 'Failed to get response'; + const msg = err instanceof Error ? err.message : t('mcp.configAssistant.failedResponse'); log('config_assist error: %s', msg); setError(msg); setMessages(messages); @@ -76,7 +78,7 @@ const ConfigAssistantPanel = ({ } finally { setSending(false); } - }, [input, messages, qualifiedName, sending, scrollToBottom]); + }, [input, messages, qualifiedName, sending, scrollToBottom, t]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -91,14 +93,14 @@ const ConfigAssistantPanel = ({ return (

- Configuration assistant + {t('mcp.configAssistant.title')}

{/* Message list */}
{messages.length === 0 && (

- Ask about configuration, required env vars, or setup steps. + {t('mcp.configAssistant.empty')}

)} {messages.map((msg, idx) => ( @@ -114,11 +116,14 @@ const ConfigAssistantPanel = ({

{msg.content}

{msg.suggested_env && Object.keys(msg.suggested_env).length > 0 && (
-

Suggested values:

+

+ {t('mcp.configAssistant.suggestedValues')} +

    {Object.keys(msg.suggested_env).map(key => (
  • - {key}: (value hidden) + {key}:{' '} + {t('mcp.configAssistant.valueHidden')}
  • ))}
@@ -127,12 +132,12 @@ const ConfigAssistantPanel = ({ type="button" onClick={() => onApplySuggestedEnv(msg.suggested_env!)} className="mt-1 rounded px-2 py-1 text-[11px] font-medium bg-white/20 hover:bg-white/30 transition-colors"> - Apply suggested values + {t('mcp.configAssistant.applySuggested')} )} {!onApplySuggestedEnv && (

- Re-install with these values to apply them. + {t('mcp.configAssistant.reinstallHint')}

)}
@@ -143,7 +148,7 @@ const ConfigAssistantPanel = ({ {sending && (
- Thinking... + {t('mcp.configAssistant.thinking')}
)} @@ -165,7 +170,7 @@ const ConfigAssistantPanel = ({ onChange={e => setInput(e.target.value)} onKeyDown={handleKeyDown} disabled={sending} - placeholder="Ask a question (Enter to send, Shift+Enter for newline)" + placeholder={t('mcp.configAssistant.inputPlaceholder')} className="flex-1 rounded-lg border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-1.5 text-sm text-stone-800 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 focus:outline-none focus:ring-2 focus:ring-primary-500/40 disabled:opacity-50 resize-none" />
diff --git a/app/src/components/channels/mcp/InstallDialog.tsx b/app/src/components/channels/mcp/InstallDialog.tsx index c242c30289..f0a54fd5a0 100644 --- a/app/src/components/channels/mcp/InstallDialog.tsx +++ b/app/src/components/channels/mcp/InstallDialog.tsx @@ -7,6 +7,7 @@ import debug from 'debug'; import { useCallback, useEffect, useRef, useState } from 'react'; +import { useT } from '../../../lib/i18n/I18nContext'; import { mcpClientsApi } from '../../../services/api/mcpClientsApi'; import type { InstalledServer, SmitheryServerDetail } from './types'; @@ -20,6 +21,7 @@ interface InstallDialogProps { } const InstallDialog = ({ qualifiedName, prefillEnv, onSuccess, onCancel }: InstallDialogProps) => { + const { t } = useT(); const [detail, setDetail] = useState(null); const [loadingDetail, setLoadingDetail] = useState(true); const [detailError, setDetailError] = useState(null); @@ -61,7 +63,7 @@ const InstallDialog = ({ qualifiedName, prefillEnv, onSuccess, onCancel }: Insta }) .catch(err => { if (latestQualifiedNameRef.current !== requestedName) return; - const msg = err instanceof Error ? err.message : 'Failed to load server details'; + const msg = err instanceof Error ? err.message : t('mcp.install.failedDetail'); log('detail error: %s', msg); setDetailError(msg); }) @@ -70,7 +72,7 @@ const InstallDialog = ({ qualifiedName, prefillEnv, onSuccess, onCancel }: Insta setLoadingDetail(false); } }); - }, [qualifiedName, prefillEnv]); + }, [qualifiedName, prefillEnv, t]); const toggleShowEnv = useCallback((key: string) => { setShowEnv(prev => ({ ...prev, [key]: !prev[key] })); @@ -86,7 +88,7 @@ const InstallDialog = ({ qualifiedName, prefillEnv, onSuccess, onCancel }: Insta // Validate required keys are filled. for (const key of detail.required_env_keys ?? []) { if (!envValues[key]?.trim()) { - setInstallError(`"${key}" is required`); + setInstallError(t('mcp.install.missingRequired').replace('{key}', key)); return; } } @@ -97,7 +99,7 @@ const InstallDialog = ({ qualifiedName, prefillEnv, onSuccess, onCancel }: Insta try { parsedConfig = JSON.parse(configJson.trim()); } catch { - setInstallError('Config JSON is not valid JSON'); + setInstallError(t('mcp.install.invalidJson')); return; } } @@ -115,18 +117,18 @@ const InstallDialog = ({ qualifiedName, prefillEnv, onSuccess, onCancel }: Insta log('install success server_id=%s', server.server_id); onSuccess(server); } catch (err) { - const msg = err instanceof Error ? err.message : 'Install failed'; + const msg = err instanceof Error ? err.message : t('mcp.install.failedInstall'); log('install error: %s', msg); setInstallError(msg); } finally { setInstalling(false); } - }, [detail, envValues, configJson, qualifiedName, onSuccess]); + }, [detail, envValues, configJson, qualifiedName, onSuccess, t]); if (loadingDetail) { return (
- Loading server details... + {t('mcp.install.loadingDetail')}
); } @@ -141,7 +143,7 @@ const InstallDialog = ({ qualifiedName, prefillEnv, onSuccess, onCancel }: Insta type="button" onClick={onCancel} className="text-sm text-stone-500 dark:text-neutral-400 hover:underline"> - Go back + {t('mcp.install.back')}
); @@ -166,7 +168,7 @@ const InstallDialog = ({ qualifiedName, prefillEnv, onSuccess, onCancel }: Insta )}

- Install {detail.display_name} + {t('mcp.install.title').replace('{name}', detail.display_name)}

{detail.description && (

@@ -180,7 +182,7 @@ const InstallDialog = ({ qualifiedName, prefillEnv, onSuccess, onCancel }: Insta {(detail.required_env_keys ?? []).length > 0 && (

- Required environment variables + {t('mcp.install.requiredEnv')}

{detail.required_env_keys!.map(key => (
@@ -195,7 +197,7 @@ const InstallDialog = ({ qualifiedName, prefillEnv, onSuccess, onCancel }: Insta type={showEnv[key] ? 'text' : 'password'} value={envValues[key] ?? ''} onChange={e => handleEnvChange(key, e.target.value)} - placeholder={`Enter ${key}`} + placeholder={t('mcp.install.enterValue').replace('{key}', key)} disabled={installing} className="flex-1 rounded-lg border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-1.5 text-sm text-stone-800 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 focus:outline-none focus:ring-2 focus:ring-primary-500/40 disabled:opacity-50" /> @@ -204,7 +206,7 @@ const InstallDialog = ({ qualifiedName, prefillEnv, onSuccess, onCancel }: Insta onClick={() => toggleShowEnv(key)} disabled={installing} className="shrink-0 rounded-lg border border-stone-200 dark:border-neutral-700 px-2 py-1 text-xs text-stone-500 dark:text-neutral-400 hover:border-stone-300 dark:hover:border-neutral-600 disabled:opacity-50"> - {showEnv[key] ? 'Hide' : 'Show'} + {showEnv[key] ? t('mcp.install.hide') : t('mcp.install.show')}
@@ -217,7 +219,7 @@ const InstallDialog = ({ qualifiedName, prefillEnv, onSuccess, onCancel }: Insta