feat(FN-3577): add native shell guide and bridge contract documentation

Adds a native shell integration guide (FN-3577) with fusionShell bridge contract documentation, wired into the docs navigation, plus a regression test; also hardens the dependency graph's task filtering (FN-3085) to include in-review tasks and handle orphan edges, with corresponding test coverage ac

Fusion-Task-Id: FN-3577
This commit is contained in:
Fusion
2026-05-07 03:59:43 -07:00
committed by gsxdsm
parent 3f09037cfe
commit f3c99eb4fa
7 changed files with 201 additions and 12 deletions

View File

@@ -79,10 +79,13 @@ Install from browser:
### Native shell onboarding and connection profiles ### Native shell onboarding and connection profiles
- First launch in the mobile shell enters a shell-level remote connection onboarding flow before dashboard model onboarding. First launch in the mobile shell enters a shell-level remote connection onboarding flow before dashboard model onboarding.
- Connection setup supports QR scan and manual URL entry, with optional auth token.
- Saved remote profiles are persisted in shell-local mobile storage and managed through `window.fusionShell` connection APIs. For the canonical flow (QR/manual setup, saved profiles, active-profile behavior, and security caveats), see [Native Shell Connection Guide](./docs/native-shell.md).
- Shell connection persistence is intentionally separate from Fusion project/global settings.
Implementation notes:
- Mobile shell profiles are persisted in shell-local storage (Capacitor Preferences), separate from Fusion project/global settings.
- The dashboard consumes this through the shared `window.fusionShell` connection APIs.
### Planning Mode ### Planning Mode

View File

