diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73859289..dea69645 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -223,3 +223,180 @@ jobs: path: test-results/e2e-safe-receipt.json if-no-files-found: error retention-days: 7 + + linux: + name: Linux ${{ matrix.arch }} + strategy: + fail-fast: false + matrix: + include: + - arch: x64 + runner: ubuntu-24.04 + - arch: arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + steps: + - name: Check out source + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + + - name: Use Node.js 22 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 + with: + node-version: 22.22.3 + cache: npm + + - name: Install Linux test dependencies + run: sudo apt-get update && sudo apt-get install --yes rpm xvfb + + - name: Install locked dependencies + run: npm ci + + - name: Verify TypeScript and lint + run: npm run type-check && npm run lint + + - name: Run Pi extension, Linux contract, and native helper tests + run: npm run test:pi-extensions && npm run test:linux-contracts && npm run test:linux-native + + - name: Build, install, and verify Linux distributions + run: | + set -euo pipefail + npm run dist:linux + node scripts/verify-linux-package.mjs release/linux-distribution + sudo apt-get install --yes ./release/linux-distribution/*.deb + dpkg --verify aiden-agent + ! ldd "/opt/Aiden Agent/aiden-agent" | grep -q "not found" + test -f /usr/share/applications/com.sambitcreate.aiden-agent.desktop + grep -F 'StartupWMClass=com.sambitcreate.aiden-agent' /usr/share/applications/com.sambitcreate.aiden-agent.desktop + expected_version="$(node -p 'require("./package.json").version')" + test "$(aiden-agent --no-sandbox --version)" = "$expected_version" + chmod +x ./release/linux-distribution/*.AppImage + appimage_output="$(./release/linux-distribution/*.AppImage --appimage-extract-and-run --no-sandbox --version)" + grep -F "$expected_version" <<<"$appimage_output" + + - name: Prepare baseline-verified RPM for Fedora acceptance + if: matrix.arch == 'x64' + shell: bash + run: | + set -euo pipefail + shopt -s nullglob + rpm_files=(release/linux-distribution/*.rpm) + test "${#rpm_files[@]}" -eq 1 + rpm_name="$(basename "${rpm_files[0]}")" + ( + cd release/linux-distribution + sha256sum "$rpm_name" > rpm.sha256 + ) + + - name: Upload baseline-verified RPM for Fedora acceptance + if: matrix.arch == 'x64' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: linux-rpm-x64-${{ github.run_id }}-${{ github.run_attempt }} + path: | + release/linux-distribution/*.rpm + release/linux-distribution/rpm.sha256 + if-no-files-found: error + retention-days: 1 + + - name: Smoke the installed GUI without a keyring session + shell: bash + run: | + set -euo pipefail + smoke_root="$RUNNER_TEMP/aiden-linux-smoke-${{ matrix.arch }}" + mkdir -p "$smoke_root/home" "$smoke_root/config" "$smoke_root/cache" "$smoke_root/data" + set +e + HOME="$smoke_root/home" \ + XDG_CONFIG_HOME="$smoke_root/config" \ + XDG_CACHE_HOME="$smoke_root/cache" \ + XDG_DATA_HOME="$smoke_root/data" \ + timeout --signal=KILL 15s xvfb-run --auto-servernum aiden-agent --no-sandbox \ + >"$smoke_root/output.log" 2>&1 + status=$? + set -e + # A living GUI is the success condition. Kill the whole timeout + # process group instead of asking Electron to perform a production + # shutdown inside an incomplete headless desktop session. + if [[ "$status" -ne 137 ]]; then + cat "$smoke_root/output.log" + exit 1 + fi + if grep -Eiq '(FATAL|symbol lookup error|error while loading shared libraries|Failed to start Aiden Agent)' "$smoke_root/output.log"; then + cat "$smoke_root/output.log" + exit 1 + fi + + - name: Run deterministic Electron E2E gate + if: matrix.arch == 'x64' + run: xvfb-run --auto-servernum npm run test:e2e + + - name: Build sanitized E2E failure receipt + if: ${{ failure() && matrix.arch == 'x64' }} + run: npm run diagnostics:failure-receipt -- test-results/e2e-safe-receipt.json electron-e2e test-failed + + - name: Upload sanitized E2E failure receipt + if: ${{ failure() && matrix.arch == 'x64' }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: playwright-e2e-linux-${{ github.run_id }}-${{ github.run_attempt }} + path: test-results/e2e-safe-receipt.json + if-no-files-found: error + retention-days: 7 + + linux-rpm: + name: Linux x64 · Fedora RPM + needs: linux + runs-on: ubuntu-24.04 + container: fedora:44 + timeout-minutes: 60 + steps: + - name: Install Fedora build prerequisites + run: dnf install --assumeyes git gcc gcc-c++ make python3 + + - name: Check out source + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + + - name: Use Node.js 22 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 + with: + node-version: 22.22.3 + cache: npm + + - name: Install locked dependencies + run: npm ci + + - name: Verify Fedora Pi extensions, contracts, and native helpers + run: npm run type-check && npm run test:pi-extensions && npm run test:linux-contracts && npm run test:linux-native + + - name: Download baseline-verified RPM + uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e + with: + name: linux-rpm-x64-${{ github.run_id }}-${{ github.run_attempt }} + path: release/linux-rpm-smoke + + - name: Install and verify baseline-verified RPM package + run: | + set -euo pipefail + shopt -s nullglob + rpm_files=(release/linux-rpm-smoke/*.rpm) + test "${#rpm_files[@]}" -eq 1 + ( + cd release/linux-rpm-smoke + sha256sum --check rpm.sha256 + ) + expected_version="$(node -p 'require("./package.json").version')" + test "$(rpm -qp --queryformat '%{NAME} %{VERSION} %{ARCH}' "${rpm_files[0]}")" = \ + "aiden-agent $expected_version x86_64" + dnf install --assumeyes "${rpm_files[0]}" + node scripts/verify-linux-package.mjs "/opt/Aiden Agent" + rpm_verify_output="$(rpm --verify aiden-agent || true)" + if [ -n "$rpm_verify_output" ]; then + # electron-builder deliberately enables its setuid fallback when + # user namespaces do not work (including containerized CI). Keep + # every other RPM integrity difference fatal and prove the exact + # privileged file owner/mode before accepting that one transition. + test "$rpm_verify_output" = '.M....... /opt/Aiden Agent/chrome-sandbox' + test "$(stat -c '%a:%U:%G' '/opt/Aiden Agent/chrome-sandbox')" = '4755:root:root' + fi + ! ldd "/opt/Aiden Agent/aiden-agent" | grep -q "not found" + test "$(aiden-agent --no-sandbox --version)" = "$expected_version" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ab7a247c..82f1d00a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: Release macOS +name: Release desktop on: push: @@ -14,7 +14,7 @@ permissions: contents: write concurrency: - group: aiden-agent-macos-release + group: aiden-agent-desktop-release cancel-in-progress: false jobs: @@ -145,6 +145,171 @@ jobs: if: ${{ steps.version.outputs.publish == 'true' }} run: npm run test:e2e:diagnostics:packaged + - name: Stage verified macOS release assets + if: ${{ steps.version.outputs.publish == 'true' }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: release-macos + path: release/distribution + if-no-files-found: error + retention-days: 2 + + linux-release: + if: vars.RELEASES_ENABLED == 'true' + name: Linux ${{ matrix.arch }} release + strategy: + fail-fast: false + matrix: + include: + - arch: x64 + runner: ubuntu-24.04 + - arch: arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + timeout-minutes: 90 + environment: release + steps: + - name: Check out source + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + + - name: Use Node.js 22 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 + with: + node-version: 22.22.3 + cache: npm + + - name: Resolve the declared release version + id: version + shell: bash + run: | + set -euo pipefail + base_version="$(node -p "require('./package.json').version")" + base_tag_match="$(git ls-remote --tags origin "refs/tags/v${base_version}")" + base_tag_exists=false + if [[ -n "$base_tag_match" ]]; then + base_tag_exists=true + fi + selection="$(node scripts/prepare-ci-release.mjs "$base_tag_exists")" + release_version="$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).version)' "$selection")" + release_tag="$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).tag)' "$selection")" + should_publish="$(node -e 'process.stdout.write(String(JSON.parse(process.argv[1]).publish))' "$selection")" + echo "version=$release_version" >> "$GITHUB_OUTPUT" + echo "tag=$release_tag" >> "$GITHUB_OUTPUT" + echo "publish=$should_publish" >> "$GITHUB_OUTPUT" + + - name: Install Linux package dependencies + if: ${{ steps.version.outputs.publish == 'true' }} + run: sudo apt-get update && sudo apt-get install --yes rpm xvfb + + - name: Install locked dependencies + if: ${{ steps.version.outputs.publish == 'true' }} + run: npm ci + + - name: Verify Linux contracts, diagnostics, and native helpers + if: ${{ steps.version.outputs.publish == 'true' }} + run: npm run type-check && npm run lint && npm run test:diagnostics && npm run test:linux-contracts && npm run test:linux-native + + - name: Build and verify Linux distributions + if: ${{ steps.version.outputs.publish == 'true' }} + run: | + set -euo pipefail + npm run models:refresh + npm run dist:linux + node scripts/verify-linux-package.mjs release/linux-distribution + sudo apt-get install --yes ./release/linux-distribution/*.deb + dpkg --verify aiden-agent + ! ldd "/opt/Aiden Agent/aiden-agent" | grep -q "not found" + expected_version="$(node -p 'require("./package.json").version')" + test "$(aiden-agent --no-sandbox --version)" = "$expected_version" + chmod +x ./release/linux-distribution/*.AppImage + appimage_output="$(./release/linux-distribution/*.AppImage --appimage-extract-and-run --no-sandbox --version)" + grep -F "$expected_version" <<<"$appimage_output" + + - name: Smoke the exact release GUI without a keyring session + if: ${{ steps.version.outputs.publish == 'true' }} + shell: bash + run: | + set -euo pipefail + smoke_root="$RUNNER_TEMP/aiden-linux-release-smoke-${{ matrix.arch }}" + mkdir -p "$smoke_root/home" "$smoke_root/config" "$smoke_root/cache" "$smoke_root/data" + set +e + HOME="$smoke_root/home" \ + XDG_CONFIG_HOME="$smoke_root/config" \ + XDG_CACHE_HOME="$smoke_root/cache" \ + XDG_DATA_HOME="$smoke_root/data" \ + timeout --signal=KILL 15s xvfb-run --auto-servernum aiden-agent --no-sandbox \ + >"$smoke_root/output.log" 2>&1 + status=$? + set -e + # A living GUI is the success condition. Kill the whole timeout + # process group instead of asking Electron to perform a production + # shutdown inside an incomplete headless desktop session. + if [[ "$status" -ne 137 ]]; then + cat "$smoke_root/output.log" + exit 1 + fi + if grep -Eiq '(FATAL|symbol lookup error|error while loading shared libraries|Failed to start Aiden Agent)' "$smoke_root/output.log"; then + cat "$smoke_root/output.log" + exit 1 + fi + + - name: Stage verified Linux release assets + if: ${{ steps.version.outputs.publish == 'true' }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: release-linux-${{ matrix.arch }} + path: | + release/linux-distribution/*.AppImage + release/linux-distribution/*.deb + release/linux-distribution/*.rpm + if-no-files-found: error + retention-days: 2 + + publish: + if: vars.RELEASES_ENABLED == 'true' + name: Publish verified desktop release + needs: + - release + - linux-release + runs-on: ubuntu-24.04 + timeout-minutes: 20 + environment: release + steps: + - name: Check out source + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + + - name: Use Node.js 22 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 + with: + node-version: 22.22.3 + + - name: Resolve release identity + id: version + shell: bash + run: | + set -euo pipefail + base_version="$(node -p "require('./package.json').version")" + base_tag_match="$(git ls-remote --tags origin "refs/tags/v${base_version}")" + base_tag_exists=false + if [[ -n "$base_tag_match" ]]; then + base_tag_exists=true + fi + selection="$(node scripts/prepare-ci-release.mjs "$base_tag_exists")" + release_version="$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).version)' "$selection")" + release_tag="$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).tag)' "$selection")" + should_publish="$(node -e 'process.stdout.write(String(JSON.parse(process.argv[1]).publish))' "$selection")" + echo "version=$release_version" >> "$GITHUB_OUTPUT" + echo "tag=$release_tag" >> "$GITHUB_OUTPUT" + echo "publish=$should_publish" >> "$GITHUB_OUTPUT" + + - name: Download verified desktop assets + if: ${{ steps.version.outputs.publish == 'true' }} + uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e + with: + pattern: release-* + path: release/distribution + merge-multiple: true + - name: Publish verified release assets if: ${{ steps.version.outputs.publish == 'true' }} shell: bash @@ -152,6 +317,4 @@ jobs: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ steps.version.outputs.tag }} RELEASE_VERSION: ${{ steps.version.outputs.version }} - run: | - set -euo pipefail - bash scripts/publish-github-release.sh release/distribution + run: bash scripts/publish-github-release.sh release/distribution diff --git a/.papercuts/troubleshooting.md b/.papercuts/troubleshooting.md index 89a93140..df145f22 100644 --- a/.papercuts/troubleshooting.md +++ b/.papercuts/troubleshooting.md @@ -155,3 +155,24 @@ with a loopback Chromium debugging port, invoke the public action through the real preload bridge, use System Events to click the real native button by its exact accessibility label, and verify a post-start diagnostic event plus `uploadToServer: false`. + +## Linux desktop reconciliation + +- A long-lived platform branch can contain hundreds of duplicated feature commits while the platform port itself is one checkpoint. Preserve both histories in a merge commit, but build the result from the latest shared tree plus that checkpoint's intent; a normal textual merge lets stale parallel history overwrite newer features and multiplies conflicts. +- A Linux Electron artifact with native Node modules cannot be truthfully accepted from a macOS build host. Build and inspect it on native x64/arm64 Linux runners, then install the generated DEB/RPM and smoke the executable; cross-target metadata checks alone do not prove the active `node-pty`, Sherpa ONNX, or C-helper layout. +- Remote Access E2E must not assume Aiden's default private port is globally unused. Assert disabled state through the test process's own IPC snapshot, then derive the committed health-check port from that same installation after enablement so another running Aiden profile cannot create a false pass or failure. +- Native Linux acceptance can look green while exercising stale macOS-built outputs or skipping behind a Darwin-only guard. The Linux suite must build both production and test helpers, run the real adapter-to-helper boundary, and launch a freshly installed package from an empty XDG profile. +- A native generation token derived from `stat` is platform-shaped: Linux has no birth-time fields and emits seven components, while macOS emits nine. Validate those two exact wire shapes at the TypeScript boundary instead of assuming the macOS token or accepting every intermediate field count. +- Compiling native helpers on a new distro can silently raise the package's glibc floor even when the Electron shell still launches. Inspect every packaged helper, `.node`, and `.so` symbol table and fail packaging above the declared baseline; avoid C-library parsers whose symbol version was retargeted by newer libc headers when a bounded local parser is straightforward. +- Debian virtual packages can satisfy an unversioned `libasound2` dependency with an OSS compatibility shim that lacks Electron's required ALSA symbols. Use a versioned `libasound2t64 | libasound2` alternative so apt selects the real ALSA implementation on both time64 and older Debian families. +- The legacy arm64 AppImage launcher links against the unversioned development name `libz.so`, so a normal desktop with only `libz.so.1` silently fails before Electron starts. Pin electron-builder's static AppImage runtime and execute the release artifact on a clean target-architecture system; installing `zlib` development files would only hide the defect. +- A packaged dependency can retain Mach-O and PE files with a `.node` suffix beside the active Linux prebuild. Native compatibility verification must identify ELF magic before invoking `objdump`; the suffix alone is not an operating-system contract. +- macOS-to-Linux Docker source transfers can materialize AppleDouble `._*` metadata sidecars. Playwright must ignore those names explicitly or it will parse a `._*.spec.ts` binary sidecar as JavaScript even though the real tests and application build are healthy. +- A hermetic Electron E2E fixture can preserve `PATH` and locale yet still discard the display transport established by `xvfb-run`. On Linux, pass through only the X11/Wayland connection variables (`DISPLAY`, `XAUTHORITY`, `WAYLAND_DISPLAY`, `XDG_RUNTIME_DIR`, and `XDG_SESSION_TYPE`); when checking the child, account for the exact non-secret desktop/accessibility variables Chromium/GTK inject after launch while continuing to reject ambient credential variables. +- electron-builder's RPM post-install script changes `chrome-sandbox` from the archived `0755` mode to `4755` when user namespaces are unavailable, so a clean `rpm --verify` reports one expected mode difference in containers. Accept only that exact line after separately proving `4755:root:root`; any additional RPM verification output remains fatal. +- Fail-closed Linux secret storage must not make keyless local providers unusable. A keyless-to-keyless provider transition cannot expose or bind a secret, so save and portable-config reconciliation may bypass the secret backend for that exact case; any transition from or to a keyed connection still requires credential reconciliation. +- Electron's conventional Linux `window-all-closed` quit policy conflicts with an explicitly enabled background Remote Access listener. Let a synchronously observed running listener own application lifetime after the last window closes; normal Linux last-window close still quits when no background service is active, and an explicit Quit still performs the complete shutdown drain. +- A freshly authenticated Linux Tailscale daemon lets an unprivileged desktop app read status but rejects Serve writes until `sudo tailscale set --operator=$USER` is granted once. Recognize that exact CLI rejection only after confirming the Serve fingerprint stayed unchanged, then surface the remediation instead of collapsing it into an uncertain mutation. +- Probing a Linux Tailscale node's own MagicDNS HTTPS name from that same OrbStack VM can time out even while another tailnet peer reaches it immediately. Hold the scoped route open and probe from a separate peer when accepting the iOS network direction; always verify the temporary handler and listener are gone afterward. +- Darwin can deliver or coalesce a directory's already-queued creation notification after `fs.watch` registration. Before a watcher integration test acquires the lease whose invalidation it means to observe, establish a quiet baseline so a rapid follow-up edit is the event under test rather than a registration race. +- Playwright's `fill("")` and `clear()` use the same select-and-delete path as a manual text-clearing test, so swapping among those APIs does not avoid an intermittent Electron/Xvfb deletion miss. When the product benefits from it, expose an accessible clear action and exercise that real user path while retaining an exact empty-value assertion. diff --git a/AGENTS.md b/AGENTS.md index 65b7263e..94c8f0a4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ Text-entry controls must not add an accent border, outline, or ring when focused ## Release model metadata -models.dev may be contacted only by `npm run models:refresh`, the release refresh invoked by `npm run dist`, the scoped post-merge catalog workflow, or the user-initiated foreground **Update model catalogs** action in Settings → Providers. The live action may request only the fixed `https://models.dev/api.json` endpoint without credentials, cookies, prompts, chats, selections, custom endpoints, or a device identifier; its validated device-local cache is display-only and must never change runtime limits, routing, or selectable inventory. Never add a models.dev call to startup, normal development, unpacked builds, ordinary live-app reads, onboarding navigation, or background polling. Artificial Analysis data and credentials must never be bundled: the live Electron app may contact its fixed Free endpoint only after the user explicitly chooses Connect & fetch or Fetch latest with their own key, then reads the normalized device-local cache offline. +`npm run models:refresh` is the explicit development refresh, and `npm run dist` invokes the same release step before packaging. Those are the only paths that may contact models.dev. Never add a models.dev call to normal development, unpacked builds, or ordinary live-app reads. Artificial Analysis data and credentials must never be bundled: the live Electron app may contact its fixed Free endpoint only after the user explicitly chooses Connect & fetch or Fetch latest with their own key, then reads the normalized device-local cache offline. OpenRouter benchmark insights are also manual-only. The live app may contact only the fixed `/api/v1/benchmarks?source=artificial-analysis&max_results=100` endpoint after the user explicitly chooses Connect & fetch or Fetch latest, using the dedicated encrypted Model Pad credential rather than any inference-provider credential. Never send prompts or model traffic during that action, never import OpenRouter's model catalog, never bundle the returned data, and serve ordinary model-info reads only from the normalized device-local cache. diff --git a/README.md b/README.md index a7fd8ec1..ba04ad42 100644 --- a/README.md +++ b/README.md @@ -5,18 +5,22 @@

- A native-feeling macOS workspace for chatting with local or hosted AI models and safely working inside the folders you choose. + A native-feeling desktop workspace for chatting with local or hosted AI models and safely working inside the folders you choose.

```sh brew install --cask sambitcreate/tap/aiden-agent ``` +Linux x64 and arm64 downloads are available as AppImage, `.deb`, and `.rpm` +packages from [GitHub Releases](https://github.com/sambitcreate/aiden-agent/releases). +See the [Linux install and compatibility guide](docs/linux.md). + ![Aiden Agent showing a workspace chat and Personal Model Pad](docs/assets/aiden-agent-app.png) ## Why Aiden -I don't come from a coding background. I'd been bouncing between the coding agents that exist, and each one had a piece of what I wanted without any of them being the whole thing. I loved **Codex** for its restrained, lovely desktop UX, **Opencode** for letting me bring whatever model and provider I wanted and also looking great in both the terminal and desktop, and **Cursor** for its nimbleness and UI, and I used **Claude Code** for the models lol. But the one terminal agent kept coming back to was **Pi**, by **Mario Zechner**, for the plugin system I could shape to my own workflow. What **Pi** lacked was a GUI, and I wanted the extensibility with a real interface on top of it. The first version was a native **SwiftUI** app, but within two weeks it was clear that building a coding agent inside **SwiftUI** was the wrong fight for someone who doesn't already write code, so the project pivoted to **Electron**. I was playing around with **Glaze**, **Raycast**'s AI app maker. I figured, let me just recreate Aiden in Glaze, ran out of credits inside an hour, used **Codex** to grab the code out of Glaze, and a week later this is what happened. **Aiden** runs on the **Pi** agent runtime and gives it a Mac-native workspace. +I don't come from a coding background. I'd been bouncing between the coding agents that exist, and each one had a piece of what I wanted without any of them being the whole thing. I loved **Codex** for its restrained, lovely desktop UX, **Opencode** for letting me bring whatever model and provider I wanted and also looking great in both the terminal and desktop, and **Cursor** for its nimbleness and UI, and I used **Claude Code** for the models lol. But the one terminal agent kept coming back to was **Pi**, by **Mario Zechner**, for the plugin system I could shape to my own workflow. What **Pi** lacked was a GUI, and I wanted the extensibility with a real interface on top of it. The first version was a native **SwiftUI** app, but within two weeks it was clear that building a coding agent inside **SwiftUI** was the wrong fight for someone who doesn't already write code, so the project pivoted to **Electron**. I was playing around with **Glaze**, **Raycast**'s AI app maker. I figured, let me just recreate Aiden in Glaze, ran out of credits inside an hour, used **Codex** to grab the code out of Glaze, and a week later this is what happened. **Aiden** runs on the **Pi** agent runtime and gives it a native-feeling desktop workspace. ## Features @@ -24,10 +28,12 @@ I don't come from a coding background. I'd been bouncing between the coding agen - **Command palette and shortcuts** - `⌘K` searches commands, chats, models, providers, Settings, and appearance actions. One typed command system also powers native menus, visible shortcut labels, transactional global hotkeys, and the searchable Keyboard Shortcuts editor. - **Commands and explicit skills** - type `/` at the start of the composer to search Aiden app commands, or `$` to search the active workspace's available skills. Commands reuse canonical app workflows; an explicitly selected skill is revalidated for the active workspace, applies to one accepted message, and persists only safe display provenance. - **Native Subagents** - a foreground chat can delegate up to four fresh `scout`, `planner`, or `reviewer` tasks. Children are read/search-only, inherit the approved workspace and model, stop with the parent, and appear as live chips plus an inspectable **Subagents** view in Environment. +- **Bots and Web Search** - macOS can create durable Bots with explicit capability grants, persistent conversations, image understanding, and Telegram control. Every desktop can configure explicit Web Search routes from the expanded provider catalog; Bots remain hidden on Linux until their native security bindings are supported there. - **Workspaces and managed worktrees** - use folders, scratch workspaces, or isolated managed worktrees with three access levels, workspace-scoped tools, Ask-mode approvals, guarded creation/deletion, and crash-aware cleanup. - **Models and the Model Pad** - choose from Pi's native hosted-provider catalog, local Ollama or LM Studio models, and declarative compatible endpoints. Arrange a personal capability-and-pace map, optionally enrich hosted models with explicitly fetched Artificial Analysis scores through a benchmark-only OpenRouter key, and keep benchmark evidence visibly separate from runtime limits and availability. - **Terminal, Git, and review** - keep a terminal drawer beside the conversation, inspect files and diffs in Environment, edit with dirty-file protection, compare branches, commit or push checked snapshots, and open the workspace in a discovered external editor. -- **macOS integration and appearance** - native menus, **Keychain**, **Parakeet**, the dictation pill, Apple **Foundation Models**, the signed **Rust** Computer Use broker, semantic themes, high contrast, reduced motion, and consistent light/dark rendering. +- **Rich responses and local diagnostics** - render sandboxed HTML, chart, math, and raster artifacts with recovery/export controls, and inspect or export bounded local diagnostics without an automatic upload path. +- **Desktop integration and appearance** - native menus, encrypted system credential storage, **Parakeet**, the dictation pill, semantic themes, high contrast, reduced motion, and consistent light/dark rendering. Apple **Foundation Models**, Accessibility auto-paste, and the signed **Rust** Computer Use broker remain macOS-only. - **Extensibility and background work** - use skills, **MCP**, **Exa** search, scheduled tasks, voice, and attachments through typed, allowlisted boundaries. - **Aiden On The Go** - opt in to a pinned local-network connection or an explicit non-Funnel Tailscale Serve route, pair each iPhone or iPad separately, and revoke devices from [Remote Access settings](docs/aiden-on-the-go-remote-access.md). - **Updates and release safety** - signed builds use the verified GitHub release feed. Once an update is downloaded, Aiden shows the version above Profile with **Later** and **Restart now**, then follows the normal save and shutdown guards before relaunching. @@ -45,11 +51,11 @@ The roadmap is maintained in [the plan index](docs/plans/README.md). These bulle ## Privacy and trust -Aiden stores chats, settings, workspace metadata, and downloaded speech models locally. Provider credentials and MCP OAuth sessions are encrypted with macOS secure storage. The renderer is sandboxed, has no direct Node.js access, and communicates with Electron through an allowlisted bridge. +Aiden stores chats, settings, workspace metadata, and downloaded speech models locally. Provider credentials and MCP OAuth sessions use the operating system's encrypted credential storage. Linux refuses to store secrets if only Electron's reversible `basic_text` backend is available. The renderer is sandboxed, has no direct Node.js access, and communicates with Electron through an allowlisted bridge. Network access happens only when the selected feature needs it: hosted models receive the conversation content sent to them, cloud transcription receives selected audio, Exa receives search queries, remote MCP servers receive tool requests, and model downloads contact their upstream host. A fully local session can use a local model, on-device voice, no remote MCP servers, and web search disabled. -Computer Use is an opt-in beta with a global switch, a separate per-chat switch, macOS permission checks, exact target binding, and one-use approval for every mutation. See the [Computer Use security design](docs/computer-use-integration.md) for the complete boundary. +On macOS, Computer Use is an opt-in beta with a global switch, a separate per-chat switch, permission checks, exact target binding, and one-use approval for every mutation. It is omitted from Linux builds. See the [Computer Use security design](docs/computer-use-integration.md) for the complete boundary. ## Architecture @@ -65,7 +71,7 @@ Electron main process ├── workspace-scoped tools, Git, review, and terminal ├── encrypted credentials and local JSON stores ├── MCP, attachments, search, and voice - └── signed native helpers for Apple models and Computer Use + └── hardened native helpers, plus macOS-only Apple model and Computer Use helpers ``` Core technologies include Electron 43, React 19, TypeScript, Vite, Tailwind CSS, TanStack Router and Query, Radix UI, the Pi agent runtime, Swift, and Rust. @@ -74,11 +80,11 @@ Core technologies include Electron 43, React 19, TypeScript, Vite, Tailwind CSS, ### Requirements -- macOS +- macOS or a glibc 2.34+ x64/arm64 Linux desktop - Node.js 22.19 or newer and npm -- Rust and Cargo -- A full Xcode 26 or newer for the Apple Foundation Models helper -- An Apple Development or Developer ID Application identity for packaged builds +- Rust and Cargo only for macOS Computer Use helper tests +- A full Xcode 26 or newer only for macOS Apple Foundation Models builds +- An Apple signing identity only for packaged macOS distribution builds ### Run locally @@ -93,7 +99,7 @@ The native Aiden On The Go iPhone and iPad client lives in [`ios/`](ios/README.m The iPhone and iPad app is distributed through **TestFlight only**. GitHub releases do not publish an IPA; [`ios/README.md`](ios/README.md) documents local development and device validation. Android validation builds remain separate from the macOS release and are uploaded by CI as installable APK artifacts. -The development launcher prepares a cached, ad-hoc-signed **Aiden Agent Dev** runtime that can run beside the installed **Aiden Agent** app. Development uses separate Application Support, Chromium session, log, crash, and `~/.aiden-dev` roots; it does not copy production data, register global shortcuts, or check the production update feed by default. Set `AIDEN_DEV_GLOBAL_SHORTCUTS=1` only when a development run intentionally needs the global bindings. +The development launcher prepares a platform-appropriate **Aiden Agent Dev** runtime that can run beside the installed **Aiden Agent** app. Development uses separate app-data, Chromium session, log, crash, and `~/.aiden-dev` roots; it does not copy production data, register global shortcuts, or check the production update feed by default. Set `AIDEN_DEV_GLOBAL_SHORTCUTS=1` only when a development run intentionally needs the global bindings. Native builds discover the newest compatible full Xcode without changing the machine-wide `xcode-select` setting; `DEVELOPER_DIR` remains available as a per-command override. @@ -114,13 +120,17 @@ npm run package npm run package:verify ``` -Distribution builds use `npm run dist` and require Developer ID signing plus notarization. The release pipeline fails closed, verifies the app, DMG, and ZIP, checks the deployed Homebrew and website consumers, and publishes updater metadata only with the matching verified artifacts. Read [macOS releases and automatic updates](docs/releasing.md) before enabling publication. +On Linux, use `npm run package:linux` and `npm run package:linux:verify` for an +unpacked development package, or `npm run dist:linux` for AppImage, Debian, and +RPM artifacts. Linux packages are built on their target architecture. + +Distribution builds use `npm run dist`; macOS artifacts require Developer ID signing plus notarization. The release pipeline fails closed, verifies the platform artifacts, checks the deployed Homebrew and website consumers, and publishes updater metadata only with the matching verified artifacts. Read [macOS releases and automatic updates](docs/releasing.md) before enabling publication. -The checked-in models.dev snapshot is refreshed only through `npm run models:refresh` or the guarded distribution path. Artificial Analysis credentials and data are never bundled. Direct Artificial Analysis suggestions require an explicit user fetch. OpenRouter benchmark insights use a separate encrypted Model Pad key only after an explicit Connect & fetch or Fetch latest action, then read normalized public scores from a device-local offline cache. That key never configures an inference provider or imports OpenRouter's model catalog. +The checked-in models.dev snapshot is refreshed only through `npm run models:refresh` or the guarded distribution path; Linux and macOS packaging invoke that explicit release step. Artificial Analysis credentials and data are never bundled. OpenRouter benchmark insights use a separate encrypted Model Pad key only after an explicit Connect & fetch or Fetch latest action, then read normalized public scores from a device-local offline cache. That key never configures an inference provider or imports OpenRouter's model catalog. ## Project status -Aiden Agent is a beta macOS release. Signed DMG and ZIP builds, checksums, and automatic-update metadata are published through [GitHub Releases](https://github.com/sambitcreate/aiden-agent/releases). The release workflow is fail-closed: it verifies signing, notarization, package contents, updater metadata, and version monotonicity before publishing. See [the release guide](docs/releasing.md) for the complete process. +Aiden Agent is a beta desktop release for macOS and Linux. Signed macOS DMG/ZIP builds and Linux AppImage/DEB/RPM builds for x64 and arm64 are published with checksums through [GitHub Releases](https://github.com/sambitcreate/aiden-agent/releases). The release workflow verifies both platform sets before publication. macOS keeps automatic updates; Linux uses explicit package replacement. See [the macOS release guide](docs/releasing.md) and [Linux guide](docs/linux.md). The canonical website download is the stable [`Aiden-Agent-Beta-arm64.dmg`](https://github.com/sambitcreate/aiden-agent/releases/latest/download/Aiden-Agent-Beta-arm64.dmg) diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/config/AidenVoiceInput.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/config/AidenVoiceInput.kt index 231afd2d..0568a854 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/config/AidenVoiceInput.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/config/AidenVoiceInput.kt @@ -7,7 +7,7 @@ import kotlinx.coroutines.flow.asStateFlow enum class AidenVoiceInputMode(val wireValue: String, val title: String) { ON_DEVICE("on-device", "On this device"), - PAIRED_MAC("paired-mac", "Paired Mac"); + PAIRED_MAC("paired-mac", "Paired desktop"); companion object { fun fromWireValue(value: String?): AidenVoiceInputMode = diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotCustomAccessFlowScreen.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotCustomAccessFlowScreen.kt index c294c481..96082fbe 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotCustomAccessFlowScreen.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotCustomAccessFlowScreen.kt @@ -184,7 +184,7 @@ fun AidenBotCustomAccessFlowScreen( val freshCat = cl.botCapabilityCatalog(detail.id) selectedBotDetail = fresh catalog = freshCat - saveError = "Access policy was changed on your Mac. Review the latest policy and try again." + saveError = "Access policy was changed on your paired desktop. Review the latest policy and try again." } catch (_: Exception) { saveError = e.message ?: "Conflict updating access" } @@ -305,7 +305,7 @@ fun AidenBotCustomAccessFlowScreen( // File Scopes Section item { - Text("Mac Files", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, color = palette.secondary) + Text("Desktop Files", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, color = palette.secondary) Spacer(modifier = Modifier.height(6.dp)) Card( modifier = Modifier.fillMaxWidth(), @@ -370,7 +370,7 @@ fun AidenBotCustomAccessFlowScreen( ) { Column(modifier = Modifier.weight(1f)) { Text("Execute Shell Commands", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, color = palette.foreground) - Text("Allows bot to run terminal commands on Mac", style = MaterialTheme.typography.bodySmall, color = palette.secondary) + Text("Allows bot to run terminal commands on the paired desktop", style = MaterialTheme.typography.bodySmall, color = palette.secondary) } Switch( checked = curDraft.shellEnabled, diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotEditorScreen.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotEditorScreen.kt index e9cfb74d..2164cc2e 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotEditorScreen.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotEditorScreen.kt @@ -703,7 +703,7 @@ fun AidenBotEditorScreen( Divider(color = palette.canvas) // File scopes - Text("Mac File Scopes", style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, color = palette.foreground) + Text("Desktop File Scopes", style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, color = palette.foreground) currentCat.fileScopes.forEach { scopeItem -> Row( verticalAlignment = Alignment.CenterVertically, diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotGeneratedAvatarLifecycle.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotGeneratedAvatarLifecycle.kt index 84736a9d..c3defe98 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotGeneratedAvatarLifecycle.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotGeneratedAvatarLifecycle.kt @@ -38,7 +38,7 @@ sealed class AidenBotGeneratedAvatarError(val messageText: String) : Exception(m object SourceTooLarge : AidenBotGeneratedAvatarError("That image is too large. Choose another image.") object UnsupportedImage : AidenBotGeneratedAvatarError("That image format can't be used for a Bot photo.") object InvalidImage : AidenBotGeneratedAvatarError("Aiden couldn't prepare that image. Choose another image.") - object Unavailable : AidenBotGeneratedAvatarError("Reconnect to your Mac before saving this Bot photo.") + object Unavailable : AidenBotGeneratedAvatarError("Reconnect to your paired desktop before saving this Bot photo.") } enum class AidenBotGeneratedAvatarPhase { diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotImagePlaygroundView.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotImagePlaygroundView.kt index 9e70b179..4f85d898 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotImagePlaygroundView.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotImagePlaygroundView.kt @@ -232,7 +232,7 @@ fun AidenBotImagePlaygroundSheet( } Spacer(modifier = Modifier.height(8.dp)) Text( - text = "Bot photos generated on macOS can be synchronized to Android. You can also customize your Bot with the built-in Semantic Avatar studio.", + text = "Bot photos generated on a supported Apple device can be synchronized to Android. You can also customize your Bot with the built-in Semantic Avatar studio.", style = MaterialTheme.typography.bodySmall, color = palette.secondary ) diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotsHomeScreen.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotsHomeScreen.kt index 4102b800..dacfca8a 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotsHomeScreen.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotsHomeScreen.kt @@ -187,7 +187,7 @@ fun aidenBotInboxActivityStatus( if (canRespondToApproval) { AidenBotInboxActivityStatus("Approval needed", "verified_user") } else { - AidenBotInboxActivityStatus("Waiting for approval on Mac", "computer") + AidenBotInboxActivityStatus("Waiting for desktop approval", "computer") } } AidenBotConversationActivityState.RECONCILING -> AidenBotInboxActivityStatus("Updating", "sync") @@ -435,7 +435,7 @@ fun AidenBotsHomeScreen( AidenEmptyState( icon = Icons.Default.WifiOff, title = "Bots couldn’t load", - body = errorMessage ?: "Reconnect to your Mac and try again.", + body = errorMessage ?: "Reconnect to your paired desktop and try again.", modifier = Modifier.padding(top = 36.dp), action = { Button( @@ -456,7 +456,7 @@ fun AidenBotsHomeScreen( body = if (connectionState == AidenConnectionState.CONNECTED) "Create a familiar helper with one persistent conversation and its own capabilities." else - "Reconnect to your Mac to load Bots.", + "Reconnect to your paired desktop to load Bots.", modifier = Modifier.padding(top = 36.dp), action = if (connectionState == AidenConnectionState.CONNECTED) { { diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/chat/AidenChatDetailScreen.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/chat/AidenChatDetailScreen.kt index 896a8d8d..79d2570a 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/chat/AidenChatDetailScreen.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/chat/AidenChatDetailScreen.kt @@ -308,7 +308,7 @@ fun AidenChatDetailScreen( ) { pendingApproval?.let { approval -> val isAutomation = AidenApprovalPresentation.isAutomation(approval.toolName) - val requiresMacConfirmation = AidenApprovalPresentation.requiresMacConfirmation(approval) + val requiresDesktopConfirmation = AidenApprovalPresentation.requiresDesktopConfirmation(approval) Card( modifier = Modifier .fillMaxWidth() @@ -353,10 +353,10 @@ fun AidenChatDetailScreen( style = MaterialTheme.typography.bodySmall, color = palette.secondary ) - } else if (requiresMacConfirmation) { + } else if (requiresDesktopConfirmation) { Spacer(modifier = Modifier.height(8.dp)) Text( - text = "Review the full unattended access scope and confirm in Aiden on your Mac. You can deny it here.", + text = "Review the full unattended access scope and confirm in Aiden on your paired desktop. You can deny it here.", style = MaterialTheme.typography.bodySmall, color = palette.secondary ) diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/chat/AidenChatViewModel.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/chat/AidenChatViewModel.kt index cb0e626d..6fa62aa8 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/chat/AidenChatViewModel.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/chat/AidenChatViewModel.kt @@ -689,10 +689,10 @@ class AidenChatViewModel( if (!capabilities.canWriteSchedules) { "Schedule write access is required to approve this task." } else { - "Confirm this automation in Aiden on your Mac after reviewing its full access scope." + "Confirm this automation in Aiden on your paired desktop after reviewing its full access scope." } } else { - "This action must be confirmed in Aiden on your Mac." + "This action must be confirmed in the Aiden desktop app." } return } diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/chat/ComposerVoiceInputController.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/chat/ComposerVoiceInputController.kt index b7de26fb..cb529c72 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/chat/ComposerVoiceInputController.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/chat/ComposerVoiceInputController.kt @@ -198,7 +198,7 @@ class ComposerVoiceInputController(private val context: Context) { private fun startMac(client: AidenRemoteClient?, session: Long) { if (client == null) { - fail("Connect to your paired Mac before using Mac transcription.", session, AidenDiagnosticCode.NETWORK) + fail("Connect to your paired desktop before using desktop transcription.", session, AidenDiagnosticCode.NETWORK) return } state = ComposerVoiceInputState.PREPARING @@ -208,18 +208,18 @@ class ComposerVoiceInputController(private val context: Context) { val status = client.speechStatus() ensureActive() if (!isCurrent(session)) return@launch - if (!status.engine.ready) throw IllegalStateException(status.engine.error ?: "The Mac speech engine is unavailable.") + if (!status.engine.ready) throw IllegalStateException(status.engine.error ?: "The desktop speech engine is unavailable.") val selected = status.models.firstOrNull { it.id == status.selectedModelId && it.installed } ?: status.models.firstOrNull { it.installed && it.recommended } ?: status.models.firstOrNull { it.installed } - ?: throw IllegalStateException("Download a Mac speech model in Settings before using this option.") + ?: throw IllegalStateException("Download a desktop speech model in Settings before using this option.") if (status.selectedModelId != selected.id) client.selectSpeechModel(selected.id) ensureActive() if (!isCurrent(session)) return@launch activeModelId = selected.id beginMacRecording(session) } catch (error: Exception) { - if (isCurrent(session)) fail(error.message ?: "Mac transcription is unavailable.", session, AidenDiagnosticCode.NETWORK) + if (isCurrent(session)) fail(error.message ?: "Desktop transcription is unavailable.", session, AidenDiagnosticCode.NETWORK) } finally { if (isCurrent(session)) preparationJob = null } @@ -294,7 +294,7 @@ class ComposerVoiceInputController(private val context: Context) { val client = activeClient val modelId = activeModelId if (client == null || modelId == null) { - fail("Mac transcription stopped because the connection changed.", session, AidenDiagnosticCode.NETWORK) + fail("Desktop transcription stopped because the connection changed.", session, AidenDiagnosticCode.NETWORK) return } state = ComposerVoiceInputState.TRANSCRIBING @@ -307,7 +307,7 @@ class ComposerVoiceInputController(private val context: Context) { updateTranscript(result.text, session) clearSession(session) } catch (error: Exception) { - if (isCurrent(session)) fail(error.message ?: "The Mac could not transcribe this recording.", session, AidenDiagnosticCode.NETWORK) + if (isCurrent(session)) fail(error.message ?: "The paired desktop could not transcribe this recording.", session, AidenDiagnosticCode.NETWORK) } finally { if (isCurrent(session)) transcriptionJob = null } diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenBotChatToolsView.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenBotChatToolsView.kt index 14f786b1..60c966bb 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenBotChatToolsView.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenBotChatToolsView.kt @@ -201,7 +201,7 @@ class AidenBotChatToolsModel( fun readOnlyMessage(connected: Boolean, canWriteBots: Boolean, hostAllowsMutations: Boolean): String? { if (bot?.health == AidenBotHealth.ARCHIVED) return "Archived bots are read-only until restored." if (bot?.health == AidenBotHealth.DEGRADED || bot?.health == AidenBotHealth.UNAVAILABLE) { - return "This bot's access needs repair on your Mac before it can work." + return "This bot's access needs repair on your paired desktop before it can work." } if (!connected) return "Offline — reconnect to change this chat's access." if (!canWriteBots) return "This phone can view Bot access but is not approved to change it." diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenPairingScreen.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenPairingScreen.kt index ab75319a..20db885e 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenPairingScreen.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenPairingScreen.kt @@ -79,7 +79,7 @@ fun AidenPairingScreen( Scaffold( topBar = { TopAppBar( - title = { Text("Paired Macs", fontWeight = FontWeight.Bold) }, + title = { Text("Paired desktops", fontWeight = FontWeight.Bold) }, navigationIcon = { IconButton(onClick = onDismiss) { Icon(Icons.Default.Close, contentDescription = "Close", tint = palette.foreground) @@ -183,7 +183,7 @@ fun AidenPairingScreen( // Pair New Mac Section Text( - text = "Pair New Mac", + text = "Pair New Desktop", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, color = palette.secondary @@ -299,7 +299,7 @@ fun AidenPairingScreen( ) { CircularProgressIndicator(color = palette.accent, modifier = Modifier.size(20.dp)) Spacer(modifier = Modifier.width(8.dp)) - Text("Pairing with Mac...", style = MaterialTheme.typography.bodyMedium, color = palette.foreground) + Text("Pairing with desktop...", style = MaterialTheme.typography.bodyMedium, color = palette.foreground) } } } @@ -325,7 +325,7 @@ fun AidenPairingScreen( colors = sbtbiswas.AidenOnTheGo.ui.theme.aidenTextFieldColors(), value = endpointUrl, onValueChange = { endpointUrl = it }, - label = { Text("Mac Address (HTTPS Endpoint)") }, + label = { Text("Desktop Address (HTTPS Endpoint)") }, singleLine = true, shape = RoundedCornerShape(12.dp), modifier = Modifier.fillMaxWidth() @@ -417,7 +417,7 @@ fun AidenPairingScreen( onDismissRequest = { installationPendingRemoval = null }, title = { Text("Remove ${installation.name}?") }, text = { - Text("This removes the pairing credential and all cached chats, Bots, usage, drafts, and workspace data for this Mac from this device.") + Text("This removes the pairing credential and all cached chats, Bots, usage, drafts, and workspace data for this desktop from this device.") }, confirmButton = { TextButton( diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenQRCodeScanner.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenQRCodeScanner.kt index 061ae1fc..e4c392ff 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenQRCodeScanner.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenQRCodeScanner.kt @@ -89,7 +89,7 @@ fun AidenQRCodeScanner( ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = "Point your camera at the QR code displayed in Aiden on your Mac to pair instantly.", + text = "Point your camera at the QR code displayed in Aiden on your desktop to pair instantly.", style = MaterialTheme.typography.bodySmall, color = palette.secondary, textAlign = TextAlign.Center diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/scheduled/AidenScheduledTasksScreen.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/scheduled/AidenScheduledTasksScreen.kt index 4f3472ae..8e814f95 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/scheduled/AidenScheduledTasksScreen.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/scheduled/AidenScheduledTasksScreen.kt @@ -290,7 +290,7 @@ fun AidenScheduledTasksScreen( refresh() loadRuns(selectedTask.id) } catch (_: CancellationException) { - // Cancellation does not prove whether the Mac accepted the run. + // Cancellation does not prove whether the paired desktop accepted the run. } catch (error: Exception) { pendingRunKeys.failed(selectedTask.id, error) if (isCurrentRequest(activeClient, AidenRemoteCapability.SCHEDULE_WRITE)) { @@ -408,7 +408,7 @@ private fun AidenScheduledTaskList( ) Spacer(Modifier.height(5.dp)) Text( - "Aiden shows a permission review before saving unattended work. If a proposal can't be fully reviewed here, Aiden asks you to confirm it on your Mac.", + "Aiden shows a permission review before saving unattended work. If a proposal can't be fully reviewed here, Aiden asks you to confirm it on your paired desktop.", style = MaterialTheme.typography.bodySmall, color = palette.secondary ) @@ -684,7 +684,7 @@ private fun AidenScheduledTaskDetail( ) { Text("Delete automation") } if (!isConnected) { - Text("Connect to your Mac to run or change this task.", style = MaterialTheme.typography.bodySmall, color = palette.secondary) + Text("Connect to your paired desktop to run or change this task.", style = MaterialTheme.typography.bodySmall, color = palette.secondary) } else if (!canManage) { Text("This paired device has read-only scheduled task access.", style = MaterialTheme.typography.bodySmall, color = palette.secondary) } diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/settings/AidenAppearanceSettingsScreen.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/settings/AidenAppearanceSettingsScreen.kt index 30fbd41a..912e117c 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/settings/AidenAppearanceSettingsScreen.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/settings/AidenAppearanceSettingsScreen.kt @@ -52,14 +52,14 @@ fun AidenAppearanceSettingsScreen( } runCatching { client.speechStatus() } .onSuccess { speechStatus = it; speechError = null } - .onFailure { speechError = it.message ?: "Mac transcription is unavailable." } + .onFailure { speechError = it.message ?: "Desktop transcription is unavailable." } } fun runSpeechAction(action: suspend () -> AidenSpeechStatus) { scope.launch { runCatching { action() } .onSuccess { speechStatus = it; speechError = null } - .onFailure { speechError = it.message ?: "Mac transcription is unavailable." } + .onFailure { speechError = it.message ?: "Desktop transcription is unavailable." } } } @@ -100,7 +100,7 @@ fun AidenAppearanceSettingsScreen( Spacer(Modifier.width(12.dp)) Column(Modifier.weight(1f)) { Text("Installations", style = MaterialTheme.typography.titleMedium, color = palette.foreground) - Text("Pair or switch your Aiden Agent Mac", style = MaterialTheme.typography.bodySmall, color = palette.secondary) + Text("Pair or switch your Aiden Agent desktop", style = MaterialTheme.typography.bodySmall, color = palette.secondary) } } } @@ -115,7 +115,7 @@ fun AidenAppearanceSettingsScreen( ) Spacer(Modifier.height(8.dp)) Text( - text = "Choose where speech is transcribed. Paired Mac sends microphone audio over Aiden's encrypted pinned connection and does not retain it.", + text = "Choose where speech is transcribed. Paired desktop sends microphone audio over Aiden's encrypted pinned connection and does not retain it.", style = MaterialTheme.typography.bodySmall, color = palette.secondary ) @@ -131,7 +131,7 @@ fun AidenAppearanceSettingsScreen( Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) { Text(mode.title, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, color = palette.foreground) Text( - if (mode == AidenVoiceInputMode.ON_DEVICE) "Android SpeechRecognizer; speech stays on this device." else "Parakeet on your connected Aiden Agent Mac; final text appears after you stop.", + if (mode == AidenVoiceInputMode.ON_DEVICE) "Android SpeechRecognizer; speech stays on this device." else "Parakeet on your connected Aiden Agent desktop; final text appears after you stop.", style = MaterialTheme.typography.bodySmall, color = palette.secondary ) @@ -167,7 +167,7 @@ fun AidenAppearanceSettingsScreen( } else { val status = speechStatus if (remoteClient == null) { - Text("Connect to a paired Mac to configure transcription.", style = MaterialTheme.typography.bodySmall, color = palette.warning) + Text("Connect to a paired desktop to configure transcription.", style = MaterialTheme.typography.bodySmall, color = palette.warning) } else if (status == null && speechError == null) { LinearProgressIndicator(Modifier.fillMaxWidth()) } else if (status != null) { diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/workspaces/AidenUsageSheet.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/workspaces/AidenUsageSheet.kt index 57791ad4..d7774951 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/workspaces/AidenUsageSheet.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/workspaces/AidenUsageSheet.kt @@ -232,7 +232,7 @@ fun AidenUsageSheet( Icon(Icons.Default.Shield, null, tint = palette.accent, modifier = Modifier.size(28.dp)) Spacer(Modifier.width(12.dp)) Text( - "Privacy-safe aggregates are recorded by Aiden Agent on your Mac. Prompts, responses, chat IDs, workspace IDs, and file paths are not included.", + "Privacy-safe aggregates are recorded by Aiden Agent on your paired desktop. Prompts, responses, chat IDs, workspace IDs, and file paths are not included.", style = MaterialTheme.typography.bodySmall, color = palette.secondary ) diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/workspaces/AidenWorkspaceEnvironmentScreen.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/workspaces/AidenWorkspaceEnvironmentScreen.kt index d2bc004a..2363f26e 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/workspaces/AidenWorkspaceEnvironmentScreen.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/workspaces/AidenWorkspaceEnvironmentScreen.kt @@ -458,7 +458,7 @@ fun AidenWorkspaceEnvironmentScreen( onDismissRequest = { showConflictDialog = false }, title = { Text("Conflict Detected", fontWeight = FontWeight.Bold) }, text = { - Text("This file on your Mac was modified since you opened it. Would you like to reload the latest version from your Mac?") + Text("This file on your paired desktop was modified since you opened it. Would you like to reload the latest version from your desktop?") }, confirmButton = { Button( @@ -478,7 +478,7 @@ fun AidenWorkspaceEnvironmentScreen( }, colors = ButtonDefaults.buttonColors(containerColor = palette.accent) ) { - Text("Reload from Mac", color = Color.White) + Text("Reload from desktop", color = Color.White) } }, dismissButton = { diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/workspaces/AidenWorkspaceHomeScreen.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/workspaces/AidenWorkspaceHomeScreen.kt index 635d47e3..95c4d9fd 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/workspaces/AidenWorkspaceHomeScreen.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/workspaces/AidenWorkspaceHomeScreen.kt @@ -287,7 +287,7 @@ private fun AidenWorkspaceHome( viewModel.load(force = true) scope.launch { snackbarHostState.showSnackbar( - usageErrorMessage ?: "Loading Usage from your Mac…" + usageErrorMessage ?: "Loading Usage from your paired desktop…" ) } } diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/workspaces/AidenWorkspaceShellScreen.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/workspaces/AidenWorkspaceShellScreen.kt index d0eab0ce..15e77ce4 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/workspaces/AidenWorkspaceShellScreen.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/workspaces/AidenWorkspaceShellScreen.kt @@ -440,7 +440,7 @@ fun AidenWorkspaceDirectoryScreen( } ) DropdownMenuItem( - text = { Text("Add Mac Folder...") }, + text = { Text("Add Desktop Folder...") }, leadingIcon = { Icon(Icons.Default.Folder, contentDescription = null) }, onClick = { showCreateMenu = false @@ -516,7 +516,7 @@ fun AidenWorkspaceDirectoryScreen( icon = if (selectedTab == 0) Icons.Default.FolderOpen else Icons.Default.Archive, title = if (selectedTab == 0) "No active workspaces" else "No archived workspaces", body = if (selectedTab == 0) - "Create a workspace or add an approved folder from your Mac." + "Create a workspace or add an approved folder from your desktop." else "Workspaces archived on this device will appear here." ) @@ -751,7 +751,7 @@ fun AidenWorkspaceDirectoryScreen( onDismissRequest = { showScratchConfirmDialog = false }, title = { Text("Create Managed Scratch?", fontWeight = FontWeight.Bold) }, text = { - Text("Aiden will create an isolated scratch workspace in an ephemeral location on your Mac.") + Text("Aiden will create an isolated scratch workspace in an ephemeral location on your paired desktop.") }, confirmButton = { Button( @@ -827,7 +827,7 @@ fun AidenWorkspaceDirectoryScreen( onDismissRequest = { showArchiveDisclosureDialog = false }, title = { Text("Archive on this Device", fontWeight = FontWeight.Bold) }, text = { - Text("Archiving a workspace hides it only on this device. Your Mac, files, and other devices remain completely unaffected.") + Text("Archiving a workspace hides it only on this device. Your paired desktop, files, and other devices remain completely unaffected.") }, confirmButton = { Button( @@ -856,7 +856,7 @@ fun AidenWorkspaceDirectoryScreen( onDismissRequest = { showRemoveDialog = false }, title = { Text("Remove Workspace?", fontWeight = FontWeight.Bold) }, text = { - Text("Are you sure you want to remove \"${target.name}\" from Aiden? Local files on your Mac are preserved.") + Text("Are you sure you want to remove \"${target.name}\" from Aiden? Local files on your paired desktop are preserved.") }, confirmButton = { Button( @@ -888,7 +888,7 @@ fun AidenWorkspaceDirectoryScreen( onDismissRequest = { showDeleteWorktreeDialog = false }, title = { Text("Delete Managed Worktree?", fontWeight = FontWeight.Bold) }, text = { - Text("This will permanently remove the managed worktree folder and git worktree on your Mac.") + Text("This will permanently remove the managed worktree folder and git worktree on your paired desktop.") }, confirmButton = { Button( @@ -1014,7 +1014,7 @@ fun AidenFolderBrowserSheet( modifier = Modifier.fillMaxWidth() ) { Text( - text = "Browse Mac Folders", + text = "Browse Desktop Folders", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = palette.foreground, @@ -1345,8 +1345,8 @@ fun AidenWorkspaceSettingsSheet( title = { Text(if (workspace.isManagedWorktree) "Delete Worktree?" else "Remove Workspace?", fontWeight = FontWeight.Bold) }, text = { Text( - if (workspace.isManagedWorktree) "This will permanently remove the managed worktree folder and git worktree on your Mac." - else "Are you sure you want to remove \"${workspace.name}\" from Aiden? Local files on your Mac are preserved." + if (workspace.isManagedWorktree) "This will permanently remove the managed worktree folder and git worktree on your paired desktop." + else "Are you sure you want to remove \"${workspace.name}\" from Aiden? Local files on your paired desktop are preserved." ) }, confirmButton = { diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/models/AidenChat.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/models/AidenChat.kt index 708f4358..7b149a24 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/models/AidenChat.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/models/AidenChat.kt @@ -355,7 +355,7 @@ object AidenAgentActivityPresentation { "web_search" to Pair("Searching the web", "Searched the web"), "schedule_task" to Pair("Scheduling", "Scheduled"), "edit_automation" to Pair("Editing automation", "Edited automation"), - "computer_use" to Pair("Using Mac", "Used Mac"), + "computer_use" to Pair("Using Computer Use", "Used Computer Use"), "compact_context" to Pair("Compacting context", "Compacted context") ) @@ -456,7 +456,7 @@ object AidenAgentActivityPresentation { if (changes > 0) clauses.add("${if (running) "editing" else "edited"} $changes file${if (changes == 1) "" else "s"}") if (commands > 0) clauses.add("${if (running) "running" else "ran"} $commands command${if (commands == 1) "" else "s"}") if (web > 0) clauses.add("$web web search${if (web == 1) "" else "es"}") - if (mac > 0) clauses.add("$mac Mac action${if (mac == 1) "" else "s"}") + if (mac > 0) clauses.add("$mac Computer Use action${if (mac == 1) "" else "s"}") if (compactions > 0) clauses.add(if (running) "compacting context" else "compacted context") if (other > 0) clauses.add("$other tool call${if (other == 1) "" else "s"}") if (clauses.isEmpty()) return if (running) "Working" else "Used ${tools.size} tool${if (tools.size == 1) "" else "s"}" @@ -733,7 +733,7 @@ object AidenApprovalPresentation { else -> "Approval Required" } - fun requiresMacConfirmation(approval: AidenPendingApproval): Boolean = + fun requiresDesktopConfirmation(approval: AidenPendingApproval): Boolean = isAutomation(approval.toolName) && approval.canRespond && approval.hasRequiredWriteCapability && !approval.hostCanAllow } diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/persistence/AidenInstallationStore.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/persistence/AidenInstallationStore.kt index 0f37c19c..ef516aed 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/persistence/AidenInstallationStore.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/persistence/AidenInstallationStore.kt @@ -70,7 +70,7 @@ class AidenInstallationStore( val installation = AidenInstallation( instanceId = exchange.instanceId, deviceId = exchange.deviceId, - name = exchange.displayName ?: "Aiden Mac", + name = exchange.displayName ?: "Aiden desktop", endpoint = exchange.endpoint, serverSpkiSha256 = exchange.serverSpkiSha256, pairingTrust = trust, diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/protocol/AidenRemoteExceptions.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/protocol/AidenRemoteExceptions.kt index 573c9694..546519a8 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/protocol/AidenRemoteExceptions.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/protocol/AidenRemoteExceptions.kt @@ -20,7 +20,7 @@ sealed class AidenBotContractException(val reason: String, message: String = rea class InvalidCombination(val combination: String) : AidenBotContractException( combination, when (combination) { - "no available provider and model" -> "Set up a provider and model on your Mac. In Aiden Agent, open Settings → Providers, connect or refresh a provider, and make at least one chat model available. Then tap Try Again." + "no available provider and model" -> "Set up a provider and model on your paired desktop. In Aiden Agent, open Settings → Providers, connect or refresh a provider, and make at least one chat model available. Then tap Try Again." "unavailable custom access" -> "One or more selected AI, Files, Connections, or Skills are no longer available. Review this Bot’s access choices and try again." "chat access exceeds bot" -> "This chat is asking for more access than the Bot currently allows. Reduce the chat’s access or expand the Bot’s access, then try again." "full access notice" -> "Review and accept the Full Access notice before giving this Bot full access." @@ -30,7 +30,7 @@ sealed class AidenBotContractException(val reason: String, message: String = rea } sealed class AidenManualPairingException(message: String) : Exception(message) { - object InvalidCode : AidenManualPairingException("Enter the 20-character setup code shown on your Mac.") + object InvalidCode : AidenManualPairingException("Enter the 20-character setup code shown on your desktop.") object InvalidBootstrap : AidenManualPairingException("Aiden Agent returned an invalid manual pairing response.") object DecryptionFailed : AidenManualPairingException("The setup code is incorrect or belongs to a different pairing window.") object EndpointMismatch : AidenManualPairingException("The setup code belongs to a different Aiden Agent address.") @@ -63,7 +63,7 @@ sealed class AidenSSEParserException(message: String) : Exception(message) { sealed class AidenRemoteClientException(message: String, cause: Throwable? = null) : Exception(message, cause) { object MissingCredential : AidenRemoteClientException("No credential available for this installation.") object MissingTrustConfiguration : AidenRemoteClientException("This Aiden installation must be paired again to establish secure server trust.") - object InstallationChanged : AidenRemoteClientException("The active Aiden Agent changed. Try again on the selected Mac.") + object InstallationChanged : AidenRemoteClientException("The active Aiden Agent changed. Try again on the selected desktop.") object InvalidEndpoint : AidenRemoteClientException("The Aiden Agent address is invalid.") class UnexpectedStatus(val statusCode: Int) : AidenRemoteClientException("Aiden Agent returned HTTP status $statusCode.") data class Server(val statusCode: Int, val body: AidenRemoteErrorEnvelope.Body) : AidenRemoteClientException(body.message) { diff --git a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenChatTest.kt b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenChatTest.kt index 54e1a255..e419b48c 100644 --- a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenChatTest.kt +++ b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenChatTest.kt @@ -405,7 +405,7 @@ class AidenChatTest { } ) assertEquals( - "1 web search, 1 Mac action, compacted context, 1 tool call", + "1 web search, 1 Computer Use action, compacted context, 1 tool call", AidenAgentActivityPresentation.summary(multiTimeline) ) @@ -583,8 +583,8 @@ class AidenChatTest { assertTrue(AidenApprovalPresentation.isAutomation(approval.toolName)) assertEquals("Create this automation?", AidenApprovalPresentation.title(approval.toolName)) assertEquals("Create a daily report", AidenApprovalPresentation.oneLineSummary(approval.summary)) - assertTrue(AidenApprovalPresentation.requiresMacConfirmation(approval)) - assertFalse(AidenApprovalPresentation.requiresMacConfirmation(approval.copy(hostCanAllow = true, canAllow = true))) + assertTrue(AidenApprovalPresentation.requiresDesktopConfirmation(approval)) + assertFalse(AidenApprovalPresentation.requiresDesktopConfirmation(approval.copy(hostCanAllow = true, canAllow = true))) assertEquals("Approval Required", AidenApprovalPresentation.title("run_command")) } @@ -624,7 +624,7 @@ class AidenChatTest { assertTrue(readOnlySchedule!!.canRespond) assertFalse(readOnlySchedule.hasRequiredWriteCapability) assertFalse(readOnlySchedule.canAllow) - assertFalse(AidenApprovalPresentation.requiresMacConfirmation(readOnlySchedule)) + assertFalse(AidenApprovalPresentation.requiresDesktopConfirmation(readOnlySchedule)) val cannotRespond = AidenPendingApprovalResolution.resolve( valid, diff --git a/docs/linux.md b/docs/linux.md new file mode 100644 index 00000000..a0f67c2e --- /dev/null +++ b/docs/linux.md @@ -0,0 +1,114 @@ +# Linux desktop support + +Aiden Agent ships native x64 and arm64 Linux builds as AppImage, Debian, and +RPM packages. The `.deb` and `.rpm` formats are recommended because the distro +package manager installs Electron's runtime libraries and owns replacement or +removal. AppImage is the portable fallback. + +## Install + +Download the package for your architecture from +[GitHub Releases](https://github.com/sambitcreate/aiden-agent/releases). + +Debian, Ubuntu, Linux Mint, Pop!_OS, and related distributions: + +```sh +sudo apt install ./Aiden-Agent-*-linux.deb +``` + +Fedora, RHEL, Rocky Linux, and other RPM-based distributions: + +```sh +sudo dnf install ./Aiden-Agent-*-linux.rpm +``` + +Portable AppImage: + +```sh +chmod +x Aiden-Agent-*-linux.AppImage +./Aiden-Agent-*-linux.AppImage +``` + +The AppImage uses a pinned static launcher, so it does not depend on the legacy +FUSE 2 userspace library. A container, locked-down host, or other environment +without a usable FUSE mount can still use AppImage's extraction fallback: + +```sh +./Aiden-Agent-*-linux.AppImage --appimage-extract-and-run +``` + +## Desktop requirements + +- A glibc 2.34 or newer x64 or arm64 desktop distribution supported by + Electron. This includes RHEL/Rocky Linux 9, Debian 12, Ubuntu 22.04, and newer + releases in those families. +- A working graphical session under X11 or Wayland. +- A Secret Service or KWallet credential backend when saving provider, MCP, + ChatGPT, or model-data credentials. GNOME Keyring, KDE Wallet, and compatible + desktop keyrings provide this on common desktop installations. +- `tar` for on-device speech-model installation and `openssl` only when the + optional nearby Aiden On The Go listener creates its local TLS identity. +- Tailscale only when the optional private-tailnet remote route is selected. + +Aiden deliberately refuses to save secrets when Electron reports the Linux +`basic_text` backend. Unlock or configure the desktop keyring and restart Aiden; +the app will not silently downgrade credentials to reversible local storage. +Keyless local connections such as LM Studio and Ollama do not use secret +storage and remain available when no keyring session is running. + +## Tailscale remote access + +Tailscale installs its CLI at `/usr/bin/tailscale` on mainstream Linux +packages. After signing in, grant your desktop user one-time permission to +manage Serve routes: + +```sh +sudo tailscale set --operator=$USER +``` + +Aiden changes only its scoped `/api/aiden/v1` HTTPS Serve path and preserves +unrelated Serve configuration. Without the operator grant, status remains +readable but Aiden reports the permission requirement instead of claiming an +uncertain connection. + +## Platform behavior + +The workspace agent, providers, local models, MCP, skills, Web Search, +schedules, terminal, Git, file editor, generative UI artifacts, diagnostics, +Gemini voice transcription, remote access, notifications, profile, themes, and +native subagents use the same contracts as macOS. Linux-specific integrations include: + +- native distro window chrome and conventional File/Edit/View/Window/Help menus; +- Ctrl-based app and global shortcuts, including the Wayland Global Shortcuts + portal on desktops that implement it; +- editor discovery through `PATH`, Snap command locations, JetBrains Toolbox + scripts, and common Flatpak application IDs; +- opening folders with the default desktop file manager; +- profile snapshot export through a Save dialog; +- bundled Node mDNS publication for nearby Aiden On The Go discovery, without + requiring Apple's `dns-sd` utility. + +Computer Use, Apple Foundation Models, and Bots are not included in the Linux +build. Their settings, navigation, onboarding promises, helper bundles, and +chat controls are omitted. Global dictation remains available when the desktop can register its +shortcut, but the transcript is copied to the clipboard instead of using the +macOS Accessibility auto-paste transaction. Wayland compositors own final +placement of the dictation pill, so exact bottom-center positioning may vary. + +Provider inventories may refresh only from the provider services the user has +configured. Descriptive model metadata comes from Aiden's bundled release +snapshot; ordinary app reads expose no live models.dev refresh action. + +## Updates and troubleshooting + +Linux builds do not apply macOS-style in-app updates. Install the newer `.deb` +or `.rpm` over the existing package, replace the AppImage, or follow the package +manager that owns the installation. Settings → About links directly to the +release page. + +If a global shortcut is unavailable, check the desktop's shortcut portal or +conflicts with another application and assign another chord under Settings → +Keyboard shortcuts. If an AppImage does not start, prefer the native distro +package or use the extraction command above. When reporting a Linux issue, +include the distribution, architecture, desktop environment, X11/Wayland +session type, package format, and the exact error shown by Aiden. diff --git a/docs/plans/README.md b/docs/plans/README.md index afdc4351..94a3419b 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -4,55 +4,56 @@ This directory is the source of truth for Aiden's implementation plans. The engi ## Active and partial -| Plan | Status | Current state | -| -------------------------------------------------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [Aiden Assistant](aiden-assistant-plan.md) | Partial | The dock, Markdown rendering, and confirmed provider-connection/model-pinned project-or-MCP automation creation/editing ship; settings tools and proactivity remain planned. | -| [Aiden On The Go](aiden-on-the-go-plan.md) | Active | Version 0.1.0 build 22 is `VALID` and `IN_BETA_TESTING` for Internal Testers. Android matches iOS's app-icon switcher, Workspace hierarchy, warm scoped Bots/Usage/SSE lifecycle, Usage dashboard, image showcase/gallery, keyboard-safe elevated composer, and split Photo/File pickers. Both clients support native in-process dictation or bounded no-retention transcription by the paired Mac's local Parakeet model. iOS also ships progressive onboarding, bidirectional media, reliable mobile approvals, typed activity timelines, semantic haptics, and one-chat-per-Bot conversations with companion vision for text-only models. Physical iPad/manual permission-system-UI acceptance, privacy publication, final store assets, and external/public-release decisions remain open. | -| [Bot-First Aiden On The Go](bot-first-aiden-on-the-go-plan.md) | Active | Phases 0–9 are implemented. Every Bot has one persistent chat and one contact row; Favorites are a pinned placement, Bot chat reuses the shared runtime with Messages-inspired identity/bubbles and Aiden's existing composer, and New/Edit Bot exclusively own its durable model. Remote open-or-create, immediate exact-cache chat entry, optimistic favorites, shaped skeleton loading, stable photos, atomic desktop creation, fresh-inventory save retries, conflict-safe Mac/iOS draft rebasing, final-only Bot replies with expandable progress, native-or-companion image handling, and internal TestFlight build 22 are green. Eligible Apple Intelligence hardware, physical iPad, multi-device/Mac, packaged rollback, live Telegram, wider staged TestFlight, Xcode 27, accessibility, and App Store owner gates remain open. | -| [Aiden Manual Pairing](aiden-manual-pairing-plan.md) | Implemented | The reviewed 100-bit setup-code path, shared one-use QR window, staged iOS activation, and adversarial coverage ship; hands-on LAN/Tailscale UI and physical-iPad acceptance remain open. | -| [Compaction](compaction-plan.md) | Partial | Pi-native checkpoints, lifecycle/crash recovery, and exact audited-upstream compatibility ship; durable memory and provider-native paths remain open. | -| [Designer Mode](designer-mode-plan.md) | Planned | Phase 0 validation has not started in the runtime. | -| [Dynamic Model Catalog](dynamic-model-catalog-plan.md) | Implemented | Validated pi.dev overlays, offline `0600` cache hydration, scoped setup refresh, four-hour launch refresh, force refresh, Pi metadata fallback, and Mac/iOS projection ship on pinned Pi 0.80.10. | -| [Generative UI Artifacts](generative-ui-artifacts-plan.md) | Active | Phases 0–6 shipped: chat-scoped `render_artifact`, strict sandboxed preview/export hosts, verified vendored Chart.js/Plotly/KaTeX, permission-aware `/visualize`, crash-recoverable authoritative storage/copies, descriptor-relative workspace reads, one-iframe handoff/expansion, visible failure states, and route-stable Responding/Visualizing activity. Three-agent PR review findings are remediated with focused regression coverage. | -| [Generation Progress Notes](generation-progress-notes-plan.md) | Planned | No implementation yet. | -| [Logging and Diagnostics Upgrade](logging-and-diagnostics-upgrade-plan.md) | Implemented | Phases 0–7 are implemented: bounded typed desktop journals, main-owned renderer evidence, local support export/delete, native categorical parity, and CI/release gates. Signed/notarized `v0.35.0` passed packaged diagnostics acceptance; physical-device termination receipts remain. | -| [Model Insights](model-insights-plan.md) | Partial | A dedicated benchmark-only OpenRouter key, manual fetch, exact source-aware offline cache, metric-selectable collision-free capability suggestions, progressive canvas-first Pad UX, axis provenance, attribution, and direct-AA retirement ship; device-local pace signals remain. | -| [Onboarding Authentication and Provider Validation](onboarding-auth-and-provider-validation-plan.md) | Active | Codex uses its dedicated auth surface, every interactive Pi provider can be configured during onboarding, OpenAI/Anthropic keys receive stronger catalog validation, completion is main-owned, and provider deferral stays explicit. | -| [Performance, Stability, Battery, and Efficiency](performance-stability-efficiency-plan.md) | Planned | Whole-app source audit is complete; implementation starts with instrumentation, durable state, and hard memory bounds. | -| [Pi Provider Integration](pi-provider-integration-plan.md) | Partial | Pi built-ins, stores, auth, native routing, custom provider composition, canonical assistant provenance, voice credential lookup, and attended structured questions ship; scalable UX and rollout cleanup remain. | -| [rpiv-advisor integration](rpiv-advisor-integration-plan.md) | Implemented | A bounded, tool-free, provider/auth-aware second opinion now uses an ephemeral per-consultation Ask User Question choice when the prompt does not name a reviewer, with no persistent Advisor settings or IPC. | -| [rpiv-todo Integration](rpiv-todo-integration-plan.md) | Partial | Attended desktop chats have a journal-replayed native todo tool, strict fail-closed snapshots, owner-fenced IPC, and a self-hiding floating progress chip with portal details; packaged visual/accessibility acceptance remains open. | -| [rpiv-btw Integration](rpiv-btw-integration-plan.md) | Partial | Attended desktop chats have bounded read-only side questions, ephemeral fingerprinted follow-ups, foreground-safe admission, exact provider dispatch, content-free usage accounting, and a native slash/card surface; packaged visual/accessibility acceptance remains open. | -| [Subagent Orchestration Expansion](subagent-orchestration-expansion-plan.md) | Active | Phases 0–6, Phase 7A durable lifecycle, the Phase 7B1 storage seam, startup/clone-boundary repairs, Pi-normalized foreground presentation and recovery, and parent-only iOS/Android projection hardening are complete; app-lifetime coordinator activation is next. | -| [Taracodlab Learnings](taracodlab-learnings-plan.md) | Partial | Phases A–B and D, plus core Phase E, are implemented; the remaining roadmap is open. | -| [Telegram First-Class Agent Parity](telegram-first-class-agent-parity-plan.md) | Active | Controls, compaction, skills, rich inbound, drafts/activity, buttons, documents, Settings, and onboarding are green; Threaded Mode/profiles/extensions/TTS/live smoke remain. | -| [Update, Microphone, and Computer Use Hardening](update-microphone-computer-use-hardening-plan.md) | Partial | Installed acceptance found a silent stalled download; observable full-download recovery is implemented, while repaired-build → next-release and clean-TCC acceptance remain. | -| [Web Access Rehaul](web-access-rehaul-plan.md) | Partial | Implementation is complete through Phase 5: fresh profiles get request-free onboarding disclosure and default-on anonymous Exa, Settings exposes 17 reviewed shipped providers with fenced Fixed/Automatic routing, autonomous authority remains explicit, and a startup-bound Exa-only rollback preserves hidden state. Focused suites, Settings E2E, build, development packaging, and hardened package verification pass; the credential-backed live installed matrix remains a release-owner acceptance gate. | +| Plan | Status | Current state | +| ---------------------------------------------------------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [Aiden Assistant](aiden-assistant-plan.md) | Partial | The dock, Markdown rendering, and confirmed provider-connection/model-pinned project-or-MCP automation creation/editing ship; settings tools and proactivity remain planned. | +| [Aiden On The Go](aiden-on-the-go-plan.md) | Active | Version 0.1.0 build 22 is `VALID` and `IN_BETA_TESTING` for Internal Testers. Android matches iOS's app-icon switcher, Workspace hierarchy, warm scoped Bots/Usage/SSE lifecycle, Usage dashboard, image showcase/gallery, keyboard-safe elevated composer, and split Photo/File pickers. Both clients support native in-process dictation or bounded no-retention transcription by the paired Mac's local Parakeet model. iOS also ships progressive onboarding, bidirectional media, reliable mobile approvals, typed activity timelines, semantic haptics, and one-chat-per-Bot conversations with companion vision for text-only models. Physical iPad/manual permission-system-UI acceptance, privacy publication, final store assets, and external/public-release decisions remain open. | +| [Bot-First Aiden On The Go](bot-first-aiden-on-the-go-plan.md) | Active | Phases 0–9 are implemented. Every Bot has one persistent chat and one contact row; Favorites are a pinned placement, Bot chat reuses the shared runtime with Messages-inspired identity/bubbles and Aiden's existing composer, and New/Edit Bot exclusively own its durable model. Remote open-or-create, immediate exact-cache chat entry, optimistic favorites, shaped skeleton loading, stable photos, atomic desktop creation, fresh-inventory save retries, conflict-safe Mac/iOS draft rebasing, final-only Bot replies with expandable progress, native-or-companion image handling, and internal TestFlight build 22 are green. Eligible Apple Intelligence hardware, physical iPad, multi-device/Mac, packaged rollback, live Telegram, wider staged TestFlight, Xcode 27, accessibility, and App Store owner gates remain open. | +| [Aiden Manual Pairing](aiden-manual-pairing-plan.md) | Implemented | The reviewed 100-bit setup-code path, shared one-use QR window, staged iOS activation, and adversarial coverage ship; hands-on LAN/Tailscale UI and physical-iPad acceptance remain open. | +| [Compaction](compaction-plan.md) | Partial | Pi-native checkpoints, lifecycle/crash recovery, and exact audited-upstream compatibility ship; durable memory and provider-native paths remain open. | +| [Designer Mode](designer-mode-plan.md) | Planned | Phase 0 validation has not started in the runtime. | +| [Dynamic Model Catalog](dynamic-model-catalog-plan.md) | Implemented | Validated pi.dev overlays, offline `0600` cache hydration, scoped setup refresh, four-hour launch refresh, force refresh, Pi metadata fallback, and Mac/iOS projection ship on pinned Pi 0.80.10. | +| [Generative UI Artifacts](generative-ui-artifacts-plan.md) | Active | Phases 0–6 shipped: chat-scoped `render_artifact`, strict sandboxed preview/export hosts, verified vendored Chart.js/Plotly/KaTeX, permission-aware `/visualize`, crash-recoverable authoritative storage/copies, descriptor-relative workspace reads, one-iframe handoff/expansion, visible failure states, and route-stable Responding/Visualizing activity. Three-agent PR review findings are remediated with focused regression coverage. | +| [Generation Progress Notes](generation-progress-notes-plan.md) | Planned | No implementation yet. | +| [Logging and Diagnostics Upgrade](logging-and-diagnostics-upgrade-plan.md) | Implemented | Phases 0–7 are implemented: bounded typed desktop journals, main-owned renderer evidence, local support export/delete, native categorical parity, and CI/release gates. Signed/notarized `v0.35.0` passed packaged diagnostics acceptance; physical-device termination receipts remain. | +| [Model Insights](model-insights-plan.md) | Partial | A dedicated benchmark-only OpenRouter key, manual fetch, exact source-aware offline cache, metric-selectable collision-free capability suggestions, progressive canvas-first Pad UX, axis provenance, attribution, and direct-AA retirement ship; device-local pace signals remain. | +| [Onboarding Authentication and Provider Validation](onboarding-auth-and-provider-validation-plan.md) | Active | Codex uses its dedicated auth surface, every interactive Pi provider can be configured during onboarding, OpenAI/Anthropic keys receive stronger catalog validation, completion is main-owned, and provider deferral stays explicit. | +| [Performance, Stability, Battery, and Efficiency](performance-stability-efficiency-plan.md) | Planned | Whole-app source audit is complete; implementation starts with instrumentation, durable state, and hard memory bounds. | +| [Pi Provider Integration](pi-provider-integration-plan.md) | Partial | Pi built-ins, stores, auth, native routing, custom provider composition, canonical assistant provenance, voice credential lookup, and attended structured questions ship; scalable UX and rollout cleanup remain. | +| [rpiv-todo Integration](rpiv-todo-integration-plan.md) | Partial | Attended desktop chats have a journal-replayed native todo tool, strict fail-closed snapshots, owner-fenced IPC, and a self-hiding floating progress chip with portal details; packaged visual/accessibility acceptance remains open. | +| [rpiv-btw Integration](rpiv-btw-integration-plan.md) | Partial | Attended desktop chats have bounded read-only side questions, ephemeral fingerprinted follow-ups, foreground-safe admission, exact provider dispatch, content-free usage accounting, and a native slash/card surface; packaged visual/accessibility acceptance remains open. | +| [Subagent Orchestration Expansion](subagent-orchestration-expansion-plan.md) | Active | Phases 0–6, Phase 7A durable lifecycle, the Phase 7B1 storage seam, startup/clone-boundary repairs, Pi-normalized foreground presentation and recovery, and parent-only iOS/Android projection hardening are complete; app-lifetime coordinator activation is next. | +| [Taracodlab Learnings](taracodlab-learnings-plan.md) | Partial | Phases A–B and D, plus core Phase E, are implemented; the remaining roadmap is open. | +| [Telegram First-Class Agent Parity](telegram-first-class-agent-parity-plan.md) | Active | Controls, compaction, skills, rich inbound, drafts/activity, buttons, documents, Settings, and onboarding are green; Threaded Mode/profiles/extensions/TTS/live smoke remain. | +| [Update, Microphone, and Computer Use Hardening](update-microphone-computer-use-hardening-plan.md) | Partial | Installed acceptance found a silent stalled download; observable full-download recovery is implemented, while repaired-build → next-release and clean-TCC acceptance remain. | +| [Web Access Rehaul](web-access-rehaul-plan.md) | Partial | Implementation is complete through Phase 5: fresh profiles get request-free onboarding disclosure and default-on anonymous Exa, Settings exposes 17 reviewed shipped providers with fenced Fixed/Automatic routing, autonomous authority remains explicit, and a startup-bound Exa-only rollback preserves hidden state. Focused suites, Settings E2E, build, development packaging, and hardened package verification pass; the credential-backed live installed matrix remains a release-owner acceptance gate. | ## Completed -| Plan | Status | Completion note | -| ---------------------------------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| [Provider Model Visibility and Catalog Refresh](completed/provider-model-catalog-controls-plan.md) | Complete | Provider-wide visibility, explicit dual-source updates, bounded device-local models.dev metadata, native all-hidden behavior, and credential-isolated main refresh automation ship with two-review remediation. | -| [Aiden Remote Multi-Instance Hardening](completed/aiden-remote-multi-instance-hardening-plan.md) | Complete | Authenticated pairing completion, multi-device/Mac isolation, transactional listeners, explicit packaged Tailscale CLI mode, exact route ownership, durable revocation, and physical-iPhone acceptance all pass. | -| [Bots Mode](completed/bots-mode-plan.md) | Complete | Reusable Pi-backed bots now have first-class conversations, authoritative personas, soft archive, and exact one-to-one Telegram DM/topic control. | -| [Bot Avatar Studio](completed/bot-avatar-studio-plan.md) | Complete | Mouthless pastel faces now have live customization, crash-reconciled rollback storage, cancellable bounded Pi suggestions, and cohesive onboarding art. | -| [Companion Vision Models](completed/companion-vision-model-plan.md) | Complete | Vision primaries keep the native fast path; text-only Bots use one explicit exact-bound companion through a current-chat-only tool across Mac and iOS. | -| [Keyboard Command System](completed/keyboard-command-system-plan.md) | Complete | One command catalog now powers transactional global hotkeys, scoped app shortcuts, native menus, canonical settings, and the `Command-K` palette. | -| [Slash Commands and Skill Invocation](completed/slash-commands-and-skill-invocation-plan.md) | Complete | Separate `/` command and `$` skill palettes dispatch canonical app actions and fresh workspace-bound one-turn skills with safe provenance. | -| [Aiden-Native Subagents](completed/aiden-native-subagents-plan.md) | Complete | All five phases passed focused/package gates, two final fresh reviews, and the default 100-cycle packaged lifecycle soak. | -| [Development and Production Coexistence](completed/development-production-coexistence-plan.md) | Complete | Development now has a visibly distinct app identity, isolated state roots, opt-in global shortcuts, and production-only updates. | -| [Gemini Native Upgrade](completed/gemini-native-upgrade-plan.md) | Complete | Its funded delivery phases shipped; deliberately deferred Gemini tracks remain future work. | -| [Gemini 3.5 Voice Transcription](completed/gemini-3-5-voice-transcription-plan.md) | Complete | Dedicated Gemini 3.5 Live and batch transcription replace legacy Flash voice models, with ordered PCM streaming and WAV fallback. | -| [Gemini Voice Setup and Delivery Hardening](completed/gemini-voice-hardening-plan.md) | Complete | Purpose-scoped Gemini setup, enforced transcription-only policy, cancelable bounded dictation, explicit Accessibility recovery, and real offline Parakeet v3 inference are complete. | -| [Scheduled Tasks](completed/scheduled-tasks-plan.md) | Complete | Implemented through the plan's original Phase 4 scope. | -| [Scheduled Tasks Experience and Chat Creation](completed/scheduled-tasks-experience-plan.md) | Complete | Natural-language creation from eligible chats, human schedule controls, exact capability approvals, revision-safe lifecycle management, native parity, and migration hardening ship. | -| [Pi-native Compaction](completed/pi-native-compaction-plan.md) | Complete | Pi `0.80.10` session checkpoints, retained-tail reconstruction, overflow recovery, private journals, child parity, and activity milestones ship. | -| [Telegram Remote Control](completed/telegram-remote-control-plan.md) | Complete | Long-polling remote control ships with owner pairing, queueing, Markdown delivery, explicit optional folder-workspace authority via Settings or `/workspace`, isolated backing chats, Settings UI, focused tests, and onboarding coverage. | -| [Dictation Capture and Delivery](completed/dictation-capture-delivery-plan.md) | Complete | Trailing-audio flush, provider-wide shortcut/Accessibility, safer paste restore, hold-to-talk, isolated on-device STT, optional silence-stop, cleanup, and sounds. | -| [Pi Runtime Harness](completed/pi-runtime-harness-plan.md) | Complete | One durable Pi-shaped runtime now owns foreground/child lifecycle, contributions, effect evidence, killable child inference, and safe startup retry. | -| [Sidebar Chat Activity](completed/sidebar-chat-activity-plan.md) | Complete | Complete, revisioned per-chat activity appears as an accessible static ring with no polling or perpetual animation. | -| [Pi Compaction Compatibility](completed/compaction-reliability-plan.md) | Complete | Aiden's adapter now matches the audited Pi baseline 1:1 and persists only closed, privacy-safe provider-failure metadata for durable UI. | -| [Pi Thinking Disclosure](completed/pi-thinking-disclosure-plan.md) | Complete | Provider-neutral readable Pi thinking, a one-second inspectable preview, and a durable local presentation toggle now match the audited Pi contract. | +| Plan | Status | Completion note | +| -------------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| [Linux Desktop Support](completed/linux-desktop-support-plan.md) | Complete | Linux x64/arm64 AppImage, DEB, and RPM packages, explicit platform capability tradeoffs, native helpers, and hosted Ubuntu/Fedora package and E2E acceptance all pass. | +| [rpiv-advisor Integration](completed/rpiv-advisor-integration-plan.md) | Complete | Bounded, tool-free second opinions now use ephemeral provider/model selection, strict foreground authority, privacy-safe context projection, no-replay journaling, and content-free usage accounting. | +| [Provider Model Visibility and Catalog Refresh](completed/provider-model-catalog-controls-plan.md) | Complete | Provider-wide visibility, explicit dual-source updates, bounded device-local models.dev metadata, native all-hidden behavior, and credential-isolated main refresh automation ship with two-review remediation. | +| [Aiden Remote Multi-Instance Hardening](completed/aiden-remote-multi-instance-hardening-plan.md) | Complete | Authenticated pairing completion, multi-device/Mac isolation, transactional listeners, explicit packaged Tailscale CLI mode, exact route ownership, durable revocation, and physical-iPhone acceptance all pass. | +| [Bots Mode](completed/bots-mode-plan.md) | Complete | Reusable Pi-backed bots now have first-class conversations, authoritative personas, soft archive, and exact one-to-one Telegram DM/topic control. | +| [Bot Avatar Studio](completed/bot-avatar-studio-plan.md) | Complete | Mouthless pastel faces now have live customization, crash-reconciled rollback storage, cancellable bounded Pi suggestions, and cohesive onboarding art. | +| [Companion Vision Models](completed/companion-vision-model-plan.md) | Complete | Vision primaries keep the native fast path; text-only Bots use one explicit exact-bound companion through a current-chat-only tool across Mac and iOS. | +| [Keyboard Command System](completed/keyboard-command-system-plan.md) | Complete | One command catalog now powers transactional global hotkeys, scoped app shortcuts, native menus, canonical settings, and the `Command-K` palette. | +| [Slash Commands and Skill Invocation](completed/slash-commands-and-skill-invocation-plan.md) | Complete | Separate `/` command and `$` skill palettes dispatch canonical app actions and fresh workspace-bound one-turn skills with safe provenance. | +| [Aiden-Native Subagents](completed/aiden-native-subagents-plan.md) | Complete | All five phases passed focused/package gates, two final fresh reviews, and the default 100-cycle packaged lifecycle soak. | +| [Development and Production Coexistence](completed/development-production-coexistence-plan.md) | Complete | Development now has a visibly distinct app identity, isolated state roots, opt-in global shortcuts, and production-only updates. | +| [Gemini Native Upgrade](completed/gemini-native-upgrade-plan.md) | Complete | Its funded delivery phases shipped; deliberately deferred Gemini tracks remain future work. | +| [Gemini 3.5 Voice Transcription](completed/gemini-3-5-voice-transcription-plan.md) | Complete | Dedicated Gemini 3.5 Live and batch transcription replace legacy Flash voice models, with ordered PCM streaming and WAV fallback. | +| [Gemini Voice Setup and Delivery Hardening](completed/gemini-voice-hardening-plan.md) | Complete | Purpose-scoped Gemini setup, enforced transcription-only policy, cancelable bounded dictation, explicit Accessibility recovery, and real offline Parakeet v3 inference are complete. | +| [Scheduled Tasks](completed/scheduled-tasks-plan.md) | Complete | Implemented through the plan's original Phase 4 scope. | +| [Scheduled Tasks Experience and Chat Creation](completed/scheduled-tasks-experience-plan.md) | Complete | Natural-language creation from eligible chats, human schedule controls, exact capability approvals, revision-safe lifecycle management, native parity, and migration hardening ship. | +| [Pi-native Compaction](completed/pi-native-compaction-plan.md) | Complete | Pi `0.80.10` session checkpoints, retained-tail reconstruction, overflow recovery, private journals, child parity, and activity milestones ship. | +| [Telegram Remote Control](completed/telegram-remote-control-plan.md) | Complete | Long-polling remote control ships with owner pairing, queueing, Markdown delivery, explicit optional folder-workspace authority via Settings or `/workspace`, isolated backing chats, Settings UI, focused tests, and onboarding coverage. | +| [Dictation Capture and Delivery](completed/dictation-capture-delivery-plan.md) | Complete | Trailing-audio flush, provider-wide shortcut/Accessibility, safer paste restore, hold-to-talk, isolated on-device STT, optional silence-stop, cleanup, and sounds. | +| [Pi Runtime Harness](completed/pi-runtime-harness-plan.md) | Complete | One durable Pi-shaped runtime now owns foreground/child lifecycle, contributions, effect evidence, killable child inference, and safe startup retry. | +| [Sidebar Chat Activity](completed/sidebar-chat-activity-plan.md) | Complete | Complete, revisioned per-chat activity appears as an accessible static ring with no polling or perpetual animation. | +| [Pi Compaction Compatibility](completed/compaction-reliability-plan.md) | Complete | Aiden's adapter now matches the audited Pi baseline 1:1 and persists only closed, privacy-safe provider-failure metadata for durable UI. | +| [Pi Thinking Disclosure](completed/pi-thinking-disclosure-plan.md) | Complete | Provider-neutral readable Pi thinking, a one-second inspectable preview, and a durable local presentation toggle now match the audited Pi contract. | Move a plan to `completed/` only when its original delivery scope is complete. Keep the original plan as historical documentation; follow-on work belongs in a new active plan. diff --git a/docs/plans/completed/linux-desktop-support-plan.md b/docs/plans/completed/linux-desktop-support-plan.md new file mode 100644 index 00000000..60ac38d1 --- /dev/null +++ b/docs/plans/completed/linux-desktop-support-plan.md @@ -0,0 +1,191 @@ +# Linux Desktop Support + +Status: Complete — implementation, native acceptance, and hosted x64/arm64/Fedora acceptance complete 2026-08-30 + +## Goal + +Ship Aiden Agent as a first-class Linux desktop application that installs and +runs on the common Debian/Ubuntu, Fedora/RHEL, and openSUSE families, with a +portable AppImage option. Preserve the existing macOS experience while making +platform-specific behavior explicit, secure, tested, and maintainable. + +## Supported baseline + +- Architectures: x86_64 and arm64. +- Packages: AppImage, `.deb`, and `.rpm`. +- Runtime baseline: glibc 2.34 or newer; package verification rejects native + executables or modules that raise that floor. +- Display servers: X11 and Wayland. Window placement that Wayland deliberately + forbids is treated as a capability limitation rather than emulated. +- Desktop integration: native Linux window frame/menu, desktop notifications, + default file manager, common installed editors, and Secret Service/KWallet + credential encryption. +- Computer Use, Apple Foundation Models, and Bots remain macOS-only. Linux omits + their helpers, settings/navigation actions, onboarding promises, and runtime + tool exposure while retaining the shared implementations for capable hosts. + +## Research-backed tradeoffs + +1. Electron supports Linux x64 and arm64, while electron-builder directly + supports AppImage, Debian, and RPM targets. Native dependencies and Aiden's + own helper executables must be built and verified on the target OS rather + than copied from macOS. +2. Linux `safeStorage` can fall back to Electron's `basic_text` backend. Aiden + will fail closed for provider and OAuth secrets when no desktop keyring is + available, with an actionable error, instead of silently storing secrets + with the hard-coded fallback key. +3. Wayland does not allow applications to position or programmatically focus + windows in all compositors. Global dictation therefore guarantees capture + and clipboard delivery on Linux, while exact floating-pill placement and + automatic paste remain macOS conveniences. +4. Linux uses a conventional native frame. macOS keeps hidden-inset traffic + lights, vibrancy, and the Dock-icon preference; Linux does not expose those + controls. +5. Native profile sharing becomes a Save dialog on Linux. Opening a workspace + uses the system file manager, and supported editors are discovered from + executable paths/Flatpak installations rather than macOS bundles/Spotlight. +6. Linux packages initially use explicit download/install updates. The current + signed macOS updater stays unchanged; silently treating `.deb`/`.rpm` + replacement as equivalent would bypass distribution ownership and package + manager expectations. + +Primary references: + +- [Electron supported platforms](https://www.electronjs.org/docs/latest/tutorial/installation) +- [Electron safeStorage](https://www.electronjs.org/docs/latest/api/safe-storage) +- [Electron Linux notifications](https://www.electronjs.org/docs/latest/tutorial/notifications) +- [Electron custom title bars](https://www.electronjs.org/docs/latest/tutorial/custom-title-bar) +- [electron-builder Linux targets](https://www.electron.build/docs/linux/) +- [electron-builder cross-platform builds](https://www.electron.build/docs/features/multi-platform-build/) + +## Delivery phases + +### Phase 1 — audit, research, and support contract + +- Inventory macOS assumptions in startup, windows, menus, permissions, + packaging, helper binaries, onboarding, settings, and release automation. +- Establish the supported distro/package matrix and deliberate limitations. +- Add this plan to the canonical plan inventory. + +Review gate: confirm that every default-on service either has a Linux path or +is explicitly capability-gated before implementation begins. + +### Phase 2 — Linux build, package, and runtime foundation + +- Make development startup platform-neutral. +- Add Linux AppImage, Debian, and RPM packaging with x64/arm64 metadata, + Linux runtime dependencies, icons, native helpers, unpacked native modules, + and fuse/package verification. +- Port the worktree remover, subagent run store, file mutator, and shell runner + to Linux without weakening their path, identity, or atomicity contracts. +- Add platform-safe window construction, menu construction, terminal shell + selection, and secure-storage selection. + +Review gate: build and test all Linux native helpers, create an unpacked Linux +package, inspect its resources/fuses/permissions, and run focused runtime tests. + +### Phase 3 — platform capabilities and service adaptations + +- Advertise typed host capabilities to the renderer. +- Omit Computer Use and Apple Foundation Models on Linux. +- Adapt microphone permission, dictation delivery, profile sharing, external + editors/file manager, Tailscale discovery, and LAN service publication. +- Remove or replace macOS-only controls and promises in Settings/onboarding + while retaining clear explanations for deliberate Linux limitations. + +Review gate: exercise every changed IPC boundary and verify that unsupported +features cannot be enabled or invoked through stale renderer state. + +### Phase 4 — CI, distribution, documentation, and acceptance + +- Add Linux unit/integration, native-helper, package-contract, and Electron E2E + coverage under Xvfb. +- Add x64 and arm64 Linux artifact workflows. +- Document install, keyring, Wayland, package ownership, and update behavior. +- Run type-check, lint, focused suites, full tests, Linux package verification, + and smoke launch acceptance. + +Review gate: freeze the diff, perform a final platform/security regression +review, and archive this plan only when the complete acceptance matrix passes. + +## Implementation and review record + +- Phase 1 complete: audited startup, windows, menus, permissions, helpers, + packaging, onboarding, settings, Remote Access, companion copy, and release + automation. Every default-on service now has a Linux implementation or an + explicit main-owned capability gate. +- Phase 2 complete: Linux development startup, x64/arm64 AppImage/DEB/RPM + configuration, package/fuse verification, native C helper portability, + Linux PTY layouts, conventional window/menu behavior, and fail-closed + keyring selection are implemented. Clean target-architecture builds produced + and verified all three package formats and every native helper/module. +- Phase 3 complete: Computer Use and Apple Foundation Models are inaccessible + on Linux; Settings, onboarding, profile export, dictation, shortcuts, + editors/file manager, Tailscale, nearby mobile discovery, and Aiden On The + Go copy follow the advertised platform capabilities. Review additionally + fixed Linux safe-save recovery ownership and Remote Access publisher races. +- Latest-main parity reconciliation retains Web Search, diagnostics, Gemini + voice transcription, Model Pad, companion projections, raster and sandboxed + generative UI artifacts, and response/accessibility improvements on Linux. + Chat-native Scheduled Tasks, including revision-bound remote runs and shared + desktop/mobile presentation, are also platform-neutral on Linux. + The attended-chat Ask User Question composer and the native todo, BTW, and + Advisor Pi extensions are also platform-neutral and covered by both Ubuntu + and Fedora Linux CI gates. + The shared capability projection now also hides Bots and all Bot entry points + until Linux receives equivalent native security bindings. Settings and + Command-K share the same availability filter, dictation never invokes macOS + Accessibility on Linux, and model metadata remains a release-bundled offline + snapshot with no live models.dev UI action. +- Phase 4 implementation complete: CI builds x64 and arm64 artifacts, installs + and verifies DEB on Ubuntu 24.04 and RPM on Fedora 44, and runs Electron E2E + under Xvfb. Release publication requires both Linux architectures alongside + the verified macOS artifacts. Linux installation and limitation guidance is + documented in `docs/linux.md`. +- Native acceptance passes on both x64 and arm64 Ubuntu 24.04: package + verification, DEB installation, RPM metadata, AppImage execution, native + linkage, desktop association, and fresh empty-XDG X11 startup. Debian 12 + additionally proves the glibc 2.36/legacy-ALSA path; Fedora 44 and openSUSE + Tumbleweed prove RPM dependency resolution; headless Weston proves Wayland + startup. The complete deterministic Electron E2E suite also runs under Xvfb. +- The final adversarial pass fixed several defects that metadata-only checks + missed: Linux's seven-field native generation token, a glibc 2.38 symbol + leak, Debian's false ALSA virtual provider, architecture-specific release + filenames, a dynamically linked arm64 AppImage launcher, non-ELF `.node` + verifier inputs, AppleDouble test sidecars, stripped X11/Wayland launch + variables, keyless onboarding incorrectly requiring a desktop keyring, and + RPM integrity checks rejecting electron-builder's exact sandbox-mode fallback. + It also corrected Linux last-window shutdown so enabled Remote Access retains + background ownership without changing the ordinary last-window quit policy. +- Local regression acceptance passes `type-check`, E2E type-check, lint, Linux + contracts, helper/parser adversarial suites, package/publisher tests, and the + production TypeScript-to-native run-store boundary. +- Live OrbStack acceptance now also exercises the installed Linux + `/usr/bin/tailscale` client from the real Aiden controller. A separate + tailnet peer reached the exact HTTPS `/api/aiden/v1/health` contract, and + teardown removed only Aiden's scoped Serve route. This pass added strict + `Running`/online status checks and actionable detection of Linux's required + one-time Tailscale operator grant. + +Hosted run `33338723528` completed the release-infrastructure gate: Linux x64, +Linux arm64, the Fedora RPM artifact consumer, deterministic Electron E2E, +Android, and the full verification job all passed. The final CI hardening uses +SIGKILL for bounded Electron smoke teardown and hands Fedora a checksum-verified +RPM built against the Ubuntu baseline, preserving the strict glibc 2.34 floor. +Together with the native acceptance matrix above, this completes the plan's +original delivery scope. + +## Acceptance criteria + +- A fresh supported Linux desktop can install an appropriate Aiden package and + complete onboarding without encountering a macOS-only control or helper. +- Chat, providers, local runtimes, MCP, terminal, workspaces/worktrees, + subagents, schedules, notifications, local voice, Telegram, and Aiden Remote + retain their existing contracts on Linux or disclose a documented platform + limitation before the user acts. +- Secret material is never written through Electron's Linux `basic_text` + backend. +- AppImage, Debian, and RPM contents include the correct-architecture native + modules/helpers and exclude Computer Use and Apple-only helper artifacts. +- macOS packaging, signing, notarization, UI, and feature availability remain + regression-tested and unchanged except for shared platform abstractions. diff --git a/docs/plans/rpiv-advisor-integration-plan.md b/docs/plans/completed/rpiv-advisor-integration-plan.md similarity index 99% rename from docs/plans/rpiv-advisor-integration-plan.md rename to docs/plans/completed/rpiv-advisor-integration-plan.md index 006818f9..0b7343c9 100644 --- a/docs/plans/rpiv-advisor-integration-plan.md +++ b/docs/plans/completed/rpiv-advisor-integration-plan.md @@ -1,6 +1,6 @@ # rpiv-advisor integration -Status: Implemented (2026-08-30) +Status: Complete (2026-08-30) ## Objective diff --git a/ios/APP_STORE_METADATA.md b/ios/APP_STORE_METADATA.md index 0606c99b..50ef60a2 100644 --- a/ios/APP_STORE_METADATA.md +++ b/ios/APP_STORE_METADATA.md @@ -30,15 +30,15 @@ Developer Tools is the closest current Apple category: Apple describes it as app Description draft: -> Aiden On The Go is the native iPhone and iPad companion for Aiden Agent on your Mac. Pair directly with a Mac you control over your local network or Tailscale, then continue Aiden chats and manage workspaces from your mobile device. +> Aiden On The Go is the native iPhone and iPad companion for Aiden Agent on your macOS or Linux desktop. Pair directly with a desktop you control over your local network or Tailscale, then continue Aiden chats and manage workspaces from your mobile device. > -> Review conversations, stream responses, handle approval requests, inspect workspace files, work with supported Git flows, and manage scheduled tasks. App Intents provide quick navigation, Live Activities show bounded run status, and optional voice dictation can use the device's native recognizer or a local speech model on your paired Mac. Read-aloud stays on device. +> Review conversations, stream responses, handle approval requests, inspect workspace files, work with supported Git flows, and manage scheduled tasks. App Intents provide quick navigation, Live Activities show bounded run status, and optional voice dictation can use the device's native recognizer or a local speech model on your paired desktop. Read-aloud stays on device. > -> Your Mac remains the execution authority. Remote Access is off by default, each mobile device uses a revocable credential, and provider credentials remain on the Mac. +> Your desktop remains the execution authority. Remote Access is off by default, each mobile device uses a revocable credential, and provider credentials remain on the desktop. Review-notes draft: -> Aiden On The Go requires the companion Aiden Agent desktop app. In Aiden Agent, open Settings → Remote Access, enable the listener, and open a short-lived pairing session. On the iPhone or iPad, scan the pairing QR code or use the approved manual connection flow. The reviewer must be supplied with a reachable review Mac and any required setup instructions; no production credential is embedded in the app. +> Aiden On The Go requires the companion Aiden Agent desktop app. In Aiden Agent, open Settings → Remote Access, enable the listener, and open a short-lived pairing session. On the iPhone or iPad, scan the pairing QR code or use the approved manual connection flow. The reviewer must be supplied with a reachable review desktop and any required setup instructions; no production credential is embedded in the app. ## Age-rating questionnaire draft @@ -75,11 +75,11 @@ Current Apple references: Evidence for that answer in the current distribution candidate: - The app contains no analytics, advertising, crash-reporting, account, or Aiden-hosted relay SDK. -- Pairing credentials and custom headers stay in Keychain. Cached chats/settings stay on the user's device; authoritative chats/files remain on the Mac the user pairs. +- Pairing credentials and custom headers stay in Keychain. Cached chats/settings stay on the user's device; authoritative chats/files remain on the desktop the user pairs. - QR camera frames are processed for pairing and are not uploaded to Aiden's developer. -- Dictation uses either Apple’s native recognizer or, only when the person selects **Paired Mac**, a bounded recording sent directly over the authenticated pinned-TLS connection to the Mac-local Parakeet model. Neither endpoint retains that recording. Read-aloud uses Apple system frameworks locally. Aiden's developer operates no speech collection service. -- Photos/files selected by the user are sent directly to their paired Mac and may then be sent to model providers the user configured on that Mac. Aiden's developer cannot access them. Provider processing remains governed by each selected provider and should be described in the public policy. -- Optional Bot image generation uses Apple's system Image Playground on supported devices and may use Private Cloud Compute. Aiden disables person/Photos personalization and supplies only the visible Bot name and purpose as starting concepts. Aiden's developer runs no image-generation or proxy service and cannot access those concepts, rejected candidates, or results. When the person chooses **Use this image**, the app sends only that normalized image directly to the paired Mac, which stores the canonical Bot photo. +- Dictation uses either Apple’s native recognizer or, only when the person selects **Paired desktop**, a bounded recording sent directly over the authenticated pinned-TLS connection to the desktop-local Parakeet model. Neither endpoint retains that recording. Read-aloud uses Apple system frameworks locally. Aiden's developer operates no speech collection service. +- Photos/files selected by the user are sent directly to their paired desktop and may then be sent to model providers the user configured there. Aiden's developer cannot access them. Provider processing remains governed by each selected provider and should be described in the public policy. +- Optional Bot image generation uses Apple's system Image Playground on supported devices and may use Private Cloud Compute. Aiden disables person/Photos personalization and supplies only the visible Bot name and purpose as starting concepts. Aiden's developer runs no image-generation or proxy service and cannot access those concepts, rejected candidates, or results. When the person chooses **Use this image**, the app sends only that normalized image directly to the paired desktop, which stores the canonical Bot photo. - Local Network or Tailscale traffic goes directly to the paired installation. Aiden does not run a central account, synchronization service, analytics endpoint, or proxy. - Live Activity state is device-local and response excerpts are off by default. - External transcript media can contact the media host without forwarding Aiden credentials; the public policy should disclose that a remote host can observe an ordinary network request when its media is displayed. @@ -111,7 +111,7 @@ Current Apple reference: `https://developer.apple.com/help/app-store-connect/ref - Owner/legal-review and publish `app-store/MOBILE_PRIVACY_SUPPORT_COPY.md` at the resolved privacy URL. - Make the prepared working support contact visible at the resolved support URL. - Required physical-iPhone and physical-iPad screenshots captured from the final distribution candidate at accepted dimensions. -- App Review phone number, notes, and a reachable companion-Mac review environment. The name/email are resolved above. +- App Review phone number, notes, and a reachable companion-desktop review environment. The name/email are resolved above. - Availability, price, territories, and release mode. Do not replace unresolved values with placeholders in App Store Connect. @@ -120,10 +120,10 @@ Do not replace unresolved values with placeholders in App Store Connect. Provide these notes only with a reachable, reviewer-safe paired Aiden Agent environment and the final approved contact details: -1. Pair the iPhone or iPad with the supplied Aiden Agent Mac, then tap the Aiden logo and choose **Bots**. -2. Accept the one-time Full Access notice or choose **Customize first**. Full Access uses only capabilities already enabled on the paired Mac; Custom can reduce Files, commands, Connections, and Skills. +1. Pair the iPhone or iPad with the supplied Aiden Agent desktop, then tap the Aiden logo and choose **Bots**. +2. Accept the one-time Full Access notice or choose **Customize first**. Full Access uses only capabilities already enabled on the paired desktop; Custom can reduce Files, commands, Connections, and Skills. 3. Create a Bot with the built-in semantic avatar, save it, and start a chat. Apple Intelligence is not required for this complete path. -4. On eligible Apple Intelligence hardware with iOS/iPadOS 18.4 or later, **Create with Apple Intelligence** opens Apple's system Image Playground. Apple controls generation and may use Private Cloud Compute. Aiden disables person/Photos personalization and sends the paired Mac only the image explicitly accepted and saved. +4. On eligible Apple Intelligence hardware with iOS/iPadOS 18.4 or later, **Create with Apple Intelligence** opens Apple's system Image Playground. Apple controls generation and may use Private Cloud Compute. Aiden disables person/Photos personalization and sends the paired desktop only the image explicitly accepted and saved. 5. On unsupported hardware, including iPhone 13 Pro, the editor honestly keeps the semantic avatar available and has no dead Image Playground action. Do not claim successful Image Playground generation in review notes until it has passed on supported physical hardware. Do not include pairing credentials, private prompts, paths, or provider secrets in metadata or notes. diff --git a/ios/AidenOnTheGo/Auth/KeychainStore.swift b/ios/AidenOnTheGo/Auth/KeychainStore.swift index dd645be7..bad497e3 100644 --- a/ios/AidenOnTheGo/Auth/KeychainStore.swift +++ b/ios/AidenOnTheGo/Auth/KeychainStore.swift @@ -7,7 +7,7 @@ protocol KeychainStoring { func delete(_ key: KeychainStore.Key) throws // A device credential is scoped to Aiden's stable installation identifier. - // Switching or removing one paired Mac must never read or clear another + // Switching or removing one paired desktop must never read or clear another // installation's credential. func save(_ value: String, forKey key: KeychainStore.Key, scope: String) throws func load(_ key: KeychainStore.Key, scope: String) throws -> String? diff --git a/ios/AidenOnTheGo/Config/AidenVoiceInput.swift b/ios/AidenOnTheGo/Config/AidenVoiceInput.swift index ee77d3cd..c1662873 100644 --- a/ios/AidenOnTheGo/Config/AidenVoiceInput.swift +++ b/ios/AidenOnTheGo/Config/AidenVoiceInput.swift @@ -7,7 +7,7 @@ enum AidenVoiceInputMode: String, CaseIterable, Identifiable, Codable, Sendable static let defaultsKey = "aiden.voiceInput.mode" var id: String { rawValue } - var title: String { self == .onDevice ? String(localized: "On this device") : String(localized: "Paired Mac") } + var title: String { self == .onDevice ? String(localized: "On this device") : String(localized: "Paired desktop") } static var selected: AidenVoiceInputMode { AidenVoiceInputMode(rawValue: UserDefaults.standard.string(forKey: defaultsKey) ?? "") ?? .onDevice diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotCustomAccessFlowView.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotCustomAccessFlowView.swift index b1b545e0..b4f81cb3 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotCustomAccessFlowView.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotCustomAccessFlowView.swift @@ -360,7 +360,7 @@ struct AidenBotCustomAccessFlowView: View { } header: { Text("Bot") } footer: { - Text("Custom Access can only reduce what Aiden and your Mac already allow. Change the AI Provider or Model in Edit Bot.") + Text("Custom Access can only reduce what Aiden and the paired desktop already allow. Change the AI Provider or Model in Edit Bot.") } if isLoadingBot, draft == nil { @@ -419,7 +419,7 @@ struct AidenBotCustomAccessFlowView: View { .disabled(!canWrite) optionSection( title: "Other Capabilities", - description: "Optional Aiden capabilities available on this Mac.", + description: "Optional Aiden capabilities available on the paired desktop.", options: catalog.otherCapabilities, keyPath: \.otherCapabilityIDs ) @@ -462,7 +462,7 @@ struct AidenBotCustomAccessFlowView: View { Toggle("Run commands", isOn: shellBinding(catalog)) .disabled(!catalog.shellAvailable && !(draft?.shellEnabled ?? false)) - .accessibilityHint("Allows the bot to use Aiden’s existing shell tool on your Mac.") + .accessibilityHint("Allows the bot to use Aiden’s existing shell tool on the paired desktop.") } header: { Text("Files and Commands") } footer: { @@ -483,7 +483,7 @@ struct AidenBotCustomAccessFlowView: View { ) return Section { if visibleOptions.isEmpty { - Text("None configured on this Mac") + Text("None configured on the paired desktop") .foregroundStyle(palette.secondary) } else { ForEach(visibleOptions) { option in @@ -709,7 +709,7 @@ struct AidenBotCustomAccessFlowView: View { capturedContext == request.context, selectedBotID == request.botID else { return } guard let loadedDraft = AidenBotCustomAccessDraft(access: detail.access, catalog: catalog) else { - botError = "No available AI provider and model can be selected on your Mac." + botError = "No available AI provider and model can be selected on your paired desktop." return } selectedBot = detail @@ -800,7 +800,7 @@ struct AidenBotCustomAccessFlowView: View { access: authoritative.access, catalog: refreshedCatalog ) else { - saveError = "Access may have changed on your Mac. Close and reopen this screen to refresh." + saveError = "Access may have changed on your paired desktop. Close and reopen this screen to refresh." return } selectedBot = authoritative diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotEditorView.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotEditorView.swift index faf34b25..0e2a8fb0 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotEditorView.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotEditorView.swift @@ -562,7 +562,7 @@ struct AidenBotEditorView: View { .disabled(draft?.usesFullAccess == true) optionSection( title: "Other Capabilities", - description: "Additional capabilities available on this Mac.", + description: "Additional capabilities available on the paired desktop.", options: catalog.otherCapabilities, keyPath: \.otherCapabilityIDs ) @@ -685,14 +685,14 @@ struct AidenBotEditorView: View { .accessibilityHint("Custom Access can reduce the capabilities this Bot may use.") if draft?.usesFullAccess == true { - Label("Uses everything Aiden and your Mac currently allow.", systemImage: "checkmark.shield") + Label("Uses everything Aiden and the paired desktop currently allow.", systemImage: "checkmark.shield") .foregroundStyle(palette.secondary) } } header: { Text("Access") } footer: { if !AidenBotEditorDraft.fullAccessAccepted(in: catalog) { - Text("Full Access is unavailable because Customize First was selected for this Mac.") + Text("Full Access is unavailable because Customize First was selected for this desktop.") } else { Text("Connections and Skills are the most important controls when using Custom Access.") } @@ -730,7 +730,7 @@ struct AidenBotEditorView: View { if visionProviders(in: catalog).isEmpty { Label( - "No image-capable model is connected. Add one in Aiden Agent on your Mac, then refresh this Bot.", + "No image-capable model is connected. Add one in Aiden Agent on your paired desktop, then refresh this Bot.", systemImage: "exclamationmark.triangle" ) .foregroundStyle(palette.secondary) @@ -759,7 +759,7 @@ struct AidenBotEditorView: View { } header: { Text("AI Provider and Model") } footer: { - Text("This Bot uses this Provider and Model in every chat. Credentials stay on your Mac.") + Text("This Bot uses this Provider and Model in every chat. Credentials stay on the paired desktop.") } } @@ -781,7 +781,7 @@ struct AidenBotEditorView: View { } header: { Text("Files and Commands") } footer: { - Text("Choose which files the Bot may work with and whether it may run commands on the paired Mac.") + Text("Choose which files the Bot may work with and whether it may run commands on the paired desktop.") } } @@ -793,7 +793,7 @@ struct AidenBotEditorView: View { ) -> some View { Section { if options.isEmpty { - Text("None configured on this Mac") + Text("None configured on the paired desktop") .foregroundStyle(palette.secondary) } else { ForEach(options) { option in @@ -823,7 +823,7 @@ struct AidenBotEditorView: View { ) if draft?.usesFullAccess == true { Label( - "Full Access: files, commands, Connections, and Skills allowed by the paired Mac", + "Full Access: files, commands, Connections, and Skills allowed by the paired desktop", systemImage: "checkmark.shield" ) } else if let access = draft?.customAccess { @@ -1051,7 +1051,7 @@ struct AidenBotEditorView: View { private var readOnlyMessage: String { if baselineBot?.health == .archived { return "Archived Bots are read-only until restored." } - if coordinator.connectionState != .connected { return "Reconnect to your Mac to save this Bot." } + if coordinator.connectionState != .connected { return "Reconnect to your paired desktop to save this Bot." } return "This phone can view Bots but is not approved to change them." } @@ -1354,7 +1354,7 @@ struct AidenBotEditorView: View { onSaved(authoritative) dismiss() } else { - saveError = "Aiden checked the Bot on your Mac. Review any remaining changes, then save again." + saveError = "Aiden checked the Bot on your paired desktop. Review any remaining changes, then save again." } } catch is CancellationError { return @@ -1365,7 +1365,7 @@ struct AidenBotEditorView: View { ) { return } guard isCurrent(attempt) else { return } capturedContext = nil - saveError = "Aiden couldn’t verify which changes reached your Mac. Close and reopen this Bot before editing again." + saveError = "Aiden couldn’t verify which changes reached your paired desktop. Close and reopen this Bot before editing again." } } } diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotGeneratedAvatarLifecycle.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotGeneratedAvatarLifecycle.swift index e098048f..d2672307 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotGeneratedAvatarLifecycle.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotGeneratedAvatarLifecycle.swift @@ -21,7 +21,7 @@ enum AidenBotGeneratedAvatarError: Error, LocalizedError, Equatable { case .invalidImage: "Aiden couldn’t prepare that image. Choose another image." case .unavailable: - "Reconnect to your Mac before saving this Bot photo." + "Reconnect to your paired desktop before saving this Bot photo." } } } @@ -464,7 +464,7 @@ final class AidenBotGeneratedAvatarModel { self.candidateBytes = nil candidateImage = nil phase = .idle - errorMessage = "The Bot photo changed on your Mac. Review it before choosing a new image." + errorMessage = "The Bot photo changed on your paired desktop. Review it before choosing a new image." return } attempt = .init( @@ -569,7 +569,7 @@ final class AidenBotGeneratedAvatarModel { } guard fresh.avatar.asset?.assetRevision == observedAssetRevision else { phase = .idle - errorMessage = "The Bot photo changed on your Mac. Review it before removing it." + errorMessage = "The Bot photo changed on your paired desktop. Review it before removing it." return } attempt = .init( @@ -730,7 +730,7 @@ final class AidenBotGeneratedAvatarModel { candidateBytes = nil candidateImage = nil phase = .idle - errorMessage = "The Bot photo changed on your Mac. Review the current photo before replacing it." + errorMessage = "The Bot photo changed on your paired desktop. Review the current photo before replacing it." } } catch is CancellationError { if isCurrent(attempt.context, generation: generation), uploadAttempt == attempt { @@ -745,7 +745,7 @@ final class AidenBotGeneratedAvatarModel { } guard isCurrent(attempt.context, generation: generation), uploadAttempt == attempt else { return } phase = .ready - errorMessage = "Aiden couldn’t verify which photo reached your Mac. Reconnect, then retry this same upload." + errorMessage = "Aiden couldn’t verify which photo reached your paired desktop. Reconnect, then retry this same upload." } } @@ -799,7 +799,7 @@ final class AidenBotGeneratedAvatarModel { } else { deleteAttempt = nil phase = .idle - errorMessage = "The Bot photo changed on your Mac. Review it before trying again." + errorMessage = "The Bot photo changed on your paired desktop. Review it before trying again." } } catch is CancellationError { if isCurrent(attempt.context, generation: generation), deleteAttempt == attempt { @@ -1020,7 +1020,7 @@ struct AidenBotGeneratedAvatarLifecycleView: View { } Button("Cancel", role: .cancel) { } } message: { - Text("This removes the generated Bot photo from your paired Mac.") + Text("This removes the generated Bot photo from your paired desktop.") } } @@ -1043,8 +1043,8 @@ struct AidenBotGeneratedAvatarLifecycleView: View { } private var statusCopy: String { - if model.hasCandidate { return "Only this accepted image will be sent to your paired Mac." } - if model.hasGeneratedAvatar { return "Saved on your paired Mac." } + if model.hasCandidate { return "Only this accepted image will be sent to your paired desktop." } + if model.hasGeneratedAvatar { return "Saved on your paired desktop." } return "Your semantic avatar is always available." } diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotProfileView.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotProfileView.swift index d94dbda0..2d601bc7 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotProfileView.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotProfileView.swift @@ -427,7 +427,7 @@ struct AidenBotProfileView: View { .accessibilityHint( detail.health == .ready ? "Opens this Bot’s persistent conversation." - : "Repair this Bot’s access on the paired Mac before starting its conversation." + : "Repair this Bot’s access on the paired desktop before starting its conversation." ) } diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotsHomeView.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotsHomeView.swift index c95e8031..fef6118b 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotsHomeView.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotsHomeView.swift @@ -278,7 +278,7 @@ func aidenBotInboxActivityStatus( case .waitingForApproval: canRespondToApproval ? .init(label: "Approval needed", symbol: "checkmark.shield") - : .init(label: "Waiting for approval on Mac", symbol: "desktopcomputer") + : .init(label: "Waiting for desktop approval", symbol: "desktopcomputer") case .reconciling: .init(label: "Updating", symbol: "arrow.triangle.2.circlepath") } } @@ -564,7 +564,7 @@ struct AidenBotsHomeView: View { Text( coordinator.connectionState == .connected ? "Create a Bot to give a familiar helper one persistent conversation and its own capabilities." - : "Reconnect to your Mac to load Bots." + : "Reconnect to your paired desktop to load Bots." ) } actions: { if coordinator.connectionState == .connected { diff --git a/ios/AidenOnTheGo/Features/Bots/Prototype/BotFirstPrototype.swift b/ios/AidenOnTheGo/Features/Bots/Prototype/BotFirstPrototype.swift index a841252e..b07c6a79 100644 --- a/ios/AidenOnTheGo/Features/Bots/Prototype/BotFirstPrototype.swift +++ b/ios/AidenOnTheGo/Features/Bots/Prototype/BotFirstPrototype.swift @@ -120,7 +120,7 @@ private enum AidenBotPrototypeChatAccess: String, CaseIterable, Identifiable, Ha } private enum AidenBotPrototypeFileAccess: String, CaseIterable, Identifiable, Hashable { - case fullMac = "Full Mac" + case fullMac = "Full desktop" case botFolderOnly = "Bot folder only" case chosenLocations = "Chosen locations" case off = "Off" @@ -711,9 +711,9 @@ private struct AidenBotPrototypeFullAccessNoticeView: View { .background(palette.accent.opacity(0.12), in: Circle()) VStack(alignment: .leading, spacing: 10) { - Text("Bots can use your Mac") + Text("Bots can use your paired desktop") .font(.largeTitle.bold()) - Text("By default, bots can work with files, run commands, and use connections, skills, and AI configured on the paired Mac. Capabilities you enable later in Aiden are also available to Full Access bots. You can choose Custom Access now or reduce access in Bot Settings anytime.") + Text("By default, bots can work with files, run commands, and use connections, skills, and AI configured on the paired desktop. Capabilities you enable later in Aiden are also available to Full Access bots. You can choose Custom Access now or reduce access in Bot Settings anytime.") .font(.body) .foregroundStyle(palette.secondary) } @@ -790,7 +790,7 @@ private struct AidenBotPrototypeWorkspacesView: View { } header: { Text("Workspaces") } footer: { - Text("This fixture root stays mounted separately from Bots and never connects to a Mac.") + Text("This fixture root stays mounted separately from Bots and never connects to a desktop.") } } .scrollContentBackground(.hidden) @@ -1091,7 +1091,7 @@ private struct AidenBotPrototypeInboxView: View { AidenBotPrototypeBanner( symbol: "exclamationmark.triangle", title: "Some selected access is unavailable.", - detail: "Review it on your Mac.", + detail: "Review it on your paired desktop.", tone: palette.warning ) default: @@ -1140,7 +1140,7 @@ private struct AidenBotPrototypeInboxView: View { AidenBotPrototypeEmptyView( symbol: "arrow.clockwise.circle", title: "Bots didn’t load", - detail: "The paired Mac did not return a complete Bot list.", + detail: "The paired desktop did not return a complete Bot list.", actionTitle: "Retry", action: { fixtureState = .ready } ) @@ -1506,7 +1506,7 @@ private struct AidenBotPrototypeProfileView: View { .multilineTextAlignment(.center) .frame(maxWidth: 380) Label( - isArchived ? "Archived bots are read-only until restored." : "Ready on your Mac", + isArchived ? "Archived bots are read-only until restored." : "Ready on your paired desktop", systemImage: isArchived ? "archivebox.fill" : "checkmark.circle.fill" ) .font(.caption.weight(.semibold)) @@ -1801,7 +1801,7 @@ private struct AidenBotPrototypeEditorView: View { } header: { Text("How this bot helps") } footer: { - Text("Write this in everyday language. Aiden adds the private operating details on your Mac.") + Text("Write this in everyday language. Aiden adds the private operating details on your paired desktop.") } Section { @@ -1847,7 +1847,7 @@ private struct AidenBotPrototypeEditorView: View { LabeledContent("Look", value: lookStyle.rawValue) LabeledContent("Access", value: accessPolicy.mode.title) Text(accessPolicy.mode == .full - ? "Can use your Mac, shell, enabled connections, and skills." + ? "Can use your paired desktop, shell, enabled connections, and skills." : "Uses only the access you select. This chat can reduce it further.") .font(.caption) .foregroundStyle(palette.secondary) @@ -1941,9 +1941,9 @@ private struct AidenBotPrototypeAccessView: View { } private static let locationCatalog = [ - CatalogItem(id: "documents", title: "Documents", detail: "Chosen on your Mac"), - CatalogItem(id: "desktop", title: "Desktop", detail: "Chosen on your Mac"), - CatalogItem(id: "downloads", title: "Downloads", detail: "Chosen on your Mac"), + CatalogItem(id: "documents", title: "Documents", detail: "Chosen on your paired desktop"), + CatalogItem(id: "desktop", title: "Desktop", detail: "Chosen on your paired desktop"), + CatalogItem(id: "downloads", title: "Downloads", detail: "Chosen on your paired desktop"), ] private static let connectionCatalog = [ CatalogItem(id: "calendar", title: "Calendar", detail: "Events and availability"), @@ -2033,7 +2033,7 @@ private struct AidenBotPrototypeAccessView: View { } if showsCustomCapabilities { - Section("Mac files") { + Section("Desktop files") { Picker("Files", selection: $files) { ForEach(AidenBotPrototypeFileAccess.allCases) { option in Text(option.rawValue) @@ -2077,7 +2077,7 @@ private struct AidenBotPrototypeAccessView: View { } header: { Text("Connections") } footer: { - Text("Choose external apps and services already configured in Aiden. Some connections are powered by MCP; account details stay on your Mac.") + Text("Choose external apps and services already configured in Aiden. Some connections are powered by MCP; account details stay on your paired desktop.") } Section { @@ -2109,7 +2109,7 @@ private struct AidenBotPrototypeAccessView: View { if scope == .bot { Label("Full Access", systemImage: "checkmark.shield.fill") .foregroundStyle(palette.accent) - Text("Can use your Mac, shell, enabled connections, and skills.") + Text("Can use your paired desktop, shell, enabled connections, and skills.") .font(.subheadline) .foregroundStyle(palette.secondary) } else { diff --git a/ios/AidenOnTheGo/Features/Remote/AidenBotChatToolsView.swift b/ios/AidenOnTheGo/Features/Remote/AidenBotChatToolsView.swift index d81f7d45..87f3e74c 100644 --- a/ios/AidenOnTheGo/Features/Remote/AidenBotChatToolsView.swift +++ b/ios/AidenOnTheGo/Features/Remote/AidenBotChatToolsView.swift @@ -246,7 +246,7 @@ final class AidenBotChatToolsModel { func readOnlyMessage(coordinator: AidenRemoteCoordinator, hostAllowsMutations: Bool) -> String? { if bot?.health == .archived { return "Archived bots are read-only until restored." } if bot?.health == .degraded || bot?.health == .unavailable { - return "This bot's access needs repair on your Mac before it can work." + return "This bot's access needs repair on your paired desktop before it can work." } if coordinator.connectionState != .connected { return "Offline — reconnect to change this chat's access." } if coordinator.installationStore.activeInstallation?.canWriteBots != true { @@ -593,7 +593,7 @@ struct AidenBotChatAccessSheetView: View { if model.draft?.mode == .custom { optionSection( title: "Connections", - description: "Connected apps and services already configured on your Mac.", + description: "Connected apps and services already configured on your paired desktop.", options: catalog.connections, keyPath: \.connectionIDs, ceiling: bot.access.custom.map { Set($0.connectionIds) } @@ -611,7 +611,7 @@ struct AidenBotChatAccessSheetView: View { .disabled(!canChangeDraft) optionSection( title: "Other abilities", - description: "Additional capabilities enabled for this bot on your Mac.", + description: "Additional capabilities enabled for this bot on your paired desktop.", options: catalog.otherCapabilities, keyPath: \.otherCapabilityIDs, ceiling: bot.access.custom.map { Set($0.otherCapabilityIds) } @@ -664,7 +664,7 @@ struct AidenBotChatAccessSheetView: View { ContentUnavailableView( "Access Unavailable", systemImage: "lock.trianglebadge.exclamationmark", - description: Text(model.errorMessage ?? "Reconnect to your Mac to load this chat's access.") + description: Text(model.errorMessage ?? "Reconnect to your paired desktop to load this chat's access.") ) } } @@ -713,7 +713,7 @@ struct AidenBotChatAccessSheetView: View { ) -> some View { Section { if options.isEmpty { - Text("None configured on this Mac").foregroundStyle(palette.secondary) + Text("None configured on the paired desktop").foregroundStyle(palette.secondary) } else { ForEach(options) { option in Toggle(isOn: optionBinding( @@ -937,7 +937,7 @@ final class AidenBotConversationFilesModel { guard coordinator.isCurrent(grant.context) else { return } if case AidenRemoteClientError.server(_, let body) = error, body.code.rawValue == "revision_conflict" { - errorMessage = "This file changed on the Mac. Reload it before saving again." + errorMessage = "This file changed on the paired desktop. Reload it before saving again." } else if error is AidenRemoteClientError { errorMessage = "Files access changed. Return to the chat and open Files again." } else { @@ -1104,8 +1104,8 @@ private struct AidenBotConversationFileEditorView: View { } else if let message = model.errorMessage { VStack(spacing: 8) { Text(message).font(.footnote).foregroundStyle(.secondary) - if message.contains("changed on the Mac") { - Button("Reload from Mac") { + if message.contains("changed on the paired desktop") { + Button("Reload from desktop") { Task { await model.reloadDocument(coordinator: coordinator) } } } diff --git a/ios/AidenOnTheGo/Features/Remote/AidenChatFeature.swift b/ios/AidenOnTheGo/Features/Remote/AidenChatFeature.swift index d445fb9f..8b14461e 100644 --- a/ios/AidenOnTheGo/Features/Remote/AidenChatFeature.swift +++ b/ios/AidenOnTheGo/Features/Remote/AidenChatFeature.swift @@ -1030,7 +1030,7 @@ final class AidenChatViewModel { throw NSError( domain: "AidenVoiceInput", code: 1, - userInfo: [NSLocalizedDescriptionKey: status.engine.error ?? String(localized: "The Mac speech engine is unavailable.")] + userInfo: [NSLocalizedDescriptionKey: status.engine.error ?? String(localized: "The desktop speech engine is unavailable.")] ) } guard let model = status.models.first(where: { $0.id == status.selectedModelId && $0.installed }) @@ -1039,7 +1039,7 @@ final class AidenChatViewModel { throw NSError( domain: "AidenVoiceInput", code: 2, - userInfo: [NSLocalizedDescriptionKey: String(localized: "Download a Mac speech model in App Settings before using this option.")] + userInfo: [NSLocalizedDescriptionKey: String(localized: "Download a desktop speech model in App Settings before using this option.")] ) } if status.selectedModelId != model.id { _ = try await client.selectSpeechModel(model.id) } @@ -1510,7 +1510,7 @@ final class AidenChatViewModel { case .scheduleWriteRequired: String(localized: "Schedule write access was removed from this paired device. The task was not approved.") case .hostApprovalRequired: - String(localized: "This request can only be approved on your Mac.") + String(localized: "This request can only be approved on your paired desktop.") } streamState = .reconciling coordinator.haptics.play(.warning, scope: hapticScope) @@ -2039,7 +2039,7 @@ final class AidenChatViewModel { return } catch { // Keep the durable stream cursor and continue retrying while - // this Mac connection remains current. Long Tailscale or + // this desktop connection remains current. Long Tailscale or // local-network outages must not erase terminal evidence. } attempt += 1 @@ -3044,7 +3044,7 @@ struct AidenMessageOutcomePresentation: Equatable { case "rate_limit": detail = "The model provider is receiving too many requests. Try again shortly." case "authentication": - detail = "The model provider rejected its credentials. Check Provider Settings on your Mac." + detail = "The model provider rejected its credentials. Check Provider Settings on your desktop." case "quota": detail = "The model provider account has no available quota." case "invalid_request": @@ -4117,7 +4117,7 @@ private struct AidenApprovalCard: View { .font(.caption) .foregroundStyle(palette.secondary) } else if kind == .scheduledTask && !canAllow { - Label("This task must be approved on your Mac.", systemImage: "desktopcomputer") + Label("This task must be approved on your paired desktop.", systemImage: "desktopcomputer") .font(.caption) .foregroundStyle(palette.secondary) } diff --git a/ios/AidenOnTheGo/Features/Remote/AidenPairingView.swift b/ios/AidenOnTheGo/Features/Remote/AidenPairingView.swift index 2ac53bc2..19d502e9 100644 --- a/ios/AidenOnTheGo/Features/Remote/AidenPairingView.swift +++ b/ios/AidenOnTheGo/Features/Remote/AidenPairingView.swift @@ -138,7 +138,7 @@ enum AidenPairingMethod: String, CaseIterable, Identifiable, Hashable { var title: String { switch self { case .scanQRCode: return String(localized: "Scan QR Code") - case .nearbyMac: return String(localized: "Nearby Mac + Setup Code") + case .nearbyMac: return String(localized: "Nearby Desktop + Setup Code") case .privateAddress: return String(localized: "Private Address + Setup Code") case .pastePayload: return String(localized: "Paste Pairing Payload") } @@ -149,9 +149,9 @@ enum AidenPairingMethod: String, CaseIterable, Identifiable, Hashable { case .scanQRCode: return String(localized: "Scan the one-time QR shown by Aiden Agent.") case .nearbyMac: - return String(localized: "Find your Mac on local Wi-Fi, then enter its setup code.") + return String(localized: "Find your desktop on local Wi-Fi, then enter its setup code.") case .privateAddress: - return String(localized: "Enter the private Tailscale address and setup code shown on your Mac.") + return String(localized: "Enter the private Tailscale address and setup code shown on your desktop.") case .pastePayload: return String(localized: "Use the complete one-time payload when the camera is unavailable.") } @@ -202,7 +202,7 @@ enum AidenMobileOnboardingPhase: String, CaseIterable, Identifiable, Hashable { var detail: String { switch self { case .build: - return String(localized: "Use Workspaces for project-focused work with files, commands, review, and Git. When Bots are available on your paired Mac, use them as reusable helpers and tap the Aiden logo to switch.") + return String(localized: "Use Workspaces for project-focused work with files, commands, review, and Git. When Bots are available on your paired desktop, use them as reusable helpers and tap the Aiden logo to switch.") case .extend: return String(localized: "Choose models and thinking levels, attach images, use web search, and extend Aiden with skills and MCP connectors.") case .control: @@ -573,19 +573,19 @@ struct AidenPairingView: View { ScrollView { VStack(alignment: .leading, spacing: 28) { VStack(alignment: .leading, spacing: 8) { - Text("Prepare your Mac").font(.largeTitle.bold()) - Text("Aiden Agent remains the server and keeps provider credentials on your Mac.") + Text("Prepare your desktop").font(.largeTitle.bold()) + Text("Aiden Agent remains the server and keeps provider credentials on your desktop.") .foregroundStyle(palette.secondary) } - pairingStep(number: 1, title: "Open Aiden Agent", detail: "On your Mac, go to Settings → Remote Access.") + pairingStep(number: 1, title: "Open Aiden Agent", detail: "On your desktop, go to Settings → Remote Access.") pairingStep(number: 2, title: "Turn on Remote Access", detail: "Choose Local Network, Tailscale, or both. Tailscale is best when you are away from home.") pairingStep(number: 3, title: "Create a pairing code", detail: "Keep the QR or setup code visible. Both expire after five minutes and can be used once.") VStack(alignment: .leading, spacing: 10) { Label("Per-device credential", systemImage: "key.fill") Label("Pinned HTTPS identity", systemImage: "lock.shield.fill") - Label("Revocable from your Mac", systemImage: "checkmark.shield") + Label("Revocable from your desktop", systemImage: "checkmark.shield") } .font(.subheadline) .foregroundStyle(palette.secondary) @@ -668,11 +668,11 @@ struct AidenPairingView: View { Label("Open Aiden Agent’s Add Device window and keep the one-time QR visible.", systemImage: "desktopcomputer") Label("The QR already contains the selected Local Network or Tailscale address.", systemImage: "network") } header: { - Text("On your Mac") + Text("On your desktop") } Section("Private pairing") { - Text("The QR expires after five minutes and can be used once. Aiden pins the Mac’s HTTPS identity during pairing.") + Text("The QR expires after five minutes and can be used once. Aiden pins the desktop’s HTTPS identity during pairing.") .foregroundStyle(palette.secondary) } } @@ -700,14 +700,14 @@ struct AidenPairingView: View { } } } header: { - Text("Nearby Macs") + Text("Nearby desktops") } footer: { - Text("Your iPhone or iPad and Mac must be on the same local network. Select the Mac shown in Aiden Agent’s Add Device window.") + Text("Your iPhone or iPad and desktop must be on the same local network. Select the desktop shown in Aiden Agent’s Add Device window.") } Section { manualEndpointField( - placeholder: "https://mac-name.local:49220/api/aiden/v1", + placeholder: "https://desktop-name.local:49220/api/aiden/v1", accessibilityLabel: "Nearby Aiden Agent address" ) manualSetupCodeField @@ -715,7 +715,7 @@ struct AidenPairingView: View { } header: { Text("Setup code") } footer: { - Text("If discovery is unavailable, enter the exact nearby Mac address shown in Aiden Agent. The setup code is encrypted and can be used once.") + Text("If discovery is unavailable, enter the exact nearby desktop address shown in Aiden Agent. The setup code is encrypted and can be used once.") } } .scrollContentBackground(.hidden) @@ -726,7 +726,7 @@ struct AidenPairingView: View { Form { Section { manualEndpointField( - placeholder: "https://mac-name.tailnet.ts.net/api/aiden/v1", + placeholder: "https://desktop-name.tailnet.ts.net/api/aiden/v1", accessibilityLabel: "Private Tailscale address" ) manualSetupCodeField @@ -739,7 +739,7 @@ struct AidenPairingView: View { Section("Before pairing") { Label("Sign in to the same Tailscale network on both devices", systemImage: "network") - Label("Keep Aiden Agent open on your Mac", systemImage: "desktopcomputer") + Label("Keep Aiden Agent open on your desktop", systemImage: "desktopcomputer") } } .scrollContentBackground(.hidden) @@ -768,7 +768,7 @@ struct AidenPairingView: View { } header: { Text("One-time pairing payload") } footer: { - Text("Use only the complete payload copied from your own Mac. It contains a one-time secret and expires after five minutes.") + Text("Use only the complete payload copied from your own desktop. It contains a one-time secret and expires after five minutes.") } } .scrollContentBackground(.hidden) @@ -806,8 +806,8 @@ struct AidenPairingView: View { .accessibilityValue(selectedAgentID == agent.id ? "Selected" : "Not selected") .accessibilityAddTraits(selectedAgentID == agent.id ? .isSelected : []) .accessibilityHint(agent.endpoint == nil - ? "This Mac is still resolving its network address." - : "Use this Mac for setup-code pairing.") + ? "This desktop is still resolving its network address." + : "Use this desktop for setup-code pairing.") } private func manualEndpointField( diff --git a/ios/AidenOnTheGo/Features/Remote/AidenProductShellView.swift b/ios/AidenOnTheGo/Features/Remote/AidenProductShellView.swift index 4a48651b..70cc488a 100644 --- a/ios/AidenOnTheGo/Features/Remote/AidenProductShellView.swift +++ b/ios/AidenOnTheGo/Features/Remote/AidenProductShellView.swift @@ -56,9 +56,9 @@ enum AidenBotsAvailability: Equatable, Sendable { case .mobileDisabled: "Bots aren’t available in this version of Aiden On The Go." case .unsupported: - "Bots need a newer version of Aiden Agent on your Mac." + "Bots need a newer version of Aiden Agent on your paired desktop." case .notGranted: - "Approve Bot access on your Mac, or pair this phone again." + "Approve Bot access on your paired desktop, or pair this phone again." } } @@ -140,7 +140,7 @@ func aidenBotSwitcherCoachmarkDetail(canWrite: Bool) -> String { if canWrite { return "Before a Bot can act, Aiden shows a one-time Full Access notice. Choose Continue with Full Access or Customize first." } - return "This Mac shared Bots as read-only. You can open their conversations here, then change Bot access on your Mac if you want to let them act." + return "This desktop shared Bots as read-only. You can open their conversations here, then change Bot access on your paired desktop if you want to let them act." } func aidenBotChatAllowsMutations( @@ -746,7 +746,7 @@ private struct AidenBotShellView: View { guard coordinator.connectionState == .connected else { if cached == nil { path = [] - coordinator.presentedError = "Reconnect to your Mac to open this Bot chat." + coordinator.presentedError = "Reconnect to your paired desktop to open this Bot chat." } return } @@ -1086,10 +1086,10 @@ private struct AidenFullAccessNoticeView: View { .foregroundStyle(.tint) .accessibilityHidden(true) - Text("Bots can use your Mac") + Text("Bots can use your paired desktop") .font(.largeTitle.bold()) - Text("By default, bots can work with files, run commands, and use connections, skills, and AI configured on the paired Mac. Capabilities you enable later in Aiden are also available to Full Access bots. You can choose Custom Access now or reduce access in Bot Settings anytime.") + Text("By default, bots can work with files, run commands, and use connections, skills, and AI configured on the paired desktop. Capabilities you enable later in Aiden are also available to Full Access bots. You can choose Custom Access now or reduce access in Bot Settings anytime.") .font(.body) if includesMigrationCopy { @@ -1116,7 +1116,7 @@ private struct AidenFullAccessNoticeView: View { .disabled(isSaving) if isSaving { - ProgressView("Saving on your Mac…") + ProgressView("Saving on your paired desktop…") .frame(maxWidth: .infinity) } } @@ -1377,7 +1377,7 @@ struct AidenProductShellView: View { case .coaching: EmptyView() case .checking: - ProgressView("Checking Bot access on your Mac…") + ProgressView("Checking Bot access on your paired desktop…") .padding() .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14)) case .failed(let message): diff --git a/ios/AidenOnTheGo/Features/Remote/AidenScheduledTasksView.swift b/ios/AidenOnTheGo/Features/Remote/AidenScheduledTasksView.swift index 6b94043c..d242f86f 100644 --- a/ios/AidenOnTheGo/Features/Remote/AidenScheduledTasksView.swift +++ b/ios/AidenOnTheGo/Features/Remote/AidenScheduledTasksView.swift @@ -435,7 +435,7 @@ final class AidenScheduledTasksModel { return } pendingRunKeys[task.id] = nil - outcomeMessage = String(localized: "Run accepted (\(accepted.runId.prefix(12))…). It continues on your Mac if this phone disconnects.") + outcomeMessage = String(localized: "Run accepted (\(accepted.runId.prefix(12))…). It continues on your paired desktop if this phone disconnects.") coordinator.haptics.play( .actionStarted, scope: hapticScope, @@ -673,7 +673,7 @@ struct AidenScheduledTasksView: View { ContentUnavailableView( "Schedule Access Required", systemImage: "lock.shield", - description: Text("Enable schedule read access for this paired device on your Mac to view task definitions and cached run history.") + description: Text("Enable schedule read access for this paired device on your desktop to view task definitions and cached run history.") ) .listRowBackground(Color.clear) } else { @@ -702,7 +702,7 @@ struct AidenScheduledTasksView: View { ContentUnavailableView( "No Scheduled Tasks", systemImage: "clock.badge.plus", - description: Text("Ask Aiden in any chat to create unattended work that runs on your Mac.") + description: Text("Ask Aiden in any chat to create unattended work that runs on your desktop.") ) .listRowBackground(Color.clear) } else if visibleTasks.isEmpty && !model.isLoading { @@ -1110,7 +1110,7 @@ private struct AidenScheduledTaskEditor: View { .font(.footnote) .foregroundStyle(.secondary) } - Text("Only enabled server names are shown. Connection details and credentials remain on your Mac.") + Text("Only enabled server names are shown. Connection details and credentials remain on your desktop.") .font(.footnote).foregroundStyle(.secondary) } } @@ -1128,8 +1128,8 @@ private struct AidenScheduledTaskEditor: View { Picker("Permission", selection: $draft.permission) { ForEach(AidenScheduledTaskPermission.allCases, id: \.self) { Text($0.title).tag($0) } } - Toggle("Mac notification", isOn: $draft.notify) - Text("Enabled tasks can run on your Mac while this phone is disconnected. Full permission can edit files and run commands without asking.") + Toggle("Desktop notification", isOn: $draft.notify) + Text("Enabled tasks can run on your desktop while this phone is disconnected. Full permission can edit files and run commands without asking.") .font(.footnote).foregroundStyle(.secondary) } if let validation = reviewValidationMessage { Section { Text(validation).foregroundStyle(.red) } } @@ -1207,7 +1207,7 @@ private struct AidenScheduledTaskEditor: View { } LabeledContent("Notifications", value: draft.notify ? "On" : "Off") } - Section { Text("Confirm only if this unattended work should run on your Mac while the phone is disconnected.") } + Section { Text("Confirm only if this unattended work should run on your desktop while the phone is disconnected.") } } .navigationTitle("Review Task") .toolbar { diff --git a/ios/AidenOnTheGo/Features/Remote/AidenWorkspaceEnvironmentView.swift b/ios/AidenOnTheGo/Features/Remote/AidenWorkspaceEnvironmentView.swift index 27796919..12b5e728 100644 --- a/ios/AidenOnTheGo/Features/Remote/AidenWorkspaceEnvironmentView.swift +++ b/ios/AidenOnTheGo/Features/Remote/AidenWorkspaceEnvironmentView.swift @@ -64,7 +64,7 @@ actor AidenWorkspaceEnvironmentCache { try? FileManager.default.removeItem(at: instanceDirectory) // The legacy flat format encoded instance + workspace identity in its // filename but not its payload. Delete only names attributable from a - // known workspace snapshot; never erase another Mac's unknown cache. + // known workspace snapshot; never erase another desktop's unknown cache. for workspaceId in knownWorkspaceIds { try? FileManager.default.removeItem( at: legacyFile(instanceId: instanceId, workspaceId: workspaceId) @@ -240,7 +240,7 @@ final class AidenWorkspaceFilesModel { guard coordinator.isCurrent(context) else { return false } if case AidenRemoteClientError.server(_, let body) = error, body.code.rawValue == "revision_conflict" { - errorMessage = "This file changed on the Mac. Reload it before saving again." + errorMessage = "This file changed on the paired desktop. Reload it before saving again." coordinator.haptics.play(.warning, scope: hapticScope) } else { errorMessage = error.localizedDescription @@ -357,8 +357,8 @@ private struct AidenWorkspaceFileEditorView: View { if let message = model.errorMessage { VStack(spacing: 8) { Text(message).font(.footnote).foregroundStyle(.secondary) - if message.contains("changed on the Mac") { - Button("Reload from Mac") { + if message.contains("changed on the desktop") { + Button("Reload from desktop") { Task { await model.reloadDocument(coordinator: coordinator) } } } diff --git a/ios/AidenOnTheGo/Features/Remote/AidenWorkspaceShellView.swift b/ios/AidenOnTheGo/Features/Remote/AidenWorkspaceShellView.swift index 14c682dc..bd3cde9a 100644 --- a/ios/AidenOnTheGo/Features/Remote/AidenWorkspaceShellView.swift +++ b/ios/AidenOnTheGo/Features/Remote/AidenWorkspaceShellView.swift @@ -1351,7 +1351,7 @@ private struct AidenWorkspacesDirectoryView: View { searchText.isEmpty ? "No Workspaces" : "No Matching Workspaces", systemImage: searchText.isEmpty ? "folder" : "magnifyingglass", description: Text(searchText.isEmpty - ? "Create a workspace or add a Mac folder to get started." + ? "Create a workspace or add a desktop folder to get started." : "Try a different search term.") ) .listRowBackground(Color.clear) @@ -1401,7 +1401,7 @@ private struct AidenWorkspacesDirectoryView: View { } Button { isShowingFolderBrowser = true } label: { - Label("Add Mac Folder", systemImage: "folder.badge.plus") + Label("Add Desktop Folder", systemImage: "folder.badge.plus") } } label: { Image(systemName: "plus") @@ -1439,7 +1439,7 @@ private struct AidenWorkspacesDirectoryView: View { } .disabled(newWorkspaceName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } message: { - Text("Creates a workspace registry entry without a Mac folder. You can add a folder later from Aiden Agent.") + Text("Creates a workspace registry entry without a desktop folder. You can add a folder later from Aiden Agent.") } .confirmationDialog( "Create a managed scratch workspace?", @@ -1459,7 +1459,7 @@ private struct AidenWorkspacesDirectoryView: View { } Button("Cancel", role: .cancel) {} } message: { - Text("Aiden Agent will create and manage the worktree on your Mac.") + Text("Aiden Agent will create and manage the worktree on your desktop.") } .alert( "Rename Workspace", @@ -1494,7 +1494,7 @@ private struct AidenWorkspacesDirectoryView: View { || coordinator.isMutating ) } message: { - Text("This updates the workspace name in Aiden Agent on your Mac and paired clients. It does not rename the folder on disk.") + Text("This updates the workspace name in Aiden Agent on your desktop and paired clients. It does not rename the folder on disk.") } .alert( "Archive on This Device?", @@ -1514,7 +1514,7 @@ private struct AidenWorkspacesDirectoryView: View { workspacePendingFirstArchive = nil } } message: { - Text("This hides the workspace and its chats only on this iPhone or iPad. It stays available in Aiden Agent on your Mac and on other devices.") + Text("This hides the workspace and its chats only on this iPhone or iPad. It stays available in Aiden Agent on your desktop and on other devices.") } .alert( "Remove from Aiden Agent?", @@ -1542,7 +1542,7 @@ private struct AidenWorkspacesDirectoryView: View { } .disabled(coordinator.connectionState != .connected || coordinator.isMutating) } message: { - Text("This unregisters the workspace from Aiden Agent and paired clients. Its folder, files, and chats stay on your Mac, but its chats will no longer be listed. Delete the folder separately in Finder if you no longer need it.") + Text("This unregisters the workspace from Aiden Agent and paired clients. Its folder, files, and chats stay on your desktop, but its chats will no longer be listed. Delete the folder separately in your system file manager if you no longer need it.") } } @@ -1884,7 +1884,7 @@ private struct AidenWorkspaceSettingsView: View { } footer: { Text(workspace.isManagedWorktree ? "Deleting an Aiden-managed worktree removes its checkout and may remove its branch when safe." - : "Removing unregisters this workspace from Aiden Agent and paired clients. Its folder, files, and chats stay on your Mac, but its chats will no longer be listed.") + : "Removing unregisters this workspace from Aiden Agent and paired clients. Its folder, files, and chats stay on your desktop, but its chats will no longer be listed.") } } } @@ -1944,7 +1944,7 @@ private struct AidenWorkspaceSettingsView: View { } message: { Text(workspace.isManagedWorktree ? "This destructive Git operation is performed by Aiden Agent using its persisted worktree ownership record." - : "The folder, its files, and chats remain on your Mac, but the chats will no longer be listed. Delete the folder separately in Finder if you no longer need it.") + : "The folder, its files, and chats remain on your desktop, but the chats will no longer be listed. Delete the folder separately in your system file manager if you no longer need it.") } } .onAppear { coordinator.haptics.activate(scope: hapticScope) } @@ -2235,7 +2235,7 @@ private struct AidenUsageView: View { .foregroundStyle(palette.accent) .frame(width: 28) - Text("Privacy-safe aggregates are recorded by Aiden Agent on your Mac. Prompts, responses, chat IDs, workspace IDs, and file paths are not included.") + Text("Privacy-safe aggregates are recorded by Aiden Agent on your desktop. Prompts, responses, chat IDs, workspace IDs, and file paths are not included.") .font(.footnote) .foregroundStyle(palette.secondary) .fixedSize(horizontal: false, vertical: true) @@ -2404,7 +2404,7 @@ private struct AidenAppSettingsView: View { Form { Section("Aiden Agent") { LabeledContent( - "Connected Mac", + "Connected desktop", value: coordinator.installationStore.activeInstallation?.name ?? "Not connected" ) Button { @@ -2441,7 +2441,7 @@ private struct AidenAppSettingsView: View { NavigationLink { AidenMacTranscriptionSettingsView(coordinator: coordinator) } label: { - Label("Mac speech model", systemImage: "desktopcomputer") + Label("Desktop speech model", systemImage: "desktopcomputer") } } } header: { @@ -2449,7 +2449,7 @@ private struct AidenAppSettingsView: View { } footer: { Text( voiceInputModeRaw == AidenVoiceInputMode.pairedMac.rawValue - ? "Microphone audio is sent over Aiden's encrypted pinned connection, processed by Parakeet on your paired Mac, and not retained. Text appears after you stop recording." + ? "Microphone audio is sent over Aiden's encrypted pinned connection, processed by Parakeet on your paired desktop, and not retained. Text appears after you stop recording." : "Uses Apple's on-device Speech framework. Microphone audio stays on this device." ) } @@ -2550,13 +2550,13 @@ private struct AidenMacTranscriptionSettingsView: View { } } } else if isLoading { - ProgressView("Loading Mac speech models…") + ProgressView("Loading desktop speech models…") } if let errorMessage { Section { Text(errorMessage).foregroundStyle(.red) } } } - .navigationTitle("Mac Transcription") + .navigationTitle("Desktop Transcription") .navigationBarTitleDisplayMode(.inline) .task { await refresh() } .task(id: downloadPollKey) { @@ -2894,7 +2894,7 @@ private struct AidenFolderBrowserView: View { } } } - .navigationTitle("Add Mac Folder") + .navigationTitle("Add Desktop Folder") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { diff --git a/ios/AidenOnTheGo/Models/AidenBot.swift b/ios/AidenOnTheGo/Models/AidenBot.swift index 1ff9cc63..c222e682 100644 --- a/ios/AidenOnTheGo/Models/AidenBot.swift +++ b/ios/AidenOnTheGo/Models/AidenBot.swift @@ -8,7 +8,7 @@ enum AidenBotContractError: Error, Equatable, LocalizedError { switch self { case .invalidCombination("no available provider and model"): String( - localized: "Set up a provider and model on your Mac. In Aiden Agent, open Settings → Providers, connect or refresh a provider, and make at least one chat model available. Then tap Try Again." + localized: "Set up a provider and model on your paired desktop. In Aiden Agent, open Settings → Providers, connect or refresh a provider, and make at least one chat model available. Then tap Try Again." ) case .invalidCombination("unavailable custom access"): String( diff --git a/ios/AidenOnTheGo/Models/AidenChat.swift b/ios/AidenOnTheGo/Models/AidenChat.swift index 6a2e9b43..d3405c34 100644 --- a/ios/AidenOnTheGo/Models/AidenChat.swift +++ b/ios/AidenOnTheGo/Models/AidenChat.swift @@ -441,7 +441,7 @@ enum AidenAgentActivityPresentation { "web_search": ("Searching the web", "Searched the web"), "schedule_task": ("Scheduling", "Scheduled"), "edit_automation": ("Editing automation", "Edited automation"), - "computer_use": ("Using Mac", "Used Mac"), + "computer_use": ("Using Computer Use", "Used Computer Use"), "compact_context": ("Compacting context", "Compacted context"), ] @@ -556,7 +556,7 @@ enum AidenAgentActivityPresentation { if changes > 0 { clauses.append("\(running ? "editing" : "edited") \(changes) file\(changes == 1 ? "" : "s")") } if commands > 0 { clauses.append("\(running ? "running" : "ran") \(commands) command\(commands == 1 ? "" : "s")") } if web > 0 { clauses.append("\(web) web search\(web == 1 ? "" : "es")") } - if mac > 0 { clauses.append("\(mac) Mac action\(mac == 1 ? "" : "s")") } + if mac > 0 { clauses.append("\(mac) Computer Use action\(mac == 1 ? "" : "s")") } if compactions > 0 { clauses.append(running ? "compacting context" : "compacted context") } if other > 0 { clauses.append("\(other) tool call\(other == 1 ? "" : "s")") } if clauses.isEmpty { return running ? "Working" : "Used \(tools.count) tool\(tools.count == 1 ? "" : "s")" } diff --git a/ios/AidenOnTheGo/Networking/AidenRemoteClient.swift b/ios/AidenOnTheGo/Networking/AidenRemoteClient.swift index 9349881e..5d02bb4d 100644 --- a/ios/AidenOnTheGo/Networking/AidenRemoteClient.swift +++ b/ios/AidenOnTheGo/Networking/AidenRemoteClient.swift @@ -315,7 +315,7 @@ enum AidenRemoteClientError: Error, LocalizedError { case .missingTrustConfiguration: return "This Aiden installation must be paired again to establish secure server trust." case .installationChanged: - return "The active Aiden Agent changed. Try again on the selected Mac." + return "The active Aiden Agent changed. Try again on the selected desktop." } } } @@ -461,7 +461,7 @@ final class AidenRemoteClient: @unchecked Sendable { ) } catch let AidenRemoteClientError.server(statusCode, body) where statusCode == 400 && body.code.rawValue == "invalid_request" { - // Strict early-v1 Macs reject additive request keys before consuming + // Strict early-v1 desktops reject additive request keys before consuming // the one-time secret. Retry once with the frozen four-field shape. exchange = try await client.send( method: "POST", diff --git a/ios/AidenOnTheGo/Networking/AidenRemoteContract.swift b/ios/AidenOnTheGo/Networking/AidenRemoteContract.swift index db108baf..71d2833f 100644 --- a/ios/AidenOnTheGo/Networking/AidenRemoteContract.swift +++ b/ios/AidenOnTheGo/Networking/AidenRemoteContract.swift @@ -64,7 +64,7 @@ enum AidenManualPairingError: Error, Equatable, LocalizedError { var errorDescription: String? { switch self { case .invalidCode: - return String(localized: "Enter the 20-character setup code shown on your Mac.") + return String(localized: "Enter the 20-character setup code shown on your desktop.") case .invalidBootstrap: return String(localized: "Aiden Agent returned an invalid manual pairing response.") case .decryptionFailed: diff --git a/ios/AidenOnTheGo/Persistence/AidenChatCache.swift b/ios/AidenOnTheGo/Persistence/AidenChatCache.swift index 788bde2c..97abbcc6 100644 --- a/ios/AidenOnTheGo/Persistence/AidenChatCache.swift +++ b/ios/AidenOnTheGo/Persistence/AidenChatCache.swift @@ -344,7 +344,7 @@ actor AidenChatCache { // Older active-stream records did not contain deviceId and cannot // decode with the current schema. Their outer envelope still has // an exact installation identity, so explicit forget/re-pair can - // remove them without touching another Mac's cache. + // remove them without touching another desktop's cache. guard let data = try? Data(contentsOf: url), data.count <= maxCacheFileBytes, let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], diff --git a/ios/AidenOnTheGoTests/AidenBotGeneratedAvatarTests.swift b/ios/AidenOnTheGoTests/AidenBotGeneratedAvatarTests.swift index 1953e20c..60452c97 100644 --- a/ios/AidenOnTheGoTests/AidenBotGeneratedAvatarTests.swift +++ b/ios/AidenOnTheGoTests/AidenBotGeneratedAvatarTests.swift @@ -723,7 +723,7 @@ final class AidenBotGeneratedAvatarTests: XCTestCase { XCTAssertEqual(model.authoritativeBot?.avatar.asset?.assetRevision, macRevision) XCTAssertNotNil(model.currentImage) XCTAssertEqual(model.phase, .idle) - XCTAssertTrue(model.errorMessage?.contains("changed on your Mac") == true) + XCTAssertTrue(model.errorMessage?.contains("changed on your paired desktop") == true) } @MainActor @@ -804,7 +804,7 @@ final class AidenBotGeneratedAvatarTests: XCTestCase { XCTAssertEqual(model.phase, .idle) XCTAssertEqual(model.authoritativeBot?.avatar.asset?.assetRevision, replacementRevision) XCTAssertNotNil(model.currentImage) - XCTAssertTrue(model.errorMessage?.contains("changed on your Mac") == true) + XCTAssertTrue(model.errorMessage?.contains("changed on your paired desktop") == true) } @MainActor diff --git a/ios/AidenOnTheGoTests/AidenChatTests.swift b/ios/AidenOnTheGoTests/AidenChatTests.swift index 8153c51b..5b09351d 100644 --- a/ios/AidenOnTheGoTests/AidenChatTests.swift +++ b/ios/AidenOnTheGoTests/AidenChatTests.swift @@ -398,7 +398,7 @@ final class AidenChatTests: XCTestCase { ) XCTAssertEqual( AidenAgentActivityPresentation.summary(timeline), - "1 web search, 1 Mac action, compacted context, 1 tool call" + "1 web search, 1 Computer Use action, compacted context, 1 tool call" ) } @@ -1354,7 +1354,7 @@ final class AidenChatTests: XCTestCase { )), .init( title: "Generation failed", - detail: "The model provider rejected its credentials. Check Provider Settings on your Mac.", + detail: "The model provider rejected its credentials. Check Provider Settings on your desktop.", symbol: "exclamationmark.triangle", isFailure: true ) diff --git a/ios/AidenOnTheGoTests/AidenNativeIntegrationTests.swift b/ios/AidenOnTheGoTests/AidenNativeIntegrationTests.swift index 9051f11e..bb667524 100644 --- a/ios/AidenOnTheGoTests/AidenNativeIntegrationTests.swift +++ b/ios/AidenOnTheGoTests/AidenNativeIntegrationTests.swift @@ -591,7 +591,7 @@ final class AidenNativeIntegrationTests: XCTestCase { XCTAssertEqual(AidenVoiceInputMode.defaultsKey, "aiden.voiceInput.mode") XCTAssertEqual(AidenVoiceInputMode.allCases, [.onDevice, .pairedMac]) XCTAssertEqual(AidenVoiceInputMode.onDevice.title, "On this device") - XCTAssertEqual(AidenVoiceInputMode.pairedMac.title, "Paired Mac") + XCTAssertEqual(AidenVoiceInputMode.pairedMac.title, "Paired desktop") } func testVoiceSessionFenceRejectsCallbacksFromAnInvalidatedSession() { diff --git a/ios/AidenOnTheGoTests/AidenProductShellTests.swift b/ios/AidenOnTheGoTests/AidenProductShellTests.swift index fd0629bf..4f7e0fe4 100644 --- a/ios/AidenOnTheGoTests/AidenProductShellTests.swift +++ b/ios/AidenOnTheGoTests/AidenProductShellTests.swift @@ -355,7 +355,7 @@ final class AidenProductShellTests: XCTestCase { state: .waitingForApproval, canRespondToApproval: false )?.label, - "Waiting for approval on Mac" + "Waiting for desktop approval" ) XCTAssertEqual( aidenBotInboxActivityStatus(state: .running, canRespondToApproval: false)?.label, @@ -529,7 +529,7 @@ final class AidenProductShellTests: XCTestCase { ) XCTAssertEqual( aidenBotSwitcherCoachmarkDetail(canWrite: false), - "This Mac shared Bots as read-only. You can open their conversations here, then change Bot access on your Mac if you want to let them act." + "This desktop shared Bots as read-only. You can open their conversations here, then change Bot access on your paired desktop if you want to let them act." ) } diff --git a/ios/CONTEXT.md b/ios/CONTEXT.md index 1fd0bf78..a9393c6a 100644 --- a/ios/CONTEXT.md +++ b/ios/CONTEXT.md @@ -1,9 +1,9 @@ # Aiden On The Go terms -- **Aiden installation:** one paired Aiden Agent Mac, identified by its server-issued instance ID. -- **Device credential:** the per-phone or per-iPad bearer secret stored only in Keychain and revocable on the Mac. +- **Aiden installation:** one paired Aiden Agent desktop, identified by its server-issued instance ID. +- **Device credential:** the per-phone or per-iPad bearer secret stored only in Keychain and revocable on the desktop. - **Workspace:** an Aiden registry entry with `full`, `ask`, or `none` permission. -- **Approved root:** a Mac folder explicitly exposed for remote browsing by a local desktop action. +- **Approved root:** a desktop folder explicitly exposed for remote browsing by a local desktop action. - **Location handle:** a short-lived opaque browser capability; never a filesystem path. - **Selection:** a short-lived, single-use capability consumed atomically when registering a selected folder. - **Remote turn:** one idempotently admitted user message whose generation remains owned by Aiden Agent across network loss. diff --git a/ios/PROJECT_INTENT.md b/ios/PROJECT_INTENT.md index 11a7f5f6..c655eb48 100644 --- a/ios/PROJECT_INTENT.md +++ b/ios/PROJECT_INTENT.md @@ -1,12 +1,12 @@ # Aiden On The Go — Project Intent -Aiden On The Go lets a user securely control their own Aiden Agent Mac from iPhone or iPad. It is a client, not an agent runtime or hosted service. +Aiden On The Go lets a user securely control their own Aiden Agent desktop from iPhone or iPad. It is a client, not an agent runtime or hosted service. Core boundaries: -- Pair explicitly with a Mac using a short-lived QR bootstrap and per-device revocable credential. -- Connect over pinned local HTTPS or the Mac's explicitly configured Tailscale route. -- Match Aiden's chat and workspace behavior without widening permissions or exposing private Mac paths. +- Pair explicitly with a desktop using a short-lived QR bootstrap and per-device revocable credential. +- Connect over pinned local HTTPS or the desktop's explicitly configured Tailscale route. +- Match Aiden's chat and workspace behavior without widening permissions or exposing private desktop paths. - Keep credentials in Keychain and bounded offline presentation state on device. - Use native SwiftUI controls and adaptive Apple navigation. diff --git a/ios/PROJECT_SPEC.md b/ios/PROJECT_SPEC.md index be9f5707..165d57b6 100644 --- a/ios/PROJECT_SPEC.md +++ b/ios/PROJECT_SPEC.md @@ -20,7 +20,7 @@ The complete planned product includes: - Multiple paired Aiden installations with QR or 100-bit setup-code pairing, Keychain credentials, discovery, manual URL entry, switching, and revocation handling. - Chat list/open/create/rename/delete, bounded attachments, provider/model/thinking selection, atomic turn start, resumable streaming, cancel, reasoning/tool/timeline status, and allow/deny approvals. -- Workspace registry list/create/update/unregister, including folderless, managed scratch, and folders selected through a server-approved Mac directory browser. +- Workspace registry list/create/update/unregister, including folderless, managed scratch, and folders selected through a server-approved desktop directory browser. - Workspace Settings from the conversation toolbar ellipsis. Workspace permission is never a composer control. - Device-local Aiden, Slate, Berry, and Moss appearance presets plus supported mobile appearance options. - Aiden workspace file index/read/version-checked write and the existing Aiden Git review/diff/compare/branch/commit/push/managed-worktree operations. @@ -39,13 +39,13 @@ Remove Kanban, Hermes projects/profiles/personalities, Hermes Skills/Memory/Insi ## 3. Security invariants -- Remote Access is off by default and has no listener until enabled on the Mac. +- Remote Access is off by default and has no listener until enabled on the desktop. - Tailscale supplies reachability, never app authorization. Aiden manages only the exact non-Funnel Serve route it owns and never invokes `tailscale serve reset`. - Local-network production transport is HTTPS. QR pairing pins the Aiden installation's stable P-256 SPKI SHA-256 fingerprint. Plain HTTP is development-build-only. - Pairing secrets are high entropy, short lived, single use, rate limited, and never logged. The reviewed manual path uses a uniformly random 100-bit Crockford code only as a local HKDF input for authenticated decryption of the existing certificate-pinned trust envelope; lower-entropy human-sized codes still require a reviewed PAKE/SAS or explicit fingerprint confirmation. -- Device credentials are random, stored as digests on Mac and in Keychain on iOS, capability scoped, revocable, and never placed in URLs, App Group data, App Intents, logs, or Live Activities. +- Device credentials are random, stored as digests on the desktop and in Keychain on iOS, capability scoped, revocable, and never placed in URLs, App Group data, App Intents, logs, or Live Activities. - DTOs are allowlists. Absolute paths, provider/MCP credentials, raw diagnostics, Git admin paths/tokens, schedule runtime internals, and private agent history never cross the API. -- Directory and file handles are opaque server-side capabilities bound to instance, device, workspace/root identity, policy revision, expiry, and snapshot. The client never submits a free-form Mac path. +- Directory and file handles are opaque server-side capabilities bound to instance, device, workspace/root identity, policy revision, expiry, and snapshot. The client never submits a free-form desktop path. - Workspace selection consumption and workspace creation are atomic and idempotent. Filesystem identity and canonical root membership are revalidated immediately before mutation. - Workspace turns honor the workspace's saved `full`, `ask`, or `none` permission. Bot turns instead honor a main-owned, revisioned Full/Custom policy: Full is explicit after the current notice, Custom uses exact reductions, and corrupt, missing-after-migration, or future-version policy state fails closed. Neither transport can mint Assistant/unattended modes or silently enable Computer Use. - Every bot has exactly one main-owned managed home. The Mac sets it as the shell/tool working directory and ordinary save location, does not initialize `.git`, and injects the operating contract after editable bot instructions so a phone, renderer, or prompt cannot replace it. Full Access may inspect other OS-accessible Mac locations only as needed and remains subject to OS permissions, global disables, approvals, and destructive-action safeguards. diff --git a/ios/README.md b/ios/README.md index 7b578224..03eac8a2 100644 --- a/ios/README.md +++ b/ios/README.md @@ -1,6 +1,6 @@ # Aiden On The Go -Aiden On The Go is the native SwiftUI companion for Aiden Agent on macOS. The Mac owns execution, persistence, providers, workspaces, and permissions; iPhone and iPad provide an authenticated remote control surface over a local network or Tailscale. +Aiden On The Go is the native SwiftUI companion for Aiden Agent on macOS and Linux. The paired desktop owns execution, persistence, providers, workspaces, and permissions; iPhone and iPad provide an authenticated remote control surface over a local network or Tailscale. The product and protocol sources of truth are: diff --git a/ios/app-store/MOBILE_PRIVACY_SUPPORT_COPY.md b/ios/app-store/MOBILE_PRIVACY_SUPPORT_COPY.md index 380e6afe..92c0fdb7 100644 --- a/ios/app-store/MOBILE_PRIVACY_SUPPORT_COPY.md +++ b/ios/app-store/MOBILE_PRIVACY_SUPPORT_COPY.md @@ -2,37 +2,37 @@ Status: ready for owner/legal review and publication on `chatwithaiden.com`. The website source is not part of this repository, so this file does not claim that the live page has changed. -The current public policy says Aiden is a local-first macOS app. Before submitting Aiden On The Go, replace that product-limited wording and add the following sections while preserving the existing provider and website-hosting disclosures. +The current public policy says Aiden is a local-first macOS app. Before submitting Aiden On The Go or shipping the Linux desktop build, replace that product-limited wording and add the following sections while preserving the existing provider and website-hosting disclosures. ## Overview replacement -Aiden is designed as a local-first Mac app with an optional native iPhone and iPad companion called Aiden On The Go. We do not collect, sell, rent, or share personal information through the Aiden website or apps. Aiden On The Go connects directly to an Aiden Agent installation that you choose and control; Aiden does not provide a hosted relay or central synchronization service for that connection. +Aiden is designed as a local-first macOS and Linux desktop app with an optional native iPhone and iPad companion called Aiden On The Go. We do not collect, sell, rent, or share personal information through the Aiden website or apps. Aiden On The Go connects directly to an Aiden Agent installation that you choose and control; Aiden does not provide a hosted relay or central synchronization service for that connection. ## Local app data replacement -Aiden Agent stores chat history, workspace configuration, and app settings locally on your Mac. Provider API keys are stored on your Mac, such as in macOS Keychain, and are not copied into Aiden On The Go or sent to Aiden servers. +Aiden Agent stores chat history, workspace configuration, and app settings locally on your desktop. Provider API keys are stored in the operating system's credential store (such as macOS Keychain or a supported Linux Secret Service or KWallet backend) and are not copied into Aiden On The Go or sent to Aiden servers. Aiden On The Go stores its pairing credential in the iPhone or iPad Keychain. It may keep device-local settings and bounded caches for paired-installation names, workspaces, chats, navigation, and last-known run status. App Intents use a limited App Group cache containing stable identifiers and display names; they do not receive the pairing credential or contact the network. You can remove a paired installation from the mobile app, revoke a device from Aiden Agent, or remove the app and its local data using normal iOS or iPadOS controls. ## Mobile remote access -Remote Access is off by default in Aiden Agent. When you enable and pair Aiden On The Go, the mobile app connects directly to your Mac over your local network or a Tailscale connection you configure. Pairing uses a short-lived, one-use session and creates a revocable device credential. Aiden does not enable Tailscale Funnel or route this traffic through an Aiden-operated service. +Remote Access is off by default in Aiden Agent. When you enable and pair Aiden On The Go, the mobile app connects directly to your desktop over your local network or a Tailscale connection you configure. Pairing uses a short-lived, one-use session and creates a revocable device credential. Aiden does not enable Tailscale Funnel or route this traffic through an Aiden-operated service. -Chats, prompts, selected attachments, workspace operations, and approval decisions sent from Aiden On The Go go to the paired Mac. If a request uses an AI provider configured in Aiden Agent, the Mac may then send prompts, selected files, metadata, and responses to that provider or local model service under the provider's privacy policy, retention rules, and account settings. +Chats, prompts, selected attachments, workspace operations, and approval decisions sent from Aiden On The Go go to the paired desktop. If a request uses an AI provider configured in Aiden Agent, the desktop may then send prompts, selected files, metadata, and responses to that provider or local model service under the provider's privacy policy, retention rules, and account settings. ## iPhone and iPad permissions - **Local Network:** used only to discover or connect to an Aiden Agent installation on a network you choose. - **Camera:** used when you choose to scan a pairing QR code. Camera frames are processed for pairing and are not uploaded to Aiden. -- **Photos and Files:** content is accessed only after you select it. Selected content is sent to the paired Mac and may be processed by the AI provider you chose for the request. -- **Microphone and Speech Recognition:** requested only when you start dictation. In **On this device** mode, the app uses the platform speech API. In **Paired Mac** mode, it sends a bounded microphone recording through the authenticated, encrypted Aiden connection to the selected local Parakeet model on your Mac; neither endpoint stores the recording. The text composer remains usable if recognition is unavailable or permission is denied. +- **Photos and Files:** content is accessed only after you select it. Selected content is sent to the paired desktop and may be processed by the AI provider you chose for the request. +- **Microphone and Speech Recognition:** requested only when you start dictation. In **On this device** mode, the app uses the platform speech API. In **Paired desktop** mode, it sends a bounded microphone recording through the authenticated, encrypted Aiden connection to the selected local Parakeet model on your desktop; neither endpoint stores the recording. The text composer remains usable if recognition is unavailable or permission is denied. - **Notifications and Live Activities:** used for device-local status. Live Activities contain bounded last-known run state, use no Aiden cloud push relay, and hide assistant response excerpts by default. ## Bot image creation Bots always include an Aiden semantic avatar that works without Apple Intelligence. On supported devices running a compatible iOS or iPadOS version, you may choose **Create with Apple Intelligence** to open Apple's system Image Playground. Apple controls image generation and may use Private Cloud Compute under Apple's privacy terms. Personalization from people or the Photos library is disabled by Aiden, and Aiden supplies only the Bot name and purpose visible in the editor as starting concepts. -Aiden does not send Image Playground concepts, rejected candidates, or temporary file locations to Aiden's developer or to your paired Mac. Apple controls the system sheet and any Private Cloud Compute processing of the visible Bot name and purpose concepts. After you explicitly accept an image in Apple's sheet, Aiden copies it temporarily inside the app, removes metadata, center-crops and re-encodes it, and shows a preview. Only when you choose **Use this image** does Aiden send the normalized image directly to your paired Mac over the authenticated Remote Access connection. The Mac independently validates and stores its canonical copy. Temporary mobile candidates are deleted after use, cancellation, replacement, pairing changes, or editor dismissal. You can remove a generated Bot photo and return to the semantic avatar at any time. +Aiden does not send Image Playground concepts, rejected candidates, or temporary file locations to Aiden's developer or to your paired desktop. Apple controls the system sheet and any Private Cloud Compute processing of the visible Bot name and purpose concepts. After you explicitly accept an image in Apple's sheet, Aiden copies it temporarily inside the app, removes metadata, center-crops and re-encodes it, and shows a preview. Only when you choose **Use this image** does Aiden send the normalized image directly to your paired desktop over the authenticated Remote Access connection. The desktop independently validates and stores its canonical copy. Temporary mobile candidates are deleted after use, cancellation, replacement, pairing changes, or editor dismissal. You can remove a generated Bot photo and return to the semantic avatar at any time. Opening a web link or displaying externally hosted transcript media can make a normal network request to that third-party host. Aiden credentials are not forwarded to the host; the host may receive ordinary request information such as the device's network address under its own policy. @@ -45,6 +45,6 @@ Questions about privacy or support for Aiden Agent and Aiden On The Go can be se - Update the policy's “Last updated” date when this copy is published. - Preserve the existing disclosure that third-party AI providers process requests under their own policies. - Keep the direct statement that Aiden does not collect chats, prompts, selected files, provider keys, model responses, device identifiers, precise location, payment information, or analytics events unless the shipped product or operational services change. -- Describe Image Playground as Apple-controlled processing that may use Private Cloud Compute; do not promise universal on-device generation. Preserve the accepted-image-only direct-to-paired-Mac boundary. +- Describe Image Playground as Apple-controlled processing that may use Private Cloud Compute; do not promise universal on-device generation. Preserve the accepted-image-only direct-to-paired-desktop boundary. - Make the support email visibly reachable from `https://chatwithaiden.com/`, which is the App Store support URL. - Recheck this copy against the final distribution candidate and App Privacy answers before every submission. diff --git a/ios/app-store/metadata/version/0.1.0/en-US.json b/ios/app-store/metadata/version/0.1.0/en-US.json index b56efca7..173de7a1 100644 --- a/ios/app-store/metadata/version/0.1.0/en-US.json +++ b/ios/app-store/metadata/version/0.1.0/en-US.json @@ -1,5 +1,5 @@ { - "description": "Aiden On The Go is the native iPhone and iPad companion for Aiden Agent on your Mac. Pair directly with a Mac you control over your local network or Tailscale, then continue Aiden chats and manage workspaces from your mobile device.\n\nReview conversations, stream responses, handle approval requests, inspect workspace files, work with supported Git flows, and manage scheduled tasks. App Intents provide quick navigation, Live Activities show bounded run status, and optional voice dictation can use native recognition or a local model on your paired Mac. Read-aloud stays on device.\n\nYour Mac remains the execution authority. Remote Access is off by default, each mobile device uses a revocable credential, and provider credentials remain on the Mac.", + "description": "Aiden On The Go is the native iPhone and iPad companion for Aiden Agent on your macOS or Linux desktop. Pair directly with a desktop you control over your local network or Tailscale, then continue Aiden chats and manage workspaces from your mobile device.\n\nReview conversations, stream responses, handle approval requests, inspect workspace files, work with supported Git flows, and manage scheduled tasks. App Intents provide quick navigation, Live Activities show bounded run status, and optional voice dictation can use native recognition or a local model on your paired desktop. Read-aloud stays on device.\n\nYour desktop remains the execution authority. Remote Access is off by default, each mobile device uses a revocable credential, and provider credentials remain on the desktop.", "keywords": "AI assistant,agent,developer,Git,workspace,chat,automation,remote,Tailscale,Swift", "marketingUrl": "https://chatwithaiden.com/", "supportUrl": "https://chatwithaiden.com/" diff --git a/main/application-lifecycle-core.test.ts b/main/application-lifecycle-core.test.ts new file mode 100644 index 00000000..92092f9e --- /dev/null +++ b/main/application-lifecycle-core.test.ts @@ -0,0 +1,12 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { shouldQuitAfterAllWindowsClose } from "./application-lifecycle-core.js"; + +test("macOS retains its conventional last-window behavior", () => { + assert.equal(shouldQuitAfterAllWindowsClose("darwin", false), false); +}); + +test("Linux quits without background ownership and stays alive for Remote Access", () => { + assert.equal(shouldQuitAfterAllWindowsClose("linux", false), true); + assert.equal(shouldQuitAfterAllWindowsClose("linux", true), false); +}); diff --git a/main/application-lifecycle-core.ts b/main/application-lifecycle-core.ts new file mode 100644 index 00000000..87e11183 --- /dev/null +++ b/main/application-lifecycle-core.ts @@ -0,0 +1,6 @@ +export function shouldQuitAfterAllWindowsClose( + platform: NodeJS.Platform, + backgroundServiceRunning: boolean, +): boolean { + return platform !== "darwin" && !backgroundServiceRunning; +} diff --git a/main/desktop-cli-core.test.ts b/main/desktop-cli-core.test.ts new file mode 100644 index 00000000..13c87baa --- /dev/null +++ b/main/desktop-cli-core.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { desktopVersionRequested } from "./desktop-cli-core.js"; + +test("packaged desktop recognizes only an explicit user --version argument", () => { + assert.equal(desktopVersionRequested(["/opt/Aiden Agent/aiden-agent"], false), false); + assert.equal( + desktopVersionRequested( + ["/opt/Aiden Agent/aiden-agent", "--no-sandbox", "--version"], + false, + ), + true, + ); +}); + +test("development desktop does not mistake the application path for an argument", () => { + assert.equal( + desktopVersionRequested(["/repo/node_modules/.bin/electron", "--version"], true), + false, + ); + assert.equal( + desktopVersionRequested( + ["/repo/node_modules/.bin/electron", "/repo", "--version"], + true, + ), + true, + ); +}); diff --git a/main/desktop-cli-core.ts b/main/desktop-cli-core.ts new file mode 100644 index 00000000..274e01e8 --- /dev/null +++ b/main/desktop-cli-core.ts @@ -0,0 +1,8 @@ +export function desktopVersionRequested( + argv: readonly string[], + defaultApp: boolean, +): boolean { + // Packaged Electron starts user arguments after argv[0]. Development + // Electron reserves argv[1] for the application path. + return argv.slice(defaultApp ? 2 : 1).includes("--version"); +} diff --git a/main/handlers/app.ts b/main/handlers/app.ts index 73d0d61f..41e9d29c 100644 --- a/main/handlers/app.ts +++ b/main/handlers/app.ts @@ -18,6 +18,7 @@ import { app, logger } from "../platform.js"; import { currentRuntimeProfile } from "../runtime-profile.js"; +import { hostPlatformCapabilities } from "../services/host-platform-capabilities.js"; import { subagentsEnabled } from "../services/subagents/feature-flag.js"; // App handlers - these are the methods your app provides to the frontend @@ -25,12 +26,20 @@ export const appHandlers = { // Example: Get app information getInfo: async () => { logger.info("app", "App info requested"); + const host = hostPlatformCapabilities(); return { name: app.getName(), version: app.getVersion(), environment: currentRuntimeProfile().id, capabilities: { + platform: host.platform, subagents: subagentsEnabled(), + bots: host.bots, + computerUse: host.computerUse, + dockIcon: host.dockIcon, + accessibilityPaste: host.accessibilityPaste, + nativeShare: host.nativeShare, + appleFoundationModels: host.appleFoundationModels, }, }; }, diff --git a/main/handlers/bots-platform-contract.test.ts b/main/handlers/bots-platform-contract.test.ts new file mode 100644 index 00000000..37c06c64 --- /dev/null +++ b/main/handlers/bots-platform-contract.test.ts @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +test("Bot IPC registration is narrowed by the main-owned host policy", () => { + const source = readFileSync(new URL("./index.ts", import.meta.url), "utf8"); + assert.match( + source, + /if \(hostPlatformCapabilities\(\)\.bots\) registerBotHandlers\(\);\s+registerBtwHandlers\(\)/u, + ); +}); + +test("ordinary chat paths cannot activate Bot services on unsupported hosts", () => { + const chatHandlers = readFileSync(new URL("./chats.ts", import.meta.url), "utf8"); + const llmClient = readFileSync( + new URL("../services/llm-client.ts", import.meta.url), + "utf8", + ); + assert.match( + chatHandlers, + /if \(source\.botId\) \{\s+if \(!hostPlatformCapabilities\(\)\.bots\)/u, + ); + assert.match( + chatHandlers, + /if \(chat\?\.botId\) \{\s+if \(!hostPlatformCapabilities\(\)\.bots\) \{\s+return chatApplicationService\.remove\(chatId\)/u, + ); + assert.match( + llmClient, + /if \(chat\.botId && !hostPlatformCapabilities\(\)\.bots\) \{\s+throw new Error\("Bot chats are not available on this platform\."\)/u, + ); +}); diff --git a/main/handlers/bots.contract.test.ts b/main/handlers/bots.contract.test.ts index 65018d97..200ced10 100644 --- a/main/handlers/bots.contract.test.ts +++ b/main/handlers/bots.contract.test.ts @@ -175,7 +175,7 @@ test("Remote production wires Bot notice and retained-chat policy authority", () assert.match(remote, /revokeNoticeAudience\(deviceId\)/u); assert.match( remote, - /const revoked = await revokeAidenRemoteRuntimeDevice[\s\S]*await botApplicationService\.revokeNoticeAudience\(deviceId\);\s*return revoked;/u, + /const revoked = await revokeAidenRemoteRuntimeDevice[\s\S]*if \(hostPlatformCapabilities\(\)\.bots\) \{\s*await botApplicationService\.revokeNoticeAudience\(deviceId\);\s*\}\s*return revoked;/u, ); }); diff --git a/main/handlers/chats.ts b/main/handlers/chats.ts index b411decd..b4a71aa3 100644 --- a/main/handlers/chats.ts +++ b/main/handlers/chats.ts @@ -52,6 +52,7 @@ import { import { chatForRenderer } from "../services/visible-chat-projection.js"; import { chatActivityRegistry } from "../services/chat-activity.js"; import { botApplicationService } from "../services/bot-application-service-main.js"; +import { hostPlatformCapabilities } from "../services/host-platform-capabilities.js"; import { piCompactionSessionStore } from "../services/pi-compaction-session-store.js"; import { isTodoSnapshotFailure, replayTodoState } from "../services/rpiv-todo/replay.js"; import { @@ -214,6 +215,9 @@ export function registerChatHistoryHandlers(): void { } const runCopy = async () => { if (source.botId) { + if (!hostPlatformCapabilities().bots) { + throw new Error("Bot chats are not available on this platform."); + } const assertCurrent = () => { if (owner.isDestroyed()) { throw new Error("The application changed before the Bot chat was copied."); @@ -465,6 +469,9 @@ export function registerChatHistoryHandlers(): void { const chatId = asString(id, "id"); const chat = await chatStore.get(chatId); if (chat?.botId) { + if (!hostPlatformCapabilities().bots) { + return chatApplicationService.remove(chatId); + } return botApplicationService.deleteChat({ botId: chat.botId, chatId }); } return chatApplicationService.remove(chatId); diff --git a/main/handlers/computer-use.ts b/main/handlers/computer-use.ts index e0b7a453..7cdb6aa9 100644 --- a/main/handlers/computer-use.ts +++ b/main/handlers/computer-use.ts @@ -1,6 +1,10 @@ import { ipcMain } from "../platform.js"; import { computerUseStatus } from "../services/computer-use/status.js"; import { computerUseSettings } from "../services/computer-use/settings.js"; +import { + computerUseSupported, + unsupportedComputerUseStatus, +} from "../services/computer-use/platform.js"; import { rendererDocumentOwner, type RendererDocumentOwner, @@ -32,14 +36,23 @@ async function ownedStatusRequest( } export function registerComputerUseHandlers(): void { - ipcMain.handle("computerUse:status", async (event, force: unknown) => - ownedStatusRequest(event, (_owner, signal) => + ipcMain.handle("computerUse:status", async (event, force: unknown) => { + if (!computerUseSupported()) { + requestOwner(event); + return unsupportedComputerUseStatus(); + } + return ownedStatusRequest(event, (_owner, signal) => computerUseStatus.status({ force: force === true, signal }), - ), - ); + ); + }); ipcMain.handle("computerUse:setEnabled", async (event, enabled: unknown) => { if (typeof enabled !== "boolean") throw new Error("Invalid Computer Use setting."); + if (!computerUseSupported()) { + requestOwner(event); + if (enabled) throw new Error("Computer Use is not available on this platform."); + return unsupportedComputerUseStatus(); + } return ownedStatusRequest(event, async (owner, signal) => { await computerUseSettings.setEnabled(enabled, () => !owner.isDestroyed()); if (owner.isDestroyed()) throw new Error("The renderer document is no longer active."); @@ -47,7 +60,13 @@ export function registerComputerUseHandlers(): void { }); }); - ipcMain.handle("computerUse:requestPermissions", async (event) => - ownedStatusRequest(event, (_owner, signal) => computerUseStatus.requestPermissions({ signal })), - ); + ipcMain.handle("computerUse:requestPermissions", async (event) => { + if (!computerUseSupported()) { + requestOwner(event); + return unsupportedComputerUseStatus(); + } + return ownedStatusRequest(event, (_owner, signal) => + computerUseStatus.requestPermissions({ signal }), + ); + }); } diff --git a/main/handlers/diagnostics.ts b/main/handlers/diagnostics.ts index 1b71519c..fb993655 100644 --- a/main/handlers/diagnostics.ts +++ b/main/handlers/diagnostics.ts @@ -142,7 +142,7 @@ export function registerDiagnosticHandlers(): void { type: "warning", title: "Include sensitive crash dumps?", message: "Crash memory may contain prompts, workspace content, credentials, or other in-memory data.", - detail: "The export stays on this Mac. Aiden will not upload it.", + detail: "The export stays on this device. Aiden will not upload it.", buttons: ["Cancel", "Include & export"], defaultId: 0, cancelId: 0, @@ -183,7 +183,7 @@ export function registerDiagnosticHandlers(): void { type: "warning", title: "Enable local crash capture?", message: "Crash memory may contain prompts, workspace content, credentials, or other in-memory data.", - detail: "Dumps stay on this Mac, are never uploaded automatically, and capture turns off when Aiden restarts.", + detail: "Dumps stay on this device, are never uploaded automatically, and capture turns off when Aiden restarts.", buttons: ["Cancel", "Enable until restart"], defaultId: 0, cancelId: 0, diff --git a/main/handlers/index.ts b/main/handlers/index.ts index 8f593751..eb49be4c 100644 --- a/main/handlers/index.ts +++ b/main/handlers/index.ts @@ -27,6 +27,7 @@ import { registerSubagentHandlers } from "./subagents.js"; import { registerAidenRemoteHandlers } from "./aiden-remote.js"; import { registerBotHandlers } from "./bots.js"; import { registerDiagnosticHandlers } from "./diagnostics.js"; +import { hostPlatformCapabilities } from "../services/host-platform-capabilities.js"; import { registerBtwHandlers } from "./btw.js"; import { initializeAdvisorRuntime } from "../services/advisor-runtime-main.js"; @@ -64,7 +65,7 @@ export function registerHandlers(): void { registerTelegramHandlers(); registerSubagentHandlers(); registerAidenRemoteHandlers(); - registerBotHandlers(); + if (hostPlatformCapabilities().bots) registerBotHandlers(); registerBtwHandlers(); logger.info("handlers", "✓ IPC handlers registered"); diff --git a/main/handlers/profile.ts b/main/handlers/profile.ts index b6d66714..d11c39c0 100644 --- a/main/handlers/profile.ts +++ b/main/handlers/profile.ts @@ -8,7 +8,7 @@ export function registerProfileHandlers(): void { if (typeof value !== "string") throw new Error("Profile name must be text."); return profileService.setName(value); }); - ipcMain.handle("profile:shareImage", async (event, dataUrl: unknown) => { - await shareProfilePng(dataUrl, BrowserWindow.fromWebContents(event.sender)); - }); + ipcMain.handle("profile:shareImage", async (event, dataUrl: unknown) => + shareProfilePng(dataUrl, BrowserWindow.fromWebContents(event.sender)), + ); } diff --git a/main/handlers/providers.ts b/main/handlers/providers.ts index 17a3c0e1..f5aabd3c 100644 --- a/main/handlers/providers.ts +++ b/main/handlers/providers.ts @@ -42,7 +42,6 @@ import { } from "../services/provider-credential-rotation-core.js"; import { listConfiguredProviders } from "../services/provider-list-main.js"; import { invalidateBotRuntimeInventoryAuthority } from "../services/bot-runtime-inventory-lease.js"; -import { modelsDevCacheRuntime, modelsDevCacheStatus } from "../services/models-dev-cache.js"; import { listProvidersWithLegacyPiCredentialMigration } from "../services/legacy-pi-credential-migration.js"; import type { ProviderDeployment, @@ -370,29 +369,23 @@ export function registerProviderHandlers(): void { return refreshProviderCatalogs(undefined, false); }); - ipcMain.handle("providers:catalogStatus", () => modelsDevCacheStatus()); + ipcMain.handle("providers:catalogStatus", () => ({ + source: "bundled" as const, + fetchedAt: null, + })); ipcMain.handle("providers:updateCatalogs", async (event) => { providerAuthOwner(event); const inventory = await refreshProviderCatalogs(); - let modelsDev; - try { - const refreshed = await modelsDevCacheRuntime.refresh(); - modelsDev = { ok: true as const, status: refreshed.status }; - } catch (error) { - modelsDev = { - ok: false as const, - status: await modelsDevCacheStatus(), - message: - error instanceof Error - ? error.message.slice(0, 240) - : "The models.dev catalog could not be updated.", - }; - } return { providers: inventory.providers, inventoryErrors: inventory.errors, - modelsDev, + // The live application is offline-only for models.dev. Release tooling + // refreshes the packaged snapshot before distribution. + modelsDev: { + ok: true as const, + status: { source: "bundled" as const, fetchedAt: null }, + }, }; }); @@ -515,7 +508,7 @@ export function registerProviderHandlers(): void { next.dictationAccelerator = p.dictationAccelerator; if ( p.chatTitleProviderId === "automatic" || - p.chatTitleProviderId === "apple-foundation-models" || + (p.chatTitleProviderId === "apple-foundation-models" && process.platform === "darwin") || p.chatTitleProviderId === "chat-model" ) { next.chatTitleProviderId = p.chatTitleProviderId; diff --git a/main/handlers/title-providers.ts b/main/handlers/title-providers.ts index 0df251de..c0953692 100644 --- a/main/handlers/title-providers.ts +++ b/main/handlers/title-providers.ts @@ -1,9 +1,14 @@ import { ipcMain } from "../platform.js"; import { foundationModelsConnection } from "../services/foundation-models-connection.js"; +import { hostPlatformCapabilities } from "../services/host-platform-capabilities.js"; export function registerTitleProviderHandlers(): void { - ipcMain.handle("titleProviders:status", async () => foundationModelsConnection.status()); - ipcMain.handle("titleProviders:refresh", async () => - foundationModelsConnection.status({ force: true }), - ); + ipcMain.handle("titleProviders:status", async () => { + if (!hostPlatformCapabilities().appleFoundationModels) return null; + return foundationModelsConnection.status(); + }); + ipcMain.handle("titleProviders:refresh", async () => { + if (!hostPlatformCapabilities().appleFoundationModels) return null; + return foundationModelsConnection.status({ force: true }); + }); } diff --git a/main/index.ts b/main/index.ts index 3bd2abba..47b63112 100644 --- a/main/index.ts +++ b/main/index.ts @@ -16,6 +16,7 @@ import { registerHandlers } from "./handlers/index.js"; import { terminalService } from "./services/terminal.js"; import { TerminalHistoryStore } from "./services/terminal-history.js"; import { getPreloadPath, getWindowUrl } from "./windows/window-paths.js"; +import { mainWindowOptions } from "./windows/main-window-options.js"; import { initShortcut, initDictationShortcut, @@ -67,6 +68,7 @@ import { effectiveBindings, migrateLegacyKeybindings, } from "../renderer/shared/keybindings.js"; +import { applicationMenuTemplate } from "./services/application-menu-core.js"; import type { NotificationChannel } from "../renderer/preload-channels.js"; import type { AppSettings, Chat } from "./services/types.js"; import { ONBOARDING_COMPLETE_STORAGE_KEY } from "../renderer/shared/onboarding.js"; @@ -122,6 +124,7 @@ import { import { rendererDocumentOwner } from "./services/renderer-document-owner.js"; import { decideRendererRecovery } from "./services/renderer-crash-recovery.js"; import { + aidenRemoteServiceKeepsApplicationAlive, initializeAidenRemoteService, stopAidenRemoteServiceAndSettle, } from "./services/aiden-remote-service-main.js"; @@ -129,6 +132,20 @@ import { initializeBotApplicationService } from "./services/bot-application-serv import { botSkillContentWatcher } from "./services/bot-capability-services-main.js"; import { geminiLiveTranscription } from "./services/gemini-live-transcription.js"; import { mainWindowState } from "./services/main-window-state.js"; +import { desktopVersionRequested } from "./desktop-cli-core.js"; +import { shouldQuitAfterAllWindowsClose } from "./application-lifecycle-core.js"; +import { hostPlatformCapabilities } from "./services/host-platform-capabilities.js"; + +if (desktopVersionRequested(process.argv, process.defaultApp === true)) { + process.stdout.write(`${app.getVersion()}\n`); + app.exit(0); +} + +if (process.platform === "linux") { + // Supporting Wayland compositors can register Electron global shortcuts + // through the desktop portal instead of relying on X11 key grabs. + app.commandLine.appendSwitch("enable-features", "GlobalShortcutsPortal"); +} registerGenerativeUiScheme(); @@ -928,14 +945,7 @@ async function applyDockIconPreference( preference: DockIconPreference, ): Promise { if (process.platform !== "darwin" || !app.dock) return false; - const iconPath = - preference === "monochrome" - ? isPackagedRuntime() - ? path.join(process.resourcesPath, "app-icon-monochrome.png") - : path.join(app.getAppPath(), "resources", "app-icon-monochrome.png") - : isPackagedRuntime() - ? path.join(process.resourcesPath, "app-icon.png") - : path.join(app.getAppPath(), "resources", "app-icon.png"); + const iconPath = applicationIconPath(preference === "monochrome"); const icon = nativeImage.createFromPath(iconPath); if (icon.isEmpty()) throw new Error(`Dock icon is unavailable: ${path.basename(iconPath)}`); @@ -944,6 +954,13 @@ async function applyDockIconPreference( return true; } +function applicationIconPath(monochrome = false): string { + const fileName = monochrome ? "app-icon-monochrome.png" : "app-icon.png"; + return isPackagedRuntime() + ? path.join(process.resourcesPath, fileName) + : path.join(app.getAppPath(), "resources", fileName); +} + async function restoreDockIconPreference( preference: DockIconPreference, ): Promise { @@ -991,6 +1008,11 @@ function openExternalUrl(value: string): void { } } +function refreshFoundationModelsStatus(force = false): void { + if (!hostPlatformCapabilities().appleFoundationModels) return; + void foundationModelsConnection.status(force ? { force: true } : undefined); +} + async function createMainWindow(): Promise { let rendererCrashTimes: number[] = []; // macOS activate, a second-instance event, or a newly registered global @@ -1024,24 +1046,16 @@ async function createMainWindow(): Promise { } mainWindow = new BrowserWindow({ + ...mainWindowOptions( + getPreloadPath(), + process.platform, + nativeTheme.shouldUseDarkColors, + ), ...restoredWindowState.bounds, - minWidth: 390, - minHeight: 456, title: app.getName(), - titleBarStyle: "hiddenInset", - // Center the 12px macOS window controls in the renderer's 52px top bar. - trafficLightPosition: { x: 14, y: 20 }, - backgroundColor: "#00000000", - transparent: true, - vibrancy: "sidebar", - visualEffectState: "active", - show: false, - webPreferences: { - preload: getPreloadPath(), - contextIsolation: true, - nodeIntegration: false, - sandbox: true, - }, + ...(process.platform === "linux" + ? { icon: nativeImage.createFromPath(applicationIconPath()) } + : {}), }); resetRendererReadiness(); @@ -1490,99 +1504,23 @@ function setupApplicationMenu( const bindings = effectiveBindings( migrateLegacyKeybindings(settings.keybindings, settings), ); - const command = (commandId: keyof typeof bindings) => - bindings[commandId] ?? undefined; - const menu = Menu.buildFromTemplate([ - { - label: app.getName(), - submenu: [ - { role: "about" }, - { - label: "Check for Updates…", - click: () => void appUpdateService.checkNow(true), - }, - { type: "separator" }, - { - label: "Command Palette…", - accelerator: command("commandPalette.toggle"), - click: () => - deliverMainWindowNotificationSafely("app:command", { - commandId: "commandPalette.toggle", - }), - }, - { - label: "Settings…", - accelerator: command("settings.open"), - click: () => - deliverMainWindowNotificationSafely("app:command", { - commandId: "settings.open", - }), - }, - { type: "separator" }, - { role: "services" }, - { type: "separator" }, - { role: "hide" }, - { role: "hideOthers" }, - { role: "unhide" }, - { type: "separator" }, - { role: "quit" }, - ], - }, - { - label: "File", - submenu: [ - { - label: "New Chat", - accelerator: command("chat.new"), - click: () => - deliverMainWindowNotificationSafely("app:command", { - commandId: "chat.new", - }), - }, - { - label: "Open Workspace in Preferred Editor", - accelerator: command("workspace.openPreferredEditor"), - click: () => - deliverMainWindowNotificationSafely("app:command", { - commandId: "workspace.openPreferredEditor", - }), - }, - { type: "separator" }, - { role: "close" }, - ], - }, - { role: "editMenu" }, - { - label: "View", - submenu: [ - { - label: "Reload", - accelerator: "Command+R", - click: () => { - if (mainWindow && !mainWindow.isDestroyed()) - void requestWindowReload(mainWindow); - }, - }, - { - label: "Force Reload", - accelerator: "Command+Shift+R", - click: () => { - if (mainWindow && !mainWindow.isDestroyed()) { - void requestWindowReload(mainWindow, { ignoreCache: true }); - } - }, + const menu = Menu.buildFromTemplate( + applicationMenuTemplate({ + platform: process.platform, + appName: app.getName(), + bindings, + actions: { + checkForUpdates: () => void appUpdateService.checkNow(true), + deliverCommand: (commandId) => + deliverMainWindowNotificationSafely("app:command", { commandId }), + reload: (ignoreCache) => { + if (mainWindow && !mainWindow.isDestroyed()) { + void requestWindowReload(mainWindow, { ignoreCache }); + } }, - { role: "toggleDevTools" }, - { type: "separator" }, - { role: "resetZoom" }, - { role: "zoomIn" }, - { role: "zoomOut" }, - { type: "separator" }, - { role: "togglefullscreen" }, - ], - }, - { role: "windowMenu" }, - ]); + }, + }), + ); Menu.setApplicationMenu(menu); } @@ -1611,14 +1549,23 @@ if (!ownsSingleInstanceLock) { app.on("second-instance", () => showMainWindow()); app.on("window-all-closed", () => { + const backgroundServiceRunning = aidenRemoteServiceKeepsApplicationAlive(); logger.info("electron-lifecycle", "All application windows closed", { platform: process.platform, + backgroundServiceRunning, }); - if (process.platform !== "darwin") app.quit(); + if ( + shouldQuitAfterAllWindowsClose( + process.platform, + backgroundServiceRunning, + ) + ) { + app.quit(); + } }); app.on("activate", () => { - void foundationModelsConnection.status({ force: true }); + refreshFoundationModelsStatus(true); showMainWindow(); }); @@ -1857,14 +1804,16 @@ if (!ownsSingleInstanceLock) { ); } } - try { - await initializeBotApplicationService(); - } catch (error) { - logger.error( - "bots", - "Bot storage could not be restored safely; the rest of Aiden will remain available for repair.", - error, - ); + if (hostPlatformCapabilities().bots) { + try { + await initializeBotApplicationService(); + } catch (error) { + logger.error( + "bots", + "Bot storage could not be restored safely; the rest of Aiden will remain available for repair.", + error, + ); + } } const visibleChatIds = new Set( (await chatStore.list()).map((chat) => chat.id), @@ -1991,7 +1940,7 @@ if (!ownsSingleInstanceLock) { error, ); } - void foundationModelsConnection.status(); + refreshFoundationModelsStatus(); resolveShortcutInitialization?.(); resolveShortcutInitialization = null; diff --git a/main/platform.ts b/main/platform.ts index 7ee76d25..34bad5b6 100644 --- a/main/platform.ts +++ b/main/platform.ts @@ -23,6 +23,7 @@ import { formatDiagnosticConsole, writeLegacyDiagnostic, } from "./services/diagnostic-journal.js"; +import { hostPlatformCapabilities } from "./services/host-platform-capabilities.js"; type LogValue = unknown; @@ -81,6 +82,7 @@ const ACCESSIBILITY_SETTINGS_URL = "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"; async function waitForAccessibilityTrust(): Promise { + if (!hostPlatformCapabilities().accessibilityPaste) return false; for (let attempt = 0; attempt < 10; attempt += 1) { if (systemPreferences.isTrustedAccessibilityClient(false)) return true; await new Promise((resolve) => setTimeout(resolve, 200)); @@ -115,15 +117,22 @@ export function registerNativeHandlers(): void { electronIpcMain.handle( "aiden:media:status", (_event, mediaType: "microphone" | "camera" | "screen") => - systemPreferences.getMediaAccessStatus(mediaType), + process.platform === "darwin" + ? systemPreferences.getMediaAccessStatus(mediaType) + : "unknown", ); electronIpcMain.handle("aiden:media:request", (_event, mediaType: "microphone" | "camera") => - systemPreferences.askForMediaAccess(mediaType), + process.platform === "darwin" + ? systemPreferences.askForMediaAccess(mediaType) + : true, ); electronIpcMain.handle("aiden:accessibility:status", () => - systemPreferences.isTrustedAccessibilityClient(false), + hostPlatformCapabilities().accessibilityPaste + ? systemPreferences.isTrustedAccessibilityClient(false) + : false, ); electronIpcMain.handle("aiden:accessibility:request", async (event) => { + if (!hostPlatformCapabilities().accessibilityPaste) return false; const parent = BrowserWindow.fromWebContents(event.sender); parent?.show(); parent?.focus(); @@ -133,6 +142,7 @@ export function registerNativeHandlers(): void { return waitForAccessibilityTrust(); }); electronIpcMain.handle("aiden:accessibility:open-settings", async () => { + if (!hostPlatformCapabilities().accessibilityPaste) return false; await shell.openExternal(ACCESSIBILITY_SETTINGS_URL); return true; }); diff --git a/main/runtime-profile-bootstrap.test.ts b/main/runtime-profile-bootstrap.test.ts index d39188a7..2a6fae9d 100644 --- a/main/runtime-profile-bootstrap.test.ts +++ b/main/runtime-profile-bootstrap.test.ts @@ -49,7 +49,7 @@ test("development shortcut registration is gated without removing in-app menu ac test("visible main-process branding derives from the configured app name", () => { const main = readFileSync(new URL("./index.ts", import.meta.url), "utf8"); assert.match(main, /title: app\.getName\(\)/u); - assert.match(main, /label: app\.getName\(\)/u); + assert.match(main, /appName: app\.getName\(\)/u); assert.match(main, /app\.dock\?\.setBadge\("DEV"\)/u); }); @@ -72,6 +72,40 @@ test("optional background services cannot close an already visible desktop windo ); }); +test("Apple Foundation Models status probes remain behind the host capability policy", () => { + const main = readFileSync(new URL("./index.ts", import.meta.url), "utf8"); + const chatTitle = readFileSync( + new URL("./services/chat-title.ts", import.meta.url), + "utf8", + ); + const titleProviders = readFileSync( + new URL("./handlers/title-providers.ts", import.meta.url), + "utf8", + ); + assert.match( + main, + /function refreshFoundationModelsStatus[\s\S]*?if \(!hostPlatformCapabilities\(\)\.appleFoundationModels\) return;[\s\S]*?foundationModelsConnection\.status/u, + ); + assert.doesNotMatch( + main, + /app\.on\("activate", \(\) => \{\s*void foundationModelsConnection\.status/u, + ); + assert.match( + chatTitle, + /!hostPlatformCapabilities\(\)\.appleFoundationModels\s+\? null\s+: await foundationModelsConnection\.status/u, + ); + assert.match( + chatTitle, + /generateFoundationModelsRename[\s\S]*?if \(!hostPlatformCapabilities\(\)\.appleFoundationModels\)/u, + ); + assert.equal( + titleProviders.match( + /if \(!hostPlatformCapabilities\(\)\.appleFoundationModels\) return null;/gu, + )?.length, + 2, + ); +}); + test("packaged test launches retain their explicit private user-data directory", () => { const profile = readFileSync(new URL("./runtime-profile.ts", import.meta.url), "utf8"); const soak = readFileSync( diff --git a/main/services/aiden-remote-chats.ts b/main/services/aiden-remote-chats.ts index 870128e6..131ee7d1 100644 --- a/main/services/aiden-remote-chats.ts +++ b/main/services/aiden-remote-chats.ts @@ -578,7 +578,7 @@ export class AidenRemoteChatService { if (result.imageArtifactRecoveryUnavailable) { throw new AidenRemoteServiceError( "operation_in_progress", - "This chat is waiting for image-artifact storage repair on the Mac.", + "This chat is waiting for image-artifact storage repair on the desktop.", 409, true, ); diff --git a/main/services/aiden-remote-files.ts b/main/services/aiden-remote-files.ts index 8e9de63e..2141f7fa 100644 --- a/main/services/aiden-remote-files.ts +++ b/main/services/aiden-remote-files.ts @@ -235,7 +235,7 @@ export class AidenRemoteFileService { if (error instanceof AidenRemoteServiceError) throw error; throw new AidenRemoteServiceError( "workspace_unavailable", - "This workspace's files are not currently available on the Mac.", + "This workspace's files are not currently available on the desktop.", 409, ); }); @@ -321,13 +321,13 @@ export class AidenRemoteFileService { if (error instanceof WorkspaceFileError && error.code === "changed_on_disk") { throw new AidenRemoteServiceError( "revision_conflict", - "This file changed on the Mac. Reload it before saving.", + "This file changed on the desktop. Reload it before saving.", 409, ); } throw new AidenRemoteServiceError( "workspace_unavailable", - "Aiden could not safely save this file on the Mac.", + "Aiden could not safely save this file on the desktop.", 409, ); } diff --git a/main/services/aiden-remote-pairing.test.ts b/main/services/aiden-remote-pairing.test.ts index 464bb5f9..26f50283 100644 --- a/main/services/aiden-remote-pairing.test.ts +++ b/main/services/aiden-remote-pairing.test.ts @@ -15,7 +15,7 @@ import { const endpoint = "https://aiden.example.test/api/aiden/v1"; const fingerprint = `sha256/${Buffer.alloc(32, 4).toString("base64")}`; -function fixture(options: { issueFails?: boolean } = {}) { +function fixture(options: { issueFails?: boolean; botCapabilitiesSupported?: boolean } = {}) { let now = 1_000; let issued = 0; let issuedAcceptsBotCapabilities: boolean | undefined; @@ -50,6 +50,7 @@ function fixture(options: { issueFails?: boolean } = {}) { statusChanges += 1; }, () => "Studio Mac", + () => options.botCapabilitiesSupported ?? true, ); return { service, @@ -230,6 +231,20 @@ test("pairing grants Bot authority only to clients that explicitly accept its vo assert.equal(current.issuedAcceptsBotCapabilities(), true); }); +test("Linux host policy narrows a Bot-aware pairing request to legacy authority", async () => { + const linux = fixture({ botCapabilitiesSupported: false }); + const opened = linux.service.begin(endpoint, fingerprint); + const result = await linux.service.exchange( + exchange(opened.bootstrap.secret, true, true), + "linux-bot-aware-client", + ); + + assert.deepEqual(result.capabilities, AIDEN_REMOTE_LEGACY_CAPABILITIES); + assert.equal(result.capabilities.includes("bot:read"), false); + assert.equal(result.capabilities.includes("bot:write"), false); + assert.equal(linux.issuedAcceptsBotCapabilities(), false); +}); + test("an expired, closed, or invalid pairing window fails with stable safe codes", async () => { const pairing = fixture(); await assert.rejects( diff --git a/main/services/aiden-remote-pairing.ts b/main/services/aiden-remote-pairing.ts index 3d67fcf5..8ee24c36 100644 --- a/main/services/aiden-remote-pairing.ts +++ b/main/services/aiden-remote-pairing.ts @@ -242,6 +242,7 @@ export class AidenRemotePairingService { }, private readonly onStatusChanged: () => void = () => undefined, private readonly displayName: () => string = () => "Aiden Agent", + private readonly botCapabilitiesSupported: () => boolean = () => true, ) {} begin( @@ -474,16 +475,18 @@ export class AidenRemotePairingService { // persistence failures can never turn this high-authority secret reusable. current.consumed = true; this.onStatusChanged(); + const acceptsBotCapabilities = + input.acceptsBotCapabilities === true && this.botCapabilitiesSupported(); let issued: Awaited>; try { issued = await this.devices.issueDevice({ name: input.deviceName, type: input.deviceType, clientVersion: input.clientVersion, - capabilities: input.acceptsBotCapabilities + capabilities: acceptsBotCapabilities ? AIDEN_REMOTE_CAPABILITIES : AIDEN_REMOTE_LEGACY_CAPABILITIES, - acceptsBotCapabilities: input.acceptsBotCapabilities === true, + acceptsBotCapabilities, authorizeCommit: () => this.window === current && !current.cancelled, }); if (this.window !== current || current.cancelled) { diff --git a/main/services/aiden-remote-platform-contract.test.ts b/main/services/aiden-remote-platform-contract.test.ts new file mode 100644 index 00000000..723194b9 --- /dev/null +++ b/main/services/aiden-remote-platform-contract.test.ts @@ -0,0 +1,22 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +test("Remote runtime keeps Linux lifecycle and Bot route gates explicit", () => { + const source = readFileSync( + new URL("./aiden-remote-service-main.ts", import.meta.url), + "utf8", + ); + assert.match(source, /createAidenRemoteBonjourPublisher\(writeRemoteLog\)/u); + assert.match(source, /const botsSupported = hostPlatformCapabilities\(\)\.bots/u); + assert.match( + source, + /new AidenRemoteStateRegistry\([\s\S]*?botCapabilitiesSupported: \(\) => hostPlatformCapabilities\(\)\.bots/u, + ); + assert.match( + source, + /new AidenRemoteService\([\s\S]*?botCapabilitiesSupported: \(\) => hostPlatformCapabilities\(\)\.bots/u, + ); + assert.match(source, /\.\.\.\(botsSupported[\s\S]*?botFiles,[\s\S]*?bots,[\s\S]*?botNotice:/u); + assert.match(source, /aidenRemoteServiceKeepsApplicationAlive/u); +}); diff --git a/main/services/aiden-remote-service-main.ts b/main/services/aiden-remote-service-main.ts index c463d133..d67b42b5 100644 --- a/main/services/aiden-remote-service-main.ts +++ b/main/services/aiden-remote-service-main.ts @@ -10,7 +10,7 @@ import { AidenRemoteApprovedRootService } from "./aiden-remote-approved-roots.js import { DataStore } from "./data-store.js"; import { AidenRemoteService, - DnsSdAidenRemoteBonjourPublisher, + createAidenRemoteBonjourPublisher, type AidenRemoteServiceLogEntry, } from "./aiden-remote-service.js"; import { @@ -110,6 +110,7 @@ import { botFavoritesStore, withBotFavoritesMutation, } from "./bot-favorites-main.js"; +import { hostPlatformCapabilities } from "./host-platform-capabilities.js"; const STATE_FILE = "aiden-remote-v1.json"; const OPERATIONS_FILE = "aiden-remote-operations-v1.json"; @@ -133,7 +134,8 @@ async function mapWithConcurrency( return output; } -async function macComputerName(): Promise { +async function computerDisplayName(): Promise { + if (process.platform !== "darwin") return os.hostname(); try { const { stdout } = await execFileAsync( "/usr/sbin/scutil", @@ -233,12 +235,13 @@ export interface AidenRemoteRuntime { } let runtimePromise: Promise | null = null; +let activeRuntime: AidenRemoteRuntime | null = null; async function createRuntime(): Promise { const runtimeProfile = currentRuntimeProfile(); const userData = app.getPath("userData"); const hostname = os.hostname(); - const defaultDisplayName = defaultAidenRemoteDisplayName(await macComputerName()); + const defaultDisplayName = defaultAidenRemoteDisplayName(await computerDisplayName()); const store = new DataStore( STATE_FILE, createDefaultAidenRemoteState( @@ -290,6 +293,8 @@ async function createRuntime(): Promise { await store.save(document); ipcMain.broadcast("remote:changed", {}); }, + }, undefined, { + botCapabilitiesSupported: () => hostPlatformCapabilities().bots, }); const operationStore = new DataStore( OPERATIONS_FILE, @@ -358,13 +363,13 @@ async function createRuntime(): Promise { models: AidenRemoteModelService; streams: AidenRemoteStreamService; files: AidenRemoteFileService; - botFiles: AidenRemoteBotFileService; + botFiles?: AidenRemoteBotFileService; git: AidenRemoteGitService; schedules: AidenRemoteScheduleService; usage: typeof usageStore; speech: AidenRemoteSpeechService; - bots: AidenRemoteBotService; - botNotice: { + bots?: AidenRemoteBotService; + botNotice?: { status: typeof botApplicationService.noticeStatus; acknowledge: typeof botApplicationService.acknowledgeNotice; }; @@ -377,13 +382,14 @@ async function createRuntime(): Promise { const service = new AidenRemoteService({ state, appVersion: app.getVersion(), + botCapabilitiesSupported: () => hostPlatformCapabilities().bots, hostname, tailscale, portCandidates: (preferredPort) => aidenRemotePortCandidatesForProfile( runtimeProfile.id, preferredPort, ), - bonjour: new DnsSdAidenRemoteBonjourPublisher(writeRemoteLog), + bonjour: createAidenRemoteBonjourPublisher(writeRemoteLog), notifyPairingChanged: () => ipcMain.broadcast("remote:changed", {}), workspaceApi: async (instanceId) => { if (!workspaceApi || workspaceApiInstanceId !== instanceId) { @@ -431,6 +437,7 @@ async function createRuntime(): Promise { logger.error("aiden-remote", "Could not persist the remote stream journal.", error), }); activeStreams = streams; + const botsSupported = hostPlatformCapabilities().bots; const chats = new AidenRemoteChatService({ application: chatApplicationService, chatStore, @@ -449,10 +456,29 @@ async function createRuntime(): Promise { }, streams, models, - bots: botStore, - botMutations: botMutationGate, - retainedBotChatAuthorizer: authorizeRemoteRetainedBotChat, - botTurnAuthorityPreflight: preflightBotTurnAuthority, + bots: botsSupported + ? botStore + : { get: async () => null }, + botMutations: botsSupported + ? botMutationGate + : { + run: async ( + _botId: string, + _action: () => Promise, + ): Promise => { + throw new AidenRemoteServiceError( + "not_found", + "This Aiden chat no longer exists.", + 404, + ); + }, + }, + ...(botsSupported + ? { + retainedBotChatAuthorizer: authorizeRemoteRetainedBotChat, + botTurnAuthorityPreflight: preflightBotTurnAuthority, + } + : {}), idempotency, persistIdempotency: (snapshot) => operationStore.save(snapshot), notifyChanged: () => ipcMain.broadcast("chats:changed", {}), @@ -490,7 +516,7 @@ async function createRuntime(): Promise { return "unavailable"; } }; - const bots = new AidenRemoteBotService({ + const bots = botsSupported ? new AidenRemoteBotService({ application: botApplicationService, chatStore, avatar: createMainBotAvatarApplicationAdapter(instanceId), @@ -594,26 +620,28 @@ async function createRuntime(): Promise { persistIdempotency: (snapshot) => operationStore.save(snapshot), notifyBotsChanged: () => ipcMain.broadcast("bots:changed", {}), notifyChatsChanged: () => ipcMain.broadcast("chats:changed", {}), - }); + }) : undefined; const files = new AidenRemoteFileService({ instanceId, application: workspaceEnvironmentApplicationService, owners: workspaceOwners, }); - const botFiles = new AidenRemoteBotFileService({ - instanceId, - authority: botRuntimeAuthority, - archivedRead: createBotArchivedFileReadAuthority({ - bots: botStore, - chats: chatStore, - capabilities: botCapabilityStore, - catalog: botCapabilityCatalog, - managedWorkspace: botManagedWorkspace, - mutationGate: botMutationGate, - inventoryLeases: botRuntimeInventoryLeases, - }), - chats: chatStore, - }); + const botFiles = botsSupported + ? new AidenRemoteBotFileService({ + instanceId, + authority: botRuntimeAuthority, + archivedRead: createBotArchivedFileReadAuthority({ + bots: botStore, + chats: chatStore, + capabilities: botCapabilityStore, + catalog: botCapabilityCatalog, + managedWorkspace: botManagedWorkspace, + mutationGate: botMutationGate, + inventoryLeases: botRuntimeInventoryLeases, + }), + chats: chatStore, + }) + : undefined; const git = new AidenRemoteGitService({ application: workspaceEnvironmentApplicationService, owners: workspaceOwners, @@ -649,20 +677,30 @@ async function createRuntime(): Promise { models, streams, files, - botFiles, git, schedules, usage: usageStore, speech, - bots, - botNotice: { - status: (deviceId) => botApplicationService.noticeStatus(deviceId), - acknowledge: (deviceId, acknowledgement) => - botApplicationService.acknowledgeNotice( - deviceId, - acknowledgement, - ), - }, + ...(botsSupported + ? { + botFiles, + bots, + botNotice: { + status: (deviceId: string) => + botApplicationService.noticeStatus(deviceId), + acknowledge: ( + deviceId: string, + acknowledgement: Parameters< + typeof botApplicationService.acknowledgeNotice + >[1], + ) => + botApplicationService.acknowledgeNotice( + deviceId, + acknowledgement, + ), + }, + } + : {}), settle: () => streams.settlePersistence(), workspaces: new AidenRemoteWorkspaceService({ application: workspaceApplicationService, @@ -682,7 +720,7 @@ async function createRuntime(): Promise { }), log: writeRemoteLog, }); - return { + const runtime: AidenRemoteRuntime = { service, state, approvedRoots: new AidenRemoteApprovedRootService(state), @@ -695,13 +733,17 @@ async function createRuntime(): Promise { }, deviceId); // Cleanup is intentionally idempotent: a retry after a crash between the // device tombstone and notice removal must still remove the acceptance. - await botApplicationService.revokeNoticeAudience(deviceId); + if (hostPlatformCapabilities().bots) { + await botApplicationService.revokeNoticeAudience(deviceId); + } return revoked; }, pendingApprovalForChat: (chatId) => activeStreams?.pendingApprovalForChat(chatId) ?? null, respondApprovalFromHost: (chatId, approvalId, decision) => activeStreams?.respondApprovalFromHost(chatId, approvalId, decision) ?? false, }; + activeRuntime = runtime; + return runtime; } export function getAidenRemoteService(): Promise { @@ -714,6 +756,10 @@ export function getAidenRemoteRuntime(): Promise { return runtimePromise; } +export function aidenRemoteServiceKeepsApplicationAlive(): boolean { + return activeRuntime?.service.keepsApplicationAlive() === true; +} + export async function initializeAidenRemoteService(): Promise { const service = await getAidenRemoteService(); await service.initialize(); diff --git a/main/services/aiden-remote-service.test.ts b/main/services/aiden-remote-service.test.ts index 35db37fe..f3854f8b 100644 --- a/main/services/aiden-remote-service.test.ts +++ b/main/services/aiden-remote-service.test.ts @@ -10,9 +10,11 @@ import test from "node:test"; import { AidenRemotePortInUseError, AidenRemoteService, + aidenRemoteBonjourBackend, aidenRemoteBonjourServiceName, aidenRemotePortCandidates, } from "./aiden-remote-service.js"; + import { AidenRemoteStateRegistry, createDefaultAidenRemoteState, @@ -22,6 +24,11 @@ import { loadOrCreateAidenRemoteTlsIdentity } from "./aiden-remote-tls-identity. import type { AidenTailscaleStatus } from "./aiden-remote-tailscale-route.js"; import { revokeAidenRemoteRuntimeDevice } from "./aiden-remote-revocation.js"; +test("Remote discovery selects the Node Bonjour backend on Linux", () => { + assert.equal(aidenRemoteBonjourBackend("darwin"), "dns-sd"); + assert.equal(aidenRemoteBonjourBackend("linux"), "node"); +}); + async function canBind( port: number, host: "::" | "127.0.0.1" = "127.0.0.1", diff --git a/main/services/aiden-remote-service.ts b/main/services/aiden-remote-service.ts index d70ee9fe..0ef93185 100644 --- a/main/services/aiden-remote-service.ts +++ b/main/services/aiden-remote-service.ts @@ -1,4 +1,5 @@ import { spawn, type ChildProcess } from "node:child_process"; +import Bonjour from "bonjour-service"; import { createHash, X509Certificate } from "node:crypto"; import { createServer as createHttpServer, type Server as HttpServer } from "node:http"; import { createServer as createHttpsServer, type Server as HttpsServer } from "node:https"; @@ -115,6 +116,7 @@ export function aidenRemoteBonjourServiceName( export interface AidenRemoteServiceOptions { state: AidenRemoteStateRegistry; appVersion: string; + botCapabilitiesSupported?: () => boolean; hostname?: string; loadTlsIdentity(): Promise; resolveTlsEndpointPin?: (hostname: string, port?: number) => Promise; @@ -373,6 +375,92 @@ export class DnsSdAidenRemoteBonjourPublisher implements AidenRemoteBonjourPubli } } +export class NodeAidenRemoteBonjourPublisher implements AidenRemoteBonjourPublisher { + private bonjour: Bonjour | null = null; + private generation = 0; + + constructor( + private readonly log: (entry: AidenRemoteServiceLogEntry) => void = () => undefined, + ) {} + + async start( + input: { instanceId: string; displayName: string; port: number }, + onUnexpectedFailure: (error: Error) => void, + ): Promise { + this.stop(); + const generation = ++this.generation; + let ready = false; + let failed = false; + let rejectStartup: (error: Error) => void = () => undefined; + const startupFailure = new Promise((_resolve, reject) => { + rejectStartup = reject; + }); + const fail = (value: unknown) => { + if (failed || this.generation !== generation) return; + failed = true; + const error = value instanceof Error ? value : new Error(String(value)); + this.log({ + level: "warn", + event: "bonjour_failed", + details: { message: error.message }, + }); + if (!ready) rejectStartup(error); + else onUnexpectedFailure(error); + }; + const bonjour = new Bonjour(undefined, fail); + this.bonjour = bonjour; + const service = bonjour.publish({ + name: aidenRemoteBonjourServiceName(input.displayName, input.instanceId), + type: "aiden-agent", + protocol: "tcp", + port: input.port, + txt: { v: "1", instance: input.instanceId }, + }); + const readySignal = new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error("Local discovery did not become ready in time.")), + 3_000, + ); + timer.unref(); + service.once("up", () => { + clearTimeout(timer); + if (this.generation !== generation) { + reject(new Error("Local discovery was stopped before it became ready.")); + return; + } + ready = true; + resolve(); + }); + }); + await Promise.race([readySignal, startupFailure]).catch((error: unknown) => { + this.stop(); + throw error; + }); + } + + stop(): void { + this.generation += 1; + const bonjour = this.bonjour; + this.bonjour = null; + bonjour?.destroy(); + } +} + +export function aidenRemoteBonjourBackend( + platform: NodeJS.Platform = process.platform, +): "dns-sd" | "node" { + return platform === "darwin" ? "dns-sd" : "node"; +} + +export function createAidenRemoteBonjourPublisher( + log: (entry: AidenRemoteServiceLogEntry) => void = () => undefined, + platform: NodeJS.Platform = process.platform, +): AidenRemoteBonjourPublisher { + return aidenRemoteBonjourBackend(platform) === "dns-sd" + ? new DnsSdAidenRemoteBonjourPublisher(log) + : new NodeAidenRemoteBonjourPublisher(log); +} + export class AidenRemoteService { private lanServer: HttpsServer | null = null; private tailscaleServer: HttpServer | null = null; @@ -421,6 +509,7 @@ export class AidenRemoteService { undefined, this.options.notifyPairingChanged, () => this.activeState?.displayName ?? state.displayName, + this.options.botCapabilitiesSupported, ); const workspaceApi = await this.options.workspaceApi?.(state.instanceId); this.settleRemoteApi = workspaceApi?.settle; @@ -688,6 +777,10 @@ export class AidenRemoteService { this.tailscaleServer?.close(); } + keepsApplicationAlive(): boolean { + return this.lanServer !== null || this.tailscaleServer !== null; + } + async setEnabled(enabled: boolean): Promise { await this.serialized(async () => { const current = await this.options.state.snapshot(); diff --git a/main/services/aiden-remote-speech-lane.ts b/main/services/aiden-remote-speech-lane.ts index 5ee6886f..3e0ebea5 100644 --- a/main/services/aiden-remote-speech-lane.ts +++ b/main/services/aiden-remote-speech-lane.ts @@ -3,7 +3,7 @@ import { AidenRemoteServiceError } from "./aiden-remote-errors.js"; /** * A small FIFO admission lane for memory-heavy local speech work. Admission is * synchronous, while operations settle serially, so callers cannot allocate or - * decode multiple PCM buffers in parallel while another recognizer owns the Mac. + * decode multiple PCM buffers in parallel while another recognizer owns the desktop engine. */ export class AidenRemoteSpeechLane { private tail: Promise = Promise.resolve(); @@ -19,7 +19,7 @@ export class AidenRemoteSpeechLane { if (this.admitted >= this.maximumAdmitted) { throw new AidenRemoteServiceError( "rate_limited", - "The Mac speech engine is busy. Try again in a moment.", + "The desktop speech engine is busy. Try again in a moment.", 429, true, { retryAfterSeconds: 2 }, diff --git a/main/services/aiden-remote-speech-transcription.ts b/main/services/aiden-remote-speech-transcription.ts index ace403b2..342259bf 100644 --- a/main/services/aiden-remote-speech-transcription.ts +++ b/main/services/aiden-remote-speech-transcription.ts @@ -23,7 +23,7 @@ function speechUsage(modelId: string, status: "completed" | "failed"): UsageRequ return unreportedUsageRecord({ source: "voice-transcription", providerId: "local-voice", - providerLabel: "Paired Mac voice", + providerLabel: "Paired desktop voice", modelId, local: true, status, diff --git a/main/services/aiden-remote-speech.ts b/main/services/aiden-remote-speech.ts index 1b220634..489fd22f 100644 --- a/main/services/aiden-remote-speech.ts +++ b/main/services/aiden-remote-speech.ts @@ -63,7 +63,7 @@ export class AidenRemoteSpeechService { return { engine: { ready: engine.ready, - error: engine.ready ? null : "The Mac speech engine is unavailable. Restart Aiden Agent and try again.", + error: engine.ready ? null : "The desktop speech engine is unavailable. Restart Aiden Agent and try again.", }, selectedModelId: settings.localVoiceModel || null, models: listModels().map((model) => ({ @@ -138,7 +138,7 @@ export class AidenRemoteSpeechService { } const id = modelId(value.modelId); const installed = listModels().some((candidate) => candidate.id === id && candidate.installed); - if (!installed) throw new AidenRemoteServiceError("operation_stale", "The selected speech model is not installed on the Mac.", 409, true); + if (!installed) throw new AidenRemoteServiceError("operation_stale", "The selected speech model is not installed on the desktop.", 409, true); return this.transcriptionLane.run(async () => { // A queued request may wait while model management runs. Revalidate at // execution time so deletion cannot leave an admitted request pointing at @@ -148,7 +148,7 @@ export class AidenRemoteSpeechService { if (!stillInstalled) { throw new AidenRemoteServiceError( "operation_stale", - "The selected speech model is no longer installed on the Mac.", + "The selected speech model is no longer installed on the desktop.", 409, true, ); diff --git a/main/services/aiden-remote-state.test.ts b/main/services/aiden-remote-state.test.ts index b9cc24b2..4f360d26 100644 --- a/main/services/aiden-remote-state.test.ts +++ b/main/services/aiden-remote-state.test.ts @@ -19,7 +19,7 @@ import { AIDEN_REMOTE_PRODUCTION_LAN_PORT, } from "./aiden-remote-ports.js"; -function fixture(initial?: unknown) { +function fixture(initial?: unknown, botCapabilitiesSupported = true) { let stored = initial ?? createDefaultAidenRemoteState(() => Buffer.alloc(24, 7)); const writes: AidenRemoteStateDocument[] = []; let failNextSave = false; @@ -41,6 +41,8 @@ function fixture(initial?: unknown) { randomBytes: (size) => Buffer.alloc(size, ++randomCounter), deriveCredentialDigest: async (credential, salt) => createHash("sha256").update(credential).update(salt).digest(), + }, { + botCapabilitiesSupported: () => botCapabilitiesSupported, }); return { registry, @@ -153,6 +155,38 @@ test("Bot-aware devices preserve only coherent explicitly negotiated Bot grants" ); }); +test("Linux host policy removes persisted Bot negotiation and grants", async () => { + const darwin = fixture(); + const issued = await darwin.registry.issueDevice({ + name: "Previously Bot-aware iPhone", + type: "iphone", + clientVersion: "2.0", + capabilities: ["server:read", "bot:read", "bot:write"], + acceptsBotCapabilities: true, + }); + + const linux = fixture(darwin.stored(), false); + const initialized = await linux.registry.initialize(); + assert.equal(initialized.devices[0]?.acceptsBotCapabilities, false); + assert.deepEqual(initialized.devices[0]?.capabilities, ["server:read"]); + assert.equal(linux.writes.length, 1); + + const authenticated = await linux.registry.authenticate(issued.credential); + assert.equal(authenticated?.acceptsBotCapabilities, false); + assert.deepEqual([...authenticated!.capabilities], ["server:read"]); + + await assert.rejects( + linux.registry.issueDevice({ + name: "New Bot-aware iPhone", + type: "iphone", + clientVersion: "2.0", + capabilities: ["server:read", "bot:read", "bot:write"], + acceptsBotCapabilities: true, + }), + /device capabilities/u, + ); +}); + test("device issuance checks pairing authorization inside the durable mutation", async () => { const state = fixture(); await assert.rejects( diff --git a/main/services/aiden-remote-state.ts b/main/services/aiden-remote-state.ts index 8cdcadd4..def038f9 100644 --- a/main/services/aiden-remote-state.ts +++ b/main/services/aiden-remote-state.ts @@ -105,6 +105,10 @@ export interface AidenRemoteStateDependencies { deriveCredentialDigest(credential: string, salt: Buffer): Promise; } +export interface AidenRemoteStateHostPolicy { + botCapabilitiesSupported(): boolean; +} + function ownRecord(value: unknown): Record | null { return value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Record) @@ -501,6 +505,9 @@ export class AidenRemoteStateRegistry { private readonly storage: AidenRemoteStateStorage, private readonly dependencies: AidenRemoteStateDependencies = defaultAidenRemoteStateDependencies(), + private readonly hostPolicy: AidenRemoteStateHostPolicy = { + botCapabilitiesSupported: () => true, + }, ) {} private serialized(operation: () => Promise): Promise { @@ -517,6 +524,20 @@ export class AidenRemoteStateRegistry { if (this.document) return structuredClone(this.document); const raw = await this.storage.load(); const loaded = parseAidenRemoteStateDocument(raw); + const botCapabilitiesSupported = this.hostPolicy.botCapabilitiesSupported(); + const devicesNeedHostPolicyMigration = !botCapabilitiesSupported + && loaded.devices.some( + (device) => + device.acceptsBotCapabilities || device.capabilities.some(isBotCapability), + ); + if (devicesNeedHostPolicyMigration) { + for (const device of loaded.devices) { + device.acceptsBotCapabilities = false; + device.capabilities = device.capabilities.filter( + (capability) => !isBotCapability(capability), + ); + } + } const rawRecord = ownRecord(raw); const storageNeedsSave = this.storage.needsSaveAfterLoad ? await this.storage.needsSaveAfterLoad() @@ -534,7 +555,11 @@ export class AidenRemoteStateRegistry { ) ); }); - if (storageNeedsSave || devicesNeedVocabularyMigration) { + if ( + storageNeedsSave || + devicesNeedVocabularyMigration || + devicesNeedHostPolicyMigration + ) { await this.storage.save(loaded); } this.document = loaded; @@ -671,7 +696,9 @@ export class AidenRemoteStateRegistry { ); if ( !capabilities || - (input.acceptsBotCapabilities !== true && capabilities.some(isBotCapability)) + (input.acceptsBotCapabilities !== true && capabilities.some(isBotCapability)) || + (!this.hostPolicy.botCapabilitiesSupported() && + (input.acceptsBotCapabilities === true || capabilities.some(isBotCapability))) ) { throw new Error("Invalid device capabilities."); } @@ -744,16 +771,19 @@ export class AidenRemoteStateRegistry { // changed while the expensive credential digest was being derived. const current = draft.devices.find((candidate) => candidate.id === device.id); if (!current) return { changed: false, value: null }; + const acceptsBotCapabilities = + this.hostPolicy.botCapabilitiesSupported() && + current.acceptsBotCapabilities === true; const capabilities = parsePersistedCapabilities( current.capabilities, - current.acceptsBotCapabilities === true, + acceptsBotCapabilities, ); if (!capabilities) return { changed: false, value: null }; const authenticated: AidenRemoteAuthenticatedDevice = { id: current.id, name: current.name, capabilities: new Set(capabilities), - acceptsBotCapabilities: current.acceptsBotCapabilities === true, + acceptsBotCapabilities, revoked: current.revokedAt !== undefined, }; const shouldPersistLastSeen = diff --git a/main/services/aiden-remote-streams.ts b/main/services/aiden-remote-streams.ts index a70ea388..7a85bd8e 100644 --- a/main/services/aiden-remote-streams.ts +++ b/main/services/aiden-remote-streams.ts @@ -1133,7 +1133,7 @@ export class AidenRemoteStreamService { if (decision === "allow" && (approvalIsHostOnly(approval.details) || !approval.canAllow)) { throw new AidenRemoteServiceError( "capability_denied", - "This approval can only be allowed from the Mac.", + "This approval can only be allowed from the Aiden desktop app.", 403, ); } diff --git a/main/services/aiden-remote-tailscale.test.ts b/main/services/aiden-remote-tailscale.test.ts index cdc27575..2d07c91e 100644 --- a/main/services/aiden-remote-tailscale.test.ts +++ b/main/services/aiden-remote-tailscale.test.ts @@ -5,6 +5,8 @@ import test from "node:test"; import { AidenRemoteTailscaleController, createSystemTailscaleCommandRunner, + tailscaleBinaryCandidates, + tailscaleCommandErrorCode, withAidenTailscaleRouteLock, type AidenTailscaleCommandRunner, type AidenTailscaleStatusReadFailureCategory, @@ -19,6 +21,7 @@ test("system Tailscale runner forces CLI mode for Finder-style production launch environment: NodeJS.ProcessEnv | undefined; }> = []; const runner = await createSystemTailscaleCommandRunner({ + platform: "darwin", environment: { HOME: "/test-home", TAILSCALE_BE_CLI: "0", @@ -32,7 +35,8 @@ test("system Tailscale runner forces CLI mode for Finder-style production launch return { stdout: args[0] === "status" ? JSON.stringify({ - Self: { DNSName: "aiden.tailnet.ts.net." }, + BackendState: "Running", + Self: { DNSName: "aiden.tailnet.ts.net.", Online: true }, CertDomains: ["aiden.tailnet.ts.net"], }) : "{}", @@ -53,6 +57,61 @@ test("system Tailscale runner forces CLI mode for Finder-style production launch } }); +test("Tailscale discovery uses fixed platform-specific executable locations", () => { + assert.deepEqual(tailscaleBinaryCandidates("linux"), [ + "/usr/bin/tailscale", + "/usr/local/bin/tailscale", + "/run/current-system/sw/bin/tailscale", + ]); + assert.deepEqual(tailscaleBinaryCandidates("darwin"), [ + "/Applications/Tailscale.app/Contents/MacOS/Tailscale", + "/usr/local/bin/tailscale", + "/opt/homebrew/bin/tailscale", + ]); + assert.deepEqual(tailscaleBinaryCandidates("win32"), []); +}); + +test("Linux Tailscale runner does not force the packaged macOS CLI environment", async () => { + let environment: NodeJS.ProcessEnv | undefined; + const runner = await createSystemTailscaleCommandRunner({ + platform: "linux", + environment: { AIDEN_TEST: "1" }, + resolveBinary: async () => "/usr/bin/tailscale", + execute: async (_binary, _args, options) => { + environment = options.env; + return { stdout: "{}" }; + }, + }); + await runner?.run(["status", "--json"]); + assert.equal(environment?.AIDEN_TEST, "1"); + assert.equal(environment?.TAILSCALE_BE_CLI, undefined); +}); + +test("Linux operator denial maps to an actionable stable code", () => { + assert.equal( + tailscaleCommandErrorCode({ + stderr: + "Access denied: serve config denied; run tailscale set --operator=$USER", + }), + "tailscale_permission_denied", + ); + assert.equal(tailscaleCommandErrorCode({ stderr: "permission denied" }), undefined); +}); + +test("a named but offline Tailscale node remains disconnected", async () => { + const controller = new AidenRemoteTailscaleController({ + run: async (args) => + args[0] === "status" + ? JSON.stringify({ + BackendState: "Stopped", + Self: { DNSName: "aiden.tailnet.ts.net.", Online: false }, + CertDomains: ["aiden.tailnet.ts.net"], + }) + : "{}", + }); + assert.equal((await controller.status()).errorCode, "not_connected"); +}); + async function availableLoopbackPort(): Promise { const socket = createSocket({ type: "udp4", reuseAddr: false }); await new Promise((resolve, reject) => { @@ -72,7 +131,8 @@ function fixture(options: { emptyServeStatus?: boolean; certDomains?: unknown } calls.push([...args]); if (args[0] === "status") { return JSON.stringify({ - Self: { DNSName: "aiden.tailnet.ts.net." }, + BackendState: "Running", + Self: { DNSName: "aiden.tailnet.ts.net.", Online: true }, CertDomains: options.certDomains ?? ["aiden.tailnet.ts.net"], }); } @@ -159,7 +219,8 @@ test("combined route inspection retries a transient CLI read and recovers", asyn } if (args[0] === "status") { return JSON.stringify({ - Self: { DNSName: "aiden.tailnet.ts.net." }, + BackendState: "Running", + Self: { DNSName: "aiden.tailnet.ts.net.", Online: true }, CertDomains: ["aiden.tailnet.ts.net"], }); } @@ -286,7 +347,8 @@ test("first-listener verification rejects a route without explicit TCP 443 HTTPS calls.push([...args]); if (args[0] === "status") { return JSON.stringify({ - Self: { DNSName: "aiden.tailnet.ts.net." }, + BackendState: "Running", + Self: { DNSName: "aiden.tailnet.ts.net.", Online: true }, CertDomains: ["aiden.tailnet.ts.net"], }); } @@ -379,7 +441,8 @@ function takeoverFixture(options: { calls.push([...args]); if (args[0] === "status") { return JSON.stringify({ - Self: { DNSName: "aiden.tailnet.ts.net." }, + BackendState: "Running", + Self: { DNSName: "aiden.tailnet.ts.net.", Online: true }, CertDomains: ["aiden.tailnet.ts.net"], }); } diff --git a/main/services/aiden-remote-tailscale.ts b/main/services/aiden-remote-tailscale.ts index 10a31d85..35f0ab29 100644 --- a/main/services/aiden-remote-tailscale.ts +++ b/main/services/aiden-remote-tailscale.ts @@ -17,11 +17,16 @@ import { } from "./aiden-remote-tailscale-route.js"; const execFileAsync = promisify(execFile); -const TAILSCALE_CANDIDATES = [ +const DARWIN_TAILSCALE_CANDIDATES = [ "/Applications/Tailscale.app/Contents/MacOS/Tailscale", "/usr/local/bin/tailscale", "/opt/homebrew/bin/tailscale", ] as const; +const LINUX_TAILSCALE_CANDIDATES = [ + "/usr/bin/tailscale", + "/usr/local/bin/tailscale", + "/run/current-system/sw/bin/tailscale", +] as const; const MAX_STATUS_BYTES = 256 * 1_024; const MAX_HEALTH_BYTES = 1_024; const HEALTH_TIMEOUT_MS = 800; @@ -54,6 +59,7 @@ export interface AidenTailscaleSystemRunnerOptions { environment?: NodeJS.ProcessEnv; execute?: AidenTailscaleCommandExecutor; resolveBinary?: () => Promise; + platform?: NodeJS.Platform; } export interface AidenTailscaleConnectionStatus { @@ -135,10 +141,30 @@ export interface AidenTailscaleRouteLockOptions { } interface AidenTailscaleNodeStatus { + connected: boolean; dnsName?: string; httpsAvailable: boolean; } +export function tailscaleBinaryCandidates( + platform: NodeJS.Platform = process.platform, +): readonly string[] { + if (platform === "darwin") return DARWIN_TAILSCALE_CANDIDATES; + if (platform === "linux") return LINUX_TAILSCALE_CANDIDATES; + return []; +} + +export function tailscaleCommandErrorCode( + error: unknown, +): "tailscale_permission_denied" | undefined { + const value = record(error); + const stderr = typeof value?.stderr === "string" ? value.stderr : ""; + return stderr.includes("Access denied: serve config denied") && + stderr.includes("tailscale set --operator=") + ? "tailscale_permission_denied" + : undefined; +} + function record(value: unknown): Record | null { return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record @@ -167,11 +193,13 @@ function normalizeDnsName(value: unknown): string | undefined { function parseNodeStatus(serialized: string): AidenTailscaleNodeStatus { const root = record(parseBoundedJson(serialized, "Tailscale status")); const self = record(root?.Self); + const connected = root?.BackendState === "Running" && self?.Online === true; const dnsName = normalizeDnsName(self?.DNSName); const certDomains = Array.isArray(root?.CertDomains) ? root.CertDomains.map(normalizeDnsName).filter((value): value is string => value !== undefined) : []; return { + connected, ...(dnsName ? { dnsName } : {}), // An exact certificate-domain match proves that the tailnet owner has // already enabled HTTPS. Aiden never follows or accepts Tailscale's @@ -330,8 +358,10 @@ export async function withAidenTailscaleRouteLock( } } -export async function resolveTailscaleBinary(): Promise { - for (const candidate of TAILSCALE_CANDIDATES) { +export async function resolveTailscaleBinary( + platform: NodeJS.Platform = process.platform, +): Promise { + for (const candidate of tailscaleBinaryCandidates(platform)) { try { await fs.access(candidate, fs.constants.X_OK); return candidate; @@ -345,7 +375,10 @@ export async function resolveTailscaleBinary(): Promise { export async function createSystemTailscaleCommandRunner( options: AidenTailscaleSystemRunnerOptions = {}, ): Promise { - const binary = await (options.resolveBinary ?? resolveTailscaleBinary)(); + const platform = options.platform ?? process.platform; + const binary = await ( + options.resolveBinary ?? (() => resolveTailscaleBinary(platform)) + )(); if (!binary) return null; const execute = options.execute ?? (async (command, args, execOptions) => { const { stdout } = await execFileAsync(command, [...args], execOptions); @@ -354,20 +387,23 @@ export async function createSystemTailscaleCommandRunner( const environment = options.environment ?? process.env; return { run: async (args) => { - const { stdout } = await execute(binary, args, { - encoding: "utf8", - env: { - ...environment, - // Tailscale's macOS app and CLI share one executable. Finder-launched - // apps do not inherit TERM/SHLVL, so force the documented CLI mode - // instead of relying on Tailscale's terminal-environment heuristic. - TAILSCALE_BE_CLI: "1", - }, - maxBuffer: MAX_STATUS_BYTES, - timeout: 15_000, - windowsHide: true, - }); - return stdout; + try { + const { stdout } = await execute(binary, args, { + encoding: "utf8", + env: { + ...environment, + ...(platform === "darwin" ? { TAILSCALE_BE_CLI: "1" } : {}), + }, + maxBuffer: MAX_STATUS_BYTES, + timeout: 15_000, + windowsHide: true, + }); + return stdout; + } catch (error) { + const code = tailscaleCommandErrorCode(error); + if (code) throw new Error(code); + throw error; + } }, }; } @@ -424,7 +460,7 @@ export class AidenRemoteTailscaleController { nodeStatus: AidenTailscaleNodeStatus, serveStatus: AidenTailscaleStatus, ): AidenTailscaleConnectionStatus { - const errorCode = !nodeStatus.dnsName + const errorCode = !nodeStatus.connected || !nodeStatus.dnsName ? "not_connected" as const : !nodeStatus.httpsAvailable ? "https_unavailable" as const @@ -492,7 +528,9 @@ export class AidenRemoteTailscaleController { if (!this.runner) throw new Error("tailscale_not_installed"); const nodeStatus = await this.nodeStatus(); const serveStatus = await this.serveStatus(); - if (!nodeStatus.dnsName) throw new Error("tailscale_not_connected"); + if (!nodeStatus.connected || !nodeStatus.dnsName) { + throw new Error("tailscale_not_connected"); + } if (!nodeStatus.httpsAvailable) throw new Error("tailscale_https_unavailable"); return { nodeStatus, serveStatus }; } @@ -670,11 +708,18 @@ export class AidenRemoteTailscaleController { createdAt: this.now(), }); let commandFailed = false; + let commandFailureCode: string | undefined; try { if (nextTarget) await this.setExactRoute(nextTarget); else await this.clearExactRoute(); - } catch { + } catch (error) { commandFailed = true; + if ( + error instanceof Error && + error.message === "tailscale_permission_denied" + ) { + commandFailureCode = error.message; + } } const observed = await this.serveStatusAfterMutation("tailscale_route_outcome_unknown"); const observedSnapshot = aidenTailscaleCanonicalRouteSnapshot(observed); @@ -700,6 +745,7 @@ export class AidenRemoteTailscaleController { await this.outcomeStore?.clear(); } else if (observedFingerprint === serveFingerprint(before)) { await this.outcomeStore?.clear(); + if (commandFailureCode) throw new Error(commandFailureCode); } throw new Error(commandFailed ? "tailscale_route_outcome_unknown" diff --git a/main/services/aiden-remote-workspace-browser.ts b/main/services/aiden-remote-workspace-browser.ts index e0e4c46a..e94cef84 100644 --- a/main/services/aiden-remote-workspace-browser.ts +++ b/main/services/aiden-remote-workspace-browser.ts @@ -174,7 +174,7 @@ export class AidenRemoteWorkspaceBrowserService { if (error instanceof AidenOpaqueHandleError) mapHandleError(error); throw new AidenRemoteServiceError( "workspace_unavailable", - "This approved folder is not currently available on the Mac.", + "This approved folder is not currently available on the desktop.", 409, ); } diff --git a/main/services/application-menu-core.test.ts b/main/services/application-menu-core.test.ts new file mode 100644 index 00000000..5b2919bf --- /dev/null +++ b/main/services/application-menu-core.test.ts @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + applicationMenuTemplate, + platformMenuAccelerator, +} from "./application-menu-core.js"; + +const actions = { + checkForUpdates() {}, + deliverCommand() {}, + reload() {}, +}; + +test("macOS menu retains application services and update entry", () => { + const menu = applicationMenuTemplate({ + platform: "darwin", + appName: "Aiden Agent", + bindings: {}, + actions, + }); + assert.equal(menu[0]?.label, "Aiden Agent"); + assert.ok( + Array.isArray(menu[0]?.submenu) && + menu[0].submenu.some((item) => item.role === "services"), + ); + assert.ok( + Array.isArray(menu[0]?.submenu) && + menu[0].submenu.some((item) => item.label === "Check for Updates…"), + ); +}); + +test("Linux menu uses conventional File and Help ownership", () => { + const menu = applicationMenuTemplate({ + platform: "linux", + appName: "Aiden Agent", + bindings: {}, + actions, + }); + assert.deepEqual( + menu.map((item) => item.label ?? item.role), + ["File", "editMenu", "View", "windowMenu", "Help"], + ); + const serialized = JSON.stringify(menu); + assert.equal(serialized.includes("Check for Updates"), false); + assert.equal(serialized.includes('"role":"services"'), false); + const file = menu[0]; + assert.ok(Array.isArray(file.submenu) && file.submenu.some((item) => item.role === "quit")); +}); + +test("Linux native menus translate canonical Command bindings to Ctrl", () => { + assert.equal(platformMenuAccelerator("Command+Shift+N", "linux"), "CommandOrControl+Shift+N"); + assert.equal(platformMenuAccelerator("Control+K", "linux"), "Super+K"); + assert.equal(platformMenuAccelerator("Command+Shift+N", "darwin"), "Command+Shift+N"); + assert.equal(platformMenuAccelerator(null, "linux"), undefined); +}); diff --git a/main/services/application-menu-core.ts b/main/services/application-menu-core.ts new file mode 100644 index 00000000..e8e545c5 --- /dev/null +++ b/main/services/application-menu-core.ts @@ -0,0 +1,117 @@ +import type { MenuItemConstructorOptions } from "electron"; +import { electronAcceleratorForPlatform } from "../../renderer/shared/keybindings.js"; + +export interface ApplicationMenuActions { + checkForUpdates(): void; + deliverCommand(commandId: string): void; + reload(ignoreCache: boolean): void; +} + +export function platformMenuAccelerator( + binding: string | null | undefined, + platform: NodeJS.Platform, +): string | undefined { + if (!binding) return undefined; + return electronAcceleratorForPlatform(binding, platform); +} + +export function applicationMenuTemplate({ + platform, + appName, + bindings, + actions, +}: { + platform: NodeJS.Platform; + appName: string; + bindings: Readonly>; + actions: ApplicationMenuActions; +}): MenuItemConstructorOptions[] { + const commandItem = ( + label: string, + commandId: string, + ): MenuItemConstructorOptions => ({ + label, + accelerator: platformMenuAccelerator(bindings[commandId], platform), + click: () => actions.deliverCommand(commandId), + }); + const fileItems: MenuItemConstructorOptions[] = [ + commandItem("New Chat", "chat.new"), + commandItem( + "Open Workspace in Preferred Editor", + "workspace.openPreferredEditor", + ), + ]; + if (platform !== "darwin") { + fileItems.push( + { type: "separator" }, + commandItem("Settings…", "settings.open"), + { type: "separator" }, + { role: "quit" }, + ); + } else { + fileItems.push({ type: "separator" }, { role: "close" }); + } + + const template: MenuItemConstructorOptions[] = []; + if (platform === "darwin") { + template.push({ + label: appName, + submenu: [ + { role: "about" }, + { + label: "Check for Updates…", + click: actions.checkForUpdates, + }, + { type: "separator" }, + commandItem("Command Palette…", "commandPalette.toggle"), + commandItem("Settings…", "settings.open"), + { type: "separator" }, + { role: "services" }, + { type: "separator" }, + { role: "hide" }, + { role: "hideOthers" }, + { role: "unhide" }, + { type: "separator" }, + { role: "quit" }, + ], + }); + } + template.push( + { label: "File", submenu: fileItems }, + { role: "editMenu" }, + { + label: "View", + submenu: [ + { + label: "Reload", + accelerator: "CmdOrCtrl+R", + click: () => actions.reload(false), + }, + { + label: "Force Reload", + accelerator: "CmdOrCtrl+Shift+R", + click: () => actions.reload(true), + }, + { role: "toggleDevTools" }, + { type: "separator" }, + { role: "resetZoom" }, + { role: "zoomIn" }, + { role: "zoomOut" }, + { type: "separator" }, + { role: "togglefullscreen" }, + ], + }, + { role: "windowMenu" }, + ); + if (platform !== "darwin") { + template.push({ + label: "Help", + submenu: [ + commandItem("Command Palette…", "commandPalette.toggle"), + { type: "separator" }, + { role: "about" }, + ], + }); + } + return template; +} diff --git a/main/services/assistant/system-prompt.ts b/main/services/assistant/system-prompt.ts index ab00f23c..20ca6947 100644 --- a/main/services/assistant/system-prompt.ts +++ b/main/services/assistant/system-prompt.ts @@ -245,7 +245,7 @@ export function buildAssistantSystemPrompt(input: AssistantPromptInput): string "live value.", ].join(" "); const prompt = [ - "You are Aiden, the in-app assistant for Aiden Agent, a macOS desktop app for", + "You are Aiden, the in-app assistant for Aiden Agent, a desktop app for", "chatting with AI models across a user's coding projects. You help the user", "understand and operate the app itself: you answer questions about it and explain", "its settings.", diff --git a/main/services/bot-capability-inventory-ports.test.ts b/main/services/bot-capability-inventory-ports.test.ts index df59ffe4..653d335f 100644 --- a/main/services/bot-capability-inventory-ports.test.ts +++ b/main/services/bot-capability-inventory-ports.test.ts @@ -61,9 +61,12 @@ test("inventory ports project safe exact facts and conservative unavailable conn credentialIncarnation: "b".repeat(43), })), }, - getSettings: async () => ({ exaEnabled: true, computerUseEnabled: false }), + // Simulate a stale preference copied from a supported host. Host policy + // must still keep Computer Use out of the effective Bot inventory. + getSettings: async () => ({ exaEnabled: true, computerUseEnabled: true }), webSearchAvailability: async () => ({ ready: true }), subagentsAvailable: () => true, + computerUseSupported: () => false, shellFingerprint: HASH, fullMacScopeFingerprint: HASH, botHomeScopeFingerprint: HASH, @@ -87,6 +90,7 @@ test("inventory ports project safe exact facts and conservative unavailable conn assert.equal(skills[0]?.available, true); assert.equal(other.find(({ kind }) => kind === "web")?.available, true); assert.equal(other.find(({ kind }) => kind === "browser")?.available, false); + assert.equal(other.find(({ kind }) => kind === "computer_use")?.available, false); assert.equal(other.find(({ kind }) => kind === "schedules")?.available, false); assert.match( other.find(({ kind }) => kind === "schedules")?.description ?? "", diff --git a/main/services/bot-capability-inventory-ports.ts b/main/services/bot-capability-inventory-ports.ts index fd1a0fa7..48bc5372 100644 --- a/main/services/bot-capability-inventory-ports.ts +++ b/main/services/bot-capability-inventory-ports.ts @@ -34,6 +34,8 @@ export interface BotCapabilityInventoryPortDependencies { /** Main-owned Web Search readiness; credentials and route details stay private. */ webSearchAvailability(): Promise>; subagentsAvailable(): boolean; + /** Host policy always narrows a persisted Computer Use preference. */ + computerUseSupported?: () => boolean; shellFingerprint?: string; fullMacScopeFingerprint?: string; botHomeScopeFingerprint?: string; @@ -244,6 +246,7 @@ function ordinaryInventory(input: { settings: AppSettings; webSearchReady: boolean; subagentsAvailable: boolean; + computerUseSupported: boolean; }): BotOrdinaryCapabilityInventory[] { const values: Array<{ kind: BotOrdinaryCapabilityInventory["kind"]; @@ -267,7 +270,9 @@ function ordinaryInventory(input: { kind: "computer_use", label: "Computer Use", description: "Use the Mac visually through Aiden's existing attended controls.", - available: input.settings.computerUseEnabled === true, + available: + input.computerUseSupported && + input.settings.computerUseEnabled === true, }, { kind: "schedules", @@ -420,6 +425,8 @@ export function createBotCapabilityInventoryPorts( settings, webSearchReady: webSearchAvailability.ready === true, subagentsAvailable: dependencies.subagentsAvailable(), + computerUseSupported: + dependencies.computerUseSupported?.() === true, }); }, }; diff --git a/main/services/bot-capability-services-main.ts b/main/services/bot-capability-services-main.ts index 9ee502c4..3d1e9b9c 100644 --- a/main/services/bot-capability-services-main.ts +++ b/main/services/bot-capability-services-main.ts @@ -39,6 +39,7 @@ import { import { BotSkillContentWatcher } from "./bot-skill-content-watcher.js"; import { skillRegistry } from "./skill-registry-main.js"; import { webSearchService } from "./web-search-main.js"; +import { hostPlatformCapabilities } from "./host-platform-capabilities.js"; export const BOT_SERVICE_DIRECTORY = "bot-service"; @@ -204,6 +205,7 @@ export const botCapabilityCatalog = createBotCapabilityCatalogMainService( return { ready: availability.ready }; }, subagentsAvailable: () => subagentsEnabled(), + computerUseSupported: () => hostPlatformCapabilities().computerUse, }), { onRuntimeSnapshot: (botId, snapshot) => { diff --git a/main/services/bot-skill-content-watcher.test.ts b/main/services/bot-skill-content-watcher.test.ts index 4bf8503f..105f6ebf 100644 --- a/main/services/bot-skill-content-watcher.test.ts +++ b/main/services/bot-skill-content-watcher.test.ts @@ -3,12 +3,19 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import test from "node:test"; -import { - botRuntimeInventoryLeases, -} from "./bot-runtime-inventory-lease.js"; +import { botRuntimeInventoryLeases } from "./bot-runtime-inventory-lease.js"; import { BotSkillContentWatcher } from "./bot-skill-content-watcher.js"; import { SkillRegistry } from "./skill-registry.js"; +const WATCHER_EVENT_TIMEOUT_MS = 5_000; + +const waitForWatcherBaseline = async (): Promise => { + // Darwin may deliver the directory's already-queued creation notification + // immediately after watch registration. Drain it before asserting which + // subsequent filesystem operation caused the watcher notification. + await new Promise((resolve) => setTimeout(resolve, 75)); +}; + test("editing an admitted discovered skill aborts the live Bot inventory lease", async (t) => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-bot-skill-watch-")); t.after(() => fs.rm(root, { recursive: true, force: true })); @@ -20,16 +27,21 @@ test("editing an admitted discovered skill aborts the live Bot inventory lease", const watcher = new BotSkillContentWatcher(); t.after(() => watcher.dispose()); await watcher.watchSkillFiles([skillFile]); + await waitForWatcherBaseline(); const lease = botRuntimeInventoryLeases.acquire(); const aborted = new Promise((resolve, reject) => { const timeout = setTimeout( () => reject(new Error("Skill watcher did not invalidate live Bot authority.")), - 1_000, + WATCHER_EVENT_TIMEOUT_MS, + ); + lease.signal.addEventListener( + "abort", + () => { + clearTimeout(timeout); + resolve(); + }, + { once: true }, ); - lease.signal.addEventListener("abort", () => { - clearTimeout(timeout); - resolve(); - }, { once: true }); }); await fs.writeFile(skillFile, "---\nname: Skill\n---\nAfter\n", "utf8"); await aborted; @@ -44,14 +56,12 @@ test("watcher ignores unrelated files beside a skill", async (t) => { const skillFile = path.join(root, "SKILL.md"); await fs.writeFile(skillFile, "Skill", "utf8"); let changes = 0; - const watcher = new BotSkillContentWatcher(() => { changes += 1; }); + const watcher = new BotSkillContentWatcher(() => { + changes += 1; + }); t.after(() => watcher.dispose()); await watcher.watchSkillFiles([skillFile]); - // Darwin may deliver the directory's already-queued creation notification - // immediately after watch registration. That event predates the behavior - // under test, so establish a quiet baseline before creating the unrelated - // sibling. - await new Promise((resolve) => setTimeout(resolve, 75)); + await waitForWatcherBaseline(); changes = 0; await fs.writeFile(path.join(root, "notes.txt"), "Unrelated", "utf8"); @@ -76,14 +86,16 @@ test("a watched edit invalidates a warm runtime skill snapshot immediately", asy const registry = new SkillRegistry({ getWorkspace: async () => workspace, listConfigured: async () => [], - discover: async () => [{ - id: `workspace:${skillFile}`, - name: "Watched", - description: "Watched skill", - instructions: await fs.readFile(skillFile, "utf8"), - source: "workspace" as const, - path: skillFile, - }], + discover: async () => [ + { + id: `workspace:${skillFile}`, + name: "Watched", + description: "Watched skill", + instructions: await fs.readFile(skillFile, "utf8"), + source: "workspace" as const, + path: skillFile, + }, + ], invocationKey: new Uint8Array(32).fill(9), cacheTtlMs: 5_000, }); @@ -94,7 +106,7 @@ test("a watched edit invalidates a warm runtime skill snapshot immediately", asy const changed = new Promise((resolve, reject) => { changeTimeout = setTimeout( () => reject(new Error("Skill watcher did not invalidate the warm Bot snapshot.")), - 1_000, + WATCHER_EVENT_TIMEOUT_MS, ); resolveChanged = () => { clearTimeout(changeTimeout); @@ -108,6 +120,7 @@ test("a watched edit invalidates a warm runtime skill snapshot immediately", asy }); t.after(() => watcher.dispose()); await watcher.watchSkillFiles([skillFile]); + await waitForWatcherBaseline(); await fs.writeFile(skillFile, "After", "utf8"); await changed; diff --git a/main/services/chat-title.ts b/main/services/chat-title.ts index 6348ef4a..036c57de 100644 --- a/main/services/chat-title.ts +++ b/main/services/chat-title.ts @@ -15,6 +15,7 @@ import { import { resolveChatTitleRoute } from "./chat-title-routing.js"; import { configStore } from "./config-store.js"; import { foundationModelsConnection } from "./foundation-models-connection.js"; +import { hostPlatformCapabilities } from "./host-platform-capabilities.js"; import { runtimeSupportsImages } from "./generation-runtime.js"; import { resolveModelRuntime } from "./model-runtime.js"; import { @@ -204,7 +205,10 @@ async function generateFirstTurnTitle(input: { const settings = await configStore.getSettings(); const titleProviderId = settings.chatTitleProviderId ?? "automatic"; const foundationModelsStatus = - titleProviderId === "chat-model" ? null : await foundationModelsConnection.status(); + titleProviderId === "chat-model" || + !hostPlatformCapabilities().appleFoundationModels + ? null + : await foundationModelsConnection.status(); const route = resolveChatTitleRoute(titleProviderId, foundationModelsStatus); if (route === "seed-only") return; @@ -234,6 +238,9 @@ async function generateFirstTurnTitle(input: { } async function generateFoundationModelsRename(chatId: string): Promise { + if (!hostPlatformCapabilities().appleFoundationModels) { + throw new Error("Apple Foundation Models are not available on this platform."); + } const backgroundTitle = inFlight.get(chatId); if (backgroundTitle) await backgroundTitle; diff --git a/main/services/computer-use/binary.ts b/main/services/computer-use/binary.ts index 0e2346d9..40d4ea5d 100644 --- a/main/services/computer-use/binary.ts +++ b/main/services/computer-use/binary.ts @@ -203,7 +203,7 @@ export async function resolveCuaDriverInstallation( if (options.platform !== "darwin") { throw new CuaDriverError( "unsupported_platform", - "Aiden Computer Use currently supports macOS only.", + "Aiden Computer Use is not available on this platform.", ); } const brokerAppPath = options.isPackaged diff --git a/main/services/computer-use/platform.test.ts b/main/services/computer-use/platform.test.ts new file mode 100644 index 00000000..2ee5de8c --- /dev/null +++ b/main/services/computer-use/platform.test.ts @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { computerUseSupported, unsupportedComputerUseStatus } from "./platform.js"; + +test("Computer Use is exposed only on macOS", () => { + assert.equal(computerUseSupported("darwin"), true); + assert.equal(computerUseSupported("linux"), false); + assert.equal(computerUseSupported("win32"), false); +}); + +test("the Linux fallback fails closed without suggesting macOS permissions", () => { + assert.deepEqual(unsupportedComputerUseStatus(), { + enabled: false, + beta: true, + state: "unsupported", + detail: "Computer Use is not included on this platform.", + ready: false, + available: false, + retryable: false, + canRequestPermissions: false, + permissions: { accessibility: null, screenRecording: null }, + }); +}); diff --git a/main/services/computer-use/platform.ts b/main/services/computer-use/platform.ts new file mode 100644 index 00000000..2b5a80c5 --- /dev/null +++ b/main/services/computer-use/platform.ts @@ -0,0 +1,19 @@ +import type { ComputerUseStatus } from "../types.js"; + +export function computerUseSupported(platform: NodeJS.Platform = process.platform): boolean { + return platform === "darwin"; +} + +export function unsupportedComputerUseStatus(): ComputerUseStatus { + return { + enabled: false, + beta: true, + state: "unsupported", + detail: "Computer Use is not included on this platform.", + ready: false, + available: false, + retryable: false, + canRequestPermissions: false, + permissions: { accessibility: null, screenRecording: null }, + }; +} diff --git a/main/services/computer-use/settings.ts b/main/services/computer-use/settings.ts index 5070e57e..fe903c8d 100644 --- a/main/services/computer-use/settings.ts +++ b/main/services/computer-use/settings.ts @@ -2,10 +2,15 @@ import { configStore } from "../config-store.js"; import { llmClient } from "../llm-client.js"; import { computerUseStatus } from "./status.js"; import { ComputerUseSettingsCoordinator } from "./settings-core.js"; +import { computerUseSupported } from "./platform.js"; export const computerUseSettings = new ComputerUseSettingsCoordinator({ - readPersisted: async () => (await configStore.getSettings()).computerUseEnabled === true, + readPersisted: async () => + computerUseSupported() && (await configStore.getSettings()).computerUseEnabled === true, persist: async (enabled, isCurrent) => { + if (enabled && !computerUseSupported()) { + throw new Error("Computer Use is not available on this platform."); + } await configStore.setSettings({ computerUseEnabled: enabled }, isCurrent); }, setRuntimeEnabled: (enabled) => computerUseStatus.setRuntimeEnabled(enabled), diff --git a/main/services/computer-use/status.ts b/main/services/computer-use/status.ts index a4a01c0d..36901ea4 100644 --- a/main/services/computer-use/status.ts +++ b/main/services/computer-use/status.ts @@ -1,8 +1,10 @@ import { configStore } from "../config-store.js"; import { createCuaDriverHost } from "./runtime.js"; import { ComputerUseStatusService } from "./status-core.js"; +import { computerUseSupported } from "./platform.js"; export const computerUseStatus = new ComputerUseStatusService({ - isEnabled: async () => (await configStore.getSettings()).computerUseEnabled === true, + isEnabled: async () => + computerUseSupported() && (await configStore.getSettings()).computerUseEnabled === true, createHost: createCuaDriverHost, }); diff --git a/main/services/dictation-platform.test.ts b/main/services/dictation-platform.test.ts new file mode 100644 index 00000000..778f1883 --- /dev/null +++ b/main/services/dictation-platform.test.ts @@ -0,0 +1,17 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { dictationPlatformBehavior } from "./dictation-platform.js"; + +test("Linux dictation is toggle-only clipboard delivery", () => { + assert.deepEqual(dictationPlatformBehavior("linux"), { + accessibilityPaste: false, + holdToTalk: false, + }); +}); +test("macOS dictation can use attended paste and hold-to-talk", () => { + assert.deepEqual(dictationPlatformBehavior("darwin"), { + accessibilityPaste: true, + holdToTalk: true, + }); +}); diff --git a/main/services/dictation-platform.ts b/main/services/dictation-platform.ts new file mode 100644 index 00000000..3f4ec5f7 --- /dev/null +++ b/main/services/dictation-platform.ts @@ -0,0 +1,15 @@ +import { hostPlatformCapabilities } from "./host-platform-capabilities.js"; + +export interface DictationPlatformBehavior { + accessibilityPaste: boolean; + holdToTalk: boolean; +} +export function dictationPlatformBehavior( + platform: NodeJS.Platform = process.platform, +): DictationPlatformBehavior { + const host = hostPlatformCapabilities(platform); + return { + accessibilityPaste: host.accessibilityPaste, + holdToTalk: host.dictationHoldToTalk, + }; +} diff --git a/main/services/dictation.ts b/main/services/dictation.ts index ca9d1b2a..36f9ba0e 100644 --- a/main/services/dictation.ts +++ b/main/services/dictation.ts @@ -11,41 +11,64 @@ import { cleanupDictationTranscript } from "./dictation-cleanup.js"; import { shouldAcceptDictationPress } from "./dictation-hotkey.js"; import { watchMacKeyUntilUp } from "./dictation-key-state.js"; import { acceleratorPrimaryMacKeyCode } from "./dictation-keycode.js"; +import { dictationPlatformBehavior } from "./dictation-platform.js"; import { pasteTranscript, runAtomicMacPaste, type PasteDeps } from "./dictation-paste.js"; import { DictationCoordinator } from "./dictation-coordinator.js"; let lastPressAt = 0; function livePasteDeps(): PasteDeps { + const behavior = dictationPlatformBehavior(); return { writeClipboard: (text) => clipboard.writeText(text), // Delivery must never steal focus with a native permission prompt. Users // grant paste access explicitly from Settings; otherwise we copy safely. - isAccessibilityTrusted: () => systemPreferences.isTrustedAccessibilityClient(false), - pasteWithPreservedClipboard: runAtomicMacPaste, + isAccessibilityTrusted: () => + behavior.accessibilityPaste && + systemPreferences.isTrustedAccessibilityClient(false), + pasteWithPreservedClipboard: behavior.accessibilityPaste + ? runAtomicMacPaste + : async () => false, log: (message, error) => logger.warn("dictation", message, error), }; } +async function deliverTranscript(text: string) { + if (!dictationPlatformBehavior().accessibilityPaste) { + clipboard.writeText(text); + return { + outcome: "copied" as const, + reason: "paste-unavailable" as const, + message: "Copied — automatic paste is not available on this system.", + }; + } + return pasteTranscript(text, livePasteDeps()); +} + const coordinator = new DictationCoordinator({ showPill, hidePill, destroyPill, broadcast: (payload) => ipcMain.broadcast("dictation:state", payload), - paste: (text) => pasteTranscript(text, livePasteDeps()), + paste: deliverTranscript, setTimer: (callback, delayMs) => setTimeout(callback, delayMs), clearTimer: (timer) => clearTimeout(timer), logError: (message, error) => logger.error("dictation", message, error), - isHoldToTalk: async () => (await configStore.getSettings()).dictationHoldToTalk === true, + isHoldToTalk: async () => + dictationPlatformBehavior().holdToTalk && + (await configStore.getSettings()).dictationHoldToTalk === true, shouldCleanup: async () => (await configStore.getSettings()).dictationCleanup === true, cleanupTranscript: cleanupDictationTranscript, getHoldKeyCode: async () => { + if (!dictationPlatformBehavior().holdToTalk) return null; const settings = await configStore.getSettings(); const binding = effectiveBindings(settings.keybindings, settings)["dictation.toggle"]; return acceleratorPrimaryMacKeyCode(binding); }, startHoldWatch: (keyCode, onRelease, onFailed) => - watchMacKeyUntilUp(keyCode, onRelease, { onFailed }), + dictationPlatformBehavior().holdToTalk + ? watchMacKeyUntilUp(keyCode, onRelease, { onFailed }) + : null, }); /** Hotkey callback (fire-and-forget). Debounced against OS key chatter. */ diff --git a/main/services/external-editors.test.ts b/main/services/external-editors.test.ts index 81adcb83..13056517 100644 --- a/main/services/external-editors.test.ts +++ b/main/services/external-editors.test.ts @@ -4,6 +4,8 @@ import { buildOpenApplicationArguments, launchApplicationBundle, openFolderInExternalEditor, + linuxExecutableSearchPaths, + resolveInstalledLinuxEditors, resolveInstalledEditorApplications, type OpenFolderInEditorDependencies, type ResolvedExternalEditor, @@ -13,7 +15,7 @@ const cursor: ResolvedExternalEditor = { id: "cursor", label: "Cursor", appPath: "/Applications/Cursor.app", - bundleId: "com.todesktop.230313mzl4w4u92", + launch: { kind: "bundle", bundleId: "com.todesktop.230313mzl4w4u92" }, iconDataUrl: "data:image/png;base64,icon", }; @@ -95,25 +97,27 @@ test("rejects missing and non-directory workspace folders", async () => { test("launches with fixed open arguments and never interprets the folder as shell syntax", async () => { const folderPath = "/tmp/workspace; touch should-not-exist"; - assert.deepEqual(buildOpenApplicationArguments(cursor.bundleId, folderPath), [ + assert.equal(cursor.launch.kind, "bundle"); + if (cursor.launch.kind !== "bundle") throw new Error("Expected a macOS bundle fixture."); + assert.deepEqual(buildOpenApplicationArguments(cursor.launch.bundleId, folderPath), [ "-b", - cursor.bundleId, + cursor.launch.bundleId, folderPath, ]); let invocation: { file: string; args: readonly string[] } | undefined; - await launchApplicationBundle(cursor.bundleId, folderPath, async (file, args) => { + await launchApplicationBundle(cursor.launch.bundleId, folderPath, async (file, args) => { invocation = { file, args }; }); assert.deepEqual(invocation, { file: "/usr/bin/open", - args: ["-b", cursor.bundleId, folderPath], + args: ["-b", cursor.launch.bundleId, folderPath], }); }); test("refreshes availability before launching the selected editor", async () => { let forcedRefresh = false; - let launched: { bundleId: string; folderPath: string } | undefined; + let launched: { editorId: string; folderPath: string } | undefined; await openFolderInExternalEditor( "/tmp/workspace", "cursor", @@ -122,18 +126,57 @@ test("refreshes availability before launching the selected editor", async () => forcedRefresh = forceRefresh; return [cursor]; }, - launchApplication: async (bundleId, folderPath) => { - launched = { bundleId, folderPath }; + launchApplication: async (editor, folderPath) => { + launched = { editorId: editor.id, folderPath }; }, }), ); assert.equal(forcedRefresh, true); assert.deepEqual(launched, { - bundleId: cursor.bundleId, + editorId: cursor.id, folderPath: "/tmp/workspace", }); }); +test("Linux editor lookup includes distro, Snap, user, and Toolbox command locations", () => { + assert.deepEqual(linuxExecutableSearchPaths("/custom/bin:/usr/bin", "/home/aiden"), [ + "/custom/bin", + "/usr/bin", + "/usr/local/bin", + "/snap/bin", + "/home/aiden/.local/bin", + "/home/aiden/.local/share/JetBrains/Toolbox/scripts", + ]); +}); + +test("Linux editor lookup recognizes common Flatpak application IDs", async () => { + const definitions = [ + { + id: "vscode", + label: "VS Code", + bundleIds: [], + applicationNames: [], + priority: 1, + }, + ]; + const resolved = await resolveInstalledLinuxEditors(definitions, [], { + executablePath: "/usr/bin/flatpak", + applicationIds: new Set(["com.visualstudio.code"]), + }); + assert.deepEqual(resolved, [ + { + id: "vscode", + label: "VS Code", + appPath: "/usr/bin/flatpak", + launch: { + kind: "flatpak", + executablePath: "/usr/bin/flatpak", + applicationId: "com.visualstudio.code", + }, + }, + ]); +}); + test("rejects an editor that disappeared after discovery", async () => { await assert.rejects( openFolderInExternalEditor( diff --git a/main/services/external-editors.ts b/main/services/external-editors.ts index 033f601b..a463fff4 100644 --- a/main/services/external-editors.ts +++ b/main/services/external-editors.ts @@ -1,4 +1,5 @@ -import { execFile } from "node:child_process"; +import { execFile, spawn } from "node:child_process"; +import { constants as fsConstants } from "node:fs"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; @@ -24,7 +25,11 @@ export interface ApplicationCandidate { export interface ResolvedExternalEditor extends ExternalEditor { appPath: string; - bundleId: string; + launch: + | { kind: "bundle"; bundleId: string } + | { kind: "executable"; executablePath: string } + | { kind: "flatpak"; executablePath: string; applicationId: string } + | { kind: "file-manager" }; } export const EXTERNAL_EDITOR_DEFINITIONS = [ @@ -238,6 +243,13 @@ export const EXTERNAL_EDITOR_DEFINITIONS = [ applicationNames: ["Finder"], priority: Number.MAX_SAFE_INTEGER, }, + { + id: "file-manager", + label: "Files", + bundleIds: [], + applicationNames: [], + priority: Number.MAX_SAFE_INTEGER, + }, ] as const satisfies readonly ExternalEditorDefinition[]; const FINDER_APP_PATH = "/System/Library/CoreServices/Finder.app"; @@ -249,6 +261,53 @@ const APPLICATION_ROOTS = [ path.join(os.homedir(), "Applications"), ] as const; +const LINUX_EXECUTABLES: Readonly> = { + cursor: ["cursor"], + vscode: ["code"], + "vscode-insiders": ["code-insiders"], + vscodium: ["codium"], + zed: ["zed"], + windsurf: ["windsurf"], + kiro: ["kiro"], + trae: ["trae"], + "android-studio": ["studio", "android-studio"], + "intellij-idea": ["idea", "idea.sh"], + clion: ["clion", "clion.sh"], + datagrip: ["datagrip", "datagrip.sh"], + dataspell: ["dataspell", "dataspell.sh"], + goland: ["goland", "goland.sh"], + phpstorm: ["phpstorm", "phpstorm.sh"], + pycharm: ["pycharm", "pycharm.sh"], + rider: ["rider", "rider.sh"], + rubymine: ["rubymine", "rubymine.sh"], + rustrover: ["rustrover", "rustrover.sh"], + webstorm: ["webstorm", "webstorm.sh"], + "sublime-text": ["subl", "sublime_text"], + opencode: ["opencode"], +}; + +const LINUX_FLATPAKS: Readonly> = { + vscode: ["com.visualstudio.code"], + "vscode-insiders": ["com.visualstudio.code.insiders"], + vscodium: ["com.vscodium.codium"], + zed: ["dev.zed.Zed"], + "android-studio": ["com.google.AndroidStudio"], + "intellij-idea": ["com.jetbrains.IntelliJ-IDEA-Community", "com.jetbrains.IntelliJ-IDEA-Ultimate"], + clion: ["com.jetbrains.CLion"], + datagrip: ["com.jetbrains.DataGrip"], + phpstorm: ["com.jetbrains.PhpStorm"], + pycharm: ["com.jetbrains.PyCharm-Community", "com.jetbrains.PyCharm-Professional"], + rider: ["com.jetbrains.Rider"], + rubymine: ["com.jetbrains.RubyMine"], + webstorm: ["com.jetbrains.WebStorm"], + "sublime-text": ["com.sublimetext.three"], +}; + +export interface LinuxFlatpakInstallation { + executablePath: string; + applicationIds: ReadonlySet; +} + let cachedEditors: { expiresAt: number; value: ResolvedExternalEditor[] } | null = null; let discoveryInFlight: Promise | null = null; @@ -261,6 +320,21 @@ function runFile(file: string, args: readonly string[]): Promise { }); } +function launchDetached(file: string, args: readonly string[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn(file, [...args], { + detached: true, + stdio: "ignore", + windowsHide: true, + }); + child.once("error", reject); + child.once("spawn", () => { + child.unref(); + resolve(); + }); + }); +} + function normalize(value: string | undefined): string { return value?.trim().toLocaleLowerCase("en-US") ?? ""; } @@ -292,7 +366,7 @@ export function resolveInstalledEditorApplications( ]; return definitions - .filter((definition) => definition.id !== "finder") + .filter((definition) => definition.id !== "finder" && definition.id !== "file-manager") .flatMap((definition) => { const matches = uniqueCandidates .map((candidate) => ({ candidate, rank: candidateRank(definition, candidate) })) @@ -311,7 +385,7 @@ export function resolveInstalledEditorApplications( id: definition.id, label: definition.label, appPath: selected.appPath, - bundleId: selected.bundleId, + launch: { kind: "bundle" as const, bundleId: selected.bundleId }, }, ]; }) @@ -332,7 +406,7 @@ export function buildExternalEditorSpotlightQuery( definitions: readonly ExternalEditorDefinition[] = EXTERNAL_EDITOR_DEFINITIONS, ): string { const clauses = definitions - .filter((definition) => definition.id !== "finder") + .filter((definition) => definition.id !== "finder" && definition.id !== "file-manager") .flatMap((definition) => [ ...definition.bundleIds.map( (bundleId) => `kMDItemCFBundleIdentifier == "${escapeSpotlightValue(bundleId)}"cd`, @@ -370,7 +444,7 @@ async function readBundleIdentifier(appPath: string): Promise { const definitions = EXTERNAL_EDITOR_DEFINITIONS.filter( - (definition) => definition.id !== "finder", + (definition) => definition.id !== "finder" && definition.id !== "file-manager", ); const directPaths = definitions.flatMap((definition) => definition.applicationNames.flatMap((name) => @@ -406,6 +480,90 @@ async function locateApplicationCandidates(): Promise { ); } +export function linuxExecutableSearchPaths( + pathValue: string | undefined = process.env.PATH, + homeDirectory: string = os.homedir(), +): string[] { + return [ + ...(pathValue?.split(path.delimiter) ?? []), + "/usr/local/bin", + "/usr/bin", + "/snap/bin", + path.join(homeDirectory, ".local", "bin"), + path.join(homeDirectory, ".local", "share", "JetBrains", "Toolbox", "scripts"), + ].filter((entry, index, values) => Boolean(entry) && values.indexOf(entry) === index); +} + +export async function resolveInstalledLinuxEditors( + definitions: readonly ExternalEditorDefinition[] = EXTERNAL_EDITOR_DEFINITIONS, + searchPaths: readonly string[] = linuxExecutableSearchPaths(), + flatpak?: LinuxFlatpakInstallation, +): Promise>> { + const resolved = await Promise.all( + definitions + .filter((definition) => LINUX_EXECUTABLES[definition.id]) + .map(async (definition) => { + for (const executable of LINUX_EXECUTABLES[definition.id] ?? []) { + for (const root of searchPaths) { + const executablePath = path.join(root, executable); + try { + await fs.access(executablePath, fsConstants.X_OK); + return { + id: definition.id, + label: definition.label, + appPath: executablePath, + launch: { kind: "executable" as const, executablePath }, + }; + } catch { + // Continue through deterministic PATH candidates. + } + } + } + const applicationId = (LINUX_FLATPAKS[definition.id] ?? []).find((candidate) => + flatpak?.applicationIds.has(candidate), + ); + if (applicationId && flatpak) { + return { + id: definition.id, + label: definition.label, + appPath: flatpak.executablePath, + launch: { + kind: "flatpak" as const, + executablePath: flatpak.executablePath, + applicationId, + }, + }; + } + return null; + }), + ); + return resolved.filter((editor): editor is NonNullable => editor !== null); +} + +async function locateLinuxFlatpak( + searchPaths: readonly string[], +): Promise { + for (const root of searchPaths) { + const executablePath = path.join(root, "flatpak"); + try { + await fs.access(executablePath, fsConstants.X_OK); + const output = await runFile(executablePath, ["list", "--app", "--columns=application"]); + return { + executablePath, + applicationIds: new Set( + output + .split("\n") + .map((entry) => entry.trim()) + .filter(Boolean), + ), + }; + } catch { + // Continue to the next deterministic command location. + } + } + return undefined; +} + async function loadNativeIcon(appPath: string): Promise { try { const { app, nativeImage } = await import("electron"); @@ -423,21 +581,40 @@ async function loadNativeIcon(appPath: string): Promise { } async function discoverExternalEditors(): Promise { - const resolved = resolveInstalledEditorApplications(await locateApplicationCandidates()); + const resolved = + process.platform === "linux" + ? await (async () => { + const searchPaths = linuxExecutableSearchPaths(); + return resolveInstalledLinuxEditors( + EXTERNAL_EDITOR_DEFINITIONS, + searchPaths, + await locateLinuxFlatpak(searchPaths), + ); + })() + : resolveInstalledEditorApplications(await locateApplicationCandidates()); const withIcons = await Promise.all( resolved.map(async (editor) => ({ ...editor, iconDataUrl: await loadNativeIcon(editor.appPath), })), ); - const finder: ResolvedExternalEditor = { - id: "finder", - label: "Finder", - appPath: FINDER_APP_PATH, - bundleId: "com.apple.finder", - iconDataUrl: await loadNativeIcon(FINDER_APP_PATH), - }; - return [...withIcons, finder]; + const fileManager: ResolvedExternalEditor = + process.platform === "linux" + ? { + id: "file-manager", + label: "Files", + appPath: "", + launch: { kind: "file-manager" }, + iconDataUrl: "", + } + : { + id: "finder", + label: "Finder", + appPath: FINDER_APP_PATH, + launch: { kind: "file-manager" }, + iconDataUrl: await loadNativeIcon(FINDER_APP_PATH), + }; + return [...withIcons, fileManager]; } async function resolvedExternalEditors(forceRefresh = false): Promise { @@ -493,7 +670,7 @@ export interface OpenFolderInEditorDependencies { stat: (folderPath: string) => Promise<{ isDirectory(): boolean }>; editors: (forceRefresh: boolean) => Promise; openPath: (folderPath: string) => Promise; - launchApplication: (bundleId: string, folderPath: string) => Promise; + launchApplication: (editor: ResolvedExternalEditor, folderPath: string) => Promise; } const defaultOpenDependencies: OpenFolderInEditorDependencies = { @@ -503,7 +680,25 @@ const defaultOpenDependencies: OpenFolderInEditorDependencies = { const { shell } = await import("electron"); return shell.openPath(folderPath); }, - launchApplication: launchApplicationBundle, + launchApplication: async (editor, folderPath) => { + if (editor.launch.kind === "bundle") { + await launchApplicationBundle(editor.launch.bundleId, folderPath); + return; + } + if (editor.launch.kind === "executable") { + await launchDetached(editor.launch.executablePath, [folderPath]); + return; + } + if (editor.launch.kind === "flatpak") { + await launchDetached(editor.launch.executablePath, [ + "run", + editor.launch.applicationId, + folderPath, + ]); + return; + } + throw new Error("The selected application cannot be launched directly."); + }, }; export async function openFolderInExternalEditor( @@ -525,14 +720,14 @@ export async function openFolderInExternalEditor( const editor = (await dependencies.editors(true)).find((candidate) => candidate.id === editorId); if (!editor) throw new Error(`${definition.label} is no longer installed.`); - if (editor.id === "finder") { + if (editor.launch.kind === "file-manager") { const error = await dependencies.openPath(folderPath); - if (error) throw new Error(`Could not open workspace in Finder: ${error}`); + if (error) throw new Error(`Could not open workspace in ${editor.label}: ${error}`); return; } try { - await dependencies.launchApplication(editor.bundleId, folderPath); + await dependencies.launchApplication(editor, folderPath); } catch (error) { const detail = error instanceof Error ? error.message : String(error); throw new Error(`Could not open workspace in ${editor.label}: ${detail}`); diff --git a/main/services/generation-timeline.ts b/main/services/generation-timeline.ts index 03842676..d9ac021b 100644 --- a/main/services/generation-timeline.ts +++ b/main/services/generation-timeline.ts @@ -148,7 +148,7 @@ export function safeToolDescriptor(toolName: string, args: unknown): SafeToolDes case "schedule_task": return { label: "Schedule task", detail: safeDetail(values.action) }; case "computer_use": - return { label: "Use Mac", detail: safeDetail(values.action) }; + return { label: "Use computer", detail: safeDetail(values.action) }; case "compact_context": return { label: "Compact context" }; case "ask_user_question": diff --git a/main/services/host-platform-capabilities.test.ts b/main/services/host-platform-capabilities.test.ts new file mode 100644 index 00000000..1e32674a --- /dev/null +++ b/main/services/host-platform-capabilities.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { hostPlatformCapabilities } from "./host-platform-capabilities.js"; + +test("Darwin exposes Apple-owned host integrations", () => { + assert.deepEqual(hostPlatformCapabilities("darwin"), { + platform: "darwin", + bots: true, + computerUse: true, + appleFoundationModels: true, + accessibilityPaste: true, + dictationHoldToTalk: true, + dockIcon: true, + nativeShare: true, + }); +}); +test("Linux fails closed for Apple-owned host integrations", () => { + assert.deepEqual(hostPlatformCapabilities("linux"), { + platform: "linux", + bots: false, + computerUse: false, + appleFoundationModels: false, + accessibilityPaste: false, + dictationHoldToTalk: false, + dockIcon: false, + nativeShare: false, + }); +}); diff --git a/main/services/host-platform-capabilities.ts b/main/services/host-platform-capabilities.ts new file mode 100644 index 00000000..55687aba --- /dev/null +++ b/main/services/host-platform-capabilities.ts @@ -0,0 +1,30 @@ +export interface HostPlatformCapabilities { + platform: "darwin" | "linux" | "other"; + bots: boolean; + computerUse: boolean; + appleFoundationModels: boolean; + accessibilityPaste: boolean; + dictationHoldToTalk: boolean; + dockIcon: boolean; + nativeShare: boolean; +} +/** + * Main-owned host capability policy. Persisted settings and renderer state may + * narrow these values, but they can never widen them. + */ +export function hostPlatformCapabilities( + platform: NodeJS.Platform = process.platform, +): HostPlatformCapabilities { + const darwin = platform === "darwin"; + return { + platform: + platform === "darwin" || platform === "linux" ? platform : "other", + bots: darwin, + computerUse: darwin, + appleFoundationModels: darwin, + accessibilityPaste: darwin, + dictationHoldToTalk: darwin, + dockIcon: darwin, + nativeShare: darwin, + }; +} diff --git a/main/services/llm-client.ts b/main/services/llm-client.ts index da935a7e..21dfc353 100644 --- a/main/services/llm-client.ts +++ b/main/services/llm-client.ts @@ -53,6 +53,7 @@ import { type BotRuntimeApprovedRoot, } from "./bot-runtime-authority-main.js"; import type { BotRuntimeAuthorityAdmission } from "./bot-runtime-authority.js"; +import { hostPlatformCapabilities } from "./host-platform-capabilities.js"; import { botManagedWorkspace, resolveBotRuntimeMcpConnectionIdentities, @@ -140,6 +141,7 @@ import { import { piRuntimeEffectStore } from "./pi-runtime-effect-store.js"; import { createComputerUseController } from "./computer-use/runtime.js"; import { computerUseStatus } from "./computer-use/status.js"; +import { computerUseSupported } from "./computer-use/platform.js"; import { GenerationTimelineProjector } from "./generation-timeline.js"; import { advisorRuntime } from "./advisor-runtime-main.js"; import { ADVISOR_TOOL_NAME } from "./advisor-runtime.js"; @@ -703,6 +705,7 @@ async function prepareGeneration( ); let computerUse: ComputerUseController | undefined; if ( + computerUseSupported() && options.allowComputerUse !== false && (!botContext || botHasOrdinaryCapability(botContext, "computer_use")) && settings.computerUseEnabled === true && @@ -1394,6 +1397,9 @@ export const llmClient = { } authoritativeChat = chat; authoritativeMode = authoritativeChatGenerationMode(chat.workspaceId, params.mode); + if (chat.botId && !hostPlatformCapabilities().bots) { + throw new Error("Bot chats are not available on this platform."); + } authoritativeBot = await resolveBotForGeneration(chat, authoritativeMode, (botId) => botStore.get(botId), ); diff --git a/main/services/mcp-oauth-store.ts b/main/services/mcp-oauth-store.ts index 2244709d..32e4f98a 100644 --- a/main/services/mcp-oauth-store.ts +++ b/main/services/mcp-oauth-store.ts @@ -6,7 +6,8 @@ import * as fs from "fs/promises"; import * as path from "path"; import { randomUUID } from "node:crypto"; -import { app, safeStorage, logger } from "../platform.js"; +import { app, logger } from "../platform.js"; +import { secureStorage } from "./secure-storage.js"; import { parseMcpOAuthSession, type McpOAuthSession } from "./mcp-oauth-session.js"; import { deleteSecretKeyEntry, @@ -129,7 +130,7 @@ export const mcpOAuthStore = { if (!b64) return {}; let session: McpOAuthSession; try { - const json = await safeStorage.decryptString(Buffer.from(b64, "base64")); + const json = secureStorage.decryptString(Buffer.from(b64, "base64")); session = parseMcpOAuthSession(JSON.parse(json)); } catch (error) { logger.error("mcp-oauth", `Failed to decrypt OAuth session for ${serverId}`, error); @@ -145,10 +146,10 @@ export const mcpOAuthStore = { session: McpOAuthSession, isCurrent: MutationGuard = () => true, ): Promise { - if (!(await safeStorage.isEncryptionAvailable())) { - throw new Error("Secure storage is unavailable on this system; cannot save the sign-in."); + if (!secureStorage.isEncryptionAvailable()) { + throw new Error(`${secureStorage.unavailableMessage()} Cannot save the sign-in.`); } - const encrypted = await safeStorage.encryptString(JSON.stringify(session)); + const encrypted = secureStorage.encryptString(JSON.stringify(session)); await mutate(async () => { assertMutationCurrent(isCurrent); const map = await readMap(); diff --git a/main/services/models-catalog.ts b/main/services/models-catalog.ts index 76f1f902..cad77cf9 100644 --- a/main/services/models-catalog.ts +++ b/main/services/models-catalog.ts @@ -7,7 +7,6 @@ import { join } from "node:path"; import { app, logger } from "../platform.js"; import { EMPTY_ARTIFICIAL_ANALYSIS_CATALOG } from "./artificial-analysis-catalog-core.js"; import { openRouterBenchmarkRuntime } from "./openrouter-benchmark-runtime.js"; -import { modelsDevCacheRuntime } from "./models-dev-cache.js"; import { createModelCatalogLoader, lookupCatalogModelInfo, @@ -37,18 +36,6 @@ const getModelsDev = createModelCatalogLoader( }, ); -async function getDisplayModelsDev() { - const bundled = await getModelsDev(); - try { - return await modelsDevCacheRuntime.catalog(bundled); - } catch (error) { - logger.warn("models-catalog", "Could not read the device-local models.dev cache.", { - error: error instanceof Error ? error.message : String(error), - }); - return bundled; - } -} - async function loadOpenRouterBenchmarks() { try { return await openRouterBenchmarkRuntime.catalog(); @@ -78,7 +65,7 @@ export const modelsCatalog = { /** Capability info for one model. */ async info(provider: ModelCatalogProvider, modelId: string): Promise { const [modelsDev, openRouterBenchmarks] = await Promise.all([ - getDisplayModelsDev(), + getModelsDev(), loadOpenRouterBenchmarks(), ]); return resolveModelInfo( @@ -96,7 +83,7 @@ export const modelsCatalog = { modelIds: string[], ): Promise> { const [modelsDev, openRouterBenchmarks] = await Promise.all([ - getDisplayModelsDev(), + getModelsDev(), loadOpenRouterBenchmarks(), ]); return Object.fromEntries( diff --git a/main/services/models-dev-live-app-policy.test.ts b/main/services/models-dev-live-app-policy.test.ts new file mode 100644 index 00000000..e8bdb6da --- /dev/null +++ b/main/services/models-dev-live-app-policy.test.ts @@ -0,0 +1,15 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +test("live app model reads and provider handlers remain offline for models.dev", () => { + const providers = readFileSync( + new URL("../handlers/providers.ts", import.meta.url), + "utf8", + ); + const catalog = readFileSync(new URL("./models-catalog.ts", import.meta.url), "utf8"); + + assert.doesNotMatch(providers, /modelsDevCacheRuntime|fetchModelsDevCatalog/u); + assert.doesNotMatch(catalog, /models-dev-cache|fetchModelsDevCatalog/u); + assert.match(providers, /source:\s*"bundled"/u); +}); diff --git a/main/services/native-menu-command-contract.test.ts b/main/services/native-menu-command-contract.test.ts index 07865972..cdeaa0bd 100644 --- a/main/services/native-menu-command-contract.test.ts +++ b/main/services/native-menu-command-contract.test.ts @@ -2,14 +2,32 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; import { COMMANDS } from "../../renderer/shared/keybindings"; +import { applicationMenuTemplate } from "./application-menu-core"; test("catalog native-menu ownership exactly matches derived Electron accelerators", () => { - const main = readFileSync(new URL("../index.ts", import.meta.url), "utf8"); - const menuCommandIds = [ - ...main.matchAll(/accelerator:\s*command\("([^"]+)"\)/gu), - ] - .map((match) => match[1]) - .sort(); + const delivered = new Set(); + const menu = applicationMenuTemplate({ + platform: "darwin", + appName: "Aiden Agent", + bindings: Object.fromEntries(COMMANDS.map((command) => [command.id, command.defaultBinding])), + actions: { + checkForUpdates() {}, + deliverCommand(commandId) { + delivered.add(commandId); + }, + reload() {}, + }, + }); + const invokeItems = (items: typeof menu): void => { + for (const item of items) { + if (typeof item.click === "function") { + item.click({} as never, {} as never, {} as never); + } + if (Array.isArray(item.submenu)) invokeItems(item.submenu); + } + }; + invokeItems(menu); + const menuCommandIds = [...delivered].sort(); const catalogCommandIds = COMMANDS.filter((command) => command.nativeMenu) .map((command) => command.id) .sort(); diff --git a/main/services/pi-credential-store.ts b/main/services/pi-credential-store.ts index 19d5bb09..2801c8f9 100644 --- a/main/services/pi-credential-store.ts +++ b/main/services/pi-credential-store.ts @@ -1,7 +1,8 @@ import * as path from "path"; -import { app, logger, safeStorage } from "../platform.js"; +import { app, logger } from "../platform.js"; import { EncryptedPiCredentialStore } from "./pi-credential-store-core.js"; import { invalidateBotRuntimeInventoryAuthority } from "./bot-runtime-inventory-lease.js"; +import { secureStorage } from "./secure-storage.js"; const FILE = "pi-provider-credentials.json"; @@ -9,9 +10,9 @@ const FILE = "pi-provider-credentials.json"; export const piCredentialStore = new EncryptedPiCredentialStore({ filePath: () => path.join(app.getPath("userData"), FILE), cipher: { - isEncryptionAvailable: () => safeStorage.isEncryptionAvailable(), - encryptString: (value) => safeStorage.encryptString(value), - decryptString: (value) => safeStorage.decryptString(value), + isEncryptionAvailable: () => secureStorage.isEncryptionAvailable(), + encryptString: (value) => secureStorage.encryptString(value), + decryptString: (value) => secureStorage.decryptString(value), }, onDurabilityWarning: (error) => { logger.warn("pi-credential-store", "Credentials were saved without a directory sync.", { diff --git a/main/services/profile-share-files.test.ts b/main/services/profile-share-files.test.ts index 6026be8b..10e07745 100644 --- a/main/services/profile-share-files.test.ts +++ b/main/services/profile-share-files.test.ts @@ -9,6 +9,7 @@ import { PROFILE_SHARE_DIRECTORY_PREFIX, PROFILE_SHARE_FILE_NAME, PROFILE_SHARE_STALE_AGE_MS, + writeProfileShareExport, } from "./profile-share-files.js"; async function withTemporaryRoot(run: (root: string) => Promise): Promise { @@ -57,3 +58,20 @@ test("removes only stale, inactive Aiden share directories", async () => { await fs.stat(unrelated); }); }); + +test("writes a private Linux export without following symbolic links", async () => { + await withTemporaryRoot(async (root) => { + const target = path.join(root, "profile.png"); + await fs.writeFile(target, "old", { mode: 0o644 }); + await writeProfileShareExport(target, Buffer.from("image")); + assert.deepEqual(await fs.readFile(target), Buffer.from("image")); + assert.equal((await fs.stat(target)).mode & 0o777, 0o600); + + const protectedTarget = path.join(root, "protected.png"); + const link = path.join(root, "link.png"); + await fs.writeFile(protectedTarget, "keep"); + await fs.symlink(protectedTarget, link); + await assert.rejects(writeProfileShareExport(link, Buffer.from("replace"))); + assert.equal(await fs.readFile(protectedTarget, "utf8"), "keep"); + }); +}); diff --git a/main/services/profile-share-files.ts b/main/services/profile-share-files.ts index da072288..d48deffb 100644 --- a/main/services/profile-share-files.ts +++ b/main/services/profile-share-files.ts @@ -1,4 +1,5 @@ import * as fs from "node:fs/promises"; +import { constants as fsConstants } from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; @@ -31,6 +32,25 @@ export async function removeProfileShareDirectory(directory: string): Promise { + const handle = await fs.open( + filePath, + fsConstants.O_WRONLY | + fsConstants.O_CREAT | + fsConstants.O_TRUNC | + fsConstants.O_NOFOLLOW, + 0o600, + ); + try { + // Opening an existing path does not apply the mode argument. Normalize it + // before writing so a user-selected export never inherits public bits. + await handle.chmod(0o600); + await handle.writeFile(image); + } finally { + await handle.close(); + } +} + export async function cleanupStaleProfileShareDirectories(options?: { temporaryRoot?: string; activeDirectories?: ReadonlySet; diff --git a/main/services/profile-share.ts b/main/services/profile-share.ts index ddbd4066..1a4b5266 100644 --- a/main/services/profile-share.ts +++ b/main/services/profile-share.ts @@ -1,4 +1,4 @@ -import { BrowserWindow, ShareMenu, logger, nativeImage } from "../platform.js"; +import { BrowserWindow, ShareMenu, dialog, logger, nativeImage } from "../platform.js"; import { decodeProfileSharePng, MAX_SHARE_IMAGE_BYTES, @@ -8,7 +8,9 @@ import { import { cleanupStaleProfileShareDirectories, createProfileShareFile, + PROFILE_SHARE_FILE_NAME, removeProfileShareDirectory, + writeProfileShareExport, } from "./profile-share-files.js"; const SHARE_FILE_RETENTION_MS = 5 * 60 * 1_000; @@ -77,19 +79,27 @@ function canonicalProfileSharePng(dataUrl: unknown): Buffer { export async function shareProfilePng( dataUrl: unknown, parent: BrowserWindow | null, -): Promise { - if (process.platform !== "darwin") { - throw new Error("The native profile share sheet is available on macOS."); - } +): Promise { if (!parent || parent.isDestroyed()) { throw new Error("The profile window is no longer available for sharing."); } + const image = canonicalProfileSharePng(dataUrl); + if (process.platform !== "darwin") { + const result = await dialog.showSaveDialog(parent, { + title: "Save profile snapshot", + defaultPath: PROFILE_SHARE_FILE_NAME, + buttonLabel: "Save", + filters: [{ name: "PNG image", extensions: ["png"] }], + }); + if (result.canceled || !result.filePath) return false; + await writeProfileShareExport(result.filePath, image); + return true; + } if (activeShareSessions.size > 0) { throw new Error("Close the current share menu before opening another one."); } await beginStaleCleanup(); - const image = canonicalProfileSharePng(dataUrl); const { directory, filePath } = await createProfileShareFile(image); ownedShareDirectories.add(directory); let session: ShareSession | null = null; @@ -112,6 +122,7 @@ export async function shareProfilePng( scheduleCleanup(session, SHARE_FILE_RETENTION_MS); }, }); + return true; } catch (error) { if (session) { clearTimeout(session.timer); diff --git a/main/services/provider-credential-rotation.ts b/main/services/provider-credential-rotation.ts index bde8ac62..50f49be8 100644 --- a/main/services/provider-credential-rotation.ts +++ b/main/services/provider-credential-rotation.ts @@ -10,7 +10,10 @@ import { serializePendingProviderCredentialRotation, type PendingProviderCredentialRotationV1, } from "./provider-credential-rotation-core.js"; -import { sameProviderConnection } from "./provider-key-policy.js"; +import { + providerTransitionNeedsCredentialAccess, + sameProviderConnection, +} from "./provider-key-policy.js"; import { secrets } from "./secrets.js"; import { mutatePortableConfigAndSync } from "./portable-credential-snapshot.js"; import type { StoredProvider } from "./types.js"; @@ -83,8 +86,13 @@ export function saveProviderWithCredentialRotation( return mutatePortableConfigAndSync(() => serialized(async () => { if (!isCurrent()) throw new Error("The renderer document is no longer active."); - await reconcilePendingProviderCredentialRotationNow(); const previous = await configStore.getProvider(provider.id); + if (!providerTransitionNeedsCredentialAccess(previous, provider)) { + // A fresh Linux desktop may intentionally have no keyring session. + // Keyless local providers neither read nor write the secret backend. + return configStore.saveProvider(provider, isCurrent); + } + await reconcilePendingProviderCredentialRotationNow(); const connectionChanged = Boolean(previous && !sameProviderConnection(previous, provider)); const hasStoredKey = await secrets.hasKey(provider.id); const { previousKey, mismatched } = providerCredentialState( @@ -221,16 +229,27 @@ export function reconcileExternalProviderCredentialChanges( current: StoredProvider[], ): Promise { return serialized(async () => { + const previousById = new Map(previous.map((provider) => [provider.id, provider])); + const currentById = new Map(current.map((provider) => [provider.id, provider])); + const transitions = [...new Set([...previousById.keys(), ...currentById.keys()])] + .map((providerId) => ({ + providerId, + before: previousById.get(providerId), + after: currentById.get(providerId), + })) + .filter(({ before, after }) => + after ? !sameProviderConnection(before, after) : Boolean(before), + ) + .filter(({ before, after }) => + providerTransitionNeedsCredentialAccess(before, after), + ); + if (transitions.length === 0) return; + // The watcher already selected `current` from one authoritative reload. // Pending recovery must use that cached projection rather than consuming a // second disk edit behind the transition the watcher is about to commit. await reconcilePendingProviderCredentialRotationNow(false, false); - const previousById = new Map(previous.map((provider) => [provider.id, provider])); - const currentById = new Map(current.map((provider) => [provider.id, provider])); - for (const providerId of new Set([...previousById.keys(), ...currentById.keys()])) { - const before = previousById.get(providerId); - const after = currentById.get(providerId); - if (before && after && sameProviderConnection(before, after)) continue; + for (const { providerId, after } of transitions) { // External config writes cannot participate in the encrypted-store queue. // Preserve the exact bound key in a bounded quarantine slot instead of // irreversibly deleting it from a potentially stale before/after pair. diff --git a/main/services/provider-key-policy.test.ts b/main/services/provider-key-policy.test.ts index 40d236a5..74e030a7 100644 --- a/main/services/provider-key-policy.test.ts +++ b/main/services/provider-key-policy.test.ts @@ -1,6 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { canUseStoredProviderKey, sameProviderConnection } from "./provider-key-policy.js"; +import { + canUseStoredProviderKey, + providerTransitionNeedsCredentialAccess, + sameProviderConnection, +} from "./provider-key-policy.js"; const saved = { id: "openai", @@ -24,3 +28,18 @@ test("only reuses a saved key for the same provider connection", () => { assert.equal(canUseStoredProviderKey(saved, { ...saved, needsKey: false }), false); assert.equal(canUseStoredProviderKey(null, saved), false); }); + +test("keyless provider changes do not require a desktop secret store", () => { + const keyless = { ...saved, needsKey: false }; + assert.equal(providerTransitionNeedsCredentialAccess(undefined, keyless), false); + assert.equal( + providerTransitionNeedsCredentialAccess(keyless, { + ...keyless, + baseUrl: "http://127.0.0.1:1234/v1", + }), + false, + ); + assert.equal(providerTransitionNeedsCredentialAccess(keyless, undefined), false); + assert.equal(providerTransitionNeedsCredentialAccess(saved, keyless), true); + assert.equal(providerTransitionNeedsCredentialAccess(keyless, saved), true); +}); diff --git a/main/services/provider-key-policy.ts b/main/services/provider-key-policy.ts index 6e167b90..0cb46f36 100644 --- a/main/services/provider-key-policy.ts +++ b/main/services/provider-key-policy.ts @@ -28,3 +28,11 @@ export function canUseStoredProviderKey( ): boolean { return draft.needsKey && sameProviderConnection(saved, draft); } + +/** Keyless-to-keyless changes cannot expose, bind, or rotate a provider secret. */ +export function providerTransitionNeedsCredentialAccess( + previous: ProviderConnection | null | undefined, + current: ProviderConnection | null | undefined, +): boolean { + return previous?.needsKey === true || current?.needsKey === true; +} diff --git a/main/services/renderer-readiness-core.test.ts b/main/services/renderer-readiness-core.test.ts index 69fb0717..5930ea7b 100644 --- a/main/services/renderer-readiness-core.test.ts +++ b/main/services/renderer-readiness-core.test.ts @@ -32,10 +32,10 @@ test("main invalidates readiness and reloads after the renderer process exits", const main = readFileSync(new URL("../index.ts", import.meta.url), "utf8"); assert.match( main, - /webContents\.on\(\s*"render-process-gone",\s*\(\) => \{\s*rendererReadiness\.reset\(\)/u, + /webContents\.on\(\s*"render-process-gone",\s*\([^)]*\) => \{[\s\S]{0,800}?rendererReadiness\.reset\(\)/u, ); assert.match( main, - /const recovery = mainWindowLoads\.replace\(createdWindow\.loadURL\(mainWindowUrl\)\)/u, + /const recovery = mainWindowLoads\.replace\(\s*createdWindow\.loadURL\(mainWindowUrl\),?\s*\)/u, ); }); diff --git a/main/services/schedule-tool.ts b/main/services/schedule-tool.ts index c13b8022..8928be20 100644 --- a/main/services/schedule-tool.ts +++ b/main/services/schedule-tool.ts @@ -1324,7 +1324,7 @@ export function createAssistantEditAutomationTool( ), notify: Type.Optional( Type.Boolean({ - description: "Replacement macOS notification preference.", + description: "Replacement desktop notification preference.", }), ), }, @@ -1429,7 +1429,7 @@ export function createScheduleTaskTool( ), notify: Type.Optional( Type.Boolean({ - description: "Show a macOS notification after non-silent runs.", + description: "Show a desktop notification after non-silent runs.", }), ), }, @@ -1561,7 +1561,7 @@ export function createScheduleTaskTool( ), notify: Type.Optional( Type.Boolean({ - description: "Show a macOS notification after non-silent runs.", + description: "Show a desktop notification after non-silent runs.", }), ), }), diff --git a/main/services/secrets.ts b/main/services/secrets.ts index 92926148..1a5ea1e8 100644 --- a/main/services/secrets.ts +++ b/main/services/secrets.ts @@ -4,7 +4,8 @@ import * as fs from "fs/promises"; import * as path from "path"; import { randomUUID } from "node:crypto"; -import { app, safeStorage, logger } from "../platform.js"; +import { app, logger } from "../platform.js"; +import { secureStorage } from "./secure-storage.js"; import { bindSecretEntryIfUnbound, deleteSecretKeyEntry, @@ -97,17 +98,17 @@ async function getKeyStrict(providerId: string): Promise { await mutationTail; const b64 = secretKeyEntry(await readMap(), providerId); if (!b64) return null; - return safeStorage.decryptString(Buffer.from(b64, "base64")); + return secureStorage.decryptString(Buffer.from(b64, "base64")); } async function encryptValue(value: string): Promise { - const encrypted = await safeStorage.encryptString(value); + const encrypted = await secureStorage.encryptString(value); return Buffer.from(encrypted).toString("base64"); } async function decryptedEntry(map: KeyMap, id: string): Promise { const b64 = secretKeyEntry(map, id); - return b64 ? safeStorage.decryptString(Buffer.from(b64, "base64")) : null; + return b64 ? secureStorage.decryptString(Buffer.from(b64, "base64")) : null; } function setKeyWithBound( @@ -121,12 +122,12 @@ function setKeyWithBound( if (key.length > maxLength) { throw new Error(`Encrypted values cannot exceed ${maxLength} characters.`); } - if (!(await safeStorage.isEncryptionAvailable())) { - throw new Error("Secure storage is unavailable on this system; cannot save the API key."); + if (!secureStorage.isEncryptionAvailable()) { + throw new Error(`${secureStorage.unavailableMessage()} Cannot save the API key.`); } const map = await readMap(); assertMutationCurrent(isCurrent); - const encrypted = await safeStorage.encryptString(key); + const encrypted = await secureStorage.encryptString(key); assertMutationCurrent(isCurrent); setSecretKeyEntry(map, providerId, Buffer.from(encrypted).toString("base64")); await writeMap(map, isCurrent); @@ -216,8 +217,8 @@ export const secrets = { return serialized(async () => { assertMutationCurrent(isCurrent); assertProviderCredentialLength(key); - if (!(await safeStorage.isEncryptionAvailable())) { - throw new Error("Secure storage is unavailable on this system; cannot save the API key."); + if (!secureStorage.isEncryptionAvailable()) { + throw new Error(`${secureStorage.unavailableMessage()} Cannot save the API key.`); } const map = await readMap(); assertMutationCurrent(isCurrent); diff --git a/main/services/secure-storage-core.test.ts b/main/services/secure-storage-core.test.ts new file mode 100644 index 00000000..064ef73a --- /dev/null +++ b/main/services/secure-storage-core.test.ts @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + secureStorageIsSafe, + secureStorageUnavailableMessage, +} from "./secure-storage-core.js"; + +test("Linux secure storage fails closed for Electron basic_text and unknown backends", () => { + assert.equal(secureStorageIsSafe("linux", true, "basic_text"), false); + assert.equal(secureStorageIsSafe("linux", true, "unknown"), false); + assert.equal(secureStorageIsSafe("linux", true, "future_backend"), false); + assert.equal(secureStorageIsSafe("linux", true, undefined), false); + assert.equal(secureStorageIsSafe("linux", false, "gnome_libsecret"), false); +}); + +test("Linux accepts desktop keyring-backed encryption", () => { + for (const backend of ["gnome_libsecret", "kwallet", "kwallet5", "kwallet6"]) { + assert.equal(secureStorageIsSafe("linux", true, backend), true); + } +}); + +test("non-Linux platforms retain the operating-system encryption decision", () => { + assert.equal(secureStorageIsSafe("darwin", true), true); + assert.equal(secureStorageIsSafe("darwin", false), false); + assert.match(secureStorageUnavailableMessage("linux"), /GNOME Keyring.*KWallet/u); +}); diff --git a/main/services/secure-storage-core.ts b/main/services/secure-storage-core.ts new file mode 100644 index 00000000..f12e02b6 --- /dev/null +++ b/main/services/secure-storage-core.ts @@ -0,0 +1,31 @@ +export type LinuxSecureStorageBackend = + | "basic_text" + | "gnome_libsecret" + | "kwallet" + | "kwallet5" + | "kwallet6" + | "unknown" + | string; + +export function secureStorageIsSafe( + platform: NodeJS.Platform, + encryptionAvailable: boolean, + backend?: LinuxSecureStorageBackend, +): boolean { + if (!encryptionAvailable) return false; + if (platform !== "linux") return true; + // Fail closed for future/unknown values. Electron's documented encrypted + // Linux backends are all desktop-keyring implementations. + return ( + backend === "gnome_libsecret" || + backend === "kwallet" || + backend === "kwallet5" || + backend === "kwallet6" + ); +} + +export function secureStorageUnavailableMessage(platform: NodeJS.Platform): string { + return platform === "linux" + ? "Secure storage is unavailable. Start or unlock GNOME Keyring, KWallet, or another Secret Service provider, then restart Aiden." + : "Secure storage is unavailable on this system."; +} diff --git a/main/services/secure-storage.ts b/main/services/secure-storage.ts new file mode 100644 index 00000000..83b8ac24 --- /dev/null +++ b/main/services/secure-storage.ts @@ -0,0 +1,45 @@ +import { safeStorage } from "../platform.js"; +import { + secureStorageIsSafe, + secureStorageUnavailableMessage, + type LinuxSecureStorageBackend, +} from "./secure-storage-core.js"; + +function selectedBackend(): LinuxSecureStorageBackend | undefined { + if (process.platform !== "linux") return undefined; + try { + return safeStorage.getSelectedStorageBackend(); + } catch { + return "unknown"; + } +} + +function assertAvailable(): void { + if (!secureStorage.isEncryptionAvailable()) { + throw new Error(secureStorageUnavailableMessage(process.platform)); + } +} + +export const secureStorage = { + unavailableMessage(): string { + return secureStorageUnavailableMessage(process.platform); + }, + + isEncryptionAvailable(): boolean { + return secureStorageIsSafe( + process.platform, + safeStorage.isEncryptionAvailable(), + selectedBackend(), + ); + }, + + encryptString(value: string): Buffer { + assertAvailable(); + return safeStorage.encryptString(value); + }, + + decryptString(value: Buffer): string { + assertAvailable(); + return safeStorage.decryptString(value); + }, +}; diff --git a/main/services/share-image-tool.ts b/main/services/share-image-tool.ts index b8a24ded..b7706ae3 100644 --- a/main/services/share-image-tool.ts +++ b/main/services/share-image-tool.ts @@ -121,7 +121,7 @@ export function createShareImageTool(dependencies: ShareImageToolDependencies): name: SHARE_IMAGE_TOOL_NAME, label: "Share Image", description: - "Attach a PNG or JPEG file from this Mac to your response so the user can view it in Aiden on Mac, iPhone, or iPad. Use this instead of opening Preview when the user asks to see or receive an image. Relative paths start at the active workspace; absolute paths are accepted after user approval.", + "Attach a PNG or JPEG file from this computer to your response so the user can view it in Aiden on desktop, iPhone, or iPad. Use this when the user asks to see or receive an image. Relative paths start at the active workspace; absolute paths are accepted after user approval.", parameters: Type.Object({ path: Type.String({ description: "Workspace-relative or absolute path to the PNG or JPEG." }), }), diff --git a/main/services/shortcut.ts b/main/services/shortcut.ts index 4e1a9a19..e10322ad 100644 --- a/main/services/shortcut.ts +++ b/main/services/shortcut.ts @@ -11,7 +11,8 @@ import { KeybindingValidationError, effectiveBindings, migrateLegacyKeybindings, - prettyAccelerator, + prettyAcceleratorForPlatform, + electronAcceleratorForPlatform, shouldPersistCanonicalKeybindings, validateEffectiveBindings, type CommandId, @@ -36,6 +37,17 @@ let recordingSuspended = false; let lastAppliedSettings: AppSettings | null = null; const transactions = createShortcutTransactionQueue(); +function nativeAccelerator(accelerator: string): string { + return electronAcceleratorForPlatform(accelerator, process.platform); +} + +function displayAccelerator(accelerator: string | null | undefined): string { + return prettyAcceleratorForPlatform( + accelerator, + process.platform === "darwin" ? "darwin" : process.platform === "linux" ? "linux" : "other", + ); +} + function logRollbackFailure(error: unknown): void { if (error instanceof ShortcutPersistenceRollbackError) { logger.error("shortcut", "Shortcut persistence and runtime rollback both failed.", error); @@ -93,7 +105,7 @@ function globalStatuses( ? { message: lastUnavailable.get(definition.id) ?? - `${prettyAccelerator(binding)} is not registered.`, + `${displayAccelerator(binding)} is not registered.`, } : {}), }; @@ -131,7 +143,7 @@ async function applyNow(settings: AppSettings): Promise { { register: async (accelerator, handler) => { try { - const ok = await globalShortcut.register(accelerator, handler); + const ok = await globalShortcut.register(nativeAccelerator(accelerator), handler); if (!ok) { logger.warn( "shortcut", @@ -149,7 +161,7 @@ async function applyNow(settings: AppSettings): Promise { return false; } }, - unregister: (accelerator) => globalShortcut.unregister(accelerator), + unregister: (accelerator) => globalShortcut.unregister(nativeAccelerator(accelerator)), }, registered, desired, @@ -157,8 +169,8 @@ async function applyNow(settings: AppSettings): Promise { registered = result.registered; if (!result.ok && result.failedCommandId) { const message = result.rollbackFailed - ? `${prettyAccelerator(result.failedAccelerator)} could not be registered, and macOS did not restore every previous shortcut.` - : `${prettyAccelerator(result.failedAccelerator)} is unavailable. Another app may be using it.`; + ? `${displayAccelerator(result.failedAccelerator)} could not be registered, and the system did not restore every previous shortcut.` + : `${displayAccelerator(result.failedAccelerator)} is unavailable. Another app may be using it.`; lastUnavailable = new Map([[result.failedCommandId, message]]); throw new KeybindingValidationError(message, "registration", result.failedCommandId); } @@ -254,7 +266,7 @@ export async function applyShortcutFromSettings(): Promise { const needsMigration = shouldPersistCanonicalKeybindings(settings.keybindings, keybindings); // Semantic V1 repair is durable configuration normalization, not a user // shortcut transaction. Persist it even when an unrelated global chord - // is currently owned by another macOS app and runtime registration fails. + // is currently owned by another operating-system app and runtime registration fails. if (needsMigration) { try { await configStore.setSettings({ keybindings }); @@ -277,7 +289,7 @@ export async function applyShortcutFromSettings(): Promise { export function disposeShortcut(): void { for (const item of registered.values()) { try { - globalShortcut.unregister(item.accelerator); + globalShortcut.unregister(nativeAccelerator(item.accelerator)); } catch { // App teardown must continue even when Electron has already disposed. } diff --git a/main/services/subagents/role-catalog.ts b/main/services/subagents/role-catalog.ts index 590d03d5..bae7eedc 100644 --- a/main/services/subagents/role-catalog.ts +++ b/main/services/subagents/role-catalog.ts @@ -71,7 +71,7 @@ export function subagentRoleSystemPrompt( : []), ...(shell ? [ - "You have exact run_command access with full macOS-user host execution authority. Every command pauses for attended Allow once approval.", + "You have exact run_command access with full host-user execution authority. Every command pauses for attended Allow once approval.", "The minimal environment reduces ambient secrets only. This is not an OS sandbox, there is no rollback, commands may use arbitrary network access, and deliberately detached processes may survive cancellation.", ] : []), diff --git a/main/services/subagents/subagent-phase3-contract.test.ts b/main/services/subagents/subagent-phase3-contract.test.ts index 8ee4db3f..3768b2c0 100644 --- a/main/services/subagents/subagent-phase3-contract.test.ts +++ b/main/services/subagents/subagent-phase3-contract.test.ts @@ -371,7 +371,7 @@ test("replacement chat reads mark bounded wait timeouts for retained renderer re const response = applicationService.indexOf("reconciliation: reconciliationRequired", read); assert.ok(getHandler >= 0); - assert.ok(inactiveCheck > getHandler); + assert.ok(inactiveCheck >= 0); assert.ok(idleWait > inactiveCheck); assert.ok(read > idleWait); assert.ok(response > read); diff --git a/main/services/subagents/subagent-run-store-io.test.ts b/main/services/subagents/subagent-run-store-io.test.ts new file mode 100644 index 00000000..d622ef9d --- /dev/null +++ b/main/services/subagents/subagent-run-store-io.test.ts @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { createNativeSubagentRunStoreStorage } from "./subagent-run-store-io.js"; + +const repositoryRoot = path.resolve(import.meta.dirname, "../../.."); +const binary = path.join(repositoryRoot, "build", "native", "aiden-subagent-run-store"); + +test("native run-store adapter accepts the platform generation and round-trips data", async (t) => { + if (process.platform !== "darwin" && process.platform !== "linux") { + t.skip("The native run-store helper is supported only on macOS and Linux."); + return; + } + const parent = await mkdtemp(path.join(os.tmpdir(), "aiden-run-store-io-")); + const storage = createNativeSubagentRunStoreStorage(path.join(parent, "store"), binary); + t.after(async () => { + await storage.close(); + await rm(parent, { recursive: true, force: true }); + }); + + assert.deepEqual(await storage.read(), { + status: "missing", + contents: undefined, + generation: "missing", + }); + const first = await storage.write("missing", '{"revision":1}'); + assert.match( + first, + process.platform === "linux" + ? /^[0-9a-f]+(?:-[0-9a-f]+){6}$/u + : /^[0-9a-f]+(?:-[0-9a-f]+){8}$/u, + ); + assert.deepEqual(await storage.read(), { + status: "data", + contents: Buffer.from('{"revision":1}'), + generation: first, + }); + const second = await storage.write(first, '{"revision":2}'); + assert.notEqual(second, first); + await storage.syncDirectory(); +}); diff --git a/main/services/subagents/subagent-run-store-io.ts b/main/services/subagents/subagent-run-store-io.ts index 7922eea3..fa1bb9b0 100644 --- a/main/services/subagents/subagent-run-store-io.ts +++ b/main/services/subagents/subagent-run-store-io.ts @@ -55,7 +55,13 @@ function defaultStorageBinary(): string { } function safeGeneration(value: string): boolean { - return value === "missing" || /^[0-9a-f]+(?:-[0-9a-f]+){8}$/u.test(value); + // macOS includes birth-time seconds/nanoseconds in addition to the seven + // identity fields available from Linux stat. Accept only those two exact + // native wire shapes; an intermediate field count is never canonical. + return ( + value === "missing" || + /^[0-9a-f]+(?:(?:-[0-9a-f]+){6}|(?:-[0-9a-f]+){8})$/u.test(value) + ); } class NativeSubagentRunStoreStorage implements SubagentRunStoreStorage { @@ -240,8 +246,10 @@ class NativeSubagentRunStoreStorage implements SubagentRunStoreStorage { if (!safeGeneration(generation)) throw new SubagentRunStoreStorageError("io_failed"); return { status: "oversized", contents: undefined, generation }; } - const match = /^data ([0-9a-f]+(?:-[0-9a-f]+){8}) ([A-Za-z0-9+/]*={0,2})$/u.exec(response); - if (!match) throw new SubagentRunStoreStorageError("io_failed"); + const match = /^data (\S+) ([A-Za-z0-9+/]*={0,2})$/u.exec(response); + if (!match || !safeGeneration(match[1])) { + throw new SubagentRunStoreStorageError("io_failed"); + } return { status: "data", generation: match[1], @@ -254,8 +262,10 @@ class NativeSubagentRunStoreStorage implements SubagentRunStoreStorage { const response = await this.request( `write ${expected} ${Buffer.from(contents, "utf8").toString("base64")}`, ); - const match = /^ok ([0-9a-f]+(?:-[0-9a-f]+){8})$/u.exec(response); - if (!match) throw new SubagentRunStoreStorageError("io_failed"); + const match = /^ok (\S+)$/u.exec(response); + if (!match || !safeGeneration(match[1])) { + throw new SubagentRunStoreStorageError("io_failed"); + } return match[1]; } diff --git a/main/services/subagents/subagent-shell-runner-io.test.ts b/main/services/subagents/subagent-shell-runner-io.test.ts index 29d6cac5..a494219f 100644 --- a/main/services/subagents/subagent-shell-runner-io.test.ts +++ b/main/services/subagents/subagent-shell-runner-io.test.ts @@ -49,6 +49,14 @@ test("resolves packaged and development helper locations", () => { resolveSubagentShellRunnerBinary({ defaultApp: true, cwd: "/workspace" }), "/workspace/build/native/aiden-subagent-shell-runner", ); + assert.equal( + resolveSubagentShellRunnerBinary({ + defaultApp: false, + resourcesPath: "/opt/Aiden Agent/resources", + cwd: "/workspace", + }), + "/opt/Aiden Agent/Helpers/aiden-subagent-shell-runner", + ); }); test("command exists only in the framed control payload, never helper argv or environment", async () => { @@ -112,7 +120,7 @@ test("protocol rejects hostile commands and response spoofing", () => { }); test("native runner returns zero, nonzero, signal, and no-output outcomes", async (t) => { - if (process.platform !== "darwin") return; + if (process.platform !== "darwin" && process.platform !== "linux") return; assert.deepEqual(await run(t, "printf hello"), { outcome: "exited", exitCode: 0, @@ -132,12 +140,20 @@ test("native runner returns zero, nonzero, signal, and no-output outcomes", asyn }); test("native runner uses a secret-free fixed environment and private 0700 directories", async (t) => { - if (process.platform !== "darwin") return; + if (process.platform !== "darwin" && process.platform !== "linux") return; process.env.AIDEN_PHASE5D_SECRET = "must-not-cross"; t.after(() => delete process.env.AIDEN_PHASE5D_SECRET); + const modeCommand = + process.platform === "linux" + ? "stat -c '%a' \"$HOME\" \"$TMPDIR\" \"$XDG_CONFIG_HOME\"" + : "stat -f '%Lp' \"$HOME\" \"$TMPDIR\" \"$XDG_CONFIG_HOME\""; const result = await run( t, - 'printf \'%s\\n\' "${AIDEN_PHASE5D_SECRET-unset}" "$PATH" "$LANG"; stat -f \'%Lp\' "$HOME" "$TMPDIR" "$XDG_CONFIG_HOME"; test ! -t 0', + [ + 'printf \'%s\\n\' "${AIDEN_PHASE5D_SECRET-unset}" "$PATH" "$LANG"', + modeCommand, + "test ! -t 0", + ].join("; "), ); assert.equal(result.outcome, "exited"); assert.match(result.stdout, /^unset\n\/usr\/bin:\/bin:\/usr\/sbin:\/sbin\nC\n700\n700\n700\n$/u); @@ -163,7 +179,7 @@ test("native runner uses a secret-free fixed environment and private 0700 direct }); test("timeout, cancellation, output floods, and held pipes clean the occupied group", async (t) => { - if (process.platform !== "darwin") return; + if (process.platform !== "darwin" && process.platform !== "linux") return; assert.equal((await run(t, "sleep 30", 30)).outcome, "timed_out"); assert.equal( (await run(t, "/usr/bin/yes x & /usr/bin/yes y >&2 & wait", 2_000)).outcome, @@ -191,7 +207,7 @@ test("timeout, cancellation, output floods, and held pipes clean the occupied gr }); test("workspace identity drift is rejected before shell execution", async (t) => { - if (process.platform !== "darwin") return; + if (process.platform !== "darwin" && process.platform !== "linux") return; const rootPath = await workspace(t); const root = await pinSubagentShellWorkspaceRoot(rootPath); root.inode = (BigInt(root.inode) + 1n).toString(); @@ -211,7 +227,7 @@ test("workspace identity drift is rejected before shell execution", async (t) => }); test("a deliberate setsid double-fork proves the documented containment limit and self-cleans", async (t) => { - if (process.platform !== "darwin") return; + if (process.platform !== "darwin" && process.platform !== "linux") return; const rootPath = await workspace(t); const marker = path.join(rootPath, "detached.pid"); const fixture = path.join( diff --git a/main/services/subagents/subagent-shell-runner-io.ts b/main/services/subagents/subagent-shell-runner-io.ts index d6278b60..992d4602 100644 --- a/main/services/subagents/subagent-shell-runner-io.ts +++ b/main/services/subagents/subagent-shell-runner-io.ts @@ -241,6 +241,11 @@ export async function runSubagentShellProductionInert(input: { helperErrorBytes += chunk.length; if (helperErrorBytes > 16 * 1024) child.kill("SIGKILL"); }); + // A helper that rejects its pinned root may close stdin before this process + // finishes the small control write. Its exit status remains authoritative; + // contain the resulting stream EPIPE so it cannot escape as an uncaught + // exception ahead of the verified close outcome. + child.stdin.on("error", () => undefined); const closed = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( (resolve, reject) => { child.once("error", reject); diff --git a/main/services/subagents/subagent-shell.test.ts b/main/services/subagents/subagent-shell.test.ts index 267742db..5817fb1e 100644 --- a/main/services/subagents/subagent-shell.test.ts +++ b/main/services/subagents/subagent-shell.test.ts @@ -12,6 +12,7 @@ import { createSubagentAuthorityV2, type SubagentAuthorityV2 } from "./authority import { createSubagentShellBrokerV2, createSubagentShellTool, + subagentShellProfile, type SubagentShellBrokerV2Input, } from "./subagent-shell.js"; import { subagentWorkspaceRevisionV2 } from "./subagent-workspace-write.js"; @@ -169,7 +170,20 @@ test("shell tool is exact and inert until the main-owned broker wraps it", () => const created = createSubagentShellTool(); assert.equal(created.tool.name, "run_command"); assert.deepEqual(created.binding, { toolName: "run_command" }); - assert.match(created.tool.description, /full macOS-user host authority/u); + assert.match(created.tool.description, /full host-user authority/u); +}); + +test("shell approvals identify the platform-correct native interpreter", () => { + assert.deepEqual(subagentShellProfile("darwin"), { + executable: "/bin/zsh", + arguments: ["-f", "-c"], + display: "/bin/zsh -f -c", + }); + assert.deepEqual(subagentShellProfile("linux"), { + executable: "/bin/sh", + arguments: ["-c"], + display: "/bin/sh -c", + }); }); test("exact multiline approval is durable before one helper dispatch", async (t) => { diff --git a/main/services/subagents/subagent-shell.ts b/main/services/subagents/subagent-shell.ts index c6dd93e0..ccf8aeb0 100644 --- a/main/services/subagents/subagent-shell.ts +++ b/main/services/subagents/subagent-shell.ts @@ -7,7 +7,10 @@ import type { BeforeToolCallContext, BeforeToolCallResult, } from "@earendil-works/pi-agent-core"; -import type { SubagentShellApprovalDetails } from "../../../renderer/shared/assistant.js"; +import type { + SubagentShellApprovalDetails, + SubagentShellApprovalShell, +} from "../../../renderer/shared/assistant.js"; import type { ToolApprovalPrompt } from "../tool-approval.js"; import type { Workspace } from "../types.js"; import { @@ -91,6 +94,21 @@ export interface SubagentShellBrokerV2Input { registry?: WorkspaceOperationRegistry; now?: () => number; randomUUID?: () => string; + platform?: NodeJS.Platform; +} + +export interface SubagentShellProfile { + executable: string; + arguments: readonly string[]; + display: SubagentShellApprovalShell; +} + +export function subagentShellProfile( + platform: NodeJS.Platform = process.platform, +): SubagentShellProfile { + return platform === "darwin" + ? { executable: "/bin/zsh", arguments: ["-f", "-c"], display: "/bin/zsh -f -c" } + : { executable: "/bin/sh", arguments: ["-c"], display: "/bin/sh -c" }; } function blocked(reason: string): BeforeToolCallResult { @@ -170,6 +188,7 @@ function effectDigest(input: { childId: string; toolCallId: string; expiresAt: number; + shell: SubagentShellProfile; }): string { return fieldsDigest( "aiden-subagent-shell-effect-v2", @@ -177,9 +196,8 @@ function effectDigest(input: { input.root.path, input.root.device, input.root.inode, - "/bin/zsh", - "-f", - "-c", + input.shell.executable, + ...input.shell.arguments, "aiden-subagent", "minimal-private-0700-v1", "stdin=/dev/null", @@ -231,7 +249,7 @@ export function createSubagentShellTool(): { name: SUBAGENT_RUN_COMMAND_TOOL_NAME, label: "Run approved host command", description: - "Run one exact command with full macOS-user host authority after attended Allow once approval. Minimal environment only; no OS sandbox or rollback.", + "Run one exact command with full host-user authority after attended Allow once approval. Minimal environment only; no OS sandbox or rollback.", parameters: Type.Object( { command: Type.String({ @@ -267,6 +285,7 @@ export function createSubagentShellBrokerV2( const allocate = input.randomUUID ?? randomUUID; const registry = input.registry ?? workspaceOperationRegistry; const runShell = input.runShell ?? runSubagentShellProductionInert; + const shell = subagentShellProfile(input.platform); const pending = new Map(); const active = new Set(); let shuttingDown = false; @@ -338,6 +357,7 @@ export function createSubagentShellBrokerV2( childId: input.childId, toolCallId: context.toolCall.id, expiresAt, + shell, }); const authorityDigest = subagentAuthorityDigestV2(authority); const ledgerInput: PrepareSubagentApprovalV2Input = { @@ -395,7 +415,7 @@ export function createSubagentShellBrokerV2( childLabel: input.childLabel, command, initialCwd: root.path, - shell: "/bin/zsh -f -c", + shell: shell.display, argumentDigestPrefix: argumentDigest.slice(0, DIGEST_PREFIX), rootDigestPrefix: rootDigest.slice(0, DIGEST_PREFIX), effectDigestPrefix: calculatedEffectDigest.slice(0, DIGEST_PREFIX), diff --git a/main/services/telegram/telegram-bot-binding-platform.test.ts b/main/services/telegram/telegram-bot-binding-platform.test.ts new file mode 100644 index 00000000..47ce8825 --- /dev/null +++ b/main/services/telegram/telegram-bot-binding-platform.test.ts @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + createUnavailableTelegramBotBindingStore, + TelegramBotBindingsUnsupportedError, +} from "./telegram-bot-binding-platform.js"; + +test("unsupported Bot bindings leave ordinary Telegram routing healthy and unbound", async () => { + const store = createUnavailableTelegramBotBindingStore(); + await store.assertHealthy(); + assert.deepEqual(await store.list(), []); + assert.equal(await store.get("bot"), null); + assert.equal(await store.resolve("default", 42), null); + assert.equal(await store.unbindProfile("default"), 0); +}); +test("unsupported Bot binding mutations fail closed", async () => { + const store = createUnavailableTelegramBotBindingStore(); + await assert.rejects( + store.bind({ + botId: "bot", + profile: "default", + chatId: 42, + ownerUserId: 42, + workspaceId: "workspace", + backingWorkspaceId: "bot-workspace", + }), + TelegramBotBindingsUnsupportedError, + ); + await assert.rejects(store.unbind("bot"), TelegramBotBindingsUnsupportedError); +}); + +test("ordinary Telegram cleanup cannot activate Bot notice storage on Linux", () => { + const source = readFileSync(new URL("./telegram-service.ts", import.meta.url), "utf8"); + assert.match( + source, + /revokeTelegramBotNoticeForCurrentOwner[\s\S]*?if \(!hostPlatformCapabilities\(\)\.bots\) return;[\s\S]*?botApplicationService\.revokeNoticeAudience/u, + ); +}); diff --git a/main/services/telegram/telegram-bot-binding-platform.ts b/main/services/telegram/telegram-bot-binding-platform.ts new file mode 100644 index 00000000..392b36dd --- /dev/null +++ b/main/services/telegram/telegram-bot-binding-platform.ts @@ -0,0 +1,32 @@ +import type { + TelegramBotBindingStore, +} from "./telegram-bot-binding-store.js"; + +export class TelegramBotBindingsUnsupportedError extends Error { + readonly name = "TelegramBotBindingsUnsupportedError"; + + constructor() { + super("Bots are not available on this platform."); + } +} +/** + * Linux can continue ordinary Telegram routing without pretending to provide + * the independently checkpointed Bot-binding authority used on macOS. + */ +export function createUnavailableTelegramBotBindingStore(): TelegramBotBindingStore { + const unavailable = async (): Promise => { + throw new TelegramBotBindingsUnsupportedError(); + }; + return Object.freeze({ + assertHealthy: async () => undefined, + list: async () => [], + get: async () => null, + resolve: async () => null, + resolveExact: async () => null, + bind: unavailable, + unbind: unavailable, + // Ordinary Telegram profile deletion/reset uses this reduction-only seam. + // With no readable or writable Bot bindings on this host, zero is exact. + unbindProfile: async () => 0, + }); +} diff --git a/main/services/telegram/telegram-bot-bindings.ts b/main/services/telegram/telegram-bot-bindings.ts index cb9f3d66..c64a9558 100644 --- a/main/services/telegram/telegram-bot-bindings.ts +++ b/main/services/telegram/telegram-bot-bindings.ts @@ -7,6 +7,8 @@ import { } from "../bot-capability-keychain-anchor.js"; import { createTelegramBotBindingStore } from "./telegram-bot-binding-store.js"; import { createTelegramBotBindingAuthorityNarrower } from "./telegram-bot-binding-authority.js"; +import { createUnavailableTelegramBotBindingStore } from "./telegram-bot-binding-platform.js"; +import { hostPlatformCapabilities } from "../host-platform-capabilities.js"; let accountPromise: Promise | undefined; const account = (): Promise => { @@ -20,13 +22,15 @@ const account = (): Promise => { }; /** Main-owned durable registry shared by Telegram routing and Bots IPC. */ -export const telegramBotBindings = createTelegramBotBindingStore({ - root: () => app.getPath("userData"), - authority: { - head: createTelegramBotBindingKeychainAnchor({ account }), - bootstrap: createTelegramBotBindingKeychainBootstrapMarker({ account }), - }, -}); +export const telegramBotBindings = hostPlatformCapabilities().bots + ? createTelegramBotBindingStore({ + root: () => app.getPath("userData"), + authority: { + head: createTelegramBotBindingKeychainAnchor({ account }), + bootstrap: createTelegramBotBindingKeychainBootstrapMarker({ account }), + }, + }) + : createUnavailableTelegramBotBindingStore(); /** Reduction-only companion; widening remains behind Bot application admission. */ export const telegramBotBindingAuthority = diff --git a/main/services/telegram/telegram-service.ts b/main/services/telegram/telegram-service.ts index f3b40d68..543ba3c7 100644 --- a/main/services/telegram/telegram-service.ts +++ b/main/services/telegram/telegram-service.ts @@ -79,6 +79,7 @@ import { } from "./telegram-bot-bindings.js"; import { createTelegramBotBindingValidator } from "./telegram-bot-binding-validation.js"; import { telegramProfileMutationFence } from "./telegram-profile-mutation-fence.js"; +import { hostPlatformCapabilities } from "../host-platform-capabilities.js"; export const TELEGRAM_PROVIDER_ID = "telegram"; let profileSettingsMutation = Promise.resolve(); @@ -90,6 +91,7 @@ async function getProfileSettings(profile: string) { async function revokeTelegramBotNoticeForCurrentOwner( profile: string, ): Promise { + if (!hostPlatformCapabilities().bots) return; const ownerUserId = (await getProfileSettings(profile)).telegramAllowedUserId; if (ownerUserId === undefined) return; await botApplicationService.revokeNoticeAudience( diff --git a/main/services/terminal-spawn-helper.ts b/main/services/terminal-spawn-helper.ts index f0d15080..9032c1e7 100644 --- a/main/services/terminal-spawn-helper.ts +++ b/main/services/terminal-spawn-helper.ts @@ -25,12 +25,17 @@ export async function resolveNodePtySpawnHelperPaths( packageDir: string, readDirectory: (directory: string) => Promise = fs.readdir, ): Promise { - const prebuildsDir = path.join(resolveNodePtyDiskPackageDir(packageDir), "prebuilds"); + const diskPackageDir = resolveNodePtyDiskPackageDir(packageDir); + const compiledHelper = path.join(diskPackageDir, "build", "Release", "spawn-helper"); + const prebuildsDir = path.join(diskPackageDir, "prebuilds"); let entries: readonly string[]; try { entries = await readDirectory(prebuildsDir); } catch { - return []; + return [compiledHelper]; } - return entries.map((entry) => path.join(prebuildsDir, entry, "spawn-helper")); + return [ + compiledHelper, + ...entries.map((entry) => path.join(prebuildsDir, entry, "spawn-helper")), + ]; } diff --git a/main/services/terminal.test.ts b/main/services/terminal.test.ts index 2c8cd9dc..847f46cf 100644 --- a/main/services/terminal.test.ts +++ b/main/services/terminal.test.ts @@ -309,6 +309,18 @@ test("production spawn-helper discovery reads only the unpacked ASAR directory", assert.deepEqual(reads, [unpackedPrebuilds]); assert.deepEqual(helpers, [ + path.join( + "/Applications", + "Aiden Agent.app", + "Contents", + "Resources", + "app.asar.unpacked", + "node_modules", + "node-pty", + "build", + "Release", + "spawn-helper", + ), path.join(unpackedPrebuilds, "darwin-arm64", "spawn-helper"), path.join(unpackedPrebuilds, "darwin-x64", "spawn-helper"), ]); @@ -333,7 +345,19 @@ test("spawn-helper discovery handles node_modules.asar and absent prebuilds", as throw new Error("missing"); }); - assert.deepEqual(helpers, []); + assert.deepEqual(helpers, [ + path.join( + "/Applications", + "Aiden Agent.app", + "Contents", + "Resources", + "node_modules.asar.unpacked", + "node-pty", + "build", + "Release", + "spawn-helper", + ), + ]); assert.deepEqual(reads, [ path.join( "/Applications", diff --git a/main/services/terminal.ts b/main/services/terminal.ts index f92ba101..3c5cbb2d 100644 --- a/main/services/terminal.ts +++ b/main/services/terminal.ts @@ -53,7 +53,7 @@ export interface TerminalServiceOptions { spawnPty?: typeof spawn; /** * Ordered shell candidates. The first that exists and is executable wins. - * Exposed for tests; production resolves `$SHELL` then the macOS defaults. + * Exposed for tests; production resolves `$SHELL` then platform defaults. */ shellCandidates?: () => string[]; /** Test seam for candidate executability checks. */ @@ -100,12 +100,16 @@ function clamp(value: unknown, min: number, max: number, fallback: number): numb * being handed to node-pty: a stale `$SHELL` pointing at a removed Homebrew * install would otherwise surface as an opaque `posix_spawnp failed.`. */ -function defaultShellCandidates(): string[] { +export function defaultShellCandidates( + platform: NodeJS.Platform = process.platform, + environment: NodeJS.ProcessEnv = process.env, +): string[] { const candidates: string[] = []; - const shell = process.env.SHELL; + const shell = environment.SHELL; if (shell && path.isAbsolute(shell)) candidates.push(shell); - candidates.push("/bin/zsh", "/bin/bash", "/bin/sh"); - // De-duplicate while preserving order (e.g. SHELL=/bin/zsh). + if (platform === "darwin") candidates.push("/bin/zsh", "/bin/bash", "/bin/sh"); + else candidates.push("/bin/bash", "/bin/sh", "/bin/zsh"); + // De-duplicate while preserving order (e.g. SHELL=/bin/bash). return [...new Set(candidates)]; } @@ -207,7 +211,7 @@ async function trySpawnShell( ? lastError : "unknown error"; throw new Error( - `Could not launch any shell (tried ${attempted}). Last failure: ${causeMessage}. Set $SHELL to an installed shell or reinstall macOS.`, + `Could not launch any shell (tried ${attempted}). Last failure: ${causeMessage}. Set $SHELL to an installed executable shell.`, ); } @@ -280,9 +284,9 @@ export class TerminalService { const executableCandidates = candidates.filter(shellIsExecutable); if (executableCandidates.length === 0) { throw new Error( - `No executable shell found on this Mac (checked ${candidates + `No executable shell found on this system (checked ${candidates .map((candidate) => JSON.stringify(candidate)) - .join(", ")}). Set $SHELL to an installed shell, or reinstall macOS.`, + .join(", ")}). Set $SHELL to an installed executable shell.`, ); } const { pty, shell: resolvedShell, preferredShellSkipped } = await trySpawnShell( @@ -458,8 +462,8 @@ export class TerminalService { } } - // node-pty's macOS helper can be restored without its execute bit by npm's - // prebuilt archive, and `posix_spawn` of a non-executable file is exactly + // node-pty's helper can be restored without its execute bit by a prebuilt + // archive, and `posix_spawn` of a non-executable file is exactly // what surfaces to users as `posix_spawnp failed.`. Guard every helper that // node-pty may load: chmod if needed, then verify (never assume). A failure // here must be descriptive so the user can fix it, not opaque. @@ -477,12 +481,10 @@ export class TerminalService { /** * Resolve every `spawn-helper` node-pty may load on this machine. * - * node-pty 1.1.0 loads the helper from `prebuilds/-/spawn-helper` - * via `utils.loadNativeModule`, and in a packaged Electron app the same file - * lives under `app.asar.unpacked`. We resolve from `node-pty/package.json`, move - * to the real unpacked directory before any filesystem operation, and enumerate - * every `prebuilds/*` directory so a wrong-arch guess, a Rosetta run, or an extra - * prebuild still gets fixed up. + * node-pty 1.1.0 loads the helper beside its native module: normally + * `prebuilds/-` on macOS and `build/Release` after a Linux + * node-gyp build. In a packaged Electron app the same files live under + * `app.asar.unpacked`. Resolve both legitimate layouts from package.json. */ async function defaultSpawnHelperPaths(): Promise { const require = createRequire(import.meta.url); diff --git a/main/services/workspace-files.test.ts b/main/services/workspace-files.test.ts index e3bd25b0..6a4b5944 100644 --- a/main/services/workspace-files.test.ts +++ b/main/services/workspace-files.test.ts @@ -4,6 +4,7 @@ import * as os from "node:os"; import * as path from "node:path"; import test from "node:test"; import { + linuxRecoveryUse, listWorkspaceFiles, readWorkspaceFile, WorkspaceFileError, @@ -194,3 +195,17 @@ test("workspace editor rejects traversal and binary files", async (t) => { await assert.rejects(readWorkspaceFile(root, "../outside.txt"), /outside the workspace/); await assert.rejects(readWorkspaceFile(root, "binary.dat"), /binary/); }); + +test( + "Linux recovery inspection detects current-user open descriptors", + { skip: process.platform !== "linux" || !process.getuid }, + async (t) => { + const root = await workspace(t); + const file = path.join(root, "recovery.txt"); + await fs.writeFile(file, "original"); + const handle = await fs.open(file, "r"); + assert.equal(await linuxRecoveryUse(file), "open"); + await handle.close(); + assert.equal(await linuxRecoveryUse(file), "clear"); + }, +); diff --git a/main/services/workspace-files.ts b/main/services/workspace-files.ts index 6034b369..ddb9ee9b 100644 --- a/main/services/workspace-files.ts +++ b/main/services/workspace-files.ts @@ -77,7 +77,53 @@ export interface WorkspaceFileWriteHooks { recoveryUse?: (recoveryPath: string) => Promise<"clear" | "open" | "unknown">; } +export async function linuxRecoveryUse( + recoveryPath: string, + procRoot = "/proc", +): Promise<"clear" | "open" | "unknown"> { + let target: { dev: number; ino: number }; + try { + const stats = await fs.stat(recoveryPath); + target = { dev: stats.dev, ino: stats.ino }; + } catch { + return "unknown"; + } + + let processEntries: string[]; + try { + processEntries = (await fs.readdir(procRoot)).filter((entry) => /^\d+$/u.test(entry)); + } catch { + return "unknown"; + } + + const currentUid = process.getuid?.(); + let complete = true; + for (const pid of processEntries) { + const processPath = path.join(procRoot, pid); + try { + const processStats = await fs.stat(processPath); + if (currentUid !== undefined && processStats.uid !== currentUid) continue; + const descriptors = await fs.readdir(path.join(processPath, "fd")); + for (const descriptor of descriptors) { + try { + const stats = await fs.stat(path.join(processPath, "fd", descriptor)); + if (stats.dev === target.dev && stats.ino === target.ino) return "open"; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") complete = false; + } + } + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + // A same-user /proc entry that cannot be inspected must fail closed so + // recovery data is never removed while a descriptor may still be open. + if (code !== "ENOENT") complete = false; + } + } + return complete ? "clear" : "unknown"; +} + async function recoveryUse(recoveryPath: string): Promise<"clear" | "open" | "unknown"> { + if (process.platform === "linux") return linuxRecoveryUse(recoveryPath); if (process.platform !== "darwin") return "unknown"; return new Promise((resolve) => { execFile( diff --git a/main/services/workspace-worktree-application-service.ts b/main/services/workspace-worktree-application-service.ts index 661f4815..f5305aab 100644 --- a/main/services/workspace-worktree-application-service.ts +++ b/main/services/workspace-worktree-application-service.ts @@ -51,7 +51,7 @@ function displayName(source: Workspace, branch: string, requested?: string): str /** * Shared renderer/remote orchestration for Aiden-owned Git worktrees. All - * filesystem and Git-admin identity is reloaded from persisted Mac state. + * filesystem and Git-admin identity is reloaded from persisted desktop state. */ export function createWorkspaceWorktreeApplicationService( dependencies: WorkspaceWorktreeApplicationDependencies, diff --git a/main/windows/main-window-options.test.ts b/main/windows/main-window-options.test.ts new file mode 100644 index 00000000..bd68bcd3 --- /dev/null +++ b/main/windows/main-window-options.test.ts @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { mainWindowOptions } from "./main-window-options.js"; + +test("macOS keeps Aiden's inset transparent window treatment", () => { + const options = mainWindowOptions("/tmp/preload.cjs", "darwin"); + assert.equal(options.titleBarStyle, "hiddenInset"); + assert.equal(options.transparent, true); + assert.equal(options.vibrancy, "sidebar"); + assert.deepEqual(options.trafficLightPosition, { x: 14, y: 20 }); +}); + +test("Linux uses compositor-owned opaque native window chrome", () => { + const options = mainWindowOptions("/tmp/preload.cjs", "linux"); + assert.equal(options.titleBarStyle, "default"); + assert.equal(options.transparent, false); + assert.equal(options.backgroundColor, "#f6f7f9"); + assert.equal(options.vibrancy, undefined); + assert.equal(options.trafficLightPosition, undefined); + assert.equal(options.webPreferences?.sandbox, true); + assert.equal(mainWindowOptions("/tmp/preload.cjs", "linux", true).backgroundColor, "#181b21"); +}); diff --git a/main/windows/main-window-options.ts b/main/windows/main-window-options.ts new file mode 100644 index 00000000..e6f1541c --- /dev/null +++ b/main/windows/main-window-options.ts @@ -0,0 +1,41 @@ +import type { BrowserWindowConstructorOptions } from "electron"; + +export function mainWindowOptions( + preload: string, + platform: NodeJS.Platform = process.platform, + dark = false, +): BrowserWindowConstructorOptions { + const shared: BrowserWindowConstructorOptions = { + width: 1000, + height: 700, + minWidth: 390, + minHeight: 456, + show: false, + webPreferences: { + preload, + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + }; + if (platform !== "darwin") { + return { + ...shared, + // Linux compositors own the native title bar and window shadow. An + // opaque semantic surface avoids transparency artifacts under Wayland. + backgroundColor: dark ? "#181b21" : "#f6f7f9", + titleBarStyle: "default", + transparent: false, + }; + } + return { + ...shared, + titleBarStyle: "hiddenInset", + // Center the 12px macOS window controls in the renderer's 52px top bar. + trafficLightPosition: { x: 14, y: 20 }, + backgroundColor: "#00000000", + transparent: true, + vibrancy: "sidebar", + visualEffectState: "active", + }; +} diff --git a/main/windows/pill-window-platform.test.ts b/main/windows/pill-window-platform.test.ts new file mode 100644 index 00000000..fda9509d --- /dev/null +++ b/main/windows/pill-window-platform.test.ts @@ -0,0 +1,10 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { shouldPositionDictationPill } from "./pill-window-platform.js"; + +test("dictation pill positioning respects Wayland compositor ownership", () => { + assert.equal(shouldPositionDictationPill("darwin", undefined), true); + assert.equal(shouldPositionDictationPill("linux", "x11"), true); + assert.equal(shouldPositionDictationPill("linux", "wayland"), false); + assert.equal(shouldPositionDictationPill("linux", "WAYLAND"), false); +}); diff --git a/main/windows/pill-window-platform.ts b/main/windows/pill-window-platform.ts new file mode 100644 index 00000000..1a1154ff --- /dev/null +++ b/main/windows/pill-window-platform.ts @@ -0,0 +1,6 @@ +export function shouldPositionDictationPill( + platform: NodeJS.Platform = process.platform, + sessionType: string | undefined = process.env.XDG_SESSION_TYPE, +): boolean { + return !(platform === "linux" && sessionType?.toLocaleLowerCase("en-US") === "wayland"); +} diff --git a/main/windows/pill-window.ts b/main/windows/pill-window.ts index 5c8111aa..6f7c51cc 100644 --- a/main/windows/pill-window.ts +++ b/main/windows/pill-window.ts @@ -7,6 +7,7 @@ import { BrowserWindow, logger, screen } from "../platform.js"; import type { IpcMainInvokeEvent } from "electron"; import { getPillPreloadPath, getWindowUrl } from "./window-paths.js"; import { isTrustedPillSender } from "./pill-window-security.js"; +import { shouldPositionDictationPill } from "./pill-window-platform.js"; const PILL_WIDTH = 280; const PILL_HEIGHT = 56; @@ -18,6 +19,7 @@ let loading: Promise | null = null; let pillUrl = ""; function positionPill(window: BrowserWindow): void { + if (!shouldPositionDictationPill()) return; const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()); const { workArea } = display; window.setBounds({ @@ -55,7 +57,7 @@ async function createPillWindow(): Promise { // Float above other apps (including fullscreen spaces) without stealing focus. window.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); - window.setAlwaysOnTop(true, "status"); + window.setAlwaysOnTop(true, process.platform === "darwin" ? "status" : "normal"); window.on("closed", () => { pillWindow = null; diff --git a/native/bot-inbox-writer/main.c b/native/bot-inbox-writer/main.c index 0af8d271..e4ac9c8f 100644 --- a/native/bot-inbox-writer/main.c +++ b/native/bot-inbox-writer/main.c @@ -1,4 +1,8 @@ +#ifdef __APPLE__ #define _DARWIN_C_SOURCE 1 +#else +#define _GNU_SOURCE +#endif #include #include diff --git a/native/shared/aiden-platform.h b/native/shared/aiden-platform.h new file mode 100644 index 00000000..1b720ca2 --- /dev/null +++ b/native/shared/aiden-platform.h @@ -0,0 +1,227 @@ +#ifndef AIDEN_PLATFORM_H +#define AIDEN_PLATFORM_H + +#ifdef __APPLE__ + +#include + +#else + +#include +#include +#include +#include +#include +#include +#include +#include + +/* + * Keep the native safety helpers self-contained. Depending on a particular + * OpenSSL ABI would make the AppImage fail on otherwise supported distros. + * This small SHA-256 implementation exposes the CommonCrypto subset used by + * the existing audited helper code. + */ +#define CC_SHA256_DIGEST_LENGTH 32 +typedef uint32_t CC_LONG; +typedef struct { + uint8_t data[64]; + uint32_t data_length; + uint64_t bit_length; + uint32_t state[8]; +} CC_SHA256_CTX; + +static inline uint32_t aiden_sha256_rotr(uint32_t value, uint32_t amount) { + return (value >> amount) | (value << (32U - amount)); +} + +static inline void aiden_sha256_transform(CC_SHA256_CTX *context, + const uint8_t data[64]) { + static const uint32_t constants[64] = { + 0x428a2f98U, 0x71374491U, 0xb5c0fbcfU, 0xe9b5dba5U, 0x3956c25bU, + 0x59f111f1U, 0x923f82a4U, 0xab1c5ed5U, 0xd807aa98U, 0x12835b01U, + 0x243185beU, 0x550c7dc3U, 0x72be5d74U, 0x80deb1feU, 0x9bdc06a7U, + 0xc19bf174U, 0xe49b69c1U, 0xefbe4786U, 0x0fc19dc6U, 0x240ca1ccU, + 0x2de92c6fU, 0x4a7484aaU, 0x5cb0a9dcU, 0x76f988daU, 0x983e5152U, + 0xa831c66dU, 0xb00327c8U, 0xbf597fc7U, 0xc6e00bf3U, 0xd5a79147U, + 0x06ca6351U, 0x14292967U, 0x27b70a85U, 0x2e1b2138U, 0x4d2c6dfcU, + 0x53380d13U, 0x650a7354U, 0x766a0abbU, 0x81c2c92eU, 0x92722c85U, + 0xa2bfe8a1U, 0xa81a664bU, 0xc24b8b70U, 0xc76c51a3U, 0xd192e819U, + 0xd6990624U, 0xf40e3585U, 0x106aa070U, 0x19a4c116U, 0x1e376c08U, + 0x2748774cU, 0x34b0bcb5U, 0x391c0cb3U, 0x4ed8aa4aU, 0x5b9cca4fU, + 0x682e6ff3U, 0x748f82eeU, 0x78a5636fU, 0x84c87814U, 0x8cc70208U, + 0x90befffaU, 0xa4506cebU, 0xbef9a3f7U, 0xc67178f2U, + }; + uint32_t words[64]; + for (uint32_t index = 0; index < 16; index += 1) { + words[index] = ((uint32_t)data[index * 4] << 24U) | + ((uint32_t)data[index * 4 + 1] << 16U) | + ((uint32_t)data[index * 4 + 2] << 8U) | + (uint32_t)data[index * 4 + 3]; + } + for (uint32_t index = 16; index < 64; index += 1) { + uint32_t s0 = aiden_sha256_rotr(words[index - 15], 7U) ^ + aiden_sha256_rotr(words[index - 15], 18U) ^ + (words[index - 15] >> 3U); + uint32_t s1 = aiden_sha256_rotr(words[index - 2], 17U) ^ + aiden_sha256_rotr(words[index - 2], 19U) ^ + (words[index - 2] >> 10U); + words[index] = words[index - 16] + s0 + words[index - 7] + s1; + } + uint32_t a = context->state[0]; + uint32_t b = context->state[1]; + uint32_t c = context->state[2]; + uint32_t d = context->state[3]; + uint32_t e = context->state[4]; + uint32_t f = context->state[5]; + uint32_t g = context->state[6]; + uint32_t h = context->state[7]; + for (uint32_t index = 0; index < 64; index += 1) { + uint32_t s1 = aiden_sha256_rotr(e, 6U) ^ aiden_sha256_rotr(e, 11U) ^ + aiden_sha256_rotr(e, 25U); + uint32_t choice = (e & f) ^ ((~e) & g); + uint32_t temporary1 = h + s1 + choice + constants[index] + words[index]; + uint32_t s0 = aiden_sha256_rotr(a, 2U) ^ aiden_sha256_rotr(a, 13U) ^ + aiden_sha256_rotr(a, 22U); + uint32_t majority = (a & b) ^ (a & c) ^ (b & c); + uint32_t temporary2 = s0 + majority; + h = g; + g = f; + f = e; + e = d + temporary1; + d = c; + c = b; + b = a; + a = temporary1 + temporary2; + } + context->state[0] += a; + context->state[1] += b; + context->state[2] += c; + context->state[3] += d; + context->state[4] += e; + context->state[5] += f; + context->state[6] += g; + context->state[7] += h; +} + +static inline int CC_SHA256_Init(CC_SHA256_CTX *context) { + context->data_length = 0; + context->bit_length = 0; + context->state[0] = 0x6a09e667U; + context->state[1] = 0xbb67ae85U; + context->state[2] = 0x3c6ef372U; + context->state[3] = 0xa54ff53aU; + context->state[4] = 0x510e527fU; + context->state[5] = 0x9b05688cU; + context->state[6] = 0x1f83d9abU; + context->state[7] = 0x5be0cd19U; + return 1; +} + +static inline int CC_SHA256_Update(CC_SHA256_CTX *context, const void *input, + CC_LONG length) { + const uint8_t *bytes = input; + for (CC_LONG index = 0; index < length; index += 1) { + context->data[context->data_length++] = bytes[index]; + if (context->data_length == 64U) { + aiden_sha256_transform(context, context->data); + context->bit_length += 512U; + context->data_length = 0; + } + } + return 1; +} + +static inline int CC_SHA256_Final( + unsigned char digest[CC_SHA256_DIGEST_LENGTH], CC_SHA256_CTX *context) { + uint32_t index = context->data_length; + context->data[index++] = 0x80U; + if (index > 56U) { + while (index < 64U) context->data[index++] = 0; + aiden_sha256_transform(context, context->data); + index = 0; + } + while (index < 56U) context->data[index++] = 0; + context->bit_length += (uint64_t)context->data_length * 8U; + for (uint32_t byte = 0; byte < 8U; byte += 1) { + context->data[63U - byte] = + (uint8_t)(context->bit_length >> (byte * 8U)); + } + aiden_sha256_transform(context, context->data); + for (index = 0; index < 4U; index += 1) { + for (uint32_t word = 0; word < 8U; word += 1) { + digest[word * 4U + index] = + (uint8_t)(context->state[word] >> (24U - index * 8U)); + } + } + return 1; +} + +static inline unsigned char *CC_SHA256( + const void *input, CC_LONG length, + unsigned char digest[CC_SHA256_DIGEST_LENGTH]) { + CC_SHA256_CTX context; + return CC_SHA256_Init(&context) == 1 && + CC_SHA256_Update(&context, input, length) == 1 && + CC_SHA256_Final(digest, &context) == 1 + ? digest + : NULL; +} + +static inline void aiden_arc4random_buf(void *output, size_t length) { + unsigned char *cursor = output; + while (length > 0) { + ssize_t count = getrandom(cursor, length, 0); + if (count > 0) { + cursor += count; + length -= (size_t)count; + continue; + } + if (count < 0 && errno == EINTR) continue; + int descriptor = open("/dev/urandom", O_RDONLY | O_CLOEXEC); + if (descriptor < 0) abort(); + while (length > 0) { + count = read(descriptor, cursor, length); + if (count > 0) { + cursor += count; + length -= (size_t)count; + } else if (count < 0 && errno == EINTR) { + continue; + } else { + close(descriptor); + abort(); + } + } + close(descriptor); + } +} + +#define arc4random_buf aiden_arc4random_buf + +static inline int aiden_renameatx_np(int old_directory, const char *old_name, + int new_directory, const char *new_name, + unsigned int flags) { + unsigned int linux_flags; + if (flags == 0x00000004U) { + linux_flags = RENAME_NOREPLACE; + } else if (flags == 0x00000002U) { + linux_flags = RENAME_EXCHANGE; + } else { + errno = EINVAL; + return -1; + } + return (int)syscall(SYS_renameat2, old_directory, old_name, new_directory, + new_name, linux_flags); +} + +#ifndef RENAME_SWAP +#define RENAME_SWAP 0x00000002U +#endif +#ifndef RENAME_EXCL +#define RENAME_EXCL 0x00000004U +#endif +#define renameatx_np aiden_renameatx_np + +#endif + +#endif diff --git a/native/subagent-file-mutator/main.c b/native/subagent-file-mutator/main.c index cb2366b0..00ad7702 100644 --- a/native/subagent-file-mutator/main.c +++ b/native/subagent-file-mutator/main.c @@ -1,4 +1,8 @@ -#include +#ifndef __APPLE__ +#define _GNU_SOURCE +#endif + +#include "../shared/aiden-platform.h" #include #include #include @@ -6,7 +10,9 @@ #include #include #include +#ifdef __APPLE__ #include +#endif #include #include #include @@ -21,6 +27,8 @@ #define MAX_NAME_ATTEMPTS 32 #define SHA256_HEX_BYTES 64 #define MAX_PROVENANCE_BYTES 256 +#define MAX_LINUX_XATTR_NAMES_BYTES 4096 +#define MAX_LINUX_XATTR_VALUE_BYTES 65536 #define STAGING_PREFIX ".aiden-subagent-file-" #define PROVENANCE_XATTR "com.apple.provenance" #define UNTRUSTED_READ_FLAGS \ @@ -71,7 +79,8 @@ static int exclusive_regular(const struct stat *identity) { static int same_read_identity(const struct stat *left, const struct stat *right) { - return S_ISREG(left->st_mode) && S_ISREG(right->st_mode) && +#ifdef __APPLE__ + return exclusive_regular(left) && exclusive_regular(right) && left->st_dev == right->st_dev && left->st_ino == right->st_ino && left->st_mode == right->st_mode && left->st_uid == right->st_uid && left->st_gid == right->st_gid && left->st_flags == right->st_flags && @@ -79,9 +88,18 @@ static int same_read_identity(const struct stat *left, same_timestamp(left->st_mtimespec, right->st_mtimespec) && same_timestamp(left->st_ctimespec, right->st_ctimespec) && same_timestamp(left->st_birthtimespec, right->st_birthtimespec); +#else + return exclusive_regular(left) && exclusive_regular(right) && + left->st_dev == right->st_dev && left->st_ino == right->st_ino && + left->st_mode == right->st_mode && left->st_uid == right->st_uid && + left->st_gid == right->st_gid && left->st_size == right->st_size && + same_timestamp(left->st_mtim, right->st_mtim) && + same_timestamp(left->st_ctim, right->st_ctim); +#endif } static int same_identity(const struct stat *left, const struct stat *right) { +#ifdef __APPLE__ return exclusive_regular(left) && exclusive_regular(right) && left->st_dev == right->st_dev && left->st_ino == right->st_ino && left->st_mode == right->st_mode && left->st_uid == right->st_uid && @@ -90,11 +108,20 @@ static int same_identity(const struct stat *left, const struct stat *right) { same_timestamp(left->st_mtimespec, right->st_mtimespec) && same_timestamp(left->st_ctimespec, right->st_ctimespec) && same_timestamp(left->st_birthtimespec, right->st_birthtimespec); +#else + return exclusive_regular(left) && exclusive_regular(right) && + left->st_dev == right->st_dev && left->st_ino == right->st_ino && + left->st_mode == right->st_mode && left->st_uid == right->st_uid && + left->st_gid == right->st_gid && left->st_size == right->st_size && + same_timestamp(left->st_mtim, right->st_mtim) && + same_timestamp(left->st_ctim, right->st_ctim); +#endif } /* renameatx_np may update ctime while preserving the underlying inode. */ static int same_renamed_identity(const struct stat *left, const struct stat *right) { +#ifdef __APPLE__ return exclusive_regular(left) && exclusive_regular(right) && left->st_dev == right->st_dev && left->st_ino == right->st_ino && left->st_mode == right->st_mode && left->st_uid == right->st_uid && @@ -102,6 +129,13 @@ static int same_renamed_identity(const struct stat *left, left->st_size == right->st_size && same_timestamp(left->st_mtimespec, right->st_mtimespec) && same_timestamp(left->st_birthtimespec, right->st_birthtimespec); +#else + return exclusive_regular(left) && exclusive_regular(right) && + left->st_dev == right->st_dev && left->st_ino == right->st_ino && + left->st_mode == right->st_mode && left->st_uid == right->st_uid && + left->st_gid == right->st_gid && left->st_size == right->st_size && + same_timestamp(left->st_mtim, right->st_mtim); +#endif } static int same_file_object(const struct stat *left, @@ -112,14 +146,21 @@ static int same_file_object(const struct stat *left, static int same_preserved_metadata(const struct stat *left, const struct stat *right) { +#ifdef __APPLE__ return (left->st_mode & (S_IFMT | 07777)) == (right->st_mode & (S_IFMT | 07777)) && left->st_uid == right->st_uid && left->st_gid == right->st_gid && left->st_flags == right->st_flags; +#else + return (left->st_mode & (S_IFMT | 07777)) == + (right->st_mode & (S_IFMT | 07777)) && + left->st_uid == right->st_uid && left->st_gid == right->st_gid; +#endif } static int same_renamed_entry(const struct stat *left, const struct stat *right) { +#ifdef __APPLE__ return left->st_dev == right->st_dev && left->st_ino == right->st_ino && left->st_mode == right->st_mode && left->st_nlink == right->st_nlink && left->st_uid == right->st_uid && left->st_gid == right->st_gid && @@ -127,6 +168,13 @@ static int same_renamed_entry(const struct stat *left, left->st_size == right->st_size && same_timestamp(left->st_mtimespec, right->st_mtimespec) && same_timestamp(left->st_birthtimespec, right->st_birthtimespec); +#else + return left->st_dev == right->st_dev && left->st_ino == right->st_ino && + left->st_mode == right->st_mode && left->st_nlink == right->st_nlink && + left->st_uid == right->st_uid && left->st_gid == right->st_gid && + left->st_size == right->st_size && + same_timestamp(left->st_mtim, right->st_mtim); +#endif } /* @@ -139,7 +187,11 @@ static int read_supported_xattrs(int descriptor, size_t *value_length, int *present) { *value_length = 0; *present = 0; +#ifdef __APPLE__ ssize_t names_length = flistxattr(descriptor, NULL, 0, 0); +#else + ssize_t names_length = flistxattr(descriptor, NULL, 0); +#endif if (names_length < 0) return -1; if (names_length == 0) @@ -149,16 +201,22 @@ static int read_supported_xattrs(int descriptor, char *names = malloc((size_t)names_length); if (names == NULL) return -1; - ssize_t read_names = - flistxattr(descriptor, names, (size_t)names_length, 0); +#ifdef __APPLE__ + ssize_t read_names = flistxattr(descriptor, names, (size_t)names_length, 0); +#else + ssize_t read_names = flistxattr(descriptor, names, (size_t)names_length); +#endif int valid = read_names == names_length; size_t offset = 0; int count = 0; while (valid && offset < (size_t)names_length) { size_t remaining = (size_t)names_length - offset; size_t name_length = strnlen(names + offset, remaining); - if (name_length == remaining || - strcmp(names + offset, PROVENANCE_XATTR) != 0) { + if (name_length == remaining +#ifdef __APPLE__ + || strcmp(names + offset, PROVENANCE_XATTR) != 0 +#endif + ) { valid = 0; break; } @@ -166,8 +224,19 @@ static int read_supported_xattrs(int descriptor, offset += name_length + 1; } free(names); - if (!valid || count != 1) + if (!valid +#ifdef __APPLE__ + || count != 1 +#endif + ) return 0; +#ifndef __APPLE__ + /* Linux xattrs are copied and compared in full during staging. The + * provenance fields are a macOS wire detail and remain absent here. */ + (void)value; + (void)count; + return 1; +#else ssize_t length = fgetxattr(descriptor, PROVENANCE_XATTR, NULL, 0, 0, 0); if (length < 0) @@ -181,9 +250,11 @@ static int read_supported_xattrs(int descriptor, *value_length = (size_t)length; *present = 1; return 1; +#endif } static int supported_metadata(int descriptor, const struct stat *identity) { +#ifdef __APPLE__ if (identity->st_flags != 0) return 0; unsigned char provenance[MAX_PROVENANCE_BYTES]; @@ -204,9 +275,198 @@ static int supported_metadata(int descriptor, const struct stat *identity) { if (entry_result == 0) return 0; return entry_error == EINVAL ? 1 : -1; +#else + (void)identity; + unsigned char provenance[MAX_PROVENANCE_BYTES]; + size_t provenance_length; + int provenance_present; + return read_supported_xattrs(descriptor, provenance, &provenance_length, + &provenance_present); +#endif +} + +#ifndef __APPLE__ +static int linux_xattr_names(int descriptor, char **names, + size_t *names_length) { + ssize_t length = flistxattr(descriptor, NULL, 0); + if (length < 0) + return -1; + if (length > MAX_LINUX_XATTR_NAMES_BYTES) + return 0; + char *result = malloc(length == 0 ? 1 : (size_t)length); + if (result == NULL) + return -1; + if (length > 0 && flistxattr(descriptor, result, (size_t)length) != length) { + free(result); + return -1; + } + *names = result; + *names_length = (size_t)length; + return 1; +} + +static int linux_xattr_value(int descriptor, const char *name, + unsigned char **value, size_t *value_length) { + ssize_t length = fgetxattr(descriptor, name, NULL, 0); + if (length < 0) + return errno == ENODATA ? 0 : -1; + if (length > MAX_LINUX_XATTR_VALUE_BYTES) + return -2; + unsigned char *result = malloc(length == 0 ? 1 : (size_t)length); + if (result == NULL) + return -1; + if (length > 0 && + fgetxattr(descriptor, name, result, (size_t)length) != length) { + free(result); + return -1; + } + *value = result; + *value_length = (size_t)length; + return 1; +} + +static int linux_named_xattr_matches(int left, int right, const char *name) { + unsigned char *left_value = NULL; + unsigned char *right_value = NULL; + size_t left_length = 0; + size_t right_length = 0; + int left_result = linux_xattr_value(left, name, &left_value, &left_length); + int right_result = + linux_xattr_value(right, name, &right_value, &right_length); + int matches = left_result == 1 && right_result == 1 && + left_length == right_length && + memcmp(left_value, right_value, left_length) == 0; + free(left_value); + free(right_value); + if (left_result < 0 || right_result < 0) + return left_result == -2 || right_result == -2 ? 0 : -1; + return matches ? 1 : 0; +} + +static int linux_xattrs_match(int left, int right) { + char *left_names = NULL; + char *right_names = NULL; + size_t left_length = 0; + size_t right_length = 0; + int left_result = linux_xattr_names(left, &left_names, &left_length); + int right_result = linux_xattr_names(right, &right_names, &right_length); + if (left_result != 1 || right_result != 1) { + free(left_names); + free(right_names); + return left_result == 0 || right_result == 0 ? 0 : -1; + } + size_t offset = 0; + int matches = 1; + size_t left_count = 0; + while (matches && offset < left_length) { + size_t name_length = strnlen(left_names + offset, left_length - offset); + if (name_length == left_length - offset || + linux_named_xattr_matches(left, right, left_names + offset) != 1) { + matches = 0; + break; + } + left_count += 1; + offset += name_length + 1; + } + offset = 0; + size_t right_count = 0; + while (matches && offset < right_length) { + size_t name_length = strnlen(right_names + offset, right_length - offset); + if (name_length == right_length - offset) { + matches = 0; + break; + } + right_count += 1; + offset += name_length + 1; + } + free(left_names); + free(right_names); + return matches && left_count == right_count ? 1 : 0; +} + +static int linux_copy_xattrs(int source, int destination) { + char *source_names = NULL; + size_t source_names_length = 0; + int source_result = + linux_xattr_names(source, &source_names, &source_names_length); + if (source_result != 1) { + free(source_names); + return source_result; + } + size_t offset = 0; + while (offset < source_names_length) { + const char *name = source_names + offset; + size_t name_length = strnlen(name, source_names_length - offset); + if (name_length == source_names_length - offset) { + free(source_names); + return 0; + } + int matches = linux_named_xattr_matches(source, destination, name); + if (matches < 0) { + free(source_names); + return -1; + } + if (matches == 0) { + unsigned char *value = NULL; + size_t value_length = 0; + int value_result = + linux_xattr_value(source, name, &value, &value_length); + if (value_result != 1 || + fsetxattr(destination, name, value, value_length, 0) != 0) { + free(value); + free(source_names); + return value_result == -2 ? 0 : -1; + } + free(value); + } + offset += name_length + 1; + } + + char *destination_names = NULL; + size_t destination_names_length = 0; + int destination_result = linux_xattr_names( + destination, &destination_names, &destination_names_length); + if (destination_result != 1) { + free(source_names); + free(destination_names); + return destination_result; + } + offset = 0; + while (offset < destination_names_length) { + const char *name = destination_names + offset; + size_t name_length = strnlen(name, destination_names_length - offset); + if (name_length == destination_names_length - offset) { + free(source_names); + free(destination_names); + return 0; + } + unsigned char *ignored = NULL; + size_t ignored_length = 0; + int exists = linux_xattr_value(source, name, &ignored, &ignored_length); + free(ignored); + if (exists == 0 && fremovexattr(destination, name) != 0 && + errno != ENODATA) { + free(source_names); + free(destination_names); + return -1; + } + if (exists < 0) { + free(source_names); + free(destination_names); + return exists == -2 ? 0 : -1; + } + offset += name_length + 1; + } + free(source_names); + free(destination_names); + return linux_xattrs_match(source, destination); } +#endif static int copy_supported_xattrs(int source, int destination) { +#ifndef __APPLE__ + return linux_copy_xattrs(source, destination); +#else unsigned char source_value[MAX_PROVENANCE_BYTES]; unsigned char destination_value[MAX_PROVENANCE_BYTES]; size_t source_length; @@ -229,9 +489,13 @@ static int copy_supported_xattrs(int source, int destination) { return -1; } return 1; +#endif } static int matching_supported_xattrs(int left, int right) { +#ifndef __APPLE__ + return linux_xattrs_match(left, right); +#else unsigned char left_value[MAX_PROVENANCE_BYTES]; unsigned char right_value[MAX_PROVENANCE_BYTES]; size_t left_length; @@ -249,11 +513,15 @@ static int matching_supported_xattrs(int left, int right) { memcmp(left_value, right_value, left_length) == 0) ? 1 : 0; +#endif } static int matches_expected_provenance(const struct Transaction *transaction, int descriptor) { - unsigned char value[MAX_PROVENANCE_BYTES]; + // Linux intentionally reports the macOS provenance wire field as absent. + // Initialize the buffer so GCC can prove the short-circuited memcmp is safe + // under -O2 -Wmaybe-uninitialized as well as Clang. + unsigned char value[MAX_PROVENANCE_BYTES] = {0}; size_t length; int present; if (read_supported_xattrs(descriptor, value, &length, &present) != 1) @@ -1504,14 +1772,19 @@ static int serve(int root_fd, const char *root_path) { } static int parse_identity(const char *value, uint64_t *result) { - if (value == NULL || value[0] == '\0' || value[0] == '-') - return -1; - errno = 0; - char *end = NULL; - unsigned long long parsed = strtoull(value, &end, 10); - if (errno != 0 || end == value || *end != '\0') + if (value == NULL || value[0] == '\0') return -1; - *result = (uint64_t)parsed; + uint64_t parsed = 0; + for (const unsigned char *cursor = (const unsigned char *)value; + *cursor != '\0'; cursor += 1) { + if (*cursor < '0' || *cursor > '9') + return -1; + uint64_t digit = (uint64_t)(*cursor - '0'); + if (parsed > (UINT64_MAX - digit) / 10U) + return -1; + parsed = parsed * 10U + digit; + } + *result = parsed; return 0; } diff --git a/native/subagent-run-store/main.c b/native/subagent-run-store/main.c index 47c3bf31..15132110 100644 --- a/native/subagent-run-store/main.c +++ b/native/subagent-run-store/main.c @@ -1,3 +1,8 @@ +#ifndef __APPLE__ +#define _GNU_SOURCE +#endif + +#include "../shared/aiden-platform.h" #include #include #include @@ -41,21 +46,36 @@ static int is_exclusive_regular_file(const struct stat *identity) { static int same_file_identity(const struct stat *left, const struct stat *right) { +#ifdef __APPLE__ return is_exclusive_regular_file(left) && is_exclusive_regular_file(right) && left->st_dev == right->st_dev && left->st_ino == right->st_ino && left->st_size == right->st_size && same_timestamp(left->st_mtimespec, right->st_mtimespec) && same_timestamp(left->st_ctimespec, right->st_ctimespec) && same_timestamp(left->st_birthtimespec, right->st_birthtimespec); +#else + return is_exclusive_regular_file(left) && is_exclusive_regular_file(right) && + left->st_dev == right->st_dev && left->st_ino == right->st_ino && + left->st_size == right->st_size && + same_timestamp(left->st_mtim, right->st_mtim) && + same_timestamp(left->st_ctim, right->st_ctim); +#endif } static int same_renamed_file_identity(const struct stat *left, const struct stat *right) { +#ifdef __APPLE__ return is_exclusive_regular_file(left) && is_exclusive_regular_file(right) && left->st_dev == right->st_dev && left->st_ino == right->st_ino && left->st_size == right->st_size && same_timestamp(left->st_mtimespec, right->st_mtimespec) && same_timestamp(left->st_birthtimespec, right->st_birthtimespec); +#else + return is_exclusive_regular_file(left) && is_exclusive_regular_file(right) && + left->st_dev == right->st_dev && left->st_ino == right->st_ino && + left->st_size == right->st_size && + same_timestamp(left->st_mtim, right->st_mtim); +#endif } static int requested_contents_match(int descriptor, @@ -115,6 +135,7 @@ static int capture_installed_identity(int staged_fd, static int make_token(const struct stat *identity, char *token, size_t capacity) { +#ifdef __APPLE__ int length = snprintf(token, capacity, "%llx-%llx-%llx-%llx-%llx-%llx-%llx-%llx-%llx", (unsigned long long)identity->st_dev, @@ -126,6 +147,17 @@ static int make_token(const struct stat *identity, char *token, (unsigned long long)identity->st_ctimespec.tv_nsec, (unsigned long long)identity->st_birthtimespec.tv_sec, (unsigned long long)identity->st_birthtimespec.tv_nsec); +#else + int length = + snprintf(token, capacity, "%llx-%llx-%llx-%llx-%llx-%llx-%llx", + (unsigned long long)identity->st_dev, + (unsigned long long)identity->st_ino, + (unsigned long long)identity->st_size, + (unsigned long long)identity->st_mtim.tv_sec, + (unsigned long long)identity->st_mtim.tv_nsec, + (unsigned long long)identity->st_ctim.tv_sec, + (unsigned long long)identity->st_ctim.tv_nsec); +#endif return length > 0 && (size_t)length < capacity ? 0 : -1; } diff --git a/native/subagent-shell-runner/main.c b/native/subagent-shell-runner/main.c index 14fc50e9..13900fbe 100644 --- a/native/subagent-shell-runner/main.c +++ b/native/subagent-shell-runner/main.c @@ -1,4 +1,8 @@ +#ifdef __APPLE__ #define _DARWIN_C_SOURCE +#else +#define _GNU_SOURCE +#endif #include #include @@ -14,6 +18,9 @@ #include #include #include +#ifndef __APPLE__ +#include +#endif #include #include @@ -281,6 +288,17 @@ static bool cleanup_group(pid_t group, pid_t original_group, pid_t direct_child, if (setpgid(0, original_group) != 0) return false; (void)kill(-group, SIGKILL); int ignored_status; +#ifndef __APPLE__ + /* As a Linux child subreaper, the helper adopts ordinary orphaned shell + * descendants. Reap every child that stayed in the occupied process group + * so a zombie cannot make kill(-group, 0) report a false cleanup failure. */ + for (;;) { + pid_t reaped = waitpid(-group, &ignored_status, 0); + if (reaped > 0) continue; + if (reaped < 0 && errno == EINTR) continue; + break; + } +#endif while (waitpid(direct_child, &ignored_status, 0) < 0 && errno == EINTR) {} deadline = monotonic_ms() + 1000U; while (monotonic_ms() < deadline && process_group_exists(group)) usleep(10000); @@ -288,6 +306,12 @@ static bool cleanup_group(pid_t group, pid_t original_group, pid_t direct_child, } static int run_shell(const char *root_path, int root_fd, const struct request *request) { +#ifndef __APPLE__ + /* Electron is not guaranteed to be a child subreaper, and minimal desktop + * or container sessions may not reap an orphan before the cleanup deadline. + * Adopt ordinary descendants here so cleanup confirmation is deterministic. */ + if (prctl(PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0) != 0) return 70; +#endif int stdout_pipe[2]; int stderr_pipe[2]; if (pipe(stdout_pipe) != 0 || pipe(stderr_pipe) != 0) return 70; @@ -314,18 +338,27 @@ static int run_shell(const char *root_path, int root_fd, const struct request *r snprintf(config, sizeof(config), "XDG_CONFIG_HOME=%s/config", private_root); snprintf(cache, sizeof(cache), "XDG_CACHE_HOME=%s/cache", private_root); snprintf(data, sizeof(data), "XDG_DATA_HOME=%s/data", private_root); +#ifdef __APPLE__ + const char *shell_path = "/bin/zsh"; + char *shell_environment = "SHELL=/bin/zsh"; + char *arguments[] = {"/bin/zsh", "-f", "-c", (char *)request->command, + "aiden-subagent", NULL}; +#else + const char *shell_path = "/bin/sh"; + char *shell_environment = "SHELL=/bin/sh"; + char *arguments[] = {"/bin/sh", "-c", (char *)request->command, + "aiden-subagent", NULL}; +#endif char *environment[] = { "PATH=/usr/bin:/bin:/usr/sbin:/sbin", home, temporary, config, cache, data, - "LANG=C", "LC_ALL=C", "SHELL=/bin/zsh", "TERM=dumb", "NO_COLOR=1", "CI=1", + "LANG=C", "LC_ALL=C", shell_environment, "TERM=dumb", "NO_COLOR=1", "CI=1", "PAGER=cat", "GIT_PAGER=cat", "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=/usr/bin/false", "SSH_ASKPASS=/usr/bin/false", "SSH_ASKPASS_REQUIRE=force", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "NPM_CONFIG_USERCONFIG=/dev/null", "NPM_CONFIG_UPDATE_NOTIFIER=false", "NPM_CONFIG_FUND=false", "NPM_CONFIG_AUDIT=false", "ZDOTDIR=/dev/null", NULL, }; - char *arguments[] = {"/bin/zsh", "-f", "-c", (char *)request->command, - "aiden-subagent", NULL}; - execve("/bin/zsh", arguments, environment); + execve(shell_path, arguments, environment); _exit(126); } (void)setpgid(child, child); diff --git a/native/worktree-remover/main.c b/native/worktree-remover/main.c index 0537c433..a6152910 100644 --- a/native/worktree-remover/main.c +++ b/native/worktree-remover/main.c @@ -1,4 +1,8 @@ -#include +#ifndef __APPLE__ +#define _GNU_SOURCE +#endif + +#include "../shared/aiden-platform.h" #include #include #include @@ -1306,14 +1310,19 @@ remove_contents(int directory_fd, dev_t root_device, int depth, } static int parse_uint64(const char *value, uint64_t *result) { - if (value == NULL || value[0] == '\0' || value[0] == '-') - return 0; - char *end = NULL; - errno = 0; - unsigned long long parsed = strtoull(value, &end, 10); - if (errno != 0 || end == NULL || *end != '\0') + if (value == NULL || value[0] == '\0') return 0; - *result = (uint64_t)parsed; + uint64_t parsed = 0; + for (const unsigned char *cursor = (const unsigned char *)value; + *cursor != '\0'; cursor += 1) { + if (*cursor < '0' || *cursor > '9') + return 0; + uint64_t digit = (uint64_t)(*cursor - '0'); + if (parsed > (UINT64_MAX - digit) / 10U) + return 0; + parsed = parsed * 10U + digit; + } + *result = parsed; return 1; } diff --git a/package-lock.json b/package-lock.json index a19d05f3..15e3c7c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "aiden-agent", "version": "0.36.1", "hasInstallScript": true, + "license": "MIT", "dependencies": { "@earendil-works/pi-agent-core": "0.80.10", "@earendil-works/pi-ai": "0.80.10", @@ -18,6 +19,7 @@ "@tanstack/react-router": "^1.131.36", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", + "bonjour-service": "1.4.4", "chart.js": "^4.5.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -2089,6 +2091,12 @@ "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", "license": "MIT" }, + "node_modules/@leichtgewicht/ip-codec": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", + "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", + "license": "MIT" + }, "node_modules/@malept/cross-spawn-promise": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", @@ -6179,6 +6187,16 @@ "url": "https://opencollective.com/express" } }, + "node_modules/bonjour-service": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.4.4.tgz", + "integrity": "sha512-jCZcVv7eoc4QesRscwEZtSROBen+6LpKAmBIsQYQrsAeVHLyMXWX/t6eIV5KiRZYNUBl8eVqImEEMQ8L5+c/Kw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "multicast-dns": "^7.2.5" + } + }, "node_modules/boolean": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", @@ -7162,6 +7180,18 @@ "js-yaml": "^4.1.0" } }, + "node_modules/dns-packet": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", + "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", + "license": "MIT", + "dependencies": { + "@leichtgewicht/ip-codec": "^2.0.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -11726,6 +11756,19 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/multicast-dns": { + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", + "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", + "license": "MIT", + "dependencies": { + "dns-packet": "^5.2.2", + "thunky": "^1.0.2" + }, + "bin": { + "multicast-dns": "cli.js" + } + }, "node_modules/nanoid": { "version": "3.3.18", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", @@ -14497,6 +14540,12 @@ "react-dom": ">=18.0.0" } }, + "node_modules/thunky": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", + "license": "MIT" + }, "node_modules/tiny-async-pool": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", diff --git a/package.json b/package.json index 14b4b864..f79353f8 100644 --- a/package.json +++ b/package.json @@ -2,13 +2,14 @@ "name": "aiden-agent", "version": "0.36.1", "private": true, - "description": "A macOS AI workspace agent for local and hosted models", + "description": "A desktop AI workspace agent for local and hosted models", "keywords": [ "ai-agent", "developer-tools", "electron", "lm-studio", "local-ai", + "linux", "macos", "mcp", "ollama", @@ -20,6 +21,8 @@ "url": "https://github.com/sambitcreate/aiden-agent/issues" }, "author": "Sambit Biswas", + "license": "MIT", + "desktopName": "com.sambitcreate.aiden-agent.desktop", "repository": { "type": "git", "url": "https://github.com/sambitcreate/aiden-agent.git" @@ -39,14 +42,14 @@ "build:electron": "node scripts/patch-pi-oauth-branding.mjs && node scripts/build-electron.mjs", "dev": "npm run build:native:optional && npm run build:electron && concurrently -k -s first \"npm:dev:renderer\" \"npm:dev:electron\"", "dev:renderer": "vite --host 127.0.0.1 --port 4143 --strictPort", - "dev:electron": "wait-on http-get://127.0.0.1:4143/main-window.html && node scripts/prepare-macos-dev-runtime.mjs --run", + "dev:electron": "wait-on http-get://127.0.0.1:4143/main-window.html && node scripts/run-development-runtime.mjs", "dev:brand": "node scripts/prepare-macos-dev-runtime.mjs", "lint": "eslint .", "postinstall": "node scripts/patch-pi-oauth-branding.mjs && node scripts/vendor-generative-ui-libs.mjs", "generative-ui:vendor": "node scripts/vendor-generative-ui-libs.mjs", "pretest:generative-ui": "npm run build:subagent-file-mutator", "test:aiden-remote-speech": "tsx --test main/services/aiden-remote-speech.test.ts", - "pretest": "npm run build:worktree-remover && npm run test:aiden-remote-speech && npm run test:aiden-remote && npm run test:aiden-service-boundary && npm run test:ios-release && npm run test:terminal:coverage && npm run test:onboarding && npm run test:assistant-automations && npm run test:slash-commands && npm run test:display-image && npm run test:ask-user-question && npm run test:todo && npm run test:btw && npm run test:advisor && npm run test:generative-ui && npm run test:provider-failure && npm run test:web-search && npm run test:compaction && npm run test:subagents && tsx --test main/services/pi-remote-catalog.test.ts main/services/provider-model-info-core.test.ts main/services/aiden-remote-models.test.ts renderer/shared/provider-thinking.test.ts && npm run test:bots && npm run test:voice", + "pretest": "npm run build:worktree-remover && npm run test:aiden-remote-speech && npm run test:aiden-remote && npm run test:aiden-service-boundary && npm run test:ios-release && npm run test:terminal:coverage && npm run test:onboarding && npm run test:assistant-automations && npm run test:slash-commands && npm run test:display-image && npm run test:pi-extensions && npm run test:generative-ui && npm run test:provider-failure && npm run test:web-search && npm run test:compaction && npm run test:subagents && tsx --test main/services/pi-remote-catalog.test.ts main/services/provider-model-info-core.test.ts main/services/aiden-remote-models.test.ts renderer/shared/provider-thinking.test.ts && npm run test:bots && npm run test:voice", "pretest:coverage": "npm run build:worktree-remover && npm run build:subagent-run-store && npm run test:preflight && npm run test:scheduled && npm run test:google-provider && npm run test:config-recovery && npm run test:command-system && npm run test:slash-commands && npm run test:display-image && npm run test:generative-ui && npm run test:compaction && npm run test:subagents && npm run test:bots:coverage", "test:preflight": "npm run test:artificial-analysis && npm run test:model-pad && tsx --test main/services/appearance-preview-core.test.ts main/services/generation-timeline.test.ts main/services/local-runtime-status.test.ts main/services/mcp-tool-result.test.ts main/services/pi-thinking-disclosure.integration.test.ts renderer/components/activity-feed.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/components/settings/providers-settings.test.tsx renderer/main/chat-transition.test.tsx renderer/components/reasoning-block.test.tsx renderer/components/reasoning-visibility-control.test.tsx renderer/components/thinking-control.test.tsx renderer/lib/agent-steps.test.ts renderer/lib/button-appearance-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/inline-metadata-hierarchy.test.ts renderer/lib/scrollbar-gutter-contract.test.ts renderer/lib/text-entry-focus-contract.test.ts renderer/lib/pill-appearance.test.ts renderer/lib/reasoning-disclosure.test.ts renderer/lib/streaming-motion-contract.test.ts renderer/lib/streaming-reveal.test.ts renderer/lib/voice-recorder-core.test.ts renderer/lib/media-recorder-stop.test.ts renderer/lib/dictation-vad.test.ts renderer/lib/dictation-sounds.test.ts renderer/pill-preload-channels.test.ts renderer/shared/anthropic-thinking.test.ts renderer/shared/app-update.test.ts renderer/shared/claim-check.test.ts renderer/shared/codex-thinking.test.ts renderer/shared/google-thinking.test.ts renderer/shared/provider-deployment.test.ts", "test:aiden-remote": "tsx --test main/handlers/aiden-remote.test.ts main/services/aiden-remote-approved-roots.test.ts main/services/aiden-remote-revocation.test.ts main/services/aiden-remote-bot-files.test.ts main/services/aiden-remote-bots.test.ts main/services/aiden-remote-chat-http.test.ts main/services/aiden-remote-chats.test.ts main/services/aiden-remote-files.test.ts main/services/aiden-remote-git.test.ts main/services/aiden-remote-models.test.ts main/services/aiden-remote-protocol.test.ts main/services/aiden-remote-opaque-handles.test.ts main/services/aiden-remote-operation-contract.test.ts main/services/aiden-remote-pairing.test.ts main/services/aiden-remote-ports.test.ts main/services/aiden-remote-router.test.ts main/services/aiden-remote-schedules.test.ts main/services/aiden-remote-service.test.ts main/services/aiden-remote-state.test.ts main/services/aiden-remote-streams.test.ts main/services/aiden-remote-tailscale-route.test.ts main/services/aiden-remote-tailscale.test.ts main/services/aiden-remote-tls-identity.test.ts main/services/aiden-remote-workspace-browser.test.ts main/services/aiden-remote-workspace-http.test.ts main/services/aiden-remote-workspaces.test.ts renderer/components/remote-connection-popover.test.tsx renderer/components/settings/remote-access-settings.test.tsx renderer/lib/remote-approval.test.ts renderer/lib/remote-connection-status.test.ts renderer/lib/remote-pairing-lifecycle.test.ts renderer/lib/settings-section.test.ts && node --test scripts/aiden-remote-lan-transport-spike.test.mjs", @@ -66,6 +69,7 @@ "test:todo": "tsx --test main/services/rpiv-todo/*.test.ts renderer/shared/todo.test.ts renderer/components/todo-panel.test.tsx main/services/generation-timeline.test.ts main/handlers/ipc-contract.test.ts renderer/lib/ipc-stream.test.ts", "test:btw": "tsx --test main/services/rpiv-btw/*.test.ts renderer/shared/btw.test.ts renderer/components/btw-card.test.tsx", "test:advisor": "tsx --test renderer/shared/advisor.test.ts main/services/advisor-context.test.ts main/services/advisor-attempt-store.test.ts main/services/advisor-runtime.test.ts main/services/advisor-integration.test.ts", + "test:pi-extensions": "npm run test:ask-user-question && npm run test:todo && npm run test:btw && npm run test:advisor", "test:generative-ui": "tsx --test main/services/generative-ui-html.test.ts main/services/generative-ui-extension.test.ts main/services/generative-ui-artifact-store.test.ts main/services/generative-ui-host-libraries.test.ts main/services/generative-ui-protocol.test.ts renderer/shared/chat-artifacts.test.ts renderer/shared/generative-ui.test.ts && node --test scripts/vendor-generative-ui-libs.test.mjs && playwright test --config=playwright.generative-ui.config.ts --fail-on-flaky-tests", "test:google-provider": "tsx --test main/services/anthropic-provider.test.ts main/services/google-provider.test.ts main/services/model-runtime-core.test.ts main/services/models.test.ts main/services/provider-config-migration-core.test.ts main/services/chat-store-core.test.ts main/services/schedule-store.test.ts renderer/lib/google-provider-migration.test.ts", "test:config-recovery": "tsx --test main/services/secret-map-core.test.ts main/services/provider-credential-rotation-core.test.ts main/services/legacy-pi-credential-migration-core.test.ts main/services/mcp-credential-cleanup-core.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-oauth-store-core.test.ts", @@ -115,6 +119,8 @@ "format": "oxfmt .", "package": "node scripts/prepare-macos-package-output.mjs development && npm run computer-use:vendor && npm run generative-ui:vendor && npm run build:native && npm run build && electron-builder --mac dir --config.mac.type=development --config.mac.notarize=false --config.directories.output=release/development", "package:verify": "node scripts/verify-macos-package.mjs --development", + "package:linux": "npm run build && electron-builder --linux dir --config.forceCodeSigning=false --config.directories.output=release/linux-development --publish never", + "package:linux:verify": "node scripts/verify-linux-package.mjs release/linux-development", "release:preflight": "node scripts/check-macos-release.mjs", "release:check-consumers": "node scripts/check-release-consumers.mjs", "release:verify": "node scripts/verify-macos-package.mjs --release", @@ -123,6 +129,10 @@ "test:model-catalog": "npm run test:provider-model-catalog && tsx --test main/services/models-dev-cache-core.test.ts renderer/components/settings/providers-catalog-update.test.tsx main/services/models.test.ts scripts/model-snapshot-core.test.mjs && node --test scripts/update-model-capabilities.test.mjs scripts/check-ci-policy.test.mjs", "release:update-model-capabilities": "npm run models:refresh", "dist": "node scripts/run-macos-distribution.mjs", + "dist:linux": "npm run build && electron-builder --linux AppImage deb rpm --config.forceCodeSigning=false --config.directories.output=release/linux-distribution --publish never", + "test:linux-native": "node scripts/build-worktree-remover.mjs && node scripts/build-worktree-remover.mjs --test && node --test scripts/worktree-remover.test.mjs && node scripts/build-bot-inbox-writer.mjs && node scripts/build-bot-inbox-writer.mjs --test && node --test scripts/bot-inbox-writer.test.mjs && node scripts/build-subagent-run-store.mjs && node scripts/build-subagent-run-store.mjs --test && node --test scripts/subagent-run-store.test.mjs && tsx --test main/services/subagents/subagent-run-store-io.test.ts && node scripts/build-subagent-file-mutator.mjs && node scripts/build-subagent-file-mutator.mjs --test && node --test scripts/subagent-file-mutator.test.mjs && node scripts/build-subagent-shell-runner.mjs && node scripts/build-subagent-shell-runner.mjs --test && tsx --test main/services/subagents/subagent-shell-runner-io.test.ts", + "test:linux-contracts": "tsx --test main/application-lifecycle-core.test.ts main/desktop-cli-core.test.ts main/services/application-menu-core.test.ts main/services/computer-use/platform.test.ts main/services/external-editors.test.ts main/services/profile-share-files.test.ts main/services/provider-key-policy.test.ts main/services/secure-storage-core.test.ts main/windows/main-window-options.test.ts main/windows/pill-window-platform.test.ts renderer/components/environment-subagents-contract.test.ts renderer/lib/command-system-core.test.ts renderer/shared/keybindings.test.ts && node --test scripts/native-c-build-core.test.mjs scripts/configure-electron-fuses.test.mjs scripts/verify-linux-package.test.mjs", + "test:tailscale-live": "tsx scripts/aiden-remote-tailscale-live-acceptance.ts", "test:portable-config": "tsx --test main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/config-store-core.test.ts main/services/secret-map-core.test.ts" }, "dependencies": { @@ -135,6 +145,7 @@ "@tanstack/react-router": "^1.131.36", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", + "bonjour-service": "1.4.4", "chart.js": "^4.5.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -221,6 +232,7 @@ "asarUnpack": [ "**/node_modules/sherpa-onnx-node/**", "**/node_modules/sherpa-onnx-darwin-*/**", + "**/node_modules/sherpa-onnx-linux-*/**", "**/node_modules/node-pty/**" ], "directories": { @@ -243,14 +255,6 @@ "from": "resources/app-icon-monochrome.png", "to": "app-icon-monochrome.png" }, - { - "from": "resources/computer-use/cua-driver-artifact.json", - "to": "computer-use/cua-driver-artifact.json" - }, - { - "from": "resources/computer-use/LICENSE.cua-driver.md", - "to": "computer-use/LICENSE.cua-driver.md" - }, { "from": "resources/generative-ui", "to": "generative-ui", @@ -278,6 +282,16 @@ "Contents/Helpers/aiden-subagent-shell-runner", "Contents/Helpers/aiden-worktree-remover" ], + "extraResources": [ + { + "from": "resources/computer-use/cua-driver-artifact.json", + "to": "computer-use/cua-driver-artifact.json" + }, + { + "from": "resources/computer-use/LICENSE.cua-driver.md", + "to": "computer-use/LICENSE.cua-driver.md" + } + ], "extraFiles": [ { "from": "build/native/Aiden Foundation Models Helper.app", @@ -317,6 +331,74 @@ "NSAppleEventsUsageDescription": "Aiden Agent pastes dictated text into the app you are typing in." } }, + "toolsets": { + "appimage": "1.0.3" + }, + "linux": { + "artifactName": "Aiden-Agent-${version}-${arch}-linux.${ext}", + "category": "Development", + "description": "A desktop AI workspace agent for local and hosted models", + "executableName": "aiden-agent", + "icon": "resources/app-icon.png", + "maintainer": "Sambit Biswas", + "synopsis": "Private AI workspace agent", + "syncDesktopName": true, + "target": [ + "AppImage", + "deb", + "rpm" + ], + "extraFiles": [ + { + "from": "build/native/aiden-worktree-remover", + "to": "Helpers/aiden-worktree-remover" + }, + { + "from": "build/native/aiden-bot-inbox-writer", + "to": "Helpers/aiden-bot-inbox-writer" + }, + { + "from": "build/native/aiden-subagent-run-store", + "to": "Helpers/aiden-subagent-run-store" + }, + { + "from": "build/native/aiden-subagent-file-mutator", + "to": "Helpers/aiden-subagent-file-mutator" + }, + { + "from": "build/native/aiden-subagent-shell-runner", + "to": "Helpers/aiden-subagent-shell-runner" + } + ] + }, + "deb": { + "depends": [ + "libgtk-3-0", + "libnotify4", + "libnss3", + "libxss1", + "libxtst6", + "xdg-utils", + "libatspi2.0-0", + "libuuid1", + "libsecret-1-0", + "libasound2t64 (>= 1.0.17) | libasound2 (>= 1.0.17)" + ] + }, + "rpm": { + "depends": [ + "gtk3", + "libnotify", + "nss", + "libXScrnSaver", + "(libXtst or libXtst6)", + "xdg-utils", + "at-spi2-core", + "(libuuid or libuuid1)", + "(libsecret or libsecret-1-0)", + "(alsa-lib or libasound2)" + ] + }, "dmg": { "artifactName": "Aiden-Agent-Beta-${version}-${arch}.${ext}", "background": "resources/dmg-background.png", diff --git a/playwright.config.ts b/playwright.config.ts index 10233493..5fbd317c 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -10,7 +10,9 @@ const liveLmStudioAcceptance = process.env.AIDEN_E2E_LIVE_LMSTUDIO === "1"; const config: PlaywrightTestConfig = { testDir: "./tests/e2e", testMatch: liveLmStudioAcceptance ? "**/*.live.spec.ts" : "**/*.spec.ts", - testIgnore: liveLmStudioAcceptance ? undefined : "**/*.live.spec.ts", + testIgnore: liveLmStudioAcceptance + ? "**/._*" + : ["**/*.live.spec.ts", "**/._*"], outputDir: "./test-results/e2e", fullyParallel: false, workers: 1, diff --git a/renderer/components/chat-sidebar.tsx b/renderer/components/chat-sidebar.tsx index 23a5433a..bb358e26 100644 --- a/renderer/components/chat-sidebar.tsx +++ b/renderer/components/chat-sidebar.tsx @@ -69,6 +69,7 @@ import { useAppUpdateSnapshot } from "../lib/use-app-update-snapshot"; import type { AppUpdateRestartResult, AppUpdateSnapshot } from "../shared/app-update"; import { useActiveChatIds } from "../lib/use-chat-activity"; import { RemoteConnectionPopover } from "./remote-connection-popover"; +import { useAppCapabilities } from "../lib/app-capabilities"; const AIDEN_MARK_URL = new URL("../../resources/app-icon.png", import.meta.url).href; /** Must match aiden-app-update-banner-out in styles.css. */ @@ -374,6 +375,7 @@ function groupChats(chats: ChatMeta[]): { label: string; chats: ChatMeta[] }[] { export function ChatSidebar({ activeChatId, titleReveal }: ChatSidebarProps) { const navigate = useNavigate(); + const capabilities = useAppCapabilities(); const pathname = useRouterState({ select: (state) => state.location.pathname }); const qc = useQueryClient(); const { workspaces, active, activeId, select } = useActiveWorkspace(); @@ -381,7 +383,7 @@ export function ChatSidebar({ activeChatId, titleReveal }: ChatSidebarProps) { const activeChatIds = useActiveChatIds(); const appendReconciliationRequired = useAppendReconciliationRequired(); const chats = useChats(activeId); - const foundationModels = useFoundationModelsConnection(); + const foundationModels = useFoundationModelsConnection(capabilities.appleFoundationModels); const [search, setSearch] = React.useState(""); const [renaming, setRenaming] = React.useState(null); const [renameValue, setRenameValue] = React.useState(""); @@ -809,12 +811,14 @@ export function ChatSidebar({ activeChatId, titleReveal }: ChatSidebarProps) { selected={pathname === "/scheduled"} onClick={() => navigate({ to: "/scheduled" })} /> - } - title="Bots" - selected={pathname.startsWith("/bots")} - onClick={() => navigate({ to: "/bots" })} - /> + {capabilities.bots ? ( + } + title="Bots" + selected={pathname.startsWith("/bots")} + onClick={() => navigate({ to: "/bots" })} + /> + ) : null} {/* Workspace switcher — change the folder Pi works in. */} @@ -968,7 +972,7 @@ export function ChatSidebar({ activeChatId, titleReveal }: ChatSidebarProps) { > Rename - {foundationModels.data !== null ? ( + {capabilities.appleFoundationModels && foundationModels.data !== null ? ( (providers.data ?? []).filter((provider) => !isUsable(provider)), @@ -290,7 +292,7 @@ export function AppCommandPalette({ if (!isCurrent()) return; toast.success( mode === "system" - ? "Appearance now follows macOS" + ? "Appearance now follows the system" : `${mode === "dark" ? "Dark" : "Light"} appearance enabled`, ); } catch (error) { @@ -640,7 +642,7 @@ export function AppCommandPalette({ {palette.mode === "settings" ? ( <> {[ - { mode: "system" as const, title: "Follow macOS appearance", icon: Palette }, + { mode: "system" as const, title: "Follow system appearance", icon: Palette }, { mode: "light" as const, title: "Use light appearance", icon: Sun }, { mode: "dark" as const, title: "Use dark appearance", icon: Moon }, ].map((item, index) => ( @@ -662,7 +664,7 @@ export function AppCommandPalette({ ) : null} ))} - {SETTINGS_DESTINATIONS.map((destination) => ( + {availableSettingsDestinations(capabilities).map((destination) => ( ) : null} - {/* Workspace context: folder (opens in Finder) · local execution · git branch. */} + {/* Workspace context: folder (opens in the system file manager) · local execution · git branch. */}
{workspacePickerEnabled && onSelectWorkspace && onCreateScratchWorkspace ? ( {folderName ?? "Workspace"} )} - {/* Execution location — Pi runs locally on this Mac. */} + {/* Execution location — Pi runs locally on this host. */} Local @@ -2049,7 +2049,7 @@ export function Composer({ open={logoutChooserOpen} onOpenChange={setLogoutChooserOpen} title="Sign out of a provider" - description="Choose an authenticated provider on this Mac." + description="Choose an authenticated provider on this device." confirmHidden returnFocus={() => inputRef?.current ?? null} > @@ -2089,7 +2089,7 @@ export function Composer({ description={ This removes Aiden's encrypted {logoutProvider?.label ?? "provider"} credential - from this Mac. Existing chats remain. If no system credential is available, those models + from this device. Existing chats remain. If no system credential is available, those models cannot run until you sign in again. } diff --git a/renderer/components/environment-overview.tsx b/renderer/components/environment-overview.tsx index 9f813659..455c63ca 100644 --- a/renderer/components/environment-overview.tsx +++ b/renderer/components/environment-overview.tsx @@ -128,7 +128,7 @@ export function EnvironmentOverview({ ); } - const localDetail = workspace.managedWorktree ? "Isolated worktree" : "Runs on this Mac"; + const localDetail = workspace.managedWorktree ? "Isolated worktree" : "Runs on this device"; const accessLabel = workspace.permission === "full" ? "Full access" : "Ask first"; const changesLabel = review.isLoading && !review.data ? "Changes, loading working tree status" diff --git a/renderer/components/environment-subagents-contract.test.ts b/renderer/components/environment-subagents-contract.test.ts index 651b4218..4f1d2e54 100644 --- a/renderer/components/environment-subagents-contract.test.ts +++ b/renderer/components/environment-subagents-contract.test.ts @@ -36,18 +36,17 @@ function between(value: string, start: string, end: string): string { return value.slice(startIndex, endIndex); } -test("fresh renderer capabilities fail closed until main explicitly enables subagents", () => { - assert.deepEqual(DISABLED_APP_CAPABILITIES, { subagents: false }); - assert.deepEqual(parseAppCapabilities(undefined), { subagents: false }); - assert.deepEqual(parseAppCapabilities({ subagents: false }), { - subagents: false, +test("fresh renderer capabilities fail closed until main explicitly enables features", () => { + assert.deepEqual(parseAppCapabilities(undefined), DISABLED_APP_CAPABILITIES); + assert.deepEqual(parseAppCapabilities({ subagents: "1", platform: "plan9" }), { + ...DISABLED_APP_CAPABILITIES, }); - assert.deepEqual(parseAppCapabilities({ subagents: "1" }), { - subagents: false, - }); - assert.deepEqual(parseAppCapabilities({ subagents: true }), { + assert.deepEqual(parseAppCapabilities({ subagents: true, platform: "linux" }), { + ...DISABLED_APP_CAPABILITIES, + platform: "linux", subagents: true, }); + assert.equal(parseAppCapabilities({ bots: true }).bots, true); assert.deepEqual(availableEnvironmentPanelTabs(false), ["review", "files"]); assert.deepEqual(availableEnvironmentPanelTabs(true), ["review", "subagents", "files"]); }); @@ -291,7 +290,10 @@ test("main-derived capabilities gate every renderer entry and repair disabled na const messages = source("./message-list.tsx"); const pane = source("../main/chat-pane.tsx"); - assert.match(appHandler, /capabilities:\s*\{\s*subagents: subagentsEnabled\(\),\s*\}/u); + assert.match(appHandler, /subagents: subagentsEnabled\(\)/u); + assert.match(appHandler, /const host = hostPlatformCapabilities\(\)/u); + assert.match(appHandler, /bots: host\.bots/u); + assert.match(appHandler, /computerUse: host\.computerUse/u); assert.match(bootstrap, /let appCapabilities = DISABLED_APP_CAPABILITIES/u); assert.match(bootstrap, /appCapabilities = parseAppCapabilities\(appInfo\.capabilities\)/u); assert.match(bootstrap, /capabilities=\{appCapabilities\}/u); @@ -311,6 +313,10 @@ test("main-derived capabilities gate every renderer entry and repair disabled na /subagentChips=\{\s*subagentsEnabled && message\.subagents \? \(/u, ); assert.match(messages, /subagentChips=\{\s*subagentsEnabled && liveSubagents\.length > 0 \? \(/u); + assert.match( + pane, + /capabilities\.computerUse && settings\.data\?\.computerUseEnabled === true/u, + ); assert.match(pane, /visibleSubagentReferences\(messages, environmentPanel\.subagentsEnabled\)/u); assert.match(pane, /subagentsEnabled=\{environmentPanel\.subagentsEnabled\}/u); }); diff --git a/renderer/components/onboarding-flow.test.tsx b/renderer/components/onboarding-flow.test.tsx index 73324b91..db102a75 100644 --- a/renderer/components/onboarding-flow.test.tsx +++ b/renderer/components/onboarding-flow.test.tsx @@ -306,7 +306,11 @@ test("onboarding presentation stays compact and free of decorative gradients", ( test("the final step is a complete grouped bento gallery with hover descriptions", () => { assert.match(source, /data-onboarding-bento/u); - assert.match(source, /data-onboarding-feature-count=\{featureBentos\.length\}/u); + assert.match(source, /data-onboarding-feature-count=\{visibleFeatureBentos\.length\}/u); + assert.match( + source, + /if \(!capabilities\.computerUse && feature\.id === "computerUse"\) continue/u, + ); assert.match(source, /auto-rows-\[118px\][\s\S]*?grid-cols-6/u); assert.match(source, /FEATURE_LAYOUTS[\s\S]*?col-span-4 row-span-2/u); assert.match(source, /group-hover:opacity-100/u); @@ -315,6 +319,7 @@ test("the final step is a complete grouped bento gallery with hover descriptions source, /Use Command-K or \/ for app commands, and \$ to attach a reusable skill\./u, ); + assert.match(source, /Use Ctrl-K or \/ for app commands/u); assert.match( source, /Create reusable instructions, then type \$ to attach one to your next message\./u, @@ -374,8 +379,7 @@ test("the final step is a complete grouped bento gallery with hover descriptions featurePresentation, /benchmark-only OpenRouter key never imports its model catalog/u, ); - assert.match(featurePresentation, /Live catalog checks happen only when you choose/u); - assert.match(featurePresentation, /ordinary browsing stays offline/u); + assert.match(featurePresentation, /bundled model details stay offline during ordinary browsing/u); assert.match(featurePresentation, /Keep audio on-device with Parakeet/u); assert.match(featurePresentation, /explicitly connect cloud transcription/u); assert.equal(featurePresentation.match(/imageUrl: FEATURE_ILLUSTRATIONS\./gu)?.length, 25); diff --git a/renderer/components/onboarding-flow.tsx b/renderer/components/onboarding-flow.tsx index f05933a5..409b3a1f 100644 --- a/renderer/components/onboarding-flow.tsx +++ b/renderer/components/onboarding-flow.tsx @@ -66,6 +66,7 @@ import { shouldOpenOnboarding, type OnboardingSnapshot, } from "../shared/onboarding"; +import { useAppCapabilities } from "../lib/app-capabilities"; type Step = "profile" | "provider" | "tour"; const steps: Step[] = ["profile", "provider", "tour"]; @@ -252,7 +253,7 @@ const featureBentos: FeatureBento[] = [ id: "models", group: "extend", title: "Model Freedom", - description: "Choose from 30+ Pi providers, ChatGPT sign-in, Apple models, or local endpoints.", + description: "Choose from 30+ Pi providers, ChatGPT sign-in, or local and private endpoints.", icon: Blocks, imageUrl: FEATURE_ILLUSTRATIONS.models, size: "hero", @@ -262,7 +263,7 @@ const featureBentos: FeatureBento[] = [ group: "extend", title: "Personal Model Pad", description: - "Arrange favorite models on your own map; an optional benchmark-only OpenRouter key never imports its model catalog. Live catalog checks happen only when you choose provider setup or Update model catalogs; ordinary browsing stays offline.", + "Arrange favorite models on your own map; an optional benchmark-only OpenRouter key never imports its model catalog, while bundled model details stay offline during ordinary browsing.", icon: ChartScatter, imageUrl: FEATURE_ILLUSTRATIONS.modelPad, size: "tall", @@ -461,6 +462,20 @@ function OnboardingDialogShell({ children }: React.PropsWithChildren) { export function OnboardingFlow() { const queryClient = useQueryClient(); + const capabilities = useAppCapabilities(); + const visibleFeatureBentos = React.useMemo(() => { + const visible: FeatureBento[] = []; + for (const feature of featureBentos) { + if (!capabilities.computerUse && feature.id === "computerUse") continue; + if (!capabilities.bots && feature.id === "bots") continue; + visible.push( + feature.id === "commands" && capabilities.platform === "linux" + ? { ...feature, description: "Use Ctrl-K or / for app commands, and $ to attach a reusable skill." } + : feature, + ); + } + return visible; + }, [capabilities.bots, capabilities.computerUse, capabilities.platform]); const providers = useProviders(); const codexStatus = useCodexProviderStatus(); const webSearch = useWebSearch(); @@ -913,7 +928,7 @@ export function OnboardingFlow() { What should Aiden call you? - This personalizes your profile and model context on this Mac. + This personalizes your profile and model context on this device.
@@ -934,7 +949,7 @@ export function OnboardingFlow() {
- Stored privately on this Mac. + Stored privately on this device.
- Explore all {featureBentos.length} shipped features. Scroll, then hover or + Explore all {visibleFeatureBentos.length} shipped features. Scroll, then hover or focus a tile to learn more. @@ -1252,11 +1267,13 @@ export function OnboardingFlow() {
{featureGroups.map((group) => { - const features = featureBentos.filter((feature) => feature.group === group.id); + const features = visibleFeatureBentos.filter( + (feature) => feature.group === group.id, + ); const headingId = `onboarding-feature-group-${group.id}`; return (
@@ -1406,7 +1423,7 @@ export function OnboardingFlow() { }} layer="onboarding" title={`Connect ${apiKeyDialogChoice === "openai-key" ? "OpenAI" : "Anthropic"}`} - description="Paste your API key to verify the connection. Validation does not send a chat message, and the key is stored encrypted on this Mac." + description="Paste your API key to verify the connection. Validation does not send a chat message, and the key is stored encrypted on this device." confirmLabel={discovering ? "Validating…" : "Validate & continue"} confirmDisabled={!apiKey.trim()} dismissDisabled={saving} diff --git a/renderer/components/open-in-editor-picker.tsx b/renderer/components/open-in-editor-picker.tsx index 39a445ec..cdd20867 100644 --- a/renderer/components/open-in-editor-picker.tsx +++ b/renderer/components/open-in-editor-picker.tsx @@ -35,7 +35,7 @@ function EditorIcon({ editor, className }: { editor: ExternalEditor; className: /> ); } - const Icon = editor.id === "finder" ? Folder : AppWindow; + const Icon = editor.id === "finder" || editor.id === "file-manager" ? Folder : AppWindow; return