From 3b93a45141dd654d7b2686a25edb0f8590dc4498 Mon Sep 17 00:00:00 2001 From: renyuanz Date: Mon, 31 Aug 2026 18:27:18 +0800 Subject: [PATCH 1/4] =?UTF-8?q?rename:=20webmux=20=E2=86=92=20offdesk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crates tc-hub/tc-cli/tc-machine/tc-protocol become offdesk-*. Binaries webmux-server/webmux-node/webmux become offdesk-hub/offdesk-node/offdesk. WEBMUX_* env vars become OFFDESK_*, the config directory moves from /webmux to /offdesk, and API tokens are minted with the odk_ prefix. Also renamed: the @webmux/* npm scope, the app bundle identifiers (com.webmux.* → dev.offdesk.*), the mobile OAuth deep link (webmux://auth → offdesk://auth), the container image (ghcr.io/zalify/webmux-server → ghcr.io/zalify/offdesk-hub), the SQLite file inside the container image, and the tmux socket. Nothing on an existing install is lost: - offdesk_protocol::config_dir() moves /webmux into place on first use, so a registered machine keeps its machine.json, its saved terminal metadata, and its tmux.user.conf. - The hub still accepts wmx_ API tokens; it just never mints new ones. WEBMUX_URL/TOKEN and the hub's WEBMUX_* vars still work, each with a deprecation notice on stderr. - tmux cannot move a session between servers, so pty.rs resolves its socket at startup: a node whose old `webmux` socket still holds wmx_ sessions keeps using it, and moves to `offdesk` once they are all closed. Resolution is explicit rather than lazy so unit tests do not depend on which tmux servers are running on the box. - The web app copies webmux:* localStorage keys to offdesk:* once, so clients keep their hub URL, fonts, and layout. Files under docs/plans/ and docs/superpowers/ are dated design records and keep the names that were current when they were written. Co-Authored-By: Claude Opus 5 --- .github/workflows/build.yml | 16 +- .github/workflows/ci.yml | 2 +- .github/workflows/container.yml | 4 +- .github/workflows/mobile-android.yml | 16 +- Cargo.lock | 181 ++++----- DESIGN.md | 2 +- Dockerfile | 10 +- Dockerfile.desktop | 4 +- README.md | 60 +-- crates/cli/Cargo.toml | 6 +- crates/cli/src/client.rs | 2 +- crates/cli/src/commands/kill.rs | 2 +- crates/cli/src/commands/machines.rs | 4 +- crates/cli/src/commands/mod.rs | 6 +- crates/cli/src/commands/read_all.rs | 4 +- crates/cli/src/config.rs | 14 +- crates/cli/src/main.rs | 23 +- crates/hub/Cargo.toml | 6 +- crates/hub/src/auth.rs | 32 +- crates/hub/src/db/agent_sessions.rs | 2 +- crates/hub/src/db/mod.rs | 2 +- crates/hub/src/db/terminal_sessions.rs | 4 +- crates/hub/src/db/types.rs | 2 +- crates/hub/src/db/workspace_layouts.rs | 2 +- crates/hub/src/machine_manager.rs | 14 +- crates/hub/src/main.rs | 30 +- crates/hub/src/routes/agent_sessions.rs | 18 +- crates/hub/src/routes/api_tokens.rs | 5 +- crates/hub/src/routes/bootstrap.rs | 2 +- crates/hub/src/routes/terminals.rs | 6 +- crates/hub/src/ws.rs | 12 +- crates/machine/Cargo.toml | 6 +- crates/machine/src/acp.rs | 2 +- crates/machine/src/config.rs | 9 +- crates/machine/src/hub_conn.rs | 12 +- crates/machine/src/main.rs | 24 +- crates/machine/src/pty.rs | 159 +++++--- crates/machine/src/service.rs | 14 +- crates/machine/src/stats.rs | 4 +- crates/protocol/Cargo.toml | 3 +- crates/protocol/src/compression.rs | 2 +- crates/protocol/src/lib.rs | 37 ++ docker-compose.yml | 12 +- docs/deployment/runbook.md | 58 +-- docs/design/next-ia/Main.dc.html | 18 +- docs/design/next-ia/MobileChat.dc.html | 2 +- docs/design/next-ia/MobileList.dc.html | 6 +- docs/design/next-ia/MobileNew.dc.html | 10 +- docs/design/next-ia/NewSession.dc.html | 26 +- docs/design/next-ia/Split.dc.html | 26 +- docs/design/next-ia/States.dc.html | 8 +- docs/design/next-ia/Terminal.dc.html | 24 +- docs/facts.md | 367 ++++++++++++++++++ e2e/Dockerfile.hub | 10 +- e2e/Dockerfile.node | 10 +- e2e/agent-sessions-smoke.sh | 14 +- e2e/docker-compose.yml | 10 +- e2e/env-playbook.md | 6 +- e2e/machine.json | 8 +- e2e/tests/helpers.ts | 12 +- e2e/tests/mobile-controls.spec.ts | 18 +- e2e/tests/mobile-ime-composition.spec.ts | 4 +- e2e/tests/mobile-link-tap.spec.ts | 4 +- e2e/tests/mobile-touch-scroll.spec.ts | 4 +- e2e/tests/terminal-attach-recovery.spec.ts | 6 +- e2e/tests/terminal-compression.spec.ts | 8 +- e2e/tests/terminal-copy-mode-scroll.spec.ts | 8 +- e2e/tests/terminal-copy-on-select.spec.ts | 34 +- e2e/tests/terminal-fit-stability.spec.ts | 8 +- e2e/tests/terminal-generated-input.spec.ts | 48 +-- e2e/tests/terminal-glyph-rendering.spec.ts | 4 +- e2e/tests/terminal-handoff-sizing.spec.ts | 4 +- e2e/tests/terminal-image-paste.spec.ts | 6 +- e2e/tests/terminal-multi-attach.spec.ts | 6 +- e2e/tests/terminal-osc52-clipboard.spec.ts | 22 +- e2e/tests/terminal-selection-scale.spec.ts | 12 +- e2e/tests/terminal-wheel-scroll.spec.ts | 4 +- e2e/tests/workspace-keepalive.spec.ts | 6 +- e2e/tests/workspace-tabs.spec.ts | 6 +- package.json | 10 +- packages/app/app.config.js | 16 +- packages/app/app/_layout.tsx | 1 + packages/app/app/login.tsx | 4 +- packages/app/components/AgentBadge.web.tsx | 6 +- packages/app/components/AgentChatView.web.tsx | 4 +- packages/app/components/ExtendedKeyBar.tsx | 2 +- .../app/components/MobileWorkbench.web.tsx | 6 +- .../app/components/NewSessionDialog.web.tsx | 2 +- .../app/components/NewSessionSheet.web.tsx | 6 +- .../app/components/OnboardingView.web.tsx | 4 +- packages/app/components/SettingsPage.tsx | 32 +- packages/app/components/Sidebar.web.tsx | 4 +- .../app/components/TerminalCanvas.web.tsx | 6 +- packages/app/components/TerminalCard.web.tsx | 8 +- .../app/components/TerminalView.xterm.tsx | 40 +- .../app/components/TerminalWorkspace.web.tsx | 2 +- packages/app/components/newSessionState.ts | 2 +- .../app/components/useTerminalLiveSocket.ts | 16 +- packages/app/global.css | 10 +- packages/app/lib/agentSessionFeed.test.ts | 2 +- packages/app/lib/agentSessionFeed.ts | 2 +- packages/app/lib/agentStarting.ts | 2 +- packages/app/lib/agentTranscript.test.ts | 2 +- packages/app/lib/agentTranscript.ts | 4 +- packages/app/lib/api.test.ts | 4 +- packages/app/lib/api.ts | 6 +- packages/app/lib/attachCompression.test.ts | 2 +- packages/app/lib/auth.tsx | 4 +- packages/app/lib/bookmarkContract.test.ts | 2 +- packages/app/lib/bootstrapState.test.ts | 2 +- packages/app/lib/bootstrapState.ts | 2 +- packages/app/lib/directoryAutocomplete.ts | 2 +- packages/app/lib/displayTerminalTitle.test.ts | 2 +- packages/app/lib/displayTerminalTitle.ts | 2 +- packages/app/lib/lazyWithReload.ts | 2 +- packages/app/lib/legacyStorageMigration.ts | 32 ++ packages/app/lib/mainLayoutReducer.test.ts | 16 +- .../app/lib/mobileSessionSwitcher.test.ts | 8 +- packages/app/lib/mobileSessionSwitcher.ts | 2 +- packages/app/lib/nodeInstaller.test.mjs | 4 +- packages/app/lib/nodeInstaller.ts | 6 +- packages/app/lib/panelOpenStorage.ts | 2 +- packages/app/lib/platform.ts | 6 +- packages/app/lib/prefixKey.ts | 2 +- packages/app/lib/resourceStats.test.ts | 2 +- packages/app/lib/resourceStats.ts | 2 +- packages/app/lib/serverUrl.test.ts | 4 +- packages/app/lib/serverUrl.ts | 8 +- packages/app/lib/sessionDefaults.test.ts | 6 +- packages/app/lib/sessionDefaults.ts | 8 +- packages/app/lib/sidebarTree.test.ts | 2 +- packages/app/lib/sidebarTree.ts | 2 +- packages/app/lib/storage.ts | 2 +- packages/app/lib/terminalGpuRenderer.test.ts | 2 +- packages/app/lib/terminalGpuRenderer.ts | 6 +- .../app/lib/terminalWorkspaceLayout.test.ts | 22 +- packages/app/lib/terminalWorkspaceLayout.ts | 4 +- packages/app/lib/viewOnlyLock.ts | 2 +- packages/app/lib/workspaceToast.ts | 2 +- packages/app/package.json | 4 +- packages/desktop/package.json | 2 +- packages/desktop/src-tauri/Cargo.lock | 2 +- packages/desktop/src-tauri/Cargo.toml | 2 +- packages/desktop/src-tauri/build.rs | 2 +- .../capabilities/mobile/default.json | 2 +- .../gen/android/app/build.gradle.kts | 4 +- .../android/app/src/main/AndroidManifest.xml | 2 +- .../offdesk}/desktop/MainActivity.kt | 2 +- .../app/src/main/res/values-night/themes.xml | 2 +- .../app/src/main/res/values/strings.xml | 4 +- .../app/src/main/res/values/themes.xml | 2 +- .../offdesk}/desktop/kotlin/BuildTask.kt | 0 .../offdesk}/desktop/kotlin/RustPlugin.kt | 0 .../src-tauri/gen/schemas/capabilities.json | 2 +- packages/desktop/src-tauri/src/lib.rs | 8 +- packages/desktop/src-tauri/src/main.rs | 2 +- packages/desktop/src-tauri/src/oauth.rs | 4 +- .../desktop/src-tauri/tauri.android.conf.json | 4 +- packages/desktop/src-tauri/tauri.conf.json | 12 +- packages/desktop/src/index.html | 2 +- packages/shared/package.json | 2 +- packages/shared/src/contracts.ts | 2 +- pnpm-lock.yaml | 6 +- proxy.mjs | 4 +- scripts/install.sh | 28 +- scripts/install.test.mjs | 24 +- scripts/stamp-build.mjs | 4 +- scripts/verify-container-runtime.sh | 6 +- vitest.config.ts | 2 +- 169 files changed, 1327 insertions(+), 794 deletions(-) create mode 100644 docs/facts.md create mode 100644 packages/app/lib/legacyStorageMigration.ts rename packages/desktop/src-tauri/gen/android/app/src/main/java/{com/webmux => dev/offdesk}/desktop/MainActivity.kt (98%) rename packages/desktop/src-tauri/gen/android/buildSrc/src/main/java/{com/webmux => dev/offdesk}/desktop/kotlin/BuildTask.kt (100%) rename packages/desktop/src-tauri/gen/android/buildSrc/src/main/java/{com/webmux => dev/offdesk}/desktop/kotlin/RustPlugin.kt (100%) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c556e2b7..402d16ea 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -40,19 +40,19 @@ jobs: include: - target: x86_64-unknown-linux-musl os: ubuntu-latest - artifact: webmux-node-linux-x64 + artifact: offdesk-node-linux-x64 use-cross: true - target: aarch64-unknown-linux-musl os: ubuntu-latest - artifact: webmux-node-linux-arm64 + artifact: offdesk-node-linux-arm64 use-cross: true - target: x86_64-apple-darwin os: macos-latest - artifact: webmux-node-darwin-x64 + artifact: offdesk-node-darwin-x64 use-cross: false - target: aarch64-apple-darwin os: macos-latest - artifact: webmux-node-darwin-arm64 + artifact: offdesk-node-darwin-arm64 use-cross: false runs-on: ${{ matrix.os }} @@ -74,16 +74,16 @@ jobs: if: matrix.use-cross uses: taiki-e/install-action@cross - - name: Build webmux-node + - name: Build offdesk-node run: | if [ "${{ matrix.use-cross }}" = "true" ]; then - cross build --release --bin webmux-node --target ${{ matrix.target }} + cross build --release --bin offdesk-node --target ${{ matrix.target }} else - cargo build --release --bin webmux-node --target ${{ matrix.target }} + cargo build --release --bin offdesk-node --target ${{ matrix.target }} fi - name: Rename binary - run: cp target/${{ matrix.target }}/release/webmux-node ${{ matrix.artifact }} + run: cp target/${{ matrix.target }}/release/offdesk-node ${{ matrix.artifact }} - name: Upload to Release if: startsWith(github.ref, 'refs/tags/') diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 225cf1e2..725d7136 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,7 +40,7 @@ jobs: - run: pnpm install --frozen-lockfile - - run: pnpm --filter @webmux/shared build + - run: pnpm --filter @offdesk/shared build - run: cd packages/app && npx expo export --platform web diff --git a/.github/workflows/container.yml b/.github/workflows/container.yml index cd26eb18..efa4d480 100644 --- a/.github/workflows/container.yml +++ b/.github/workflows/container.yml @@ -12,7 +12,7 @@ concurrency: env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository_owner }}/webmux-server + IMAGE_NAME: ${{ github.repository_owner }}/offdesk-hub jobs: publish: @@ -35,7 +35,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Smoke-test container runtime - run: ./scripts/verify-container-runtime.sh webmux-runtime-smoke-ci + run: ./scripts/verify-container-runtime.sh offdesk-runtime-smoke-ci - id: meta uses: docker/metadata-action@v6 diff --git a/.github/workflows/mobile-android.yml b/.github/workflows/mobile-android.yml index c6f514e1..ada9c439 100644 --- a/.github/workflows/mobile-android.yml +++ b/.github/workflows/mobile-android.yml @@ -7,8 +7,8 @@ on: workflow_dispatch: inputs: hub_url: - description: "WEBMUX_MOBILE_HUB_URL baked into the build" - default: "https://webmux.nas.chareice.site" + description: "OFFDESK_MOBILE_HUB_URL baked into the build" + default: "https://offdesk.nas.chareice.site" required: false permissions: @@ -18,7 +18,7 @@ jobs: build-apk: runs-on: ubuntu-22.04 env: - WEBMUX_MOBILE_HUB_URL: ${{ github.event.inputs.hub_url || 'https://webmux.nas.chareice.site' }} + OFFDESK_MOBILE_HUB_URL: ${{ github.event.inputs.hub_url || 'https://offdesk.nas.chareice.site' }} steps: - uses: actions/checkout@v4 @@ -66,10 +66,10 @@ jobs: run: pnpm install --frozen-lockfile=false - name: Build shared package - run: pnpm --filter @webmux/shared build + run: pnpm --filter @offdesk/shared build - name: Build web bundle (loaded as launch shell, replaced at runtime) - run: pnpm --filter @webmux/app build + run: pnpm --filter @offdesk/app build - name: Set release version from tag id: version @@ -201,7 +201,7 @@ jobs: base=$(basename "$src" .apk) abi=$(echo "$base" | sed -E 's/^app-//; s/-release.*$//') [ -z "$abi" ] && abi="universal" - cp "$src" "artifacts/webmux-${version}-${abi}.apk" + cp "$src" "artifacts/offdesk-${version}-${abi}.apk" done ls -lh artifacts/ { @@ -213,7 +213,7 @@ jobs: - name: Upload artifacts uses: actions/upload-artifact@v4 with: - name: webmux-android-apks + name: offdesk-android-apks path: artifacts/*.apk - name: Create or update release @@ -225,7 +225,7 @@ jobs: if ! gh release view "$tag" >/dev/null 2>&1; then gh release create "$tag" \ --title "Mobile ${tag}" \ - --notes "Tauri-based Android client. Sideload via 'install from unknown sources'. Pick the **arm64-v8a** APK on modern phones; the **universal** APK works on any architecture but is larger. The app loads the configured webmux URL on launch, so all UI changes ship automatically without a new APK." + --notes "Tauri-based Android client. Sideload via 'install from unknown sources'. Pick the **arm64-v8a** APK on modern phones; the **universal** APK works on any architecture but is larger. The app loads the configured offdesk URL on launch, so all UI changes ship automatically without a new APK." fi for apk in artifacts/*.apk; do gh release upload "$tag" "$apk" --clobber diff --git a/Cargo.lock b/Cargo.lock index 143bfbe6..f4c5e138 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1224,6 +1224,97 @@ dependencies = [ "autocfg", ] +[[package]] +name = "offdesk-cli" +version = "0.1.0" +dependencies = [ + "clap", + "dirs", + "futures", + "offdesk-protocol", + "regex", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-tungstenite", + "toml", + "tracing", + "tracing-subscriber", + "vt100", +] + +[[package]] +name = "offdesk-hub" +version = "0.1.0" +dependencies = [ + "axum", + "bcrypt", + "bytes", + "chrono", + "clap", + "futures", + "hex", + "http", + "jsonwebtoken", + "offdesk-protocol", + "portable-pty", + "r2d2", + "r2d2_sqlite", + "reqwest", + "rusqlite", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.18", + "tokio", + "tokio-tungstenite", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "urlencoding", + "uuid", + "vt100", +] + +[[package]] +name = "offdesk-machine" +version = "0.1.0" +dependencies = [ + "bytes", + "clap", + "dirs", + "futures", + "hostname", + "offdesk-protocol", + "portable-pty", + "reqwest", + "rustls", + "serde", + "serde_json", + "sysinfo", + "tokio", + "tokio-tungstenite", + "tracing", + "tracing-subscriber", + "url", + "uuid", + "vt100", +] + +[[package]] +name = "offdesk-protocol" +version = "0.1.0" +dependencies = [ + "bytes", + "dirs", + "flate2", + "serde", + "serde_json", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1962,96 +2053,6 @@ dependencies = [ "windows", ] -[[package]] -name = "tc-cli" -version = "0.1.0" -dependencies = [ - "clap", - "dirs", - "futures", - "regex", - "reqwest", - "serde", - "serde_json", - "tc-protocol", - "thiserror 2.0.18", - "tokio", - "tokio-tungstenite", - "toml", - "tracing", - "tracing-subscriber", - "vt100", -] - -[[package]] -name = "tc-hub" -version = "0.1.0" -dependencies = [ - "axum", - "bcrypt", - "bytes", - "chrono", - "clap", - "futures", - "hex", - "http", - "jsonwebtoken", - "portable-pty", - "r2d2", - "r2d2_sqlite", - "reqwest", - "rusqlite", - "serde", - "serde_json", - "sha2", - "tc-protocol", - "thiserror 2.0.18", - "tokio", - "tokio-tungstenite", - "tower", - "tower-http", - "tracing", - "tracing-subscriber", - "urlencoding", - "uuid", - "vt100", -] - -[[package]] -name = "tc-machine" -version = "0.1.0" -dependencies = [ - "bytes", - "clap", - "dirs", - "futures", - "hostname", - "portable-pty", - "reqwest", - "rustls", - "serde", - "serde_json", - "sysinfo", - "tc-protocol", - "tokio", - "tokio-tungstenite", - "tracing", - "tracing-subscriber", - "url", - "uuid", - "vt100", -] - -[[package]] -name = "tc-protocol" -version = "0.1.0" -dependencies = [ - "bytes", - "flate2", - "serde", - "serde_json", -] - [[package]] name = "thiserror" version = "1.0.69" diff --git a/DESIGN.md b/DESIGN.md index 909945b4..00a09cc0 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,4 +1,4 @@ -# webmux Design System +# offdesk Design System This documents the system the app actually ships. Dark-only, cool-neutral, terminal-first. Canonical tokens live in `packages/app/global.css` (CSS custom diff --git a/Dockerfile b/Dockerfile index ca196600..01e8cadb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,16 +22,16 @@ RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/li WORKDIR /app COPY Cargo.toml Cargo.lock ./ COPY crates/ crates/ -RUN cargo build --release --bin webmux-server +RUN cargo build --release --bin offdesk-hub # Stage 3: Production FROM debian:bookworm-slim RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* -COPY --from=builder /app/target/release/webmux-server /usr/local/bin/ +COPY --from=builder /app/target/release/offdesk-hub /usr/local/bin/ COPY --from=frontend /app/packages/app/dist /app/web -ENV WEBMUX_STATIC_DIR=/app/web -ENV DATABASE_PATH=/app/data/tc.db +ENV OFFDESK_STATIC_DIR=/app/web +ENV DATABASE_PATH=/app/data/offdesk.db EXPOSE 4317 -CMD ["webmux-server"] +CMD ["offdesk-hub"] diff --git a/Dockerfile.desktop b/Dockerfile.desktop index 567b9b09..d69d6c3e 100644 --- a/Dockerfile.desktop +++ b/Dockerfile.desktop @@ -48,9 +48,9 @@ RUN apt-get update && apt-get install -y \ fonts-noto-cjk \ && rm -rf /var/lib/apt/lists/* -COPY --from=builder /app/packages/desktop/src-tauri/target/release/webmux-desktop /usr/local/bin/ +COPY --from=builder /app/packages/desktop/src-tauri/target/release/offdesk-desktop /usr/local/bin/ ENV DISPLAY=:99 # Start Xvfb, dbus, then the app -CMD ["sh", "-c", "Xvfb :99 -screen 0 1280x800x24 & sleep 1 && dbus-launch webmux-desktop"] +CMD ["sh", "-c", "Xvfb :99 -screen 0 1280x800x24 & sleep 1 && dbus-launch offdesk-desktop"] diff --git a/README.md b/README.md index 9d72550c..a57b9dbe 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,49 @@ -# webmux +# offdesk -Web-based control plane for terminals and AI coding agents. Run shells, editors, and TUI agents (Claude Code, Codex, Grok, …) on any machine, reach them from any browser or phone — and drive them programmatically from other agents via the `webmux` CLI. +Web-based control plane for terminals and AI coding agents. Run shells, editors, and TUI agents (Claude Code, Codex, Grok, …) on any machine, reach them from any browser or phone — and drive them programmatically from other agents via the `offdesk` CLI. ## Architecture -- `crates/hub` — Rust server (Axum + WebSocket + SQLite). Serves the web UI as an SPA, brokers terminal I/O between browsers/CLI and machines, owns auth (GitHub/Google OAuth + `wmx_` API tokens) and the per-machine control lease (single controller, last-writer-wins). -- `crates/machine` — Rust machine agent (`webmux-node`). Registers with a hub, hosts terminals as tmux sessions (one `tmux attach` per client — multi-client views, no shared scroll state), reports stats. -- `crates/cli` — Rust CLI (`webmux`). Remote `tmux send-keys` + `capture-pane` through the hub; the agent-to-agent interface (see below). +- `crates/hub` — Rust server (Axum + WebSocket + SQLite). Serves the web UI as an SPA, brokers terminal I/O between browsers/CLI and machines, owns auth (GitHub/Google OAuth + `odk_` API tokens) and the per-machine control lease (single controller, last-writer-wins). +- `crates/machine` — Rust machine agent (`offdesk-node`). Registers with a hub, hosts terminals as tmux sessions (one `tmux attach` per client — multi-client views, no shared scroll state), reports stats. +- `crates/cli` — Rust CLI (`offdesk`). Remote `tmux send-keys` + `capture-pane` through the hub; the agent-to-agent interface (see below). - `packages/app` — the only frontend: Expo Router + React Native Web + xterm.js 6. Built with `expo export --platform web`, served by the hub, wrapped by Tauri for desktop (`packages/desktop`) and Android. -- `crates/protocol` (`tc-protocol`) — shared wire types between hub, machine, and CLI. +- `crates/protocol` (`offdesk-protocol`) — shared wire types between hub, machine, and CLI. -## The `webmux` CLI (for humans and agents) +## The `offdesk` CLI (for humans and agents) The CLI lets anything that can run a shell command — a human, a script, or another AI agent — list, open, read, write to, and wait on terminals on any machine registered to a hub. ### Install & authenticate ```bash -cargo build --release -p tc-cli # binary: target/release/webmux +cargo build --release -p offdesk-cli # binary: target/release/offdesk ``` Create an API token in the web UI (**⌃B k → Settings → API Tokens → Create**), then either: ```bash -# ~/.config/webmux/config.toml (chmod 600) +# ~/.config/offdesk/config.toml (chmod 600) url = "https://your-hub.example.com" -token = "wmx_..." +token = "odk_..." ``` -or export `WEBMUX_URL` + `WEBMUX_TOKEN` (flags `--url/--token` override both). +or export `OFFDESK_URL` + `OFFDESK_TOKEN` (flags `--url/--token` override both). ### Commands ``` -webmux machines [--all] [--json] # list machines (default: online; --all includes offline) -webmux machines rm [--yes] # forget a registered machine -webmux ls [--machine ] [--json] # list terminals: id, title, group, cwd, size, reachable -webmux open --cwd [--cmd ] [--group ] [--json] -webmux read [--lines N] [--json] # capture the current screen as text -webmux read --all [--machine ] [--lines N] [--json] [--concurrency N] [--include-unreachable] +offdesk machines [--all] [--json] # list machines (default: online; --all includes offline) +offdesk machines rm [--yes] # forget a registered machine +offdesk ls [--machine ] [--json] # list terminals: id, title, group, cwd, size, reachable +offdesk open --cwd [--cmd ] [--group ] [--json] +offdesk read [--lines N] [--json] # capture the current screen as text +offdesk read --all [--machine ] [--lines N] [--json] [--concurrency N] [--include-unreachable] # batch-capture every terminal's screen in one call -webmux send [--no-enter] # type text (Enter appended by default) -webmux key ... # Enter Esc Tab BTab Up Down Left Right C-c C-d F1-F12 ... -webmux wait [--pattern ] [--silence ] [--timeout ] -webmux kill [--yes] +offdesk send [--no-enter] # type text (Enter appended by default) +offdesk key ... # Enter Esc Tab BTab Up Down Left Right C-c C-d F1-F12 ... +offdesk wait [--pattern ] [--silence ] [--timeout ] +offdesk kill [--yes] ``` - Machines and terminals are addressed by **id prefix** (first column of `ls`); ambiguous prefixes list candidates. @@ -56,11 +56,11 @@ webmux kill [--yes] ### Orchestrating an agent inside a terminal ```bash -T=$(webmux open nas --cwd ~/projects/foo --cmd claude --json | jq -r .id) -webmux send $T "fix the type errors in src/auth.ts; stop when tests pass" -webmux wait $T --silence 5000 --timeout 600 # or --pattern '❯' to await a prompt -webmux read $T --lines 80 # collect the result -webmux kill $T --yes +T=$(offdesk open nas --cwd ~/projects/foo --cmd claude --json | jq -r .id) +offdesk send $T "fix the type errors in src/auth.ts; stop when tests pass" +offdesk wait $T --silence 5000 --timeout 600 # or --pattern '❯' to await a prompt +offdesk read $T --lines 80 # collect the result +offdesk kill $T --yes ``` ### Semantics you must know @@ -77,15 +77,15 @@ webmux kill $T --yes ```bash # hub (serves API + the exported web build on :4317) -WEBMUX_DEV_MODE=true cargo run -p tc-hub +OFFDESK_DEV_MODE=true cargo run -p offdesk-hub # machine agent (registers on first run) -webmux-node register --hub-url http://127.0.0.1:4317 --token -webmux-node start +offdesk-node register --hub-url http://127.0.0.1:4317 --token +offdesk-node start # web app (Expo dev server; proxy.mjs forwards /api and /ws to the hub) pnpm install && pnpm --filter app dev:web node proxy.mjs ``` -`WEBMUX_DEV_MODE=true` enables `/api/auth/dev` for token-less local logins. See `AGENTS.md` for the E2E browser rules, and `docs/plans/` for design specs (notably `2026-08-03-webmux-cli.md` for the CLI protocol details and `2026-08-03-api-tokens-ui.md`). +`OFFDESK_DEV_MODE=true` enables `/api/auth/dev` for token-less local logins. See `AGENTS.md` for the E2E browser rules, and `docs/plans/` for design specs (notably `2026-08-03-offdesk-cli.md` for the CLI protocol details and `2026-08-03-api-tokens-ui.md`). diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index f3898388..f6ca65a2 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -1,14 +1,14 @@ [package] -name = "tc-cli" +name = "offdesk-cli" version = "0.1.0" edition = "2021" [[bin]] -name = "webmux" +name = "offdesk" path = "src/main.rs" [dependencies] -tc-protocol = { path = "../protocol" } +offdesk-protocol = { path = "../protocol" } tokio = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/cli/src/client.rs b/crates/cli/src/client.rs index af9bd629..35d406f6 100644 --- a/crates/cli/src/client.rs +++ b/crates/cli/src/client.rs @@ -3,7 +3,7 @@ use reqwest::{Response, StatusCode}; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use std::time::Duration; -use tc_protocol::{MachineInfo, TerminalInfo, WorkspaceGroupInfo}; +use offdesk_protocol::{MachineInfo, TerminalInfo, WorkspaceGroupInfo}; use crate::config::ResolvedConfig; use crate::CliError; diff --git a/crates/cli/src/commands/kill.rs b/crates/cli/src/commands/kill.rs index 2cc07e70..c2bc0e31 100644 --- a/crates/cli/src/commands/kill.rs +++ b/crates/cli/src/commands/kill.rs @@ -1,6 +1,6 @@ use std::io::{IsTerminal, Write}; -use tc_protocol::TerminalInfo; +use offdesk_protocol::TerminalInfo; use crate::client::HubClient; use crate::resolve::short_id; diff --git a/crates/cli/src/commands/machines.rs b/crates/cli/src/commands/machines.rs index 6d980ca8..9ce4fa70 100644 --- a/crates/cli/src/commands/machines.rs +++ b/crates/cli/src/commands/machines.rs @@ -1,7 +1,7 @@ use std::collections::{HashMap, HashSet}; use std::io::{IsTerminal, Write}; -use tc_protocol::MachineInfo; +use offdesk_protocol::MachineInfo; use crate::client::HubClient; use crate::resolve::{resolve_prefix, short_id}; @@ -134,7 +134,7 @@ fn confirm(machine: &MachineInfo) -> Result { #[cfg(test)] mod tests { use super::resolve_machine; - use tc_protocol::MachineInfo; + use offdesk_protocol::MachineInfo; fn machine(id: &str, name: &str) -> MachineInfo { MachineInfo { diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index eb4a50e6..f7bd3f35 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -12,14 +12,14 @@ use std::collections::{HashMap, HashSet}; use std::io::Write; use serde::Serialize; -use tc_protocol::TerminalInfo; +use offdesk_protocol::TerminalInfo; use crate::client::HubClient; use crate::resolve::{resolve_prefix, short_id}; use crate::CliError; /// Print one line to stdout. Rust ignores SIGPIPE, so a closed reader -/// (`webmux ls | head -1`) would otherwise panic on EPIPE; exit 0 like a +/// (`offdesk ls | head -1`) would otherwise panic on EPIPE; exit 0 like a /// well-behaved Unix tool instead. All user-visible output goes through here. pub fn out_line(line: &str) { let mut stdout = std::io::stdout().lock(); @@ -91,7 +91,7 @@ pub fn group_label(terminal: &TerminalInfo, names: &HashMap) -> mod tests { use super::group_label; use std::collections::HashMap; - use tc_protocol::TerminalInfo; + use offdesk_protocol::TerminalInfo; fn terminal(group_id: Option<&str>) -> TerminalInfo { TerminalInfo { diff --git a/crates/cli/src/commands/read_all.rs b/crates/cli/src/commands/read_all.rs index eafddb9a..15e17e52 100644 --- a/crates/cli/src/commands/read_all.rs +++ b/crates/cli/src/commands/read_all.rs @@ -3,7 +3,7 @@ use std::time::Duration; use futures::StreamExt; use serde_json::{json, Value}; -use tc_protocol::{MachineInfo, TerminalInfo}; +use offdesk_protocol::{MachineInfo, TerminalInfo}; use super::read::ReadOptions; use crate::attach; @@ -274,7 +274,7 @@ mod tests { entry_json, json_output, render_text, retain_machine, BatchEntry, Capture, Outcome, }; use serde_json::json; - use tc_protocol::{MachineInfo, TerminalInfo, TerminalTitleSource}; + use offdesk_protocol::{MachineInfo, TerminalInfo, TerminalTitleSource}; fn terminal(id: &str, machine_id: &str, cwd: &str) -> TerminalInfo { TerminalInfo { diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index 342f8987..fa0c4360 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; use crate::CliError; -/// Contents of ~/.config/webmux/config.toml (lowest precedence source). +/// Contents of ~/.config/offdesk/config.toml (lowest precedence source). #[derive(Debug, Default, Deserialize)] pub struct ConfigFile { pub url: Option, @@ -18,7 +18,7 @@ pub struct ResolvedConfig { } pub fn config_path() -> Option { - dirs::config_dir().map(|dir| dir.join("webmux").join("config.toml")) + Some(offdesk_protocol::config_dir().join("config.toml")) } /// Read the config file if it exists; a missing file is not an error. @@ -54,8 +54,8 @@ pub fn resolve( .or_else(|| file.and_then(|file| file.url.as_deref())) .ok_or_else(|| { CliError::Config( - "hub URL not configured — pass --url, set WEBMUX_URL, or add `url` to \ - ~/.config/webmux/config.toml" + "hub URL not configured — pass --url, set OFFDESK_URL, or add `url` to \ + ~/.config/offdesk/config.toml" .to_string(), ) })?; @@ -65,7 +65,7 @@ pub fn resolve( .ok_or_else(|| { CliError::Config( "no API token configured — create an API token in the web UI and set \ - WEBMUX_TOKEN (or pass --token)" + OFFDESK_TOKEN (or pass --token)" .to_string(), ) })?; @@ -169,14 +169,14 @@ mod tests { #[test] fn missing_url_is_an_error() { let error = resolve(None, None, None, Some("token"), None).unwrap_err(); - assert!(error.to_string().contains("WEBMUX_URL")); + assert!(error.to_string().contains("OFFDESK_URL")); } #[test] fn missing_token_points_at_web_ui_token() { let error = resolve(Some("https://hub"), None, None, None, None).unwrap_err(); let message = error.to_string(); - assert!(message.contains("WEBMUX_TOKEN")); + assert!(message.contains("OFFDESK_TOKEN")); assert!(message.contains("API token")); } diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 0ba2165d..d0431346 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -34,18 +34,18 @@ impl CliError { #[derive(Parser)] #[command( - name = "webmux", + name = "offdesk", version, - about = "webmux CLI — remote `tmux send-keys` + `capture-pane` through the hub" + about = "offdesk CLI — remote `tmux send-keys` + `capture-pane` through the hub" )] struct Cli { /// Verbose debug logging to stderr #[arg(short, long, global = true)] verbose: bool, - /// Hub URL (or WEBMUX_URL / url in ~/.config/webmux/config.toml) + /// Hub URL (or OFFDESK_URL / url in ~/.config/offdesk/config.toml) #[arg(long, global = true)] url: Option, - /// API token (or WEBMUX_TOKEN / token in ~/.config/webmux/config.toml) + /// API token (or OFFDESK_TOKEN / token in ~/.config/offdesk/config.toml) #[arg(long, global = true)] token: Option, #[command(subcommand)] @@ -206,10 +206,21 @@ async fn main() { std::process::exit(code); } +/// Read `name`, falling back to the pre-rename `legacy` variable with a +/// deprecation notice on stderr. Dropped once nobody is on webmux. +fn env_with_legacy(name: &str, legacy: &str) -> Option { + if let Ok(value) = std::env::var(name) { + return Some(value); + } + let value = std::env::var(legacy).ok()?; + eprintln!("warning: {legacy} is deprecated, use {name}"); + Some(value) +} + async fn run(cli: Cli) -> Result<(), CliError> { let file = config::load_config_file()?; - let env_url = std::env::var("WEBMUX_URL").ok(); - let env_token = std::env::var("WEBMUX_TOKEN").ok(); + let env_url = env_with_legacy("OFFDESK_URL", "WEBMUX_URL"); + let env_token = env_with_legacy("OFFDESK_TOKEN", "WEBMUX_TOKEN"); let resolved = config::resolve( cli.url.as_deref(), cli.token.as_deref(), diff --git a/crates/hub/Cargo.toml b/crates/hub/Cargo.toml index dc8c0581..a5417976 100644 --- a/crates/hub/Cargo.toml +++ b/crates/hub/Cargo.toml @@ -1,14 +1,14 @@ [package] -name = "tc-hub" +name = "offdesk-hub" version = "0.1.0" edition = "2021" [[bin]] -name = "webmux-server" +name = "offdesk-hub" path = "src/main.rs" [dependencies] -tc-protocol = { path = "../protocol" } +offdesk-protocol = { path = "../protocol" } tokio = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/hub/src/auth.rs b/crates/hub/src/auth.rs index a0460694..5230b977 100644 --- a/crates/hub/src/auth.rs +++ b/crates/hub/src/auth.rs @@ -144,14 +144,20 @@ pub fn hash_token(token: &str) -> String { // Unified bearer token verification (JWT or API token) // --------------------------------------------------------------------------- -/// Verify a bearer token — either an API token (wmx_ prefix) or a JWT. +/// Prefix on every API token this hub mints. +pub const API_TOKEN_PREFIX: &str = "odk_"; +/// Prefix webmux used. Tokens issued before the rename still authenticate; +/// the hub never mints new ones with it. +const API_TOKEN_PREFIX_LEGACY: &str = "wmx_"; + +/// Verify a bearer token — either an API token or a JWT. /// Returns the user_id on success. pub fn verify_bearer_token( token: &str, db: &db::DbPool, jwt_secret: &str, ) -> Result { - if token.starts_with("wmx_") { + if token.starts_with(API_TOKEN_PREFIX) || token.starts_with(API_TOKEN_PREFIX_LEGACY) { // API token path let token_hash = hash_token(token); let conn = db @@ -265,7 +271,7 @@ pub async fn exchange_github_code( .get("https://api.github.com/user") .header("Authorization", format!("Bearer {access_token}")) .header("Accept", "application/vnd.github+json") - .header("User-Agent", "webmux-server") + .header("User-Agent", "offdesk-hub") .send() .await .map_err(|e| AuthError::Internal(e.to_string()))? @@ -398,7 +404,7 @@ pub fn google_oauth_url(client_id: &str, base_url: &str, state: Option<&str>) -> } const MOBILE_OAUTH_STATE_PREFIX: &str = "mobile:"; -const MOBILE_AUTH_CALLBACK_URL: &str = "webmux://auth"; +const MOBILE_AUTH_CALLBACK_URL: &str = "offdesk://auth"; fn is_valid_mobile_callback(callback: &str) -> bool { callback == MOBILE_AUTH_CALLBACK_URL @@ -436,12 +442,12 @@ mod tests { use super::*; #[test] - fn mobile_oauth_state_round_trips_the_webmux_callback() { - let state = mobile_oauth_state("webmux://auth").expect("callback should be accepted"); + fn mobile_oauth_state_round_trips_the_offdesk_callback() { + let state = mobile_oauth_state("offdesk://auth").expect("callback should be accepted"); assert_eq!( mobile_callback_from_oauth_state(Some(&state)).as_deref(), - Some("webmux://auth") + Some("offdesk://auth") ); } @@ -454,20 +460,20 @@ mod tests { fn google_oauth_url_includes_state_when_present() { let url = google_oauth_url( "client id", - "https://webmux.example", - Some("mobile:webmux%3A%2F%2Fauth"), + "https://offdesk.example", + Some("mobile:offdesk%3A%2F%2Fauth"), ); - assert!(url.contains("state=mobile%3Awebmux%253A%252F%252Fauth")); + assert!(url.contains("state=mobile%3Aoffdesk%253A%252F%252Fauth")); } #[test] fn oauth_success_redirect_url_uses_mobile_callback_when_state_is_valid() { - let state = mobile_oauth_state("webmux://auth").expect("callback should be accepted"); + let state = mobile_oauth_state("offdesk://auth").expect("callback should be accepted"); assert_eq!( - oauth_success_redirect_url("https://webmux.example", "jwt.token", Some(&state)), - "webmux://auth?token=jwt.token" + oauth_success_redirect_url("https://offdesk.example", "jwt.token", Some(&state)), + "offdesk://auth?token=jwt.token" ); } } diff --git a/crates/hub/src/db/agent_sessions.rs b/crates/hub/src/db/agent_sessions.rs index dcffb874..18016510 100644 --- a/crates/hub/src/db/agent_sessions.rs +++ b/crates/hub/src/db/agent_sessions.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use rusqlite::{params, Connection}; -use tc_protocol::{AgentKind, AgentModelInfo, AgentSessionInfo, AgentSessionStatus}; +use offdesk_protocol::{AgentKind, AgentModelInfo, AgentSessionInfo, AgentSessionStatus}; use super::now_ms; use super::types::AgentSessionRow; diff --git a/crates/hub/src/db/mod.rs b/crates/hub/src/db/mod.rs index 5c288716..60362f3b 100644 --- a/crates/hub/src/db/mod.rs +++ b/crates/hub/src/db/mod.rs @@ -4,7 +4,7 @@ use r2d2::Pool; use r2d2_sqlite::SqliteConnectionManager; use rusqlite::{params, Connection}; use serde::Deserialize; -use tc_protocol::{WorkspaceLayoutNode, WorkspaceSplitDirection}; +use offdesk_protocol::{WorkspaceLayoutNode, WorkspaceSplitDirection}; pub mod agent_sessions; pub mod bookmarks; diff --git a/crates/hub/src/db/terminal_sessions.rs b/crates/hub/src/db/terminal_sessions.rs index 75781b0e..292f01b4 100644 --- a/crates/hub/src/db/terminal_sessions.rs +++ b/crates/hub/src/db/terminal_sessions.rs @@ -1,5 +1,5 @@ use rusqlite::{params, Connection}; -use tc_protocol::TerminalTitleSource; +use offdesk_protocol::TerminalTitleSource; use super::now_ms; use super::types::TerminalSessionRow; @@ -227,7 +227,7 @@ pub fn find_all_active(conn: &Connection) -> rusqlite::Result, pub workspace_group_id: Option, - /// JSON array of tc_protocol::AgentModelInfo; "[]" = no model support. + /// JSON array of offdesk_protocol::AgentModelInfo; "[]" = no model support. pub available_models: String, pub current_model_id: Option, /// Model requested at create time, applied by the machine after ready. diff --git a/crates/hub/src/db/workspace_layouts.rs b/crates/hub/src/db/workspace_layouts.rs index a3d2694a..ce017c7e 100644 --- a/crates/hub/src/db/workspace_layouts.rs +++ b/crates/hub/src/db/workspace_layouts.rs @@ -196,7 +196,7 @@ fn workspace_layout_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result, /// Latest resource stats from this machine - pub latest_stats: Option, + pub latest_stats: Option, /// Capability tokens from the machine's Register (e.g. deflate-raw-v1). pub capabilities: Vec, } @@ -1490,7 +1490,7 @@ impl MachineManager { if let Err(e) = crate::db::agent_sessions::set_status( &db_conn, &session_id, - tc_protocol::AgentSessionStatus::Disconnected, + offdesk_protocol::AgentSessionStatus::Disconnected, ) { tracing::warn!("Failed to mark agent session disconnected: {}", e); return; @@ -1736,7 +1736,7 @@ impl MachineManager { .unwrap_or_default() } - pub async fn get_machine_stats(&self, machine_id: &str) -> Option { + pub async fn get_machine_stats(&self, machine_id: &str) -> Option { self.machines .lock() .await @@ -2082,12 +2082,12 @@ mod tests { } } - fn stats() -> tc_protocol::ResourceStats { - tc_protocol::ResourceStats { + fn stats() -> offdesk_protocol::ResourceStats { + offdesk_protocol::ResourceStats { cpu_percent: 12.5, memory_total: 1024, memory_used: 512, - disks: vec![tc_protocol::DiskInfo { + disks: vec![offdesk_protocol::DiskInfo { mount_point: "/".to_string(), total_bytes: 2048, used_bytes: 1024, diff --git a/crates/hub/src/main.rs b/crates/hub/src/main.rs index 2d4370cc..d60b3e17 100644 --- a/crates/hub/src/main.rs +++ b/crates/hub/src/main.rs @@ -21,18 +21,18 @@ use crate::db::DbPool; use crate::machine_manager::MachineManager; #[derive(Parser)] -#[command(name = "webmux-server", about = "webmux hub server")] +#[command(name = "offdesk-hub", about = "offdesk hub server")] struct Args { /// Listen address #[arg(long, default_value = "0.0.0.0:4317")] listen: String, /// Path to frontend static files - #[arg(long, default_value = "packages/app/dist", env = "WEBMUX_STATIC_DIR")] + #[arg(long, default_value = "packages/app/dist", env = "OFFDESK_STATIC_DIR")] static_dir: String, /// Path to SQLite database file - #[arg(long, default_value = "./webmux.db", env = "DATABASE_PATH")] + #[arg(long, default_value = "./offdesk.db", env = "DATABASE_PATH")] database: String, } @@ -50,6 +50,23 @@ pub struct AppState { pub google_client_secret: Option, } +/// Pre-rename environment variables still work. Promote each one into its +/// offdesk name before anything reads the environment, so both clap's +/// `env =` attributes and `env_or` below see it. Dropped once nobody is on +/// webmux. +fn promote_legacy_env() { + for suffix in ["STATIC_DIR", "BASE_URL", "DEV_MODE"] { + let new = format!("OFFDESK_{suffix}"); + let old = format!("WEBMUX_{suffix}"); + if std::env::var_os(&new).is_none() { + if let Some(value) = std::env::var_os(&old) { + eprintln!("warning: {old} is deprecated, use {new}"); + std::env::set_var(&new, value); + } + } + } +} + fn env_or(key: &str, default: &str) -> String { std::env::var(key).unwrap_or_else(|_| default.to_string()) } @@ -62,6 +79,7 @@ fn env_opt(key: &str) -> Option { async fn main() { tracing_subscriber::fmt::init(); + promote_legacy_env(); let args = Args::parse(); // Initialize database @@ -77,8 +95,8 @@ async fn main() { router: Arc::new(HubRouter::new()), db: pool, jwt_secret: env_or("JWT_SECRET", "dev-secret-change-me"), - base_url: env_or("WEBMUX_BASE_URL", "http://localhost:4317"), - dev_mode: env_or("WEBMUX_DEV_MODE", "false") == "true", + base_url: env_or("OFFDESK_BASE_URL", "http://localhost:4317"), + dev_mode: env_or("OFFDESK_DEV_MODE", "false") == "true", github_client_id: env_opt("GITHUB_CLIENT_ID"), github_client_secret: env_opt("GITHUB_CLIENT_SECRET"), google_client_id: env_opt("GOOGLE_CLIENT_ID"), @@ -181,7 +199,7 @@ mod tests { .duration_since(UNIX_EPOCH) .expect("system time should be after Unix epoch") .as_nanos(); - let dir = std::env::temp_dir().join(format!("webmux-hub-static-{unique}")); + let dir = std::env::temp_dir().join(format!("offdesk-hub-static-{unique}")); fs::create_dir_all(&dir).expect("static fixture directory should be created"); fs::write(dir.join("index.html"), "app shell") .expect("index fixture should be written"); diff --git a/crates/hub/src/routes/agent_sessions.rs b/crates/hub/src/routes/agent_sessions.rs index 4259acda..0108177c 100644 --- a/crates/hub/src/routes/agent_sessions.rs +++ b/crates/hub/src/routes/agent_sessions.rs @@ -6,7 +6,7 @@ use axum::{ Router, }; use serde::{Deserialize, Serialize}; -use tc_protocol::{AgentKind, AgentSessionInfo, AgentSessionStatus, HubToMachine, MachineInfo}; +use offdesk_protocol::{AgentKind, AgentSessionInfo, AgentSessionStatus, HubToMachine, MachineInfo}; use crate::auth::AuthUser; use crate::db::agent_sessions::{self, row_to_info}; @@ -587,7 +587,7 @@ mod tests { use r2d2::Pool; use r2d2_sqlite::SqliteConnectionManager; use serde_json::{json, Value}; - use tc_protocol::{AgentEvent, HubToMachine, MachineInfo, MachineToHub}; + use offdesk_protocol::{AgentEvent, HubToMachine, MachineInfo, MachineToHub}; use tokio::sync::mpsc; use tower::ServiceExt; @@ -738,7 +738,7 @@ mod tests { model_id, } => { assert_eq!(cmd_session_id, session_id); - assert_eq!(agent_kind, tc_protocol::AgentKind::Kimi); + assert_eq!(agent_kind, offdesk_protocol::AgentKind::Kimi); assert_eq!(cwd, "/work/repo"); assert!(auto_run); assert_eq!(resume_acp_session_id, None); @@ -753,7 +753,7 @@ mod tests { .unwrap(); assert!(matches!( broadcast.event, - tc_protocol::BrowserEvent::AgentSessionCreated { ref session } + offdesk_protocol::BrowserEvent::AgentSessionCreated { ref session } if session.id == session_id )); @@ -806,7 +806,7 @@ mod tests { .unwrap(); assert!(matches!( broadcast.event, - tc_protocol::BrowserEvent::MachineRemoved { ref machine_id } + offdesk_protocol::BrowserEvent::MachineRemoved { ref machine_id } if machine_id == "machine-a" )); @@ -1194,16 +1194,16 @@ mod tests { "machine-a", MachineToHub::AgentSessionUpdate { session_id: session_id.clone(), - status: Some(tc_protocol::AgentSessionStatus::Idle), + status: Some(offdesk_protocol::AgentSessionStatus::Idle), title: None, acp_session_id: Some("acp-1".to_string()), available_models: Some(vec![ - tc_protocol::AgentModelInfo { + offdesk_protocol::AgentModelInfo { model_id: "fake-model-a".to_string(), name: "Fake Model A".to_string(), description: None, }, - tc_protocol::AgentModelInfo { + offdesk_protocol::AgentModelInfo { model_id: "fake-model-b".to_string(), name: "Fake Model B".to_string(), description: Some("the other one".to_string()), @@ -1247,6 +1247,6 @@ mod tests { .unwrap(); assert_eq!(session.current_model_id.as_deref(), Some("fake-model-b")); assert_eq!(session.available_models.len(), 2, "list untouched by None"); - assert_eq!(session.status, tc_protocol::AgentSessionStatus::Idle); + assert_eq!(session.status, offdesk_protocol::AgentSessionStatus::Idle); } } diff --git a/crates/hub/src/routes/api_tokens.rs b/crates/hub/src/routes/api_tokens.rs index ebf61c3e..a4af039b 100644 --- a/crates/hub/src/routes/api_tokens.rs +++ b/crates/hub/src/routes/api_tokens.rs @@ -70,9 +70,10 @@ async fn create_token( auth_user: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { - // Generate wmx_ + 2 UUIDs + // Generate odk_ + 2 UUIDs let raw_token = format!( - "wmx_{}{}", + "{}{}{}", + auth::API_TOKEN_PREFIX, uuid::Uuid::new_v4().as_simple(), uuid::Uuid::new_v4().as_simple() ); diff --git a/crates/hub/src/routes/bootstrap.rs b/crates/hub/src/routes/bootstrap.rs index db48df91..5365ea00 100644 --- a/crates/hub/src/routes/bootstrap.rs +++ b/crates/hub/src/routes/bootstrap.rs @@ -1,5 +1,5 @@ use axum::{extract::State, response::Json, routing::get, Router}; -use tc_protocol::BrowserStateSnapshot; +use offdesk_protocol::BrowserStateSnapshot; use crate::{auth::AuthUser, AppState}; diff --git a/crates/hub/src/routes/terminals.rs b/crates/hub/src/routes/terminals.rs index 1f0a42d1..fff870ce 100644 --- a/crates/hub/src/routes/terminals.rs +++ b/crates/hub/src/routes/terminals.rs @@ -7,7 +7,7 @@ use axum::{ }; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; -use tc_protocol::{ +use offdesk_protocol::{ DirEntry, MachineInfo, TerminalInfo, WorkspaceGroupInfo, WorkspaceLayoutInfo, WorkspaceLayoutNode, }; @@ -70,7 +70,7 @@ fn default_rows() -> u16 { /// MAX_PANES_PER_TAB in packages/shared/src/contracts.ts. Only workspace /// groups are capped here — the cwd fallback tabs the clients derive from /// ungrouped terminals are not a hub concept, and capping them would break -/// `webmux open` on a busy directory. +/// `offdesk open` on a busy directory. const MAX_PANES_PER_TAB: usize = 4; /// Terminals already assigned to `group_id`, ignoring `exclude_terminal_id` @@ -1061,7 +1061,7 @@ mod tests { use r2d2::Pool; use r2d2_sqlite::SqliteConnectionManager; use serde_json::{json, Value}; - use tc_protocol::{HubToMachine, MachineInfo, MachineToHub, TerminalInfo, WorkspaceLayoutNode}; + use offdesk_protocol::{HubToMachine, MachineInfo, MachineToHub, TerminalInfo, WorkspaceLayoutNode}; use tower::ServiceExt; use super::{ diff --git a/crates/hub/src/ws.rs b/crates/hub/src/ws.rs index 2ba0b6b1..b8bbefbf 100644 --- a/crates/hub/src/ws.rs +++ b/crates/hub/src/ws.rs @@ -12,7 +12,7 @@ use futures::{SinkExt, StreamExt}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::time::Duration; -use tc_protocol::{ +use offdesk_protocol::{ decode_attach_output_frame, encode_terminal_preview_output_frame, BrowserEventEnvelope, BrowserEventsClientMessage, BrowserEventsPong, HubToMachine, MachineToHub, }; @@ -166,7 +166,7 @@ async fn terminal_ws_handler( let device_id = params.get("device_id").cloned().unwrap_or_default(); let compress_requested = params.get("compress").map(String::as_str) - == Some(tc_protocol::compression::DEFLATE_RAW_V1); + == Some(offdesk_protocol::compression::DEFLATE_RAW_V1); ws.on_upgrade(move |socket| { handle_terminal_ws( socket, @@ -219,11 +219,11 @@ async fn handle_terminal_ws( let compress = compress_requested && state .manager - .machine_supports(&machine_id, tc_protocol::compression::DEFLATE_RAW_V1) + .machine_supports(&machine_id, offdesk_protocol::compression::DEFLATE_RAW_V1) .await; if compress { let ack = serde_json::to_string(&ServerMessage::CompressionEnabled { - algo: tc_protocol::compression::DEFLATE_RAW_V1.to_string(), + algo: offdesk_protocol::compression::DEFLATE_RAW_V1.to_string(), }) .unwrap(); if sender.send(Message::Text(ack.into())).await.is_err() { @@ -619,7 +619,7 @@ async fn handle_machine_ws(socket: WebSocket, state: AppState) { .unwrap_or(false); } - let info = tc_protocol::MachineInfo { + let info = offdesk_protocol::MachineInfo { id: machine_id.clone(), name, os, @@ -632,7 +632,7 @@ async fn handle_machine_ws(socket: WebSocket, state: AppState) { // // Security: a compressed stream that mixes secrets with // attacker-influenced bytes is a CRIME-class oracle. - // webmux sessions are single-tenant per user today, so + // offdesk sessions are single-tenant per user today, so // the practical risk is low — but if a shared-session / // multi-tenant mode ever appears, compression MUST be // disabled for it (never ack CompressionEnabled there). diff --git a/crates/machine/Cargo.toml b/crates/machine/Cargo.toml index ab85798b..fb44188b 100644 --- a/crates/machine/Cargo.toml +++ b/crates/machine/Cargo.toml @@ -1,14 +1,14 @@ [package] -name = "tc-machine" +name = "offdesk-machine" version = "0.1.0" edition = "2021" [[bin]] -name = "webmux-node" +name = "offdesk-node" path = "src/main.rs" [dependencies] -tc-protocol = { path = "../protocol" } +offdesk-protocol = { path = "../protocol" } tokio = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/machine/src/acp.rs b/crates/machine/src/acp.rs index a2adb35e..b29be0f6 100644 --- a/crates/machine/src/acp.rs +++ b/crates/machine/src/acp.rs @@ -12,7 +12,7 @@ use std::sync::Arc; use std::time::Duration; use serde_json::{json, Value}; -use tc_protocol::{ +use offdesk_protocol::{ AgentEvent, AgentKind, AgentModelInfo, AgentQuestionOption, AgentSessionStatus, MachineToHub, }; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; diff --git a/crates/machine/src/config.rs b/crates/machine/src/config.rs index 562668a0..4d448a61 100644 --- a/crates/machine/src/config.rs +++ b/crates/machine/src/config.rs @@ -14,12 +14,11 @@ pub struct MachineConfig { pub acp_agents: HashMap>, } -/// Get the config file path: ~/.config/webmux/machine.json +/// Get the config file path, e.g. `~/.config/offdesk/machine.json` on +/// Linux. See `offdesk_protocol::config_dir` for the macOS location and +/// the one-time move from the old `webmux` directory. pub fn config_path() -> PathBuf { - let config_dir = dirs::config_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join("webmux"); - config_dir.join("machine.json") + offdesk_protocol::config_dir().join("machine.json") } pub fn load_config() -> Result { diff --git a/crates/machine/src/hub_conn.rs b/crates/machine/src/hub_conn.rs index e1ba8751..cfc4e59f 100644 --- a/crates/machine/src/hub_conn.rs +++ b/crates/machine/src/hub_conn.rs @@ -2,7 +2,7 @@ use bytes::Bytes; use futures::{SinkExt, StreamExt}; use std::sync::Arc; use std::time::Duration; -use tc_protocol::{ +use offdesk_protocol::{ compression::{AttachCompressor, DEFLATE_RAW_V1}, encode_attach_output_frame, DirEntry, HubToMachine, MachineToHub, TerminalTitleSource, }; @@ -226,9 +226,9 @@ impl HubConnection { // signal to destroy persisted terminals that no longer exist (e.g. // after a reboot killed every tmux session). let existing = pty.list_terminals(); - let terminals: Vec = existing + let terminals: Vec = existing .iter() - .map(|s| tc_protocol::TerminalInfo { + .map(|s| offdesk_protocol::TerminalInfo { id: s.id.clone(), machine_id: self.machine_id.clone(), title: s.title.clone(), @@ -901,16 +901,16 @@ mod tests { #[test] fn image_paste_returns_bracketed_path_for_single_attach_write() { - let filename = format!("webmux-image-paste-test-{}.png", std::process::id()); + let filename = format!("offdesk-image-paste-test-{}.png", std::process::id()); let path = std::env::temp_dir().join(&filename); let _ = std::fs::remove_file(&path); - let paste = handle_image_paste("d2VibXV4", "image/png", &filename) + let paste = handle_image_paste("b2ZmZGVzaw==", "image/png", &filename) .expect("image paste should be prepared"); assert_eq!( std::fs::read(&path).expect("image file should exist"), - b"webmux" + b"offdesk" ); assert_eq!( paste, diff --git a/crates/machine/src/main.rs b/crates/machine/src/main.rs index 1908d0fc..ace294a2 100644 --- a/crates/machine/src/main.rs +++ b/crates/machine/src/main.rs @@ -13,7 +13,7 @@ use std::sync::Arc; use clap::{Parser, Subcommand}; #[derive(Parser)] -#[command(name = "webmux-node", about = "webmux node daemon")] +#[command(name = "offdesk-node", about = "offdesk node daemon")] struct Args { #[command(subcommand)] command: Option, @@ -21,7 +21,7 @@ struct Args { #[derive(Subcommand)] enum Command { - /// Register this machine with a webmux-server instance + /// Register this machine with an offdesk hub Register { /// Hub base URL (e.g. http://localhost:3000) #[arg(long)] @@ -209,8 +209,8 @@ async fn run_register(hub_url: String, token: String, name: Option) { println!(" Machine ID: {}", register_resp.machine_id); println!(" Config saved to: {}", config_path.display()); println!(); - println!("Start the daemon with: webmux-node start"); - println!("Install as service: webmux-node service install"); + println!("Start the daemon with: offdesk-node start"); + println!("Install as service: offdesk-node service install"); } /// Convert any hub URL to its WebSocket machine endpoint. @@ -242,7 +242,7 @@ async fn run_start(hub_url: Option, name: Option, id: Option, name: Option, id: Option --token "); + eprintln!(" offdesk-node register --hub-url --token "); eprintln!(); - eprintln!("Or run in dev mode with: webmux-node start --id "); + eprintln!("Or run in dev mode with: offdesk-node start --id "); std::process::exit(1); }; @@ -279,7 +279,7 @@ async fn run_start(hub_url: Option, name: Option, id: Option c, Err(_) => { - eprintln!("Not registered. Run \"webmux-node register\" first."); + eprintln!("Not registered. Run \"offdesk-node register\" first."); std::process::exit(1); } }; @@ -364,13 +364,13 @@ fn cmd_service_install(no_auto_upgrade: bool) { println!(); println!("Useful commands:"); if cfg!(target_os = "macos") { - println!(" launchctl list com.webmux.node"); - println!(" tail -f ~/Library/Logs/webmux/stderr.log"); + println!(" launchctl list dev.offdesk.node"); + println!(" tail -f ~/Library/Logs/offdesk/stderr.log"); } else { println!(" systemctl --user status {}", service::SERVICE_NAME); println!(" journalctl --user -u {} -f", service::SERVICE_NAME); } - println!(" webmux-node service uninstall"); + println!(" offdesk-node service uninstall"); } Err(e) => { let home = dirs::home_dir() diff --git a/crates/machine/src/pty.rs b/crates/machine/src/pty.rs index 2d097c96..b9be7010 100644 --- a/crates/machine/src/pty.rs +++ b/crates/machine/src/pty.rs @@ -4,8 +4,65 @@ use std::io::{Read, Write}; use std::path::PathBuf; use std::sync::{Arc, Mutex, OnceLock}; -pub const TMUX_SOCKET: &str = "webmux"; -const TMUX_PREFIX: &str = "wmx_"; +/// Socket and session-name prefix this build creates terminals under. +const TMUX_SOCKET_CURRENT: &str = "offdesk"; +const TMUX_PREFIX_CURRENT: &str = "odk_"; + +/// What webmux used before the rename. A node upgrading in place would +/// otherwise stop seeing terminals that are still running, so if the old +/// tmux server still holds sessions we keep talking to it until they are +/// all gone. tmux cannot move a session between servers, so adopting the +/// old socket is the only way to not lose them. +const TMUX_SOCKET_LEGACY: &str = "webmux"; +const TMUX_PREFIX_LEGACY: &str = "wmx_"; + +static TMUX_NAMING: OnceLock<(&'static str, &'static str)> = OnceLock::new(); + +/// Probe both sockets and pin the naming for the rest of the process. +/// Called once from `PtyManager::new()`. Deliberately explicit rather than +/// lazy: a lazy probe would make every caller — unit tests included — +/// depend on whatever tmux servers happen to be running on the box. +fn resolve_tmux_naming() { + let current = (TMUX_SOCKET_CURRENT, TMUX_PREFIX_CURRENT); + let resolved = if sessions_on_socket(TMUX_SOCKET_CURRENT) + .iter() + .any(|name| name.starts_with(TMUX_PREFIX_CURRENT)) + { + current + } else if sessions_on_socket(TMUX_SOCKET_LEGACY) + .iter() + .any(|name| name.starts_with(TMUX_PREFIX_LEGACY)) + { + tracing::warn!( + "using the legacy tmux socket '{}': it still has terminals running \ + from before the offdesk rename, and tmux cannot move a session \ + between servers. New terminals join it too. Close them all and \ + restart offdesk-node to move to '{}'.", + TMUX_SOCKET_LEGACY, + TMUX_SOCKET_CURRENT + ); + (TMUX_SOCKET_LEGACY, TMUX_PREFIX_LEGACY) + } else { + current + }; + let _ = TMUX_NAMING.set(resolved); +} + +/// Socket and prefix in force. Before `resolve_tmux_naming()` runs — which +/// is every unit test — this is the current pair. +fn tmux_naming() -> (&'static str, &'static str) { + *TMUX_NAMING + .get() + .unwrap_or(&(TMUX_SOCKET_CURRENT, TMUX_PREFIX_CURRENT)) +} + +pub fn tmux_socket() -> &'static str { + tmux_naming().0 +} + +fn tmux_prefix() -> &'static str { + tmux_naming().1 +} #[derive(Debug, Clone)] pub struct SessionInfo { @@ -57,15 +114,16 @@ pub struct PtyManager { impl PtyManager { /// Construct a new PtyManager. Panics if tmux is not available — see - /// `webmux-node start` for the user-facing check that fails fast on + /// `offdesk-node start` for the user-facing check that fails fast on /// missing tmux. tmux is mandatory in this build. pub fn new() -> Self { if !check_tmux_available() { panic!( - "tmux not found in PATH. webmux-node requires tmux. \ + "tmux not found in PATH. offdesk-node requires tmux. \ Install tmux via your package manager and try again." ); } + resolve_tmux_naming(); ensure_tmux_config(); Self { sessions: Arc::new(Mutex::new(HashMap::new())), @@ -89,7 +147,7 @@ impl PtyManager { tmux_args.extend( [ "-L", - TMUX_SOCKET, + tmux_socket(), "new-session", "-d", "-s", @@ -132,7 +190,7 @@ impl PtyManager { let _ = tmux_cmd() .args([ "-L", - TMUX_SOCKET, + tmux_socket(), "set-option", "-t", &tmux_name, @@ -147,7 +205,7 @@ impl PtyManager { let _ = tmux_cmd() .args([ "-L", - TMUX_SOCKET, + tmux_socket(), "set-environment", "-t", &tmux_name, @@ -206,7 +264,7 @@ impl PtyManager { if let Some(first) = parts.next() { if !first.is_empty() { let status = tmux_cmd() - .args(["-L", TMUX_SOCKET, "send-keys", "-l", "-t", &name, first]) + .args(["-L", tmux_socket(), "send-keys", "-l", "-t", &name, first]) .status() .map_err(|e| format!("Failed to run tmux send-keys: {}", e))?; if !status.success() { @@ -217,11 +275,11 @@ impl PtyManager { for chunk in parts { // Each split boundary represents one '\r' — press Enter. let _ = tmux_cmd() - .args(["-L", TMUX_SOCKET, "send-keys", "-t", &name, "C-m"]) + .args(["-L", tmux_socket(), "send-keys", "-t", &name, "C-m"]) .status(); if !chunk.is_empty() { let _ = tmux_cmd() - .args(["-L", TMUX_SOCKET, "send-keys", "-l", "-t", &name, chunk]) + .args(["-L", tmux_socket(), "send-keys", "-l", "-t", &name, chunk]) .status(); } } @@ -244,7 +302,7 @@ impl PtyManager { let output = tmux_cmd() .args([ "-L", - TMUX_SOCKET, + tmux_socket(), "list-panes", "-t", &tmux_name, @@ -282,7 +340,7 @@ impl PtyManager { let output = tmux_cmd() .args([ "-L", - TMUX_SOCKET, + tmux_socket(), "list-panes", "-a", "-F", @@ -306,7 +364,7 @@ impl PtyManager { .unwrap_or_default() } - /// Recover existing tmux sessions from a previous webmux-node run. + /// Recover existing tmux sessions from a previous offdesk-node run. /// Returns recovered SessionInfo list for reporting to the hub. The /// per-attach byte streams are established on-demand when browsers /// connect, so there is nothing more to wire up here than the metadata. @@ -407,8 +465,8 @@ fn set_term_env(cmd: &mut std::process::Command) { /// Build the command that runs `tmux new-session`. On systemd machines /// the tmux server is auto-spawned by this call, so it would land inside -/// webmux-node.service's cgroup; distro tmux builds then stamp every -/// pane's transient scope with `PartOf=webmux-node.service`, and a node +/// offdesk-node.service's cgroup; distro tmux builds then stamp every +/// pane's transient scope with `PartOf=offdesk-node.service`, and a node /// stop/restart cascades SIGTERM to every pane — killing all sessions. /// Wrapping the spawn in `systemd-run --user --scope` births the server /// in its own scope, so the `PartOf` chain points there instead and node @@ -458,22 +516,20 @@ fn systemd_scope_available() -> bool { }) } -fn webmux_dir() -> PathBuf { - dirs::config_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join("webmux") +fn offdesk_dir() -> PathBuf { + offdesk_protocol::config_dir() } fn tmux_config_path() -> PathBuf { - webmux_dir().join("tmux.conf") + offdesk_dir().join("tmux.conf") } fn user_tmux_config_path() -> PathBuf { - webmux_dir().join("tmux.user.conf") + offdesk_dir().join("tmux.user.conf") } fn osc52_script_path() -> PathBuf { - webmux_dir().join("osc52copy.sh") + offdesk_dir().join("osc52copy.sh") } /// Build the tmux config string (extracted for testability). @@ -537,7 +593,7 @@ const OSC52_SCRIPT: &str = /// Write a minimal tmux config and the OSC 52 helper script. fn ensure_tmux_config() { - let dir = webmux_dir(); + let dir = offdesk_dir(); let _ = std::fs::create_dir_all(&dir); let script_path = osc52_script_path(); @@ -561,7 +617,7 @@ fn ensure_tmux_config() { let _ = tmux_cmd() .args([ "-L", - TMUX_SOCKET, + tmux_socket(), "source-file", config_path.to_str().unwrap_or(""), ]) @@ -577,7 +633,7 @@ pub fn check_tmux_available() -> bool { } pub fn tmux_session_name(id: &str) -> String { - format!("{}{}", TMUX_PREFIX, id) + format!("{}{}", tmux_prefix(), id) } /// Parse `tmux list-panes -a -F @@ -595,7 +651,7 @@ fn parse_pane_info(output: &str, hostname: &str) -> HashMap { let Some(session_name) = parts.next() else { continue; }; - let Some(terminal_id) = session_name.strip_prefix(TMUX_PREFIX) else { + let Some(terminal_id) = session_name.strip_prefix(tmux_prefix()) else { continue; }; let title = parts @@ -643,14 +699,11 @@ fn current_hostname() -> String { } fn clear_pane_title_args(tmux_name: &str) -> [&str; 7] { - ["-L", TMUX_SOCKET, "select-pane", "-t", tmux_name, "-T", ""] + ["-L", tmux_socket(), "select-pane", "-t", tmux_name, "-T", ""] } fn sessions_file_path() -> PathBuf { - dirs::config_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join("webmux") - .join("sessions.json") + offdesk_dir().join("sessions.json") } fn load_sessions_file() -> HashMap { @@ -662,8 +715,14 @@ fn load_sessions_file() -> HashMap { } pub fn tmux_list_sessions() -> Vec { + sessions_on_socket(tmux_socket()) +} + +/// List sessions on a named socket without consulting `tmux_naming()` — +/// this is what `tmux_naming()` itself uses to probe both sockets. +fn sessions_on_socket(socket: &str) -> Vec { tmux_cmd() - .args(["-L", TMUX_SOCKET, "list-sessions", "-F", "#{session_name}"]) + .args(["-L", socket, "list-sessions", "-F", "#{session_name}"]) .output() .ok() .and_then(|o| { @@ -684,7 +743,7 @@ pub fn tmux_list_sessions() -> Vec { fn tmux_kill_session(id: &str) { let name = tmux_session_name(id); let _ = tmux_cmd() - .args(["-L", TMUX_SOCKET, "kill-session", "-t", &name]) + .args(["-L", tmux_socket(), "kill-session", "-t", &name]) .status(); } @@ -708,7 +767,7 @@ pub fn spawn_tmux_attach(session_id: &str, cols: u16, rows: u16) -> Result = [ "-L", - TMUX_SOCKET, + tmux_socket(), "new-session", "-d", "-s", - "wmx_terminal-a", + "odk_terminal-a", "-x", "80", "-y", @@ -1123,7 +1182,7 @@ mod tests { #[test] fn new_session_cmd_falls_back_to_direct_tmux_spawn() { - let tmux_args: Vec = ["-L", TMUX_SOCKET, "new-session", "-d", "-s", "wmx_terminal-a"] + let tmux_args: Vec = ["-L", tmux_socket(), "new-session", "-d", "-s", "odk_terminal-a"] .into_iter() .map(String::from) .collect(); @@ -1139,13 +1198,13 @@ mod tests { #[test] fn clear_pane_title_targets_the_new_sessions_active_pane() { assert_eq!( - clear_pane_title_args("wmx_terminal-a"), + clear_pane_title_args("odk_terminal-a"), [ "-L", - TMUX_SOCKET, + tmux_socket(), "select-pane", "-t", - "wmx_terminal-a", + "odk_terminal-a", "-T", "" ] diff --git a/crates/machine/src/service.rs b/crates/machine/src/service.rs index 03155bcc..54d5a64b 100644 --- a/crates/machine/src/service.rs +++ b/crates/machine/src/service.rs @@ -2,7 +2,7 @@ use std::fs; use std::path::PathBuf; use std::process::Command; -pub const SERVICE_NAME: &str = "webmux-node"; +pub const SERVICE_NAME: &str = "offdesk-node"; // ── Shared helpers ───────────────────────────────────────────────── @@ -36,7 +36,7 @@ mod platform { fn render_service_unit(name: &str, home_dir: &str, exe_path: &str, path_env: &str) -> String { format!( r#"[Unit] -Description=Webmux Node ({name}) +Description=offdesk Node ({name}) After=network-online.target Wants=network-online.target @@ -137,7 +137,7 @@ WantedBy=default.target if !PathBuf::from(&unit_path).exists() { return Err( - "service is not installed. Run \"webmux-node service install\" first.".to_string(), + "service is not installed. Run \"offdesk-node service install\" first.".to_string(), ); } @@ -170,10 +170,10 @@ WantedBy=default.target mod platform { use super::*; - const LABEL: &str = "com.webmux.node"; + const LABEL: &str = "dev.offdesk.node"; fn render_plist(home_dir: &str, exe_path: &str, path_env: &str) -> String { - let log_dir = format!("{home_dir}/Library/Logs/webmux"); + let log_dir = format!("{home_dir}/Library/Logs/offdesk"); format!( r#" @@ -239,7 +239,7 @@ mod platform { let log_dir = PathBuf::from(&home_str) .join("Library") .join("Logs") - .join("webmux"); + .join("offdesk"); fs::create_dir_all(&log_dir).map_err(|e| format!("failed to create log directory: {e}"))?; let plist_file = plist_path(&home_str); @@ -297,7 +297,7 @@ mod platform { if !plist_file.exists() { return Err( - "service is not installed. Run \"webmux-node service install\" first.".to_string(), + "service is not installed. Run \"offdesk-node service install\" first.".to_string(), ); } diff --git a/crates/machine/src/stats.rs b/crates/machine/src/stats.rs index 51b08819..62f67790 100644 --- a/crates/machine/src/stats.rs +++ b/crates/machine/src/stats.rs @@ -1,5 +1,5 @@ use sysinfo::{CpuRefreshKind, Disks, MemoryRefreshKind, RefreshKind, System}; -use tc_protocol::{DiskInfo, ResourceStats}; +use offdesk_protocol::{DiskInfo, ResourceStats}; pub const MAX_SILENT_STATS_INTERVALS: u8 = 6; @@ -102,7 +102,7 @@ fn stats_changed_enough(previous: &ResourceStats, next: &ResourceStats) -> bool #[cfg(test)] mod tests { use super::{should_emit_stats, MAX_SILENT_STATS_INTERVALS}; - use tc_protocol::{DiskInfo, ResourceStats}; + use offdesk_protocol::{DiskInfo, ResourceStats}; fn stats(cpu_percent: f32, memory_used: u64, disk_used: u64) -> ResourceStats { ResourceStats { diff --git a/crates/protocol/Cargo.toml b/crates/protocol/Cargo.toml index 31829879..0e5a9de6 100644 --- a/crates/protocol/Cargo.toml +++ b/crates/protocol/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "tc-protocol" +name = "offdesk-protocol" version = "0.1.0" edition = "2021" @@ -8,3 +8,4 @@ serde = { workspace = true } serde_json = { workspace = true } bytes.workspace = true flate2 = { workspace = true } +dirs = { workspace = true } diff --git a/crates/protocol/src/compression.rs b/crates/protocol/src/compression.rs index b1754934..c069ede8 100644 --- a/crates/protocol/src/compression.rs +++ b/crates/protocol/src/compression.rs @@ -126,7 +126,7 @@ mod tests { ); } for _ in 0..4 { - messages.push(b"\x1b[2K\rbuilding crate webmux ... 128/256\r\n".to_vec()); + messages.push(b"\x1b[2K\rbuilding crate offdesk ... 128/256\r\n".to_vec()); } // Full redraw: home + clear + the same screen contents again. let mut redraw = b"\x1b[H\x1b[2J".to_vec(); diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 73e0152d..42a7d386 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -998,3 +998,40 @@ mod tests { )); } } + +// --------------------------------------------------------------------------- +// Config directory +// --------------------------------------------------------------------------- + +/// Directory holding every on-disk file offdesk owns: the CLI's +/// `config.toml`, the node's `machine.json` and `sessions.json`, and the +/// generated tmux config. On Linux this is `~/.config/offdesk`; on macOS +/// `dirs::config_dir()` resolves to `~/Library/Application Support`. +/// +/// The first call after an upgrade from webmux moves the old `webmux` +/// directory into place, so a rename does not orphan a registered machine. +/// The move only happens when the new directory does not exist yet, so it +/// can never clobber a fresh config. +pub fn config_dir() -> std::path::PathBuf { + let base = dirs::config_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); + let dir = base.join("offdesk"); + static MIGRATED: std::sync::OnceLock<()> = std::sync::OnceLock::new(); + MIGRATED.get_or_init(|| { + let legacy = base.join("webmux"); + if !dir.exists() && legacy.is_dir() { + match std::fs::rename(&legacy, &dir) { + Ok(()) => eprintln!( + "offdesk: moved {} to {} (webmux -> offdesk rename)", + legacy.display(), + dir.display() + ), + Err(error) => eprintln!( + "offdesk: could not move {} to {}: {error}", + legacy.display(), + dir.display() + ), + } + } + }); + dir +} diff --git a/docker-compose.yml b/docker-compose.yml index cf24d3d8..490cb896 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,23 +1,23 @@ services: server: - image: ghcr.io/zalify/webmux-server:main + image: ghcr.io/zalify/offdesk-hub:main build: . restart: unless-stopped ports: - "127.0.0.1:4317:4317" volumes: - - webmux-data:/app/data + - offdesk-data:/app/data environment: - DATABASE_PATH: "/app/data/tc.db" - WEBMUX_STATIC_DIR: "/app/web" + DATABASE_PATH: "/app/data/offdesk.db" + OFFDESK_STATIC_DIR: "/app/web" JWT_SECRET: "${JWT_SECRET}" GITHUB_CLIENT_ID: "${GITHUB_CLIENT_ID}" GITHUB_CLIENT_SECRET: "${GITHUB_CLIENT_SECRET}" GOOGLE_CLIENT_ID: "${GOOGLE_CLIENT_ID}" GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET}" - WEBMUX_BASE_URL: "${WEBMUX_BASE_URL}" + OFFDESK_BASE_URL: "${OFFDESK_BASE_URL}" labels: com.centurylinklabs.watchtower.enable: "true" volumes: - webmux-data: + offdesk-data: diff --git a/docs/deployment/runbook.md b/docs/deployment/runbook.md index fca6f528..bedab630 100644 --- a/docs/deployment/runbook.md +++ b/docs/deployment/runbook.md @@ -1,26 +1,26 @@ # Deployment Runbook -Operational reference for webmux (terminal-canvas). +Operational reference for offdesk. ## Environments | Environment | Host | SSH | Domains | Health URL | |-------------|------|-----|---------|------------| -| production | NAS (Synology) | `ssh chareice@nas.chareice.site -p 10220` | `webmux.nas.chareice.site` | `https://webmux.nas.chareice.site/` | +| production | NAS (Synology) | `ssh chareice@nas.chareice.site -p 10220` | `offdesk.nas.chareice.site` | `https://offdesk.nas.chareice.site/` | ## Services | Service | Image / Binary | Port | Notes | |---------|---------------|------|-------| -| webmux-server | `ghcr.io/zalify/webmux-server:main` | 4317 | Axum server + static frontend (Docker) | -| webmux-node | GitHub Release binary | — | Machine agent, systemd/launchd service on each machine | +| offdesk-hub | `ghcr.io/zalify/offdesk-hub:main` | 4317 | Axum server + static frontend (Docker) | +| offdesk-node | GitHub Release binary | — | Machine agent, systemd/launchd service on each machine | | caddy | — | 443/80 | Reverse proxy, TLS termination | ## Paths ``` NAS:/var/services/homes/chareice/projects/ -├── webmux/ +├── offdesk/ │ └── docker-compose.yml # Production compose ├── caddy/ │ └── Caddyfile # Reverse proxy config @@ -31,8 +31,8 @@ NAS:/var/services/homes/chareice/projects/ Caddy config at `/var/services/homes/chareice/projects/caddy/Caddyfile`: ``` -webmux.nas.chareice.site { - reverse_proxy webmux-server-1:4317 +offdesk.nas.chareice.site { + reverse_proxy offdesk-hub-1:4317 } ``` @@ -49,21 +49,21 @@ ssh chareice@nas.chareice.site -p 10220 "export PATH=/usr/local/bin:\$PATH; dock git push origin main → GitHub Actions (.github/workflows/container.yml) → Build Docker image (linux/amd64) - → Push to ghcr.io/zalify/webmux-server:main + → Push to ghcr.io/zalify/offdesk-hub:main → Manual pull & restart on NAS ``` ```bash -ssh chareice@nas.chareice.site -p 10220 "export PATH=/usr/local/bin:\$PATH; cd /var/services/homes/chareice/projects/webmux && docker compose pull && docker compose up -d" +ssh chareice@nas.chareice.site -p 10220 "export PATH=/usr/local/bin:\$PATH; cd /var/services/homes/chareice/projects/offdesk && docker compose pull && docker compose up -d" ``` **CI check:** ```bash -gh run list --repo zalify/webmux --limit 5 -gh run view --repo zalify/webmux +gh run list --repo zalify/offdesk --limit 5 +gh run view --repo zalify/offdesk ``` -## Update Machine Nodes (webmux-node) +## Update Machine Nodes (offdesk-node) Machine nodes are standalone binaries installed on each machine. They connect to the hub via WebSocket. @@ -77,24 +77,24 @@ git tag v && git push origin v Wait for `Build & Release` workflow to complete: ```bash -gh run list --repo zalify/webmux --workflow build.yml --limit 3 +gh run list --repo zalify/offdesk --workflow build.yml --limit 3 ``` ### Update node on a machine SSH to the machine and re-run the install script: ```bash -curl -sSL https://raw.githubusercontent.com/zalify/webmux/main/scripts/install.sh | sh +curl -sSL https://raw.githubusercontent.com/zalify/offdesk/main/scripts/install.sh | sh ``` Then restart the service: ```bash # Linux (systemd) -systemctl --user restart webmux-node +systemctl --user restart offdesk-node # macOS (launchd) -launchctl unload ~/Library/LaunchAgents/com.webmux.node.plist -launchctl load -w ~/Library/LaunchAgents/com.webmux.node.plist +launchctl unload ~/Library/LaunchAgents/dev.offdesk.node.plist +launchctl load -w ~/Library/LaunchAgents/dev.offdesk.node.plist ``` ### Compatibility @@ -104,38 +104,38 @@ Hub and node versions don't need to match exactly. Unknown message types are sil ## Database - **Type:** SQLite -- **Path (in container):** `/app/data/webmux.db` -- **Volume:** `webmux-data` (Docker named volume, persists across container restarts) +- **Path (in container):** `/app/data/offdesk.db` +- **Volume:** `offdesk-data` (Docker named volume, persists across container restarts) - **Access:** the server image has no `sqlite3` binary — query through a throwaway container mounting the volume: ```bash -ssh chareice@nas.chareice.site -p 10220 "export PATH=/usr/local/bin:\$PATH; docker run --rm -v webmux_webmux-data:/data keinos/sqlite3 sqlite3 /data/webmux.db '.tables'" +ssh chareice@nas.chareice.site -p 10220 "export PATH=/usr/local/bin:\$PATH; docker run --rm -v offdesk_offdesk-data:/data keinos/sqlite3 sqlite3 /data/offdesk.db '.tables'" ``` ## Common Operations ### Status ```bash -ssh chareice@nas.chareice.site -p 10220 "export PATH=/usr/local/bin:\$PATH; cd /var/services/homes/chareice/projects/webmux && docker compose ps" +ssh chareice@nas.chareice.site -p 10220 "export PATH=/usr/local/bin:\$PATH; cd /var/services/homes/chareice/projects/offdesk && docker compose ps" ``` ### Logs ```bash # Recent logs -ssh chareice@nas.chareice.site -p 10220 "export PATH=/usr/local/bin:\$PATH; cd /var/services/homes/chareice/projects/webmux && docker compose logs --tail=100" +ssh chareice@nas.chareice.site -p 10220 "export PATH=/usr/local/bin:\$PATH; cd /var/services/homes/chareice/projects/offdesk && docker compose logs --tail=100" # Follow logs -ssh chareice@nas.chareice.site -p 10220 "export PATH=/usr/local/bin:\$PATH; cd /var/services/homes/chareice/projects/webmux && docker compose logs -f --tail=50" +ssh chareice@nas.chareice.site -p 10220 "export PATH=/usr/local/bin:\$PATH; cd /var/services/homes/chareice/projects/offdesk && docker compose logs -f --tail=50" ``` ### Health Check ```bash -curl -sf -o /dev/null -w "%{http_code}" https://webmux.nas.chareice.site/ +curl -sf -o /dev/null -w "%{http_code}" https://offdesk.nas.chareice.site/ # 200 = OK ``` ### Restart ```bash -ssh chareice@nas.chareice.site -p 10220 "export PATH=/usr/local/bin:\$PATH; cd /var/services/homes/chareice/projects/webmux && docker compose restart" +ssh chareice@nas.chareice.site -p 10220 "export PATH=/usr/local/bin:\$PATH; cd /var/services/homes/chareice/projects/offdesk && docker compose restart" ``` ### Rollback @@ -143,16 +143,16 @@ ssh chareice@nas.chareice.site -p 10220 "export PATH=/usr/local/bin:\$PATH; cd / Roll back to a specific image SHA: ```bash # 1. Find recent image tags -gh api /user/packages/container/webmux-server/versions --jq '.[0:5] | .[] | "\(.metadata.container.tags | join(", ")) — \(.created_at)"' +gh api /user/packages/container/offdesk-hub/versions --jq '.[0:5] | .[] | "\(.metadata.container.tags | join(", ")) — \(.created_at)"' # 2. Update compose to pin the sha- tag -ssh chareice@nas.chareice.site -p 10220 "export PATH=/usr/local/bin:\$PATH; cd /var/services/homes/chareice/projects/webmux && sed -i 's|image:.*|image: ghcr.io/zalify/webmux-server:sha-|' docker-compose.yml && docker compose pull && docker compose up -d" +ssh chareice@nas.chareice.site -p 10220 "export PATH=/usr/local/bin:\$PATH; cd /var/services/homes/chareice/projects/offdesk && sed -i 's|image:.*|image: ghcr.io/zalify/offdesk-hub:sha-|' docker-compose.yml && docker compose pull && docker compose up -d" # 3. After fix is deployed, restore to :main tag -ssh chareice@nas.chareice.site -p 10220 "export PATH=/usr/local/bin:\$PATH; cd /var/services/homes/chareice/projects/webmux && sed -i 's|image:.*|image: ghcr.io/zalify/webmux-server:main|' docker-compose.yml && docker compose pull && docker compose up -d" +ssh chareice@nas.chareice.site -p 10220 "export PATH=/usr/local/bin:\$PATH; cd /var/services/homes/chareice/projects/offdesk && sed -i 's|image:.*|image: ghcr.io/zalify/offdesk-hub:main|' docker-compose.yml && docker compose pull && docker compose up -d" ``` ### Stop ```bash -ssh chareice@nas.chareice.site -p 10220 "export PATH=/usr/local/bin:\$PATH; cd /var/services/homes/chareice/projects/webmux && docker compose down" +ssh chareice@nas.chareice.site -p 10220 "export PATH=/usr/local/bin:\$PATH; cd /var/services/homes/chareice/projects/offdesk && docker compose down" ``` diff --git a/docs/design/next-ia/Main.dc.html b/docs/design/next-ia/Main.dc.html index 2bc7e5ed..387c4740 100644 --- a/docs/design/next-ia/Main.dc.html +++ b/docs/design/next-ia/Main.dc.html @@ -26,7 +26,7 @@
-
WEBMUX
+
offdesk
@@ -112,11 +112,11 @@
- +
-
webmux
-
nas · ~/projects/webmux
+
offdesk
+
nas · ~/projects/offdesk
@@ -140,11 +140,11 @@
- +
-
webmux
-
mbp-m3 · ~/dev/webmux
+
offdesk
+
mbp-m3 · ~/dev/offdesk
@@ -237,7 +237,7 @@
- nas·~/projects/webmux·main + nas·~/projects/offdesk·main
@@ -356,7 +356,7 @@
B
合并成一组,行内标机器
-
key = repo root。「webmux 这个项目」只有一个入口,机器退成行尾的小字。
+
key = repo root。「offdesk 这个项目」只有一个入口,机器退成行尾的小字。
diff --git a/docs/design/next-ia/MobileChat.dc.html b/docs/design/next-ia/MobileChat.dc.html index 66ce745b..747b12e6 100644 --- a/docs/design/next-ia/MobileChat.dc.html +++ b/docs/design/next-ia/MobileChat.dc.html @@ -33,7 +33,7 @@
重构侧栏 IA
- nas · webmux · auto-run + nas · offdesk · auto-run
diff --git a/docs/design/next-ia/MobileList.dc.html b/docs/design/next-ia/MobileList.dc.html index 4d8f6073..58a5abf5 100644 --- a/docs/design/next-ia/MobileList.dc.html +++ b/docs/design/next-ia/MobileList.dc.html @@ -26,7 +26,7 @@
-
WEBMUX
+
offdesk
@@ -93,7 +93,7 @@
-
webmux
+
offdesk
nas
@@ -126,7 +126,7 @@
-
webmux
+
offdesk
mbp-m3
diff --git a/docs/design/next-ia/MobileNew.dc.html b/docs/design/next-ia/MobileNew.dc.html index ffe0702c..3d94d9f1 100644 --- a/docs/design/next-ia/MobileNew.dc.html +++ b/docs/design/next-ia/MobileNew.dc.html @@ -26,7 +26,7 @@
-
WEBMUX
+
offdesk
@@ -93,7 +93,7 @@
-
webmux
+
offdesk
nas
@@ -126,7 +126,7 @@
-
webmux
+
offdesk
mbp-m3
@@ -301,7 +301,7 @@
-
~/projects/webmux
+
~/projects/offdesk
pinned
@@ -312,7 +312,7 @@
Create session
-
claude · nas · ~/projects/webmux
+
claude · nas · ~/projects/offdesk
diff --git a/docs/design/next-ia/NewSession.dc.html b/docs/design/next-ia/NewSession.dc.html index ed144e09..ab7b7fc9 100644 --- a/docs/design/next-ia/NewSession.dc.html +++ b/docs/design/next-ia/NewSession.dc.html @@ -26,7 +26,7 @@
-
WEBMUX
+
offdesk
@@ -112,11 +112,11 @@
- +
-
webmux
-
nas · ~/projects/webmux
+
offdesk
+
nas · ~/projects/offdesk
@@ -140,11 +140,11 @@
- +
-
webmux
-
mbp-m3 · ~/dev/webmux
+
offdesk
+
mbp-m3 · ~/dev/offdesk
@@ -237,7 +237,7 @@
- nas·~/projects/webmux·main + nas·~/projects/offdesk·main
@@ -356,7 +356,7 @@
B
合并成一组,行内标机器
-
key = repo root。「webmux 这个项目」只有一个入口,机器退成行尾的小字。
+
key = repo root。「offdesk 这个项目」只有一个入口,机器退成行尾的小字。
@@ -519,18 +519,18 @@
-
~/projects/webmux
+
~/projects/offdesk
pinned
main*
-
~/projects/webmux/main
+
~/projects/offdesk/main
worktree
-
~/projects/webmux/fix-tmux-scope-detach
+
~/projects/offdesk/fix-tmux-scope-detach
worktree
@@ -544,7 +544,7 @@
- claude · nas · ~/projects/webmux + claude · nas · ~/projects/offdesk
diff --git a/docs/design/next-ia/Split.dc.html b/docs/design/next-ia/Split.dc.html index acd0bc0c..3e17fb6e 100644 --- a/docs/design/next-ia/Split.dc.html +++ b/docs/design/next-ia/Split.dc.html @@ -26,7 +26,7 @@
-
WEBMUX
+
offdesk
@@ -112,11 +112,11 @@
- +
-
webmux
-
nas · ~/projects/webmux
+
offdesk
+
nas · ~/projects/offdesk
@@ -140,11 +140,11 @@
- +
-
webmux
-
mbp-m3 · ~/dev/webmux
+
offdesk
+
mbp-m3 · ~/dev/offdesk
@@ -236,7 +236,7 @@
- nas·~/projects/webmux·main + nas·~/projects/offdesk·main
@@ -367,17 +367,17 @@
pane 2
-
$ cargo watch -x 'test -p webmux-hub sidebar'
+
$ cargo watch -x 'test -p offdesk-hub sidebar'
-
[Running 'test -p webmux-hub sidebar']
-
Compiling webmux-hub v0.4.2
+
[Running 'test -p offdesk-hub sidebar']
+
Compiling offdesk-hub v0.4.2
error[E0425]: cannot find function `repoRoot` in this scope
--> src/sidebar/groupSessions.ts:24:16
[Finished running. Exit status: 101]
-
[Running 'test -p webmux-hub sidebar']
-
Compiling webmux-hub v0.4.2
+
[Running 'test -p offdesk-hub sidebar']
+
Compiling offdesk-hub v0.4.2
Finished in 11.42s
running 6 tests
diff --git a/docs/design/next-ia/States.dc.html b/docs/design/next-ia/States.dc.html index 159800d1..98a93d84 100644 --- a/docs/design/next-ia/States.dc.html +++ b/docs/design/next-ia/States.dc.html @@ -26,7 +26,7 @@
工具默认自动放行,agent 只在被堵住时才叫你。琥珀只留给这一种情况;干完一轮只留一个未读记号。
-
webmux · sidebar spec
+
offdesk · sidebar spec
@@ -185,7 +185,7 @@
琥珀色只出现在 agent 会话上
-
>_ 终端会话只有 working / idle / error / disconnected——没有 chat 视图,也没有旁白。webmux 无法可靠判断一个 shell 是不是在等你输入,所以「轮到你了」只从 ACP 事件流来。
+
>_ 终端会话只有 working / idle / error / disconnected——没有 chat 视图,也没有旁白。offdesk 无法可靠判断一个 shell 是不是在等你输入,所以「轮到你了」只从 ACP 事件流来。
@@ -208,7 +208,7 @@
CL
重构侧栏 IA
-
nas · webmux
+
nas · offdesk
1m
同一个仓库 checkout 在两台机器上,侧栏该怎么显示?
@@ -308,7 +308,7 @@
- (2) webmux + (2) offdesk
标题只数琥珀,未读不进计数。
diff --git a/docs/design/next-ia/Terminal.dc.html b/docs/design/next-ia/Terminal.dc.html index e083cf3b..a77a9c39 100644 --- a/docs/design/next-ia/Terminal.dc.html +++ b/docs/design/next-ia/Terminal.dc.html @@ -26,7 +26,7 @@
-
WEBMUX
+
offdesk
@@ -112,11 +112,11 @@
- +
-
webmux
-
nas · ~/projects/webmux
+
offdesk
+
nas · ~/projects/offdesk
@@ -140,11 +140,11 @@
- +
-
webmux
-
mbp-m3 · ~/dev/webmux
+
offdesk
+
mbp-m3 · ~/dev/offdesk
@@ -237,7 +237,7 @@
- nas·~/projects/webmux·claude --resume 4f2a1c + nas·~/projects/offdesk·claude --resume 4f2a1c
@@ -280,7 +280,7 @@
$ claude --resume 4f2a1c
╭──────────────────────────────────────────────────────╮
-
✻ claude opus-4.6 ~/projects/webmux main*
+
✻ claude opus-4.6 ~/projects/offdesk main*
╰──────────────────────────────────────────────────────╯
> 侧栏的 project 分组现在直接拿 session.cwd 当 key,
@@ -303,15 +303,15 @@
(或直接输入你的想法)
-
~/projects/webmux main* ⧉ 2 files · ctrl-b d to detach
+
~/projects/offdesk main* ⧉ 2 files · ctrl-b d to detach
-
$ cargo test -p webmux-hub sidebar
+
$ cargo test -p offdesk-hub sidebar
-
Compiling webmux-hub v0.4.2 (/home/chareice/projects/webmux/crates/hub)
+
Compiling offdesk-hub v0.4.2 (/home/chareice/projects/offdesk/crates/hub)
Finished test profile [unoptimized + debuginfo] in 11.42s
Running unittests src/lib.rs
diff --git a/docs/facts.md b/docs/facts.md new file mode 100644 index 00000000..f771b686 --- /dev/null +++ b/docs/facts.md @@ -0,0 +1,367 @@ +# Verified facts + +Every statement here was read out of this repository on 2026-08-31, at the +commit that precedes the offdesk rename. Each line names the file it came +from. README, docs, and site copy may only assert things on this list. + +Names below are the **pre-rename** names unless a line says otherwise. The +rename mapping is at the bottom. + +--- + +## 1. Workspace layout + +| Path | What it is | Source | +|---|---|---| +| `crates/hub` | crate `tc-hub`, binary `webmux-server` | `crates/hub/Cargo.toml` | +| `crates/machine` | crate `tc-machine`, binary `webmux-node` | `crates/machine/Cargo.toml` | +| `crates/cli` | crate `tc-cli`, binary `webmux` | `crates/cli/Cargo.toml` | +| `crates/protocol` | crate `tc-protocol`, library only, no binary | `crates/protocol/Cargo.toml` | +| `packages/app` | the only frontend: Expo Router + React Native Web + xterm.js | `packages/app/package.json` | +| `packages/shared` | `@webmux/shared`, TS wire contracts | `packages/shared/package.json` | +| `packages/desktop` | Tauri v2 shell, crate `webmux-desktop` | `packages/desktop/src-tauri/Cargo.toml` | + +- Cargo workspace members are `crates/*`; `packages/desktop/src-tauri` is + explicitly **excluded** from the workspace (`Cargo.toml`). +- Rust edition 2021 on every crate. No `license` field on any Cargo.toml + today. No `license` field on any package.json today. +- Every crate is at version `0.1.0` except the Tauri desktop app, which + `tauri.conf.json` pins at `0.3.14`. +- pnpm workspace is `packages/*` only (`pnpm-workspace.yaml`); packageManager + is `pnpm@10.23.0` (`package.json`). + +## 2. Binaries and what they do + +- **`webmux-server`** (hub). Axum HTTP + WebSocket server. Serves the exported + web app as an SPA, brokers terminal I/O, owns auth and the control lease. + Clap `--listen` default `0.0.0.0:4317`; `--static-dir` default + `packages/app/dist` (env `WEBMUX_STATIC_DIR`); `--database` default + `./webmux.db` (env `DATABASE_PATH`). Source: `crates/hub/src/main.rs`. +- **`webmux-node`** (machine agent). Subcommands: `register`, `start`, + `service install|uninstall|restart|status`, `status`. Running it with no + subcommand is the same as `start`. Source: `crates/machine/src/main.rs`. +- **`webmux`** (CLI). Subcommands: `machines`, `machines rm`, `ls`, `open`, + `read`, `send`, `key`, `wait`, `kill`. Source: `crates/cli/src/main.rs`. + +## 3. Ports and URLs + +- Hub listens on **4317** by default (`crates/hub/src/main.rs`, `Dockerfile` + `EXPOSE 4317`, `docker-compose.yml` maps `127.0.0.1:4317:4317`). +- WebSocket endpoints on the hub (`crates/hub/src/ws.rs`): + `/ws/machine`, `/ws/terminal/{machine_id}/{terminal_id}`, + `/ws/terminal-previews`, `/ws/events`. +- The machine agent connects to `/ws/machine`. It accepts an http/https + or ws/wss `--hub-url` and converts the scheme itself + (`crates/machine/src/main.rs::build_ws_url`). +- CLI derives `wss://` from `https://` and `ws://` from `http://` + (`crates/cli/src/config.rs::ws_terminal_url`). Any other scheme is an error. +- `/api/*` returns 404 for unknown API paths rather than falling through to + the SPA; extensionless paths fall through to `index.html` + (`crates/hub/src/main.rs`). + +## 4. Environment variables actually read + +Hub (`crates/hub/src/main.rs`, `crates/hub/src/routes/auth.rs`): + +| Var | Default | Effect | +|---|---|---| +| `WEBMUX_STATIC_DIR` | `packages/app/dist` | directory of the built web app | +| `DATABASE_PATH` | `./webmux.db` | SQLite file path | +| `WEBMUX_BASE_URL` | `http://localhost:4317` | used to build OAuth redirect URIs | +| `WEBMUX_DEV_MODE` | `false` | `"true"` enables `GET /api/auth/dev` | +| `JWT_SECRET` | `dev-secret-change-me` | HS256 signing key | +| `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` | unset | GitHub OAuth | +| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | unset | Google OAuth | + +CLI (`crates/cli/src/main.rs`): `WEBMUX_URL`, `WEBMUX_TOKEN`. + +Frontend / build: `EXPO_PUBLIC_WEBMUX_DEFAULT_SERVER_URL`, +`WEBMUX_DEFAULT_SERVER_URL` (`packages/app/lib/serverUrl.ts`), +`WEBMUX_ALLOW_CLEARTEXT`, `WEBMUX_APP_VERSION` (`packages/app/app.config.js`), +`WEBMUX_MOBILE_HUB_URL` (`.github/workflows/mobile-android.yml`), +`WEBMUX_INSTALL_DIR` (`scripts/install.sh`, default `$HOME/.local/bin`). + +`JWT_SECRET` has a hardcoded default of `dev-secret-change-me`. A hub deployed +without setting it signs sessions with a publicly known key. + +## 5. Config files on disk + +Both paths come from `dirs::config_dir()`, which is **not** `~/.config` on +macOS — it is `~/Library/Application Support`. + +| File | Written by | Contents | +|---|---|---| +| `/webmux/config.toml` | the user, by hand | `url`, `token` for the CLI (`crates/cli/src/config.rs`) | +| `/webmux/machine.json` | `webmux-node register` | `machine_id`, `machine_secret`, `hub_url`, optional `acp_agents` map (`crates/machine/src/config.rs`) | +| `/webmux/tmux.conf` | the node, on start | generated tmux config (`crates/machine/src/pty.rs`) | +| `/webmux/tmux.user.conf` | the user, optional | sourced at the end of the generated config, so it wins | +| `/webmux/osc52copy.sh` | the node, on start | clipboard helper, mode 0755 | + +The CLI resolves settings in the order **flag > env > config file**. A missing +url or token exits 2 (`crates/cli/src/config.rs::resolve`). + +## 6. Auth + +- Sign-in providers: **GitHub OAuth** and **Google OAuth**. There is also a + dev-only login. No password login route exists. + (`crates/hub/src/routes/auth.rs`: `/api/auth/github`, + `/api/auth/github/callback`, `/api/auth/google`, `/api/auth/google/callback`, + `/api/auth/dev`, `/api/auth/me`.) +- OAuth callback URLs the hub builds from `WEBMUX_BASE_URL` + (`crates/hub/src/auth.rs`): + - GitHub: `/api/auth/github/callback`, scope `read:user` + - Google: `/api/auth/google/callback`, scope `openid email profile` +- `GET /api/auth/dev` returns 404 unless `WEBMUX_DEV_MODE=true` + (`crates/hub/src/routes/auth.rs:177`). +- Session JWT: HS256, expiry **180 days** (`JWT_EXPIRY_DAYS`, + `crates/hub/src/auth.rs`). Delivered to the browser as `?token=` on the + post-OAuth redirect. +- API tokens: prefix **`wmx_`** followed by two UUIDv4s with dashes stripped + (`crates/hub/src/routes/api_tokens.rs`). Stored as a **SHA-256 hex hash**; + the plaintext is returned once, at creation, and never again. +- Token routes: `GET|POST /api/auth/api-tokens`, + `DELETE /api/auth/api-tokens/{id}`. Create takes a `name`. List returns + `id, name, created_at, last_used_at, expires_at` — so per-token naming, + individual revoke, and last-used are all real, shipped features. +- `last_used_at` is updated on every successful API-token authentication + (`crates/hub/src/auth.rs::verify_bearer_token`). +- A bearer token is treated as an API token if it starts with `wmx_`, + otherwise it is parsed as a JWT (same function). +- Machine registration tokens are bare UUIDv4s, hashed with SHA-256, + **single-use**, and expire **24 hours** after issue + (`crates/hub/src/routes/registration.rs`). +- On successful registration the hub mints a `machine_id` and `machine_secret` + (both UUIDv4); the secret is stored bcrypt-hashed at **cost 10** + (`crates/hub/src/auth.rs::BCRYPT_COST`, `routes/registration.rs`). +- Machines belong to exactly one user (`machines.user_id`, `db/mod.rs`), and + every terminal route checks `user_can_access_machine` before acting. + +## 7. Control lease + +Source: `crates/hub/src/machine_manager.rs`, `crates/hub/src/ws.rs`, +`crates/hub/src/routes/mode.rs`. + +- The lease is keyed **per (user, machine)** and held **in memory only** — + it is not persisted to SQLite and does not survive a hub restart. +- `request_control` overwrites the current holder unconditionally. This is + literal last-writer-wins; there is no queue and no refusal. +- Messages that require the lease: `input`, `command_input`, `resize`, + `image_paste` (`ws.rs::client_message_allowed`). A client without the lease + has those messages dropped — it still receives all output. That is what + "view-only" means here. +- Sending `input`, `command_input`, or `image_paste` **auto-claims** the lease + if the sender doesn't hold it (`ws.rs::client_message_claims_control`). + `resize` alone does not claim. +- `terminal_response` (the reply to a terminal query sequence) is always + allowed, lease or not. +- On device disconnect the lease is released after a grace period and stashed; + if the same `device_id` reconnects, the lease is restored. +- HTTP surface: `GET /api/mode?machine_id=`, plus request/release endpoints + (`routes/mode.rs`). +- The lease governs **who may type**. It does not isolate output, does not + encrypt anything, and does not stop a second token holder on the same + account from taking control a millisecond later. + +## 8. Terminals and tmux + +Source: `crates/machine/src/pty.rs`, `crates/machine/src/attach.rs`, +`crates/machine/src/hub_conn.rs`. + +- tmux is **mandatory**. `webmux-node start` checks for tmux in `PATH` and + exits 1 with install instructions for Debian/Ubuntu, macOS Homebrew, and + Arch if it is missing. `PtyManager::new()` panics if tmux is absent. +- Every terminal is a tmux session on a private tmux socket. Socket name is + `webmux` (`TMUX_SOCKET`); session names are prefixed `wmx_` (`TMUX_PREFIX`). + These are tmux-level identifiers and are **separate** from the `wmx_` API + token prefix. +- One `tmux attach` subprocess is spawned **per attached client**, so two + browsers on the same terminal get independent views and independent scroll + position. +- `window-size manual` is set **per session, after new-session** — never + globally, because tmux 3.3a's server crashes at startup if + `set -g window-size manual` is in the config file. Consequence: a client + attaching or resizing does not resize anyone else's view. +- Generated tmux config sets, among other things: `status off`, `prefix None` + (the tmux prefix key is unbound), `mouse on`, `set-clipboard on`, + `allow-passthrough on`, `escape-time 10` (down from tmux's 500 ms default), + `history-limit 10000`, and OSC 8 hyperlink passthrough for xterm.js. +- `/webmux/tmux.user.conf` is sourced last, so user settings + override the generated ones. +- Sessions survive a `webmux-node` restart: `recover_sessions()` re-adopts the + tmux sessions found on the socket. +- ACP agent kinds with built-in spawn commands: `claude`, `codex`, `grok`, + `kimi` (`crates/machine/src/acp.rs`). Each can be overridden per machine via + the `acp_agents` map in `machine.json`. This is the structured agent-session + feature; it is **not** required to run those tools — any of them can also be + started as a plain command in a terminal. + +## 9. CLI semantics + +Source: `crates/cli/src/`. + +- Exit codes: **0** success or wait condition met; **1** wait timed out; + **2** usage, config, network, or protocol error (`main.rs::exit_code`). +- Machines and terminals are addressed by **id prefix**; an ambiguous prefix + lists candidates instead of guessing. +- `read` attaches as a read-only watcher, waits for the repaint, and prints the + reconstructed **current screen**. It cannot see scrollback and cannot see + output that has already scrolled past. Defaults: `--quiet-ms 500`, + `--timeout 10s`. +- `read`/`wait` never claim the lease. `send`/`key` do. +- `--lines N` = the last N rendered lines after trailing blank lines are + trimmed. JSON adds `lines_total` (pre-slice) and `truncated`. +- `read --all` JSON entries carry `pane_title`, `title_source` + (`osc` / `process` / `none`), `foreground_process` + (`{has_foreground_process, process_name}`, null on lookup failure), + `activity` (`active` / `quiet` / `idle`) and `idle_ms`. Top level carries + `skipped_unreachable_count`. Unreachable terminals are omitted unless + `--include-unreachable` (`commands/read_all.rs`). +- `cwd` reported by `ls` is live — tmux `pane_current_path`, polled — not the + directory the terminal was created in. +- All printed and serialized output is sanitized: control bytes stripped, + `\n` and `\t` and Unicode preserved (`attach.rs::sanitize_screen`). +- `send` writes the text as one frame, then sends `\r` as a **separate, + delayed** frame. The delay is `150 ms + 60 ms per newline`, capped at + **800 ms** (`attach.rs::plan_send_frames`). `--no-enter` sends the text + frame only. +- The CLI uses a per-invocation device id: `cli-send-`, `cli-read-`. +- `key` accepts: `Enter Esc Tab BTab Space Up Down Left Right Home End PgUp + PgDn Del Backspace F1`–`F12`, `C-`, `C-[` (`keys.rs::VALID_FORMS`). +- `wait` requires at least one of `--pattern` (regex against the current + screen) or `--silence `; `--timeout` defaults to 60 s, `0` means forever. +- `open` requires `--cwd`. `--group` attaches to an **existing** workspace + group; groups are not auto-created. +- SIGPIPE is handled so `webmux ls | head -1` exits 0 instead of panicking + (`commands/mod.rs`). + +## 10. What the hub stores in SQLite + +Tables, from `crates/hub/src/db/mod.rs`: + +`users` (provider, provider_id, display_name, avatar_url, role) · +`machines` (name, bcrypt `machine_secret_hash`, status, os, home_dir, +last_seen_at) · `registration_tokens` (sha256 hash, expiry, used flag) · +`api_tokens` (name, sha256 hash, created_at, last_used_at, expires_at) · +`bookmarks` · `workspace_groups` · `workspace_layouts` · +`terminal_sessions` (title, title_source, cwd, group, cols, rows, +created_at, destroyed_at) · `settings` · `user_settings` · `user_focus` · +`hub_state` · `agent_sessions` · `agent_session_events` (full ACP event JSON) · +`agent_session_seen`. + +What that means in plain terms: the hub stores terminal **titles**, **working +directories**, and window geometry, plus the full event stream of structured +agent sessions. It does **not** store terminal output or scrollback for plain +terminals — those live only in tmux on the machine. + +## 11. Platforms that actually build + +| Target | Built by | Evidence | +|---|---|---| +| Linux x86_64 / aarch64 (musl) node binary | `Build & Release` on `v*` tags, via `cross` | `.github/workflows/build.yml` | +| macOS x86_64 / aarch64 node binary | same workflow, `macos-latest` | same | +| Hub container image, **linux/amd64 only** | `Publish Container Image` on push to main → `ghcr.io//webmux-server` | `.github/workflows/container.yml` | +| Desktop app: macOS (universal), Ubuntu 22.04, Windows | `Desktop Build` on `desktop-v*` tags, Tauri v2 | `.github/workflows/desktop.yml` | +| Android APK (arm64-v8a, armeabi-v7a, x86_64, universal) | `Build Android APK (Tauri)` on `app-v*` tags | `.github/workflows/mobile-android.yml` | +| Web app | `expo export --platform web`, served by the hub | `packages/app/package.json`, `Dockerfile` | + +- **There is no iOS build.** `src-tauri/Cargo.toml` lists `staticlib` in + `crate-type` with an iOS comment, but no iOS workflow, no Xcode project, and + no iOS config exist in this repo. iOS users use the web app in a browser. +- Release artifact names are `webmux-node-{linux,darwin}-{x64,arm64}` + (`build.yml`). **Only the node binary is released.** There is no published + binary for the hub or the CLI today — the hub ships as a container image and + the CLI must be built from source. +- The Android APK is a Tauri shell that loads a hub URL at runtime; the + workflow bakes in a default hub URL. +- `packages/app` declares `platforms: ["web", "android"]` + (`packages/app/app.config.js`). + +## 12. Install script that exists today + +`scripts/install.sh`, invoked as +`curl -sSL https://raw.githubusercontent.com/zalify/webmux/main/scripts/install.sh | sh`. + +- Installs **`webmux-node` only**. +- Requires tmux and refuses to run without it. +- Detects `linux`/`darwin` and `x64`/`arm64`; anything else exits 1. +- Installs to `$WEBMUX_INSTALL_DIR`, default `$HOME/.local/bin`. It never uses + sudo and never writes outside that directory. +- Picks the newest `vX.Y.Z` GitHub release, deliberately skipping `desktop-v*`. +- Downloads to `.new.$$` and atomically `mv`s over the target, so + replacing a running binary doesn't hit "text file busy". +- Warns if the install dir is not on `PATH`, and if a running systemd/launchd + service needs restarting. + +## 13. Service management + +`crates/machine/src/service.rs`: + +- Linux: a **systemd user unit** named `webmux-node.service` at + `~/.config/systemd/user/webmux-node.service`, with `Restart=always`, + `RestartSec=10`. Install also runs `loginctl enable-linger ` so the + service survives logout. +- macOS: a **launchd** agent with label `com.webmux.node`; logs go to + `~/Library/Logs/webmux/stderr.log`. +- Windows: no service support in this file. + +## 14. Deployment shape in the repo + +- `Dockerfile`: three stages — Node 22 builds the Expo web export and stamps a + cache-busting build id through `index.html` and nested chunks; + `rust:1-slim-bookworm` builds `webmux-server` release; `debian:bookworm-slim` + runs it. Sets `WEBMUX_STATIC_DIR=/app/web`, `DATABASE_PATH=/app/data/tc.db`. +- `docker-compose.yml`: one `server` service, port bound to `127.0.0.1:4317`, + named volume `webmux-data` at `/app/data`, all OAuth/JWT settings from the + environment. +- Static asset cache policy (`crates/hub/src/main.rs`): HTML is + `no-cache, no-store, must-revalidate`; JS and CSS are + `public, max-age=31536000, immutable`; everything else `max-age=3600`. +- The hub sets `TCP_NODELAY` on accepted connections, because axum does not by + default and Nagle turns per-keystroke frames into latency spikes. +- WebSocket traffic supports permessage-deflate (`crates/protocol/src/compression.rs`). + +## 15. Tests + +- `cargo check --workspace`, an Expo web export, and the E2E suite run in CI + (`.github/workflows/ci.yml`). +- E2E is Playwright + Chromium **inside containers** — `pnpm e2e:test` locally + and `pnpm e2e:ci` in automation, both running `e2e/run-in-docker.sh`, which + brings up `hub`, `node`, and `runner` from `e2e/docker-compose.yml`. It is + headless and needs no host browser. Host-browser runs + (`pnpm e2e:test:debug-host`) are debug-only per `AGENTS.md`. +- Unit tests: `vitest run` for TypeScript, `cargo test` for Rust. +- 28 Playwright spec files in `e2e/tests/`. + +## 16. Personal/deployment-specific values currently hardcoded + +These are one person's infrastructure, not product facts. They must not appear +in the README or on the site. + +- `https://webmux.nas.chareice.site` — default server URL in + `packages/app/lib/serverUrl.ts`, the Android window URL in + `tauri.android.conf.json`, and the default in `mobile-android.yml`. +- `ssh chareice@nas.chareice.site -p 10220` and NAS paths throughout + `docs/deployment/runbook.md`. + +--- + +## Rename mapping applied in Step 1 + +| From | To | +|---|---| +| crate `tc-hub` | `offdesk-hub` | +| crate `tc-cli` | `offdesk-cli` | +| crate `tc-protocol` | `offdesk-protocol` | +| crate `tc-machine` | `offdesk-machine` | +| binary `webmux-server` | `offdesk-hub` | +| binary `webmux-node` | `offdesk-node` | +| binary `webmux` | `offdesk` | +| `WEBMUX_URL` / `WEBMUX_TOKEN` / `WEBMUX_DEV_MODE` | `OFFDESK_URL` / `OFFDESK_TOKEN` / `OFFDESK_DEV_MODE` | +| `WEBMUX_BASE_URL` / `WEBMUX_STATIC_DIR` / `WEBMUX_INSTALL_DIR` | `OFFDESK_BASE_URL` / `OFFDESK_STATIC_DIR` / `OFFDESK_INSTALL_DIR` | +| `/webmux/` | `/offdesk/` | +| API token prefix `wmx_` | `odk_` | +| npm scope `@webmux/*` | `@offdesk/*` | +| `com.webmux.node` / `com.webmux.desktop` / `com.webmux.app` | `dev.offdesk.node` / `dev.offdesk.desktop` / `dev.offdesk.app` | +| deep link `webmux://auth` | `offdesk://auth` | +| `ghcr.io/zalify/webmux-server` | `ghcr.io/zalify/offdesk-hub` | +| `github.com/zalify/webmux` | `github.com/zalify/offdesk` | diff --git a/e2e/Dockerfile.hub b/e2e/Dockerfile.hub index 448594bc..ead6f5f6 100644 --- a/e2e/Dockerfile.hub +++ b/e2e/Dockerfile.hub @@ -18,16 +18,16 @@ FROM rust:bookworm AS builder WORKDIR /app COPY Cargo.toml Cargo.lock ./ COPY crates crates -RUN cargo build --release --bin webmux-server +RUN cargo build --release --bin offdesk-hub FROM debian:bookworm-slim RUN apt-get update && apt-get install -y ca-certificates curl && rm -rf /var/lib/apt/lists/* -COPY --from=builder /app/target/release/webmux-server /usr/local/bin/ +COPY --from=builder /app/target/release/offdesk-hub /usr/local/bin/ COPY --from=frontend /app/packages/app/dist /app/web RUN mkdir -p /app/data -ENV WEBMUX_STATIC_DIR=/app/web -ENV DATABASE_PATH=/app/data/tc.db +ENV OFFDESK_STATIC_DIR=/app/web +ENV DATABASE_PATH=/app/data/offdesk.db EXPOSE 4317 -CMD ["webmux-server"] +CMD ["offdesk-hub"] diff --git a/e2e/Dockerfile.node b/e2e/Dockerfile.node index 4cd4c9b5..8460d0a7 100644 --- a/e2e/Dockerfile.node +++ b/e2e/Dockerfile.node @@ -2,18 +2,18 @@ FROM rust:bookworm AS builder WORKDIR /app COPY Cargo.toml Cargo.lock ./ COPY crates crates -RUN cargo build --release --bin webmux-node +RUN cargo build --release --bin offdesk-node FROM debian:bookworm-slim RUN apt-get update && apt-get install -y ca-certificates bash coreutils tmux python3 && rm -rf /var/lib/apt/lists/* -COPY --from=builder /app/target/release/webmux-node /usr/local/bin/ +COPY --from=builder /app/target/release/offdesk-node /usr/local/bin/ # Fake ACP agent for agent-session tests; machine.json points every agent # kind at it (its machine_id must match the compose `--id e2e-node`). -COPY e2e/fake-acp-agent.py /opt/webmux/fake-acp-agent.py -COPY e2e/machine.json /root/.config/webmux/machine.json +COPY e2e/fake-acp-agent.py /opt/offdesk/fake-acp-agent.py +COPY e2e/machine.json /root/.config/offdesk/machine.json # UTF-8 locale so tmux and readline handle multibyte input/echo (CJK IME # e2e tests); bookworm's glibc always ships C.UTF-8. ENV LANG=C.UTF-8 LC_ALL=C.UTF-8 -CMD ["webmux-node"] +CMD ["offdesk-node"] diff --git a/e2e/agent-sessions-smoke.sh b/e2e/agent-sessions-smoke.sh index 5e18686a..da10239a 100755 --- a/e2e/agent-sessions-smoke.sh +++ b/e2e/agent-sessions-smoke.sh @@ -9,9 +9,9 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -PORT="${WEBMUX_SMOKE_PORT:-4399}" +PORT="${OFFDESK_SMOKE_PORT:-4399}" BASE="http://127.0.0.1:$PORT" -WORK="$(mktemp -d /tmp/webmux-agent-smoke.XXXXXX)" +WORK="$(mktemp -d /tmp/offdesk-agent-smoke.XXXXXX)" HUB_LOG="$WORK/hub.log" NODE_LOG="$WORK/node.log" HUB_PID="" @@ -28,12 +28,12 @@ trap cleanup EXIT json_get() { python3 -c "import json,sys; print(json.load(sys.stdin)$1)"; } echo "==> building hub and node" -cargo build -q -p tc-hub -p tc-machine --manifest-path "$ROOT/Cargo.toml" -HUB_BIN="$ROOT/target/debug/webmux-server" -NODE_BIN="$ROOT/target/debug/webmux-node" +cargo build -q -p offdesk-hub -p offdesk-machine --manifest-path "$ROOT/Cargo.toml" +HUB_BIN="$ROOT/target/debug/offdesk-hub" +NODE_BIN="$ROOT/target/debug/offdesk-node" echo "==> starting dev-mode hub on $BASE (db: $WORK/hub.db)" -WEBMUX_DEV_MODE=true "$HUB_BIN" --listen "127.0.0.1:$PORT" --database "$WORK/hub.db" \ +OFFDESK_DEV_MODE=true "$HUB_BIN" --listen "127.0.0.1:$PORT" --database "$WORK/hub.db" \ >"$HUB_LOG" 2>&1 & HUB_PID=$! for _ in $(seq 1 50); do @@ -52,7 +52,7 @@ XDG_CONFIG_HOME="$WORK/xdg" "$NODE_BIN" register --hub-url "$BASE" --token "$REG # Point every agent kind at the fake ACP agent. FAKE_AGENT="$ROOT/e2e/fake-acp-agent.py" -python3 - "$WORK/xdg/webmux/machine.json" "$FAKE_AGENT" <<'EOF' +python3 - "$WORK/xdg/offdesk/machine.json" "$FAKE_AGENT" <<'EOF' import json, sys path, agent = sys.argv[1], sys.argv[2] config = json.load(open(path)) diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index 9ec18946..ec86e50f 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -14,11 +14,11 @@ services: # per session/worktree) don't fight over the host port. - "127.0.0.1:${E2E_HUB_HOST_PORT:-4317}:4317" environment: - DATABASE_PATH: "/app/data/tc.db" - WEBMUX_STATIC_DIR: "/app/web" - WEBMUX_DEV_MODE: "true" + DATABASE_PATH: "/app/data/offdesk.db" + OFFDESK_STATIC_DIR: "/app/web" + OFFDESK_DEV_MODE: "true" JWT_SECRET: "e2e-test-secret" - WEBMUX_BASE_URL: "http://localhost:4317" + OFFDESK_BASE_URL: "http://localhost:4317" healthcheck: test: ["CMD-SHELL", "curl -sf http://localhost:4317/api/auth/dev || exit 1"] interval: 3s @@ -31,7 +31,7 @@ services: context: .. dockerfile: e2e/Dockerfile.node command: - - webmux-node + - offdesk-node - start - --hub-url - ws://hub:4317/ws/machine diff --git a/e2e/env-playbook.md b/e2e/env-playbook.md index 17951a96..59e42ae7 100644 --- a/e2e/env-playbook.md +++ b/e2e/env-playbook.md @@ -2,10 +2,10 @@ ## Architecture -- **Hub** (`webmux-server`): Axum web server on port 4317. Built inside Docker for E2E. Serves REST API, WebSocket endpoints, and static frontend. SQLite database. Runs in `WEBMUX_DEV_MODE=true` for E2E (skip OAuth, allow unauthenticated nodes). -- **Node** (`webmux-node`): Machine daemon connecting to hub via `ws://hub:4317/ws/machine`. Built inside Docker for E2E. Manages real PTY sessions with bash. Runs with `--id e2e-node` (dev mode, no registration needed). Ships `e2e/machine.json` at `/root/.config/webmux/machine.json` — its `acp_agents` map points every agent kind (claude/codex/grok/kimi) at the fake ACP agent (`/opt/webmux/fake-acp-agent.py`, python3), and `FAKE_ACP_ASK=1` in the container env makes it emit a permission request per prompt (only surfaces as an ask-card when the session's `auto_run` is false). +- **Hub** (`offdesk-hub`): Axum web server on port 4317. Built inside Docker for E2E. Serves REST API, WebSocket endpoints, and static frontend. SQLite database. Runs in `OFFDESK_DEV_MODE=true` for E2E (skip OAuth, allow unauthenticated nodes). +- **Node** (`offdesk-node`): Machine daemon connecting to hub via `ws://hub:4317/ws/machine`. Built inside Docker for E2E. Manages real PTY sessions with bash. Runs with `--id e2e-node` (dev mode, no registration needed). Ships `e2e/machine.json` at `/root/.config/offdesk/machine.json` — its `acp_agents` map points every agent kind (claude/codex/grok/kimi) at the fake ACP agent (`/opt/offdesk/fake-acp-agent.py`, python3), and `FAKE_ACP_ASK=1` in the container env makes it emit a permission request per prompt (only surfaces as an ask-card when the session's `auto_run` is false). - **Runner** (`playwright`): Playwright test runner based on the official Playwright image with browsers preinstalled. The actual browser process runs inside the `runner` container and talks to `http://hub:4317` on the compose network. Normal E2E verification must use this containerized browser path. -- **Database:** SQLite at `/app/data/tc.db` (ephemeral per test run, no volume mount) +- **Database:** SQLite at `/app/data/offdesk.db` (ephemeral per test run, no volume mount) ## Default Commands diff --git a/e2e/machine.json b/e2e/machine.json index 389cb6d1..56d9a7d8 100644 --- a/e2e/machine.json +++ b/e2e/machine.json @@ -3,9 +3,9 @@ "machine_secret": "", "hub_url": "ws://hub:4317/ws/machine", "acp_agents": { - "claude": ["python3", "/opt/webmux/fake-acp-agent.py"], - "codex": ["python3", "/opt/webmux/fake-acp-agent.py"], - "grok": ["python3", "/opt/webmux/fake-acp-agent.py"], - "kimi": ["python3", "/opt/webmux/fake-acp-agent.py"] + "claude": ["python3", "/opt/offdesk/fake-acp-agent.py"], + "codex": ["python3", "/opt/offdesk/fake-acp-agent.py"], + "grok": ["python3", "/opt/offdesk/fake-acp-agent.py"], + "kimi": ["python3", "/opt/offdesk/fake-acp-agent.py"] } } diff --git a/e2e/tests/helpers.ts b/e2e/tests/helpers.ts index ad240d84..7327e022 100644 --- a/e2e/tests/helpers.ts +++ b/e2e/tests/helpers.ts @@ -8,11 +8,11 @@ async function authenticate(page: Page): Promise { const { token } = await response.json(); await page.context().addInitScript((value) => { - localStorage.setItem("webmux:token", value); - // Opt-in to test-only hooks (e.g. the window.__webmuxTerminals map that + localStorage.setItem("offdesk:token", value); + // Opt-in to test-only hooks (e.g. the window.__offdeskTerminals map that // exposes live xterm instances for buffer inspection). Production builds // never set this flag and therefore never expose internals globally. - localStorage.setItem("webmux:e2e", "1"); + localStorage.setItem("offdesk:e2e", "1"); }, token); } @@ -37,7 +37,7 @@ export async function openApp(page: Page): Promise { } export async function getAuthHeaders(page: Page): Promise> { - const token = await page.evaluate(() => localStorage.getItem("webmux:token")); + const token = await page.evaluate(() => localStorage.getItem("offdesk:token")); expect(token).toBeTruthy(); return { Authorization: `Bearer ${token}`, @@ -451,8 +451,8 @@ export async function readTerminalBuffer( ): Promise { return page.evaluate((tid) => { const map = ( - window as unknown as { __webmuxTerminals?: Map } - ).__webmuxTerminals; + window as unknown as { __offdeskTerminals?: Map } + ).__offdeskTerminals; const term = map?.get(tid) as | { buffer: { diff --git a/e2e/tests/mobile-controls.spec.ts b/e2e/tests/mobile-controls.spec.ts index 5a05d917..e83de2e3 100644 --- a/e2e/tests/mobile-controls.spec.ts +++ b/e2e/tests/mobile-controls.spec.ts @@ -84,7 +84,7 @@ test("mobile terminal flow works inside the responsive web shell", async ({ page // Destroy via API → the shell returns to the empty state. const deviceId = await page.evaluate(() => sessionStorage.getItem("tc-device-id")); - const token = await page.evaluate(() => localStorage.getItem("webmux:token")); + const token = await page.evaluate(() => localStorage.getItem("offdesk:token")); const resp = await page.request.delete( `/api/machines/${terminal.machine_id}/terminals/${terminal.id}?device_id=${encodeURIComponent(deviceId ?? "")}`, { headers: { Authorization: `Bearer ${token}` } }, @@ -658,9 +658,9 @@ async function hasXtermInstance( return page.evaluate((tid) => { const map = ( window as unknown as { - __webmuxTerminals?: Map; + __offdeskTerminals?: Map; } - ).__webmuxTerminals; + ).__offdeskTerminals; return map?.has(tid) ?? false; }, terminalId); } @@ -669,9 +669,9 @@ async function getMountedXtermIds(page: Page): Promise { return page.evaluate(() => { const map = ( window as unknown as { - __webmuxTerminals?: Map; + __offdeskTerminals?: Map; } - ).__webmuxTerminals; + ).__offdeskTerminals; return [...(map?.keys() ?? [])]; }); } @@ -683,9 +683,9 @@ async function getXtermSize( return page.evaluate((tid) => { const map = ( window as unknown as { - __webmuxTerminals?: Map; + __offdeskTerminals?: Map; } - ).__webmuxTerminals; + ).__offdeskTerminals; const term = map?.get(tid); return { cols: term?.cols ?? 0, rows: term?.rows ?? 0 }; }, terminalId); @@ -698,7 +698,7 @@ async function getXtermViewportState( return page.evaluate((tid) => { const map = ( window as unknown as { - __webmuxTerminals?: Map< + __offdeskTerminals?: Map< string, { rows: number; @@ -712,7 +712,7 @@ async function getXtermViewportState( } >; } - ).__webmuxTerminals; + ).__offdeskTerminals; const term = map?.get(tid); if (!term) return { rows: 0, cursorVisible: false }; const buffer = term.buffer.active; diff --git a/e2e/tests/mobile-ime-composition.spec.ts b/e2e/tests/mobile-ime-composition.spec.ts index 73201925..ec3dfc1b 100644 --- a/e2e/tests/mobile-ime-composition.spec.ts +++ b/e2e/tests/mobile-ime-composition.spec.ts @@ -77,9 +77,9 @@ async function focusTerminal(page: Page, terminalId: string): Promise { await page.evaluate((tid) => { const map = ( window as unknown as { - __webmuxTerminals?: Map; + __offdeskTerminals?: Map; } - ).__webmuxTerminals; + ).__offdeskTerminals; map?.get(tid)?.focus(); }, terminalId); const focused = await page.evaluate(() => diff --git a/e2e/tests/mobile-link-tap.spec.ts b/e2e/tests/mobile-link-tap.spec.ts index 62ee233d..64b6175f 100644 --- a/e2e/tests/mobile-link-tap.spec.ts +++ b/e2e/tests/mobile-link-tap.spec.ts @@ -54,7 +54,7 @@ test("tapping a terminal hyperlink on touch opens it", async ({ page }) => { const screenRect = screen.getBoundingClientRect(); const terminals = ( window as unknown as { - __webmuxTerminals?: Map< + __offdeskTerminals?: Map< string, { cols: number; @@ -70,7 +70,7 @@ test("tapping a terminal hyperlink on touch opens it", async ({ page }) => { } >; } - ).__webmuxTerminals; + ).__offdeskTerminals; const term = terminals?.values().next().value; if (!term) return null; const cellWidth = screenRect.width / term.cols; diff --git a/e2e/tests/mobile-touch-scroll.spec.ts b/e2e/tests/mobile-touch-scroll.spec.ts index b330026c..defd7af1 100644 --- a/e2e/tests/mobile-touch-scroll.spec.ts +++ b/e2e/tests/mobile-touch-scroll.spec.ts @@ -29,7 +29,7 @@ test.use({ const SCROLLBACK_LINES = 300; interface TouchCapableWindow { - __webmuxTerminals?: Map< + __offdeskTerminals?: Map< string, { rows: number; @@ -51,7 +51,7 @@ async function readPxPerLine(page: Page, terminalId: string): Promise { const pxPerLine = await page.evaluate((tid) => { const term = ( window as unknown as TouchCapableWindow - ).__webmuxTerminals?.get(tid); + ).__offdeskTerminals?.get(tid); if (!term) return null; return ( term._core?._renderService?.dimensions?.css?.cell?.height ?? diff --git a/e2e/tests/terminal-attach-recovery.spec.ts b/e2e/tests/terminal-attach-recovery.spec.ts index a8bfb058..4e015b07 100644 --- a/e2e/tests/terminal-attach-recovery.spec.ts +++ b/e2e/tests/terminal-attach-recovery.spec.ts @@ -34,7 +34,7 @@ test("WS reconnect rebuilds the attach via a fresh tmux client", async ({ const resp = await page.request.post("/api/machines/e2e-node/terminals", { headers: { - Authorization: `Bearer ${await page.evaluate(() => localStorage.getItem("webmux:token"))}`, + Authorization: `Bearer ${await page.evaluate(() => localStorage.getItem("offdesk:token"))}`, }, data: { cwd: "/tmp", @@ -51,8 +51,8 @@ test("WS reconnect rebuilds the attach via a fresh tmux client", async ({ const readBuffer = async (): Promise => page.evaluate((id) => { const map = ( - window as unknown as { __webmuxTerminals?: Map } - ).__webmuxTerminals; + window as unknown as { __offdeskTerminals?: Map } + ).__offdeskTerminals; const term = map?.get(id) as | { buffer: { diff --git a/e2e/tests/terminal-compression.spec.ts b/e2e/tests/terminal-compression.spec.ts index 32f8af56..ef25945f 100644 --- a/e2e/tests/terminal-compression.spec.ts +++ b/e2e/tests/terminal-compression.spec.ts @@ -10,7 +10,7 @@ import { } from "./helpers"; // deflate-raw-v1 is ON by default in the e2e environment (hub, machine, and -// web all support it, and the localStorage "webmux:compress" escape hatch is +// web all support it, and the localStorage "offdesk:compress" escape hatch is // unset), so the entire suite exercises the compressed path; this spec pins // the negotiation and stream integrity explicitly. test("terminal output streams through deflate-raw-v1 when negotiated", async ({ @@ -29,11 +29,11 @@ test("terminal output streams through deflate-raw-v1 when negotiated", async ({ await expandTerminalById(page, tid); // The hub sends the CompressionEnabled ack before any output byte can - // reach the socket; the app exposes it as window.__webmuxCompression. + // reach the socket; the app exposes it as window.__offdeskCompression. await page.waitForFunction( (id) => - (window as unknown as { __webmuxCompression?: Record }) - .__webmuxCompression?.[id] === true, + (window as unknown as { __offdeskCompression?: Record }) + .__offdeskCompression?.[id] === true, tid, { timeout: 15_000 }, ); diff --git a/e2e/tests/terminal-copy-mode-scroll.spec.ts b/e2e/tests/terminal-copy-mode-scroll.spec.ts index 1ad91c7b..0efe15e3 100644 --- a/e2e/tests/terminal-copy-mode-scroll.spec.ts +++ b/e2e/tests/terminal-copy-mode-scroll.spec.ts @@ -78,9 +78,9 @@ async function hasXtermInstance( return page.evaluate((tid) => { const map = ( window as unknown as { - __webmuxTerminals?: Map; + __offdeskTerminals?: Map; } - ).__webmuxTerminals; + ).__offdeskTerminals; return map?.has(tid) ?? false; }, terminalId); } @@ -100,9 +100,9 @@ async function readTerminalText( }; const map = ( window as unknown as { - __webmuxTerminals?: Map; + __offdeskTerminals?: Map; } - ).__webmuxTerminals; + ).__offdeskTerminals; const term = map?.get(tid); if (!term) return ""; const lines: string[] = []; diff --git a/e2e/tests/terminal-copy-on-select.spec.ts b/e2e/tests/terminal-copy-on-select.spec.ts index 65255bb4..346285f3 100644 --- a/e2e/tests/terminal-copy-on-select.spec.ts +++ b/e2e/tests/terminal-copy-on-select.spec.ts @@ -11,7 +11,7 @@ import { // Regression test for the desktop copy-on-select bug where releasing the // mouse OUTSIDE the terminal container left the clipboard untouched. The // xterm SelectionService listens on `document` (so dragging out of the -// viewport still completes the selection); webmux now mirrors that pattern +// viewport still completes the selection); offdesk now mirrors that pattern // by attaching the mouseup listener to `document` from a mousedown inside // the terminal — see TerminalView.xterm.tsx. // @@ -30,7 +30,7 @@ test("copy-on-select writes clipboard even when mouse is released outside the te }) => { await page.addInitScript(() => { const writes: string[] = []; - (window as unknown as { __webmuxClipboardWrites: string[] }).__webmuxClipboardWrites = + (window as unknown as { __offdeskClipboardWrites: string[] }).__offdeskClipboardWrites = writes; Object.defineProperty(navigator, "clipboard", { configurable: true, @@ -55,9 +55,9 @@ test("copy-on-select writes clipboard even when mouse is released outside the te (id) => ( window as unknown as { - __webmuxTerminals?: Map; + __offdeskTerminals?: Map; } - ).__webmuxTerminals?.has(id), + ).__offdeskTerminals?.has(id), terminalId, ); @@ -71,10 +71,10 @@ test("copy-on-select writes clipboard even when mouse is released outside the te ({ id, text }) => { const term = ( window as unknown as { - __webmuxTerminals?: Map>; + __offdeskTerminals?: Map>; } - ).__webmuxTerminals?.get(id); - if (!term) throw new Error("terminal not found in __webmuxTerminals"); + ).__offdeskTerminals?.get(id); + if (!term) throw new Error("terminal not found in __offdeskTerminals"); term.hasSelection = () => true; term.getSelection = () => text; }, @@ -110,9 +110,9 @@ test("copy-on-select writes clipboard even when mouse is released outside the te () => ( window as unknown as { - __webmuxClipboardWrites: string[]; + __offdeskClipboardWrites: string[]; } - ).__webmuxClipboardWrites.join("\n"), + ).__offdeskClipboardWrites.join("\n"), ), { timeout: 5_000 }, ) @@ -124,7 +124,7 @@ test("copy-on-select writes after xterm reports the completed selection", async }) => { await page.addInitScript(() => { const writes: string[] = []; - (window as unknown as { __webmuxClipboardWrites: string[] }).__webmuxClipboardWrites = + (window as unknown as { __offdeskClipboardWrites: string[] }).__offdeskClipboardWrites = writes; Object.defineProperty(navigator, "clipboard", { configurable: true, @@ -149,9 +149,9 @@ test("copy-on-select writes after xterm reports the completed selection", async (id) => ( window as unknown as { - __webmuxTerminals?: Map; + __offdeskTerminals?: Map; } - ).__webmuxTerminals?.has(id), + ).__offdeskTerminals?.has(id), terminalId, ); @@ -161,7 +161,7 @@ test("copy-on-select writes after xterm reports the completed selection", async ({ id, text }) => { const term = ( window as unknown as { - __webmuxTerminals?: Map< + __offdeskTerminals?: Map< string, { hasSelection: () => boolean; @@ -170,8 +170,8 @@ test("copy-on-select writes after xterm reports the completed selection", async } >; } - ).__webmuxTerminals?.get(id); - if (!term) throw new Error("terminal not found in __webmuxTerminals"); + ).__offdeskTerminals?.get(id); + if (!term) throw new Error("terminal not found in __offdeskTerminals"); term.hasSelection = () => true; term.getSelection = () => text; term.select(0, 0, 1); @@ -186,9 +186,9 @@ test("copy-on-select writes after xterm reports the completed selection", async () => ( window as unknown as { - __webmuxClipboardWrites: string[]; + __offdeskClipboardWrites: string[]; } - ).__webmuxClipboardWrites.join("\n"), + ).__offdeskClipboardWrites.join("\n"), ), { timeout: 5_000 }, ) diff --git a/e2e/tests/terminal-fit-stability.spec.ts b/e2e/tests/terminal-fit-stability.spec.ts index 6c84f11c..bf29d95a 100644 --- a/e2e/tests/terminal-fit-stability.spec.ts +++ b/e2e/tests/terminal-fit-stability.spec.ts @@ -190,9 +190,9 @@ async function getLocalTerminalSize( return page.evaluate((tid) => { const map = ( window as unknown as { - __webmuxTerminals?: Map; + __offdeskTerminals?: Map; } - ).__webmuxTerminals; + ).__offdeskTerminals; const terminal = map?.get(tid); if (!terminal) return null; return { cols: terminal.cols, rows: terminal.rows }; @@ -234,9 +234,9 @@ async function readTerminalLayout(page: Page, terminalId: string): Promise<{ const scaledSurface = frame?.firstElementChild as HTMLElement | null; const map = ( window as unknown as { - __webmuxTerminals?: Map; + __offdeskTerminals?: Map; } - ).__webmuxTerminals; + ).__offdeskTerminals; const terminal = map?.get(tid); const rect = (element: Element | null) => { const box = element?.getBoundingClientRect(); diff --git a/e2e/tests/terminal-generated-input.spec.ts b/e2e/tests/terminal-generated-input.spec.ts index 2c9870e9..dc73eca6 100644 --- a/e2e/tests/terminal-generated-input.spec.ts +++ b/e2e/tests/terminal-generated-input.spec.ts @@ -16,9 +16,9 @@ async function waitForRendererReady( (id) => ( window as unknown as { - __webmuxTerminals?: Map void }>; + __offdeskTerminals?: Map void }>; } - ).__webmuxTerminals?.has(id), + ).__offdeskTerminals?.has(id), terminalId, ); } @@ -30,15 +30,15 @@ test("browser-generated terminal attribute responses are not typed into the shel const originalSend = WebSocket.prototype.send; ( window as unknown as { - __webmuxInputFrames?: Array<{ data: string; codes: number[] }>; - __webmuxRawTerminalData?: Array<{ data: string; codes: number[] }>; + __offdeskInputFrames?: Array<{ data: string; codes: number[] }>; + __offdeskRawTerminalData?: Array<{ data: string; codes: number[] }>; } - ).__webmuxInputFrames = []; + ).__offdeskInputFrames = []; ( window as unknown as { - __webmuxRawTerminalData?: Array<{ data: string; codes: number[] }>; + __offdeskRawTerminalData?: Array<{ data: string; codes: number[] }>; } - ).__webmuxRawTerminalData = []; + ).__offdeskRawTerminalData = []; WebSocket.prototype.send = function patchedSend(data) { try { const message = JSON.parse(String(data)) as { @@ -48,9 +48,9 @@ test("browser-generated terminal attribute responses are not typed into the shel if (message.type === "input" && typeof message.data === "string") { ( window as unknown as { - __webmuxInputFrames: Array<{ data: string; codes: number[] }>; + __offdeskInputFrames: Array<{ data: string; codes: number[] }>; } - ).__webmuxInputFrames.push({ + ).__offdeskInputFrames.push({ data: message.data, codes: Array.from(message.data).map((ch) => ch.charCodeAt(0)), }); @@ -71,7 +71,7 @@ test("browser-generated terminal attribute responses are not typed into the shel await page.evaluate((id) => { ( window as unknown as { - __webmuxTerminals?: Map< + __offdeskTerminals?: Map< string, { onData: ( @@ -80,17 +80,17 @@ test("browser-generated terminal attribute responses are not typed into the shel } >; } - ).__webmuxTerminals + ).__offdeskTerminals ?.get(id) ?.onData((data) => { ( window as unknown as { - __webmuxRawTerminalData?: Array<{ + __offdeskRawTerminalData?: Array<{ data: string; codes: number[]; }>; } - ).__webmuxRawTerminalData?.push({ + ).__offdeskRawTerminalData?.push({ data, codes: Array.from(data).map((ch) => ch.charCodeAt(0)), }); @@ -100,31 +100,31 @@ test("browser-generated terminal attribute responses are not typed into the shel await page.evaluate(() => { ( window as unknown as { - __webmuxInputFrames?: Array<{ data: string; codes: number[] }>; - __webmuxRawTerminalData?: Array<{ data: string; codes: number[] }>; + __offdeskInputFrames?: Array<{ data: string; codes: number[] }>; + __offdeskRawTerminalData?: Array<{ data: string; codes: number[] }>; } - ).__webmuxInputFrames = []; + ).__offdeskInputFrames = []; ( window as unknown as { - __webmuxRawTerminalData?: Array<{ data: string; codes: number[] }>; + __offdeskRawTerminalData?: Array<{ data: string; codes: number[] }>; } - ).__webmuxRawTerminalData = []; + ).__offdeskRawTerminalData = []; }); await page.evaluate((id) => { ( window as unknown as { - __webmuxTerminals?: Map void }>; + __offdeskTerminals?: Map void }>; } - ).__webmuxTerminals?.get(id)?.write("\x1b[>c"); + ).__offdeskTerminals?.get(id)?.write("\x1b[>c"); }, terminalId); await page.waitForTimeout(100); const rawTerminalData = await page.evaluate(() => ( window as unknown as { - __webmuxRawTerminalData?: Array<{ data: string; codes: number[] }>; + __offdeskRawTerminalData?: Array<{ data: string; codes: number[] }>; } - ).__webmuxRawTerminalData ?? [], + ).__offdeskRawTerminalData ?? [], ); expect(rawTerminalData.map((frame) => frame.data).join("")).toMatch( /\x1b\[[?>][0-9;]*c/, @@ -133,9 +133,9 @@ test("browser-generated terminal attribute responses are not typed into the shel const inputFrames = await page.evaluate(() => ( window as unknown as { - __webmuxInputFrames?: Array<{ data: string; codes: number[] }>; + __offdeskInputFrames?: Array<{ data: string; codes: number[] }>; } - ).__webmuxInputFrames ?? [], + ).__offdeskInputFrames ?? [], ); expect(inputFrames.map((frame) => frame.data).join("")).not.toMatch( /\x1b\[[?>][0-9;]*c/, diff --git a/e2e/tests/terminal-glyph-rendering.spec.ts b/e2e/tests/terminal-glyph-rendering.spec.ts index d2625f09..734a8955 100644 --- a/e2e/tests/terminal-glyph-rendering.spec.ts +++ b/e2e/tests/terminal-glyph-rendering.spec.ts @@ -61,7 +61,7 @@ async function readTerminalLine( ({ tid, rowIndex }) => { const map = ( window as unknown as { - __webmuxTerminals?: Map< + __offdeskTerminals?: Map< string, { buffer: { @@ -76,7 +76,7 @@ async function readTerminalLine( } >; } - ).__webmuxTerminals; + ).__offdeskTerminals; return ( map ?.get(tid) diff --git a/e2e/tests/terminal-handoff-sizing.spec.ts b/e2e/tests/terminal-handoff-sizing.spec.ts index ba721d68..0ff612ac 100644 --- a/e2e/tests/terminal-handoff-sizing.spec.ts +++ b/e2e/tests/terminal-handoff-sizing.spec.ts @@ -257,9 +257,9 @@ async function getLocalTerminalSize( return page.evaluate((tid) => { const map = ( window as unknown as { - __webmuxTerminals?: Map; + __offdeskTerminals?: Map; } - ).__webmuxTerminals; + ).__offdeskTerminals; const terminal = map?.get(tid); if (!terminal) return null; return { cols: terminal.cols, rows: terminal.rows }; diff --git a/e2e/tests/terminal-image-paste.spec.ts b/e2e/tests/terminal-image-paste.spec.ts index 37b11305..21b85448 100644 --- a/e2e/tests/terminal-image-paste.spec.ts +++ b/e2e/tests/terminal-image-paste.spec.ts @@ -92,8 +92,8 @@ test("single browser image paste injects one image path into the terminal", asyn async function focusTerminal(page: Page, terminalId: string): Promise { await page.evaluate((tid) => { const map = ( - window as unknown as { __webmuxTerminals?: Map void }> } - ).__webmuxTerminals; + window as unknown as { __offdeskTerminals?: Map void }> } + ).__offdeskTerminals; map?.get(tid)?.focus(); }, terminalId); await expect @@ -122,7 +122,7 @@ async function dispatchClipboardImagePaste(page: Page): Promise { const data = new DataTransfer(); data.items.add( - new File(["webmux-image-paste"], "paste.png", { type: "image/png" }), + new File(["offdesk-image-paste"], "paste.png", { type: "image/png" }), ); target.dispatchEvent( new ClipboardEvent("paste", { diff --git a/e2e/tests/terminal-multi-attach.spec.ts b/e2e/tests/terminal-multi-attach.spec.ts index 3a71af41..01b817d7 100644 --- a/e2e/tests/terminal-multi-attach.spec.ts +++ b/e2e/tests/terminal-multi-attach.spec.ts @@ -34,7 +34,7 @@ test("two simultaneous attaches both render the same terminal content", async ({ const resp = await pageA.request.post("/api/machines/e2e-node/terminals", { headers: { - Authorization: `Bearer ${await pageA.evaluate(() => localStorage.getItem("webmux:token"))}`, + Authorization: `Bearer ${await pageA.evaluate(() => localStorage.getItem("offdesk:token"))}`, }, data: { cwd: "/tmp", @@ -59,8 +59,8 @@ test("two simultaneous attaches both render the same terminal content", async ({ async (id: string): Promise => page.evaluate((tid) => { const map = ( - window as unknown as { __webmuxTerminals?: Map } - ).__webmuxTerminals; + window as unknown as { __offdeskTerminals?: Map } + ).__offdeskTerminals; const term = map?.get(tid) as | { buffer: { diff --git a/e2e/tests/terminal-osc52-clipboard.spec.ts b/e2e/tests/terminal-osc52-clipboard.spec.ts index 722ea5f8..d3cdd7f6 100644 --- a/e2e/tests/terminal-osc52-clipboard.spec.ts +++ b/e2e/tests/terminal-osc52-clipboard.spec.ts @@ -13,8 +13,8 @@ test("OSC 52 clipboard writes use the Tauri clipboard bridge when available", as }) => { await page.addInitScript(() => { const writes: string[] = []; - (window as unknown as { __webmuxTauriClipboardWrites: string[] }) - .__webmuxTauriClipboardWrites = writes; + (window as unknown as { __offdeskTauriClipboardWrites: string[] }) + .__offdeskTauriClipboardWrites = writes; Object.defineProperty(navigator, "clipboard", { configurable: true, value: { @@ -30,8 +30,8 @@ test("OSC 52 clipboard writes use the Tauri clipboard bridge when available", as await takeControlFromHeader(page); await page.evaluate(() => { const writes = ( - window as unknown as { __webmuxTauriClipboardWrites: string[] } - ).__webmuxTauriClipboardWrites; + window as unknown as { __offdeskTauriClipboardWrites: string[] } + ).__offdeskTauriClipboardWrites; Object.defineProperty(window, "__TAURI_INTERNALS__", { configurable: true, value: { @@ -55,9 +55,9 @@ test("OSC 52 clipboard writes use the Tauri clipboard bridge when available", as (id) => ( window as unknown as { - __webmuxTerminals?: Map; + __offdeskTerminals?: Map; } - ).__webmuxTerminals?.has(id), + ).__offdeskTerminals?.has(id), terminalId, ); @@ -66,10 +66,10 @@ test("OSC 52 clipboard writes use the Tauri clipboard bridge when available", as ({ id, text }) => { const term = ( window as unknown as { - __webmuxTerminals?: Map void }>; + __offdeskTerminals?: Map void }>; } - ).__webmuxTerminals?.get(id); - if (!term) throw new Error("terminal not found in __webmuxTerminals"); + ).__offdeskTerminals?.get(id); + if (!term) throw new Error("terminal not found in __offdeskTerminals"); term.write(`\x1b]52;c;${btoa(text)}\x07`); }, { id: terminalId, text: marker }, @@ -82,9 +82,9 @@ test("OSC 52 clipboard writes use the Tauri clipboard bridge when available", as () => ( window as unknown as { - __webmuxTauriClipboardWrites: string[]; + __offdeskTauriClipboardWrites: string[]; } - ).__webmuxTauriClipboardWrites.join("\n"), + ).__offdeskTauriClipboardWrites.join("\n"), ), { timeout: 5_000 }, ) diff --git a/e2e/tests/terminal-selection-scale.spec.ts b/e2e/tests/terminal-selection-scale.spec.ts index 7e1fd908..fee50158 100644 --- a/e2e/tests/terminal-selection-scale.spec.ts +++ b/e2e/tests/terminal-selection-scale.spec.ts @@ -72,7 +72,7 @@ async function readBufferLine( ({ tid, rowIndex }) => { const map = ( window as unknown as { - __webmuxTerminals?: Map< + __offdeskTerminals?: Map< string, { buffer: { @@ -87,7 +87,7 @@ async function readBufferLine( } >; } - ).__webmuxTerminals; + ).__offdeskTerminals; return ( map ?.get(tid) @@ -106,9 +106,9 @@ async function readSelection( return page.evaluate((tid) => { const map = ( window as unknown as { - __webmuxTerminals?: Map string }>; + __offdeskTerminals?: Map string }>; } - ).__webmuxTerminals; + ).__offdeskTerminals; return map?.get(tid)?.getSelection() ?? ""; }, terminalId); } @@ -121,9 +121,9 @@ async function readScaledTerminalLayout(page: Page): Promise { const screen = root?.querySelector(".xterm-screen") as HTMLElement | null; const map = ( window as unknown as { - __webmuxTerminals?: Map; + __offdeskTerminals?: Map; } - ).__webmuxTerminals; + ).__offdeskTerminals; const terminal = Array.from(map?.values() ?? [])[0]; const rect = screen?.getBoundingClientRect(); if (!root || !screen || !terminal || !rect) { diff --git a/e2e/tests/terminal-wheel-scroll.spec.ts b/e2e/tests/terminal-wheel-scroll.spec.ts index 6260f56a..34a15405 100644 --- a/e2e/tests/terminal-wheel-scroll.spec.ts +++ b/e2e/tests/terminal-wheel-scroll.spec.ts @@ -68,9 +68,9 @@ async function hasXtermInstance( return page.evaluate((tid) => { const map = ( window as unknown as { - __webmuxTerminals?: Map; + __offdeskTerminals?: Map; } - ).__webmuxTerminals; + ).__offdeskTerminals; return map?.has(tid) ?? false; }, terminalId); } diff --git a/e2e/tests/workspace-keepalive.spec.ts b/e2e/tests/workspace-keepalive.spec.ts index 33147703..a2b2868a 100644 --- a/e2e/tests/workspace-keepalive.spec.ts +++ b/e2e/tests/workspace-keepalive.spec.ts @@ -21,7 +21,7 @@ import { // /ws/terminal/ URL (a re-attach means a re-mount), and __wsInputByUrl logs // every {type:"input"} payload per socket URL (same wrapping pattern as // mobile-ime-composition.spec.ts, but keyed per socket). Buffer reads go -// through the __webmuxTerminals xterm instances (translateToString). +// through the __offdeskTerminals xterm instances (translateToString). test.use({ viewport: { width: 1440, height: 960 } }); @@ -105,9 +105,9 @@ async function focusTerminal(page: Page, terminalId: string): Promise { await page.evaluate((tid) => { const map = ( window as unknown as { - __webmuxTerminals?: Map; + __offdeskTerminals?: Map; } - ).__webmuxTerminals; + ).__offdeskTerminals; map?.get(tid)?.focus(); }, terminalId); const focused = await page.evaluate(() => diff --git a/e2e/tests/workspace-tabs.spec.ts b/e2e/tests/workspace-tabs.spec.ts index b6154a70..f4d09cd2 100644 --- a/e2e/tests/workspace-tabs.spec.ts +++ b/e2e/tests/workspace-tabs.spec.ts @@ -565,7 +565,7 @@ test("prefix bindings switch groups with a custom second key", async ({ }); await context.addInitScript(() => { localStorage.setItem( - "webmux:prefix-bindings", + "offdesk:prefix-bindings", JSON.stringify({ nextTab: "g", prevTab: "f", @@ -620,7 +620,7 @@ test("settings can record prefix bindings", async ({ page }) => { await expect(recorder).toHaveText("⌃B g"); const stored = await page.evaluate(() => - JSON.parse(localStorage.getItem("webmux:prefix-bindings") ?? "{}"), + JSON.parse(localStorage.getItem("offdesk:prefix-bindings") ?? "{}"), ); expect(stored.nextTab).toBe("g"); }); @@ -637,7 +637,7 @@ test("settings reject duplicate prefix bindings", async ({ page }) => { .toContainText("Focus pane left"); await expect(recorder).toHaveText("⌃B + key..."); const stored = await page.evaluate(() => - JSON.parse(localStorage.getItem("webmux:prefix-bindings") ?? "{}"), + JSON.parse(localStorage.getItem("offdesk:prefix-bindings") ?? "{}"), ); expect(stored.nextTab).toBeUndefined(); }); diff --git a/package.json b/package.json index ae17d520..7d41b07b 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,11 @@ { - "name": "webmux", + "name": "offdesk", "private": true, "type": "module", "packageManager": "pnpm@10.23.0", "scripts": { - "dev": "pnpm --filter @webmux/shared build && pnpm --filter @webmux/app dev:web", - "build": "pnpm --filter @webmux/shared build && pnpm --filter @webmux/app build", + "dev": "pnpm --filter @offdesk/shared build && pnpm --filter @offdesk/app dev:web", + "build": "pnpm --filter @offdesk/shared build && pnpm --filter @offdesk/app build", "test": "vitest run", "typecheck": "tsc -b", "e2e:up": "docker compose -f e2e/docker-compose.yml up -d --build hub node", @@ -13,8 +13,8 @@ "e2e:test": "./e2e/run-in-docker.sh", "e2e:ci": "./e2e/run-in-docker.sh", "e2e:test:debug-host": "playwright test", - "desktop:dev": "pnpm --filter @webmux/desktop dev", - "desktop:build": "pnpm --filter @webmux/desktop build" + "desktop:dev": "pnpm --filter @offdesk/desktop dev", + "desktop:build": "pnpm --filter @offdesk/desktop build" }, "devDependencies": { "@playwright/test": "^1.59.1", diff --git a/packages/app/app.config.js b/packages/app/app.config.js index a9dacee8..4fb1b800 100644 --- a/packages/app/app.config.js +++ b/packages/app/app.config.js @@ -10,16 +10,16 @@ const withCleartextTraffic = (config) => }); const plugins = ["expo-router"]; -if (process.env.WEBMUX_ALLOW_CLEARTEXT === "1") { +if (process.env.OFFDESK_ALLOW_CLEARTEXT === "1") { plugins.push(withCleartextTraffic); } module.exports = ({ config }) => ({ ...config, - name: "webmux", - slug: "webmux", - version: process.env.WEBMUX_APP_VERSION || "0.1.0", - scheme: "webmux", + name: "offdesk", + slug: "offdesk", + version: process.env.OFFDESK_APP_VERSION || "0.1.0", + scheme: "offdesk", userInterfaceStyle: "dark", platforms: ["web", "android"], web: { @@ -34,13 +34,13 @@ module.exports = ({ config }) => ({ }, plugins, android: { - package: "com.webmux.app", + package: "dev.offdesk.app", permissions: ["REQUEST_INSTALL_PACKAGES"], }, extra: { defaultServerUrl: - process.env.EXPO_PUBLIC_WEBMUX_DEFAULT_SERVER_URL || - process.env.WEBMUX_DEFAULT_SERVER_URL || + process.env.EXPO_PUBLIC_OFFDESK_DEFAULT_SERVER_URL || + process.env.OFFDESK_DEFAULT_SERVER_URL || null, }, }); diff --git a/packages/app/app/_layout.tsx b/packages/app/app/_layout.tsx index a7b7396f..daac23f3 100644 --- a/packages/app/app/_layout.tsx +++ b/packages/app/app/_layout.tsx @@ -1,4 +1,5 @@ import "../global.css"; +import "../lib/legacyStorageMigration"; import { Component, type ErrorInfo, type ReactNode } from "react"; import { Slot } from "expo-router"; import { SafeAreaProvider } from "react-native-safe-area-context"; diff --git a/packages/app/app/login.tsx b/packages/app/app/login.tsx index 9d643c4b..43b55745 100644 --- a/packages/app/app/login.tsx +++ b/packages/app/app/login.tsx @@ -57,7 +57,7 @@ export default function LoginScreen() { - webmux + offdesk Connect to your server @@ -106,7 +106,7 @@ export default function LoginScreen() { - webmux + offdesk Sign in to continue diff --git a/packages/app/components/AgentBadge.web.tsx b/packages/app/components/AgentBadge.web.tsx index 76814e08..8dc9746f 100644 --- a/packages/app/components/AgentBadge.web.tsx +++ b/packages/app/components/AgentBadge.web.tsx @@ -4,7 +4,7 @@ // (the brand accent) is reserved for the `asked` state on agent sessions; // terminal rows never show amber. -import type { AgentKind, AgentSessionStatus } from "@webmux/shared"; +import type { AgentKind, AgentSessionStatus } from "@offdesk/shared"; import { colors } from "@/lib/colors"; export type SessionKind = AgentKind | "terminal"; @@ -88,7 +88,7 @@ export function AgentStatusDot({ status }: { status: AgentSessionStatus }) { strokeWidth={3} strokeLinecap="round" aria-hidden - style={{ flexShrink: 0, animation: "webmuxSpin 1.6s linear infinite" }} + style={{ flexShrink: 0, animation: "offdeskSpin 1.6s linear infinite" }} > @@ -125,7 +125,7 @@ export function AgentStatusDot({ status }: { status: AgentSessionStatus }) { borderRadius: 999, background: colors.fg3, flexShrink: 0, - animation: "webmuxBlink 1.2s step-start infinite", + animation: "offdeskBlink 1.2s step-start infinite", }} /> ); diff --git a/packages/app/components/AgentChatView.web.tsx b/packages/app/components/AgentChatView.web.tsx index c2526176..7487df40 100644 --- a/packages/app/components/AgentChatView.web.tsx +++ b/packages/app/components/AgentChatView.web.tsx @@ -27,7 +27,7 @@ import { useState, } from "react"; import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent } from "react"; -import type { AgentSessionInfo } from "@webmux/shared"; +import type { AgentSessionInfo } from "@offdesk/shared"; import { ArrowUp, ChevronDown, ChevronRight, Square, X } from "lucide-react"; import { colors, colorAlpha } from "@/lib/colors"; import { putAgentSessionSeen } from "@/lib/api"; @@ -921,7 +921,7 @@ function toolStatusIcon(status: string) { strokeWidth={3} strokeLinecap="round" aria-hidden - style={{ animation: "webmuxSpin 1.6s linear infinite" }} + style={{ animation: "offdeskSpin 1.6s linear infinite" }} > diff --git a/packages/app/components/ExtendedKeyBar.tsx b/packages/app/components/ExtendedKeyBar.tsx index bf1ed5e4..64c68e98 100644 --- a/packages/app/components/ExtendedKeyBar.tsx +++ b/packages/app/components/ExtendedKeyBar.tsx @@ -290,7 +290,7 @@ export function ExtendedKeyBar({ strokeWidth="2.5" strokeLinecap="round" style={{ - animation: 'webmuxSpin 800ms linear infinite', + animation: 'offdeskSpin 800ms linear infinite', transformOrigin: 'center', }} data-testid="extended-keybar-attach-spinner" diff --git a/packages/app/components/MobileWorkbench.web.tsx b/packages/app/components/MobileWorkbench.web.tsx index 6a120b0b..ea67c545 100644 --- a/packages/app/components/MobileWorkbench.web.tsx +++ b/packages/app/components/MobileWorkbench.web.tsx @@ -21,7 +21,7 @@ import type { MachineInfo, ResourceStats, TerminalInfo, -} from "@webmux/shared"; +} from "@offdesk/shared"; import { ChevronRight, CircuitBoard, @@ -1434,7 +1434,7 @@ function Sheet({ background: "rgba(0, 0, 0, 0.5)", display: "flex", alignItems: "flex-end", - animation: "webmuxFadeIn 120ms ease-out", + animation: "offdeskFadeIn 120ms ease-out", }} >
- Install webmux-node on the machine you want to manage, + Install offdesk-node on the machine you want to manage, then register it with the commands below.

@@ -275,7 +275,7 @@ export function OnboardingView({
{/* Step 1: Install */} diff --git a/packages/app/components/SettingsPage.tsx b/packages/app/components/SettingsPage.tsx index 70a85333..2b9d0945 100644 --- a/packages/app/components/SettingsPage.tsx +++ b/packages/app/components/SettingsPage.tsx @@ -25,11 +25,11 @@ import { import { ArrowLeft } from "lucide-react"; // Frontend build id stamped into index.html by the Docker build -// (window.__WEBMUX_BUILD__). "dev" when running unstamped (local dev). +// (window.__OFFDESK_BUILD__). "dev" when running unstamped (local dev). function getFrontendBuildId(): string { if (typeof window === "undefined") return "dev"; - const id = (window as unknown as { __WEBMUX_BUILD__?: string }) - .__WEBMUX_BUILD__; + const id = (window as unknown as { __OFFDESK_BUILD__?: string }) + .__OFFDESK_BUILD__; return id || "dev"; } @@ -272,18 +272,18 @@ function formatTokenDate(ms: number | null): string { export function SettingsPage({ onClose }: SettingsPageProps) { // Terminal font settings const [terminalFont, setTerminalFont] = useState( - () => localStorage.getItem("webmux:terminal-font-family") || "", + () => localStorage.getItem("offdesk:terminal-font-family") || "", ); const [terminalFontSize, setTerminalFontSize] = useState( - () => localStorage.getItem("webmux:terminal-font-size") || "", + () => localStorage.getItem("offdesk:terminal-font-size") || "", ); // UI font settings const [uiFont, setUiFont] = useState( - () => localStorage.getItem("webmux:ui-font-family") || "", + () => localStorage.getItem("offdesk:ui-font-family") || "", ); const [uiFontSize, setUiFontSize] = useState( - () => localStorage.getItem("webmux:ui-font-size") || "", + () => localStorage.getItem("offdesk:ui-font-size") || "", ); // Quick commands @@ -383,9 +383,9 @@ export function SettingsPage({ onClose }: SettingsPageProps) { const handleTerminalFontChange = useCallback((value: string) => { setTerminalFont(value); if (value) { - localStorage.setItem("webmux:terminal-font-family", value); + localStorage.setItem("offdesk:terminal-font-family", value); } else { - localStorage.removeItem("webmux:terminal-font-family"); + localStorage.removeItem("offdesk:terminal-font-family"); } }, []); @@ -395,9 +395,9 @@ export function SettingsPage({ onClose }: SettingsPageProps) { setTerminalFontSize(v); const size = parseInt(v, 10); if (size >= 10 && size <= 24) { - localStorage.setItem("webmux:terminal-font-size", String(size)); + localStorage.setItem("offdesk:terminal-font-size", String(size)); } else if (!v) { - localStorage.removeItem("webmux:terminal-font-size"); + localStorage.removeItem("offdesk:terminal-font-size"); } }, [], @@ -407,9 +407,9 @@ export function SettingsPage({ onClose }: SettingsPageProps) { const handleUiFontChange = useCallback((value: string) => { setUiFont(value); if (value) { - localStorage.setItem("webmux:ui-font-family", value); + localStorage.setItem("offdesk:ui-font-family", value); } else { - localStorage.removeItem("webmux:ui-font-family"); + localStorage.removeItem("offdesk:ui-font-family"); } }, []); @@ -419,10 +419,10 @@ export function SettingsPage({ onClose }: SettingsPageProps) { setUiFontSize(v); const size = parseInt(v, 10); if (size >= 10 && size <= 20) { - localStorage.setItem("webmux:ui-font-size", String(size)); + localStorage.setItem("offdesk:ui-font-size", String(size)); document.documentElement.style.fontSize = `${size}px`; } else if (!v) { - localStorage.removeItem("webmux:ui-font-size"); + localStorage.removeItem("offdesk:ui-font-size"); document.documentElement.style.fontSize = ""; } }, @@ -972,7 +972,7 @@ export function SettingsPage({ onClose }: SettingsPageProps) { marginBottom: 12, }} > - Tokens (wmx_…) used to authenticate the webmux CLI + Tokens (odk_…) used to authenticate the offdesk CLI
{/* Create */} diff --git a/packages/app/components/Sidebar.web.tsx b/packages/app/components/Sidebar.web.tsx index dcd3f2df..20df2a9c 100644 --- a/packages/app/components/Sidebar.web.tsx +++ b/packages/app/components/Sidebar.web.tsx @@ -24,7 +24,7 @@ import { useState, } from "react"; import type { CSSProperties, MouseEvent as ReactMouseEvent } from "react"; -import type { ResourceStats } from "@webmux/shared"; +import type { ResourceStats } from "@offdesk/shared"; import { ChevronDown, Lock, LogOut, MessageCircle, Plus, Settings } from "lucide-react"; import { ContextMenu, type ContextMenuEntry } from "./ContextMenu"; import { colors, colorAlpha } from "@/lib/colors"; @@ -373,7 +373,7 @@ function SidebarComponent({ color: colors.fg2, }} > - WEBMUX + offdesk
diff --git a/packages/app/components/TerminalView.xterm.tsx b/packages/app/components/TerminalView.xterm.tsx index 46f26601..b58e97dd 100644 --- a/packages/app/components/TerminalView.xterm.tsx +++ b/packages/app/components/TerminalView.xterm.tsx @@ -396,7 +396,7 @@ function formatErr(err: unknown): string { // was never registered. Tapping the toast copies the URL. function showLinkDiagnostic(url: string, errors: string[]): void { if (typeof document === "undefined") return; - const id = "webmux-link-diagnostic"; + const id = "offdesk-link-diagnostic"; document.getElementById(id)?.remove(); const div = document.createElement("div"); div.id = id; @@ -420,7 +420,7 @@ function showLinkDiagnostic(url: string, errors: string[]): void { // devtools (production Tauri builds). Auto-removes after 8 seconds. function showCopyDiagnostic(message: string): void { if (typeof document === "undefined") return; - const id = "webmux-copy-diagnostic"; + const id = "offdesk-copy-diagnostic"; const existing = document.getElementById(id); existing?.remove(); const div = document.createElement("div"); @@ -467,7 +467,7 @@ export const TerminalView = forwardRef( useRef(null); const inputBatcherRef = useRef(null); // Stamped with the send time of each input batch when the echo-latency - // probe is enabled (localStorage webmux:echo-probe=1); the live socket + // probe is enabled (localStorage offdesk:echo-probe=1); the live socket // turns the first output after it into a round-trip sample. const echoProbeSentAtRef = useRef(null); const [viewportSize, setViewportSize] = useState({ width: 0, height: 0 }); @@ -560,7 +560,7 @@ export const TerminalView = forwardRef( } catch (err) { tauriError = err; // eslint-disable-next-line no-console - console.warn("[webmux] tauri clipboard invoke failed", err); + console.warn("[offdesk] tauri clipboard invoke failed", err); } } else { showCopyDiagnostic("__TAURI_INTERNALS__ not available"); @@ -574,7 +574,7 @@ export const TerminalView = forwardRef( : "Browser clipboard failed"; showCopyDiagnostic(`${prefix}: ${formatErr(err)}`); // eslint-disable-next-line no-console - console.warn("[webmux] navigator.clipboard.writeText failed", err); + console.warn("[offdesk] navigator.clipboard.writeText failed", err); throw err; } }, []); @@ -597,7 +597,7 @@ export const TerminalView = forwardRef( return typeof text === "string" ? text : ""; } catch (err) { // eslint-disable-next-line no-console - console.warn("[webmux] tauri clipboard read failed", err); + console.warn("[offdesk] tauri clipboard read failed", err); } } } @@ -746,8 +746,8 @@ export const TerminalView = forwardRef( const container = containerRef.current; if (!container) return; - const userFont = localStorage.getItem("webmux:terminal-font-family"); - const userFontSize = localStorage.getItem("webmux:terminal-font-size"); + const userFont = localStorage.getItem("offdesk:terminal-font-family"); + const userFontSize = localStorage.getItem("offdesk:terminal-font-size"); const fontFamily = resolveTerminalFontFamily(userFont); const fontSize = userFontSize ? Math.max(10, Math.min(24, parseInt(userFontSize, 10) || 14)) : 14; @@ -853,20 +853,20 @@ export const TerminalView = forwardRef( // Expose the Terminal instance for Playwright E2E tests. Renderer DOM // shape is not stable across xterm versions, so tests read content via // `term.buffer.active.getLine(i).translateToString` through this map. - // Gated behind localStorage("webmux:e2e")==="1" so production builds + // Gated behind localStorage("offdesk:e2e")==="1" so production builds // never expose live xterm internals on window. if ( typeof window !== "undefined" && typeof localStorage !== "undefined" && - localStorage.getItem("webmux:e2e") === "1" + localStorage.getItem("offdesk:e2e") === "1" ) { const winAny = window as unknown as { - __webmuxTerminals?: Map; + __offdeskTerminals?: Map; }; - if (!winAny.__webmuxTerminals) { - winAny.__webmuxTerminals = new Map(); + if (!winAny.__offdeskTerminals) { + winAny.__offdeskTerminals = new Map(); } - winAny.__webmuxTerminals.set(terminalId, term); + winAny.__offdeskTerminals.set(terminalId, term); } // Forward terminal input to the current WebSocket: xterm's hidden @@ -880,7 +880,7 @@ export const TerminalView = forwardRef( // time inside the send callback. Command/image sends flush first so // cross-type message ordering is preserved. const echoProbeEnabled = - localStorage.getItem("webmux:echo-probe") === "1"; + localStorage.getItem("offdesk:echo-probe") === "1"; const batcher = createInputBatcher((data) => { const ws = wsRef.current; if (ws?.readyState === WebSocket.OPEN && canTypeRef.current) { @@ -955,7 +955,7 @@ export const TerminalView = forwardRef( term.clearSelection(); }).catch((err) => { // eslint-disable-next-line no-console - console.warn("[webmux] Cmd/Ctrl+C clipboard write failed", err); + console.warn("[offdesk] Cmd/Ctrl+C clipboard write failed", err); }); return false; } @@ -1017,7 +1017,7 @@ export const TerminalView = forwardRef( if (file.size > MAX_DROP_BYTES) { // eslint-disable-next-line no-console console.warn( - `[webmux] skipping ${file.name}: ${file.size} bytes exceeds ${MAX_DROP_BYTES}`, + `[offdesk] skipping ${file.name}: ${file.size} bytes exceeds ${MAX_DROP_BYTES}`, ); continue; } @@ -1063,7 +1063,7 @@ export const TerminalView = forwardRef( writeText: clipboardWrite, onError: (err) => { // eslint-disable-next-line no-console - console.warn("[webmux] copy-on-select clipboard write failed", err); + console.warn("[offdesk] copy-on-select clipboard write failed", err); }, }); const selectionChangeDisposable = term.onSelectionChange(() => { @@ -1277,11 +1277,11 @@ export const TerminalView = forwardRef( container.removeEventListener("touchend", onTouchEnd); if (typeof window !== "undefined") { const winAny = window as unknown as { - __webmuxTerminals?: Map; + __offdeskTerminals?: Map; }; // Map only exists when the test-hook flag was set; delete is a no-op // otherwise because the map itself was never created. - winAny.__webmuxTerminals?.delete(terminalId); + winAny.__offdeskTerminals?.delete(terminalId); } restoreMouseCoordinates(); restoreCompositionHelper(); diff --git a/packages/app/components/TerminalWorkspace.web.tsx b/packages/app/components/TerminalWorkspace.web.tsx index 15c2c217..0a1b5adb 100644 --- a/packages/app/components/TerminalWorkspace.web.tsx +++ b/packages/app/components/TerminalWorkspace.web.tsx @@ -15,7 +15,7 @@ import type { WorkspaceGroupInfo, WorkspaceLayoutInfo, WorkspaceLayoutNode, -} from "@webmux/shared"; +} from "@offdesk/shared"; import { Plus } from "lucide-react"; import { ContextMenu, type ContextMenuEntry } from "./ContextMenu"; import { TerminalCard, type TerminalCardRef } from "./TerminalCard.web"; diff --git a/packages/app/components/newSessionState.ts b/packages/app/components/newSessionState.ts index ae113e2d..28080610 100644 --- a/packages/app/components/newSessionState.ts +++ b/packages/app/components/newSessionState.ts @@ -12,7 +12,7 @@ import type { Bookmark, MachineInfo, TerminalInfo, -} from "@webmux/shared"; +} from "@offdesk/shared"; import { listBookmarks } from "@/lib/api"; import { modelsForAgentKind, diff --git a/packages/app/components/useTerminalLiveSocket.ts b/packages/app/components/useTerminalLiveSocket.ts index 6e522f28..79ec0ad1 100644 --- a/packages/app/components/useTerminalLiveSocket.ts +++ b/packages/app/components/useTerminalLiveSocket.ts @@ -11,7 +11,7 @@ interface UseTerminalLiveSocketOptions { wsUrl?: string; terminalId?: string; // Echo-latency probe: TerminalView stamps this with the send time of each - // input batch when localStorage webmux:echo-probe=1. Stays null otherwise, + // input batch when localStorage offdesk:echo-probe=1. Stays null otherwise, // which keeps the probe a single null check per output chunk. echoProbeSentAtRef?: RefObject; scheduleMeasure: () => void; @@ -85,7 +85,7 @@ export function useTerminalLiveSocket({ // Passive echo-latency probe: the first output chunk after an input // send approximates the keystroke echo round trip. Samples land on - // window.__webmuxEcho[terminalId]; a summary is logged every 20. + // window.__offdeskEcho[terminalId]; a summary is logged every 20. const recordEchoProbeSample = () => { const ref = echoProbeSentAtRef; const sentAt = ref?.current; @@ -94,9 +94,9 @@ export function useTerminalLiveSocket({ const sample = performance.now() - sentAt; if (sample > ECHO_PROBE_WINDOW_MS) return; const winAny = window as unknown as { - __webmuxEcho?: Record; + __offdeskEcho?: Record; }; - const store = (winAny.__webmuxEcho ??= {}); + const store = (winAny.__offdeskEcho ??= {}); const prev = store[terminalId]; const next: EchoProbeStats = prev ? { @@ -109,7 +109,7 @@ export function useTerminalLiveSocket({ if (next.n % 20 === 0) { // eslint-disable-next-line no-console console.log( - `[webmux] echo probe ${terminalId}: last=${next.last.toFixed(1)}ms ema=${next.ema.toFixed(1)}ms n=${next.n}`, + `[offdesk] echo probe ${terminalId}: last=${next.last.toFixed(1)}ms ema=${next.ema.toFixed(1)}ms n=${next.n}`, ); } }; @@ -148,16 +148,16 @@ export function useTerminalLiveSocket({ onAck: () => { if (!terminalId) return; const winAny = window as unknown as { - __webmuxCompression?: Record; + __offdeskCompression?: Record; }; - (winAny.__webmuxCompression ??= {})[terminalId] = true; + (winAny.__offdeskCompression ??= {})[terminalId] = true; }, onError: (error) => { // Inflate errors are unrecoverable (the stream context is corrupt): // log once and close so the reconnect path re-attaches with a fresh // context — renegotiation may also land uncompressed. // eslint-disable-next-line no-console - console.warn("[webmux] deflate-raw-v1 inflate failed, closing socket", error); + console.warn("[offdesk] deflate-raw-v1 inflate failed, closing socket", error); ws.close(); }, }); diff --git a/packages/app/global.css b/packages/app/global.css index eb62e906..33df1203 100644 --- a/packages/app/global.css +++ b/packages/app/global.css @@ -127,22 +127,22 @@ body { -ms-overflow-style: none; } -@keyframes webmuxFadeIn { +@keyframes offdeskFadeIn { from { opacity: 0; } to { opacity: 1; } } -@keyframes webmuxSlideUp { +@keyframes offdeskSlideUp { from { transform: translateY(16px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } -@keyframes webmuxBlink { +@keyframes offdeskBlink { 0%, 50% { opacity: 1; } 50.01%, 100% { opacity: 0; } } -@keyframes webmuxSpin { +@keyframes offdeskSpin { to { transform: rotate(360deg); } } -@keyframes webmuxReconnect { +@keyframes offdeskReconnect { from { transform: translateX(-100%); } to { transform: translateX(265%); } } diff --git a/packages/app/lib/agentSessionFeed.test.ts b/packages/app/lib/agentSessionFeed.test.ts index 596da590..abb446f9 100644 --- a/packages/app/lib/agentSessionFeed.test.ts +++ b/packages/app/lib/agentSessionFeed.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { AgentEvent } from "@webmux/shared"; +import type { AgentEvent } from "@offdesk/shared"; vi.mock("./api", () => ({ getAgentSessionEvents: vi.fn(), diff --git a/packages/app/lib/agentSessionFeed.ts b/packages/app/lib/agentSessionFeed.ts index b543cf83..d8ee6293 100644 --- a/packages/app/lib/agentSessionFeed.ts +++ b/packages/app/lib/agentSessionFeed.ts @@ -19,7 +19,7 @@ // The store core is DOM-free; only the hook at the bottom touches React. import { useCallback, useEffect, useSyncExternalStore } from "react"; -import type { AgentEvent } from "@webmux/shared"; +import type { AgentEvent } from "@offdesk/shared"; import { getAgentSessionEvents } from "./api"; import { diff --git a/packages/app/lib/agentStarting.ts b/packages/app/lib/agentStarting.ts index 22b66dc6..ffd000eb 100644 --- a/packages/app/lib/agentStarting.ts +++ b/packages/app/lib/agentStarting.ts @@ -6,7 +6,7 @@ // as broken. import { useEffect, useRef, useState } from "react"; -import type { AgentKind } from "@webmux/shared"; +import type { AgentKind } from "@offdesk/shared"; export const COLD_START_HINT: Partial> = { claude: "claude 冷启动约 1 分钟", diff --git a/packages/app/lib/agentTranscript.test.ts b/packages/app/lib/agentTranscript.test.ts index e0081696..63f03b77 100644 --- a/packages/app/lib/agentTranscript.test.ts +++ b/packages/app/lib/agentTranscript.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { AgentEvent } from "@webmux/shared"; +import type { AgentEvent } from "@offdesk/shared"; import { createAgentTranscript, type TranscriptBlock } from "./agentTranscript"; diff --git a/packages/app/lib/agentTranscript.ts b/packages/app/lib/agentTranscript.ts index 75796be2..f5e8cb6c 100644 --- a/packages/app/lib/agentTranscript.ts +++ b/packages/app/lib/agentTranscript.ts @@ -1,7 +1,7 @@ // Incremental builder for the agent-session chat transcript. // // Agent sessions stream normalized ACP events (`AgentEvent` from -// `@webmux/shared`), each tagged with a per-session monotonic `seq`. The same +// `@offdesk/shared`), each tagged with a per-session monotonic `seq`. The same // builder serves a backfill page (events replayed in seq order) and live // continuation, so `apply` dedups: any event with `seq <= last applied seq` is // dropped. @@ -10,7 +10,7 @@ // are O(1)-ish: the currently open assistant/thought block is tracked by index // instead of rescanning the block list. -import type { AgentEvent, AgentQuestionOption } from "@webmux/shared"; +import type { AgentEvent, AgentQuestionOption } from "@offdesk/shared"; export type TranscriptBlock = | { kind: "user"; id: string; text: string } diff --git a/packages/app/lib/api.test.ts b/packages/app/lib/api.test.ts index 9b3bebf4..c5128866 100644 --- a/packages/app/lib/api.test.ts +++ b/packages/app/lib/api.test.ts @@ -52,7 +52,7 @@ describe("api request", () => { JSON.stringify({ id: "tok-1", name: "cli", - token: "wmx_abc", + token: "odk_abc", created_at: 123, }), { status: 200 }, @@ -61,7 +61,7 @@ describe("api request", () => { .mockResolvedValueOnce(new Response(null, { status: 204 })); await expect(createApiToken("cli")).resolves.toMatchObject({ - token: "wmx_abc", + token: "odk_abc", }); expect(fetchMock).toHaveBeenNthCalledWith( 1, diff --git a/packages/app/lib/api.ts b/packages/app/lib/api.ts index 78f4cd55..0d2d1bae 100644 --- a/packages/app/lib/api.ts +++ b/packages/app/lib/api.ts @@ -12,7 +12,7 @@ import type { AgentKind, AgentEvent, AgentSessionInfo, -} from "@webmux/shared"; +} from "@offdesk/shared"; import { generateDeviceId } from "./deviceIdShared"; @@ -412,10 +412,10 @@ export function terminalWsUrl( if (deviceId) params.set("device_id", deviceId); // deflate-raw-v1 request: the hub acks only if the machine also supports // it; old hubs ignore the param and the stream stays uncompressed. - // Escape hatch: localStorage "webmux:compress" === "off". + // Escape hatch: localStorage "offdesk:compress" === "off". if ( typeof localStorage !== "undefined" && - localStorage.getItem("webmux:compress") !== "off" + localStorage.getItem("offdesk:compress") !== "off" ) { params.set("compress", "deflate-raw-v1"); } diff --git a/packages/app/lib/attachCompression.test.ts b/packages/app/lib/attachCompression.test.ts index 9feae393..50d2af45 100644 --- a/packages/app/lib/attachCompression.test.ts +++ b/packages/app/lib/attachCompression.test.ts @@ -31,7 +31,7 @@ function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { const MESSAGES = [ new TextEncoder().encode("\x1b[38;5;246mdrwxr-xr-x 2 user user dir\x1b[0m\r\n"), - new TextEncoder().encode("\x1b[2K\rbuilding crate webmux ... 128/256\r\n"), + new TextEncoder().encode("\x1b[2K\rbuilding crate offdesk ... 128/256\r\n"), new TextEncoder().encode("\x1b[38;5;246mdrwxr-xr-x 2 user user dir\x1b[0m\r\n"), ]; diff --git a/packages/app/lib/auth.tsx b/packages/app/lib/auth.tsx index 9f3c6ef7..5515990d 100644 --- a/packages/app/lib/auth.tsx +++ b/packages/app/lib/auth.tsx @@ -10,7 +10,7 @@ import type { ReactNode } from "react"; import { Platform } from "react-native"; import { configure, devLogin, getMe } from "./api"; -import type { User } from "@webmux/shared"; +import type { User } from "@offdesk/shared"; import { storage } from "./storage"; import { getServerUrl } from "./serverUrl"; import { isTauri, isTauriMobile } from "./platform"; @@ -19,7 +19,7 @@ export type { User }; const TOKEN_KEY = "token"; const GET_ME_TIMEOUT_MS = 10_000; -const DESKTOP_CALLBACK_KEY = "webmux:desktop_callback"; +const DESKTOP_CALLBACK_KEY = "offdesk:desktop_callback"; export interface AuthContextType { user: User | null; diff --git a/packages/app/lib/bookmarkContract.test.ts b/packages/app/lib/bookmarkContract.test.ts index 7bf54b3e..26c3b999 100644 --- a/packages/app/lib/bookmarkContract.test.ts +++ b/packages/app/lib/bookmarkContract.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { Bookmark } from "@webmux/shared"; +import type { Bookmark } from "@offdesk/shared"; // Pins the wire shape of `GET /api/machines/{id}/bookmarks` against the TS // type. The Rust handler (crates/hub/src/routes/bookmarks.rs) returns diff --git a/packages/app/lib/bootstrapState.test.ts b/packages/app/lib/bootstrapState.test.ts index 76fe3da9..70a03690 100644 --- a/packages/app/lib/bootstrapState.test.ts +++ b/packages/app/lib/bootstrapState.test.ts @@ -3,7 +3,7 @@ import type { AgentSessionInfo, BrowserEvent, BrowserEventEnvelope, -} from "@webmux/shared"; +} from "@offdesk/shared"; import { applyBootstrapSnapshot, applyBrowserEventEnvelope, diff --git a/packages/app/lib/bootstrapState.ts b/packages/app/lib/bootstrapState.ts index 085a75bc..441b2f2b 100644 --- a/packages/app/lib/bootstrapState.ts +++ b/packages/app/lib/bootstrapState.ts @@ -8,7 +8,7 @@ import type { TerminalInfo, WorkspaceGroupInfo, WorkspaceLayoutInfo, -} from "@webmux/shared"; +} from "@offdesk/shared"; export interface BrowserSessionState { lastSeq: number; diff --git a/packages/app/lib/directoryAutocomplete.ts b/packages/app/lib/directoryAutocomplete.ts index f7bba53d..53c61f03 100644 --- a/packages/app/lib/directoryAutocomplete.ts +++ b/packages/app/lib/directoryAutocomplete.ts @@ -1,4 +1,4 @@ -import type { DirEntry } from "@webmux/shared"; +import type { DirEntry } from "@offdesk/shared"; export const AUTOCOMPLETE_CACHE_TTL_MS = 30_000; diff --git a/packages/app/lib/displayTerminalTitle.test.ts b/packages/app/lib/displayTerminalTitle.test.ts index 7735bf48..f964fa42 100644 --- a/packages/app/lib/displayTerminalTitle.test.ts +++ b/packages/app/lib/displayTerminalTitle.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { TerminalInfo } from "@webmux/shared"; +import type { TerminalInfo } from "@offdesk/shared"; import { displayTerminalTitle } from "./displayTerminalTitle"; diff --git a/packages/app/lib/displayTerminalTitle.ts b/packages/app/lib/displayTerminalTitle.ts index e4450b8e..6b268042 100644 --- a/packages/app/lib/displayTerminalTitle.ts +++ b/packages/app/lib/displayTerminalTitle.ts @@ -1,4 +1,4 @@ -import type { TerminalInfo } from "@webmux/shared"; +import type { TerminalInfo } from "@offdesk/shared"; const LEGACY_TERMINAL_TITLE = /^Terminal [0-9a-f]{8}$/; diff --git a/packages/app/lib/lazyWithReload.ts b/packages/app/lib/lazyWithReload.ts index e79c2020..65276267 100644 --- a/packages/app/lib/lazyWithReload.ts +++ b/packages/app/lib/lazyWithReload.ts @@ -1,4 +1,4 @@ -export const CHUNK_RELOAD_KEY = "webmux:chunk-reload"; +export const CHUNK_RELOAD_KEY = "offdesk:chunk-reload"; export async function lazyWithReload(loader: () => Promise): Promise { try { diff --git a/packages/app/lib/legacyStorageMigration.ts b/packages/app/lib/legacyStorageMigration.ts new file mode 100644 index 00000000..4d68a709 --- /dev/null +++ b/packages/app/lib/legacyStorageMigration.ts @@ -0,0 +1,32 @@ +// One-time localStorage rename, webmux: -> offdesk:. Without it an upgrading +// client forgets its hub URL, fonts, panel state and session defaults. +// +// Imported for its side effect at the top of app/_layout.tsx, before anything +// else reads storage. Keys are copied, not moved, so a downgrade still works; +// a marker key stops it re-running on every boot. Delete this module once +// nobody is upgrading from webmux. + +const LEGACY_PREFIX = "webmux:"; +const PREFIX = "offdesk:"; +const MARKER = "offdesk:legacy-storage-migrated"; + +export function migrateLegacyStorage(): void { + if (typeof localStorage === "undefined") return; + try { + if (localStorage.getItem(MARKER)) return; + for (let i = 0; i < localStorage.length; i += 1) { + const key = localStorage.key(i); + if (!key?.startsWith(LEGACY_PREFIX)) continue; + const renamed = PREFIX + key.slice(LEGACY_PREFIX.length); + if (localStorage.getItem(renamed) !== null) continue; + const value = localStorage.getItem(key); + if (value !== null) localStorage.setItem(renamed, value); + } + localStorage.setItem(MARKER, "1"); + } catch { + // Private mode or a storage quota error. Nothing to migrate, and the + // app works fine from defaults. + } +} + +migrateLegacyStorage(); diff --git a/packages/app/lib/mainLayoutReducer.test.ts b/packages/app/lib/mainLayoutReducer.test.ts index 3516da34..5d9fbdfa 100644 --- a/packages/app/lib/mainLayoutReducer.test.ts +++ b/packages/app/lib/mainLayoutReducer.test.ts @@ -16,18 +16,18 @@ describe("mainLayoutReducer", () => { it("SELECT_WORKPATH sets workpath and clears zoom", () => { const next = mainLayoutReducer( { ...initial, zoomedTerminalId: "t1" }, - { type: "SELECT_WORKPATH", workpathId: "wp-webmux" }, + { type: "SELECT_WORKPATH", workpathId: "wp-offdesk" }, ); - expect(next.selectedWorkpathId).toBe("wp-webmux"); + expect(next.selectedWorkpathId).toBe("wp-offdesk"); expect(next.zoomedTerminalId).toBeNull(); }); it("ZOOM_TERMINAL sets zoomed terminal without touching workpath", () => { const next = mainLayoutReducer( - { ...initial, selectedWorkpathId: "wp-webmux" }, + { ...initial, selectedWorkpathId: "wp-offdesk" }, { type: "ZOOM_TERMINAL", terminalId: "t1" }, ); - expect(next.selectedWorkpathId).toBe("wp-webmux"); + expect(next.selectedWorkpathId).toBe("wp-offdesk"); expect(next.zoomedTerminalId).toBe("t1"); }); @@ -67,18 +67,18 @@ describe("mainLayoutReducer", () => { it("WORKPATH_DELETED falls back to All if the deleted one was selected", () => { const next = mainLayoutReducer( - { ...initial, selectedWorkpathId: "wp-webmux" }, - { type: "WORKPATH_DELETED", workpathId: "wp-webmux" }, + { ...initial, selectedWorkpathId: "wp-offdesk" }, + { type: "WORKPATH_DELETED", workpathId: "wp-offdesk" }, ); expect(next.selectedWorkpathId).toBe("all"); }); it("WORKPATH_DELETED leaves selection alone if a different workpath was deleted", () => { const next = mainLayoutReducer( - { ...initial, selectedWorkpathId: "wp-webmux" }, + { ...initial, selectedWorkpathId: "wp-offdesk" }, { type: "WORKPATH_DELETED", workpathId: "wp-z1" }, ); - expect(next.selectedWorkpathId).toBe("wp-webmux"); + expect(next.selectedWorkpathId).toBe("wp-offdesk"); }); it("TOGGLE_PANEL flips panelOpen", () => { diff --git a/packages/app/lib/mobileSessionSwitcher.test.ts b/packages/app/lib/mobileSessionSwitcher.test.ts index 1498363c..723919bb 100644 --- a/packages/app/lib/mobileSessionSwitcher.test.ts +++ b/packages/app/lib/mobileSessionSwitcher.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { AgentSessionInfo, TerminalInfo } from "@webmux/shared"; +import type { AgentSessionInfo, TerminalInfo } from "@offdesk/shared"; import { buildMobileSessionGroups } from "./mobileSessionSwitcher"; import type { WorkspaceGroup } from "./terminalWorkspaceLayout"; @@ -124,20 +124,20 @@ describe("buildMobileSessionGroups", () => { // cwd matches group alpha's cwd → joins that section. agentSession("s-cwd", "/groups/alpha"), // No match → synthetic cwd-fallback section, appended last. - agentSession("s-new", "/projects/webmux"), + agentSession("s-new", "/projects/offdesk"), ], ); expect(result.map((entry) => entry.group.id)).toEqual([ "alpha", - "cwd:/projects/webmux", + "cwd:/projects/offdesk", ]); expect(result[0].rows.map((row) => row.kind)).toEqual([ "terminal", "agent", ]); const synthetic = result[1]; - expect(synthetic.group.label).toBe("webmux"); + expect(synthetic.group.label).toBe("offdesk"); expect(synthetic.group.persistent).toBe(false); expect(synthetic.rows).toHaveLength(1); expect(synthetic.rows[0].kind).toBe("agent"); diff --git a/packages/app/lib/mobileSessionSwitcher.ts b/packages/app/lib/mobileSessionSwitcher.ts index 5e249321..1d5e8110 100644 --- a/packages/app/lib/mobileSessionSwitcher.ts +++ b/packages/app/lib/mobileSessionSwitcher.ts @@ -1,4 +1,4 @@ -import type { AgentSessionInfo, TerminalInfo } from "@webmux/shared"; +import type { AgentSessionInfo, TerminalInfo } from "@offdesk/shared"; import { labelFromCwd, diff --git a/packages/app/lib/nodeInstaller.test.mjs b/packages/app/lib/nodeInstaller.test.mjs index 70e0083c..f46b1994 100644 --- a/packages/app/lib/nodeInstaller.test.mjs +++ b/packages/app/lib/nodeInstaller.test.mjs @@ -9,8 +9,8 @@ import { test("getInstallCommand uses the shared install script", () => { assert.equal( INSTALL_SCRIPT_URL, - "https://raw.githubusercontent.com/zalify/webmux/main/scripts/install.sh", + "https://raw.githubusercontent.com/zalify/offdesk/main/scripts/install.sh", ); assert.equal(getInstallCommand(), `curl -sSL ${INSTALL_SCRIPT_URL} | sh`); - assert.doesNotMatch(getInstallCommand(), /webmux-node-(linux|darwin)-(x64|arm64)/); + assert.doesNotMatch(getInstallCommand(), /offdesk-node-(linux|darwin)-(x64|arm64)/); }); diff --git a/packages/app/lib/nodeInstaller.ts b/packages/app/lib/nodeInstaller.ts index b04c4700..0c923cb5 100644 --- a/packages/app/lib/nodeInstaller.ts +++ b/packages/app/lib/nodeInstaller.ts @@ -1,16 +1,16 @@ export const INSTALL_SCRIPT_URL = - "https://raw.githubusercontent.com/zalify/webmux/main/scripts/install.sh"; + "https://raw.githubusercontent.com/zalify/offdesk/main/scripts/install.sh"; export function getInstallCommand(): string { return `curl -sSL ${INSTALL_SCRIPT_URL} | sh`; } export function getRegisterCommand(hubUrl: string, token: string): string { - return `webmux-node register --hub-url ${hubUrl} --token ${token}`; + return `offdesk-node register --hub-url ${hubUrl} --token ${token}`; } export function getServiceInstallCommand(): string { - return "webmux-node service install"; + return "offdesk-node service install"; } export function buildOnboardingScript(hubUrl: string, token: string): string { diff --git a/packages/app/lib/panelOpenStorage.ts b/packages/app/lib/panelOpenStorage.ts index 72675f93..df7ce8e3 100644 --- a/packages/app/lib/panelOpenStorage.ts +++ b/packages/app/lib/panelOpenStorage.ts @@ -1,4 +1,4 @@ -export const PANEL_OPEN_KEY = "webmux:panel-open"; +export const PANEL_OPEN_KEY = "offdesk:panel-open"; // Persists the workpath-panel open/closed state across reloads. Falls back // to the caller-supplied default if storage is unavailable (Tauri WebView diff --git a/packages/app/lib/platform.ts b/packages/app/lib/platform.ts index 43eb0453..6ba4f528 100644 --- a/packages/app/lib/platform.ts +++ b/packages/app/lib/platform.ts @@ -23,9 +23,9 @@ export function detectOS(): OS { } const DOWNLOAD_FILENAMES: Record = { - macos: "webmux.dmg", - windows: "webmux.msi", - linux: "webmux.AppImage", + macos: "offdesk.dmg", + windows: "offdesk.msi", + linux: "offdesk.AppImage", unknown: null, }; diff --git a/packages/app/lib/prefixKey.ts b/packages/app/lib/prefixKey.ts index 4645e70c..2b8ed4bc 100644 --- a/packages/app/lib/prefixKey.ts +++ b/packages/app/lib/prefixKey.ts @@ -5,7 +5,7 @@ // Pure logic, no DOM — see // docs/superpowers/specs/2026-07-18-raw-terminal-ux-redesign-design.md §6. -export const PREFIX_BINDINGS_STORAGE_KEY = "webmux:prefix-bindings"; +export const PREFIX_BINDINGS_STORAGE_KEY = "offdesk:prefix-bindings"; export type PrefixActionId = | "newTerminal" diff --git a/packages/app/lib/resourceStats.test.ts b/packages/app/lib/resourceStats.test.ts index f7f7c1a4..8d77070c 100644 --- a/packages/app/lib/resourceStats.test.ts +++ b/packages/app/lib/resourceStats.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import type { DiskInfo, ResourceStats } from "@webmux/shared"; +import type { DiskInfo, ResourceStats } from "@offdesk/shared"; import { diskPercent, diff --git a/packages/app/lib/resourceStats.ts b/packages/app/lib/resourceStats.ts index 19315f75..3e5aa4f6 100644 --- a/packages/app/lib/resourceStats.ts +++ b/packages/app/lib/resourceStats.ts @@ -1,4 +1,4 @@ -import type { DiskInfo, ResourceStats } from "@webmux/shared"; +import type { DiskInfo, ResourceStats } from "@offdesk/shared"; export interface DiskUsage { mountPoint: string; diff --git a/packages/app/lib/serverUrl.test.ts b/packages/app/lib/serverUrl.test.ts index 2249d97d..24a8149f 100644 --- a/packages/app/lib/serverUrl.test.ts +++ b/packages/app/lib/serverUrl.test.ts @@ -8,7 +8,7 @@ describe("resolveServerUrl", () => { resolveServerUrl({ platformOs: "web", isTauriRuntime: false, - storedUrl: "https://webmux.example", + storedUrl: "https://offdesk.example", }), ).toBe(""); }); @@ -20,7 +20,7 @@ describe("resolveServerUrl", () => { isTauriRuntime: false, storedUrl: null, }), - ).toBe("https://webmux.nas.chareice.site"); + ).toBe("https://offdesk.nas.chareice.site"); }); it("uses a configured native hub URL before the production fallback", () => { diff --git a/packages/app/lib/serverUrl.ts b/packages/app/lib/serverUrl.ts index 25677856..f84f6510 100644 --- a/packages/app/lib/serverUrl.ts +++ b/packages/app/lib/serverUrl.ts @@ -1,7 +1,7 @@ import { isTauri } from "./platform"; -const SERVER_URL_KEY = "webmux:server_url"; -const DEFAULT_SERVER_URL = "https://webmux.nas.chareice.site"; +const SERVER_URL_KEY = "offdesk:server_url"; +const DEFAULT_SERVER_URL = "https://offdesk.nas.chareice.site"; export interface ResolveServerUrlOptions { platformOs: string; @@ -38,8 +38,8 @@ function getRuntimePlatformOs(): string { function getConfiguredDefaultServerUrl(): string | null { return ( - process.env.EXPO_PUBLIC_WEBMUX_DEFAULT_SERVER_URL || - process.env.WEBMUX_DEFAULT_SERVER_URL || + process.env.EXPO_PUBLIC_OFFDESK_DEFAULT_SERVER_URL || + process.env.OFFDESK_DEFAULT_SERVER_URL || null ); } diff --git a/packages/app/lib/sessionDefaults.test.ts b/packages/app/lib/sessionDefaults.test.ts index c37b2a28..81026363 100644 --- a/packages/app/lib/sessionDefaults.test.ts +++ b/packages/app/lib/sessionDefaults.test.ts @@ -51,12 +51,12 @@ describe("session defaults", () => { it("drops unknown kinds and malformed payloads back to the fallback", () => { expect( readSessionDefaults( - fakeStorage({ "webmux:session-defaults": '{"agentKind":"nope"}' }), + fakeStorage({ "offdesk:session-defaults": '{"agentKind":"nope"}' }), ).agentKind, ).toBe("kimi"); expect( readSessionDefaults( - fakeStorage({ "webmux:session-defaults": "not json" }), + fakeStorage({ "offdesk:session-defaults": "not json" }), ), ).toEqual(FALLBACK_SESSION_DEFAULTS); }); @@ -93,7 +93,7 @@ describe("model cache", () => { it("drops malformed cache entries", () => { const storage = fakeStorage({ - "webmux:agent-models": JSON.stringify({ grok: [{ nope: 1 }], kimi: models }), + "offdesk:agent-models": JSON.stringify({ grok: [{ nope: 1 }], kimi: models }), }); expect(readModelCache(storage).grok).toBeUndefined(); expect(readModelCache(storage).kimi).toEqual(models); diff --git a/packages/app/lib/sessionDefaults.ts b/packages/app/lib/sessionDefaults.ts index ce25700e..19fa6635 100644 --- a/packages/app/lib/sessionDefaults.ts +++ b/packages/app/lib/sessionDefaults.ts @@ -9,7 +9,7 @@ // Storage is injected (same pattern as viewOnlyLock) to stay testable under // the node vitest environment. -import type { AgentKind, AgentModelInfo } from "@webmux/shared"; +import type { AgentKind, AgentModelInfo } from "@offdesk/shared"; export interface KeyValueStorage { getItem(key: string): string | null; @@ -26,9 +26,9 @@ export interface SessionDefaults { autoRun: boolean | null; } -const SESSION_DEFAULTS_KEY = "webmux:session-defaults"; -const LAST_CWD_PREFIX = "webmux:last-cwd:"; -const MODEL_CACHE_KEY = "webmux:agent-models"; +const SESSION_DEFAULTS_KEY = "offdesk:session-defaults"; +const LAST_CWD_PREFIX = "offdesk:last-cwd:"; +const MODEL_CACHE_KEY = "offdesk:agent-models"; const KNOWN_KINDS: SessionDefaultKind[] = [ "claude", diff --git a/packages/app/lib/sidebarTree.test.ts b/packages/app/lib/sidebarTree.test.ts index aadb375b..2430ae4b 100644 --- a/packages/app/lib/sidebarTree.test.ts +++ b/packages/app/lib/sidebarTree.test.ts @@ -4,7 +4,7 @@ import type { MachineInfo, TerminalInfo, WorkspaceGroupInfo, -} from "@webmux/shared"; +} from "@offdesk/shared"; import { SIDEBAR_SHORTCUT_COUNT, buildSidebarTree, diff --git a/packages/app/lib/sidebarTree.ts b/packages/app/lib/sidebarTree.ts index 45077f94..e18aff49 100644 --- a/packages/app/lib/sidebarTree.ts +++ b/packages/app/lib/sidebarTree.ts @@ -14,7 +14,7 @@ import type { TerminalInfo, WorkspaceGroupInfo, WorkspaceLayoutInfo, -} from "@webmux/shared"; +} from "@offdesk/shared"; import { displayTerminalTitle } from "./displayTerminalTitle"; import { createTerminalWorkspace, diff --git a/packages/app/lib/storage.ts b/packages/app/lib/storage.ts index 2891ce7a..b59dcef2 100644 --- a/packages/app/lib/storage.ts +++ b/packages/app/lib/storage.ts @@ -1,4 +1,4 @@ -const PREFIX = "webmux:"; +const PREFIX = "offdesk:"; // All shipping clients (mobile-web, desktop-Tauri, Android-Tauri) run in a // WebView or browser context, so localStorage is universally available. diff --git a/packages/app/lib/terminalGpuRenderer.test.ts b/packages/app/lib/terminalGpuRenderer.test.ts index c0151139..3f26d959 100644 --- a/packages/app/lib/terminalGpuRenderer.test.ts +++ b/packages/app/lib/terminalGpuRenderer.test.ts @@ -115,7 +115,7 @@ describe("activateGpuRenderer", () => { expect(term.atlasClears).toBe(2); }); - it("respects the webmux:renderer=dom escape hatch", () => { + it("respects the offdesk:renderer=dom escape hatch", () => { const term = makeFakeTerminal(); const handle = activateGpuRenderer(term, { createAddon: () => makeFakeAddon(), diff --git a/packages/app/lib/terminalGpuRenderer.ts b/packages/app/lib/terminalGpuRenderer.ts index a07bf6e4..3fefacb6 100644 --- a/packages/app/lib/terminalGpuRenderer.ts +++ b/packages/app/lib/terminalGpuRenderer.ts @@ -21,12 +21,12 @@ // re-rasterization of the visible screen — invisible next to the // corruption it prevents. // -// Escape hatch: localStorage "webmux:renderer" = "dom" skips activation +// Escape hatch: localStorage "offdesk:renderer" = "dom" skips activation // entirely (diagnosis / rollback without a deploy). import type { Terminal } from "@xterm/xterm"; import { WebglAddon } from "@xterm/addon-webgl"; -export const RENDERER_STORAGE_KEY = "webmux:renderer"; +export const RENDERER_STORAGE_KEY = "offdesk:renderer"; export const ATLAS_CLEAR_INTERVAL_MS = 5 * 60_000; interface WebglAddonLike { @@ -120,7 +120,7 @@ export function activateGpuRenderer( // Silent fallback is the contract, but a construct/load miss has to // be visible in e2e/browser consoles or the canvas canary is the // only signal that we dropped to the DOM renderer. - console.warn("[webmux] WebGL renderer unavailable, using DOM", error); + console.warn("[offdesk] WebGL renderer unavailable, using DOM", error); return INERT_HANDLE; } diff --git a/packages/app/lib/terminalWorkspaceLayout.test.ts b/packages/app/lib/terminalWorkspaceLayout.test.ts index 51bb9875..a16bcdc3 100644 --- a/packages/app/lib/terminalWorkspaceLayout.test.ts +++ b/packages/app/lib/terminalWorkspaceLayout.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { TerminalInfo, WorkspaceGroupInfo } from "@webmux/shared"; +import type { TerminalInfo, WorkspaceGroupInfo } from "@offdesk/shared"; import { MAX_PANES_PER_TAB, appendWorkspacePaneToGroup, @@ -95,8 +95,8 @@ function leafWidths(root: WorkspacePaneNode | null): Record { describe("terminalWorkspaceLayout", () => { const terminals = [ - terminal("web-1", "/home/chareice/projects/webmux"), - terminal("web-2", "/home/chareice/projects/webmux"), + terminal("web-1", "/home/chareice/projects/offdesk"), + terminal("web-2", "/home/chareice/projects/offdesk"), terminal("api-1", "/home/chareice/projects/zhuyang"), ]; @@ -107,7 +107,7 @@ describe("terminalWorkspaceLayout", () => { "cwd:/home/chareice/projects/zhuyang", ); expect(workspace.groups.map((group) => group.label)).toEqual([ - "webmux", + "offdesk", "zhuyang", ]); expect(workspace.groups.map((group) => group.paneCount)).toEqual([2, 1]); @@ -121,7 +121,7 @@ describe("terminalWorkspaceLayout", () => { const firstSnapshot = createTerminalWorkspace( [ terminal("api-1", "/home/chareice/projects/zhuyang"), - terminal("web-1", "/home/chareice/projects/webmux"), + terminal("web-1", "/home/chareice/projects/offdesk"), terminal("ops-1", "/home/chareice/projects/ops"), ], "api-1", @@ -129,15 +129,15 @@ describe("terminalWorkspaceLayout", () => { const secondSnapshot = createTerminalWorkspace( [ terminal("ops-1", "/home/chareice/projects/ops"), - terminal("web-1", "/home/chareice/projects/webmux"), + terminal("web-1", "/home/chareice/projects/offdesk"), terminal("api-1", "/home/chareice/projects/zhuyang"), ], "api-1", ); expect(firstSnapshot.groups.map((group) => group.label)).toEqual([ + "offdesk", "ops", - "webmux", "zhuyang", ]); expect(secondSnapshot.groups.map((group) => group.label)).toEqual( @@ -350,7 +350,7 @@ describe("terminalWorkspaceLayout", () => { it("groups panes by persisted workspace tab before falling back to cwd", () => { const workspace = createTerminalWorkspace( [ - groupedTerminal("web-1", "/home/chareice/projects/webmux", "tab-agents"), + groupedTerminal("web-1", "/home/chareice/projects/offdesk", "tab-agents"), groupedTerminal("api-1", "/home/chareice/projects/zhuyang", "tab-agents"), terminal("ops-1", "/home/chareice/projects/ops"), ], @@ -532,7 +532,7 @@ describe("terminalWorkspaceLayout", () => { }); const activeGroup = getActiveWorkspaceGroup(next); - expect(activeGroup?.label).toBe("webmux"); + expect(activeGroup?.label).toBe("offdesk"); expect(activeGroup?.root).toMatchObject({ type: "split", direction: "horizontal", @@ -782,13 +782,13 @@ describe("terminalWorkspaceLayout", () => { { id: "web-1", label: "Terminal web-1", - cwd: "/home/chareice/projects/webmux", + cwd: "/home/chareice/projects/offdesk", active: false, }, { id: "web-2", label: "Terminal web-2", - cwd: "/home/chareice/projects/webmux", + cwd: "/home/chareice/projects/offdesk", active: true, }, ]); diff --git a/packages/app/lib/terminalWorkspaceLayout.ts b/packages/app/lib/terminalWorkspaceLayout.ts index ea8b83f6..8f786d19 100644 --- a/packages/app/lib/terminalWorkspaceLayout.ts +++ b/packages/app/lib/terminalWorkspaceLayout.ts @@ -1,10 +1,10 @@ -import { MAX_PANES_PER_TAB } from "@webmux/shared"; +import { MAX_PANES_PER_TAB } from "@offdesk/shared"; import type { TerminalInfo, WorkspaceGroupInfo, WorkspaceLayoutInfo, WorkspaceLayoutNode, -} from "@webmux/shared"; +} from "@offdesk/shared"; export type WorkspaceSplitDirection = "horizontal" | "vertical"; export type WorkspaceSplitIntent = "right" | "down"; diff --git a/packages/app/lib/viewOnlyLock.ts b/packages/app/lib/viewOnlyLock.ts index 2c7e5292..d081f27c 100644 --- a/packages/app/lib/viewOnlyLock.ts +++ b/packages/app/lib/viewOnlyLock.ts @@ -1,4 +1,4 @@ -const VIEW_ONLY_LOCK_KEY = "webmux:view-only-lock"; +const VIEW_ONLY_LOCK_KEY = "offdesk:view-only-lock"; export interface ViewOnlyLockStorage { getItem(key: string): string | null; diff --git a/packages/app/lib/workspaceToast.ts b/packages/app/lib/workspaceToast.ts index 0cf1ffb0..a6ccf327 100644 --- a/packages/app/lib/workspaceToast.ts +++ b/packages/app/lib/workspaceToast.ts @@ -3,7 +3,7 @@ // yet; a keyboard shortcut that silently does nothing is indistinguishable // from one that never registered, which is exactly the confusion the pane cap // would otherwise create. -const TOAST_ID = "webmux-workspace-toast"; +const TOAST_ID = "offdesk-workspace-toast"; export function showWorkspaceToast(message: string, timeoutMs = 3000): void { if (typeof document === "undefined") return; diff --git a/packages/app/package.json b/packages/app/package.json index a1b4448f..c9332c89 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,5 +1,5 @@ { - "name": "@webmux/app", + "name": "@offdesk/app", "version": "0.1.0", "private": true, "main": "expo-router/entry", @@ -9,7 +9,7 @@ }, "dependencies": { "@tauri-apps/plugin-opener": "^2.5.4", - "@webmux/shared": "workspace:*", + "@offdesk/shared": "workspace:*", "@xterm/addon-clipboard": "0.3.0-beta.302", "@xterm/addon-fit": "0.12.0-beta.300", "@xterm/addon-web-links": "0.13.0-beta.300", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 36d00131..6388c996 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,5 +1,5 @@ { - "name": "@webmux/desktop", + "name": "@offdesk/desktop", "version": "0.1.0", "private": true, "scripts": { diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index d495d961..539bc7f2 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -5478,7 +5478,7 @@ dependencies = [ ] [[package]] -name = "webmux-desktop" +name = "offdesk-desktop" version = "0.1.0" dependencies = [ "axum", diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index d9a7195e..7f763e27 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "webmux-desktop" +name = "offdesk-desktop" version = "0.1.0" edition = "2021" diff --git a/packages/desktop/src-tauri/build.rs b/packages/desktop/src-tauri/build.rs index 438b3189..8cfa8056 100644 --- a/packages/desktop/src-tauri/build.rs +++ b/packages/desktop/src-tauri/build.rs @@ -1,6 +1,6 @@ fn main() { // Mobile builds bake the hub URL via option_env!; tell cargo to // recompile when it changes so dev iteration doesn't need a clean. - println!("cargo:rerun-if-env-changed=WEBMUX_MOBILE_HUB_URL"); + println!("cargo:rerun-if-env-changed=OFFDESK_MOBILE_HUB_URL"); tauri_build::build() } diff --git a/packages/desktop/src-tauri/capabilities/mobile/default.json b/packages/desktop/src-tauri/capabilities/mobile/default.json index 5c0159d6..88e3c3f3 100644 --- a/packages/desktop/src-tauri/capabilities/mobile/default.json +++ b/packages/desktop/src-tauri/capabilities/mobile/default.json @@ -4,7 +4,7 @@ "platforms": ["android", "iOS"], "windows": ["main"], "remote": { - "urls": ["https://webmux.nas.chareice.site/*", "http://10.0.2.2:*/*"] + "urls": ["https://offdesk.nas.chareice.site/*", "http://10.0.2.2:*/*"] }, "permissions": [ "core:default", diff --git a/packages/desktop/src-tauri/gen/android/app/build.gradle.kts b/packages/desktop/src-tauri/gen/android/app/build.gradle.kts index 0968c501..7ea80a55 100644 --- a/packages/desktop/src-tauri/gen/android/app/build.gradle.kts +++ b/packages/desktop/src-tauri/gen/android/app/build.gradle.kts @@ -15,10 +15,10 @@ val tauriProperties = Properties().apply { android { compileSdk = 36 - namespace = "com.webmux.desktop" + namespace = "dev.offdesk.desktop" defaultConfig { manifestPlaceholders["usesCleartextTraffic"] = "false" - applicationId = "com.webmux.desktop" + applicationId = "dev.offdesk.desktop" minSdk = 24 targetSdk = 36 versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt() diff --git a/packages/desktop/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/packages/desktop/src-tauri/gen/android/app/src/main/AndroidManifest.xml index d4a4270e..d05c5062 100644 --- a/packages/desktop/src-tauri/gen/android/app/src/main/AndroidManifest.xml +++ b/packages/desktop/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -8,7 +8,7 @@ - diff --git a/packages/desktop/src-tauri/gen/android/app/src/main/res/values/strings.xml b/packages/desktop/src-tauri/gen/android/app/src/main/res/values/strings.xml index b11b23db..3bc37bcb 100644 --- a/packages/desktop/src-tauri/gen/android/app/src/main/res/values/strings.xml +++ b/packages/desktop/src-tauri/gen/android/app/src/main/res/values/strings.xml @@ -1,4 +1,4 @@ - webmux - webmux + offdesk + offdesk \ No newline at end of file diff --git a/packages/desktop/src-tauri/gen/android/app/src/main/res/values/themes.xml b/packages/desktop/src-tauri/gen/android/app/src/main/res/values/themes.xml index bc551b25..4a54ad92 100644 --- a/packages/desktop/src-tauri/gen/android/app/src/main/res/values/themes.xml +++ b/packages/desktop/src-tauri/gen/android/app/src/main/res/values/themes.xml @@ -1,6 +1,6 @@ - diff --git a/packages/desktop/src-tauri/gen/android/buildSrc/src/main/java/com/webmux/desktop/kotlin/BuildTask.kt b/packages/desktop/src-tauri/gen/android/buildSrc/src/main/java/dev/offdesk/desktop/kotlin/BuildTask.kt similarity index 100% rename from packages/desktop/src-tauri/gen/android/buildSrc/src/main/java/com/webmux/desktop/kotlin/BuildTask.kt rename to packages/desktop/src-tauri/gen/android/buildSrc/src/main/java/dev/offdesk/desktop/kotlin/BuildTask.kt diff --git a/packages/desktop/src-tauri/gen/android/buildSrc/src/main/java/com/webmux/desktop/kotlin/RustPlugin.kt b/packages/desktop/src-tauri/gen/android/buildSrc/src/main/java/dev/offdesk/desktop/kotlin/RustPlugin.kt similarity index 100% rename from packages/desktop/src-tauri/gen/android/buildSrc/src/main/java/com/webmux/desktop/kotlin/RustPlugin.kt rename to packages/desktop/src-tauri/gen/android/buildSrc/src/main/java/dev/offdesk/desktop/kotlin/RustPlugin.kt diff --git a/packages/desktop/src-tauri/gen/schemas/capabilities.json b/packages/desktop/src-tauri/gen/schemas/capabilities.json index b5142801..6af77f47 100644 --- a/packages/desktop/src-tauri/gen/schemas/capabilities.json +++ b/packages/desktop/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"default":{"identifier":"default","description":"Default capability for the main window","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-minimize","core:window:allow-maximize","core:window:allow-toggle-maximize","core:window:allow-close","core:window:allow-start-dragging","core:window:allow-set-focus","global-shortcut:default","notification:default","updater:default","shell:allow-open","clipboard-manager:allow-read-text","clipboard-manager:allow-write-text","process:default"],"platforms":["macOS","windows","linux"]},"mobile":{"identifier":"mobile","description":"Capabilities for Android/iOS, scoped to the production hub origin since the WebView loads it directly","remote":{"urls":["https://webmux.nas.chareice.site/*","http://10.0.2.2:*/*"]},"local":true,"windows":["main"],"permissions":["core:default","notification:default","shell:allow-open","clipboard-manager:allow-read-text","clipboard-manager:allow-write-text","process:default"],"platforms":["android","iOS"]}} \ No newline at end of file +{"default":{"identifier":"default","description":"Default capability for the main window","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-minimize","core:window:allow-maximize","core:window:allow-toggle-maximize","core:window:allow-close","core:window:allow-start-dragging","core:window:allow-set-focus","global-shortcut:default","notification:default","updater:default","shell:allow-open","clipboard-manager:allow-read-text","clipboard-manager:allow-write-text","process:default"],"platforms":["macOS","windows","linux"]},"mobile":{"identifier":"mobile","description":"Capabilities for Android/iOS, scoped to the production hub origin since the WebView loads it directly","remote":{"urls":["https://offdesk.nas.chareice.site/*","http://10.0.2.2:*/*"]},"local":true,"windows":["main"],"permissions":["core:default","notification:default","shell:allow-open","clipboard-manager:allow-read-text","clipboard-manager:allow-write-text","process:default"],"platforms":["android","iOS"]}} \ No newline at end of file diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index 08884c74..f2e9d11a 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -6,15 +6,15 @@ mod tray; #[cfg(any(desktop, mobile))] use tauri::Manager; -// On mobile we wrap a remote URL (webmux mobile-web). This ensures the +// On mobile we wrap a remote URL (offdesk mobile-web). This ensures the // Android client always tracks production features instead of needing a // parallel native UI tree. The URL is whatever the user has configured; // for now we hard-code the production hub but read an override at compile // time to ease internal dogfooding. #[cfg(mobile)] -const MOBILE_HUB_URL: &str = match option_env!("WEBMUX_MOBILE_HUB_URL") { +const MOBILE_HUB_URL: &str = match option_env!("OFFDESK_MOBILE_HUB_URL") { Some(value) => value, - None => "https://webmux.nas.chareice.site", + None => "https://offdesk.nas.chareice.site", }; #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -102,7 +102,7 @@ fn setup_mobile(app: &tauri::App) -> Result<(), Box> { if let Some(window) = app.get_webview_window("main") { let url = MOBILE_HUB_URL .parse::() - .map_err(|e| format!("invalid WEBMUX_MOBILE_HUB_URL {MOBILE_HUB_URL}: {e}"))?; + .map_err(|e| format!("invalid OFFDESK_MOBILE_HUB_URL {MOBILE_HUB_URL}: {e}"))?; window.navigate(url)?; } Ok(()) diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index 7a9f04fe..a9bab633 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -1,5 +1,5 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { - webmux_desktop::run() + offdesk_desktop::run() } diff --git a/packages/desktop/src-tauri/src/oauth.rs b/packages/desktop/src-tauri/src/oauth.rs index 494fd705..a6a07505 100644 --- a/packages/desktop/src-tauri/src/oauth.rs +++ b/packages/desktop/src-tauri/src/oauth.rs @@ -40,11 +40,11 @@ pub async fn start_oauth_listener(app: AppHandle) -> Result -webmux +offdesk

Login successful

-

You can close this tab and return to webmux.

+

You can close this tab and return to offdesk.

"#, diff --git a/packages/desktop/src-tauri/tauri.android.conf.json b/packages/desktop/src-tauri/tauri.android.conf.json index fb11c7ae..f8e55839 100644 --- a/packages/desktop/src-tauri/tauri.android.conf.json +++ b/packages/desktop/src-tauri/tauri.android.conf.json @@ -1,6 +1,6 @@ { "$schema": "../gen/schemas/android-schema.json", - "productName": "webmux", + "productName": "offdesk", "build": { "beforeBuildCommand": "", "beforeDevCommand": "" @@ -9,7 +9,7 @@ "windows": [ { "label": "main", - "url": "https://webmux.nas.chareice.site" + "url": "https://offdesk.nas.chareice.site" } ], "security": { diff --git a/packages/desktop/src-tauri/tauri.conf.json b/packages/desktop/src-tauri/tauri.conf.json index c3f50fdc..f9cba265 100644 --- a/packages/desktop/src-tauri/tauri.conf.json +++ b/packages/desktop/src-tauri/tauri.conf.json @@ -1,17 +1,17 @@ { - "productName": "webmux", + "productName": "offdesk", "version": "0.3.14", - "identifier": "com.webmux.desktop", + "identifier": "dev.offdesk.desktop", "build": { "frontendDist": "../../app/dist", "devUrl": "http://localhost:8081", - "beforeBuildCommand": "pnpm --filter @webmux/shared build && pnpm --filter @webmux/app build", - "beforeDevCommand": "pnpm --filter @webmux/shared build && pnpm --filter @webmux/app dev:web" + "beforeBuildCommand": "pnpm --filter @offdesk/shared build && pnpm --filter @offdesk/app build", + "beforeDevCommand": "pnpm --filter @offdesk/shared build && pnpm --filter @offdesk/app dev:web" }, "app": { "windows": [ { - "title": "webmux", + "title": "offdesk", "width": 1200, "height": 800, "minWidth": 800, @@ -39,7 +39,7 @@ "plugins": { "updater": { "endpoints": [ - "https://github.com/zalify/webmux/releases/download/desktop-latest/latest.json" + "https://github.com/zalify/offdesk/releases/download/desktop-latest/latest.json" ], "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDI5RkZERjcwNUQ4MzFDRkIKUldUN0hJTmRjTi8vS2NsTi80WWg0MDJoK2pmT3AxTE5TWFVMOUhvRVdYd2tvR1M1WnNHSExLbTIK" } diff --git a/packages/desktop/src/index.html b/packages/desktop/src/index.html index 111a2b28..1f6586a7 100644 --- a/packages/desktop/src/index.html +++ b/packages/desktop/src/index.html @@ -3,7 +3,7 @@ - webmux + offdesk diff --git a/packages/shared/package.json b/packages/shared/package.json index 707b5edb..948ef7a8 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,5 +1,5 @@ { - "name": "@webmux/shared", + "name": "@offdesk/shared", "version": "0.1.0", "private": true, "type": "module", diff --git a/packages/shared/src/contracts.ts b/packages/shared/src/contracts.ts index 84e5099a..21ec9435 100644 --- a/packages/shared/src/contracts.ts +++ b/packages/shared/src/contracts.ts @@ -1,4 +1,4 @@ -// ── Shared data types (mirrors tc-protocol Rust types) ── +// ── Shared data types (mirrors offdesk-protocol Rust types) ── export interface MachineInfo { id: string diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d111e57a..4183fff7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,12 +20,12 @@ importers: packages/app: dependencies: + '@offdesk/shared': + specifier: workspace:* + version: link:../shared '@tauri-apps/plugin-opener': specifier: ^2.5.4 version: 2.5.4 - '@webmux/shared': - specifier: workspace:* - version: link:../shared '@xterm/addon-clipboard': specifier: 0.3.0-beta.302 version: 0.3.0-beta.302(@xterm/xterm@6.1.0-beta.303) diff --git a/proxy.mjs b/proxy.mjs index d8ea5e2b..c61ea7b0 100644 --- a/proxy.mjs +++ b/proxy.mjs @@ -1,13 +1,13 @@ // Dev proxy for local development. // Proxies /api/* and /ws/* to the Rust hub server, everything else to Expo dev server. -// Usage: WEBMUX_PROXY_TARGET=http://127.0.0.1:4317 node proxy.mjs +// Usage: OFFDESK_PROXY_TARGET=http://127.0.0.1:4317 node proxy.mjs import http from 'node:http' import { URL } from 'node:url' const EXPO_PORT = 8081 const PROXY_PORT = parseInt(process.env.PROXY_PORT || '4000', 10) -const BACKEND = process.env.WEBMUX_PROXY_TARGET || 'http://127.0.0.1:4317' +const BACKEND = process.env.OFFDESK_PROXY_TARGET || 'http://127.0.0.1:4317' const backendUrl = new URL(BACKEND) function proxyRequest(req, res, target) { diff --git a/scripts/install.sh b/scripts/install.sh index 11e8eaf9..6b474292 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,12 +1,12 @@ #!/bin/sh -# webmux-node installer — detects OS/arch and downloads the correct binary. -# Usage: curl -sSL https://raw.githubusercontent.com/zalify/webmux/main/scripts/install.sh | sh +# offdesk-node installer — detects OS/arch and downloads the correct binary. +# Usage: curl -sSL https://raw.githubusercontent.com/zalify/offdesk/main/scripts/install.sh | sh set -e -REPO="zalify/webmux" -BINARY="webmux-node" -INSTALL_DIR="${WEBMUX_INSTALL_DIR:-$HOME/.local/bin}" +REPO="zalify/offdesk" +BINARY="offdesk-node" +INSTALL_DIR="${OFFDESK_INSTALL_DIR:-$HOME/.local/bin}" main() { require_tmux @@ -39,18 +39,18 @@ main() { echo "" echo "Installed ${BINARY} to ${INSTALL_DIR}/${BINARY}" if [ "$OS" = "linux" ]; then - if systemctl --user is-active webmux-node >/dev/null 2>&1; then + if systemctl --user is-active offdesk-node >/dev/null 2>&1; then echo "" - echo "NOTE: webmux-node systemd service is running. Restart to pick up the new binary:" - echo " webmux-node service restart" - echo "(raw equivalent: systemctl --user restart webmux-node)" + echo "NOTE: offdesk-node systemd service is running. Restart to pick up the new binary:" + echo " offdesk-node service restart" + echo "(raw equivalent: systemctl --user restart offdesk-node)" fi elif [ "$OS" = "darwin" ]; then - if launchctl list com.webmux.node >/dev/null 2>&1; then + if launchctl list dev.offdesk.node >/dev/null 2>&1; then echo "" - echo "NOTE: webmux-node launchd service is running. Restart to pick up the new binary:" - echo " webmux-node service restart" - echo "(raw equivalent: launchctl kickstart -k gui/$(id -u)/com.webmux.node)" + echo "NOTE: offdesk-node launchd service is running. Restart to pick up the new binary:" + echo " offdesk-node service restart" + echo "(raw equivalent: launchctl kickstart -k gui/$(id -u)/dev.offdesk.node)" fi fi @@ -78,7 +78,7 @@ require_tmux() { return fi cat <<'EOF' >&2 -error: tmux is required by webmux but is not installed. +error: tmux is required by offdesk but is not installed. Please install tmux first: diff --git a/scripts/install.test.mjs b/scripts/install.test.mjs index fee4cfa1..b5e22fae 100644 --- a/scripts/install.test.mjs +++ b/scripts/install.test.mjs @@ -9,7 +9,7 @@ import { fileURLToPath } from "node:url"; const repoRoot = fileURLToPath(new URL("..", import.meta.url)); function makeTempDir() { - return mkdtempSync(join(tmpdir(), "webmux-install-test-")); + return mkdtempSync(join(tmpdir(), "offdesk-install-test-")); } function writeExecutable(path, contents) { @@ -46,7 +46,7 @@ fi writeExecutable( join(binDir, "curl"), `#!/bin/sh -if [ "$1" = "-sSL" ] && [ "$2" = "https://api.github.com/repos/zalify/webmux/releases" ]; then +if [ "$1" = "-sSL" ] && [ "$2" = "https://api.github.com/repos/zalify/offdesk/releases" ]; then printf '%s' '[{"tag_name":"v9.9.9"}]' exit 0 fi @@ -70,7 +70,7 @@ exit 1 writeExecutable( join(binDir, "systemctl"), `#!/bin/sh -if [ "$1" = "--user" ] && [ "$2" = "is-active" ] && [ "$3" = "webmux-node" ]; then +if [ "$1" = "--user" ] && [ "$2" = "is-active" ] && [ "$3" = "offdesk-node" ]; then exit 0 fi exit 1 @@ -82,7 +82,7 @@ exit 1 writeExecutable( join(binDir, "launchctl"), `#!/bin/sh -if [ "$1" = "list" ] && [ "$2" = "com.webmux.node" ]; then +if [ "$1" = "list" ] && [ "$2" = "dev.offdesk.node" ]; then exit 0 fi exit 1 @@ -96,12 +96,12 @@ exit 1 ...process.env, HOME: tempDir, PATH: `${binDir}:${process.env.PATH}`, - WEBMUX_INSTALL_DIR: installDir, + OFFDESK_INSTALL_DIR: installDir, }, encoding: "utf8", }); - const installedBinaryPath = join(installDir, "webmux-node"); + const installedBinaryPath = join(installDir, "offdesk-node"); const installedBinary = result.status === 0 ? readFileSync(installedBinaryPath, "utf8") : null; @@ -118,7 +118,7 @@ test("install script selects the darwin arm64 binary", () => { assert.equal(result.status, 0, result.stderr); assert.equal( installedBinary, - "https://github.com/zalify/webmux/releases/download/v9.9.9/webmux-node-darwin-arm64", + "https://github.com/zalify/offdesk/releases/download/v9.9.9/offdesk-node-darwin-arm64", ); } finally { rmSync(tempDir, { recursive: true, force: true }); @@ -154,9 +154,9 @@ test("install script prints a restart note on darwin when the launchd service is assert.equal(result.status, 0, result.stderr); assert.match( result.stdout, - /NOTE: webmux-node launchd service is running\. Restart to pick up the new binary:/, + /NOTE: offdesk-node launchd service is running\. Restart to pick up the new binary:/, ); - assert.match(result.stdout, /webmux-node service restart/); + assert.match(result.stdout, /offdesk-node service restart/); } finally { rmSync(tempDir, { recursive: true, force: true }); } @@ -170,7 +170,7 @@ test("install script prints no restart note on darwin when the launchd service i try { assert.equal(result.status, 0, result.stderr); - assert.doesNotMatch(result.stdout, /NOTE: webmux-node .* service is running/); + assert.doesNotMatch(result.stdout, /NOTE: offdesk-node .* service is running/); } finally { rmSync(tempDir, { recursive: true, force: true }); } @@ -187,9 +187,9 @@ test("install script prints a restart note on linux when the systemd service is assert.equal(result.status, 0, result.stderr); assert.match( result.stdout, - /NOTE: webmux-node systemd service is running\. Restart to pick up the new binary:/, + /NOTE: offdesk-node systemd service is running\. Restart to pick up the new binary:/, ); - assert.match(result.stdout, /webmux-node service restart/); + assert.match(result.stdout, /offdesk-node service restart/); } finally { rmSync(tempDir, { recursive: true, force: true }); } diff --git a/scripts/stamp-build.mjs b/scripts/stamp-build.mjs index b82f5a54..4015f314 100644 --- a/scripts/stamp-build.mjs +++ b/scripts/stamp-build.mjs @@ -5,7 +5,7 @@ // // What it does: // 1. index.html: append ?v= to every /_expo/static script src (top-level -// entry chunks) and inject window.__WEBMUX_BUILD__ = "". +// entry chunks) and inject window.__OFFDESK_BUILD__ = "". // 2. Every .js chunk: append ?v= to every nested "/_expo/static/js/web/… // .js" reference. Metro emits async-chunk path maps whose FILENAMES are // stable across builds while their contents change (e.g. a lazy wrapper @@ -38,7 +38,7 @@ if (stampedHtml === html) { } const withBuildGlobal = stampedHtml.replace( /window.__OFFDESK_BUILD__=${JSON.stringify(buildId)}; tag found in index.html to anchor build global"); diff --git a/scripts/verify-container-runtime.sh b/scripts/verify-container-runtime.sh index 76bce439..4a664b5d 100755 --- a/scripts/verify-container-runtime.sh +++ b/scripts/verify-container-runtime.sh @@ -1,13 +1,13 @@ #!/usr/bin/env bash set -euo pipefail -IMAGE_TAG="${1:-webmux-runtime-smoke}" +IMAGE_TAG="${1:-offdesk-runtime-smoke}" docker build -t "$IMAGE_TAG" . docker run --rm --entrypoint sh "$IMAGE_TAG" -lc ' - /usr/local/bin/webmux-server --help >/tmp/webmux-help.txt 2>&1 + /usr/local/bin/offdesk-hub --help >/tmp/offdesk-help.txt 2>&1 code=$? - cat /tmp/webmux-help.txt + cat /tmp/offdesk-help.txt exit "$code" ' diff --git a/vitest.config.ts b/vitest.config.ts index 55f17d4f..a90da6b6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,7 +7,7 @@ export default defineConfig({ }, resolve: { alias: { - "@webmux/shared": fileURLToPath(new URL("packages/shared/src", import.meta.url)), + "@offdesk/shared": fileURLToPath(new URL("packages/shared/src", import.meta.url)), "@": fileURLToPath(new URL("packages/app", import.meta.url)), }, }, From 1fcbeab244b39c424eaabf929255491c25a79d1e Mon Sep 17 00:00:00 2001 From: renyuanz Date: Mon, 31 Aug 2026 18:27:18 +0800 Subject: [PATCH 2/4] license: MIT, Copyright (c) 2026 Zalify Inc. LICENSE at the repo root, plus a license field on every Cargo.toml and package.json so the manifests agree with it. Co-Authored-By: Claude Opus 5 --- LICENSE | 21 +++++++++++++++++++++ crates/cli/Cargo.toml | 1 + crates/hub/Cargo.toml | 1 + crates/machine/Cargo.toml | 1 + crates/protocol/Cargo.toml | 1 + package.json | 1 + packages/app/package.json | 1 + packages/desktop/package.json | 1 + packages/desktop/src-tauri/Cargo.toml | 1 + packages/shared/package.json | 1 + 10 files changed, 30 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..d573f651 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Zalify Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index f6ca65a2..e3c91d2b 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -2,6 +2,7 @@ name = "offdesk-cli" version = "0.1.0" edition = "2021" +license = "MIT" [[bin]] name = "offdesk" diff --git a/crates/hub/Cargo.toml b/crates/hub/Cargo.toml index a5417976..08ca9c72 100644 --- a/crates/hub/Cargo.toml +++ b/crates/hub/Cargo.toml @@ -2,6 +2,7 @@ name = "offdesk-hub" version = "0.1.0" edition = "2021" +license = "MIT" [[bin]] name = "offdesk-hub" diff --git a/crates/machine/Cargo.toml b/crates/machine/Cargo.toml index fb44188b..f1f6be68 100644 --- a/crates/machine/Cargo.toml +++ b/crates/machine/Cargo.toml @@ -2,6 +2,7 @@ name = "offdesk-machine" version = "0.1.0" edition = "2021" +license = "MIT" [[bin]] name = "offdesk-node" diff --git a/crates/protocol/Cargo.toml b/crates/protocol/Cargo.toml index 0e5a9de6..36ca7830 100644 --- a/crates/protocol/Cargo.toml +++ b/crates/protocol/Cargo.toml @@ -2,6 +2,7 @@ name = "offdesk-protocol" version = "0.1.0" edition = "2021" +license = "MIT" [dependencies] serde = { workspace = true } diff --git a/package.json b/package.json index 7d41b07b..c7b1339c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "offdesk", "private": true, + "license": "MIT", "type": "module", "packageManager": "pnpm@10.23.0", "scripts": { diff --git a/packages/app/package.json b/packages/app/package.json index c9332c89..4826d87a 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -2,6 +2,7 @@ "name": "@offdesk/app", "version": "0.1.0", "private": true, + "license": "MIT", "main": "expo-router/entry", "scripts": { "dev:web": "expo start --web", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 6388c996..0d56ebb8 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -2,6 +2,7 @@ "name": "@offdesk/desktop", "version": "0.1.0", "private": true, + "license": "MIT", "scripts": { "dev": "tauri dev", "build": "tauri build", diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index 7f763e27..70a5efef 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -2,6 +2,7 @@ name = "offdesk-desktop" version = "0.1.0" edition = "2021" +license = "MIT" # `cdylib` is required by Tauri's mobile (Android/iOS) build, which loads # the Rust code as a shared library from the JVM / ObjC runtime. diff --git a/packages/shared/package.json b/packages/shared/package.json index 948ef7a8..0606f3a7 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -2,6 +2,7 @@ "name": "@offdesk/shared", "version": "0.1.0", "private": true, + "license": "MIT", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", From d6f016ccecdbf02c0f26517a3df966fa2e486e09 Mon Sep 17 00:00:00 2001 From: renyuanz Date: Mon, 31 Aug 2026 18:32:43 +0800 Subject: [PATCH 3/4] docs: rewrite README for first-time users, add setup guides and SECURITY.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README now leads with what offdesk is and how to run it, and keeps the CLI reference under "For agents and scripts". Development instructions and the crate-by-crate layout move to CONTRIBUTING.md. New: - docs/setup-lan.md — hub on a Mac or NAS, phone on the same Wi-Fi, and an explicit account of what that setup does not protect. OFFDESK_DEV_MODE signs in anyone who opens the URL, which is what makes it a five-minute setup and why it must not be exposed. - docs/setup-public.md — VPS behind Caddy with a working Caddyfile and the OAuth callback URLs for GitHub and Google, plus a Tailscale variant and the steps to upgrade an existing webmux deployment. - SECURITY.md — threat model, what the control lease does and does not prevent, what the hub keeps in SQLite, and where to report. - docs/media/README.md — the screenshots and GIFs still to record. - docs/plans/README.md, docs/superpowers/README.md — note that those dated records keep the pre-rename names on purpose. Every claim traces to docs/facts.md. The comparison table has offdesk's row filled and TODO in every competitor cell; those need sourcing before the README is published. Co-Authored-By: Claude Opus 5 --- CONTRIBUTING.md | 71 +++++++++++ README.md | 252 ++++++++++++++++++++++++++++++------- SECURITY.md | 104 +++++++++++++++ docs/facts.md | 13 +- docs/media/README.md | 62 +++++++++ docs/plans/README.md | 10 ++ docs/setup-lan.md | 111 ++++++++++++++++ docs/setup-public.md | 184 +++++++++++++++++++++++++++ docs/superpowers/README.md | 10 ++ 9 files changed, 768 insertions(+), 49 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md create mode 100644 docs/media/README.md create mode 100644 docs/plans/README.md create mode 100644 docs/setup-lan.md create mode 100644 docs/setup-public.md create mode 100644 docs/superpowers/README.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..7fa41422 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,71 @@ +# Contributing + +## Layout + +- `crates/hub` — the hub. Axum + WebSocket + SQLite. Serves the web app as an + SPA, brokers terminal I/O between clients and machines, owns auth + (GitHub/Google OAuth + `odk_` API tokens) and the per-machine control lease. + Binary: `offdesk-hub`. +- `crates/machine` — the machine agent. Registers with a hub, hosts terminals + as tmux sessions (one `tmux attach` per client, so views and scroll position + are independent), reports stats. Binary: `offdesk-node`. +- `crates/cli` — the CLI. Remote `tmux send-keys` + `capture-pane` through the + hub. Binary: `offdesk`. +- `crates/protocol` — wire types shared by all three, plus the config-directory + helper. +- `packages/app` — the only frontend. Expo Router + React Native Web + + xterm.js 6. Built with `expo export --platform web`, served by the hub, + wrapped by Tauri for desktop (`packages/desktop`) and Android. +- `packages/shared` — TypeScript wire contracts. + +The Cargo workspace is `crates/*`. `packages/desktop/src-tauri` is excluded +from it and builds on its own. + +## Running it locally + +```bash +# hub — serves the API and the exported web build on :4317 +OFFDESK_DEV_MODE=true cargo run -p offdesk-hub + +# machine agent — registers on first run +offdesk-node register --hub-url http://127.0.0.1:4317 --token +offdesk-node start + +# web app — Expo dev server; proxy.mjs forwards /api and /ws to the hub +pnpm install && pnpm --filter app dev:web +node proxy.mjs +``` + +`OFFDESK_DEV_MODE=true` enables `GET /api/auth/dev`, a token-less local login. +It returns 404 otherwise. + +## Tests + +```bash +cargo test --workspace # Rust +pnpm test # vitest +pnpm typecheck # tsc -b +pnpm e2e:test # Playwright, in containers +``` + +E2E rules live in `AGENTS.md`. The short version: browser verification runs +Playwright and Chromium inside the `runner` container, via `pnpm e2e:test` +locally or `pnpm e2e:ci` in automation. Do not run `playwright test` directly +for routine checks. `pnpm e2e:test:debug-host` uses a host browser and is for +debugging container startup only — say so explicitly if you use it. + +## Design records + +- `DESIGN.md` — the design system the app actually ships. +- `docs/plans/`, `docs/superpowers/` — dated design specs and reports. They are + a record of what was decided when, so they still use the pre-rename names + (`webmux`, `tc-hub`, `WEBMUX_*`, `wmx_`). Read `docs/facts.md` for the + current names. +- `docs/facts.md` — verified facts about the shipping system, each citing the + file it came from. README and site copy may only assert things on that list. +- `docs/deployment/runbook.md` — operating the production deployment. + +## Conventions + +- Every factual claim in user-facing docs must be backed by code in this repo. +- Product name is `offdesk`, lowercase, always. diff --git a/README.md b/README.md index a57b9dbe..d88700ed 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,143 @@ # offdesk -Web-based control plane for terminals and AI coding agents. Run shells, editors, and TUI agents (Claude Code, Codex, Grok, …) on any machine, reach them from any browser or phone — and drive them programmatically from other agents via the `offdesk` CLI. +Vibe code from your phone, on the terminal running at home. +One self-hosted hub, all your machines, any agent that runs in tmux. -## Architecture + +![Claude Code running on a desk machine, with a phone attached to the same tmux session](docs/media/hero.gif) -- `crates/hub` — Rust server (Axum + WebSocket + SQLite). Serves the web UI as an SPA, brokers terminal I/O between browsers/CLI and machines, owns auth (GitHub/Google OAuth + `odk_` API tokens) and the per-machine control lease (single controller, last-writer-wins). -- `crates/machine` — Rust machine agent (`offdesk-node`). Registers with a hub, hosts terminals as tmux sessions (one `tmux attach` per client — multi-client views, no shared scroll state), reports stats. -- `crates/cli` — Rust CLI (`offdesk`). Remote `tmux send-keys` + `capture-pane` through the hub; the agent-to-agent interface (see below). -- `packages/app` — the only frontend: Expo Router + React Native Web + xterm.js 6. Built with `expo export --platform web`, served by the hub, wrapped by Tauri for desktop (`packages/desktop`) and Android. -- `crates/protocol` (`offdesk-protocol`) — shared wire types between hub, machine, and CLI. +- Runs anything that runs in a terminal: Claude Code, Codex, Grok, vim, htop. + No agent-specific integration. +- One hub, any number of machines. Register a Mac, a NAS, a VPS — open them all + from one URL. +- Your traffic goes through your hub. No third-party server in the path. +- `offdesk open nas --cwd ~/app --cmd claude` works from a script — or from + another agent. +- Rust. The hub is one binary plus a SQLite file. The machine agent is one + binary. -## The `offdesk` CLI (for humans and agents) +## Install -The CLI lets anything that can run a shell command — a human, a script, or another AI agent — list, open, read, write to, and wait on terminals on any machine registered to a hub. +Three pieces. The hub is the only one that needs a URL other people can reach. -### Install & authenticate +### Hub + +Docker, on the machine that will hold the URL: + +```bash +git clone https://github.com/zalify/offdesk && cd offdesk +JWT_SECRET=$(openssl rand -hex 32) docker compose up -d --build +``` + +It listens on `127.0.0.1:4317` and keeps its SQLite file in the `offdesk-data` +volume. Set `JWT_SECRET` — the built-in default is the literal string +`dev-secret-change-me`, which is not a secret. + +The hub signs you in with GitHub or Google OAuth. Which one you configure, and +what callback URL it needs, depends on how you reach the hub, so that part is +in the two setup guides below. + +Or build it directly: + +```bash +cargo build --release --bin offdesk-hub +./target/release/offdesk-hub --listen 0.0.0.0:4317 +``` + + + +### Machine + +On every machine you want to reach. tmux is required — the agent checks for it +at startup and exits if it is missing. ```bash -cargo build --release -p offdesk-cli # binary: target/release/offdesk +cargo build --release --bin offdesk-node +offdesk-node register --hub-url https://your-hub.example.com --token +offdesk-node start ``` -Create an API token in the web UI (**⌃B k → Settings → API Tokens → Create**), then either: +Get `` from the hub's web UI. It is single-use and expires 24 hours +after it is issued. + +To keep it running across reboots — a systemd user service on Linux, a launchd +agent on macOS: ```bash +offdesk-node service install +``` + + + +### CLI + +```bash +cargo build --release --bin offdesk # binary: target/release/offdesk +``` + +Create an API token in the web UI (Settings → API Tokens → Create), then either +write it to a config file: + +```toml # ~/.config/offdesk/config.toml (chmod 600) +# macOS: ~/Library/Application Support/offdesk/config.toml url = "https://your-hub.example.com" token = "odk_..." ``` -or export `OFFDESK_URL` + `OFFDESK_TOKEN` (flags `--url/--token` override both). +or export `OFFDESK_URL` and `OFFDESK_TOKEN`. `--url` and `--token` override +both. + +### Phone + +Open the hub URL in a browser. There is nothing to install. + +## Two setups + +- **At home, off the desk** — hub on your Mac or NAS, phone on the same Wi-Fi. + → [docs/setup-lan.md](docs/setup-lan.md) +- **Away from home** — hub on a VPS behind Caddy, or on your tailnet. + → [docs/setup-public.md](docs/setup-public.md) + +## How it works + +``` + phone / browser your hub your machines + ┌────────────────┐ ┌──────────────┐ ┌──────────────────────┐ + │ xterm.js in a │ │ offdesk-hub │ │ offdesk-node │ + │ browser tab │◄────►│ │◄──────►│ tmux ── claude │ + └────────────────┘ WS │ Axum + WS │ WS │ tmux ── vim │ + │ SQLite │ │ tmux ── htop │ + ┌────────────────┐ │ │ └──────────────────────┘ + │ offdesk CLI │◄────►│ control │ ┌──────────────────────┐ + │ another agent │ WS │ lease │◄──────►│ offdesk-node (NAS) │ + └────────────────┘ └──────────────┘ WS └──────────────────────┘ +``` + +Each machine runs `offdesk-node`, which opens one outbound WebSocket to the hub +and hosts every terminal as a tmux session. Nothing on the machine needs an +inbound port. Browsers, phones, and the CLI all connect to the hub, and the hub +brokers bytes between them and tmux. + +**The control lease** decides who may type. It is held per (user, machine), and +sending input claims it — last writer wins, no queue. Everyone else keeps +receiving output but their keystrokes, resizes, and image pastes are dropped: +they are watching, live, not disconnected. Reading and waiting never claim it. +The lease is held in memory, so it does not survive a hub restart. -### Commands +Because tmux runs with `window-size manual`, a second client attaching or +resizing does not resize anyone else's view. + +## For agents and scripts + +The CLI lets anything that can run a shell command — a human, a script, or +another AI agent — list, open, read, write to, and wait on terminals on any +machine registered to a hub. ``` offdesk machines [--all] [--json] # list machines (default: online; --all includes offline) @@ -39,53 +146,102 @@ offdesk ls [--machine ] [--json] # list terminals: id, title, gro offdesk open --cwd [--cmd ] [--group ] [--json] offdesk read [--lines N] [--json] # capture the current screen as text offdesk read --all [--machine ] [--lines N] [--json] [--concurrency N] [--include-unreachable] - # batch-capture every terminal's screen in one call + # batch-capture every terminal's screen in one call offdesk send [--no-enter] # type text (Enter appended by default) offdesk key ... # Enter Esc Tab BTab Up Down Left Right C-c C-d F1-F12 ... offdesk wait [--pattern ] [--silence ] [--timeout ] offdesk kill [--yes] ``` -- Machines and terminals are addressed by **id prefix** (first column of `ls`); ambiguous prefixes list candidates. -- Exit codes: `0` success / wait condition met · `1` wait timed out · `2` usage/config/network error. Everything is scriptable; `--json` for machine consumption. -- `--lines N` means "the last N rendered lines of the current screen" (after trailing blank lines are trimmed) in both text and JSON mode; JSON also reports `lines_total` (pre-slice count) and `truncated`. -- All printed/serialized output is sanitized (control bytes stripped, `\n`/`\t` and Unicode kept) — safe to pipe to `jq`/`file`. -- `send` types the text, then sends Enter as a **separate delayed frame** (delay scales with line count, capped at 800 ms) so TUI apps that treat multi-line bursts as pastes still submit. `--no-enter` sends the text frame only (pure paste). -- `read --all --json` lists **reachable terminals only** by default, with `skipped_unreachable_count` at the top level; `--include-unreachable` restores their `{"error":"unreachable"}` entries. Each captured entry carries `pane_title`, `title_source` (`osc`/`process`/`none`), `foreground_process` (`{has_foreground_process, process_name}`, null on lookup failure), `activity` (`active`/`quiet`/`idle`) and `idle_ms` — activity is observed during the capture window only. `cwd` is live (tmux `pane_current_path`, refreshed ~5s), not creation-time. - -### Orchestrating an agent inside a terminal +- Machines and terminals are addressed by **id prefix** (first column of `ls`); + ambiguous prefixes list candidates. +- Exit codes: `0` success or wait condition met · `1` wait timed out · + `2` usage/config/network error. `--json` on every command that prints. +- `--lines N` means "the last N rendered lines of the current screen" (after + trailing blank lines are trimmed) in both text and JSON mode; JSON also + reports `lines_total` (pre-slice count) and `truncated`. +- All printed and serialized output is sanitized — control bytes stripped, + `\n`/`\t` and Unicode kept. Safe to pipe to `jq`. +- `send` types the text, then sends Enter as a **separate delayed frame** + (150 ms plus 60 ms per newline, capped at 800 ms) so TUI apps that treat + multi-line bursts as pastes still submit. `--no-enter` sends the text frame + only. +- `read --all --json` lists **reachable terminals only** by default, with + `skipped_unreachable_count` at the top level; `--include-unreachable` + restores their `{"error":"unreachable"}` entries. Each captured entry carries + `pane_title`, `title_source` (`osc`/`process`/`none`), `foreground_process` + (`{has_foreground_process, process_name}`, null on lookup failure), + `activity` (`active`/`quiet`/`idle`) and `idle_ms` — activity is observed + during the capture window only. `cwd` is live (tmux `pane_current_path`), not + creation-time. + +### Driving an agent inside a terminal ```bash T=$(offdesk open nas --cwd ~/projects/foo --cmd claude --json | jq -r .id) offdesk send $T "fix the type errors in src/auth.ts; stop when tests pass" offdesk wait $T --silence 5000 --timeout 600 # or --pattern '❯' to await a prompt -offdesk read $T --lines 80 # collect the result +offdesk read $T --lines 80 # collect the result offdesk kill $T --yes ``` ### Semantics you must know -1. **`send`/`key` claim control** (last-writer-wins). Other clients of the same account become view-only until a human reclaims. Read first if a human might be typing. The CLI claims the machine's control lease automatically for mutating calls (`open`, `kill`) with a stable `cli-` device id. -2. **`read`/`wait` are pure watchers** — they never claim control and never disturb other clients (tmux runs `window-size manual`, so attaching doesn't resize anyone's view). -3. **`read` sees the current screen only** (reconstructed from tmux's attach repaint). For long output, have the session's agent write files and read those. -4. **Don't poll with repeated `read`** (~1 s attach per call) — hold a `wait` for the condition instead. -5. **For an overview of every terminal, use `read --all`** — do not loop N CLI processes over `read` (slow: N×TLS+attach; and consumers that don't drain stdout concurrently can deadlock on the pipe buffer). -6. `REACHABLE=no` terminals belong to offline machines and can't be attached. `read --all` skips them (one-line `skipped (unreachable)` row; omitted from JSON unless `--include-unreachable`) and reports the count on stderr and as `skipped_unreachable_count` in JSON. -7. **A token is remote code execution on every registered machine.** Mint one token per agent (name them in Settings) so you can revoke individually and see who was active via "last used". - -## Development - -```bash -# hub (serves API + the exported web build on :4317) -OFFDESK_DEV_MODE=true cargo run -p offdesk-hub - -# machine agent (registers on first run) -offdesk-node register --hub-url http://127.0.0.1:4317 --token -offdesk-node start - -# web app (Expo dev server; proxy.mjs forwards /api and /ws to the hub) -pnpm install && pnpm --filter app dev:web -node proxy.mjs -``` - -`OFFDESK_DEV_MODE=true` enables `/api/auth/dev` for token-less local logins. See `AGENTS.md` for the E2E browser rules, and `docs/plans/` for design specs (notably `2026-08-03-offdesk-cli.md` for the CLI protocol details and `2026-08-03-api-tokens-ui.md`). +1. **`send`/`key` claim control** (last-writer-wins). Other clients on the same + account become view-only until a human reclaims. Read first if a human might + be typing. The CLI also claims the lease for `open` and `kill`. +2. **`read`/`wait` are pure watchers.** They never claim control and never + disturb other clients — tmux runs `window-size manual`, so attaching does + not resize anyone's view. +3. **`read` sees the current screen only**, reconstructed from tmux's attach + repaint. It cannot see scrollback. For long output, have the agent in the + session write a file and read that. +4. **Don't poll with repeated `read`** — each call costs an attach, about a + second. Hold a `wait` for the condition instead. +5. **For an overview of every terminal, use `read --all`.** Do not loop N CLI + processes over `read`: it is N×(TLS + attach), and a consumer that doesn't + drain stdout concurrently can deadlock on the pipe buffer. +6. `REACHABLE=no` terminals belong to offline machines and can't be attached. + `read --all` skips them and reports the count on stderr and as + `skipped_unreachable_count` in JSON. +7. **A token is remote code execution on every registered machine.** Mint one + per agent, name it, and revoke it individually when you're done. + +## How it compares + +offdesk's row is from [docs/facts.md](docs/facts.md). Every other cell is +unverified — do not treat this table as accurate until the TODOs are filled in. + +| | Any terminal program | Machines per hub | Traffic goes through | Agents can drive it via CLI | Self-hosted | +|---|---|---|---|---|---| +| **offdesk** | Yes — anything that runs in tmux | Any number, one URL | Your own hub | Yes — `open` / `send` / `wait` / `read` | Yes | +| Claude Code Remote Control | TODO | TODO | TODO | TODO | TODO | +| VibeTunnel | TODO | TODO | TODO | TODO | TODO | +| Happy Coder | TODO | TODO | TODO | TODO | TODO | +| Omnara | TODO | TODO | TODO | TODO | TODO | +| Orca | TODO | TODO | TODO | TODO | TODO | + + + +## Security + +**A token is remote code execution on every registered machine.** It opens +terminals and types into them, and a terminal runs whatever your shell runs. +Treat one like an SSH key, not an API key. + +What the hub gives you to contain that: + +- **One token per agent.** Every token has a name you choose at creation. +- **Individual revoke.** Deleting one token does not touch the others. +- **Last used.** Every token records the last time it authenticated, so you can + tell which ones are live. +- Tokens are stored as SHA-256 hashes. The plaintext is shown once, at + creation, and is not recoverable. + +Threat model, what the control lease does and does not prevent, and what the +hub keeps in SQLite: [SECURITY.md](SECURITY.md). + +## License + +MIT. See [LICENSE](LICENSE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..fca22959 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,104 @@ +# Security + +## The one sentence that matters + +**A token is remote code execution on every registered machine.** offdesk opens +terminals and types into them, and a terminal runs whatever your shell runs. +Anyone holding a valid token can open a terminal on any machine on your hub and +run anything as you. Treat a token like an SSH private key, not like an API +key. + +The same is true of a session cookie, a hub URL that signs people in, and +physical access to a logged-in browser tab. + +## What holds a key to your machines + +| Credential | Where it lives | What it opens | Revoke by | +|---|---|---|---| +| API token (`odk_…`) | you keep it; hub stores a SHA-256 hash | every machine on that account | deleting it in Settings | +| Session JWT | browser localStorage | same | rotating `JWT_SECRET` (invalidates all sessions) | +| Machine registration token | one-time, from Settings | registering one new machine | expires after 24 hours, single-use | +| Machine secret | `machine.json` on the machine | that machine's connection to the hub | forgetting the machine in the UI or `offdesk machines rm` | + +Tokens you mint are named, and every one records the last time it +authenticated. Mint one per agent or per script. Then a leak is one revoke, and +"last used" tells you which token was live. + +The session JWT is signed HS256 and valid for **180 days**. There is no +server-side session list and no per-session logout — the only way to invalidate +an issued JWT before it expires is to change `JWT_SECRET`, which logs everyone +out. + +## Hub configuration that decides how exposed you are + +- **`JWT_SECRET` defaults to the literal string `dev-secret-change-me`.** A hub + deployed without setting it signs sessions with a value published in this + repository, so anyone can mint a valid session for any user. Set it. +- **`OFFDESK_DEV_MODE=true` disables sign-in.** The web client calls the + dev-login endpoint on its own, unprompted, and everyone who opens the URL + lands on the same shared account. It exists for local development and a + trusted LAN. Never enable it on anything reachable from the internet. +- **OAuth sign-in has no allowlist.** With GitHub or Google configured, any + account that completes the flow becomes a user on your hub. If you need to + restrict who can sign in, put the hub on a tailnet or behind your own + authenticating proxy. +- **The machine agent needs no inbound port.** It dials out to the hub. Do not + expose it. + +## What the control lease does and does not do + +The control lease decides who may type. It is held per (user, machine), in the +hub's memory. + +**It prevents:** two of your own clients fighting over the keyboard. Only the +lease holder's input, resizes, and image pastes reach the terminal. Everyone +else keeps receiving output, so they watch live rather than getting kicked off. + +**It does not prevent anything an attacker would do.** It is a coordination +mechanism, not a security boundary: + +- Claiming it takes no permission. Sending input claims it, unconditionally — + last writer wins. A second token holder on the same account takes control + from you the moment they type. +- It does not restrict reading. A view-only client sees every byte of output, + including whatever your agent prints. +- It is per account, not per credential. A leaked token is not a lesser + participant than your browser. +- It lives in memory and does not survive a hub restart. + +If you would not let someone type on the machine, do not give them a token. + +## What the hub stores + +SQLite, at the path in `DATABASE_PATH`. + +**It stores:** your OAuth provider id, display name and avatar URL; each +machine's name, OS, home directory, last-seen time and bcrypt-hashed secret; +SHA-256 hashes of API and registration tokens; per-terminal **titles, working +directories** and window sizes; bookmarks, workspace groups and saved layouts; +and, for structured agent sessions, the full event stream including prompts and +responses. + +**It does not store** terminal output or scrollback for ordinary terminals. +Those live only in tmux on the machine, with a 10,000-line history limit, and +are gone when the session ends. + +Practically: a stolen hub database does not replay your terminal sessions, but +it does reveal what you were working on, where, and when — and every prompt and +answer from structured agent sessions. + +## Transport + +Traffic between the browser, the hub, and each machine is WebSocket. offdesk +does not terminate TLS itself. Put it behind a reverse proxy that does +(`docs/setup-public.md`), or keep it on a network where plaintext is acceptable +(`docs/setup-lan.md`). There is no end-to-end encryption: the hub sees +everything in the clear, which is the trade you make for it being your hub. + +## Reporting a vulnerability + +Email **security@offdesk.dev**. Include what you did, what happened, and what +you expected. Please do not open a public issue for anything exploitable. + +We will acknowledge your report and tell you whether we think it is a +vulnerability. This is a small project without a paid bounty program. diff --git a/docs/facts.md b/docs/facts.md index f771b686..484dc596 100644 --- a/docs/facts.md +++ b/docs/facts.md @@ -112,7 +112,18 @@ url or token exits 2 (`crates/cli/src/config.rs::resolve`). - GitHub: `/api/auth/github/callback`, scope `read:user` - Google: `/api/auth/google/callback`, scope `openid email profile` - `GET /api/auth/dev` returns 404 unless `WEBMUX_DEV_MODE=true` - (`crates/hub/src/routes/auth.rs:177`). + (`crates/hub/src/routes/auth.rs`). When it is enabled it finds-or-creates a + single shared user with provider `dev` / provider_id `dev-user`, named + "Dev User", and returns a signed JWT. The first user created on a hub gets + role `admin`. +- **The web client calls it automatically.** `packages/app/lib/auth.tsx` + restores a session in three steps: `?token=` from an OAuth redirect, then a + stored token, then — on web — an unprompted `devLogin()`. So a hub running + with dev mode on logs in *anyone who opens the URL*, as the same user, with + no prompt. That is the fastest way to get running on a LAN and it is not + safe to expose. +- The login screen itself only offers GitHub and Google + (`packages/app/app/login.tsx`); dev mode never appears as a button. - Session JWT: HS256, expiry **180 days** (`JWT_EXPIRY_DAYS`, `crates/hub/src/auth.rs`). Delivered to the browser as `?token=` on the post-OAuth redirect. diff --git a/docs/media/README.md b/docs/media/README.md new file mode 100644 index 00000000..0c66ca69 --- /dev/null +++ b/docs/media/README.md @@ -0,0 +1,62 @@ +# Media to record + +Nothing here is committed yet. Each file below is referenced by the README, the +site, or both, and each will render as a broken image until it exists. + +Shoot everything on a dark terminal. The product is dark-only, so a light +screenshot will look like a different app. + +## Required + +### `hero.gif` + +Referenced by: `README.md` (top), `site/public/media/hero.gif` (hero section). +This is the one that has to work; everything else is optional. + +The whole pitch in one loop: the same terminal, on two screens, at once. + +- **Frame:** a desk screen and a phone in one shot. Real phone in hand is + better than a simulated frame. +- **On the desk screen:** Claude Code mid-task in an offdesk terminal, output + scrolling. +- **On the phone:** the same session, attached, showing the same output. +- **The beat that sells it:** type on the phone, and the desk screen updates. + Then keep the desk screen visible while the phone keeps working — that is the + control lease and it should read as obvious, not explained. +- **Length:** 6–10 seconds, looping cleanly. No captions, no cursor + highlighting, no zoom effects. +- **Size:** under 4 MB. It loads on a phone, on mobile data, above the fold. + +## Worth having + +### `machines.png` + +The machine list with **three** machines registered and online — a laptop, a +NAS, a VPS, named so the difference is obvious. This is the "one hub, many +machines" claim, and one machine in the shot proves nothing. + +### `cli.png` or `cli.gif` + +A terminal running the orchestration example from the README end to end: +`offdesk open` → `send` → `wait` → `read`. Let `wait` actually block for a +second or two; that pause is the point of the command. + +### `tokens.png` + +Settings → API Tokens, with several tokens that have distinct names +(`claude-nas`, `ci-runner`, `phone`) and visibly different "last used" values. +This is the security story: per-agent tokens, individually revocable. + +### `phone-terminal.png` + +Portrait phone screenshot, real device. A full-screen terminal with the key bar +at the bottom — the Ctrl/Esc/arrow row. Something recognisable on screen, `vim` +or `htop` rather than a bare prompt, to make "it is the real terminal" land. + +## Before you commit anything + +- No real hostnames, tokens, IP addresses, or client names on screen. Check the + tmux status area and the browser URL bar. +- No `webmux` anywhere in frame — that is the whole point of the rename. +- Compress GIFs (`gifsicle -O3 --lossy=80`). A 20 MB hero GIF is worse than no + hero GIF. diff --git a/docs/plans/README.md b/docs/plans/README.md new file mode 100644 index 00000000..049e5a7b --- /dev/null +++ b/docs/plans/README.md @@ -0,0 +1,10 @@ +# Design plans + +Dated design specs and their follow-up reports, kept as a record of what was +decided when. + +They are **not** maintained. Everything written before 2026-08-31 uses the +names the project had at the time — `webmux`, `webmux-node`, `tc-hub`, +`tc-cli`, `WEBMUX_*`, `wmx_`, `~/.config/webmux/`. Commands in those files will +not run as written. `docs/facts.md` has the current names, and the mapping is +at the bottom of it. diff --git a/docs/setup-lan.md b/docs/setup-lan.md new file mode 100644 index 00000000..c72db487 --- /dev/null +++ b/docs/setup-lan.md @@ -0,0 +1,111 @@ +# At home, off the desk + +Hub on a Mac or a NAS, phone on the same Wi-Fi. Target: working in five +minutes. + +This setup has no TLS and no real sign-in. It is for a network you control. +Read [What this setup does not protect](#what-this-setup-does-not-protect) +before you use it, and use [setup-public.md](setup-public.md) if the hub will +be reachable from outside your home. + +## 1. Run the hub + +On the machine that will hold the URL — the Mac that stays on, or the NAS: + +```bash +git clone https://github.com/zalify/offdesk && cd offdesk +``` + +The bundled `docker-compose.yml` binds to `127.0.0.1:4317` so a public +deployment does not expose the hub directly. Your phone cannot reach that, so +add an override file next to it: + +```bash +cat > docker-compose.override.yml <<'YAML' +services: + server: + ports: !override ["4317:4317"] + environment: + OFFDESK_DEV_MODE: "true" +YAML +``` + +`!override` replaces the base file's port list instead of adding to it — +without it Compose keeps both bindings and the second one fails on the port +already being in use. It needs Compose v2.24 or newer (`docker compose +version`). + +```bash +JWT_SECRET=$(openssl rand -hex 32) docker compose up -d --build +``` + +Docker Compose merges `docker-compose.override.yml` automatically. Or skip +Docker and run the binary: + +```bash +cargo build --release --bin offdesk-hub +OFFDESK_DEV_MODE=true JWT_SECRET=$(openssl rand -hex 32) \ + ./target/release/offdesk-hub --listen 0.0.0.0:4317 +``` + +Either way the point is the same: bind to `0.0.0.0`, not loopback, so the +phone can reach it. Find the machine's LAN address: + +```bash +ipconfig getifaddr en0 # macOS +hostname -I | awk '{print $1}' # Linux +``` + +## 2. Register the machine + +`offdesk-node` needs tmux. Install it first — `brew install tmux` on macOS, +`sudo apt install tmux` on Debian or Ubuntu. The agent exits at startup if tmux +is missing. + +Open `http://:4317` in a browser on the same machine. With +`OFFDESK_DEV_MODE=true` it signs you straight in. Go to Settings and create a +machine registration token, then: + +```bash +cargo build --release --bin offdesk-node +./target/release/offdesk-node register --hub-url http://:4317 --token +./target/release/offdesk-node start +``` + +The token is single-use and expires 24 hours after it is issued. + +To keep the agent running after a reboot: + +```bash +./target/release/offdesk-node service install +``` + +That writes a systemd user unit on Linux (and runs `loginctl enable-linger` so +it survives logout), or a launchd agent on macOS. + +## 3. Open it on your phone + +Browse to `http://:4317`. Nothing to install. Open a terminal, run +`claude`, and it is the same tmux session you would get on the desk. + +Leave the desktop browser open on the same terminal if you want to watch. Both +clients see the output; whichever one types last holds the control lease, and +the other goes view-only until it types again. + +## What this setup does not protect + +- **`OFFDESK_DEV_MODE=true` signs in anyone who opens the URL.** The web client + calls the dev-login endpoint on its own, with no prompt, and everyone who + does lands on the same account. Anyone who can reach `:4317` — a + guest on your Wi-Fi, a smart TV, anything on the network — gets a shell on + every registered machine. +- **There is no TLS.** Traffic, including the session JWT, crosses your LAN in + the clear. +- **Do not port-forward this.** If you want the hub reachable from outside, + turn dev mode off, configure OAuth, and put it behind TLS: + [setup-public.md](setup-public.md). + +To close the first hole without leaving the LAN, drop `OFFDESK_DEV_MODE` and +configure GitHub OAuth with `http://:4317/api/auth/github/callback` as +the callback URL. GitHub accepts a plain-http callback; Google does not, except +on `localhost`. diff --git a/docs/setup-public.md b/docs/setup-public.md new file mode 100644 index 00000000..018137ec --- /dev/null +++ b/docs/setup-public.md @@ -0,0 +1,184 @@ +# Away from home + +Two ways to reach your hub from anywhere: + +- **[A VPS behind Caddy](#a-vps-behind-caddy)** — a real domain, a real + certificate, open to the internet. Sign-in is GitHub or Google OAuth. +- **[Tailscale](#tailscale)** — no public exposure at all. The hub is only + reachable from devices on your tailnet. + +Either way, do not run with `OFFDESK_DEV_MODE=true`. It makes the web client +sign in silently as a shared user, with no prompt, for anyone who opens the +URL. See [setup-lan.md](setup-lan.md) for what that is for. + +## A VPS behind Caddy + +### 1. Hub + +```bash +git clone https://github.com/zalify/offdesk && cd offdesk +``` + +Write the environment the compose file reads, as `.env` next to +`docker-compose.yml`: + +``` +JWT_SECRET= +OFFDESK_BASE_URL=https://offdesk.example.com +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +``` + +```bash +chmod 600 .env +``` + +`OFFDESK_BASE_URL` is the public origin. The hub builds its OAuth callback URLs +from it, so it must match what you register with GitHub and Google exactly — +scheme, host, no trailing slash. + +Fill in at least one provider's pair (next section), then: + +```bash +docker compose up -d --build +``` + +The compose file binds to `127.0.0.1:4317` on purpose. Caddy is what faces the +internet. + +### 2. OAuth callback URLs + +The hub always builds these two paths from `OFFDESK_BASE_URL`: + +| Provider | Callback URL to register | Scope requested | +|---|---|---| +| GitHub | `https://offdesk.example.com/api/auth/github/callback` | `read:user` | +| Google | `https://offdesk.example.com/api/auth/google/callback` | `openid email profile` | + +**GitHub** — github.com → Settings → Developer settings → OAuth Apps → New +OAuth App. Homepage URL is `https://offdesk.example.com`; Authorization +callback URL is the one in the table. Copy the Client ID, generate a client +secret, and put both in `.env` as `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET`. + +**Google** — console.cloud.google.com → APIs & Services → Credentials → Create +credentials → OAuth client ID → Web application. Add the table's URL under +**Authorized redirect URIs**, not "Authorized JavaScript origins". Put the +client ID and secret in `.env` as `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET`. +Google rejects plain-http redirect URIs except on `localhost`, so this path +needs the certificate working first. + +Restart after editing `.env`: + +```bash +docker compose up -d +``` + +Anyone who can complete either OAuth flow gets an account on your hub. There is +no allowlist. If that is not what you want, keep the hub on a tailnet instead. + +### 3. Caddy + +```caddyfile +offdesk.example.com { + reverse_proxy 127.0.0.1:4317 +} +``` + +That is the whole file. Caddy obtains the certificate itself, and its default +reverse proxy already forwards WebSocket upgrades, which is all the hub needs. + +If Caddy runs in Docker on a shared network with the hub, target the container +name instead: + +```caddyfile +offdesk.example.com { + reverse_proxy offdesk-server-1:4317 +} +``` + +Reload: + +```bash +docker exec caddy caddy reload --config /etc/caddy/Caddyfile +``` + +### 4. Machines + +On each machine you want to reach — tmux required: + +```bash +cargo build --release --bin offdesk-node +./target/release/offdesk-node register --hub-url https://offdesk.example.com --token +./target/release/offdesk-node service install +``` + +Create `` in the hub's Settings. It is single-use and expires after 24 +hours. The machine agent dials out to the hub over WebSocket, so it needs no +inbound port and no port forwarding of its own. That is why a laptop behind NAT +works here. + +## Tailscale + +Same hub, no public exposure. Nothing listens on the internet, so there is no +certificate to manage and no login page facing strangers. + +```bash +tailscale up +``` + +```bash +docker compose up -d --build +``` + +```bash +tailscale serve --bg 4317 +``` + +`tailscale serve` publishes it at `https://..ts.net` with a +certificate Tailscale issues, reachable only by devices on your tailnet. Set +that hostname as `OFFDESK_BASE_URL` and use it for the OAuth callback URLs the +same way as above. + +Do not use `tailscale funnel` here unless you mean it — funnel puts the hub on +the public internet, which is the thing this section avoids. + +Machines register with the tailnet URL: + +```bash +offdesk-node register --hub-url https://..ts.net --token +``` + +Your phone needs the Tailscale app and needs to be on the tailnet. That is the +trade: one more app, against a hub no stranger can reach. + +## Upgrading a webmux deployment + +The rename changed two things a running deployment cares about. + +**The container's SQLite path.** It was `/app/data/tc.db` and is now +`/app/data/offdesk.db`. Rename the file inside the volume before the first +start, or the hub creates an empty database and every machine looks +unregistered: + +```bash +docker compose down +``` + +```bash +docker run --rm -v webmux_webmux-data:/data alpine mv /data/tc.db /data/offdesk.db +``` + +Check the volume's real name first with `docker volume ls` — Compose prefixes +it with the project directory name. + +**The volume name.** It was `webmux-data` and is now `offdesk-data`. Either +copy the contents across, or keep using the old volume by naming it explicitly +in an override file. + +Nothing else needs a migration. `WEBMUX_*` environment variables still work +with a deprecation notice on stderr, `wmx_` API tokens still authenticate, +`offdesk-node` moves its own config directory on first run, and a node whose +tmux sessions predate the rename keeps using the old tmux socket until those +sessions are closed. diff --git a/docs/superpowers/README.md b/docs/superpowers/README.md new file mode 100644 index 00000000..252429f6 --- /dev/null +++ b/docs/superpowers/README.md @@ -0,0 +1,10 @@ +# Superpowers plans and specs + +Dated design specs and implementation plans, kept as a record of what was +decided when. + +They are **not** maintained. Everything written before 2026-08-31 uses the +names the project had at the time — `webmux`, `webmux-node`, `tc-hub`, +`tc-cli`, `WEBMUX_*`, `wmx_`, `~/.config/webmux/`. Commands in those files will +not run as written. `docs/facts.md` has the current names, and the mapping is +at the bottom of it. From ffa1ca333fd294fdb90dc59f0dd55d776cf7af73 Mon Sep 17 00:00:00 2001 From: renyuanz Date: Mon, 31 Aug 2026 19:12:44 +0800 Subject: [PATCH 4/4] docs: repo metadata and a Show HN stub for the rename docs/github-metadata.md holds the repo description, website, and topics to paste into the GitHub settings UI, plus a checklist of the one-time steps the rename needs (renaming the repo, publishing the container under its new name, tagging a release so the install script has binaries to fetch). docs/launch/show-hn.md is a stub. Ryan is supplying the draft; it carries a TODO plus the verified raw material and the questions worth having an answer ready for. Co-Authored-By: Claude Opus 5 --- docs/github-metadata.md | 49 +++++++++++++++++++++++++++++++++++++++++ docs/launch/show-hn.md | 34 ++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 docs/github-metadata.md create mode 100644 docs/launch/show-hn.md diff --git a/docs/github-metadata.md b/docs/github-metadata.md new file mode 100644 index 00000000..3cbcb597 --- /dev/null +++ b/docs/github-metadata.md @@ -0,0 +1,49 @@ +# GitHub repo metadata + +Paste-ready values for the repository settings page. Nothing here is applied by +code — set it in the GitHub UI. + +## Description + +Settings → General, or the "About" gear on the repo home page. + +``` +Vibe code from your phone on the terminal you left at home. +``` + +## Website + +``` +https://offdesk.dev +``` + +## Topics + +About → gear → Topics. Add each one: + +``` +tmux +terminal +self-hosted +claude-code +codex +ai-agents +remote-development +rust +mobile +``` + +## Checklist for the rename + +- [ ] Rename the repository to `offdesk` (Settings → General → Repository + name). GitHub redirects the old URL, so existing clones keep working. +- [ ] Set the description and topics above. +- [ ] Set the website to `https://offdesk.dev`. +- [ ] Under About, tick "Releases" and "Packages" so the node binaries and the + container image show on the home page. +- [ ] Create the `offdesk` Cloudflare Pages project and add the two secrets + listed in `.github/workflows/site.yml`. +- [ ] Tag a `v*` release so `https://offdesk.dev/install` has binaries to + fetch. Until then it exits with a message pointing at `cargo build`. +- [ ] Check the container workflow published `ghcr.io/zalify/offdesk-hub`, and + make the package public if the old one was. diff --git a/docs/launch/show-hn.md b/docs/launch/show-hn.md new file mode 100644 index 00000000..0b880e3c --- /dev/null +++ b/docs/launch/show-hn.md @@ -0,0 +1,34 @@ +# Show HN + + + +## Title + +TODO + +## Body + +TODO