@@ -23,6 +23,7 @@ For a full walkthrough (installation, onboarding, first task, and daily workflow
| [Dashboard Guide](./dashboard-guide.md) | Board/list views, terminal, git manager, files, planning, and UI tools | | [Dashboard Guide](./dashboard-guide.md) | Board/list views, terminal, git manager, files, planning, and UI tools |
| [CLI Reference](./cli-reference.md) | Complete `fn` command reference with subcommands, flags, and examples | | [CLI Reference](./cli-reference.md) | Complete `fn` command reference with subcommands, flags, and examples |
| [Remote Access](./remote-access.md) | Operator runbook for Tailscale/Cloudflare setup, tokenized login links, security caveats, and troubleshooting | | [Remote Access](./remote-access.md) | Operator runbook for Tailscale/Cloudflare setup, tokenized login links, security caveats, and troubleshooting |
| [Native Shell Connection Guide](./native-shell.md) | Canonical mobile/desktop shell onboarding, profile management, QR/manual setup, and remote handoff behavior |
### Task & Project Management ### Task & Project Management
| Guide | Description | | Guide | Description |

View File

@@ -21,6 +21,38 @@ At a high level, Fusion is split into:
Native shells expose a shared host-neutral bridge at `window.fusionShell` for first-run shell onboarding, connection profile persistence, and active shell mode/profile state. The dashboard consumes `window.fusionShell` when present and degrades cleanly in plain web/PWA mode. Native shells expose a shared host-neutral bridge at `window.fusionShell` for first-run shell onboarding, connection profile persistence, and active shell mode/profile state. The dashboard consumes `window.fusionShell` when present and degrades cleanly in plain web/PWA mode.
### `window.fusionShell` bridge contract
Canonical dashboard-side types live in `packages/dashboard/app/types/native-shell.d.ts`.
Shared bridge methods used by dashboard/mobile/desktop flows:
- `getState()`
- `listProfiles()`
- `saveProfile(profile)`
- `deleteProfile(profileId)`
- `setActiveProfile(profileId)`
- `setDesktopMode(mode)`
- `startQrScan()`
- `openConnectionManager()`
- `subscribe(listener)`
Shared shell state contract (`ShellConnectionState`):
- `host` (`"web" | "mobile-shell" | "desktop-shell"`)
- `desktopMode` (`"local" | "remote"`, optional)
- `activeProfileId`
- `profiles`
- `localServer` (`status`, optional `port`, optional `error`)
Desktop-specific bootstrap extension:
- Electron preload also exposes `getDesktopModeState()` for first-run desktop mode selection (`{ isFirstRun, desktopMode }`).
- The dashboard itself does **not** depend on that preload-only helper for steady-state rendering; it consumes shared shell state via `ShellContext` (`packages/dashboard/app/context/ShellContext.tsx`).
Persistence ownership by host:
- **Mobile shell** persists connection profiles + active profile with Capacitor Preferences (`packages/mobile/src/plugins/connection-profiles.ts`).
- **Desktop shell** persists shell settings in app-owned JSON at `app.getPath("userData")/shell-connections.json` (`packages/desktop/src/shell-settings.ts`).
These are shell-owned persistence layers, intentionally separate from Fusion project/global settings.
### High-level runtime diagram ### High-level runtime diagram
```text ```text

View File

@@ -379,6 +379,12 @@ Click the chevron next to the status indicator to open the node selector dropdow
The selected node persists across browser sessions via localStorage. If the selected remote node is unregistered, the dashboard automatically falls back to local mode. The selected node persists across browser sessions via localStorage. If the selected remote node is unregistered, the dashboard automatically falls back to local mode.
## Native shell connection flow
If you use Fusion from a native shell (mobile app or desktop shell in remote mode), dashboard startup is gated by shell onboarding until a connection is selected.
For the canonical workflow (first-run onboarding, QR/manual setup, saved profiles, and desktop local/remote handoff), see [Native Shell Connection Guide](./native-shell.md).
## Remote Access (Settings) ## Remote Access (Settings)
Dashboard remote controls live in **Settings → Remote Access**. Dashboard remote controls live in **Settings → Remote Access**.

95
docs/native-shell.md Normal file
View File

@@ -0,0 +1,95 @@
# Native Shell Connection Guide
[← Docs index](./README.md)
This is the canonical guide for how Fusion native shells (mobile and desktop) connect to remote Fusion dashboards, persist saved connections, and hand off shell state to the dashboard.
## Overview
Native shells expose a shared `window.fusionShell` bridge that the dashboard reads through `ShellContext`.
- **Mobile shell** always runs in remote mode and requires an active connection profile.
- **Desktop shell** supports both **Local Fusion** and **Remote Server** modes.
- **Web/PWA** does not use shell onboarding.
## First-run onboarding flow
The dashboard gates first-run shell onboarding in `requiresNativeShellOnboarding(...)` (`packages/dashboard/app/App.tsx`):
- `host === "mobile-shell"`: onboarding is required until `activeProfileId` is set.
- `host === "desktop-shell"`:
- `desktopMode === "local"`: onboarding is not required.
- `desktopMode === "remote"` (or unset): onboarding is required until `activeProfileId` is set.
`NativeShellOnboardingModal` then provides:
1. **Desktop mode choice** (desktop only):
- **Local Fusion** → calls `setDesktopMode("local")`.
- **Remote Server** → stays in remote flow.
2. **Remote connection entry** (mobile + desktop remote mode):
- **Scan QR** (`startQrScan()`)
- Manual fields: profile name, server URL, optional auth token
3. **Continue**:
- Saves profile via `saveProfile(...)`
- Sets desktop mode to remote when relevant
- Activates profile via `setActiveProfile(...)`
- Redirects to selected remote dashboard URL (adds `rt=<token>` query when token is present)
## QR scan and manual fallback
QR scan is optional convenience. Manual entry is always supported.
QR payload parsing accepts either:
- JSON payload with `serverUrl` and optional `authToken`
- URL payload, reading token from `authToken` or `rt`
If scanning fails or is unavailable, users can continue with manual URL + token entry.
## Saved connection profiles
Profiles are first-class saved objects shared by onboarding and Connection Manager:
- `name`
- `serverUrl`
- optional `authToken`
- timestamps (`createdAt`, `updatedAt`, `lastUsedAt`)
Connection Manager supports:
- **Use** (activate profile)
- **Edit** (update name/URL/token)
- **Delete**
- **Add connection**
Activation updates `activeProfileId` and stamps `lastUsedAt` on the selected profile.
## Desktop remote handoff behavior
Desktop shell stores shell settings separately from Fusion project/global settings.
When desktop mode is `remote` and an active profile exists, `App.tsx` redirects to that profile URL and appends `rt` when a token exists.
When desktop mode is `local` and the local server reports `ready` with a port, `App.tsx` redirects to `http://localhost:<port>`.
## Persistence model
- **Mobile shell**: connection profiles + active profile are persisted through Capacitor Preferences (`packages/mobile/src/plugins/connection-profiles.ts`).
- **Desktop shell**: shell settings are persisted in app-owned JSON at `app.getPath("userData")/shell-connections.json` (`packages/desktop/src/shell-settings.ts`).
## Security guidance
Tokenized URLs and QR payloads are secrets.
- Treat `rt`/`authToken` values like passwords.
- Do not paste tokenized links into chats, screenshots, or tickets.
- Prefer short-lived token workflows when sharing access.
For canonical token caveats and operator guidance, see [Remote Access runbook](./remote-access.md).
## Related docs
- [Dashboard Guide](./dashboard-guide.md)
- [Architecture](./architecture.md)
- [Mobile Development Guide](../MOBILE.md)
- [Remote Access runbook](./remote-access.md)

View File

@@ -0,0 +1,48 @@
// @vitest-environment node
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import path from "node:path";
const repoRoot = path.resolve(__dirname, "../../../../");
function readDoc(relativePath: string): string {
return readFileSync(path.join(repoRoot, relativePath), "utf8");
}
describe("native shell documentation contract", () => {
it("publishes the canonical native shell guide with required flow sections", () => {
const nativeShellGuide = readDoc("docs/native-shell.md");
expect(nativeShellGuide).toContain("# Native Shell Connection Guide");
expect(nativeShellGuide).toContain("## First-run onboarding flow");
expect(nativeShellGuide).toContain("## QR scan and manual fallback");
expect(nativeShellGuide).toContain("## Saved connection profiles");
expect(nativeShellGuide).toContain("## Desktop remote handoff behavior");
expect(nativeShellGuide).toContain("window.fusionShell");
expect(nativeShellGuide).toContain("Remote Access runbook");
});
it("keeps cross-doc links to the canonical guide and bridge API discoverable", () => {
const docsIndex = readDoc("docs/README.md");
const dashboardGuide = readDoc("docs/dashboard-guide.md");
const mobileGuide = readDoc("MOBILE.md");
const architecture = readDoc("docs/architecture.md");
expect(docsIndex).toContain("[Native Shell Connection Guide](./native-shell.md)");
expect(dashboardGuide).toContain("[Native Shell Connection Guide](./native-shell.md)");
expect(mobileGuide).toContain("[Native Shell Connection Guide](./docs/native-shell.md)");
expect(architecture).toContain("window.fusionShell");
expect(architecture).toContain("getState()");
expect(architecture).toContain("listProfiles()");
expect(architecture).toContain("saveProfile(profile)");
expect(architecture).toContain("deleteProfile(profileId)");
expect(architecture).toContain("setActiveProfile(profileId)");
expect(architecture).toContain("setDesktopMode(mode)");
expect(architecture).toContain("startQrScan()");
expect(architecture).toContain("openConnectionManager()");
expect(architecture).toContain("subscribe(listener)");
expect(architecture).toContain("getDesktopModeState()");
});
});

View File

@@ -528,14 +528,18 @@ const { totalWorkers, concurrency } = defaultTestWorkerBudget(process.env);
const isolatedHomesToCleanup = new Set(); const isolatedHomesToCleanup = new Set();
function cleanupIsolatedHomePath(homePath) {
try {
rmSync(homePath, { recursive: true, force: true });
} catch {
// Best-effort cleanup only.
}
isolatedHomesToCleanup.delete(homePath);
}
function cleanupIsolatedHomes() { function cleanupIsolatedHomes() {
for (const homePath of isolatedHomesToCleanup) { for (const homePath of isolatedHomesToCleanup) {
try { cleanupIsolatedHomePath(homePath);
rmSync(homePath, { recursive: true, force: true });
} catch {
// Best-effort cleanup only.
}
isolatedHomesToCleanup.delete(homePath);
} }
} }
@@ -552,6 +556,7 @@ process.on("SIGTERM", () => {
export function createIsolatedHomeEnv(env = process.env) { export function createIsolatedHomeEnv(env = process.env) {
const rawIsolatedHome = mkdtempSync(path.join(tmpdir(), "fusion-test-home-root-")); const rawIsolatedHome = mkdtempSync(path.join(tmpdir(), "fusion-test-home-root-"));
const isolatedHome = realpathSync(rawIsolatedHome); const isolatedHome = realpathSync(rawIsolatedHome);
isolatedHomesToCleanup.add(rawIsolatedHome);
isolatedHomesToCleanup.add(isolatedHome); isolatedHomesToCleanup.add(isolatedHome);
const nextEnv = { const nextEnv = {
@@ -613,8 +618,7 @@ export function main(argv = process.argv.slice(2)) {
const { env: isolatedHomeEnv, isolatedHome } = createIsolatedHomeEnv(fullSuiteEnv); const { env: isolatedHomeEnv, isolatedHome } = createIsolatedHomeEnv(fullSuiteEnv);
const cleanupIsolatedHome = () => { const cleanupIsolatedHome = () => {
rmSync(isolatedHome, { recursive: true, force: true }); cleanupIsolatedHomePath(isolatedHome);
isolatedHomesToCleanup.delete(isolatedHome);
}; };
try { try {