release: prepare v0.9.7
@@ -1,51 +1,175 @@
|
||||
# Desktop Shell Scaffold
|
||||
# Desktop Shell
|
||||
|
||||
This folder is the first native-side scaffold for the staged desktop boundary.
|
||||
Native-side scaffold for the ShadowBroker desktop boundary.
|
||||
|
||||
## Purpose
|
||||
|
||||
It gives the future Tauri/native shell a concrete shape for:
|
||||
This package owns the accepted desktop track:
|
||||
|
||||
- command routing
|
||||
- handler grouping
|
||||
- runtime bridge installation
|
||||
- native privileged control routing through Rust
|
||||
- authoritative policy enforcement and audit
|
||||
- packaged managed local backend ownership
|
||||
- packaged desktop runtime with same-origin `/api/*`
|
||||
- tray/menu-bar lifecycle
|
||||
- optional reduced-trust browser companion mode
|
||||
- desktop packaging/release tooling
|
||||
|
||||
without forcing a packaging migration yet.
|
||||
Browser mode remains intact; the desktop path layers on top of it.
|
||||
|
||||
## Source of truth
|
||||
|
||||
The shared desktop control contract still lives in:
|
||||
|
||||
- `F:\Codebase\Oracle\live-risk-dashboard\frontend\src\lib\desktopControlContract.ts`
|
||||
- `frontend/src/lib/desktopControlContract.ts`
|
||||
- `frontend/src/lib/desktopControlRouting.ts`
|
||||
|
||||
The native-side scaffold imports that contract rather than redefining it.
|
||||
The native side imports that contract instead of redefining it.
|
||||
|
||||
## First command scope
|
||||
## Layout
|
||||
|
||||
The initial native command set covers only:
|
||||
```text
|
||||
desktop-shell/
|
||||
├── package.json
|
||||
├── scripts/
|
||||
│ └── run-desktop-build.cjs # Cross-platform npm build wrapper
|
||||
├── src/
|
||||
│ ├── runtimeBridge.ts
|
||||
│ ├── nativeControlRouter.ts
|
||||
│ ├── nativeControlAudit.ts
|
||||
│ └── handlers/
|
||||
└── tauri-skeleton/
|
||||
├── dev.sh
|
||||
├── build.sh
|
||||
├── build.ps1
|
||||
├── RELEASE.md
|
||||
├── scripts/
|
||||
│ ├── generate-icons.cjs
|
||||
│ └── write-release-manifest.cjs
|
||||
└── src-tauri/
|
||||
├── Cargo.toml
|
||||
├── tauri.conf.json
|
||||
├── icons/ # Generated branded bundle assets
|
||||
└── src/
|
||||
├── main.rs
|
||||
├── bridge.rs
|
||||
├── policy.rs
|
||||
├── tray.rs
|
||||
├── companion.rs
|
||||
├── companion_server.rs
|
||||
├── handlers.rs
|
||||
└── http_client.rs
|
||||
```
|
||||
|
||||
- Wormhole lifecycle
|
||||
- protected settings get/set
|
||||
- update trigger
|
||||
## Desktop runtime model
|
||||
|
||||
That is deliberate. The goal is to move the local privileged control plane first, not the entire
|
||||
mesh data plane.
|
||||
### Native privileged path
|
||||
|
||||
## Scaffold layout
|
||||
The accepted 27-command privileged path remains native-only:
|
||||
|
||||
- `F:\Codebase\Oracle\live-risk-dashboard\desktop-shell\src\types.ts`
|
||||
- `F:\Codebase\Oracle\live-risk-dashboard\desktop-shell\src\handlers\wormholeHandlers.ts`
|
||||
- `F:\Codebase\Oracle\live-risk-dashboard\desktop-shell\src\handlers\settingsHandlers.ts`
|
||||
- `F:\Codebase\Oracle\live-risk-dashboard\desktop-shell\src\handlers\updateHandlers.ts`
|
||||
- `F:\Codebase\Oracle\live-risk-dashboard\desktop-shell\src\nativeControlRouter.ts`
|
||||
- `F:\Codebase\Oracle\live-risk-dashboard\desktop-shell\src\runtimeBridge.ts`
|
||||
- frontend bridge detection builds `window.__SHADOWBROKER_LOCAL_CONTROL__`
|
||||
- privileged requests go through Tauri IPC
|
||||
- Rust policy enforces capability/profile rules before dispatch
|
||||
- Rust audit ring records all outcomes
|
||||
- the native admin key never reaches webview JavaScript
|
||||
|
||||
## How to use later
|
||||
### Packaged main window
|
||||
|
||||
When the Tauri shell is introduced, its command layer should:
|
||||
Packaged builds now own a bundled local backend runtime by default, then use an
|
||||
app-level loopback server as the native window origin so ordinary
|
||||
non-privileged `/api/*` fetches resolve same-origin instead of dying on static
|
||||
asset serving.
|
||||
|
||||
1. receive `invokeLocalControl(command, payload)`
|
||||
2. dispatch through `createNativeControlRouter(...)`
|
||||
3. return the handler result back to the frontend bridge
|
||||
### Browser companion
|
||||
|
||||
This keeps the frontend contract stable while shifting privileged ownership into the native shell.
|
||||
Browser companion remains:
|
||||
|
||||
- optional
|
||||
- loopback-only
|
||||
- explicitly enabled
|
||||
- reduced-trust
|
||||
|
||||
It never receives the native bridge injection, and it is not a drop-in
|
||||
replacement for standalone browser mode.
|
||||
|
||||
## Packaging / release flow
|
||||
|
||||
Use any of these entrypoints:
|
||||
|
||||
```bash
|
||||
./desktop-shell/tauri-skeleton/build.sh
|
||||
./desktop-shell/tauri-skeleton/build.ps1
|
||||
npm --prefix desktop-shell run build:desktop
|
||||
```
|
||||
|
||||
Use `--clean` to remove the previous export, generated icons, and old installer
|
||||
artifacts before rebuilding.
|
||||
|
||||
The release flow now:
|
||||
|
||||
1. generates branded desktop icons
|
||||
2. stages a desktop-only frontend export tree without Next server-only
|
||||
route handlers / middleware
|
||||
3. stages a managed backend runtime bundle from `backend/`
|
||||
4. builds the frontend export for Tauri packaging
|
||||
5. copies the export to `companion-www`
|
||||
6. runs `cargo tauri build`
|
||||
7. writes `SHA256SUMS.txt` and `release-manifest.json` next to the bundle output
|
||||
|
||||
If the Tauri CLI is missing, the build scripts now fail immediately with the
|
||||
correct `cargo install tauri-cli@^2` instruction.
|
||||
|
||||
The repo also now has a no-secrets desktop matrix workflow at
|
||||
[`../.github/workflows/desktop-release.yml`](../.github/workflows/desktop-release.yml)
|
||||
that builds unsigned desktop artifacts on Windows, macOS, and Linux and turns
|
||||
`v*.*.*` tags into downloadable GitHub release assets.
|
||||
|
||||
See [`tauri-skeleton/RELEASE.md`](./tauri-skeleton/RELEASE.md) for release-path
|
||||
details and [`tauri-skeleton/RELEASE_INPUTS.md`](./tauri-skeleton/RELEASE_INPUTS.md)
|
||||
for the future inputs that only matter once public distribution trust becomes a
|
||||
goal.
|
||||
|
||||
## Current status
|
||||
|
||||
This is a **runnable desktop foundation with a repeatable packaging path**.
|
||||
|
||||
What works:
|
||||
|
||||
- native desktop window with full app UI
|
||||
- packaged desktop ownership of a bundled local backend runtime
|
||||
- packaged desktop auto-generates and persists its local backend admin/private-plane secrets on first run
|
||||
- packaged desktop-managed backend blocks legacy `16`-hex node-ID compat and direct `legacy_agent_id` lookup by default
|
||||
- packaged same-origin `/api/*` path for non-privileged data
|
||||
- Rust-side policy enforcement and audit
|
||||
- tray/menu-bar background lifecycle
|
||||
- macOS dock reopen
|
||||
- optional reduced-trust browser companion opener
|
||||
- branded Tauri/Windows/macOS bundle icons
|
||||
- release manifest + checksum generation
|
||||
|
||||
What is still not done:
|
||||
|
||||
- code signing / notarization
|
||||
- auto-update mechanism
|
||||
- final installer copy / splash polish
|
||||
- DM/data-plane native migration
|
||||
- standalone-browser-equivalent companion parity
|
||||
|
||||
## Managed backend defaults
|
||||
|
||||
The packaged desktop-managed backend now defaults to the hardened posture for
|
||||
compatibility sunset work:
|
||||
|
||||
- `MESH_BLOCK_LEGACY_NODE_ID_COMPAT=true`
|
||||
- `MESH_ALLOW_LEGACY_NODE_ID_COMPAT_UNTIL=` unless an operator sets a dated temporary migration override
|
||||
- `MESH_BLOCK_LEGACY_AGENT_ID_LOOKUP=true`
|
||||
|
||||
That default applies to the app-owned managed backend created under
|
||||
`%LOCALAPPDATA%`. Source/server deployments remain operator-controlled and can
|
||||
set those flags independently.
|
||||
|
||||
If a managed desktop operator leaves `MESH_BLOCK_LEGACY_NODE_ID_COMPAT=false`
|
||||
in the managed backend `.env`, bootstrap now normalizes it back to `true`.
|
||||
The only supported escape hatch for legacy 16-hex node IDs is a dated
|
||||
`MESH_ALLOW_LEGACY_NODE_ID_COMPAT_UNTIL=YYYY-MM-DD` override.
|
||||
`MESH_BLOCK_LEGACY_AGENT_ID_LOOKUP=false` is still preserved if an operator
|
||||
intentionally needs that separate migration path.
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@shadowbroker/desktop-shell",
|
||||
"version": "0.9.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@shadowbroker/desktop-shell",
|
||||
"version": "0.9.7",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@shadowbroker/desktop-shell",
|
||||
"version": "0.9.7",
|
||||
"private": true,
|
||||
"description": "ShadowBroker desktop shell packaging, runtime bridge, and release tooling",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build:desktop": "node ./scripts/run-desktop-build.cjs",
|
||||
"build:desktop:clean": "node ./scripts/run-desktop-build.cjs --clean"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { spawn } = require('node:child_process');
|
||||
const path = require('node:path');
|
||||
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const forwardedArgs = process.argv
|
||||
.slice(2)
|
||||
.map((arg) => (process.platform === 'win32' && arg === '--clean' ? '-Clean' : arg));
|
||||
|
||||
const buildScript = process.platform === 'win32'
|
||||
? path.join(root, 'tauri-skeleton', 'build.ps1')
|
||||
: path.join(root, 'tauri-skeleton', 'build.sh');
|
||||
|
||||
const command = process.platform === 'win32' ? 'powershell' : 'bash';
|
||||
const args = process.platform === 'win32'
|
||||
? ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', buildScript, ...forwardedArgs]
|
||||
: [buildScript, ...forwardedArgs];
|
||||
|
||||
const child = spawn(command, args, {
|
||||
cwd: root,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal);
|
||||
return;
|
||||
}
|
||||
process.exit(code ?? 1);
|
||||
});
|
||||
@@ -7,7 +7,6 @@ export function createSettingsHandlers(): Pick<
|
||||
| 'settings.privacy.get'
|
||||
| 'settings.privacy.set'
|
||||
| 'settings.api_keys.get'
|
||||
| 'settings.api_keys.set'
|
||||
| 'settings.news.get'
|
||||
| 'settings.news.set'
|
||||
| 'settings.news.reset'
|
||||
@@ -29,12 +28,6 @@ export function createSettingsHandlers(): Pick<
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
'settings.api_keys.get': async (_payload, _ctx, exec) => exec('/api/settings/api-keys'),
|
||||
'settings.api_keys.set': async (payload, _ctx, exec) =>
|
||||
exec('/api/settings/api-keys', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
'settings.news.get': async (_payload, _ctx, exec) => exec('/api/settings/news-feeds'),
|
||||
'settings.news.set': async (payload, _ctx, exec) =>
|
||||
exec('/api/settings/news-feeds', {
|
||||
|
||||
@@ -15,6 +15,7 @@ export function createWormholeHandlers(): Pick<
|
||||
| 'wormhole.gate.persona.clear'
|
||||
| 'wormhole.gate.key.get'
|
||||
| 'wormhole.gate.key.rotate'
|
||||
| 'wormhole.gate.state.resync'
|
||||
| 'wormhole.gate.message.compose'
|
||||
| 'wormhole.gate.message.decrypt'
|
||||
| 'wormhole.gate.message.post'
|
||||
@@ -56,6 +57,12 @@ export function createWormholeHandlers(): Pick<
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
'wormhole.gate.state.resync': async (payload, _ctx, exec) =>
|
||||
exec('/api/wormhole/gate/state/export', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
'wormhole.gate.message.compose': async (payload, _ctx, exec) =>
|
||||
exec('/api/wormhole/gate/message/compose', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -1,33 +1,174 @@
|
||||
# Tauri Skeleton
|
||||
|
||||
This folder is the first concrete Tauri-side integration skeleton for the desktop boundary.
|
||||
Cross-platform Tauri integration for the ShadowBroker desktop boundary.
|
||||
|
||||
## Scope
|
||||
|
||||
It is intentionally limited to the first trusted local control-plane command set:
|
||||
This skeleton covers the accepted native desktop foundation:
|
||||
|
||||
- Wormhole lifecycle
|
||||
- protected settings reads/writes
|
||||
- update trigger
|
||||
- Rust-authoritative local-control policy enforcement and audit
|
||||
- cross-platform tray/menu-bar lifecycle
|
||||
- packaged managed local backend runtime
|
||||
- packaged loopback runtime for same-origin `/api/*`
|
||||
- optional reduced-trust browser companion opener
|
||||
- desktop packaging flow with branded bundle icons and release manifests
|
||||
|
||||
It does **not** attempt to move DM/data-plane operations yet.
|
||||
It does **not** move DM/data-plane operations into native code.
|
||||
|
||||
## What this scaffold demonstrates
|
||||
## Architecture
|
||||
|
||||
1. a native `invoke_local_control` command entrypoint
|
||||
2. a small Rust-side router for the first command set
|
||||
3. backend HTTP delegation with native-side admin-key ownership
|
||||
4. a simple webview runtime injection path for:
|
||||
- `window.__SHADOWBROKER_DESKTOP__.invokeLocalControl(...)`
|
||||
1. `main.rs` creates the main window programmatically and attaches an
|
||||
`initialization_script` so `window.__SHADOWBROKER_DESKTOP__` exists before
|
||||
page JavaScript runs
|
||||
2. `bridge.rs` routes Tauri IPC through `policy.rs` before any privileged
|
||||
backend dispatch
|
||||
3. `backend_runtime.rs` installs and launches the bundled backend runtime into
|
||||
app-local writable storage for packaged builds
|
||||
4. `companion_server.rs` provides the packaged loopback HTTP origin used by:
|
||||
- the native main window for ordinary same-origin `/api/*`
|
||||
- the optional external browser companion opener
|
||||
5. `tray.rs` owns tray/menu-bar restore/hide/quit behavior
|
||||
6. `http_client.rs` forwards privileged native requests with the native-owned
|
||||
admin key
|
||||
|
||||
## Important note
|
||||
## Environment variables
|
||||
|
||||
This is a scaffold, not a fully integrated desktop app yet. It exists so the next Tauri pass has a
|
||||
clear structure instead of starting from scratch.
|
||||
- `SHADOWBROKER_BACKEND_URL` - Optional backend override. In packaged mode, if unset, the app launches its bundled local backend automatically.
|
||||
- `SHADOWBROKER_ADMIN_KEY` - Optional admin key for privileged backend access
|
||||
- `SHADOWBROKER_FRONTEND_URL` - Explicit frontend origin override for dev/custom setups
|
||||
|
||||
## Shared contract
|
||||
## Development
|
||||
|
||||
The command names this scaffold must track are defined in:
|
||||
```bash
|
||||
# Install Tauri CLI
|
||||
cargo install tauri-cli@^2
|
||||
|
||||
- `F:\Codebase\Oracle\live-risk-dashboard\frontend\src\lib\desktopControlContract.ts`
|
||||
- `F:\Codebase\Oracle\live-risk-dashboard\frontend\src\lib\desktopControlRouting.ts`
|
||||
# Start the dev shell (frontend dev server must already be running on :3000)
|
||||
./dev.sh
|
||||
```
|
||||
|
||||
Platform dependencies:
|
||||
|
||||
- Linux: `libwebkit2gtk-4.1-dev`, `libjavascriptcoregtk-4.1-dev`, `libayatana-appindicator3-dev`, `libxdo-dev`
|
||||
- macOS: Xcode command-line tools
|
||||
- Windows: Visual Studio C++ build tools
|
||||
|
||||
## Production build
|
||||
|
||||
Use whichever entrypoint matches your environment:
|
||||
|
||||
```bash
|
||||
# POSIX shell
|
||||
./build.sh
|
||||
|
||||
# Windows PowerShell
|
||||
./build.ps1
|
||||
|
||||
# Cross-platform npm wrapper from repo root
|
||||
npm --prefix desktop-shell run build:desktop
|
||||
```
|
||||
|
||||
Add `--clean` when you want a fresh export/icon rebuild and old bundle
|
||||
artifacts removed before packaging.
|
||||
|
||||
The release build now does the full packaging pipeline:
|
||||
|
||||
1. Generates branded icons in `src-tauri/icons/`
|
||||
2. Stages a desktop-only frontend export tree that omits Next server-only
|
||||
routes/middleware (`src/app/api`, `src/middleware.ts`)
|
||||
3. Stages a managed backend runtime bundle from `backend/` into
|
||||
`src-tauri/backend-runtime/`
|
||||
4. Builds the frontend export with `NEXT_OUTPUT=export`
|
||||
5. Copies `frontend/out` to `src-tauri/companion-www/`
|
||||
6. Runs `cargo tauri build`
|
||||
7. Writes `SHA256SUMS.txt` and `release-manifest.json` to
|
||||
`src-tauri/target/release/bundle/`
|
||||
|
||||
If `cargo tauri` is not installed, the build now fails immediately with the
|
||||
required install command instead of failing after the frontend export.
|
||||
|
||||
See [RELEASE.md](./RELEASE.md) for the release-oriented checklist.
|
||||
See [RELEASE_INPUTS.md](./RELEASE_INPUTS.md) for the future credentials/secrets
|
||||
that only matter once you want signed/notarized public distribution.
|
||||
|
||||
## Runtime model
|
||||
|
||||
### Native privileged path
|
||||
|
||||
The 27 privileged local-control commands still go through the Rust IPC bridge.
|
||||
The packaged loopback server does **not** replace that boundary.
|
||||
|
||||
### Packaged loopback app server
|
||||
|
||||
In packaged builds, `main.rs` now launches a bundled local backend by default,
|
||||
then starts a loopback HTTP server and points the native window at it. That
|
||||
gives the packaged desktop app ownership of both the app shell and the local
|
||||
backend runtime, while keeping a real same-origin `/api/*` path for ordinary
|
||||
non-privileged fetches.
|
||||
|
||||
The managed backend runtime also seeds and persists its own local secrets on
|
||||
first launch:
|
||||
|
||||
- `ADMIN_KEY`
|
||||
- `MESH_PEER_PUSH_SECRET`
|
||||
- `MESH_DM_TOKEN_PEPPER`
|
||||
- `MESH_SECURE_STORAGE_SECRET` on non-Windows
|
||||
|
||||
It also defaults the managed compatibility-cutoff flags to the hardened desktop
|
||||
posture:
|
||||
|
||||
- `MESH_BLOCK_LEGACY_NODE_ID_COMPAT=true`
|
||||
- `MESH_ALLOW_LEGACY_NODE_ID_COMPAT_UNTIL=` unless an operator sets a dated temporary migration override
|
||||
- `MESH_BLOCK_LEGACY_AGENT_ID_LOOKUP=true`
|
||||
|
||||
That keeps the packaged desktop path out of the "edit `.env` by hand before it
|
||||
is safe" trap for normal local users.
|
||||
|
||||
If a managed desktop operator leaves `MESH_BLOCK_LEGACY_NODE_ID_COMPAT=false`
|
||||
in the managed backend `.env`, bootstrap now normalizes it back to `true`.
|
||||
The only supported escape hatch for legacy 16-hex node IDs is a dated
|
||||
`MESH_ALLOW_LEGACY_NODE_ID_COMPAT_UNTIL=YYYY-MM-DD` override. Source/server
|
||||
deployments remain operator-controlled through their own env files and do not
|
||||
inherit this desktop-specific default.
|
||||
|
||||
### Browser companion
|
||||
|
||||
Browser companion is:
|
||||
|
||||
- optional
|
||||
- disabled by default
|
||||
- loopback-only
|
||||
- reduced-trust
|
||||
|
||||
It does **not** receive the native bridge injection and is **not** equivalent
|
||||
to standalone browser mode. The built-in loopback server is a thin static
|
||||
`/api/*` proxy and does not reproduce Next middleware, admin-session cookie
|
||||
logic, or wormhole routing.
|
||||
|
||||
## Current status
|
||||
|
||||
This is now a **runnable desktop build path** with branded assets and repeatable
|
||||
bundle outputs.
|
||||
|
||||
What works:
|
||||
|
||||
- Native desktop window (dev + packaged)
|
||||
- Packaged bundled local backend launch + ownership
|
||||
- Managed packaged backend auto-seeding of local admin/private-plane secrets
|
||||
- Packaged same-origin `/api/*` path for non-privileged data
|
||||
- Rust-authoritative policy enforcement and audit
|
||||
- Tray/menu-bar background lifecycle
|
||||
- macOS dock reopen restores the main window
|
||||
- Browser companion opener with honest reduced-trust scoping
|
||||
- Branded bundle icon set (`.png`, `.ico`, `.icns`, Windows tile assets)
|
||||
- Release checksums + artifact manifest alongside bundle output
|
||||
- GitHub Actions desktop build matrix for Windows/macOS/Linux
|
||||
- Tag-driven GitHub release asset upload without required secrets
|
||||
|
||||
What is still not done:
|
||||
|
||||
- Windows code signing
|
||||
- macOS notarization credentials
|
||||
- Auto-update publishing
|
||||
- Final installer copy / splash polish
|
||||
- Standalone-browser-equivalent companion parity
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
# Desktop Release Guide
|
||||
|
||||
This directory now has a repeatable desktop release path with branded bundle
|
||||
icons, checksum output, Tauri updater artifacts, and a local updater signing
|
||||
key path, but **not** full Windows/macOS distribution signing/notarization.
|
||||
|
||||
## Entry points
|
||||
|
||||
Use any of these:
|
||||
|
||||
```bash
|
||||
# POSIX shell
|
||||
./build.sh
|
||||
|
||||
# Windows PowerShell
|
||||
./build.ps1
|
||||
|
||||
# Cross-platform npm wrapper
|
||||
npm --prefix desktop-shell run build:desktop
|
||||
```
|
||||
|
||||
Use `--clean` when you want to wipe the previous static export, companion
|
||||
bundle, managed backend bundle, generated icons, and old installer outputs
|
||||
before rebuilding.
|
||||
|
||||
Prerequisites:
|
||||
|
||||
- Rust toolchain
|
||||
- `cargo tauri` available via `cargo install tauri-cli@^2`
|
||||
- Node.js / npm with the frontend dependencies already installed
|
||||
|
||||
## CI / GitHub Actions
|
||||
|
||||
The repo also has a desktop matrix workflow at:
|
||||
|
||||
```text
|
||||
.github/workflows/desktop-release.yml
|
||||
```
|
||||
|
||||
What it does today:
|
||||
|
||||
- builds unsigned desktop artifacts on Windows, macOS, and Linux
|
||||
- uploads bundle artifacts for PRs and branch builds
|
||||
- on `v*.*.*` tags, attaches release assets to the GitHub release
|
||||
- forwards Apple signing/notarization secrets to the macOS build **if** they
|
||||
exist, but does not require them
|
||||
|
||||
See [RELEASE_INPUTS.md](./RELEASE_INPUTS.md) for the plain-language answer to
|
||||
"what would I need later?".
|
||||
|
||||
## What the build does
|
||||
|
||||
1. Generates the desktop icon set in `src-tauri/icons/`
|
||||
2. Stages a desktop-only frontend export tree that omits Next server-only
|
||||
routes/middleware (`src/app/api`, `src/middleware.ts`)
|
||||
3. Stages a managed backend runtime bundle into `src-tauri/backend-runtime/`
|
||||
4. Builds the frontend export with `NEXT_OUTPUT=export`
|
||||
5. Copies `frontend/out` into `src-tauri/companion-www/`
|
||||
6. Runs `cargo tauri build`
|
||||
7. Writes:
|
||||
- `src-tauri/target/release/bundle/SHA256SUMS.txt`
|
||||
- `src-tauri/target/release/bundle/release-manifest.json`
|
||||
- `src-tauri/target/release/bundle/latest.json` when signed updater
|
||||
artifacts are present
|
||||
|
||||
For CI/release builds, the backend release-gate attestation is also staged into
|
||||
the managed backend bundle at `backend-runtime/data/release_attestation.json`,
|
||||
and the managed-backend updater refreshes that file on version sync without
|
||||
overwriting the rest of the runtime `data/` directory.
|
||||
|
||||
## Release artifacts
|
||||
|
||||
Artifacts are emitted under:
|
||||
|
||||
```text
|
||||
desktop-shell/tauri-skeleton/src-tauri/target/release/bundle/
|
||||
```
|
||||
|
||||
Expected bundle types vary by platform:
|
||||
|
||||
- Windows: `.msi`, `.exe`
|
||||
- macOS: `.dmg`, `.app`-related archives
|
||||
- Linux: `.deb`, `.AppImage`
|
||||
|
||||
## What is still manual
|
||||
|
||||
- Windows code signing
|
||||
- macOS notarization/signing credentials
|
||||
- Publishing `latest.json` plus the signed updater installer assets to the
|
||||
GitHub release
|
||||
- Final splash/installer copy polish
|
||||
|
||||
## Tauri updater notes
|
||||
|
||||
The updater public key is baked into `src-tauri/tauri.conf.json`. Keep the
|
||||
private key in `release-secrets/shadowbroker-updater.key` and its local
|
||||
password file in `release-secrets/shadowbroker-updater.key.pass`, or provide
|
||||
the same values through `TAURI_SIGNING_PRIVATE_KEY` and
|
||||
`TAURI_SIGNING_PRIVATE_KEY_PASSWORD` at build time. The local
|
||||
`release-secrets/` folder is gitignored.
|
||||
|
||||
The production updater endpoint is:
|
||||
|
||||
```text
|
||||
https://github.com/BigBodyCobain/Shadowbroker/releases/latest/download/latest.json
|
||||
```
|
||||
|
||||
For GitHub releases, upload `latest.json`, the installer (`.msi` / `.exe`), and
|
||||
the matching `.sig` files generated under `src-tauri/target/release/bundle/`.
|
||||
Tauri updater signing verifies update packages only; it does not remove Windows
|
||||
SmartScreen warnings. Windows public trust still requires a real code-signing
|
||||
certificate later.
|
||||
|
||||
## Trust model reminder
|
||||
|
||||
The packaged build still uses:
|
||||
|
||||
- a bundled local backend runtime that the desktop app owns by default
|
||||
- Rust-authoritative policy enforcement for privileged local control
|
||||
- the packaged loopback app server for same-origin non-privileged `/api/*`
|
||||
- reduced-trust browser companion mode with no native bridge injection
|
||||
@@ -0,0 +1,101 @@
|
||||
# Future Release Inputs
|
||||
|
||||
You can ignore this file until you care about public distribution. The repo can
|
||||
already build unsigned desktop artifacts locally and in GitHub Actions without
|
||||
any secrets.
|
||||
|
||||
## What works now with zero input
|
||||
|
||||
- Windows, macOS, and Linux desktop builds in GitHub Actions
|
||||
- PR/main-branch artifact builds through `.github/workflows/desktop-release.yml`
|
||||
- tag-driven GitHub release asset upload for `v*.*.*`
|
||||
- unsigned installers/bundles plus `release-manifest.json`, `SHA256SUMS.txt`,
|
||||
and Tauri updater metadata when the updater private key is available locally
|
||||
|
||||
## What you only need later
|
||||
|
||||
### Windows public trust
|
||||
|
||||
Unsigned Windows installers still run, but SmartScreen may warn.
|
||||
|
||||
If you later want signed Windows `.msi` / `.exe` bundles, you will eventually
|
||||
need:
|
||||
|
||||
- a code-signing certificate or signing service
|
||||
- the provider-specific credentials/password
|
||||
- a final choice of signing tool/provider
|
||||
|
||||
This repo does **not** auto-sign Windows bundles yet. The workflow keeps
|
||||
Windows unsigned on purpose until you pick a provider.
|
||||
|
||||
### macOS public trust
|
||||
|
||||
Unsigned macOS builds are fine for internal testing, but public distribution
|
||||
usually wants Apple signing/notarization.
|
||||
|
||||
If these GitHub Actions secrets are present, the desktop workflow forwards them
|
||||
to the Tauri build:
|
||||
|
||||
- `APPLE_CERTIFICATE`
|
||||
- `APPLE_CERTIFICATE_PASSWORD`
|
||||
- `APPLE_SIGNING_IDENTITY`
|
||||
- `APPLE_ID`
|
||||
- `APPLE_PASSWORD`
|
||||
- `APPLE_TEAM_ID`
|
||||
|
||||
In plain language, that means you would eventually need:
|
||||
|
||||
- an Apple Developer account
|
||||
- a Developer ID Application certificate export
|
||||
- the certificate password
|
||||
- your Apple team ID
|
||||
- an app-specific password for notarization
|
||||
|
||||
If those secrets are absent, the macOS build still runs. It just stays unsigned
|
||||
and unnotarized.
|
||||
|
||||
### Linux publication
|
||||
|
||||
Linux usually does not require a comparable account just to build artifacts.
|
||||
|
||||
You only need extra inputs later if you want things like:
|
||||
|
||||
- signed apt/rpm repositories
|
||||
- distro-specific repository publication
|
||||
- a permanent download host for direct package links
|
||||
|
||||
### In-app updates
|
||||
|
||||
The desktop app now uses the Tauri updater when it is running as a packaged
|
||||
install. That requires the updater signing key generated for this app, but it
|
||||
does not require your government name.
|
||||
|
||||
For local builds, keep this ignored file safe:
|
||||
|
||||
```text
|
||||
release-secrets/shadowbroker-updater.key
|
||||
release-secrets/shadowbroker-updater.key.pass
|
||||
```
|
||||
|
||||
For CI/release builds, set the same key through these environment variables or
|
||||
GitHub secrets:
|
||||
|
||||
- `TAURI_SIGNING_PRIVATE_KEY`
|
||||
- `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`
|
||||
|
||||
Publishing still means uploading the generated `latest.json`, installer, and
|
||||
matching `.sig` files to the GitHub release.
|
||||
|
||||
Packaged desktop builds now bundle and own a local backend runtime by default,
|
||||
so the desktop installer/update path updates the app shell and that bundled
|
||||
backend together. That still does **not** replace Docker updates or external
|
||||
backend overrides.
|
||||
|
||||
## What to do right now
|
||||
|
||||
If you want test builds and downloadable installers right now, you do not need
|
||||
to buy anything or set any secrets:
|
||||
|
||||
1. open a PR or push to `main` to get CI desktop artifacts
|
||||
2. push a `vX.Y.Z` tag when you want GitHub release assets
|
||||
3. use the uploaded artifacts as unsigned internal/test builds
|
||||
@@ -0,0 +1,151 @@
|
||||
param(
|
||||
[switch]$Clean
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$repoRoot = Resolve-Path (Join-Path $scriptDir "..\..")
|
||||
$frontendDir = Join-Path $repoRoot "frontend"
|
||||
$frontendOut = Join-Path $frontendDir "out"
|
||||
$srcTauriDir = Join-Path $scriptDir "src-tauri"
|
||||
$companionDir = Join-Path $srcTauriDir "companion-www"
|
||||
$backendRuntimeDir = Join-Path $srcTauriDir "backend-runtime"
|
||||
$iconsScript = Join-Path $scriptDir "scripts\generate-icons.cjs"
|
||||
$exportScript = Join-Path $scriptDir "scripts\build-frontend-export.cjs"
|
||||
$backendRuntimeScript = Join-Path $scriptDir "scripts\build-backend-runtime.cjs"
|
||||
$manifestScript = Join-Path $scriptDir "scripts\write-release-manifest.cjs"
|
||||
$localUpdaterKey = Join-Path $repoRoot "release-secrets\shadowbroker-updater.key"
|
||||
$localUpdaterKeyPassword = Join-Path $repoRoot "release-secrets\shadowbroker-updater.key.pass"
|
||||
|
||||
function Invoke-External {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string[]]$Command,
|
||||
[string]$WorkingDirectory = $scriptDir
|
||||
)
|
||||
|
||||
$exe = $Command[0]
|
||||
$args = @()
|
||||
if ($Command.Length -gt 1) {
|
||||
$args = $Command[1..($Command.Length - 1)]
|
||||
}
|
||||
|
||||
Push-Location $WorkingDirectory
|
||||
try {
|
||||
& $exe @args
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Command failed: $($Command -join ' ')"
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($tool in @("cargo", "npm", "node")) {
|
||||
if (-not (Get-Command $tool -ErrorAction SilentlyContinue)) {
|
||||
throw "$tool is required for desktop packaging."
|
||||
}
|
||||
}
|
||||
|
||||
Push-Location $scriptDir
|
||||
try {
|
||||
& cargo tauri -V *> $null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "The Tauri CLI is required for desktop packaging. Install it with: cargo install tauri-cli@^2"
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
if ($Clean) {
|
||||
Write-Host "=== Cleaning previous desktop release artifacts ==="
|
||||
foreach ($path in @(
|
||||
$frontendOut,
|
||||
$companionDir,
|
||||
$backendRuntimeDir,
|
||||
(Join-Path $srcTauriDir "icons"),
|
||||
(Join-Path $srcTauriDir "target\\release\\bundle"),
|
||||
(Join-Path $srcTauriDir "target\\release\\wix"),
|
||||
(Join-Path $srcTauriDir "target\\release\\nsis")
|
||||
)) {
|
||||
if (Test-Path $path) {
|
||||
Remove-Item -LiteralPath $path -Recurse -Force
|
||||
}
|
||||
}
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
Write-Host "=== Generating branded desktop icons ==="
|
||||
Invoke-External -Command @("node", $iconsScript)
|
||||
Write-Host ""
|
||||
|
||||
Write-Host "=== Building frontend static export for desktop packaging ==="
|
||||
Invoke-External -Command @("node", $exportScript)
|
||||
Write-Host ""
|
||||
|
||||
Write-Host "=== Staging managed backend runtime for desktop packaging ==="
|
||||
Invoke-External -Command @("node", $backendRuntimeScript)
|
||||
Write-Host ""
|
||||
|
||||
if (-not (Test-Path $frontendOut)) {
|
||||
throw "frontend/out was not produced by NEXT_OUTPUT=export npm run build"
|
||||
}
|
||||
if (-not (Test-Path $backendRuntimeDir)) {
|
||||
throw "src-tauri/backend-runtime was not produced by build-backend-runtime.cjs"
|
||||
}
|
||||
|
||||
Write-Host "Copying frontend export to companion-www..."
|
||||
if (Test-Path $companionDir) {
|
||||
Remove-Item -LiteralPath $companionDir -Recurse -Force
|
||||
}
|
||||
Copy-Item -LiteralPath $frontendOut -Destination $companionDir -Recurse
|
||||
$fileCount = (Get-ChildItem -LiteralPath $companionDir -Recurse -File | Measure-Object).Count
|
||||
Write-Host " -> $fileCount files"
|
||||
Write-Host ""
|
||||
|
||||
Push-Location $srcTauriDir
|
||||
try {
|
||||
if (-not $env:SHADOWBROKER_BACKEND_URL) {
|
||||
$env:SHADOWBROKER_BACKEND_URL = "http://127.0.0.1:8000"
|
||||
}
|
||||
if (
|
||||
-not $env:TAURI_SIGNING_PRIVATE_KEY -and
|
||||
-not $env:TAURI_SIGNING_PRIVATE_KEY_PATH -and
|
||||
(Test-Path $localUpdaterKey)
|
||||
) {
|
||||
$env:TAURI_SIGNING_PRIVATE_KEY = Get-Content -LiteralPath $localUpdaterKey -Raw
|
||||
if (($null -eq $env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD) -and (Test-Path $localUpdaterKeyPassword)) {
|
||||
$env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD = Get-Content -LiteralPath $localUpdaterKeyPassword -Raw
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "=== ShadowBroker Tauri Build ==="
|
||||
Write-Host "Frontend dist: $frontendOut"
|
||||
Write-Host "Companion www: $companionDir"
|
||||
Write-Host "Backend runtime: $backendRuntimeDir"
|
||||
Write-Host "Backend URL: $env:SHADOWBROKER_BACKEND_URL"
|
||||
if ($env:TAURI_SIGNING_PRIVATE_KEY -or $env:TAURI_SIGNING_PRIVATE_KEY_PATH) {
|
||||
Write-Host "Updater signing: enabled"
|
||||
} else {
|
||||
Write-Host "Updater signing: disabled (set TAURI_SIGNING_PRIVATE_KEY_PATH to emit update signatures)"
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
cargo tauri build
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "cargo tauri build failed."
|
||||
}
|
||||
|
||||
$bundleDir = Join-Path $srcTauriDir "target\release\bundle"
|
||||
if (Test-Path $bundleDir) {
|
||||
Write-Host ""
|
||||
Write-Host "=== Writing release manifest ==="
|
||||
Invoke-External -Command @("node", $manifestScript, $bundleDir)
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env bash
|
||||
# Cross-platform Tauri production build.
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Rust toolchain (rustup.rs)
|
||||
# - Tauri CLI: cargo install tauri-cli@^2
|
||||
# - Node.js 18+ (for frontend build)
|
||||
# - Node.js 18+ (for frontend build and asset/release tooling)
|
||||
#
|
||||
# What this script does:
|
||||
# 1. Generates branded bundle icons in src-tauri/icons/
|
||||
# 2. Builds the frontend as a static export (NEXT_OUTPUT=export)
|
||||
# 3. Copies the export to src-tauri/companion-www for the companion server
|
||||
# 4. Runs cargo tauri build to produce the native bundle
|
||||
# 5. Writes SHA256SUMS.txt, release-manifest.json, and latest.json
|
||||
#
|
||||
# The static export is used for:
|
||||
# - Tauri webview content (frontendDist in tauri.conf.json)
|
||||
# - Companion server static assets (companion-www bundle resource)
|
||||
#
|
||||
# The web deployment (Docker/Vercel) is unaffected - it continues to use
|
||||
# output: 'standalone' via the normal `npm run build` without NEXT_OUTPUT.
|
||||
#
|
||||
# Usage:
|
||||
# ./build.sh
|
||||
# ./build.sh --clean
|
||||
#
|
||||
# Output:
|
||||
# Platform-specific bundle in src-tauri/target/release/bundle/
|
||||
# - Linux: .deb, .AppImage
|
||||
# - macOS: .dmg, .app
|
||||
# - Windows: .msi, .exe
|
||||
#
|
||||
# This is a polished unsigned app build path. Updater signing is configured
|
||||
# when TAURI_SIGNING_PRIVATE_KEY_PATH/TAURI_SIGNING_PRIVATE_KEY is available.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
FRONTEND_DIR="$REPO_ROOT/frontend"
|
||||
FRONTEND_OUT="$FRONTEND_DIR/out"
|
||||
ICON_SCRIPT="$SCRIPT_DIR/scripts/generate-icons.cjs"
|
||||
EXPORT_SCRIPT="$SCRIPT_DIR/scripts/build-frontend-export.cjs"
|
||||
BACKEND_RUNTIME_SCRIPT="$SCRIPT_DIR/scripts/build-backend-runtime.cjs"
|
||||
MANIFEST_SCRIPT="$SCRIPT_DIR/scripts/write-release-manifest.cjs"
|
||||
LOCAL_UPDATER_KEY="$REPO_ROOT/release-secrets/shadowbroker-updater.key"
|
||||
LOCAL_UPDATER_KEY_PASSWORD="$REPO_ROOT/release-secrets/shadowbroker-updater.key.pass"
|
||||
CLEAN=0
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--clean)
|
||||
CLEAN=1
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: unknown argument: $arg"
|
||||
echo "Usage: ./build.sh [--clean]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
ensure_cmd() {
|
||||
if ! command -v "$1" >/dev/null 2>&1; then
|
||||
echo "ERROR: required command not found: $1"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_cmd npm
|
||||
ensure_cmd node
|
||||
ensure_cmd cargo
|
||||
|
||||
if ! cargo tauri -V >/dev/null 2>&1; then
|
||||
echo "ERROR: cargo tauri is required for desktop packaging."
|
||||
echo "Install it with: cargo install tauri-cli@^2"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$CLEAN" -eq 1 ]; then
|
||||
echo "=== Cleaning previous desktop release artifacts ==="
|
||||
rm -rf \
|
||||
"$FRONTEND_OUT" \
|
||||
"$SCRIPT_DIR/src-tauri/companion-www" \
|
||||
"$SCRIPT_DIR/src-tauri/backend-runtime" \
|
||||
"$SCRIPT_DIR/src-tauri/icons" \
|
||||
"$SCRIPT_DIR/src-tauri/target/release/bundle" \
|
||||
"$SCRIPT_DIR/src-tauri/target/release/wix" \
|
||||
"$SCRIPT_DIR/src-tauri/target/release/nsis"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo "=== Generating branded desktop icons ==="
|
||||
node "$ICON_SCRIPT"
|
||||
echo ""
|
||||
|
||||
echo "=== Building frontend static export for desktop packaging ==="
|
||||
echo ""
|
||||
node "$EXPORT_SCRIPT"
|
||||
echo ""
|
||||
|
||||
echo "=== Staging managed backend runtime for desktop packaging ==="
|
||||
node "$BACKEND_RUNTIME_SCRIPT"
|
||||
echo ""
|
||||
|
||||
if [ ! -d "$FRONTEND_OUT" ]; then
|
||||
echo "ERROR: frontend/out/ does not exist after build."
|
||||
echo ""
|
||||
echo "Possible causes:"
|
||||
echo " - Dynamic routes without generateStaticParams"
|
||||
echo " - Build errors in the frontend"
|
||||
echo ""
|
||||
echo "Try running manually:"
|
||||
echo " node desktop-shell/tauri-skeleton/scripts/build-frontend-export.cjs"
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -d "$SCRIPT_DIR/src-tauri/backend-runtime" ]; then
|
||||
echo "ERROR: src-tauri/backend-runtime/ does not exist after staging."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Copying frontend export to companion-www..."
|
||||
rm -rf "$SCRIPT_DIR/src-tauri/companion-www"
|
||||
cp -r "$FRONTEND_OUT" "$SCRIPT_DIR/src-tauri/companion-www"
|
||||
echo " -> $(find "$SCRIPT_DIR/src-tauri/companion-www" -type f | wc -l | tr -d ' ') files"
|
||||
echo ""
|
||||
|
||||
cd "$SCRIPT_DIR/src-tauri"
|
||||
|
||||
export SHADOWBROKER_BACKEND_URL="${SHADOWBROKER_BACKEND_URL:-http://127.0.0.1:8000}"
|
||||
if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ] && [ -z "${TAURI_SIGNING_PRIVATE_KEY_PATH:-}" ] && [ -f "$LOCAL_UPDATER_KEY" ]; then
|
||||
TAURI_SIGNING_PRIVATE_KEY="$(cat "$LOCAL_UPDATER_KEY")"
|
||||
export TAURI_SIGNING_PRIVATE_KEY
|
||||
if [ -z "${TAURI_SIGNING_PRIVATE_KEY_PASSWORD:-}" ] && [ -f "$LOCAL_UPDATER_KEY_PASSWORD" ]; then
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD="$(cat "$LOCAL_UPDATER_KEY_PASSWORD")"
|
||||
export TAURI_SIGNING_PRIVATE_KEY_PASSWORD
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "=== ShadowBroker Tauri Build ==="
|
||||
echo "Frontend dist: $FRONTEND_OUT"
|
||||
echo "Companion www: $SCRIPT_DIR/src-tauri/companion-www"
|
||||
echo "Backend runtime: $SCRIPT_DIR/src-tauri/backend-runtime"
|
||||
echo "Backend URL: $SHADOWBROKER_BACKEND_URL"
|
||||
if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ] || [ -n "${TAURI_SIGNING_PRIVATE_KEY_PATH:-}" ]; then
|
||||
echo "Updater signing: enabled"
|
||||
else
|
||||
echo "Updater signing: disabled (set TAURI_SIGNING_PRIVATE_KEY_PATH to emit update signatures)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
cargo tauri build
|
||||
|
||||
BUNDLE_DIR="$SCRIPT_DIR/src-tauri/target/release/bundle"
|
||||
if [ -d "$BUNDLE_DIR" ]; then
|
||||
echo ""
|
||||
echo "=== Writing release manifest ==="
|
||||
node "$MANIFEST_SCRIPT" "$BUNDLE_DIR"
|
||||
fi
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
# Cross-platform Tauri dev launcher.
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Rust toolchain (rustup.rs)
|
||||
# - Tauri CLI: cargo install tauri-cli@^2
|
||||
# - Node.js 18+ and the frontend dev server running on :3000
|
||||
# - Backend running on :8000 (or set SHADOWBROKER_BACKEND_URL)
|
||||
#
|
||||
# Usage:
|
||||
# ./dev.sh # default backend at http://127.0.0.1:8000
|
||||
# SHADOWBROKER_ADMIN_KEY=secret ./dev.sh # with admin key for privileged commands
|
||||
#
|
||||
# This script starts Tauri in dev mode, which:
|
||||
# 1. Opens a native window pointed at the frontend dev server (http://127.0.0.1:3000)
|
||||
# 2. Injects window.__SHADOWBROKER_DESKTOP__ for native command routing
|
||||
# 3. Proxies privileged commands to the backend with X-Admin-Key header
|
||||
#
|
||||
# Platform notes:
|
||||
# Linux: Requires webkit2gtk-4.1 and libayatana-appindicator3 dev packages.
|
||||
# Debian/Ubuntu: sudo apt install libwebkit2gtk-4.1-dev libayatana-appindicator3-dev
|
||||
# macOS: Xcode command-line tools required.
|
||||
# Windows: Run from Git Bash, WSL, or MSYS2. Visual Studio C++ build tools required.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
if [ ! -d "$SCRIPT_DIR/src-tauri/icons" ]; then
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
node "$SCRIPT_DIR/scripts/generate-icons.cjs"
|
||||
fi
|
||||
fi
|
||||
|
||||
cd "$SCRIPT_DIR/src-tauri"
|
||||
|
||||
export SHADOWBROKER_BACKEND_URL="${SHADOWBROKER_BACKEND_URL:-http://127.0.0.1:8000}"
|
||||
|
||||
echo "=== ShadowBroker Tauri Dev Shell ==="
|
||||
echo "Backend URL: $SHADOWBROKER_BACKEND_URL"
|
||||
echo "Admin key: ${SHADOWBROKER_ADMIN_KEY:+(set)}"
|
||||
echo ""
|
||||
echo "Make sure the frontend dev server is running on http://127.0.0.1:3000"
|
||||
echo "Make sure the backend is running on $SHADOWBROKER_BACKEND_URL"
|
||||
echo ""
|
||||
|
||||
cargo tauri dev
|
||||
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const scriptDir = __dirname;
|
||||
const tauriDir = path.resolve(scriptDir, '..');
|
||||
const repoRoot = path.resolve(tauriDir, '..', '..');
|
||||
const backendDir = path.join(repoRoot, 'backend');
|
||||
const outputDir = path.join(tauriDir, 'src-tauri', 'backend-runtime');
|
||||
const venvMarkerPath = path.join(backendDir, '.venv-dir');
|
||||
const releaseAttestationPath = path.join(backendDir, 'data', 'release_attestation.json');
|
||||
const stagedReleaseAttestationPath = path.join(
|
||||
outputDir,
|
||||
'data',
|
||||
'release_attestation.json',
|
||||
);
|
||||
|
||||
const excludedNames = new Set([
|
||||
'.env',
|
||||
'.pytest_cache',
|
||||
'__pycache__',
|
||||
'backend.egg-info',
|
||||
'build',
|
||||
'data',
|
||||
'tests',
|
||||
]);
|
||||
|
||||
const excludedFiles = new Set([
|
||||
'pytest.ini',
|
||||
]);
|
||||
|
||||
function backendPythonPath() {
|
||||
let venvDir = 'venv';
|
||||
try {
|
||||
const persisted = fs.readFileSync(venvMarkerPath, 'utf8').trim();
|
||||
if (persisted) {
|
||||
venvDir = persisted;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
return path.join(backendDir, venvDir, 'Scripts', 'python.exe');
|
||||
}
|
||||
return path.join(backendDir, venvDir, 'bin', 'python3');
|
||||
}
|
||||
|
||||
function shouldCopy(srcPath) {
|
||||
const relativePath = path.relative(backendDir, srcPath);
|
||||
if (!relativePath) return true;
|
||||
|
||||
const parts = relativePath.split(path.sep);
|
||||
return parts.every((part, index) => {
|
||||
const isLeaf = index === parts.length - 1;
|
||||
if (excludedNames.has(part)) return false;
|
||||
if (isLeaf && excludedFiles.has(part)) return false;
|
||||
if (/^test_.*\.py$/i.test(part)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function ensureRuntimePrereqs() {
|
||||
if (!fs.existsSync(path.join(backendDir, 'main.py'))) {
|
||||
throw new Error(`Missing backend/main.py at ${backendDir}`);
|
||||
}
|
||||
if (!fs.existsSync(backendPythonPath())) {
|
||||
throw new Error(
|
||||
`Missing bundled backend Python runtime at ${backendPythonPath()}. ` +
|
||||
'Create the backend venv before packaging the desktop app.',
|
||||
);
|
||||
}
|
||||
if (!fs.existsSync(path.join(backendDir, 'node_modules', 'ws'))) {
|
||||
throw new Error(
|
||||
`Missing backend/node_modules/ws at ${path.join(backendDir, 'node_modules', 'ws')}. ` +
|
||||
'Install backend Node dependencies before packaging the desktop app.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function stageBackendRuntime() {
|
||||
fs.rmSync(outputDir, { recursive: true, force: true });
|
||||
fs.cpSync(backendDir, outputDir, {
|
||||
recursive: true,
|
||||
filter: shouldCopy,
|
||||
});
|
||||
stageReleaseAttestation();
|
||||
}
|
||||
|
||||
function stageReleaseAttestation() {
|
||||
if (!fs.existsSync(releaseAttestationPath)) {
|
||||
console.warn(`backend-runtime staged without release attestation: ${releaseAttestationPath}`);
|
||||
return;
|
||||
}
|
||||
fs.mkdirSync(path.dirname(stagedReleaseAttestationPath), { recursive: true });
|
||||
fs.copyFileSync(releaseAttestationPath, stagedReleaseAttestationPath);
|
||||
}
|
||||
|
||||
function writeBundleVersion() {
|
||||
const versionPath = path.join(outputDir, '.bundle-version');
|
||||
const pkg = JSON.parse(
|
||||
fs.readFileSync(path.join(repoRoot, 'desktop-shell', 'package.json'), 'utf8'),
|
||||
);
|
||||
fs.writeFileSync(versionPath, `${pkg.version || '0.0.0'}\n`, 'utf8');
|
||||
}
|
||||
|
||||
function fileCount(root) {
|
||||
let count = 0;
|
||||
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
||||
const fullPath = path.join(root, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
count += fileCount(fullPath);
|
||||
} else {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
ensureRuntimePrereqs();
|
||||
stageBackendRuntime();
|
||||
writeBundleVersion();
|
||||
console.log(`backend-runtime staged: ${fileCount(outputDir)} files`);
|
||||
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const scriptDir = __dirname;
|
||||
const tauriDir = path.resolve(scriptDir, '..');
|
||||
const repoRoot = path.resolve(tauriDir, '..', '..');
|
||||
const frontendDir = path.join(repoRoot, 'frontend');
|
||||
const buildRoot = path.join(repoRoot, '.desktop-export-build');
|
||||
const buildFrontendDir = path.join(buildRoot, 'frontend');
|
||||
const buildOutDir = path.join(buildFrontendDir, 'out');
|
||||
const liveOutDir = path.join(frontendDir, 'out');
|
||||
const excludedPaths = [
|
||||
'node_modules',
|
||||
'.next',
|
||||
'out',
|
||||
'src/app/api',
|
||||
'src/middleware.ts',
|
||||
];
|
||||
|
||||
function normalizeRelativePath(target) {
|
||||
return target.split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function shouldCopy(srcPath) {
|
||||
const relativePath = path.relative(frontendDir, srcPath);
|
||||
if (!relativePath) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalized = normalizeRelativePath(relativePath);
|
||||
return !excludedPaths.some(
|
||||
(excluded) => normalized === excluded || normalized.startsWith(`${excluded}/`),
|
||||
);
|
||||
}
|
||||
|
||||
function prepareBuildTree() {
|
||||
fs.rmSync(buildRoot, { recursive: true, force: true });
|
||||
fs.cpSync(frontendDir, buildFrontendDir, {
|
||||
recursive: true,
|
||||
filter: shouldCopy,
|
||||
});
|
||||
|
||||
const liveNodeModules = path.join(frontendDir, 'node_modules');
|
||||
const stagedNodeModules = path.join(buildFrontendDir, 'node_modules');
|
||||
if (!fs.existsSync(liveNodeModules)) {
|
||||
throw new Error(`Missing frontend/node_modules at ${liveNodeModules}`);
|
||||
}
|
||||
fs.symlinkSync(liveNodeModules, stagedNodeModules, 'junction');
|
||||
}
|
||||
|
||||
function runExportBuild() {
|
||||
const env = {
|
||||
...process.env,
|
||||
NEXT_OUTPUT: 'export',
|
||||
};
|
||||
|
||||
const result =
|
||||
process.platform === 'win32'
|
||||
? spawnSync(
|
||||
process.env.ComSpec || 'cmd.exe',
|
||||
['/d', '/s', '/c', 'npm.cmd run build -- --webpack'],
|
||||
{
|
||||
cwd: buildFrontendDir,
|
||||
env,
|
||||
stdio: 'inherit',
|
||||
},
|
||||
)
|
||||
: spawnSync('npm', ['run', 'build', '--', '--webpack'], {
|
||||
cwd: buildFrontendDir,
|
||||
env,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
if (typeof result.status === 'number' && result.status !== 0) {
|
||||
throw new Error(`Frontend export build failed with exit code ${result.status}.`);
|
||||
}
|
||||
}
|
||||
|
||||
function syncBuildOutput() {
|
||||
if (!fs.existsSync(buildOutDir)) {
|
||||
throw new Error(`Desktop export did not produce ${buildOutDir}`);
|
||||
}
|
||||
fs.rmSync(liveOutDir, { recursive: true, force: true });
|
||||
fs.cpSync(buildOutDir, liveOutDir, {
|
||||
recursive: true,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
prepareBuildTree();
|
||||
runExportBuild();
|
||||
syncBuildOutput();
|
||||
} finally {
|
||||
fs.rmSync(buildRoot, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const zlib = require('node:zlib');
|
||||
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const iconsDir = path.join(root, 'src-tauri', 'icons');
|
||||
|
||||
const pngOutputs = {
|
||||
'32x32.png': 32,
|
||||
'128x128.png': 128,
|
||||
'128x128@2x.png': 256,
|
||||
'icon.png': 512,
|
||||
'Square30x30Logo.png': 30,
|
||||
'Square44x44Logo.png': 44,
|
||||
'Square71x71Logo.png': 71,
|
||||
'Square89x89Logo.png': 89,
|
||||
'Square107x107Logo.png': 107,
|
||||
'Square142x142Logo.png': 142,
|
||||
'Square150x150Logo.png': 150,
|
||||
'Square284x284Logo.png': 284,
|
||||
'Square310x310Logo.png': 310,
|
||||
'StoreLogo.png': 50,
|
||||
};
|
||||
|
||||
const internalPngSizes = [16, 32, 64, 128, 256, 512, 1024];
|
||||
|
||||
function clamp(value, lower = 0, upper = 1) {
|
||||
return Math.max(lower, Math.min(upper, value));
|
||||
}
|
||||
|
||||
function smoothstep(edge0, edge1, value) {
|
||||
if (edge0 === edge1) return 0;
|
||||
const t = clamp((value - edge0) / (edge1 - edge0));
|
||||
return t * t * (3 - 2 * t);
|
||||
}
|
||||
|
||||
function blend(dst, srcRgb, srcAlpha) {
|
||||
if (srcAlpha <= 0) return dst;
|
||||
const alpha = clamp(srcAlpha);
|
||||
const inv = 1 - alpha;
|
||||
return [
|
||||
Math.round(dst[0] * inv + srcRgb[0] * alpha),
|
||||
Math.round(dst[1] * inv + srcRgb[1] * alpha),
|
||||
Math.round(dst[2] * inv + srcRgb[2] * alpha),
|
||||
255,
|
||||
];
|
||||
}
|
||||
|
||||
function roundedRectAlpha(nx, ny, half, radius, feather) {
|
||||
const qx = Math.abs(nx) - (half - radius);
|
||||
const qy = Math.abs(ny) - (half - radius);
|
||||
const outside = Math.hypot(Math.max(qx, 0), Math.max(qy, 0));
|
||||
const inside = Math.min(Math.max(qx, qy), 0);
|
||||
const signedDistance = outside + inside - radius;
|
||||
return smoothstep(feather, -feather, signedDistance);
|
||||
}
|
||||
|
||||
function drawIcon(size) {
|
||||
const pixels = Buffer.alloc(size * size * 4);
|
||||
const feather = 2.4 / Math.max(size, 1);
|
||||
for (let y = 0; y < size; y += 1) {
|
||||
for (let x = 0; x < size; x += 1) {
|
||||
const nx = ((x + 0.5) / size) * 2 - 1;
|
||||
const ny = ((y + 0.5) / size) * 2 - 1;
|
||||
|
||||
const bgAlpha = roundedRectAlpha(nx, ny, 0.93, 0.28, feather);
|
||||
if (bgAlpha <= 0) continue;
|
||||
|
||||
const gradientMix = clamp((nx - ny + 2) / 4);
|
||||
const bg = [
|
||||
Math.round(7 + 8 * gradientMix),
|
||||
Math.round(20 + 26 * (1 - gradientMix)),
|
||||
Math.round(28 + 42 * gradientMix),
|
||||
];
|
||||
let rgba = [bg[0], bg[1], bg[2], Math.round(255 * bgAlpha)];
|
||||
const r = Math.hypot(nx, ny);
|
||||
|
||||
const ringDistance = Math.abs(r - 0.53) - 0.085;
|
||||
const ringAlpha = smoothstep(feather * 2.2, -feather * 2.2, ringDistance) * bgAlpha;
|
||||
rgba = blend(rgba, [27, 196, 157], ringAlpha);
|
||||
|
||||
const glowAlpha = smoothstep(0.74, 0.16, r) * 0.18 * bgAlpha;
|
||||
rgba = blend(rgba, [32, 228, 190], glowAlpha);
|
||||
|
||||
const diamondDistance = Math.abs(nx) + Math.abs(ny) - 0.34;
|
||||
const diamondAlpha = smoothstep(feather * 2.6, -feather * 2.6, diamondDistance) * bgAlpha;
|
||||
rgba = blend(rgba, [13, 41, 45], diamondAlpha);
|
||||
|
||||
const barVDistance = Math.max(Math.abs(nx) - 0.055, Math.abs(ny) - 0.44);
|
||||
const barHDistance = Math.max(Math.abs(ny) - 0.055, Math.abs(nx) - 0.44);
|
||||
const barAlpha = Math.max(
|
||||
smoothstep(feather * 2.6, -feather * 2.6, barVDistance),
|
||||
smoothstep(feather * 2.6, -feather * 2.6, barHDistance),
|
||||
) * 0.92 * bgAlpha;
|
||||
rgba = blend(rgba, [183, 251, 239], barAlpha);
|
||||
|
||||
const coreDistance = r - 0.108;
|
||||
const coreAlpha = smoothstep(feather * 2.4, -feather * 2.4, coreDistance) * bgAlpha;
|
||||
rgba = blend(rgba, [244, 255, 253], coreAlpha);
|
||||
|
||||
const index = (y * size + x) * 4;
|
||||
pixels[index] = rgba[0];
|
||||
pixels[index + 1] = rgba[1];
|
||||
pixels[index + 2] = rgba[2];
|
||||
pixels[index + 3] = rgba[3];
|
||||
}
|
||||
}
|
||||
return pixels;
|
||||
}
|
||||
|
||||
function crc32(buffer) {
|
||||
let crc = ~0;
|
||||
for (let i = 0; i < buffer.length; i += 1) {
|
||||
crc ^= buffer[i];
|
||||
for (let j = 0; j < 8; j += 1) {
|
||||
crc = (crc >>> 1) ^ (0xEDB88320 & -(crc & 1));
|
||||
}
|
||||
}
|
||||
return (~crc) >>> 0;
|
||||
}
|
||||
|
||||
function chunk(type, data) {
|
||||
const out = Buffer.alloc(8 + data.length + 4);
|
||||
out.writeUInt32BE(data.length, 0);
|
||||
out.write(type, 4, 4, 'ascii');
|
||||
data.copy(out, 8);
|
||||
out.writeUInt32BE(crc32(Buffer.concat([Buffer.from(type, 'ascii'), data])), out.length - 4);
|
||||
return out;
|
||||
}
|
||||
|
||||
function encodePng(size, rgba) {
|
||||
const stride = size * 4;
|
||||
const rows = [];
|
||||
for (let y = 0; y < size; y += 1) {
|
||||
rows.push(Buffer.from([0]));
|
||||
rows.push(rgba.subarray(y * stride, (y + 1) * stride));
|
||||
}
|
||||
const raw = Buffer.concat(rows);
|
||||
return Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
|
||||
chunk('IHDR', Buffer.from([
|
||||
(size >>> 24) & 0xff,
|
||||
(size >>> 16) & 0xff,
|
||||
(size >>> 8) & 0xff,
|
||||
size & 0xff,
|
||||
(size >>> 24) & 0xff,
|
||||
(size >>> 16) & 0xff,
|
||||
(size >>> 8) & 0xff,
|
||||
size & 0xff,
|
||||
8,
|
||||
6,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
])),
|
||||
chunk('IDAT', zlib.deflateSync(raw, { level: 9 })),
|
||||
chunk('IEND', Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
function writeIco(targetPath, pngData) {
|
||||
const header = Buffer.alloc(6);
|
||||
header.writeUInt16LE(0, 0);
|
||||
header.writeUInt16LE(1, 2);
|
||||
header.writeUInt16LE(1, 4);
|
||||
|
||||
const entry = Buffer.alloc(16);
|
||||
entry.writeUInt8(0, 0);
|
||||
entry.writeUInt8(0, 1);
|
||||
entry.writeUInt8(0, 2);
|
||||
entry.writeUInt8(0, 3);
|
||||
entry.writeUInt16LE(1, 4);
|
||||
entry.writeUInt16LE(32, 6);
|
||||
entry.writeUInt32LE(pngData.length, 8);
|
||||
entry.writeUInt32LE(22, 12);
|
||||
|
||||
fs.writeFileSync(targetPath, Buffer.concat([header, entry, pngData]));
|
||||
}
|
||||
|
||||
function writeIcns(targetPath, pngBySize) {
|
||||
const typeMap = new Map([
|
||||
[16, 'icp4'],
|
||||
[32, 'icp5'],
|
||||
[64, 'icp6'],
|
||||
[128, 'ic07'],
|
||||
[256, 'ic08'],
|
||||
[512, 'ic09'],
|
||||
[1024, 'ic10'],
|
||||
]);
|
||||
|
||||
const blocks = [];
|
||||
for (const [size, type] of typeMap) {
|
||||
const png = pngBySize.get(size);
|
||||
if (!png) continue;
|
||||
const header = Buffer.alloc(8);
|
||||
header.write(type, 0, 4, 'ascii');
|
||||
header.writeUInt32BE(png.length + 8, 4);
|
||||
blocks.push(header, png);
|
||||
}
|
||||
|
||||
const payload = Buffer.concat(blocks);
|
||||
const icnsHeader = Buffer.alloc(8);
|
||||
icnsHeader.write('icns', 0, 4, 'ascii');
|
||||
icnsHeader.writeUInt32BE(payload.length + 8, 4);
|
||||
fs.writeFileSync(targetPath, Buffer.concat([icnsHeader, payload]));
|
||||
}
|
||||
|
||||
fs.mkdirSync(iconsDir, { recursive: true });
|
||||
|
||||
const pngBySize = new Map();
|
||||
for (const size of internalPngSizes) {
|
||||
pngBySize.set(size, encodePng(size, drawIcon(size)));
|
||||
}
|
||||
|
||||
for (const [filename, size] of Object.entries(pngOutputs)) {
|
||||
fs.writeFileSync(path.join(iconsDir, filename), pngBySize.get(size) ?? encodePng(size, drawIcon(size)));
|
||||
}
|
||||
|
||||
writeIco(path.join(iconsDir, 'icon.ico'), pngBySize.get(256));
|
||||
writeIcns(path.join(iconsDir, 'icon.icns'), pngBySize);
|
||||
|
||||
const created = fs.readdirSync(iconsDir).sort();
|
||||
console.log(`Generated ${created.length} desktop icons in ${iconsDir}`);
|
||||
for (const name of created) {
|
||||
console.log(` - ${name}`);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
if (process.argv.length < 3) {
|
||||
throw new Error('Usage: write-release-manifest.cjs <bundle_dir>');
|
||||
}
|
||||
const bundleDir = path.resolve(process.argv[2]);
|
||||
const tauriConfigPath = path.resolve(__dirname, '..', 'src-tauri', 'tauri.conf.json');
|
||||
const updateBaseUrl = (
|
||||
process.env.SHADOWBROKER_UPDATE_BASE_URL ||
|
||||
'https://github.com/BigBodyCobain/Shadowbroker/releases/latest/download'
|
||||
).replace(/\/+$/, '');
|
||||
|
||||
const releaseSuffixes = [
|
||||
'.AppImage',
|
||||
'.app.tar.gz',
|
||||
'.deb',
|
||||
'.dmg',
|
||||
'.exe',
|
||||
'.msi',
|
||||
'.pkg',
|
||||
'.rpm',
|
||||
'.sig',
|
||||
'.tar.gz',
|
||||
'.zip',
|
||||
'latest.json',
|
||||
];
|
||||
|
||||
function collectFiles(dir) {
|
||||
const files = [];
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...collectFiles(fullPath));
|
||||
} else if (entry.isFile()) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function sha256File(filePath) {
|
||||
const hash = crypto.createHash('sha256');
|
||||
hash.update(fs.readFileSync(filePath));
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
function readReleaseVersion() {
|
||||
try {
|
||||
const config = JSON.parse(fs.readFileSync(tauriConfigPath, 'utf8'));
|
||||
return String(config.version || '').trim() || '0.0.0';
|
||||
} catch {
|
||||
return '0.0.0';
|
||||
}
|
||||
}
|
||||
|
||||
function updaterPlatformForArtifact(relativePath) {
|
||||
if (/\.msi$/i.test(relativePath) || /\.exe$/i.test(relativePath)) {
|
||||
return 'windows-x86_64';
|
||||
}
|
||||
if (/\.app\.tar\.gz$/i.test(relativePath) || /\.dmg$/i.test(relativePath)) {
|
||||
return 'darwin-x86_64';
|
||||
}
|
||||
if (/\.AppImage$/i.test(relativePath) || /\.deb$/i.test(relativePath) || /\.rpm$/i.test(relativePath)) {
|
||||
return 'linux-x86_64';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function updaterArtifactPriority(relativePath) {
|
||||
if (/\.msi$/i.test(relativePath)) return 0;
|
||||
if (/setup\.exe$/i.test(relativePath) || /\.exe$/i.test(relativePath)) return 1;
|
||||
if (/\.app\.tar\.gz$/i.test(relativePath)) return 0;
|
||||
if (/\.AppImage$/i.test(relativePath)) return 0;
|
||||
return 10;
|
||||
}
|
||||
|
||||
function writeUpdaterManifest(files) {
|
||||
const signedArtifacts = files
|
||||
.filter((filePath) => filePath.endsWith('.sig'))
|
||||
.map((signaturePath) => {
|
||||
const artifactPath = signaturePath.slice(0, -4);
|
||||
if (!fs.existsSync(artifactPath)) return null;
|
||||
const relativePath = path.relative(bundleDir, artifactPath).replaceAll(path.sep, '/');
|
||||
const platform = updaterPlatformForArtifact(relativePath);
|
||||
if (!platform) return null;
|
||||
return {
|
||||
platform,
|
||||
relativePath,
|
||||
signature: fs.readFileSync(signaturePath, 'utf8').trim(),
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => updaterArtifactPriority(a.relativePath) - updaterArtifactPriority(b.relativePath));
|
||||
|
||||
const platforms = {};
|
||||
for (const artifact of signedArtifacts) {
|
||||
if (platforms[artifact.platform]) continue;
|
||||
platforms[artifact.platform] = {
|
||||
signature: artifact.signature,
|
||||
url: `${updateBaseUrl}/${encodeURIComponent(path.basename(artifact.relativePath))}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (Object.keys(platforms).length === 0) return false;
|
||||
|
||||
const latest = {
|
||||
version: readReleaseVersion(),
|
||||
notes: `ShadowBroker ${readReleaseVersion()}`,
|
||||
pub_date: new Date().toISOString(),
|
||||
platforms,
|
||||
};
|
||||
|
||||
fs.writeFileSync(path.join(bundleDir, 'latest.json'), `${JSON.stringify(latest, null, 2)}\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
fs.mkdirSync(bundleDir, { recursive: true });
|
||||
|
||||
let files = collectFiles(bundleDir);
|
||||
const wroteUpdaterManifest = writeUpdaterManifest(files);
|
||||
if (wroteUpdaterManifest) {
|
||||
files = collectFiles(bundleDir);
|
||||
}
|
||||
const artifacts = files.filter((file) => releaseSuffixes.some((suffix) => file.endsWith(suffix)));
|
||||
const releaseFiles = (artifacts.length > 0 ? artifacts : files).sort();
|
||||
|
||||
const manifest = releaseFiles.map((filePath) => ({
|
||||
path: path.relative(bundleDir, filePath).replaceAll(path.sep, '/'),
|
||||
size_bytes: fs.statSync(filePath).size,
|
||||
sha256: sha256File(filePath),
|
||||
}));
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(bundleDir, 'SHA256SUMS.txt'),
|
||||
manifest.map((item) => `${item.sha256} ${item.path}`).join('\n') + (manifest.length ? '\n' : ''),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(bundleDir, 'release-manifest.json'),
|
||||
`${JSON.stringify({ artifacts: manifest }, null, 2)}\n`,
|
||||
);
|
||||
|
||||
console.log(`Wrote release manifest for ${manifest.length} artifacts in ${bundleDir}`);
|
||||
if (wroteUpdaterManifest) {
|
||||
console.log(`Wrote Tauri updater manifest: ${path.join(bundleDir, 'latest.json')}`);
|
||||
}
|
||||
for (const item of manifest) {
|
||||
console.log(` - ${item.path} (${item.size_bytes} bytes)`);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
# Rust build artifacts
|
||||
target/
|
||||
|
||||
# Companion server static assets (copied by build.sh from frontend/out)
|
||||
companion-www/
|
||||
|
||||
# Managed backend runtime bundle (copied by build scripts from backend/)
|
||||
backend-runtime/
|
||||
@@ -1,13 +1,25 @@
|
||||
[package]
|
||||
name = "shadowbroker-tauri-shell"
|
||||
version = "0.1.0"
|
||||
version = "0.9.7"
|
||||
edition = "2021"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2" }
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
axum = "0.7"
|
||||
base64 = "0.22"
|
||||
bytes = "1"
|
||||
getrandom = "0.2"
|
||||
open = "5"
|
||||
privacy-core = { path = "../../../privacy-core" }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
tokio = { version = "1", features = ["net", "sync", "time"] }
|
||||
tower-http = { version = "0.5", features = ["fs"] }
|
||||
url = "2"
|
||||
urlencoding = "2"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 54 KiB |
@@ -0,0 +1,723 @@
|
||||
use std::fmt::Write as _;
|
||||
use std::fs;
|
||||
use std::net::TcpListener;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const RESOURCE_DIR_NAME: &str = "backend-runtime";
|
||||
const INSTALL_DIR_NAME: &str = "managed-backend";
|
||||
const BUNDLE_VERSION_FILE: &str = ".bundle-version";
|
||||
const PERSISTENT_NAMES: &[&str] = &[".env", "data"];
|
||||
const RELEASE_ATTESTATION_RELATIVE_PATH: &[&str] = &["data", "release_attestation.json"];
|
||||
const GENERATED_SECRET_BYTES: usize = 32;
|
||||
|
||||
struct ManagedBackendSecrets {
|
||||
admin_key: String,
|
||||
}
|
||||
|
||||
struct ManagedSecretSpec {
|
||||
key: &'static str,
|
||||
min_len: usize,
|
||||
}
|
||||
|
||||
struct ManagedBoolDefaultSpec {
|
||||
key: &'static str,
|
||||
default_value: bool,
|
||||
preserve_non_default: bool,
|
||||
}
|
||||
|
||||
pub struct ManagedBackendHandle {
|
||||
child: Option<Child>,
|
||||
base_url: String,
|
||||
admin_key: String,
|
||||
}
|
||||
|
||||
impl ManagedBackendHandle {
|
||||
pub fn base_url(&self) -> &str {
|
||||
&self.base_url
|
||||
}
|
||||
|
||||
pub fn admin_key(&self) -> Option<&str> {
|
||||
if self.admin_key.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(self.admin_key.as_str())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ManagedBackendHandle {
|
||||
fn drop(&mut self) {
|
||||
if let Some(child) = self.child.as_mut() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bundled_backend_root(resource_dir: &Path) -> Option<PathBuf> {
|
||||
let candidate = resource_dir.join(RESOURCE_DIR_NAME);
|
||||
if candidate.join("main.py").exists() {
|
||||
Some(candidate)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn ensure_and_start_managed_backend(
|
||||
bundled_root: PathBuf,
|
||||
app_local_data_dir: PathBuf,
|
||||
desired_admin_key: Option<String>,
|
||||
) -> Result<ManagedBackendHandle, String> {
|
||||
let runtime_root = install_bundled_backend(&bundled_root, &app_local_data_dir)?;
|
||||
let python_bin = resolve_python_bin(&runtime_root)?;
|
||||
let port = reserve_loopback_port()?;
|
||||
let base_url = format!("http://127.0.0.1:{port}");
|
||||
let data_dir = runtime_root.join("data");
|
||||
fs::create_dir_all(&data_dir).map_err(|e| format!("managed_backend_data_dir_failed:{e}"))?;
|
||||
let secrets = ensure_env_file(&runtime_root, desired_admin_key)?;
|
||||
|
||||
let stdout_log = data_dir.join("backend_stdout.log");
|
||||
let stderr_log = data_dir.join("backend_stderr.log");
|
||||
let stdout = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&stdout_log)
|
||||
.map_err(|e| format!("managed_backend_stdout_log_failed:{e}"))?;
|
||||
let stderr = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&stderr_log)
|
||||
.map_err(|e| format!("managed_backend_stderr_log_failed:{e}"))?;
|
||||
|
||||
let mut child = Command::new(&python_bin)
|
||||
.current_dir(&runtime_root)
|
||||
.arg("-m")
|
||||
.arg("uvicorn")
|
||||
.arg("main:app")
|
||||
.arg("--host")
|
||||
.arg("127.0.0.1")
|
||||
.arg("--port")
|
||||
.arg(port.to_string())
|
||||
.arg("--timeout-keep-alive")
|
||||
.arg("120")
|
||||
.env("PYTHONUNBUFFERED", "1")
|
||||
.env("SB_DATA_DIR", data_dir.as_os_str())
|
||||
.stdout(Stdio::from(stdout))
|
||||
.stderr(Stdio::from(stderr))
|
||||
.spawn()
|
||||
.map_err(|e| format!("managed_backend_spawn_failed:{e}"))?;
|
||||
|
||||
wait_for_backend_ready(&base_url, &mut child).await?;
|
||||
|
||||
Ok(ManagedBackendHandle {
|
||||
child: Some(child),
|
||||
base_url,
|
||||
admin_key: secrets.admin_key,
|
||||
})
|
||||
}
|
||||
|
||||
fn install_bundled_backend(
|
||||
bundled_root: &Path,
|
||||
app_local_data_dir: &Path,
|
||||
) -> Result<PathBuf, String> {
|
||||
let install_root = app_local_data_dir.join(INSTALL_DIR_NAME);
|
||||
let bundled_version = read_trimmed_file(&bundled_root.join(BUNDLE_VERSION_FILE))?;
|
||||
let installed_version = read_trimmed_file_optional(&install_root.join(BUNDLE_VERSION_FILE));
|
||||
let should_sync = !install_root.join("main.py").exists()
|
||||
|| installed_version.as_deref() != Some(bundled_version.as_str());
|
||||
|
||||
if should_sync {
|
||||
fs::create_dir_all(&install_root)
|
||||
.map_err(|e| format!("managed_backend_install_dir_failed:{e}"))?;
|
||||
sync_runtime_tree(bundled_root, &install_root)?;
|
||||
fs::write(
|
||||
install_root.join(BUNDLE_VERSION_FILE),
|
||||
format!("{bundled_version}\n"),
|
||||
)
|
||||
.map_err(|e| format!("managed_backend_version_write_failed:{e}"))?;
|
||||
}
|
||||
|
||||
fs::create_dir_all(install_root.join("data"))
|
||||
.map_err(|e| format!("managed_backend_data_preserve_dir_failed:{e}"))?;
|
||||
sync_release_attestation(bundled_root, &install_root)?;
|
||||
Ok(install_root)
|
||||
}
|
||||
|
||||
fn sync_runtime_tree(src: &Path, dst: &Path) -> Result<(), String> {
|
||||
for entry in fs::read_dir(src).map_err(|e| format!("managed_backend_read_dir_failed:{e}"))? {
|
||||
let entry = entry.map_err(|e| format!("managed_backend_dir_entry_failed:{e}"))?;
|
||||
let file_name = entry.file_name();
|
||||
let file_name_str = file_name.to_string_lossy();
|
||||
if PERSISTENT_NAMES.contains(&file_name_str.as_ref()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let src_path = entry.path();
|
||||
let dst_path = dst.join(&file_name);
|
||||
let file_type = entry
|
||||
.file_type()
|
||||
.map_err(|e| format!("managed_backend_file_type_failed:{e}"))?;
|
||||
|
||||
if file_type.is_dir() {
|
||||
fs::create_dir_all(&dst_path)
|
||||
.map_err(|e| format!("managed_backend_mkdir_failed:{e}"))?;
|
||||
sync_runtime_tree(&src_path, &dst_path)?;
|
||||
} else {
|
||||
if let Some(parent) = dst_path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("managed_backend_parent_dir_failed:{e}"))?;
|
||||
}
|
||||
fs::copy(&src_path, &dst_path)
|
||||
.map_err(|e| format!("managed_backend_copy_failed:{e}"))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sync_release_attestation(bundled_root: &Path, install_root: &Path) -> Result<(), String> {
|
||||
let bundled_path = release_attestation_path(bundled_root);
|
||||
let installed_path = release_attestation_path(install_root);
|
||||
if !bundled_path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(parent) = installed_path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("managed_backend_attestation_dir_failed:{e}"))?;
|
||||
}
|
||||
fs::copy(&bundled_path, &installed_path)
|
||||
.map_err(|e| format!("managed_backend_attestation_copy_failed:{e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn release_attestation_path(root: &Path) -> PathBuf {
|
||||
RELEASE_ATTESTATION_RELATIVE_PATH
|
||||
.iter()
|
||||
.fold(root.to_path_buf(), |acc, part| acc.join(part))
|
||||
}
|
||||
|
||||
fn ensure_env_file(
|
||||
runtime_root: &Path,
|
||||
desired_admin_key: Option<String>,
|
||||
) -> Result<ManagedBackendSecrets, String> {
|
||||
let env_path = runtime_root.join(".env");
|
||||
if env_path.exists() {
|
||||
return seed_managed_env(&env_path, desired_admin_key);
|
||||
}
|
||||
let example_path = runtime_root.join(".env.example");
|
||||
if example_path.exists() {
|
||||
fs::copy(&example_path, &env_path)
|
||||
.map_err(|e| format!("managed_backend_env_copy_failed:{e}"))?;
|
||||
} else {
|
||||
fs::write(&env_path, b"").map_err(|e| format!("managed_backend_env_create_failed:{e}"))?;
|
||||
}
|
||||
seed_managed_env(&env_path, desired_admin_key)
|
||||
}
|
||||
|
||||
fn seed_managed_env(
|
||||
env_path: &Path,
|
||||
desired_admin_key: Option<String>,
|
||||
) -> Result<ManagedBackendSecrets, String> {
|
||||
let mut lines: Vec<String> = fs::read_to_string(env_path)
|
||||
.unwrap_or_default()
|
||||
.lines()
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
let mut modified = false;
|
||||
let mut resolved_admin_key = String::new();
|
||||
|
||||
for spec in managed_secret_specs() {
|
||||
let override_value = if spec.key == "ADMIN_KEY" {
|
||||
desired_admin_key.as_deref()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut found = false;
|
||||
|
||||
for line in &mut lines {
|
||||
if let Some(current) = parse_env_value(line, spec.key) {
|
||||
found = true;
|
||||
if let Some(forced) = override_value {
|
||||
if current != forced {
|
||||
*line = format!("{}={}", spec.key, forced);
|
||||
modified = true;
|
||||
}
|
||||
if spec.key == "ADMIN_KEY" {
|
||||
resolved_admin_key = forced.to_string();
|
||||
}
|
||||
} else if is_invalid_secret_value(current, spec.min_len) {
|
||||
let generated = generate_secret()?;
|
||||
*line = format!("{}={}", spec.key, generated);
|
||||
modified = true;
|
||||
if spec.key == "ADMIN_KEY" {
|
||||
resolved_admin_key = generated;
|
||||
}
|
||||
} else if spec.key == "ADMIN_KEY" {
|
||||
resolved_admin_key = current.to_string();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
let value = if let Some(forced) = override_value {
|
||||
forced.to_string()
|
||||
} else {
|
||||
generate_secret()?
|
||||
};
|
||||
if !lines.is_empty() && !lines.last().is_some_and(|line| line.is_empty()) {
|
||||
lines.push(String::new());
|
||||
}
|
||||
lines.push(format!("{}={}", spec.key, value));
|
||||
modified = true;
|
||||
if spec.key == "ADMIN_KEY" {
|
||||
resolved_admin_key = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for spec in managed_bool_default_specs() {
|
||||
let mut found = false;
|
||||
|
||||
for line in &mut lines {
|
||||
if let Some(current) = parse_env_value(line, spec.key) {
|
||||
found = true;
|
||||
match parse_env_boolish(current) {
|
||||
Some(parsed) if spec.preserve_non_default || parsed == spec.default_value => {}
|
||||
_ => {
|
||||
*line = format!("{}={}", spec.key, render_env_bool(spec.default_value));
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
if !lines.is_empty() && !lines.last().is_some_and(|line| line.is_empty()) {
|
||||
lines.push(String::new());
|
||||
}
|
||||
lines.push(format!(
|
||||
"{}={}",
|
||||
spec.key,
|
||||
render_env_bool(spec.default_value)
|
||||
));
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
|
||||
if modified {
|
||||
let mut rendered = lines.join("\n");
|
||||
if !rendered.ends_with('\n') {
|
||||
rendered.push('\n');
|
||||
}
|
||||
fs::write(env_path, rendered)
|
||||
.map_err(|e| format!("managed_backend_env_seed_failed:{e}"))?;
|
||||
}
|
||||
|
||||
Ok(ManagedBackendSecrets {
|
||||
admin_key: resolved_admin_key,
|
||||
})
|
||||
}
|
||||
|
||||
fn managed_secret_specs() -> Vec<ManagedSecretSpec> {
|
||||
let mut specs = vec![
|
||||
ManagedSecretSpec {
|
||||
key: "ADMIN_KEY",
|
||||
min_len: 32,
|
||||
},
|
||||
ManagedSecretSpec {
|
||||
key: "MESH_PEER_PUSH_SECRET",
|
||||
min_len: 16,
|
||||
},
|
||||
ManagedSecretSpec {
|
||||
key: "MESH_DM_TOKEN_PEPPER",
|
||||
min_len: 16,
|
||||
},
|
||||
];
|
||||
|
||||
if !cfg!(target_os = "windows") {
|
||||
specs.push(ManagedSecretSpec {
|
||||
key: "MESH_SECURE_STORAGE_SECRET",
|
||||
min_len: 16,
|
||||
});
|
||||
}
|
||||
|
||||
specs
|
||||
}
|
||||
|
||||
fn managed_bool_default_specs() -> Vec<ManagedBoolDefaultSpec> {
|
||||
vec![
|
||||
ManagedBoolDefaultSpec {
|
||||
key: "MESH_BLOCK_LEGACY_NODE_ID_COMPAT",
|
||||
default_value: true,
|
||||
preserve_non_default: false,
|
||||
},
|
||||
ManagedBoolDefaultSpec {
|
||||
key: "MESH_BLOCK_LEGACY_AGENT_ID_LOOKUP",
|
||||
default_value: true,
|
||||
preserve_non_default: true,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn parse_env_value<'a>(line: &'a str, key: &str) -> Option<&'a str> {
|
||||
let trimmed = line.trim_start();
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
return None;
|
||||
}
|
||||
let normalized = trimmed.strip_prefix("export ").unwrap_or(trimmed);
|
||||
let (line_key, raw_value) = normalized.split_once('=')?;
|
||||
if line_key.trim() != key {
|
||||
return None;
|
||||
}
|
||||
Some(raw_value.trim().trim_matches('"').trim_matches('\'').trim())
|
||||
}
|
||||
|
||||
fn parse_env_boolish(value: &str) -> Option<bool> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"1" | "true" | "yes" | "on" => Some(true),
|
||||
"0" | "false" | "no" | "off" => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_env_bool(value: bool) -> &'static str {
|
||||
if value {
|
||||
"true"
|
||||
} else {
|
||||
"false"
|
||||
}
|
||||
}
|
||||
|
||||
fn is_invalid_secret_value(value: &str, min_len: usize) -> bool {
|
||||
let raw = value.trim();
|
||||
let lowered = raw.to_ascii_lowercase();
|
||||
raw.is_empty() || lowered == "change-me" || lowered == "changeme" || raw.len() < min_len
|
||||
}
|
||||
|
||||
fn generate_secret() -> Result<String, String> {
|
||||
let mut bytes = [0u8; GENERATED_SECRET_BYTES];
|
||||
getrandom::getrandom(&mut bytes)
|
||||
.map_err(|e| format!("managed_backend_secret_rng_failed:{e}"))?;
|
||||
let mut out = String::with_capacity(GENERATED_SECRET_BYTES * 2);
|
||||
for byte in bytes {
|
||||
let _ = write!(&mut out, "{byte:02x}");
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn reserve_loopback_port() -> Result<u16, String> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.map_err(|e| format!("managed_backend_port_bind_failed:{e}"))?;
|
||||
let port = listener
|
||||
.local_addr()
|
||||
.map_err(|e| format!("managed_backend_port_addr_failed:{e}"))?
|
||||
.port();
|
||||
drop(listener);
|
||||
Ok(port)
|
||||
}
|
||||
|
||||
fn resolve_python_bin(runtime_root: &Path) -> Result<PathBuf, String> {
|
||||
let selected_venv = read_trimmed_file_optional(&runtime_root.join(".venv-dir"))
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| "venv".to_string());
|
||||
|
||||
let mut candidate_roots = vec![runtime_root.join(&selected_venv)];
|
||||
if selected_venv != "venv" {
|
||||
candidate_roots.push(runtime_root.join("venv"));
|
||||
}
|
||||
|
||||
let candidates = if cfg!(target_os = "windows") {
|
||||
candidate_roots
|
||||
.into_iter()
|
||||
.map(|root| root.join("Scripts").join("python.exe"))
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
candidate_roots
|
||||
.into_iter()
|
||||
.flat_map(|root| {
|
||||
[
|
||||
root.join("bin").join("python3"),
|
||||
root.join("bin").join("python"),
|
||||
]
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
for candidate in candidates {
|
||||
if candidate.exists() {
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
Err("managed_backend_python_missing".to_string())
|
||||
}
|
||||
|
||||
async fn wait_for_backend_ready(base_url: &str, child: &mut Child) -> Result<(), String> {
|
||||
let client = reqwest::Client::new();
|
||||
let deadline = Instant::now() + Duration::from_secs(45);
|
||||
let health_url = format!("{base_url}/api/health");
|
||||
|
||||
while Instant::now() < deadline {
|
||||
if let Some(status) = child
|
||||
.try_wait()
|
||||
.map_err(|e| format!("managed_backend_wait_failed:{e}"))?
|
||||
{
|
||||
return Err(format!("managed_backend_exited_early:{status}"));
|
||||
}
|
||||
|
||||
if let Ok(response) = client.get(&health_url).send().await {
|
||||
if response.status().is_success() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
Err("managed_backend_health_timeout".to_string())
|
||||
}
|
||||
|
||||
fn read_trimmed_file(path: &Path) -> Result<String, String> {
|
||||
fs::read_to_string(path)
|
||||
.map(|s| s.trim().to_string())
|
||||
.map_err(|e| format!("managed_backend_version_read_failed:{e}"))
|
||||
}
|
||||
|
||||
fn read_trimmed_file_optional(path: &Path) -> Option<String> {
|
||||
fs::read_to_string(path).ok().map(|s| s.trim().to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bundled_backend_root_requires_main_py() {
|
||||
let temp = std::env::temp_dir().join(format!(
|
||||
"sb_backend_root_test_{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
let resource_dir = temp.join("resources");
|
||||
let backend_dir = resource_dir.join(RESOURCE_DIR_NAME);
|
||||
fs::create_dir_all(&backend_dir).unwrap();
|
||||
|
||||
assert!(bundled_backend_root(&resource_dir).is_none());
|
||||
|
||||
fs::write(backend_dir.join("main.py"), "print('ok')").unwrap();
|
||||
assert_eq!(
|
||||
bundled_backend_root(&resource_dir),
|
||||
Some(backend_dir.clone())
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(temp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_runtime_tree_preserves_env_and_data() {
|
||||
let temp = std::env::temp_dir().join(format!(
|
||||
"sb_backend_sync_test_{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
let src = temp.join("src");
|
||||
let dst = temp.join("dst");
|
||||
fs::create_dir_all(src.join("config")).unwrap();
|
||||
fs::create_dir_all(dst.join("data")).unwrap();
|
||||
fs::write(src.join("main.py"), "print('new')").unwrap();
|
||||
fs::write(src.join(".env.example"), "ADMIN_KEY=").unwrap();
|
||||
fs::write(dst.join(".env"), "preserve_me").unwrap();
|
||||
fs::write(dst.join("data").join("keep.txt"), "keep").unwrap();
|
||||
|
||||
sync_runtime_tree(&src, &dst).unwrap();
|
||||
|
||||
assert_eq!(fs::read_to_string(dst.join(".env")).unwrap(), "preserve_me");
|
||||
assert_eq!(
|
||||
fs::read_to_string(dst.join("data").join("keep.txt")).unwrap(),
|
||||
"keep"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(dst.join("main.py")).unwrap(),
|
||||
"print('new')"
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(temp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_release_attestation_updates_only_attestation_file() {
|
||||
let temp = std::env::temp_dir().join(format!(
|
||||
"sb_backend_attestation_sync_test_{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
let src = temp.join("src");
|
||||
let dst = temp.join("dst");
|
||||
fs::create_dir_all(src.join("data")).unwrap();
|
||||
fs::create_dir_all(dst.join("data")).unwrap();
|
||||
fs::write(release_attestation_path(&src), "{\"commit\":\"new\"}\n").unwrap();
|
||||
fs::write(release_attestation_path(&dst), "{\"commit\":\"old\"}\n").unwrap();
|
||||
fs::write(dst.join("data").join("keep.txt"), "keep").unwrap();
|
||||
|
||||
sync_release_attestation(&src, &dst).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(release_attestation_path(&dst)).unwrap(),
|
||||
"{\"commit\":\"new\"}\n"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(dst.join("data").join("keep.txt")).unwrap(),
|
||||
"keep"
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(temp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_env_file_generates_required_managed_secrets() {
|
||||
let temp = std::env::temp_dir().join(format!(
|
||||
"sb_backend_env_seed_test_{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
fs::create_dir_all(&temp).unwrap();
|
||||
fs::write(temp.join(".env.example"), "AIS_API_KEY=\n").unwrap();
|
||||
|
||||
let secrets = ensure_env_file(&temp, None).unwrap();
|
||||
let env_text = fs::read_to_string(temp.join(".env")).unwrap();
|
||||
let env_lines: Vec<&str> = env_text.lines().collect();
|
||||
|
||||
assert!(secrets.admin_key.len() >= 32);
|
||||
assert!(
|
||||
env_lines
|
||||
.iter()
|
||||
.find_map(|line| parse_env_value(line, "ADMIN_KEY"))
|
||||
.unwrap()
|
||||
.len()
|
||||
>= 32
|
||||
);
|
||||
assert!(
|
||||
env_lines
|
||||
.iter()
|
||||
.find_map(|line| parse_env_value(line, "MESH_PEER_PUSH_SECRET"))
|
||||
.unwrap()
|
||||
.len()
|
||||
>= 16
|
||||
);
|
||||
assert!(
|
||||
env_lines
|
||||
.iter()
|
||||
.find_map(|line| parse_env_value(line, "MESH_DM_TOKEN_PEPPER"))
|
||||
.unwrap()
|
||||
.len()
|
||||
>= 16
|
||||
);
|
||||
assert_eq!(
|
||||
env_lines
|
||||
.iter()
|
||||
.find_map(|line| parse_env_value(line, "MESH_BLOCK_LEGACY_NODE_ID_COMPAT"))
|
||||
.unwrap(),
|
||||
"true"
|
||||
);
|
||||
assert_eq!(
|
||||
env_lines
|
||||
.iter()
|
||||
.find_map(|line| parse_env_value(line, "MESH_BLOCK_LEGACY_AGENT_ID_LOOKUP"))
|
||||
.unwrap(),
|
||||
"true"
|
||||
);
|
||||
if cfg!(target_os = "windows") {
|
||||
assert!(env_lines
|
||||
.iter()
|
||||
.find_map(|line| parse_env_value(line, "MESH_SECURE_STORAGE_SECRET"))
|
||||
.is_none());
|
||||
} else {
|
||||
assert!(
|
||||
env_lines
|
||||
.iter()
|
||||
.find_map(|line| parse_env_value(line, "MESH_SECURE_STORAGE_SECRET"))
|
||||
.unwrap()
|
||||
.len()
|
||||
>= 16
|
||||
);
|
||||
}
|
||||
|
||||
let _ = fs::remove_dir_all(temp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_env_file_replaces_invalid_values_and_preserves_valid_ones() {
|
||||
let temp = std::env::temp_dir().join(format!(
|
||||
"sb_backend_env_backfill_test_{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
fs::create_dir_all(&temp).unwrap();
|
||||
fs::write(
|
||||
temp.join(".env"),
|
||||
"ADMIN_KEY=short\nMESH_PEER_PUSH_SECRET=change-me\nMESH_DM_TOKEN_PEPPER=valid-pepper-value-1234\nMESH_BLOCK_LEGACY_NODE_ID_COMPAT=false\nMESH_BLOCK_LEGACY_AGENT_ID_LOOKUP=\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let secrets = ensure_env_file(
|
||||
&temp,
|
||||
Some("desktop-admin-key-0123456789abcdef".to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
let env_text = fs::read_to_string(temp.join(".env")).unwrap();
|
||||
let env_lines: Vec<&str> = env_text.lines().collect();
|
||||
|
||||
assert_eq!(secrets.admin_key, "desktop-admin-key-0123456789abcdef");
|
||||
assert_eq!(
|
||||
env_lines
|
||||
.iter()
|
||||
.find_map(|line| parse_env_value(line, "ADMIN_KEY"))
|
||||
.unwrap(),
|
||||
"desktop-admin-key-0123456789abcdef"
|
||||
);
|
||||
assert_ne!(
|
||||
env_lines
|
||||
.iter()
|
||||
.find_map(|line| parse_env_value(line, "MESH_PEER_PUSH_SECRET"))
|
||||
.unwrap(),
|
||||
"change-me"
|
||||
);
|
||||
assert_eq!(
|
||||
env_lines
|
||||
.iter()
|
||||
.find_map(|line| parse_env_value(line, "MESH_DM_TOKEN_PEPPER"))
|
||||
.unwrap(),
|
||||
"valid-pepper-value-1234"
|
||||
);
|
||||
assert_eq!(
|
||||
env_lines
|
||||
.iter()
|
||||
.find_map(|line| parse_env_value(line, "MESH_BLOCK_LEGACY_NODE_ID_COMPAT"))
|
||||
.unwrap(),
|
||||
"true"
|
||||
);
|
||||
assert_eq!(
|
||||
env_lines
|
||||
.iter()
|
||||
.find_map(|line| parse_env_value(line, "MESH_BLOCK_LEGACY_AGENT_ID_LOOKUP"))
|
||||
.unwrap(),
|
||||
"true"
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(temp);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,73 @@
|
||||
use serde_json::Value;
|
||||
use tauri::State;
|
||||
|
||||
use crate::{handlers::dispatch_control_command, DesktopAppState};
|
||||
use crate::handlers::dispatch_control_command;
|
||||
use crate::policy::{self, PolicyOutcome};
|
||||
use crate::{DesktopAppState, NativeGateCryptoState};
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn invoke_local_control(
|
||||
command: String,
|
||||
payload: Option<Value>,
|
||||
meta: Option<Value>,
|
||||
state: State<'_, DesktopAppState>,
|
||||
gate_crypto_state: State<'_, NativeGateCryptoState>,
|
||||
) -> Result<Value, String> {
|
||||
// Enforce policy on the Rust side — this runs even if webview JS is
|
||||
// bypassed and invoke_local_control is called directly via Tauri IPC.
|
||||
match policy::enforce(&command, &payload, &meta) {
|
||||
PolicyOutcome::Allowed(entry) => {
|
||||
if let Ok(mut ring) = state.audit_ring.lock() {
|
||||
ring.record(entry);
|
||||
}
|
||||
}
|
||||
PolicyOutcome::ProfileWarn(entry) => {
|
||||
// Profile mismatch but not enforced — log warning, allow dispatch
|
||||
eprintln!(
|
||||
"native_control_profile_warn: command={} profile={:?} cap={}",
|
||||
entry.command, entry.session_profile, entry.expected_capability
|
||||
);
|
||||
if let Ok(mut ring) = state.audit_ring.lock() {
|
||||
ring.record(entry);
|
||||
}
|
||||
}
|
||||
PolicyOutcome::Denied(entry, message) => {
|
||||
if let Ok(mut ring) = state.audit_ring.lock() {
|
||||
ring.record(entry);
|
||||
}
|
||||
return Err(message);
|
||||
}
|
||||
}
|
||||
|
||||
dispatch_control_command(
|
||||
&state.backend_base_url,
|
||||
state.admin_key.as_deref(),
|
||||
&command,
|
||||
payload,
|
||||
&gate_crypto_state,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_native_audit_report(
|
||||
limit: Option<usize>,
|
||||
state: State<'_, DesktopAppState>,
|
||||
) -> Result<Value, String> {
|
||||
let ring = state
|
||||
.audit_ring
|
||||
.lock()
|
||||
.map_err(|e| format!("audit_lock_failed:{e}"))?;
|
||||
let report = ring.snapshot(limit.unwrap_or(25));
|
||||
serde_json::to_value(report).map_err(|e| format!("audit_serialize_failed:{e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn clear_native_audit_report(state: State<'_, DesktopAppState>) -> Result<(), String> {
|
||||
let mut ring = state
|
||||
.audit_ring
|
||||
.lock()
|
||||
.map_err(|e| format!("audit_lock_failed:{e}"))?;
|
||||
ring.clear();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
//! Optional localhost/browser companion mode.
|
||||
//!
|
||||
//! When explicitly enabled by the user, allows opening the frontend in the
|
||||
//! system browser on a loopback-only URL. The browser session does **not**
|
||||
//! receive the native desktop control boundary (`window.__SHADOWBROKER_DESKTOP__`)
|
||||
//! and therefore cannot invoke any of the 27 native-control commands. It
|
||||
//! operates at materially reduced trust compared with the native window.
|
||||
//!
|
||||
//! **Important honesty note:**
|
||||
//! The browser companion session in packaged mode does **not** have the same
|
||||
//! capabilities as standalone browser mode (i.e. `npm run dev` + a real
|
||||
//! Next.js server). The built-in loopback server is a thin static + API
|
||||
//! proxy — it does NOT reproduce Next.js middleware, the catch-all `/api/*`
|
||||
//! route's admin session cookie logic, the wormhole routing logic, or the
|
||||
//! sensitive-path `X-Admin-Key` injection. Admin-gated backend endpoints
|
||||
//! (settings, wormhole lifecycle, gate operations, system update) are
|
||||
//! **not reachable** from the browser companion.
|
||||
//!
|
||||
//! **Ownership model (post-P6D-R):**
|
||||
//! In packaged mode the loopback server is started at app launch by
|
||||
//! `main.rs` (not by `companion_enable`) so that the Tauri main window also
|
||||
//! uses it as its HTTP origin. Companion state simply tracks whether the
|
||||
//! browser opener is enabled and what URL to hand out. Server lifecycle is
|
||||
//! owned by the app, not by this module.
|
||||
|
||||
use serde::Serialize;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
use tauri::State;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Warning text
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Warning shown to users when enabling or querying companion mode.
|
||||
///
|
||||
/// Honest about what the browser session cannot do. Does NOT claim parity
|
||||
/// with standalone browser mode, because the built-in loopback server is a
|
||||
/// thin proxy and does not reproduce Next.js middleware or admin session
|
||||
/// handling.
|
||||
pub const COMPANION_WARNING: &str = "\
|
||||
Browser companion mode opens the app in your default browser on localhost. \
|
||||
This is less secure than the native desktop window: browser extensions, \
|
||||
shared cookies, and local processes can interact with the page. The browser \
|
||||
session does NOT receive native desktop control privileges and cannot use \
|
||||
admin-gated APIs (settings, wormhole lifecycle, gate operations, system \
|
||||
update). In packaged builds, only public data endpoints are reachable from \
|
||||
the browser session — it is not equivalent to standalone browser mode. \
|
||||
Use the native window for any sensitive or admin-gated operations.";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Serializable status returned by companion commands.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct CompanionStatus {
|
||||
pub enabled: bool,
|
||||
pub url: Option<String>,
|
||||
pub warning: &'static str,
|
||||
}
|
||||
|
||||
/// Companion mode state. Disabled by default.
|
||||
///
|
||||
/// This module does NOT own the loopback server lifecycle. In packaged mode
|
||||
/// the server is started at app launch (see `main.rs`) and its URL is
|
||||
/// registered here via `set_app_server_url`. Companion mode then uses that
|
||||
/// shared URL when the user enables the browser opener.
|
||||
pub struct CompanionState {
|
||||
enabled: bool,
|
||||
/// Default frontend URL (from `SHADOWBROKER_FRONTEND_URL` or the
|
||||
/// `http://127.0.0.1:3000` fallback used in dev mode).
|
||||
default_frontend_url: String,
|
||||
/// Whether `SHADOWBROKER_FRONTEND_URL` was explicitly set by the user.
|
||||
/// When true the default URL is honored even in packaged builds
|
||||
/// (explicit override beats built-in server).
|
||||
frontend_url_explicit: bool,
|
||||
/// URL of the app-level loopback server, set by `main.rs` at startup
|
||||
/// when packaged assets are available and no explicit URL override is
|
||||
/// active. `None` in dev mode or when no bundled assets were found.
|
||||
app_server_url: Option<String>,
|
||||
/// Path to bundled frontend assets (informational; server lifecycle
|
||||
/// is owned by `main.rs`). Set during setup when the resource
|
||||
/// directory contains `companion-www/index.html`.
|
||||
www_root: Option<PathBuf>,
|
||||
}
|
||||
|
||||
pub type SharedCompanionState = Mutex<CompanionState>;
|
||||
|
||||
/// Create initial companion state. Called from `main()`.
|
||||
pub fn new_companion_state(
|
||||
default_frontend_url: String,
|
||||
frontend_url_explicit: bool,
|
||||
) -> SharedCompanionState {
|
||||
Mutex::new(CompanionState {
|
||||
enabled: false,
|
||||
default_frontend_url,
|
||||
frontend_url_explicit,
|
||||
app_server_url: None,
|
||||
www_root: None,
|
||||
})
|
||||
}
|
||||
|
||||
impl CompanionState {
|
||||
fn status(&self) -> CompanionStatus {
|
||||
CompanionStatus {
|
||||
enabled: self.enabled,
|
||||
url: if self.enabled {
|
||||
Some(self.effective_url())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
warning: COMPANION_WARNING,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the URL the browser should open.
|
||||
///
|
||||
/// Packaged mode with server running (no explicit URL override): use the
|
||||
/// app-level loopback server URL. Otherwise fall back to the configured
|
||||
/// default frontend URL (dev mode or explicit override).
|
||||
fn effective_url(&self) -> String {
|
||||
if !self.frontend_url_explicit {
|
||||
if let Some(url) = self.app_server_url.as_deref() {
|
||||
return url.to_string();
|
||||
}
|
||||
}
|
||||
self.default_frontend_url.clone()
|
||||
}
|
||||
|
||||
/// Whether this companion state will route through the built-in
|
||||
/// loopback server (packaged mode without explicit override).
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub fn uses_builtin_server(&self) -> bool {
|
||||
!self.frontend_url_explicit && self.app_server_url.is_some()
|
||||
}
|
||||
|
||||
/// Set the URL of the app-level loopback server. Called from `main.rs`
|
||||
/// setup once the server has successfully bound.
|
||||
pub fn set_app_server_url(&mut self, url: String) {
|
||||
self.app_server_url = Some(url);
|
||||
}
|
||||
|
||||
/// Record the bundled frontend asset path (packaged build indicator).
|
||||
pub fn set_www_root(&mut self, path: PathBuf) {
|
||||
self.www_root = Some(path);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Loopback validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Check whether a URL string points to a loopback address.
|
||||
/// Only `127.0.0.1`, `localhost`, and `::1` (including bracketed `[::1]`)
|
||||
/// are considered loopback. `0.0.0.0`, LAN IPs, and public hosts are rejected.
|
||||
pub fn is_loopback_origin(url: &str) -> bool {
|
||||
let after_scheme = match url.split_once("://") {
|
||||
Some((_, rest)) => rest,
|
||||
None => return false,
|
||||
};
|
||||
let host_port = after_scheme.split('/').next().unwrap_or("");
|
||||
let host = if host_port.starts_with('[') {
|
||||
// IPv6: [::1]:port
|
||||
host_port
|
||||
.split(']')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim_start_matches('[')
|
||||
} else {
|
||||
host_port.split(':').next().unwrap_or("")
|
||||
};
|
||||
matches!(host, "127.0.0.1" | "localhost" | "::1")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tauri commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Query companion mode status.
|
||||
#[tauri::command]
|
||||
pub fn companion_status(state: State<'_, SharedCompanionState>) -> Result<CompanionStatus, String> {
|
||||
let cs = state.lock().map_err(|e| format!("companion_lock:{e}"))?;
|
||||
Ok(cs.status())
|
||||
}
|
||||
|
||||
/// Enable companion mode.
|
||||
///
|
||||
/// In packaged mode, uses the already-running app loopback server URL.
|
||||
/// In dev mode / explicit override, uses the configured frontend URL.
|
||||
/// Either way, validates the URL is loopback-only before enabling.
|
||||
#[tauri::command]
|
||||
pub fn companion_enable(state: State<'_, SharedCompanionState>) -> Result<CompanionStatus, String> {
|
||||
let mut cs = state.lock().map_err(|e| format!("companion_lock:{e}"))?;
|
||||
|
||||
if cs.enabled {
|
||||
return Ok(cs.status());
|
||||
}
|
||||
|
||||
let url = cs.effective_url();
|
||||
if !is_loopback_origin(&url) {
|
||||
return Err(format!(
|
||||
"companion_not_loopback: frontend origin '{url}' is not a loopback address"
|
||||
));
|
||||
}
|
||||
|
||||
cs.enabled = true;
|
||||
Ok(cs.status())
|
||||
}
|
||||
|
||||
/// Disable companion mode. Does not affect the app-level loopback server
|
||||
/// (which remains running for the native main window).
|
||||
#[tauri::command]
|
||||
pub fn companion_disable(
|
||||
state: State<'_, SharedCompanionState>,
|
||||
) -> Result<CompanionStatus, String> {
|
||||
let mut cs = state.lock().map_err(|e| format!("companion_lock:{e}"))?;
|
||||
cs.enabled = false;
|
||||
Ok(cs.status())
|
||||
}
|
||||
|
||||
/// Open the frontend in the system browser. Only works when companion mode
|
||||
/// is enabled and the URL is loopback-only.
|
||||
#[tauri::command]
|
||||
pub fn companion_open_browser(
|
||||
state: State<'_, SharedCompanionState>,
|
||||
) -> Result<CompanionStatus, String> {
|
||||
let cs = state.lock().map_err(|e| format!("companion_lock:{e}"))?;
|
||||
if !cs.enabled {
|
||||
return Err(
|
||||
"companion_not_enabled: enable companion mode before opening in browser".to_string(),
|
||||
);
|
||||
}
|
||||
let url = cs.effective_url();
|
||||
// Defense in depth: re-verify loopback before launching the browser.
|
||||
if !is_loopback_origin(&url) {
|
||||
return Err(format!(
|
||||
"companion_not_loopback: refusing to open non-loopback origin '{url}'"
|
||||
));
|
||||
}
|
||||
let status = cs.status();
|
||||
drop(cs); // release lock before launching browser
|
||||
|
||||
open::that(&url).map_err(|e| format!("companion_open_failed:{e}"))?;
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// -- Loopback validation --
|
||||
|
||||
#[test]
|
||||
fn loopback_127_0_0_1() {
|
||||
assert!(is_loopback_origin("http://127.0.0.1:3000"));
|
||||
assert!(is_loopback_origin("http://127.0.0.1"));
|
||||
assert!(is_loopback_origin("https://127.0.0.1:8443/path"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loopback_localhost() {
|
||||
assert!(is_loopback_origin("http://localhost:3000"));
|
||||
assert!(is_loopback_origin("http://localhost"));
|
||||
assert!(is_loopback_origin("https://localhost:8443/path"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loopback_ipv6() {
|
||||
assert!(is_loopback_origin("http://[::1]:3000"));
|
||||
assert!(is_loopback_origin("http://[::1]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_loopback() {
|
||||
assert!(!is_loopback_origin("http://0.0.0.0:3000"));
|
||||
assert!(!is_loopback_origin("http://192.168.1.1:3000"));
|
||||
assert!(!is_loopback_origin("http://example.com"));
|
||||
assert!(!is_loopback_origin("https://10.0.0.1:8443"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_and_malformed() {
|
||||
assert!(!is_loopback_origin(""));
|
||||
assert!(!is_loopback_origin("not-a-url"));
|
||||
assert!(!is_loopback_origin("://127.0.0.1"));
|
||||
}
|
||||
|
||||
// -- Companion state --
|
||||
|
||||
#[test]
|
||||
fn disabled_by_default() {
|
||||
let state = new_companion_state("http://127.0.0.1:3000".to_string(), false);
|
||||
let cs = state.lock().unwrap();
|
||||
let status = cs.status();
|
||||
assert!(!status.enabled);
|
||||
assert!(status.url.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_includes_honest_warning() {
|
||||
let state = new_companion_state("http://127.0.0.1:3000".to_string(), false);
|
||||
let cs = state.lock().unwrap();
|
||||
let warning = cs.status().warning;
|
||||
assert!(!warning.is_empty());
|
||||
assert!(
|
||||
warning.contains("less secure"),
|
||||
"warning should mention reduced trust"
|
||||
);
|
||||
assert!(
|
||||
warning.contains("native desktop window"),
|
||||
"warning should reference the native window"
|
||||
);
|
||||
assert!(
|
||||
warning.contains("admin-gated"),
|
||||
"warning must name the specific capabilities browser companion lacks"
|
||||
);
|
||||
assert!(
|
||||
warning.contains("not equivalent to standalone browser mode"),
|
||||
"warning must NOT imply standalone browser parity"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_hidden_when_disabled() {
|
||||
let state = new_companion_state("http://127.0.0.1:3000".to_string(), false);
|
||||
let cs = state.lock().unwrap();
|
||||
assert!(cs.status().url.is_none(), "URL must not leak when disabled");
|
||||
}
|
||||
|
||||
// -- Mode detection: effective URL resolution --
|
||||
|
||||
#[test]
|
||||
fn dev_mode_uses_default_url() {
|
||||
let state = new_companion_state("http://127.0.0.1:3000".to_string(), false);
|
||||
let cs = state.lock().unwrap();
|
||||
assert_eq!(cs.effective_url(), "http://127.0.0.1:3000");
|
||||
assert!(!cs.uses_builtin_server());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packaged_mode_prefers_app_server_url() {
|
||||
let state = new_companion_state("http://127.0.0.1:3000".to_string(), false);
|
||||
{
|
||||
let mut cs = state.lock().unwrap();
|
||||
cs.set_app_server_url("http://127.0.0.1:54321".to_string());
|
||||
}
|
||||
let cs = state.lock().unwrap();
|
||||
assert_eq!(cs.effective_url(), "http://127.0.0.1:54321");
|
||||
assert!(cs.uses_builtin_server());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_override_beats_app_server() {
|
||||
let state = new_companion_state("http://127.0.0.1:4000".to_string(), true);
|
||||
{
|
||||
let mut cs = state.lock().unwrap();
|
||||
cs.set_app_server_url("http://127.0.0.1:54321".to_string());
|
||||
}
|
||||
let cs = state.lock().unwrap();
|
||||
assert_eq!(
|
||||
cs.effective_url(),
|
||||
"http://127.0.0.1:4000",
|
||||
"explicit SHADOWBROKER_FRONTEND_URL must beat the built-in server URL"
|
||||
);
|
||||
assert!(!cs.uses_builtin_server());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enable_returns_url_reflecting_mode() {
|
||||
let state = new_companion_state("http://127.0.0.1:3000".to_string(), false);
|
||||
{
|
||||
let mut cs = state.lock().unwrap();
|
||||
cs.set_app_server_url("http://127.0.0.1:54321".to_string());
|
||||
cs.enabled = true;
|
||||
}
|
||||
let cs = state.lock().unwrap();
|
||||
assert_eq!(
|
||||
cs.status().url,
|
||||
Some("http://127.0.0.1:54321".to_string()),
|
||||
"enabled status URL should reflect the app server URL in packaged mode"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_www_root_records_path() {
|
||||
let state = new_companion_state("http://127.0.0.1:3000".to_string(), false);
|
||||
let mut cs = state.lock().unwrap();
|
||||
cs.set_www_root(PathBuf::from("/tmp/companion-www"));
|
||||
assert!(cs.www_root.is_some());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
//! Loopback-only HTTP server for packaged desktop builds.
|
||||
//!
|
||||
//! Serves the bundled frontend static assets on `127.0.0.1` (dynamic port)
|
||||
//! and proxies `/api/*` requests to the backend. The proxy does **not** inject
|
||||
//! `X-Admin-Key` and does **not** reproduce the Next.js catch-all route's
|
||||
//! admin session cookie logic, wormhole routing, or sensitive-path handling.
|
||||
//! It is intentionally a thin loopback shim, not a Next.js replacement.
|
||||
//!
|
||||
//! **Dual role (post-P6D-R):**
|
||||
//! 1. Origin of the packaged Tauri main window — same-origin `/api/*` gives
|
||||
//! the main window a working HTTP path for ordinary non-privileged data
|
||||
//! fetches. Privileged (27-command) paths still go through the Rust IPC
|
||||
//! control boundary with its own admin key ownership and policy
|
||||
//! enforcement — they do NOT traverse this server.
|
||||
//! 2. Origin for the optional browser companion opener. Browser sessions
|
||||
//! have materially reduced trust compared to standalone browser mode:
|
||||
//! no admin session cookies, no admin-gated backend endpoints, no
|
||||
//! Next.js middleware. Only public data endpoints are reachable.
|
||||
//!
|
||||
//! **Not used in dev mode** — when `SHADOWBROKER_FRONTEND_URL` is explicitly
|
||||
//! set, or when no bundled frontend assets exist, this server is not started.
|
||||
//! In those cases the main window and companion both fall back to the
|
||||
//! configured external URL (a running Next.js dev server).
|
||||
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::{HeaderMap, Method, StatusCode, Uri},
|
||||
response::IntoResponse,
|
||||
routing::any,
|
||||
Router,
|
||||
};
|
||||
use bytes::Bytes;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpListener;
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct ServerState {
|
||||
backend_url: String,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Header stripping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Headers stripped from proxied requests (hop-by-hop + security-sensitive).
|
||||
/// `x-admin-key` is stripped intentionally — browser companion is reduced trust.
|
||||
const STRIP_REQ: &[&str] = &[
|
||||
"host",
|
||||
"connection",
|
||||
"transfer-encoding",
|
||||
"x-admin-key",
|
||||
"keep-alive",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailers",
|
||||
"upgrade",
|
||||
];
|
||||
|
||||
/// Headers stripped from proxied responses.
|
||||
const STRIP_RESP: &[&str] = &[
|
||||
"connection",
|
||||
"transfer-encoding",
|
||||
"content-encoding",
|
||||
"content-length",
|
||||
"keep-alive",
|
||||
"te",
|
||||
"trailers",
|
||||
"upgrade",
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API proxy handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Proxy `/api/*` to the backend without `X-Admin-Key` (reduced trust).
|
||||
///
|
||||
/// Forwards the request method, safe headers, and body to the backend.
|
||||
/// The response is returned verbatim (minus hop-by-hop headers).
|
||||
async fn api_proxy(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
method: Method,
|
||||
uri: Uri,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> impl IntoResponse {
|
||||
let path_and_query = uri.path_and_query().map(|pq| pq.as_str()).unwrap_or("/");
|
||||
let target = format!("{}{}", state.backend_url, path_and_query);
|
||||
|
||||
let req_method =
|
||||
reqwest::Method::from_bytes(method.as_str().as_bytes()).unwrap_or(reqwest::Method::GET);
|
||||
|
||||
let mut builder = state.client.request(req_method.clone(), &target);
|
||||
|
||||
// Forward headers, stripping hop-by-hop and security-sensitive ones.
|
||||
for (key, value) in &headers {
|
||||
let name = key.as_str().to_lowercase();
|
||||
if !STRIP_REQ.contains(&name.as_str()) {
|
||||
if let Ok(val) = value.to_str() {
|
||||
builder = builder.header(key.as_str(), val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Forward body for non-GET/HEAD methods.
|
||||
let is_bodyless = req_method == reqwest::Method::GET || req_method == reqwest::Method::HEAD;
|
||||
if !is_bodyless && !body.is_empty() {
|
||||
builder = builder.body(body);
|
||||
}
|
||||
|
||||
match builder.send().await {
|
||||
Ok(resp) => {
|
||||
let status = StatusCode::from_u16(resp.status().as_u16())
|
||||
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
let upstream_headers = resp.headers().clone();
|
||||
let resp_bytes = resp.bytes().await.unwrap_or_default();
|
||||
|
||||
let mut response = axum::response::Response::builder().status(status);
|
||||
for (key, value) in upstream_headers.iter() {
|
||||
let name = key.as_str().to_lowercase();
|
||||
if !STRIP_RESP.contains(&name.as_str()) {
|
||||
response = response.header(key, value);
|
||||
}
|
||||
}
|
||||
match response.body(axum::body::Body::from(resp_bytes)) {
|
||||
Ok(r) => r.into_response(),
|
||||
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||
}
|
||||
}
|
||||
Err(_) => (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
[("content-type", "application/json")],
|
||||
"{\"error\":\"Backend unavailable\"}",
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server handle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Handle to a running companion server.
|
||||
///
|
||||
/// Dropping the handle gracefully shuts down the server.
|
||||
pub struct CompanionServerHandle {
|
||||
addr: SocketAddr,
|
||||
shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
|
||||
}
|
||||
|
||||
impl CompanionServerHandle {
|
||||
/// The loopback URL browsers should open.
|
||||
pub fn url(&self) -> String {
|
||||
format!("http://127.0.0.1:{}", self.addr.port())
|
||||
}
|
||||
|
||||
/// Gracefully stop the server.
|
||||
pub fn shutdown(&mut self) {
|
||||
if let Some(tx) = self.shutdown_tx.take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CompanionServerHandle {
|
||||
fn drop(&mut self) {
|
||||
self.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server startup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Start the companion loopback server.
|
||||
///
|
||||
/// Binds to `127.0.0.1:0` (OS-assigned port), serves static frontend files
|
||||
/// from `www_root`, and proxies `/api/*` to `backend_url` without admin key.
|
||||
///
|
||||
/// Static file serving uses an index.html SPA fallback: requests that don't
|
||||
/// match a static file are served the root `index.html`, letting Next.js
|
||||
/// client-side routing handle the path.
|
||||
pub async fn start_companion_server(
|
||||
www_root: PathBuf,
|
||||
backend_url: String,
|
||||
) -> Result<CompanionServerHandle, String> {
|
||||
let state = Arc::new(ServerState {
|
||||
backend_url,
|
||||
client: reqwest::Client::new(),
|
||||
});
|
||||
|
||||
// Static file serving with SPA fallback to index.html.
|
||||
let index_fallback = www_root.join("index.html");
|
||||
let serve = ServeDir::new(&www_root)
|
||||
.append_index_html_on_directories(true)
|
||||
.not_found_service(ServeFile::new(index_fallback));
|
||||
|
||||
let app = Router::new()
|
||||
.route("/api/*rest", any(api_proxy))
|
||||
.with_state(state)
|
||||
.fallback_service(serve);
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.map_err(|e| format!("companion_bind_failed:{e}"))?;
|
||||
let addr = listener
|
||||
.local_addr()
|
||||
.map_err(|e| format!("companion_addr_failed:{e}"))?;
|
||||
|
||||
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
|
||||
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(async {
|
||||
let _ = shutdown_rx.await;
|
||||
})
|
||||
.await
|
||||
.ok();
|
||||
});
|
||||
|
||||
Ok(CompanionServerHandle {
|
||||
addr,
|
||||
shutdown_tx: Some(shutdown_tx),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn server_handle_url_format() {
|
||||
let handle = CompanionServerHandle {
|
||||
addr: "127.0.0.1:12345".parse().unwrap(),
|
||||
shutdown_tx: None,
|
||||
};
|
||||
assert_eq!(handle.url(), "http://127.0.0.1:12345");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_lists_include_admin_key() {
|
||||
assert!(
|
||||
STRIP_REQ.contains(&"x-admin-key"),
|
||||
"proxy must strip X-Admin-Key from requests (reduced trust)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_lists_include_hop_by_hop() {
|
||||
for header in &["connection", "transfer-encoding", "keep-alive"] {
|
||||
assert!(
|
||||
STRIP_REQ.contains(header),
|
||||
"should strip {header} from requests"
|
||||
);
|
||||
assert!(
|
||||
STRIP_RESP.contains(header),
|
||||
"should strip {header} from responses"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn binds_to_loopback() {
|
||||
let tmp = std::env::temp_dir().join("sb_companion_server_test");
|
||||
let _ = std::fs::create_dir_all(&tmp);
|
||||
std::fs::write(tmp.join("index.html"), "<html></html>").unwrap();
|
||||
|
||||
let handle = start_companion_server(tmp.clone(), "http://127.0.0.1:9999".to_string())
|
||||
.await
|
||||
.expect("server should start");
|
||||
|
||||
assert!(handle.addr.ip().is_loopback(), "must bind to loopback");
|
||||
assert_ne!(handle.addr.port(), 0, "port should be assigned");
|
||||
assert!(handle.url().starts_with("http://127.0.0.1:"));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shutdown_is_idempotent() {
|
||||
let tmp = std::env::temp_dir().join("sb_companion_shutdown_test");
|
||||
let _ = std::fs::create_dir_all(&tmp);
|
||||
std::fs::write(tmp.join("index.html"), "<html></html>").unwrap();
|
||||
|
||||
let mut handle = start_companion_server(tmp.clone(), "http://127.0.0.1:9999".to_string())
|
||||
.await
|
||||
.expect("server should start");
|
||||
|
||||
// First shutdown
|
||||
handle.shutdown();
|
||||
// Second shutdown is safe (idempotent)
|
||||
handle.shutdown();
|
||||
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
}
|
||||
@@ -1,57 +1,418 @@
|
||||
use reqwest::Method;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::gate_crypto;
|
||||
use crate::http_client::call_backend_json;
|
||||
use crate::NativeGateCryptoState;
|
||||
|
||||
fn extract_gate_id(payload: &Option<Value>) -> Result<String, String> {
|
||||
payload
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("gate_id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| urlencoding::encode(s).into_owned())
|
||||
.ok_or_else(|| "missing_or_empty_gate_id".to_string())
|
||||
}
|
||||
|
||||
fn payload_gate_id(payload: &Option<Value>) -> Option<String> {
|
||||
payload
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("gate_id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(ToString::to_string)
|
||||
}
|
||||
|
||||
fn command_expects_gate_authority_change(command: &str) -> bool {
|
||||
matches!(
|
||||
command,
|
||||
"wormhole.gate.enter"
|
||||
| "wormhole.gate.leave"
|
||||
| "wormhole.gate.persona.create"
|
||||
| "wormhole.gate.persona.activate"
|
||||
| "wormhole.gate.persona.clear"
|
||||
| "wormhole.gate.key.rotate"
|
||||
)
|
||||
}
|
||||
|
||||
fn command_requires_gate_state_snapshot(command: &str) -> bool {
|
||||
matches!(
|
||||
command,
|
||||
"wormhole.gate.enter"
|
||||
| "wormhole.gate.persona.create"
|
||||
| "wormhole.gate.persona.activate"
|
||||
| "wormhole.gate.persona.clear"
|
||||
| "wormhole.gate.key.rotate"
|
||||
)
|
||||
}
|
||||
|
||||
fn payload_prefers_backend_gate_decrypt(command: &str, payload: &Option<Value>) -> bool {
|
||||
let Some(value) = payload.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
match command {
|
||||
"wormhole.gate.message.decrypt" => {
|
||||
let format = value
|
||||
.get("format")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("mls1")
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
let recovery_requested = value
|
||||
.get("recovery_envelope")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
recovery_requested || format != "mls1"
|
||||
}
|
||||
"wormhole.gate.messages.decrypt" => value
|
||||
.get("messages")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|messages| {
|
||||
messages.iter().any(|message| {
|
||||
let format = message
|
||||
.get("format")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("mls1")
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
let recovery_requested = message
|
||||
.get("recovery_envelope")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
recovery_requested || format != "mls1"
|
||||
})
|
||||
})
|
||||
.unwrap_or(false),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn dispatch_control_command(
|
||||
backend_base_url: &str,
|
||||
admin_key: Option<&str>,
|
||||
command: &str,
|
||||
payload: Option<Value>,
|
||||
gate_crypto_state: &NativeGateCryptoState,
|
||||
) -> Result<Value, String> {
|
||||
match command {
|
||||
let expected_gate_change = if command_expects_gate_authority_change(command) {
|
||||
payload_gate_id(&payload)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(gate_id) = expected_gate_change.as_deref() {
|
||||
gate_crypto::mark_expected_gate_change(&gate_crypto_state.0, gate_id)?;
|
||||
}
|
||||
|
||||
let result = match command {
|
||||
// --- Wormhole lifecycle ---
|
||||
"wormhole.status" => {
|
||||
call_backend_json(backend_base_url, admin_key, "/api/wormhole/status", Method::GET, None).await
|
||||
call_backend_json(
|
||||
backend_base_url,
|
||||
admin_key,
|
||||
"/api/wormhole/status",
|
||||
Method::GET,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
"wormhole.connect" => {
|
||||
call_backend_json(backend_base_url, admin_key, "/api/wormhole/connect", Method::POST, None).await
|
||||
call_backend_json(
|
||||
backend_base_url,
|
||||
admin_key,
|
||||
"/api/wormhole/connect",
|
||||
Method::POST,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
"wormhole.disconnect" => {
|
||||
call_backend_json(backend_base_url, admin_key, "/api/wormhole/disconnect", Method::POST, None).await
|
||||
call_backend_json(
|
||||
backend_base_url,
|
||||
admin_key,
|
||||
"/api/wormhole/disconnect",
|
||||
Method::POST,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
"wormhole.restart" => {
|
||||
call_backend_json(backend_base_url, admin_key, "/api/wormhole/restart", Method::POST, None).await
|
||||
call_backend_json(
|
||||
backend_base_url,
|
||||
admin_key,
|
||||
"/api/wormhole/restart",
|
||||
Method::POST,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// --- Gate access ---
|
||||
"wormhole.gate.enter" => {
|
||||
call_backend_json(
|
||||
backend_base_url,
|
||||
admin_key,
|
||||
"/api/wormhole/gate/enter",
|
||||
Method::POST,
|
||||
payload,
|
||||
)
|
||||
.await
|
||||
}
|
||||
"wormhole.gate.leave" => {
|
||||
call_backend_json(
|
||||
backend_base_url,
|
||||
admin_key,
|
||||
"/api/wormhole/gate/leave",
|
||||
Method::POST,
|
||||
payload,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// --- Gate personas ---
|
||||
"wormhole.gate.personas.get" => {
|
||||
let gate_id = extract_gate_id(&payload)?;
|
||||
let path = format!("/api/wormhole/gate/{gate_id}/personas");
|
||||
call_backend_json(backend_base_url, admin_key, &path, Method::GET, None).await
|
||||
}
|
||||
"wormhole.gate.persona.create" => {
|
||||
call_backend_json(
|
||||
backend_base_url,
|
||||
admin_key,
|
||||
"/api/wormhole/gate/persona/create",
|
||||
Method::POST,
|
||||
payload,
|
||||
)
|
||||
.await
|
||||
}
|
||||
"wormhole.gate.persona.activate" => {
|
||||
call_backend_json(
|
||||
backend_base_url,
|
||||
admin_key,
|
||||
"/api/wormhole/gate/persona/activate",
|
||||
Method::POST,
|
||||
payload,
|
||||
)
|
||||
.await
|
||||
}
|
||||
"wormhole.gate.persona.clear" => {
|
||||
call_backend_json(
|
||||
backend_base_url,
|
||||
admin_key,
|
||||
"/api/wormhole/gate/persona/clear",
|
||||
Method::POST,
|
||||
payload,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// --- Gate keys ---
|
||||
"wormhole.gate.key.get" => {
|
||||
let gate_id = extract_gate_id(&payload)?;
|
||||
let path = format!("/api/wormhole/gate/{gate_id}/key");
|
||||
call_backend_json(backend_base_url, admin_key, &path, Method::GET, None).await
|
||||
}
|
||||
"wormhole.gate.key.rotate" => {
|
||||
call_backend_json(
|
||||
backend_base_url,
|
||||
admin_key,
|
||||
"/api/wormhole/gate/key/rotate",
|
||||
Method::POST,
|
||||
payload,
|
||||
)
|
||||
.await
|
||||
}
|
||||
"wormhole.gate.state.resync" => {
|
||||
gate_crypto::resync_gate_state(
|
||||
&gate_crypto_state.0,
|
||||
backend_base_url,
|
||||
admin_key,
|
||||
payload,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// --- Gate messages ---
|
||||
"wormhole.gate.proof" => {
|
||||
call_backend_json(
|
||||
backend_base_url,
|
||||
admin_key,
|
||||
"/api/wormhole/gate/proof",
|
||||
Method::POST,
|
||||
payload,
|
||||
)
|
||||
.await
|
||||
}
|
||||
"wormhole.gate.message.compose" => {
|
||||
gate_crypto::compose_gate_message(
|
||||
&gate_crypto_state.0,
|
||||
backend_base_url,
|
||||
admin_key,
|
||||
payload,
|
||||
)
|
||||
.await
|
||||
}
|
||||
"wormhole.gate.message.post" => {
|
||||
gate_crypto::post_gate_message(
|
||||
&gate_crypto_state.0,
|
||||
backend_base_url,
|
||||
admin_key,
|
||||
payload,
|
||||
)
|
||||
.await
|
||||
}
|
||||
"wormhole.gate.message.decrypt" => {
|
||||
if payload_prefers_backend_gate_decrypt(command, &payload) {
|
||||
return call_backend_json(
|
||||
backend_base_url,
|
||||
admin_key,
|
||||
"/api/wormhole/gate/message/decrypt",
|
||||
Method::POST,
|
||||
payload,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
gate_crypto::decrypt_gate_message(
|
||||
&gate_crypto_state.0,
|
||||
backend_base_url,
|
||||
admin_key,
|
||||
payload,
|
||||
)
|
||||
.await
|
||||
}
|
||||
"wormhole.gate.messages.decrypt" => {
|
||||
if payload_prefers_backend_gate_decrypt(command, &payload) {
|
||||
return call_backend_json(
|
||||
backend_base_url,
|
||||
admin_key,
|
||||
"/api/wormhole/gate/messages/decrypt",
|
||||
Method::POST,
|
||||
payload,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
gate_crypto::decrypt_gate_messages(
|
||||
&gate_crypto_state.0,
|
||||
backend_base_url,
|
||||
admin_key,
|
||||
payload,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// --- Settings ---
|
||||
"settings.wormhole.get" => {
|
||||
call_backend_json(backend_base_url, admin_key, "/api/settings/wormhole", Method::GET, None).await
|
||||
call_backend_json(
|
||||
backend_base_url,
|
||||
admin_key,
|
||||
"/api/settings/wormhole",
|
||||
Method::GET,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
"settings.wormhole.set" => {
|
||||
call_backend_json(backend_base_url, admin_key, "/api/settings/wormhole", Method::PUT, payload).await
|
||||
call_backend_json(
|
||||
backend_base_url,
|
||||
admin_key,
|
||||
"/api/settings/wormhole",
|
||||
Method::PUT,
|
||||
payload,
|
||||
)
|
||||
.await
|
||||
}
|
||||
"settings.privacy.get" => {
|
||||
call_backend_json(backend_base_url, admin_key, "/api/settings/privacy-profile", Method::GET, None).await
|
||||
call_backend_json(
|
||||
backend_base_url,
|
||||
admin_key,
|
||||
"/api/settings/privacy-profile",
|
||||
Method::GET,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
"settings.privacy.set" => {
|
||||
call_backend_json(backend_base_url, admin_key, "/api/settings/privacy-profile", Method::PUT, payload).await
|
||||
call_backend_json(
|
||||
backend_base_url,
|
||||
admin_key,
|
||||
"/api/settings/privacy-profile",
|
||||
Method::PUT,
|
||||
payload,
|
||||
)
|
||||
.await
|
||||
}
|
||||
"settings.api_keys.get" => {
|
||||
call_backend_json(backend_base_url, admin_key, "/api/settings/api-keys", Method::GET, None).await
|
||||
}
|
||||
"settings.api_keys.set" => {
|
||||
call_backend_json(backend_base_url, admin_key, "/api/settings/api-keys", Method::PUT, payload).await
|
||||
call_backend_json(
|
||||
backend_base_url,
|
||||
admin_key,
|
||||
"/api/settings/api-keys",
|
||||
Method::GET,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
"settings.news.get" => {
|
||||
call_backend_json(backend_base_url, admin_key, "/api/settings/news-feeds", Method::GET, None).await
|
||||
call_backend_json(
|
||||
backend_base_url,
|
||||
admin_key,
|
||||
"/api/settings/news-feeds",
|
||||
Method::GET,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
"settings.news.set" => {
|
||||
call_backend_json(backend_base_url, admin_key, "/api/settings/news-feeds", Method::PUT, payload).await
|
||||
call_backend_json(
|
||||
backend_base_url,
|
||||
admin_key,
|
||||
"/api/settings/news-feeds",
|
||||
Method::PUT,
|
||||
payload,
|
||||
)
|
||||
.await
|
||||
}
|
||||
"settings.news.reset" => {
|
||||
call_backend_json(backend_base_url, admin_key, "/api/settings/news-feeds/reset", Method::POST, None).await
|
||||
call_backend_json(
|
||||
backend_base_url,
|
||||
admin_key,
|
||||
"/api/settings/news-feeds/reset",
|
||||
Method::POST,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// --- System ---
|
||||
"system.update" => {
|
||||
call_backend_json(backend_base_url, admin_key, "/api/system/update", Method::POST, None).await
|
||||
call_backend_json(
|
||||
backend_base_url,
|
||||
admin_key,
|
||||
"/api/system/update",
|
||||
Method::POST,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
_ => Err(format!("unsupported_control_command:{command}")),
|
||||
};
|
||||
|
||||
if let Some(gate_id) = expected_gate_change.as_deref() {
|
||||
if result.is_err() {
|
||||
let _ = gate_crypto::clear_expected_gate_change(&gate_crypto_state.0, gate_id);
|
||||
} else if command == "wormhole.gate.leave" {
|
||||
let _ = gate_crypto::forget_gate_state(&gate_crypto_state.0, gate_id);
|
||||
} else if command_requires_gate_state_snapshot(command) {
|
||||
if let Ok(value) = result.as_ref() {
|
||||
if let Err(err) =
|
||||
gate_crypto::adopt_gate_state_snapshot_from_result(&gate_crypto_state.0, value)
|
||||
{
|
||||
let _ = gate_crypto::clear_expected_gate_change(&gate_crypto_state.0, gate_id);
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
@@ -0,0 +1,654 @@
|
||||
use std::ffi::c_void;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use base64::Engine as _;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const ENVELOPE_KIND: &str = "sb_local_custody";
|
||||
const ENVELOPE_VERSION: u8 = 1;
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct LocalCustodyStatus {
|
||||
pub code: String,
|
||||
pub label: String,
|
||||
pub provider: String,
|
||||
pub detail: String,
|
||||
pub protected_at_rest: bool,
|
||||
pub last_error: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LoadOutcome<T> {
|
||||
pub value: T,
|
||||
pub migrated: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
struct LocalCustodyEnvelope {
|
||||
kind: String,
|
||||
version: u8,
|
||||
scope: String,
|
||||
provider: String,
|
||||
protected_at_rest: bool,
|
||||
#[serde(default)]
|
||||
protected_payload: String,
|
||||
#[serde(default)]
|
||||
payload_b64: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum ProviderMode {
|
||||
Dpapi,
|
||||
Raw,
|
||||
#[cfg(test)]
|
||||
TestProtected,
|
||||
#[cfg(test)]
|
||||
TestProtectedAlt,
|
||||
#[cfg(test)]
|
||||
TestFailWrap,
|
||||
}
|
||||
|
||||
fn status_labels(code: &str) -> &'static str {
|
||||
match code {
|
||||
"protected_at_rest" => "Protected at rest",
|
||||
"degraded_local_custody" => "Degraded local custody",
|
||||
"migration_in_progress" => "Migration in progress",
|
||||
"migration_failed" => "Migration failed",
|
||||
_ => "Degraded local custody",
|
||||
}
|
||||
}
|
||||
|
||||
fn default_status() -> LocalCustodyStatus {
|
||||
LocalCustodyStatus {
|
||||
code: "degraded_local_custody".to_string(),
|
||||
label: status_labels("degraded_local_custody").to_string(),
|
||||
provider: "unknown".to_string(),
|
||||
detail: "Native local custody has not been initialized yet.".to_string(),
|
||||
protected_at_rest: false,
|
||||
last_error: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn status_cell() -> &'static Mutex<LocalCustodyStatus> {
|
||||
static STATUS: OnceLock<Mutex<LocalCustodyStatus>> = OnceLock::new();
|
||||
STATUS.get_or_init(|| Mutex::new(default_status()))
|
||||
}
|
||||
|
||||
fn set_status(status: LocalCustodyStatus) {
|
||||
if let Ok(mut guard) = status_cell().lock() {
|
||||
*guard = status;
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_status(mode: ProviderMode, detail: &str) -> LocalCustodyStatus {
|
||||
let (code, provider, protected_at_rest) = match mode {
|
||||
ProviderMode::Dpapi => ("protected_at_rest", "dpapi-machine", true),
|
||||
ProviderMode::Raw => ("degraded_local_custody", "raw", false),
|
||||
#[cfg(test)]
|
||||
ProviderMode::TestProtected => ("protected_at_rest", "test-protected", true),
|
||||
#[cfg(test)]
|
||||
ProviderMode::TestProtectedAlt => ("protected_at_rest", "test-protected-alt", true),
|
||||
#[cfg(test)]
|
||||
ProviderMode::TestFailWrap => ("protected_at_rest", "test-protected", true),
|
||||
};
|
||||
LocalCustodyStatus {
|
||||
code: code.to_string(),
|
||||
label: status_labels(code).to_string(),
|
||||
provider: provider.to_string(),
|
||||
detail: detail.to_string(),
|
||||
protected_at_rest,
|
||||
last_error: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_migration_status(code: &str, detail: &str, last_error: &str) {
|
||||
let (provider, protected_at_rest) = if let Ok(guard) = status_cell().lock() {
|
||||
(guard.provider.clone(), guard.protected_at_rest)
|
||||
} else {
|
||||
("unknown".to_string(), false)
|
||||
};
|
||||
set_status(LocalCustodyStatus {
|
||||
code: code.to_string(),
|
||||
label: status_labels(code).to_string(),
|
||||
provider,
|
||||
detail: detail.to_string(),
|
||||
protected_at_rest,
|
||||
last_error: last_error.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
pub fn local_custody_status() -> LocalCustodyStatus {
|
||||
status_cell()
|
||||
.lock()
|
||||
.map(|guard| guard.clone())
|
||||
.unwrap_or_else(|_| default_status())
|
||||
}
|
||||
|
||||
fn normalized_scope(scope: &str) -> String {
|
||||
scope.trim().to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn is_custody_envelope(value: &serde_json::Value) -> bool {
|
||||
value
|
||||
.get("kind")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(|kind| kind == ENVELOPE_KIND)
|
||||
.unwrap_or(false)
|
||||
&& value
|
||||
.get("version")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.map(|version| version == ENVELOPE_VERSION as u64)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn active_provider() -> ProviderMode {
|
||||
#[cfg(test)]
|
||||
if let Some(mode) = test_provider() {
|
||||
return mode;
|
||||
}
|
||||
if cfg!(target_os = "windows") {
|
||||
ProviderMode::Dpapi
|
||||
} else {
|
||||
ProviderMode::Raw
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_for_name(provider: &str) -> Result<ProviderMode, String> {
|
||||
match provider.trim().to_ascii_lowercase().as_str() {
|
||||
"dpapi-machine" => Ok(ProviderMode::Dpapi),
|
||||
"raw" => Ok(ProviderMode::Raw),
|
||||
#[cfg(test)]
|
||||
"test-protected" => Ok(ProviderMode::TestProtected),
|
||||
#[cfg(test)]
|
||||
"test-protected-alt" => Ok(ProviderMode::TestProtectedAlt),
|
||||
#[cfg(test)]
|
||||
"test-fail-wrap" => Ok(ProviderMode::TestFailWrap),
|
||||
other if other.is_empty() => Err("local_custody_provider_missing".to_string()),
|
||||
other => Err(format!("local_custody_provider_unsupported:{other}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn wrap_bytes(scope: &str, plaintext: &[u8]) -> Result<LocalCustodyEnvelope, String> {
|
||||
let scope = normalized_scope(scope);
|
||||
let provider = active_provider();
|
||||
let envelope = match provider {
|
||||
ProviderMode::Dpapi => LocalCustodyEnvelope {
|
||||
kind: ENVELOPE_KIND.to_string(),
|
||||
version: ENVELOPE_VERSION,
|
||||
scope,
|
||||
provider: "dpapi-machine".to_string(),
|
||||
protected_at_rest: true,
|
||||
protected_payload: base64::engine::general_purpose::STANDARD
|
||||
.encode(dpapi_protect(plaintext)?),
|
||||
payload_b64: String::new(),
|
||||
},
|
||||
ProviderMode::Raw => LocalCustodyEnvelope {
|
||||
kind: ENVELOPE_KIND.to_string(),
|
||||
version: ENVELOPE_VERSION,
|
||||
scope,
|
||||
provider: "raw".to_string(),
|
||||
protected_at_rest: false,
|
||||
protected_payload: String::new(),
|
||||
payload_b64: base64::engine::general_purpose::STANDARD.encode(plaintext),
|
||||
},
|
||||
#[cfg(test)]
|
||||
ProviderMode::TestProtected => LocalCustodyEnvelope {
|
||||
kind: ENVELOPE_KIND.to_string(),
|
||||
version: ENVELOPE_VERSION,
|
||||
scope,
|
||||
provider: "test-protected".to_string(),
|
||||
protected_at_rest: true,
|
||||
protected_payload: base64::engine::general_purpose::STANDARD
|
||||
.encode(test_protect(plaintext)),
|
||||
payload_b64: String::new(),
|
||||
},
|
||||
#[cfg(test)]
|
||||
ProviderMode::TestProtectedAlt => LocalCustodyEnvelope {
|
||||
kind: ENVELOPE_KIND.to_string(),
|
||||
version: ENVELOPE_VERSION,
|
||||
scope,
|
||||
provider: "test-protected-alt".to_string(),
|
||||
protected_at_rest: true,
|
||||
protected_payload: base64::engine::general_purpose::STANDARD
|
||||
.encode(test_protect_alt(plaintext)),
|
||||
payload_b64: String::new(),
|
||||
},
|
||||
#[cfg(test)]
|
||||
ProviderMode::TestFailWrap => return Err(format!("test_wrap_failed:{scope}")),
|
||||
};
|
||||
set_status(provider_status(
|
||||
provider,
|
||||
if envelope.protected_at_rest {
|
||||
"Native gate state is wrapped before persistence."
|
||||
} else {
|
||||
"Native gate state is preserved, but the local custody provider is degraded."
|
||||
},
|
||||
));
|
||||
Ok(envelope)
|
||||
}
|
||||
|
||||
fn unwrap_bytes(scope: &str, envelope: &LocalCustodyEnvelope) -> Result<Vec<u8>, String> {
|
||||
let scope = normalized_scope(scope);
|
||||
if !envelope.scope.is_empty() && normalized_scope(&envelope.scope) != scope {
|
||||
return Err(format!(
|
||||
"local_custody_scope_mismatch:{}:{}",
|
||||
envelope.scope, scope
|
||||
));
|
||||
}
|
||||
match provider_for_name(&envelope.provider)? {
|
||||
ProviderMode::Dpapi => {
|
||||
let protected = base64::engine::general_purpose::STANDARD
|
||||
.decode(envelope.protected_payload.trim())
|
||||
.map_err(|e| format!("local_custody_payload_b64_invalid:{e}"))?;
|
||||
dpapi_unprotect(&protected)
|
||||
}
|
||||
ProviderMode::Raw => base64::engine::general_purpose::STANDARD
|
||||
.decode(envelope.payload_b64.trim())
|
||||
.map_err(|e| format!("local_custody_payload_b64_invalid:{e}")),
|
||||
#[cfg(test)]
|
||||
ProviderMode::TestProtected => {
|
||||
let protected = base64::engine::general_purpose::STANDARD
|
||||
.decode(envelope.protected_payload.trim())
|
||||
.map_err(|e| format!("local_custody_payload_b64_invalid:{e}"))?;
|
||||
Ok(test_unprotect(&protected))
|
||||
}
|
||||
#[cfg(test)]
|
||||
ProviderMode::TestProtectedAlt => {
|
||||
let protected = base64::engine::general_purpose::STANDARD
|
||||
.decode(envelope.protected_payload.trim())
|
||||
.map_err(|e| format!("local_custody_payload_b64_invalid:{e}"))?;
|
||||
Ok(test_unprotect_alt(&protected))
|
||||
}
|
||||
#[cfg(test)]
|
||||
ProviderMode::TestFailWrap => Err("test_wrap_provider_cannot_unwrap".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn atomic_write_bytes(target: &Path, bytes: &[u8]) -> Result<(), String> {
|
||||
let parent = target
|
||||
.parent()
|
||||
.ok_or_else(|| "native_local_custody_parent_missing".to_string())?;
|
||||
fs::create_dir_all(parent).map_err(|e| format!("native_local_custody_dir_failed:{e}"))?;
|
||||
let tmp_path = target.with_extension("tmp");
|
||||
{
|
||||
let mut file = fs::File::create(&tmp_path)
|
||||
.map_err(|e| format!("native_local_custody_tmp_create_failed:{e}"))?;
|
||||
file.write_all(bytes)
|
||||
.map_err(|e| format!("native_local_custody_tmp_write_failed:{e}"))?;
|
||||
file.flush()
|
||||
.map_err(|e| format!("native_local_custody_tmp_flush_failed:{e}"))?;
|
||||
}
|
||||
fs::rename(&tmp_path, target).map_err(|e| format!("native_local_custody_rename_failed:{e}"))
|
||||
}
|
||||
|
||||
pub fn write_protected_json_file<T: Serialize>(
|
||||
path: &Path,
|
||||
scope: &str,
|
||||
value: &T,
|
||||
) -> Result<(), String> {
|
||||
let plaintext = serde_json::to_vec(value)
|
||||
.map_err(|e| format!("native_local_custody_serialize_failed:{e}"))?;
|
||||
let envelope = wrap_bytes(scope, &plaintext)?;
|
||||
let encoded = serde_json::to_vec(&envelope)
|
||||
.map_err(|e| format!("native_local_custody_envelope_serialize_failed:{e}"))?;
|
||||
atomic_write_bytes(path, &encoded)
|
||||
}
|
||||
|
||||
pub fn read_or_migrate_json_file<T: Serialize + DeserializeOwned>(
|
||||
path: &Path,
|
||||
scope: &str,
|
||||
) -> Result<Option<LoadOutcome<T>>, String> {
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let bytes = fs::read(path).map_err(|e| format!("native_local_custody_read_failed:{e}"))?;
|
||||
let raw_value: serde_json::Value = serde_json::from_slice(&bytes)
|
||||
.map_err(|e| format!("native_local_custody_json_invalid:{e}"))?;
|
||||
if is_custody_envelope(&raw_value) {
|
||||
let envelope: LocalCustodyEnvelope = serde_json::from_value(raw_value)
|
||||
.map_err(|e| format!("native_local_custody_envelope_invalid:{e}"))?;
|
||||
let provider = provider_for_name(&envelope.provider)?;
|
||||
let plaintext = unwrap_bytes(scope, &envelope)?;
|
||||
let value = serde_json::from_slice(&plaintext)
|
||||
.map_err(|e| format!("native_local_custody_decode_failed:{e}"))?;
|
||||
set_status(provider_status(
|
||||
provider,
|
||||
if envelope.protected_at_rest {
|
||||
"Native gate state is wrapped before persistence."
|
||||
} else {
|
||||
"Native gate state is preserved, but the local custody provider is degraded."
|
||||
},
|
||||
));
|
||||
return Ok(Some(LoadOutcome {
|
||||
value,
|
||||
migrated: false,
|
||||
}));
|
||||
}
|
||||
|
||||
let legacy_bytes = bytes;
|
||||
let legacy_value: T = serde_json::from_slice(&legacy_bytes)
|
||||
.map_err(|e| format!("native_local_custody_legacy_decode_failed:{e}"))?;
|
||||
set_migration_status(
|
||||
"migration_in_progress",
|
||||
"Native gate state is being migrated to wrapped local custody.",
|
||||
"",
|
||||
);
|
||||
match write_protected_json_file(path, scope, &legacy_value) {
|
||||
Ok(()) => match read_or_migrate_json_file(path, scope)? {
|
||||
Some(LoadOutcome { value, .. }) => Ok(Some(LoadOutcome {
|
||||
value,
|
||||
migrated: true,
|
||||
})),
|
||||
None => Err("native_local_custody_migration_missing".to_string()),
|
||||
},
|
||||
Err(err) => {
|
||||
let _ = atomic_write_bytes(path, &legacy_bytes);
|
||||
set_migration_status(
|
||||
"migration_failed",
|
||||
"Native gate state could not be migrated and remains in the legacy readable form.",
|
||||
&err,
|
||||
);
|
||||
Ok(Some(LoadOutcome {
|
||||
value: legacy_value,
|
||||
migrated: false,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[repr(C)]
|
||||
struct DataBlob {
|
||||
cb_data: u32,
|
||||
pb_data: *mut u8,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[link(name = "Crypt32")]
|
||||
extern "system" {
|
||||
fn CryptProtectData(
|
||||
p_data_in: *const DataBlob,
|
||||
sz_data_descr: *const u16,
|
||||
p_optional_entropy: *const DataBlob,
|
||||
pv_reserved: *mut c_void,
|
||||
p_prompt_struct: *mut c_void,
|
||||
dw_flags: u32,
|
||||
p_data_out: *mut DataBlob,
|
||||
) -> i32;
|
||||
fn CryptUnprotectData(
|
||||
p_data_in: *const DataBlob,
|
||||
ppsz_data_descr: *mut *mut u16,
|
||||
p_optional_entropy: *const DataBlob,
|
||||
pv_reserved: *mut c_void,
|
||||
p_prompt_struct: *mut c_void,
|
||||
dw_flags: u32,
|
||||
p_data_out: *mut DataBlob,
|
||||
) -> i32;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[link(name = "Kernel32")]
|
||||
extern "system" {
|
||||
fn LocalFree(mem: *mut c_void) -> *mut c_void;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn dpapi_protect(bytes: &[u8]) -> Result<Vec<u8>, String> {
|
||||
const CRYPTPROTECT_UI_FORBIDDEN: u32 = 0x1;
|
||||
const CRYPTPROTECT_LOCAL_MACHINE: u32 = 0x4;
|
||||
let mut input = bytes.to_vec();
|
||||
let in_blob = DataBlob {
|
||||
cb_data: input.len() as u32,
|
||||
pb_data: input.as_mut_ptr(),
|
||||
};
|
||||
let mut out_blob = DataBlob {
|
||||
cb_data: 0,
|
||||
pb_data: std::ptr::null_mut(),
|
||||
};
|
||||
let ok = unsafe {
|
||||
CryptProtectData(
|
||||
&in_blob,
|
||||
std::ptr::null(),
|
||||
std::ptr::null(),
|
||||
std::ptr::null_mut(),
|
||||
std::ptr::null_mut(),
|
||||
CRYPTPROTECT_UI_FORBIDDEN | CRYPTPROTECT_LOCAL_MACHINE,
|
||||
&mut out_blob,
|
||||
)
|
||||
};
|
||||
if ok == 0 {
|
||||
return Err("native_local_custody_dpapi_protect_failed".to_string());
|
||||
}
|
||||
let out =
|
||||
unsafe { std::slice::from_raw_parts(out_blob.pb_data, out_blob.cb_data as usize).to_vec() };
|
||||
unsafe {
|
||||
LocalFree(out_blob.pb_data as *mut c_void);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn dpapi_unprotect(bytes: &[u8]) -> Result<Vec<u8>, String> {
|
||||
const CRYPTPROTECT_UI_FORBIDDEN: u32 = 0x1;
|
||||
let mut input = bytes.to_vec();
|
||||
let in_blob = DataBlob {
|
||||
cb_data: input.len() as u32,
|
||||
pb_data: input.as_mut_ptr(),
|
||||
};
|
||||
let mut out_blob = DataBlob {
|
||||
cb_data: 0,
|
||||
pb_data: std::ptr::null_mut(),
|
||||
};
|
||||
let ok = unsafe {
|
||||
CryptUnprotectData(
|
||||
&in_blob,
|
||||
std::ptr::null_mut(),
|
||||
std::ptr::null(),
|
||||
std::ptr::null_mut(),
|
||||
std::ptr::null_mut(),
|
||||
CRYPTPROTECT_UI_FORBIDDEN,
|
||||
&mut out_blob,
|
||||
)
|
||||
};
|
||||
if ok == 0 {
|
||||
return Err("native_local_custody_dpapi_unprotect_failed".to_string());
|
||||
}
|
||||
let out =
|
||||
unsafe { std::slice::from_raw_parts(out_blob.pb_data, out_blob.cb_data as usize).to_vec() };
|
||||
unsafe {
|
||||
LocalFree(out_blob.pb_data as *mut c_void);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn dpapi_protect(_bytes: &[u8]) -> Result<Vec<u8>, String> {
|
||||
Err("native_local_custody_dpapi_unavailable".to_string())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn dpapi_unprotect(_bytes: &[u8]) -> Result<Vec<u8>, String> {
|
||||
Err("native_local_custody_dpapi_unavailable".to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn test_provider_cell() -> &'static Mutex<Option<ProviderMode>> {
|
||||
static TEST_PROVIDER: OnceLock<Mutex<Option<ProviderMode>>> = OnceLock::new();
|
||||
TEST_PROVIDER.get_or_init(|| Mutex::new(None))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn test_provider() -> Option<ProviderMode> {
|
||||
test_provider_cell().lock().ok().and_then(|guard| *guard)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_test_provider_for_tests(provider: Option<ProviderMode>) {
|
||||
if let Ok(mut guard) = test_provider_cell().lock() {
|
||||
*guard = provider;
|
||||
}
|
||||
reset_local_custody_for_tests();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn reset_local_custody_for_tests() {
|
||||
set_status(default_status());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn test_protect(bytes: &[u8]) -> Vec<u8> {
|
||||
bytes.iter().rev().map(|byte| byte ^ 0x5a).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn test_unprotect(bytes: &[u8]) -> Vec<u8> {
|
||||
bytes.iter().rev().map(|byte| byte ^ 0x5a).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn test_protect_alt(bytes: &[u8]) -> Vec<u8> {
|
||||
bytes.iter().rev().map(|byte| byte ^ 0x33).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn test_unprotect_alt(bytes: &[u8]) -> Vec<u8> {
|
||||
bytes.iter().rev().map(|byte| byte ^ 0x33).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
local_custody_status, read_or_migrate_json_file, reset_local_custody_for_tests,
|
||||
set_test_provider_for_tests, write_protected_json_file, ProviderMode,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::fs;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
fn test_lock() -> &'static Mutex<()> {
|
||||
static TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
TEST_LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
fn tmp_file(name: &str) -> std::path::PathBuf {
|
||||
let root = std::env::temp_dir().join(format!("shadowbroker-local-custody-{name}"));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
root.join("state.json")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protected_native_state_is_not_persisted_as_plaintext() {
|
||||
let _guard = test_lock().lock().unwrap();
|
||||
reset_local_custody_for_tests();
|
||||
set_test_provider_for_tests(Some(ProviderMode::TestProtected));
|
||||
let path = tmp_file("protected");
|
||||
|
||||
write_protected_json_file(&path, "gate::ops", &json!({"rust_state_blob_b64":"opaque"}))
|
||||
.unwrap();
|
||||
let raw = fs::read_to_string(&path).unwrap();
|
||||
|
||||
assert!(!raw.contains("opaque"));
|
||||
assert!(raw.contains("sb_local_custody"));
|
||||
assert_eq!(local_custody_status().code, "protected_at_rest");
|
||||
set_test_provider_for_tests(None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_native_state_auto_migrates() {
|
||||
let _guard = test_lock().lock().unwrap();
|
||||
reset_local_custody_for_tests();
|
||||
set_test_provider_for_tests(Some(ProviderMode::TestProtected));
|
||||
let path = tmp_file("migrate");
|
||||
fs::write(
|
||||
&path,
|
||||
serde_json::to_vec(&json!({"gate_id":"ops","epoch":7})).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let loaded = read_or_migrate_json_file::<serde_json::Value>(&path, "gate::ops")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let raw = fs::read_to_string(&path).unwrap();
|
||||
|
||||
assert_eq!(loaded.value["gate_id"], "ops");
|
||||
assert!(loaded.migrated);
|
||||
assert!(raw.contains("sb_local_custody"));
|
||||
set_test_provider_for_tests(None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_native_migration_preserves_legacy_readable_state() {
|
||||
let _guard = test_lock().lock().unwrap();
|
||||
reset_local_custody_for_tests();
|
||||
set_test_provider_for_tests(Some(ProviderMode::TestFailWrap));
|
||||
let path = tmp_file("fail-migrate");
|
||||
let legacy = serde_json::to_vec(&json!({"gate_id":"ops","epoch":7})).unwrap();
|
||||
fs::write(&path, &legacy).unwrap();
|
||||
|
||||
let loaded = read_or_migrate_json_file::<serde_json::Value>(&path, "gate::ops")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let raw = fs::read(&path).unwrap();
|
||||
|
||||
assert_eq!(loaded.value["gate_id"], "ops");
|
||||
assert_eq!(raw, legacy);
|
||||
assert_eq!(local_custody_status().code, "migration_failed");
|
||||
set_test_provider_for_tests(None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degraded_status_is_exposed_when_only_raw_provider_is_available() {
|
||||
let _guard = test_lock().lock().unwrap();
|
||||
reset_local_custody_for_tests();
|
||||
set_test_provider_for_tests(Some(ProviderMode::Raw));
|
||||
let path = tmp_file("raw");
|
||||
|
||||
write_protected_json_file(&path, "gate::ops", &json!({"gate_id":"ops"})).unwrap();
|
||||
|
||||
assert_eq!(local_custody_status().code, "degraded_local_custody");
|
||||
set_test_provider_for_tests(None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_aware_read_handles_raw_to_protected_transition() {
|
||||
let _guard = test_lock().lock().unwrap();
|
||||
reset_local_custody_for_tests();
|
||||
let path = tmp_file("raw-to-protected");
|
||||
set_test_provider_for_tests(Some(ProviderMode::Raw));
|
||||
write_protected_json_file(&path, "gate::ops", &json!({"gate_id":"ops","epoch":7})).unwrap();
|
||||
|
||||
set_test_provider_for_tests(Some(ProviderMode::TestProtected));
|
||||
let loaded = read_or_migrate_json_file::<serde_json::Value>(&path, "gate::ops")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(loaded.value["gate_id"], "ops");
|
||||
assert_eq!(local_custody_status().provider, "raw");
|
||||
assert_eq!(local_custody_status().code, "degraded_local_custody");
|
||||
set_test_provider_for_tests(None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_aware_read_handles_protected_to_other_provider_transition() {
|
||||
let _guard = test_lock().lock().unwrap();
|
||||
reset_local_custody_for_tests();
|
||||
let path = tmp_file("protected-transition");
|
||||
set_test_provider_for_tests(Some(ProviderMode::TestProtected));
|
||||
write_protected_json_file(&path, "gate::ops", &json!({"gate_id":"ops","epoch":9})).unwrap();
|
||||
|
||||
set_test_provider_for_tests(Some(ProviderMode::TestProtectedAlt));
|
||||
let loaded = read_or_migrate_json_file::<serde_json::Value>(&path, "gate::ops")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(loaded.value["epoch"], 9);
|
||||
assert_eq!(local_custody_status().provider, "test-protected");
|
||||
assert_eq!(local_custody_status().code, "protected_at_rest");
|
||||
set_test_provider_for_tests(None);
|
||||
}
|
||||
}
|
||||
@@ -1,37 +1,556 @@
|
||||
mod backend_runtime;
|
||||
mod bridge;
|
||||
mod companion;
|
||||
mod companion_server;
|
||||
mod gate_crypto;
|
||||
mod handlers;
|
||||
mod http_client;
|
||||
mod local_custody;
|
||||
pub mod policy;
|
||||
mod tray;
|
||||
|
||||
use bridge::invoke_local_control;
|
||||
use bridge::{clear_native_audit_report, get_native_audit_report, invoke_local_control};
|
||||
use companion::{companion_disable, companion_enable, companion_open_browser, companion_status};
|
||||
use policy::SharedAuditRing;
|
||||
use tauri::{Manager, WebviewUrl, WebviewWindowBuilder};
|
||||
use url::Url;
|
||||
|
||||
pub struct DesktopAppState {
|
||||
pub backend_base_url: String,
|
||||
pub admin_key: Option<String>,
|
||||
pub audit_ring: SharedAuditRing,
|
||||
pub owns_managed_backend: bool,
|
||||
}
|
||||
|
||||
/// Retained tray icon handle. Stored in Tauri managed state to keep the handle
|
||||
/// alive for the app's lifetime — dropping it may cause the OS to unregister
|
||||
/// the tray icon.
|
||||
#[allow(dead_code)]
|
||||
pub struct TrayHandle(tauri::tray::TrayIcon);
|
||||
|
||||
/// Retained app-level loopback server handle. Stored in Tauri managed state
|
||||
/// so the server lives for the app's lifetime. Dropping it gracefully shuts
|
||||
/// the server down (see `CompanionServerHandle::Drop`).
|
||||
///
|
||||
/// Wrapped in a `Mutex` to satisfy Tauri's managed-state `Send + Sync` bound:
|
||||
/// the underlying handle contains a `tokio::sync::oneshot::Sender` which is
|
||||
/// `Send` but not `Sync`. The mutex is never contended — the handle is only
|
||||
/// touched on shutdown via `Drop`.
|
||||
#[allow(dead_code)]
|
||||
pub struct AppServerHandle(std::sync::Mutex<companion_server::CompanionServerHandle>);
|
||||
|
||||
/// Retained managed backend process handle for packaged builds. Stored in
|
||||
/// managed state so the child process lives for the app's lifetime and is
|
||||
/// terminated on shutdown via `Drop`.
|
||||
#[allow(dead_code)]
|
||||
pub struct ManagedBackendState(std::sync::Mutex<backend_runtime::ManagedBackendHandle>);
|
||||
|
||||
/// Retained native gate-crypto runtime. This lets the packaged native window
|
||||
/// import opaque gate MLS state into the Tauri boundary and decrypt there,
|
||||
/// rather than handing ordinary gate reads back to backend HTTP decrypt routes.
|
||||
#[allow(dead_code)]
|
||||
pub struct NativeGateCryptoState(std::sync::Mutex<gate_crypto::GateCryptoRuntime>);
|
||||
|
||||
// Initialization script installed into every page load of the main webview.
|
||||
//
|
||||
// SECURITY MODEL:
|
||||
// Authoritative policy enforcement (capability mismatch, session profile
|
||||
// warn/deny) lives in Rust — see policy.rs and bridge.rs. The JS-side
|
||||
// preflight checks here are defense in depth only; even if bypassed via
|
||||
// direct Tauri IPC, the Rust side enforces the same semantics and records
|
||||
// every invocation in its AuditRing.
|
||||
//
|
||||
// AUDIT MODEL:
|
||||
// Rust AuditRing is the authoritative audit trail for ALL invocations
|
||||
// (including direct IPC bypasses). The Rust audit is accessible via Tauri
|
||||
// commands: get_native_audit_report / clear_native_audit_report. The
|
||||
// JS-side audit shadow below mirrors wrapper-path invocations and provides
|
||||
// the synchronous getNativeControlAuditReport() interface that the
|
||||
// existing frontend consumers (MeshTerminal, useMeshChat) depend on.
|
||||
//
|
||||
// DELIVERY MODEL (post-P6D-R):
|
||||
// The script is delivered via `WebviewWindowBuilder::initialization_script`
|
||||
// so it runs on every page load of the native window, regardless of the
|
||||
// URL being served (static frontendDist in dev or the loopback app server
|
||||
// in packaged mode). It is NOT served to the browser companion — the
|
||||
// companion loads from the same loopback server but in a plain browser
|
||||
// webview, which does not inject this script. That boundary preserves the
|
||||
// "native window only" trust model for `__SHADOWBROKER_DESKTOP__`.
|
||||
const DESKTOP_INIT_SCRIPT: &str = r#"
|
||||
(function() {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (window.__SHADOWBROKER_DESKTOP__) return; // idempotent on navigation
|
||||
|
||||
var _auditLog = [];
|
||||
var _totalRecorded = 0;
|
||||
var MAX_AUDIT = 100;
|
||||
|
||||
// --- Capability resolution (defense-in-depth, mirrors policy.rs) ---
|
||||
var _capMap = {
|
||||
'wormhole.status': 'wormhole_runtime',
|
||||
'wormhole.connect': 'wormhole_runtime',
|
||||
'wormhole.disconnect': 'wormhole_runtime',
|
||||
'wormhole.restart': 'wormhole_runtime',
|
||||
'wormhole.gate.enter': 'wormhole_gate_persona',
|
||||
'wormhole.gate.leave': 'wormhole_gate_persona',
|
||||
'wormhole.gate.personas.get': 'wormhole_gate_persona',
|
||||
'wormhole.gate.persona.create': 'wormhole_gate_persona',
|
||||
'wormhole.gate.persona.activate': 'wormhole_gate_persona',
|
||||
'wormhole.gate.persona.clear': 'wormhole_gate_persona',
|
||||
'wormhole.gate.key.get': 'wormhole_gate_key',
|
||||
'wormhole.gate.key.rotate': 'wormhole_gate_key',
|
||||
'wormhole.gate.state.resync': 'wormhole_gate_key',
|
||||
'wormhole.gate.proof': 'wormhole_gate_content',
|
||||
'wormhole.gate.message.compose': 'wormhole_gate_content',
|
||||
'wormhole.gate.message.post': 'wormhole_gate_content',
|
||||
'wormhole.gate.message.decrypt': 'wormhole_gate_content',
|
||||
'wormhole.gate.messages.decrypt': 'wormhole_gate_content',
|
||||
'settings.wormhole.get': 'settings',
|
||||
'settings.wormhole.set': 'settings',
|
||||
'settings.privacy.get': 'settings',
|
||||
'settings.privacy.set': 'settings',
|
||||
'settings.api_keys.get': 'settings',
|
||||
'settings.news.get': 'settings',
|
||||
'settings.news.set': 'settings',
|
||||
'settings.news.reset': 'settings',
|
||||
'system.update': 'settings'
|
||||
};
|
||||
|
||||
// --- Profile → capabilities (defense-in-depth, mirrors policy.rs) ---
|
||||
var _profileCaps = {
|
||||
'full_app': ['wormhole_gate_persona','wormhole_gate_key','wormhole_gate_content','wormhole_runtime','settings'],
|
||||
'gate_observe': ['wormhole_gate_content'],
|
||||
'gate_operator': ['wormhole_gate_persona','wormhole_gate_key','wormhole_gate_content'],
|
||||
'wormhole_runtime': ['wormhole_runtime'],
|
||||
'settings_only': ['settings']
|
||||
};
|
||||
|
||||
var _gateCommands = [
|
||||
'wormhole.gate.enter','wormhole.gate.leave',
|
||||
'wormhole.gate.personas.get','wormhole.gate.persona.create',
|
||||
'wormhole.gate.persona.activate','wormhole.gate.persona.clear',
|
||||
'wormhole.gate.key.get','wormhole.gate.key.rotate',
|
||||
'wormhole.gate.state.resync',
|
||||
'wormhole.gate.proof','wormhole.gate.message.compose',
|
||||
'wormhole.gate.message.post','wormhole.gate.message.decrypt'
|
||||
];
|
||||
|
||||
function _extractTargetRef(command, payload) {
|
||||
if (!payload || typeof payload !== 'object') return undefined;
|
||||
var gid = payload.gate_id;
|
||||
if (typeof gid !== 'string' || !gid) return undefined;
|
||||
return _gateCommands.indexOf(command) !== -1 ? gid : undefined;
|
||||
}
|
||||
|
||||
function _recordAudit(entry) {
|
||||
_totalRecorded += 1;
|
||||
entry.recordedAt = Date.now();
|
||||
_auditLog.push(entry);
|
||||
if (_auditLog.length > MAX_AUDIT) {
|
||||
_auditLog.splice(0, _auditLog.length - MAX_AUDIT);
|
||||
}
|
||||
}
|
||||
|
||||
window.__SHADOWBROKER_DESKTOP__ = {
|
||||
invokeLocalControl: function(command, payload, meta) {
|
||||
var expectedCap = _capMap[command];
|
||||
if (!expectedCap) {
|
||||
return Promise.reject('unsupported_control_command:' + command);
|
||||
}
|
||||
var m = meta || {};
|
||||
var profile = m.sessionProfileHint;
|
||||
var profileCaps = profile && _profileCaps[profile] ? _profileCaps[profile] : [];
|
||||
var profileAllows = !profile || profileCaps.length === 0 || profileCaps.indexOf(expectedCap) !== -1;
|
||||
var enforced = Boolean(m.enforceProfileHint && profile);
|
||||
var targetRef = _extractTargetRef(command, payload);
|
||||
var auditBase = {
|
||||
command: command,
|
||||
expectedCapability: expectedCap,
|
||||
declaredCapability: m.capability,
|
||||
sessionProfileHint: m.sessionProfileHint,
|
||||
enforceProfileHint: m.enforceProfileHint,
|
||||
profileAllows: profileAllows,
|
||||
allowedCapabilitiesConfigured: false,
|
||||
enforced: enforced
|
||||
};
|
||||
if (targetRef) auditBase.targetRef = targetRef;
|
||||
if (profile) auditBase.sessionProfile = profile;
|
||||
|
||||
if (m.capability && m.capability !== expectedCap) {
|
||||
_recordAudit(Object.assign({}, auditBase, { outcome: 'capability_mismatch' }));
|
||||
return Promise.reject(
|
||||
'native_control_capability_mismatch:' + m.capability + ':' + expectedCap
|
||||
);
|
||||
}
|
||||
|
||||
if (!profileAllows) {
|
||||
var profileOutcome = enforced ? 'profile_denied' : 'profile_warn';
|
||||
_recordAudit(Object.assign({}, auditBase, { outcome: profileOutcome }));
|
||||
if (enforced) {
|
||||
return Promise.reject(
|
||||
'native_control_profile_mismatch:' + profile + ':' + expectedCap
|
||||
);
|
||||
}
|
||||
console.warn('native_control_profile_mismatch:' + profile + ':' + expectedCap, {
|
||||
command: command, sessionProfileHint: m.sessionProfileHint
|
||||
});
|
||||
}
|
||||
|
||||
if (profileAllows) {
|
||||
_recordAudit(Object.assign({}, auditBase, { outcome: 'allowed' }));
|
||||
}
|
||||
|
||||
return window.__TAURI__.core.invoke('invoke_local_control', {
|
||||
command: command,
|
||||
payload: payload || null,
|
||||
meta: m.capability || m.sessionProfileHint || m.enforceProfileHint
|
||||
? {
|
||||
capability: m.capability || null,
|
||||
sessionProfileHint: m.sessionProfileHint || null,
|
||||
enforceProfileHint: Boolean(m.enforceProfileHint)
|
||||
}
|
||||
: null
|
||||
});
|
||||
},
|
||||
getNativeControlAuditReport: function(limit) {
|
||||
var n = Math.max(1, limit || 25);
|
||||
var recent = _auditLog.slice(-n).reverse();
|
||||
var byOutcome = {};
|
||||
var lastDenied;
|
||||
var lastProfileMismatch;
|
||||
_auditLog.forEach(function(e) {
|
||||
byOutcome[e.outcome] = (byOutcome[e.outcome] || 0) + 1;
|
||||
if (e.outcome === 'profile_warn' || e.outcome === 'profile_denied') lastProfileMismatch = e;
|
||||
if (e.outcome === 'profile_denied' || e.outcome === 'capability_denied') lastDenied = e;
|
||||
});
|
||||
return {
|
||||
totalEvents: _auditLog.length,
|
||||
totalRecorded: _totalRecorded,
|
||||
recent: recent,
|
||||
byOutcome: byOutcome,
|
||||
lastProfileMismatch: lastProfileMismatch,
|
||||
lastDenied: lastDenied
|
||||
};
|
||||
},
|
||||
clearNativeControlAuditReport: function() {
|
||||
_auditLog.splice(0, _auditLog.length);
|
||||
_totalRecorded = 0;
|
||||
if (window.__TAURI__ && window.__TAURI__.core) {
|
||||
window.__TAURI__.core.invoke('clear_native_audit_report', {});
|
||||
}
|
||||
}
|
||||
};
|
||||
})();
|
||||
"#;
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
struct DesktopUpdateContext {
|
||||
mode: &'static str,
|
||||
platform: &'static str,
|
||||
is_packaged_build: bool,
|
||||
backend_mode: &'static str,
|
||||
owns_local_backend: bool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn desktop_update_context(state: tauri::State<'_, DesktopAppState>) -> DesktopUpdateContext {
|
||||
let is_packaged_build = !cfg!(debug_assertions);
|
||||
DesktopUpdateContext {
|
||||
mode: if is_packaged_build { "packaged" } else { "dev" },
|
||||
platform: match std::env::consts::OS {
|
||||
"windows" => "windows",
|
||||
"macos" => "macos",
|
||||
"linux" => "linux",
|
||||
_ => "unknown",
|
||||
},
|
||||
is_packaged_build,
|
||||
backend_mode: if state.owns_managed_backend {
|
||||
"managed"
|
||||
} else {
|
||||
"external"
|
||||
},
|
||||
owns_local_backend: state.owns_managed_backend,
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn desktop_local_custody_status() -> local_custody::LocalCustodyStatus {
|
||||
local_custody::local_custody_status()
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let backend_base_url =
|
||||
std::env::var("SHADOWBROKER_BACKEND_URL").unwrap_or_else(|_| "http://127.0.0.1:8000".to_string());
|
||||
let explicit_backend_url = std::env::var("SHADOWBROKER_BACKEND_URL").ok();
|
||||
let admin_key = std::env::var("SHADOWBROKER_ADMIN_KEY").ok();
|
||||
|
||||
// Frontend URL detection:
|
||||
// - If SHADOWBROKER_FRONTEND_URL is explicitly set → honor it (dev mode
|
||||
// or custom setup; the built-in loopback app server is skipped)
|
||||
// - Else → default to http://127.0.0.1:3000 for dev; in packaged mode
|
||||
// we'll start the loopback app server in setup below and override this.
|
||||
let frontend_url_explicit = std::env::var("SHADOWBROKER_FRONTEND_URL").ok();
|
||||
let default_frontend_url = frontend_url_explicit
|
||||
.clone()
|
||||
.unwrap_or_else(|| "http://127.0.0.1:3000".to_string());
|
||||
|
||||
tauri::Builder::default()
|
||||
.manage(DesktopAppState {
|
||||
backend_base_url,
|
||||
admin_key,
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![invoke_local_control])
|
||||
.setup(|app| {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let script = r#"
|
||||
window.__SHADOWBROKER_DESKTOP__ = {
|
||||
invokeLocalControl: (command, payload) =>
|
||||
window.__TAURI__.core.invoke('invoke_local_control', { command, payload })
|
||||
};
|
||||
"#;
|
||||
let _ = window.eval(script);
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.manage(NativeGateCryptoState(std::sync::Mutex::new(
|
||||
gate_crypto::GateCryptoRuntime::default(),
|
||||
)))
|
||||
.manage(companion::new_companion_state(
|
||||
default_frontend_url.clone(),
|
||||
frontend_url_explicit.is_some(),
|
||||
))
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
desktop_update_context,
|
||||
desktop_local_custody_status,
|
||||
invoke_local_control,
|
||||
get_native_audit_report,
|
||||
clear_native_audit_report,
|
||||
companion_status,
|
||||
companion_enable,
|
||||
companion_disable,
|
||||
companion_open_browser,
|
||||
])
|
||||
.on_window_event(|window, event| {
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
tray::handle_close_requested(window, api);
|
||||
}
|
||||
})
|
||||
.setup(move |app| {
|
||||
// ---- Tray setup (existing behavior, unchanged) ----
|
||||
match tray::setup_tray(app.handle()) {
|
||||
Ok(tray_icon) => {
|
||||
app.manage(TrayHandle(tray_icon));
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"tray setup failed (app will run without tray, close will quit normally): {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let resource_dir = app.path().resource_dir().ok();
|
||||
let app_local_data_dir = app
|
||||
.path()
|
||||
.app_local_data_dir()
|
||||
.or_else(|_| app.path().app_data_dir())
|
||||
.ok();
|
||||
|
||||
if let Some(cache_root) = app_local_data_dir
|
||||
.as_ref()
|
||||
.map(|dir| dir.join("gate-state-cache"))
|
||||
{
|
||||
if let Ok(mut runtime) = app
|
||||
.state::<NativeGateCryptoState>()
|
||||
.0
|
||||
.lock()
|
||||
{
|
||||
runtime.set_cache_root(cache_root);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Resolve bundled frontend + backend assets (packaged mode indicators) ----
|
||||
//
|
||||
// Packaged desktop now owns a bundled local backend runtime as
|
||||
// well as the static frontend export. In packaged mode, when the
|
||||
// user has NOT explicitly set SHADOWBROKER_BACKEND_URL, the app:
|
||||
// 1. installs/refreshes the bundled backend into app-local
|
||||
// writable storage
|
||||
// 2. launches it as a managed child process on loopback
|
||||
// 3. points the loopback app server and native bridge at that
|
||||
// managed backend
|
||||
//
|
||||
// Dev/custom setups can still override the backend explicitly.
|
||||
let www_root: Option<std::path::PathBuf> = resource_dir
|
||||
.as_ref()
|
||||
.map(|d| d.join("companion-www"))
|
||||
.filter(|p| p.join("index.html").exists());
|
||||
let bundled_backend_root = resource_dir
|
||||
.as_ref()
|
||||
.and_then(|d| backend_runtime::bundled_backend_root(d));
|
||||
|
||||
if let Some(root) = www_root.as_ref() {
|
||||
let companion_state_lock =
|
||||
app.state::<companion::SharedCompanionState>();
|
||||
if let Ok(mut cs) = companion_state_lock.lock() {
|
||||
cs.set_www_root(root.clone());
|
||||
};
|
||||
}
|
||||
|
||||
let audit_ring = policy::new_shared_audit_ring(100);
|
||||
let packaged_frontend_present = www_root.is_some();
|
||||
let (resolved_backend_base_url, owns_managed_backend, resolved_admin_key) =
|
||||
if let Some(url) = explicit_backend_url.as_ref() {
|
||||
(url.clone(), false, admin_key.clone())
|
||||
} else if let Some(bundled_root) = bundled_backend_root {
|
||||
let app_local_data_dir = app_local_data_dir
|
||||
.clone()
|
||||
.ok_or_else(|| "managed_backend_app_data_dir_failed:no_app_data_dir".to_string())?;
|
||||
match tauri::async_runtime::block_on(
|
||||
backend_runtime::ensure_and_start_managed_backend(
|
||||
bundled_root,
|
||||
app_local_data_dir,
|
||||
admin_key.clone(),
|
||||
),
|
||||
) {
|
||||
Ok(handle) => {
|
||||
let base_url = handle.base_url().to_string();
|
||||
let resolved_admin_key =
|
||||
handle.admin_key().map(str::to_string);
|
||||
app.manage(ManagedBackendState(std::sync::Mutex::new(handle)));
|
||||
(base_url, true, resolved_admin_key)
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(format!(
|
||||
"ShadowBroker cannot start: the bundled local backend failed to launch.\n\n\
|
||||
This packaged desktop build now owns its backend runtime and cannot fall back \
|
||||
to an external service silently.\n\n\
|
||||
Technical detail: {e}"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
} else if packaged_frontend_present {
|
||||
return Err(
|
||||
"ShadowBroker cannot start: this packaged build is missing the bundled backend runtime."
|
||||
.into(),
|
||||
);
|
||||
} else {
|
||||
("http://127.0.0.1:8000".to_string(), false, admin_key.clone())
|
||||
};
|
||||
|
||||
app.manage(DesktopAppState {
|
||||
backend_base_url: resolved_backend_base_url.clone(),
|
||||
admin_key: resolved_admin_key,
|
||||
audit_ring,
|
||||
owns_managed_backend,
|
||||
});
|
||||
|
||||
// ---- Start app-level loopback server (packaged mode only) ----
|
||||
//
|
||||
// The loopback server has two jobs post-P6D-R:
|
||||
// 1. Act as the HTTP origin for the packaged Tauri main window
|
||||
// so ordinary non-privileged /api/* fetches have a real,
|
||||
// same-origin path to the backend.
|
||||
// 2. Serve the optional browser companion opener.
|
||||
//
|
||||
// It is NOT started when the user explicitly overrides the
|
||||
// frontend URL — in that case the user owns the frontend
|
||||
// environment (dev server, remote mirror, etc.).
|
||||
let packaged_server_url: Option<String> = if www_root.is_some()
|
||||
&& frontend_url_explicit.is_none()
|
||||
{
|
||||
let root = www_root.clone().unwrap();
|
||||
let backend = resolved_backend_base_url.clone();
|
||||
// Synchronously start the server in the Tauri async runtime
|
||||
// so we have the bound URL before creating the webview. The
|
||||
// server task is spawned inside and continues running for
|
||||
// the app's lifetime (owned by AppServerHandle below).
|
||||
match tauri::async_runtime::block_on(async move {
|
||||
companion_server::start_companion_server(root, backend).await
|
||||
}) {
|
||||
Ok(server) => {
|
||||
let url_string = server.url();
|
||||
// Defense in depth: refuse anything that isn't loopback.
|
||||
if !companion::is_loopback_origin(&url_string) {
|
||||
eprintln!(
|
||||
"loopback app server bound to non-loopback origin '{url_string}' — refusing to use it"
|
||||
);
|
||||
None
|
||||
} else {
|
||||
// Register the URL with companion state so the
|
||||
// browser companion opener hands out the same URL.
|
||||
{
|
||||
let companion_state_lock = app
|
||||
.state::<companion::SharedCompanionState>();
|
||||
if let Ok(mut cs) = companion_state_lock.lock() {
|
||||
cs.set_app_server_url(url_string.clone());
|
||||
};
|
||||
}
|
||||
// Keep the handle alive for the app's lifetime.
|
||||
app.manage(AppServerHandle(std::sync::Mutex::new(server)));
|
||||
Some(url_string)
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// In packaged mode the loopback server is required —
|
||||
// without it, the webview has no same-origin /api/*
|
||||
// path and the app is non-functional. Fail honestly
|
||||
// rather than presenting a silently broken UI.
|
||||
return Err(format!(
|
||||
"ShadowBroker cannot start: the packaged loopback server failed to bind.\n\n\
|
||||
This usually means another process is using all available loopback ports, \
|
||||
or a firewall is blocking localhost listeners.\n\n\
|
||||
Technical detail: {e}"
|
||||
).into());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// ---- Create the main window ----
|
||||
//
|
||||
// We create the main window programmatically (rather than via
|
||||
// tauri.conf.json's app.windows) so we can:
|
||||
// (a) Point it at the loopback app server URL in packaged mode
|
||||
// — giving the webview same-origin /api/* access.
|
||||
// (b) Attach an initialization_script that runs BEFORE any page
|
||||
// JavaScript on every page load (including full reloads),
|
||||
// so the __SHADOWBROKER_DESKTOP__ native control bridge is
|
||||
// always present in the native window but never leaks into
|
||||
// browser companion sessions.
|
||||
//
|
||||
// URL resolution order:
|
||||
// 1. Packaged mode with loopback app server → server URL
|
||||
// 2. Explicit SHADOWBROKER_FRONTEND_URL → that URL
|
||||
// (packaged + explicit override, or custom dev setup)
|
||||
// 3. Fall through to WebviewUrl::default() → resolves to
|
||||
// build.devUrl (dev) or build.frontendDist (release) from
|
||||
// tauri.conf.json
|
||||
fn parse_or_default(url: &str, label: &str) -> WebviewUrl {
|
||||
match Url::parse(url) {
|
||||
Ok(parsed) => WebviewUrl::External(parsed),
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"failed to parse {label} URL '{url}' ({e}) — falling back to default webview URL"
|
||||
);
|
||||
WebviewUrl::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
let main_url: WebviewUrl =
|
||||
if let Some(url) = packaged_server_url.as_deref() {
|
||||
parse_or_default(url, "loopback server")
|
||||
} else if let Some(url) = frontend_url_explicit.as_deref() {
|
||||
parse_or_default(url, "explicit frontend override")
|
||||
} else {
|
||||
WebviewUrl::default()
|
||||
};
|
||||
|
||||
WebviewWindowBuilder::new(app, "main", main_url)
|
||||
.title("ShadowBroker")
|
||||
.inner_size(1600.0, 1000.0)
|
||||
.resizable(true)
|
||||
.initialization_script(DESKTOP_INIT_SCRIPT)
|
||||
.build()?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("failed to run shadowbroker tauri shell");
|
||||
.build(tauri::generate_context!())
|
||||
.expect("failed to build shadowbroker tauri shell")
|
||||
.run(|app, event| {
|
||||
// macOS dock-icon reopen: restore/focus the main window when
|
||||
// the user clicks the dock icon while the app is hidden in the
|
||||
// background. On Windows/Linux this event is not emitted, so
|
||||
// the existing tray restore path is the only restore mechanism.
|
||||
#[cfg(target_os = "macos")]
|
||||
if let tauri::RunEvent::Reopen { .. } = event {
|
||||
tray::show_main_window(app);
|
||||
}
|
||||
// All other events use default handling.
|
||||
let _ = (app, event);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,654 @@
|
||||
//! Native-side policy enforcement and audit ring for local-control commands.
|
||||
//!
|
||||
//! This module is the authoritative guardrail layer. Even if webview JS is
|
||||
//! bypassed and `invoke_local_control` is called directly via Tauri IPC,
|
||||
//! every invocation passes through `enforce_and_audit()` before reaching
|
||||
//! the backend HTTP dispatch.
|
||||
//!
|
||||
//! The capability and profile tables mirror the TypeScript source of truth
|
||||
//! in `frontend/src/lib/desktopControlContract.ts`.
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Capability resolution (mirrors controlCommandCapability in TS)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn resolve_command_capability(command: &str) -> Option<&'static str> {
|
||||
match command {
|
||||
"wormhole.status" | "wormhole.connect" | "wormhole.disconnect" | "wormhole.restart" => {
|
||||
Some("wormhole_runtime")
|
||||
}
|
||||
"wormhole.gate.enter"
|
||||
| "wormhole.gate.leave"
|
||||
| "wormhole.gate.personas.get"
|
||||
| "wormhole.gate.persona.create"
|
||||
| "wormhole.gate.persona.activate"
|
||||
| "wormhole.gate.persona.clear" => Some("wormhole_gate_persona"),
|
||||
"wormhole.gate.key.get" | "wormhole.gate.key.rotate" | "wormhole.gate.state.resync" => {
|
||||
Some("wormhole_gate_key")
|
||||
}
|
||||
"wormhole.gate.proof"
|
||||
| "wormhole.gate.message.compose"
|
||||
| "wormhole.gate.message.post"
|
||||
| "wormhole.gate.message.decrypt"
|
||||
| "wormhole.gate.messages.decrypt" => Some("wormhole_gate_content"),
|
||||
"settings.wormhole.get"
|
||||
| "settings.wormhole.set"
|
||||
| "settings.privacy.get"
|
||||
| "settings.privacy.set"
|
||||
| "settings.api_keys.get"
|
||||
| "settings.news.get"
|
||||
| "settings.news.set"
|
||||
| "settings.news.reset"
|
||||
| "system.update" => Some("settings"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Profile → capabilities (mirrors sessionProfileCapabilities in TS)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn resolve_profile_capabilities(profile: &str) -> &'static [&'static str] {
|
||||
match profile {
|
||||
"full_app" => &[
|
||||
"wormhole_gate_persona",
|
||||
"wormhole_gate_key",
|
||||
"wormhole_gate_content",
|
||||
"wormhole_runtime",
|
||||
"settings",
|
||||
],
|
||||
"gate_observe" => &["wormhole_gate_content"],
|
||||
"gate_operator" => &[
|
||||
"wormhole_gate_persona",
|
||||
"wormhole_gate_key",
|
||||
"wormhole_gate_content",
|
||||
],
|
||||
"wormhole_runtime" => &["wormhole_runtime"],
|
||||
"settings_only" => &["settings"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gate target ref extraction (mirrors extractGateTargetRef in TS)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn is_gate_target_command(command: &str) -> bool {
|
||||
matches!(
|
||||
command,
|
||||
"wormhole.gate.enter"
|
||||
| "wormhole.gate.leave"
|
||||
| "wormhole.gate.personas.get"
|
||||
| "wormhole.gate.persona.create"
|
||||
| "wormhole.gate.persona.activate"
|
||||
| "wormhole.gate.persona.clear"
|
||||
| "wormhole.gate.key.get"
|
||||
| "wormhole.gate.key.rotate"
|
||||
| "wormhole.gate.state.resync"
|
||||
| "wormhole.gate.proof"
|
||||
| "wormhole.gate.message.compose"
|
||||
| "wormhole.gate.message.post"
|
||||
| "wormhole.gate.message.decrypt"
|
||||
)
|
||||
}
|
||||
|
||||
fn extract_target_ref(command: &str, payload: &Option<Value>) -> Option<String> {
|
||||
if !is_gate_target_command(command) {
|
||||
return None;
|
||||
}
|
||||
payload
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("gate_id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Audit entry and ring
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AuditEntry {
|
||||
pub command: String,
|
||||
#[serde(rename = "expectedCapability")]
|
||||
pub expected_capability: String,
|
||||
#[serde(rename = "declaredCapability", skip_serializing_if = "Option::is_none")]
|
||||
pub declared_capability: Option<String>,
|
||||
#[serde(rename = "targetRef", skip_serializing_if = "Option::is_none")]
|
||||
pub target_ref: Option<String>,
|
||||
#[serde(rename = "sessionProfile", skip_serializing_if = "Option::is_none")]
|
||||
pub session_profile: Option<String>,
|
||||
#[serde(rename = "sessionProfileHint", skip_serializing_if = "Option::is_none")]
|
||||
pub session_profile_hint: Option<String>,
|
||||
#[serde(rename = "enforceProfileHint")]
|
||||
pub enforce_profile_hint: bool,
|
||||
#[serde(rename = "profileAllows")]
|
||||
pub profile_allows: bool,
|
||||
#[serde(rename = "allowedCapabilitiesConfigured")]
|
||||
pub allowed_capabilities_configured: bool,
|
||||
pub enforced: bool,
|
||||
pub outcome: String,
|
||||
#[serde(rename = "recordedAt")]
|
||||
pub recorded_at: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct AuditReport {
|
||||
#[serde(rename = "totalEvents")]
|
||||
pub total_events: u64,
|
||||
#[serde(rename = "totalRecorded")]
|
||||
pub total_recorded: u64,
|
||||
pub recent: Vec<AuditEntry>,
|
||||
#[serde(rename = "byOutcome")]
|
||||
pub by_outcome: HashMap<String, u64>,
|
||||
#[serde(
|
||||
rename = "lastProfileMismatch",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub last_profile_mismatch: Option<AuditEntry>,
|
||||
#[serde(rename = "lastDenied", skip_serializing_if = "Option::is_none")]
|
||||
pub last_denied: Option<AuditEntry>,
|
||||
}
|
||||
|
||||
pub struct AuditRing {
|
||||
entries: Vec<AuditEntry>,
|
||||
max_entries: usize,
|
||||
total_recorded: u64,
|
||||
}
|
||||
|
||||
impl AuditRing {
|
||||
pub fn new(max_entries: usize) -> Self {
|
||||
Self {
|
||||
entries: Vec::new(),
|
||||
max_entries,
|
||||
total_recorded: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record(&mut self, entry: AuditEntry) {
|
||||
self.total_recorded += 1;
|
||||
self.entries.push(entry);
|
||||
if self.entries.len() > self.max_entries {
|
||||
let excess = self.entries.len() - self.max_entries;
|
||||
self.entries.drain(..excess);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn snapshot(&self, limit: usize) -> AuditReport {
|
||||
let n = limit.max(1);
|
||||
let start = self.entries.len().saturating_sub(n);
|
||||
let recent: Vec<AuditEntry> = self.entries[start..].iter().rev().cloned().collect();
|
||||
|
||||
let mut by_outcome: HashMap<String, u64> = HashMap::new();
|
||||
let mut last_profile_mismatch: Option<AuditEntry> = None;
|
||||
let mut last_denied: Option<AuditEntry> = None;
|
||||
|
||||
for entry in &self.entries {
|
||||
*by_outcome.entry(entry.outcome.clone()).or_insert(0) += 1;
|
||||
if entry.outcome == "profile_warn" || entry.outcome == "profile_denied" {
|
||||
last_profile_mismatch = Some(entry.clone());
|
||||
}
|
||||
if entry.outcome == "profile_denied" || entry.outcome == "capability_denied" {
|
||||
last_denied = Some(entry.clone());
|
||||
}
|
||||
}
|
||||
|
||||
AuditReport {
|
||||
total_events: self.entries.len() as u64,
|
||||
total_recorded: self.total_recorded,
|
||||
recent,
|
||||
by_outcome,
|
||||
last_profile_mismatch,
|
||||
last_denied,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.entries.clear();
|
||||
self.total_recorded = 0;
|
||||
}
|
||||
}
|
||||
|
||||
fn now_millis() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as u64
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Policy enforcement — returns the audit entry on success, or (entry, error
|
||||
// message) on denial. The caller records the entry into the AuditRing
|
||||
// regardless of outcome.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub enum PolicyOutcome {
|
||||
/// Command is allowed — proceed with dispatch.
|
||||
Allowed(AuditEntry),
|
||||
/// Profile mismatch but not enforced — proceed with dispatch, log a warning.
|
||||
ProfileWarn(AuditEntry),
|
||||
/// Denied — do not dispatch.
|
||||
Denied(AuditEntry, String),
|
||||
}
|
||||
|
||||
pub fn enforce(command: &str, payload: &Option<Value>, meta: &Option<Value>) -> PolicyOutcome {
|
||||
let expected_capability = match resolve_command_capability(command) {
|
||||
Some(cap) => cap.to_string(),
|
||||
None => {
|
||||
let entry = AuditEntry {
|
||||
command: command.to_string(),
|
||||
expected_capability: "unknown".to_string(),
|
||||
declared_capability: None,
|
||||
target_ref: None,
|
||||
session_profile: None,
|
||||
session_profile_hint: None,
|
||||
enforce_profile_hint: false,
|
||||
profile_allows: false,
|
||||
allowed_capabilities_configured: false,
|
||||
enforced: false,
|
||||
outcome: "capability_denied".to_string(),
|
||||
recorded_at: now_millis(),
|
||||
};
|
||||
return PolicyOutcome::Denied(entry, format!("unsupported_control_command:{command}"));
|
||||
}
|
||||
};
|
||||
|
||||
// Parse meta fields
|
||||
let declared_capability = meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.get("capability"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let session_profile_hint = meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.get("sessionProfileHint"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let enforce_profile_hint = meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.get("enforceProfileHint"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
let profile = session_profile_hint.as_deref();
|
||||
let profile_caps = profile.map(resolve_profile_capabilities).unwrap_or(&[]);
|
||||
let profile_allows = profile.is_none()
|
||||
|| profile_caps.is_empty()
|
||||
|| profile_caps.contains(&expected_capability.as_str());
|
||||
let enforced = enforce_profile_hint && profile.is_some();
|
||||
let target_ref = extract_target_ref(command, payload);
|
||||
|
||||
let base = AuditEntry {
|
||||
command: command.to_string(),
|
||||
expected_capability: expected_capability.clone(),
|
||||
declared_capability: declared_capability.clone(),
|
||||
target_ref,
|
||||
session_profile: profile.map(|s| s.to_string()),
|
||||
session_profile_hint: session_profile_hint.clone(),
|
||||
enforce_profile_hint,
|
||||
profile_allows,
|
||||
allowed_capabilities_configured: false,
|
||||
enforced,
|
||||
outcome: String::new(),
|
||||
recorded_at: now_millis(),
|
||||
};
|
||||
|
||||
// --- Capability mismatch check ---
|
||||
if let Some(ref declared) = declared_capability {
|
||||
if *declared != expected_capability {
|
||||
let mut entry = base;
|
||||
entry.outcome = "capability_mismatch".to_string();
|
||||
return PolicyOutcome::Denied(
|
||||
entry,
|
||||
format!("native_control_capability_mismatch:{declared}:{expected_capability}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Profile enforcement ---
|
||||
if !profile_allows {
|
||||
let profile_str = profile.unwrap_or("unknown");
|
||||
if enforced {
|
||||
let mut entry = base;
|
||||
entry.outcome = "profile_denied".to_string();
|
||||
return PolicyOutcome::Denied(
|
||||
entry,
|
||||
format!("native_control_profile_mismatch:{profile_str}:{expected_capability}"),
|
||||
);
|
||||
} else {
|
||||
let mut entry = base;
|
||||
entry.outcome = "profile_warn".to_string();
|
||||
return PolicyOutcome::ProfileWarn(entry);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Allowed ---
|
||||
let mut entry = base;
|
||||
entry.outcome = "allowed".to_string();
|
||||
PolicyOutcome::Allowed(entry)
|
||||
}
|
||||
|
||||
/// Thread-safe wrapper for shared audit state.
|
||||
pub type SharedAuditRing = Mutex<AuditRing>;
|
||||
|
||||
pub fn new_shared_audit_ring(max_entries: usize) -> SharedAuditRing {
|
||||
Mutex::new(AuditRing::new(max_entries))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn allowed_command_without_meta() {
|
||||
let result = enforce("wormhole.status", &None, &None);
|
||||
match result {
|
||||
PolicyOutcome::Allowed(entry) => {
|
||||
assert_eq!(entry.outcome, "allowed");
|
||||
assert_eq!(entry.expected_capability, "wormhole_runtime");
|
||||
assert!(entry.profile_allows);
|
||||
assert!(!entry.enforced);
|
||||
}
|
||||
_ => panic!("expected Allowed"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowed_command_with_matching_capability() {
|
||||
let meta = Some(json!({ "capability": "wormhole_runtime" }));
|
||||
let result = enforce("wormhole.status", &None, &meta);
|
||||
match result {
|
||||
PolicyOutcome::Allowed(entry) => {
|
||||
assert_eq!(entry.outcome, "allowed");
|
||||
assert_eq!(
|
||||
entry.declared_capability.as_deref(),
|
||||
Some("wormhole_runtime")
|
||||
);
|
||||
}
|
||||
_ => panic!("expected Allowed"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capability_mismatch_is_denied() {
|
||||
let meta = Some(json!({ "capability": "settings" }));
|
||||
let result = enforce("wormhole.gate.key.rotate", &None, &meta);
|
||||
match result {
|
||||
PolicyOutcome::Denied(entry, msg) => {
|
||||
assert_eq!(entry.outcome, "capability_mismatch");
|
||||
assert!(msg.contains("native_control_capability_mismatch"));
|
||||
assert!(msg.contains("settings"));
|
||||
assert!(msg.contains("wormhole_gate_key"));
|
||||
}
|
||||
_ => panic!("expected Denied"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enforced_profile_denial() {
|
||||
let meta = Some(json!({
|
||||
"capability": "wormhole_gate_key",
|
||||
"sessionProfileHint": "settings_only",
|
||||
"enforceProfileHint": true
|
||||
}));
|
||||
let payload = Some(json!({ "gate_id": "infonet", "reason": "test" }));
|
||||
let result = enforce("wormhole.gate.key.rotate", &payload, &meta);
|
||||
match result {
|
||||
PolicyOutcome::Denied(entry, msg) => {
|
||||
assert_eq!(entry.outcome, "profile_denied");
|
||||
assert_eq!(entry.target_ref.as_deref(), Some("infonet"));
|
||||
assert_eq!(entry.session_profile.as_deref(), Some("settings_only"));
|
||||
assert!(entry.enforced);
|
||||
assert!(!entry.profile_allows);
|
||||
assert!(msg.contains("native_control_profile_mismatch"));
|
||||
}
|
||||
_ => panic!("expected Denied"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_enforced_profile_mismatch_warns() {
|
||||
let meta = Some(json!({
|
||||
"capability": "wormhole_gate_key",
|
||||
"sessionProfileHint": "settings_only"
|
||||
}));
|
||||
let result = enforce("wormhole.gate.key.rotate", &None, &meta);
|
||||
match result {
|
||||
PolicyOutcome::ProfileWarn(entry) => {
|
||||
assert_eq!(entry.outcome, "profile_warn");
|
||||
assert!(!entry.enforced);
|
||||
assert!(!entry.profile_allows);
|
||||
}
|
||||
_ => panic!("expected ProfileWarn"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_app_profile_allows_everything() {
|
||||
let meta = Some(json!({
|
||||
"sessionProfileHint": "full_app",
|
||||
"enforceProfileHint": true
|
||||
}));
|
||||
let result = enforce("wormhole.gate.key.rotate", &None, &meta);
|
||||
match result {
|
||||
PolicyOutcome::Allowed(entry) => {
|
||||
assert_eq!(entry.outcome, "allowed");
|
||||
assert!(entry.profile_allows);
|
||||
}
|
||||
_ => panic!("expected Allowed"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_command_is_denied() {
|
||||
let result = enforce("nonexistent.command", &None, &None);
|
||||
match result {
|
||||
PolicyOutcome::Denied(entry, msg) => {
|
||||
assert_eq!(entry.outcome, "capability_denied");
|
||||
assert!(msg.contains("unsupported_control_command"));
|
||||
}
|
||||
_ => panic!("expected Denied"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gate_command_extracts_target_ref() {
|
||||
let payload = Some(json!({ "gate_id": "testgate", "reason": "r" }));
|
||||
let result = enforce("wormhole.gate.key.rotate", &payload, &None);
|
||||
match result {
|
||||
PolicyOutcome::Allowed(entry) => {
|
||||
assert_eq!(entry.target_ref.as_deref(), Some("testgate"));
|
||||
}
|
||||
_ => panic!("expected Allowed"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_gate_command_has_no_target_ref() {
|
||||
let result = enforce("wormhole.status", &None, &None);
|
||||
match result {
|
||||
PolicyOutcome::Allowed(entry) => {
|
||||
assert!(entry.target_ref.is_none());
|
||||
}
|
||||
_ => panic!("expected Allowed"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_ring_records_and_snapshots() {
|
||||
let mut ring = AuditRing::new(5);
|
||||
for i in 0..3 {
|
||||
ring.record(AuditEntry {
|
||||
command: format!("cmd.{i}"),
|
||||
expected_capability: "settings".to_string(),
|
||||
declared_capability: None,
|
||||
target_ref: None,
|
||||
session_profile: None,
|
||||
session_profile_hint: None,
|
||||
enforce_profile_hint: false,
|
||||
profile_allows: true,
|
||||
allowed_capabilities_configured: false,
|
||||
enforced: false,
|
||||
outcome: "allowed".to_string(),
|
||||
recorded_at: 1000 + i,
|
||||
});
|
||||
}
|
||||
let report = ring.snapshot(10);
|
||||
assert_eq!(report.total_events, 3);
|
||||
assert_eq!(report.total_recorded, 3);
|
||||
assert_eq!(report.recent.len(), 3);
|
||||
// Most recent first
|
||||
assert_eq!(report.recent[0].command, "cmd.2");
|
||||
assert_eq!(*report.by_outcome.get("allowed").unwrap(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_ring_evicts_oldest() {
|
||||
let mut ring = AuditRing::new(2);
|
||||
for i in 0..4 {
|
||||
ring.record(AuditEntry {
|
||||
command: format!("cmd.{i}"),
|
||||
expected_capability: "settings".to_string(),
|
||||
declared_capability: None,
|
||||
target_ref: None,
|
||||
session_profile: None,
|
||||
session_profile_hint: None,
|
||||
enforce_profile_hint: false,
|
||||
profile_allows: true,
|
||||
allowed_capabilities_configured: false,
|
||||
enforced: false,
|
||||
outcome: "allowed".to_string(),
|
||||
recorded_at: 1000 + i,
|
||||
});
|
||||
}
|
||||
let report = ring.snapshot(10);
|
||||
assert_eq!(report.total_events, 2);
|
||||
assert_eq!(report.total_recorded, 4);
|
||||
assert_eq!(report.recent[0].command, "cmd.3");
|
||||
assert_eq!(report.recent[1].command, "cmd.2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_ring_clear() {
|
||||
let mut ring = AuditRing::new(10);
|
||||
ring.record(AuditEntry {
|
||||
command: "test".to_string(),
|
||||
expected_capability: "settings".to_string(),
|
||||
declared_capability: None,
|
||||
target_ref: None,
|
||||
session_profile: None,
|
||||
session_profile_hint: None,
|
||||
enforce_profile_hint: false,
|
||||
profile_allows: true,
|
||||
allowed_capabilities_configured: false,
|
||||
enforced: false,
|
||||
outcome: "allowed".to_string(),
|
||||
recorded_at: 1000,
|
||||
});
|
||||
ring.clear();
|
||||
let report = ring.snapshot(10);
|
||||
assert_eq!(report.total_events, 0);
|
||||
assert_eq!(report.total_recorded, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_ring_tracks_denied_entries() {
|
||||
let mut ring = AuditRing::new(10);
|
||||
ring.record(AuditEntry {
|
||||
command: "wormhole.gate.key.rotate".to_string(),
|
||||
expected_capability: "wormhole_gate_key".to_string(),
|
||||
declared_capability: None,
|
||||
target_ref: None,
|
||||
session_profile: Some("settings_only".to_string()),
|
||||
session_profile_hint: Some("settings_only".to_string()),
|
||||
enforce_profile_hint: true,
|
||||
profile_allows: false,
|
||||
allowed_capabilities_configured: false,
|
||||
enforced: true,
|
||||
outcome: "profile_denied".to_string(),
|
||||
recorded_at: 1000,
|
||||
});
|
||||
let report = ring.snapshot(10);
|
||||
assert!(report.last_denied.is_some());
|
||||
assert!(report.last_profile_mismatch.is_some());
|
||||
assert_eq!(
|
||||
report.last_denied.as_ref().unwrap().outcome,
|
||||
"profile_denied"
|
||||
);
|
||||
assert_eq!(*report.by_outcome.get("profile_denied").unwrap(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_27_commands_resolve_capability() {
|
||||
let commands = [
|
||||
"wormhole.status",
|
||||
"wormhole.connect",
|
||||
"wormhole.disconnect",
|
||||
"wormhole.restart",
|
||||
"wormhole.gate.enter",
|
||||
"wormhole.gate.leave",
|
||||
"wormhole.gate.personas.get",
|
||||
"wormhole.gate.persona.create",
|
||||
"wormhole.gate.persona.activate",
|
||||
"wormhole.gate.persona.clear",
|
||||
"wormhole.gate.key.get",
|
||||
"wormhole.gate.key.rotate",
|
||||
"wormhole.gate.proof",
|
||||
"wormhole.gate.message.compose",
|
||||
"wormhole.gate.message.post",
|
||||
"wormhole.gate.message.decrypt",
|
||||
"wormhole.gate.messages.decrypt",
|
||||
"settings.wormhole.get",
|
||||
"settings.wormhole.set",
|
||||
"settings.privacy.get",
|
||||
"settings.privacy.set",
|
||||
"settings.api_keys.get",
|
||||
"settings.news.get",
|
||||
"settings.news.set",
|
||||
"settings.news.reset",
|
||||
"system.update",
|
||||
];
|
||||
assert_eq!(commands.len(), 26);
|
||||
for cmd in &commands {
|
||||
assert!(
|
||||
resolve_command_capability(cmd).is_some(),
|
||||
"command {cmd} should resolve to a capability"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_profiles_resolve_non_empty() {
|
||||
let profiles = [
|
||||
"full_app",
|
||||
"gate_observe",
|
||||
"gate_operator",
|
||||
"wormhole_runtime",
|
||||
"settings_only",
|
||||
];
|
||||
for profile in &profiles {
|
||||
let caps = resolve_profile_capabilities(profile);
|
||||
assert!(
|
||||
!caps.is_empty(),
|
||||
"profile {profile} should have capabilities"
|
||||
);
|
||||
}
|
||||
assert_eq!(resolve_profile_capabilities("full_app").len(), 5);
|
||||
assert_eq!(resolve_profile_capabilities("settings_only"), &["settings"]);
|
||||
assert_eq!(
|
||||
resolve_profile_capabilities("gate_observe"),
|
||||
&["wormhole_gate_content"]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
//! Cross-platform tray / menu-bar background lifecycle.
|
||||
//!
|
||||
//! Provides:
|
||||
//! - System tray icon with Show / Hide / Quit menu
|
||||
//! - Window close interception (hide to background instead of quit)
|
||||
//! - Restore from tray on menu action or tray icon click
|
||||
//!
|
||||
//! **Close behavior is conditional on tray availability:**
|
||||
//! - If tray setup succeeds: close hides to background (tray can restore/quit)
|
||||
//! - If tray setup fails: close behaves normally (app exits)
|
||||
//! - The user is never stranded with a hidden app and no restore path.
|
||||
//!
|
||||
//! Platform behavior:
|
||||
//! - **Windows**: Tray icon in system notification area. Left-click opens
|
||||
//! the menu; "Show ShadowBroker" restores the window. "Quit" exits fully.
|
||||
//! - **macOS**: Menu bar icon. Click opens menu (macOS convention).
|
||||
//! - **Linux**: Appindicator tray icon (requires libayatana-appindicator3).
|
||||
//! Click opens menu. Behavior depends on the desktop environment —
|
||||
//! not all DEs render appindicator icons identically.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use tauri::image::Image;
|
||||
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
|
||||
use tauri::tray::{MouseButton, TrayIcon, TrayIconBuilder, TrayIconEvent};
|
||||
use tauri::{AppHandle, CloseRequestApi, Manager};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tray menu item IDs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub const MENU_ID_SHOW: &str = "sb_tray_show";
|
||||
pub const MENU_ID_HIDE: &str = "sb_tray_hide";
|
||||
pub const MENU_ID_QUIT: &str = "sb_tray_quit";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tray icon generation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ICON_SIZE: u32 = 32;
|
||||
|
||||
/// Generate a minimal 32x32 RGBA tray icon: a filled teal circle on a
|
||||
/// transparent background. Avoids requiring external asset files.
|
||||
pub fn generate_tray_icon_rgba() -> (Vec<u8>, u32, u32) {
|
||||
let size = ICON_SIZE;
|
||||
let mut rgba = vec![0u8; (size * size * 4) as usize];
|
||||
let center = size as f32 / 2.0;
|
||||
let radius = center - 2.0;
|
||||
|
||||
for y in 0..size {
|
||||
for x in 0..size {
|
||||
let dx = x as f32 - center;
|
||||
let dy = y as f32 - center;
|
||||
let dist = (dx * dx + dy * dy).sqrt();
|
||||
let idx = ((y * size + x) * 4) as usize;
|
||||
|
||||
if dist <= radius {
|
||||
// Teal/green brand accent
|
||||
rgba[idx] = 0x1B; // R
|
||||
rgba[idx + 1] = 0xC4; // G
|
||||
rgba[idx + 2] = 0x9D; // B
|
||||
rgba[idx + 3] = 0xFF; // A
|
||||
}
|
||||
// Transparent otherwise (already zeroed)
|
||||
}
|
||||
}
|
||||
(rgba, size, size)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tray readiness state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Shared atomic flag indicating whether the tray icon was successfully set up.
|
||||
/// Used by `should_hide_on_close()` to decide whether close should hide to
|
||||
/// background (tray alive → restore path exists) or quit normally (no tray →
|
||||
/// hiding would strand the user).
|
||||
pub static TRAY_READY: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Returns `true` if the tray icon is live and the app should hide on close
|
||||
/// instead of quitting.
|
||||
pub fn should_hide_on_close() -> bool {
|
||||
TRAY_READY.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tray setup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Set up the system tray icon with a Show / Hide / Quit menu.
|
||||
/// On success, returns the `TrayIcon` handle — the caller **must** retain it
|
||||
/// for the lifetime of the app (dropping it may unregister the tray icon).
|
||||
/// Also sets `TRAY_READY` to `true`.
|
||||
///
|
||||
/// On failure (e.g. missing appindicator on Linux), returns an error string
|
||||
/// and `TRAY_READY` remains `false`.
|
||||
pub fn setup_tray(app: &AppHandle) -> Result<TrayIcon, String> {
|
||||
let show_item = MenuItem::with_id(app, MENU_ID_SHOW, "Show ShadowBroker", true, None::<&str>)
|
||||
.map_err(|e| format!("tray_menu_show:{e}"))?;
|
||||
let hide_item = MenuItem::with_id(app, MENU_ID_HIDE, "Hide to Background", true, None::<&str>)
|
||||
.map_err(|e| format!("tray_menu_hide:{e}"))?;
|
||||
let separator =
|
||||
PredefinedMenuItem::separator(app).map_err(|e| format!("tray_menu_separator:{e}"))?;
|
||||
let quit_item = MenuItem::with_id(app, MENU_ID_QUIT, "Quit ShadowBroker", true, None::<&str>)
|
||||
.map_err(|e| format!("tray_menu_quit:{e}"))?;
|
||||
|
||||
let menu = Menu::with_items(app, &[&show_item, &hide_item, &separator, &quit_item])
|
||||
.map_err(|e| format!("tray_menu_build:{e}"))?;
|
||||
|
||||
let (rgba, width, height) = generate_tray_icon_rgba();
|
||||
let icon = Image::new_owned(rgba, width, height);
|
||||
|
||||
let tray = TrayIconBuilder::new()
|
||||
.icon(icon)
|
||||
.tooltip("ShadowBroker")
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(true)
|
||||
.on_menu_event(|app, event| {
|
||||
handle_tray_menu_event(app, event.id.as_ref());
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
// Double-click left button: show window (cross-platform convenience)
|
||||
if let TrayIconEvent::DoubleClick {
|
||||
button: MouseButton::Left,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
show_main_window(tray.app_handle());
|
||||
}
|
||||
})
|
||||
.build(app)
|
||||
.map_err(|e| format!("tray_build:{e}"))?;
|
||||
|
||||
TRAY_READY.store(true, Ordering::Relaxed);
|
||||
Ok(tray)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Menu event handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn handle_tray_menu_event(app: &AppHandle, id: &str) {
|
||||
match id {
|
||||
MENU_ID_SHOW => show_main_window(app),
|
||||
MENU_ID_HIDE => hide_main_window(app),
|
||||
MENU_ID_QUIT => app.exit(0),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Window lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Show, unminimize, and focus the main window.
|
||||
pub fn show_main_window(app: &AppHandle) {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
|
||||
/// Hide the main window to the background.
|
||||
pub fn hide_main_window(app: &AppHandle) {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.hide();
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a window close request. Behavior depends on tray availability:
|
||||
/// - **Tray alive** (`should_hide_on_close()` = true): prevent close, hide to
|
||||
/// background. The user can restore via tray menu or quit via "Quit ShadowBroker".
|
||||
/// - **No tray** (`should_hide_on_close()` = false): allow the close to proceed
|
||||
/// normally so the app exits. Never strand the user with a hidden window and
|
||||
/// no visible restore path.
|
||||
pub fn handle_close_requested(window: &tauri::Window, api: &CloseRequestApi) {
|
||||
if window.label() == "main" && should_hide_on_close() {
|
||||
api.prevent_close();
|
||||
let _ = window.hide();
|
||||
}
|
||||
// If tray is not ready or window is not "main", close proceeds normally.
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn icon_rgba_has_correct_dimensions() {
|
||||
let (rgba, w, h) = generate_tray_icon_rgba();
|
||||
assert_eq!(w, ICON_SIZE);
|
||||
assert_eq!(h, ICON_SIZE);
|
||||
assert_eq!(rgba.len(), (w * h * 4) as usize);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icon_center_pixel_is_opaque_teal() {
|
||||
let (rgba, w, _h) = generate_tray_icon_rgba();
|
||||
let center = w / 2;
|
||||
let idx = ((center * w + center) * 4) as usize;
|
||||
// R=0x1B, G=0xC4, B=0x9D, A=0xFF
|
||||
assert_eq!(rgba[idx], 0x1B);
|
||||
assert_eq!(rgba[idx + 1], 0xC4);
|
||||
assert_eq!(rgba[idx + 2], 0x9D);
|
||||
assert_eq!(rgba[idx + 3], 0xFF);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icon_corner_pixel_is_transparent() {
|
||||
let (rgba, _w, _h) = generate_tray_icon_rgba();
|
||||
// Top-left corner (0,0) should be transparent
|
||||
assert_eq!(rgba[0], 0); // R
|
||||
assert_eq!(rgba[1], 0); // G
|
||||
assert_eq!(rgba[2], 0); // B
|
||||
assert_eq!(rgba[3], 0); // A
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_ids_are_distinct() {
|
||||
assert_ne!(MENU_ID_SHOW, MENU_ID_HIDE);
|
||||
assert_ne!(MENU_ID_SHOW, MENU_ID_QUIT);
|
||||
assert_ne!(MENU_ID_HIDE, MENU_ID_QUIT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_ids_are_namespaced() {
|
||||
// All IDs should be prefixed to avoid collisions
|
||||
assert!(MENU_ID_SHOW.starts_with("sb_tray_"));
|
||||
assert!(MENU_ID_HIDE.starts_with("sb_tray_"));
|
||||
assert!(MENU_ID_QUIT.starts_with("sb_tray_"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_hide_reflects_tray_ready_state() {
|
||||
// Reset to known state
|
||||
TRAY_READY.store(false, Ordering::Relaxed);
|
||||
assert!(
|
||||
!should_hide_on_close(),
|
||||
"should not hide when tray is not ready"
|
||||
);
|
||||
|
||||
TRAY_READY.store(true, Ordering::Relaxed);
|
||||
assert!(should_hide_on_close(), "should hide when tray is ready");
|
||||
|
||||
// Clean up for other tests
|
||||
TRAY_READY.store(false, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tray_ready_default_is_false() {
|
||||
// TRAY_READY is initialized to false — if no tray setup runs,
|
||||
// close should behave normally (no stranding).
|
||||
// Note: other tests may have mutated TRAY_READY, so we verify
|
||||
// the semantic contract via should_hide_on_close after explicit reset.
|
||||
TRAY_READY.store(false, Ordering::Relaxed);
|
||||
assert!(!should_hide_on_close());
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,50 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ShadowBroker Desktop Shell",
|
||||
"version": "0.1.0",
|
||||
"productName": "ShadowBroker",
|
||||
"version": "0.9.7",
|
||||
"identifier": "com.shadowbroker.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../../frontend/.next",
|
||||
"frontendDist": "../../../frontend/out",
|
||||
"devUrl": "http://127.0.0.1:3000"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "ShadowBroker",
|
||||
"width": 1600,
|
||||
"height": 1000,
|
||||
"resizable": true
|
||||
}
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"createUpdaterArtifacts": true,
|
||||
"resources": ["companion-www", "backend-runtime"],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.ico",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.png",
|
||||
"icons/Square30x30Logo.png",
|
||||
"icons/Square44x44Logo.png",
|
||||
"icons/Square71x71Logo.png",
|
||||
"icons/Square89x89Logo.png",
|
||||
"icons/Square107x107Logo.png",
|
||||
"icons/Square142x142Logo.png",
|
||||
"icons/Square150x150Logo.png",
|
||||
"icons/Square284x284Logo.png",
|
||||
"icons/Square310x310Logo.png",
|
||||
"icons/StoreLogo.png"
|
||||
]
|
||||
},
|
||||
"app": {
|
||||
"windows": [],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; connect-src 'self' http://127.0.0.1:* https://*; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://*"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDJDMUU1NkRENjNCNTI5RjUKUldUMUtiVmozVlllTEd0STJlMGtORUxUWHlGQ2V0ZXM3Z1BOc3hwc0pUK1c3dlplcWc2OFpKd3oK",
|
||||
"endpoints": [
|
||||
"https://github.com/BigBodyCobain/Shadowbroker/releases/latest/download/latest.json"
|
||||
],
|
||||
"windows": {
|
||||
"installMode": "passive"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||