release: prepare v0.9.7

This commit is contained in:
BigBodyCobain
2026-05-01 22:55:04 -06:00
parent ea457f27da
commit 28b3bd5ebf
670 changed files with 187060 additions and 14006 deletions
+153 -29
View File
@@ -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.
+29
View File
@@ -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"
}
}
}
}
+14
View File
@@ -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',
+160 -19
View File
@@ -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
+121
View File
@@ -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
+151
View File
@@ -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
}
+160
View File
@@ -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
+47
View File
@@ -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/
File diff suppressed because it is too large Load Diff
@@ -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"
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

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);
}
}
File diff suppressed because it is too large Load Diff
@@ -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"
}
}
}
}