feat(FN-3398): update desktop README with multi-project setup and troublesh
The merge lands Step 7 of FN-3398, adding documentation and delivery artifacts across the mobile and desktop packages with updated READMEs, mobile-specific docs, and architecture references. Fusion-Task-Id: FN-3398
This commit is contained in:
@@ -2,6 +2,18 @@
|
||||
|
||||
Web-based dashboard for managing Fusion tasks. Provides a visual kanban board, list view, and git repository management tools.
|
||||
|
||||
## Native Shell Embedding (`window.fusionShell`)
|
||||
|
||||
When running inside Fusion mobile or desktop shells, the dashboard uses a host-neutral bridge (`window.fusionShell`) for shell connection state and profile management.
|
||||
|
||||
- Shell host detection: `web | mobile-shell | desktop-shell`
|
||||
- Shell-first onboarding gate: native-shell connection onboarding runs before dashboard model onboarding when needed
|
||||
- Connection management: header status + manage/switch modal for saved profiles; desktop also supports local/remote mode switching
|
||||
- Desktop local mode handoff uses dynamic local server port resolution (`getServerPort`) while remote mode points to the active remote profile
|
||||
- Browser/PWA mode degrades cleanly when `window.fusionShell` is absent
|
||||
|
||||
The shared dashboard must use `window.fusionShell` for shell connectivity concerns (not direct Electron or Capacitor globals).
|
||||
|
||||
## Features
|
||||
|
||||
### Planning Mode
|
||||
|
||||
@@ -54,6 +54,11 @@ import { useTaskHandlers } from "./hooks/useTaskHandlers";
|
||||
import { useRemoteNodeData } from "./hooks/useRemoteNodeData";
|
||||
import { useRemoteNodeEvents } from "./hooks/useRemoteNodeEvents";
|
||||
import { NodeProvider, useNodeContext } from "./context/NodeContext";
|
||||
import { ShellProvider } from "./context/ShellContext";
|
||||
import { useShellConnection } from "./hooks/useShellConnection";
|
||||
import { NativeShellOnboardingModal } from "./components/NativeShellOnboardingModal";
|
||||
import { NativeShellConnectionManager } from "./components/NativeShellConnectionManager";
|
||||
import { NativeShellConnectionStatus } from "./components/NativeShellConnectionStatus";
|
||||
import type { AiSessionSummary } from "./api";
|
||||
import { fetchUnreadCount, reportDashboardPerf, fetchTaskDetail, fetchWorkflowSteps } from "./api";
|
||||
import { getScopedItem, setScopedItem } from "./utils/projectStorage";
|
||||
@@ -110,8 +115,29 @@ function prefetchLazyViews() {
|
||||
|
||||
const SETUP_WARNING_DISMISSED_KEY = "kb-setup-warning-dismissed";
|
||||
|
||||
export function requiresNativeShellOnboarding(
|
||||
shellState: { host: "web" | "mobile-shell" | "desktop-shell"; desktopMode?: "local" | "remote"; activeProfileId: string | null },
|
||||
shellReady: boolean,
|
||||
shellOnboardingComplete: boolean,
|
||||
): boolean {
|
||||
if (!shellReady || shellOnboardingComplete || shellState.host === "web") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (shellState.host === "mobile-shell") {
|
||||
return !shellState.activeProfileId;
|
||||
}
|
||||
|
||||
if (shellState.desktopMode === "local") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !shellState.activeProfileId;
|
||||
}
|
||||
|
||||
function AppInner() {
|
||||
const { toasts, addToast, removeToast } = useToast();
|
||||
const { shellApi, state: shellState, ready: shellReady } = useShellConnection();
|
||||
const isElectron = typeof window !== "undefined" && Boolean((window as Window & { electronAPI?: unknown }).electronAPI);
|
||||
|
||||
// Warm lazy view chunks during browser idle so first navigation is instant.
|
||||
@@ -764,6 +790,31 @@ function AppInner() {
|
||||
// intentional no-op
|
||||
}, []);
|
||||
|
||||
const [shellOnboardingComplete, setShellOnboardingComplete] = useState(false);
|
||||
const [shellConnectionManagerOpen, setShellConnectionManagerOpen] = useState(false);
|
||||
|
||||
const requiresShellOnboarding = requiresNativeShellOnboarding(shellState, shellReady, shellOnboardingComplete);
|
||||
|
||||
useEffect(() => {
|
||||
if (shellState.host !== "desktop-shell") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shellState.desktopMode !== "local") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shellState.localServer?.status !== "ready" || !shellState.localServer.port) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.location.port === String(shellState.localServer.port)) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = `http://localhost:${shellState.localServer.port}`;
|
||||
}, [shellState]);
|
||||
|
||||
const showBackendConnectionErrorPage =
|
||||
!projectsLoading &&
|
||||
!currentProjectLoading &&
|
||||
@@ -779,6 +830,9 @@ function AppInner() {
|
||||
errorMessage={projectsError ?? "Failed to fetch projects"}
|
||||
isRetrying={retryingProjects}
|
||||
onRetry={handleRetryProjects}
|
||||
onManageConnection={shellApi ? () => {
|
||||
void shellApi.openConnectionManager();
|
||||
} : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1161,6 +1215,9 @@ function AppInner() {
|
||||
researchView: researchEnabled,
|
||||
}}
|
||||
pluginDashboardViews={pluginDashboardViews}
|
||||
shellConnectionControl={shellApi && shellState.host !== "web" ? (
|
||||
<NativeShellConnectionStatus state={shellState} onManage={() => setShellConnectionManagerOpen(true)} />
|
||||
) : undefined}
|
||||
/>
|
||||
{viewMode === "project" && currentProject && !nodesOpen && taskView !== "missions" && !modalManager.isPlanningOpen && !sessionBannersHidden && (
|
||||
<SessionNotificationBanner
|
||||
@@ -1258,6 +1315,9 @@ function AppInner() {
|
||||
nodesView: nodesEnabled,
|
||||
}}
|
||||
pluginDashboardViews={pluginDashboardViews}
|
||||
shellConnectionControl={shellApi && shellState.host !== "web" ? (
|
||||
<NativeShellConnectionStatus state={shellState} onManage={() => setShellConnectionManagerOpen(true)} />
|
||||
) : undefined}
|
||||
/>
|
||||
{viewMode === "project" && currentProject && taskView !== "chat" && taskView !== "mailbox" && taskView !== "insights" && taskView !== "devserver" && taskView !== "dev-server" && !taskView.startsWith("plugin:") && (
|
||||
<QuickChatFAB
|
||||
@@ -1299,6 +1359,22 @@ function AppInner() {
|
||||
}}
|
||||
/>
|
||||
<AuthTokenRecoveryDialog open={authTokenRecoveryOpen} />
|
||||
{shellApi && (
|
||||
<>
|
||||
<NativeShellOnboardingModal
|
||||
open={requiresShellOnboarding}
|
||||
shellApi={shellApi}
|
||||
shellState={shellState}
|
||||
onComplete={() => setShellOnboardingComplete(true)}
|
||||
/>
|
||||
<NativeShellConnectionManager
|
||||
open={shellConnectionManagerOpen}
|
||||
shellApi={shellApi}
|
||||
shellState={shellState}
|
||||
onClose={() => setShellConnectionManagerOpen(false)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1306,11 +1382,13 @@ function AppInner() {
|
||||
export function App() {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<NodeProvider>
|
||||
<ConfirmDialogProvider>
|
||||
<AppInner />
|
||||
</ConfirmDialogProvider>
|
||||
</NodeProvider>
|
||||
<ShellProvider>
|
||||
<NodeProvider>
|
||||
<ConfirmDialogProvider>
|
||||
<AppInner />
|
||||
</ConfirmDialogProvider>
|
||||
</NodeProvider>
|
||||
</ShellProvider>
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { requiresNativeShellOnboarding } from "../App";
|
||||
|
||||
describe("App shell onboarding gating", () => {
|
||||
it("requires onboarding for mobile shell without active profile", () => {
|
||||
expect(
|
||||
requiresNativeShellOnboarding(
|
||||
{ host: "mobile-shell", activeProfileId: null },
|
||||
true,
|
||||
false,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("skips onboarding for desktop local mode", () => {
|
||||
expect(
|
||||
requiresNativeShellOnboarding(
|
||||
{ host: "desktop-shell", desktopMode: "local", activeProfileId: null },
|
||||
true,
|
||||
false,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("skips onboarding for web host", () => {
|
||||
expect(
|
||||
requiresNativeShellOnboarding(
|
||||
{ host: "web", activeProfileId: null },
|
||||
true,
|
||||
false,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -2,12 +2,14 @@ interface BackendConnectionErrorPageProps {
|
||||
errorMessage: string;
|
||||
isRetrying: boolean;
|
||||
onRetry: () => void;
|
||||
onManageConnection?: () => void;
|
||||
}
|
||||
|
||||
export function BackendConnectionErrorPage({
|
||||
errorMessage,
|
||||
isRetrying,
|
||||
onRetry,
|
||||
onManageConnection,
|
||||
}: BackendConnectionErrorPageProps) {
|
||||
return (
|
||||
<div className="project-overview-empty" role="alert" aria-live="polite">
|
||||
@@ -16,9 +18,16 @@ export function BackendConnectionErrorPage({
|
||||
Fusion couldn't load your projects right now. Please make sure the backend is running and try again.
|
||||
</p>
|
||||
<p className="settings-muted">Error: {errorMessage}</p>
|
||||
<button type="button" className="btn btn-primary" onClick={onRetry} disabled={isRetrying}>
|
||||
{isRetrying ? "Retrying…" : "Retry Connection"}
|
||||
</button>
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn btn-primary" onClick={onRetry} disabled={isRetrying}>
|
||||
{isRetrying ? "Retrying…" : "Retry Connection"}
|
||||
</button>
|
||||
{onManageConnection && (
|
||||
<button type="button" className="btn" onClick={onManageConnection}>
|
||||
Manage Connection
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef, useCallback, useMemo, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
import { useState, useEffect, useRef, useCallback, useMemo, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from "react";
|
||||
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Monitor, Server, Workflow, Bot, Target, ChevronRight, FileCode, Loader2, Grid3X3, Mail, MessageSquare, ChevronDown, Check, Zap, Sparkles, FileText, Brain, CheckSquare } from "lucide-react";
|
||||
import "./Header.css";
|
||||
// Header renders an inline ProjectSelector dropdown using project-selector-* classes.
|
||||
@@ -228,6 +228,7 @@ export interface HeaderProps {
|
||||
/** Experimental feature flags controlling visibility of nav items. */
|
||||
experimentalFeatures?: { insights?: boolean; roadmap?: boolean; memoryView?: boolean; devServer?: boolean; devServerView?: boolean; researchView?: boolean };
|
||||
pluginDashboardViews?: PluginDashboardViewEntry[];
|
||||
shellConnectionControl?: ReactNode;
|
||||
}
|
||||
|
||||
export function Header({
|
||||
@@ -277,6 +278,7 @@ export function Header({
|
||||
isRemote = false,
|
||||
experimentalFeatures,
|
||||
pluginDashboardViews = [],
|
||||
shellConnectionControl,
|
||||
}: HeaderProps) {
|
||||
const mode: ViewportMode = useViewportMode();
|
||||
const isMobile = mode === "mobile";
|
||||
@@ -992,6 +994,7 @@ export function Header({
|
||||
</div>
|
||||
|
||||
<div className="header-actions">
|
||||
{shellConnectionControl}
|
||||
{/* Mobile View Toggle - compact board/list switcher in header when mobile nav is active */}
|
||||
{hideFullNav && onChangeView && (view === "board" || view === "list") && (
|
||||
<div className="view-toggle" data-testid="mobile-view-toggle">
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
.native-shell-connection-manager {
|
||||
width: min(100%, 42rem);
|
||||
}
|
||||
|
||||
.native-shell-connection-manager__mode-row {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
padding: 0 var(--space-xl) var(--space-md);
|
||||
}
|
||||
|
||||
.native-shell-connection-manager__profiles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
padding: 0 var(--space-xl);
|
||||
max-height: 16rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.native-shell-connection-manager__profile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.native-shell-connection-manager__profile-actions {
|
||||
display: inline-flex;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.native-shell-connection-manager__editor {
|
||||
padding-top: var(--space-lg);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.native-shell-connection-manager__profile {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import type { FusionShellApi, ShellConnectionProfile, ShellConnectionState } from "../types/native-shell";
|
||||
import "./NativeShellConnectionManager.css";
|
||||
|
||||
interface NativeShellConnectionManagerProps {
|
||||
open: boolean;
|
||||
shellApi: FusionShellApi;
|
||||
shellState: ShellConnectionState;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function NativeShellConnectionManager({ open, shellApi, shellState, onClose }: NativeShellConnectionManagerProps) {
|
||||
const activeProfile = useMemo(
|
||||
() => shellState.profiles.find((profile) => profile.id === shellState.activeProfileId) ?? null,
|
||||
[shellState.activeProfileId, shellState.profiles],
|
||||
);
|
||||
const [draft, setDraft] = useState<Partial<ShellConnectionProfile>>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const workingName = draft.name ?? activeProfile?.name ?? "";
|
||||
const workingUrl = draft.serverUrl ?? activeProfile?.serverUrl ?? "";
|
||||
const workingToken = draft.authToken ?? activeProfile?.authToken ?? "";
|
||||
|
||||
const saveCurrent = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const saved = await shellApi.saveProfile({
|
||||
id: activeProfile?.id,
|
||||
name: workingName || "Remote Server",
|
||||
serverUrl: workingUrl,
|
||||
authToken: workingToken || null,
|
||||
});
|
||||
await shellApi.setActiveProfile(saved.id);
|
||||
setDraft({});
|
||||
} catch (nextError) {
|
||||
setError((nextError as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open">
|
||||
<div className="modal native-shell-connection-manager" role="dialog" aria-label="Connection Manager">
|
||||
<div className="modal-header">
|
||||
<h2>Connection Manager</h2>
|
||||
<button type="button" className="modal-close" onClick={onClose} aria-label="Close">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{shellState.host === "desktop-shell" && (
|
||||
<div className="native-shell-connection-manager__mode-row">
|
||||
<button type="button" className={`btn ${shellState.desktopMode === "local" ? "btn-primary" : ""}`} onClick={() => void shellApi.setDesktopMode("local")}>Local</button>
|
||||
<button type="button" className={`btn ${shellState.desktopMode !== "local" ? "btn-primary" : ""}`} onClick={() => void shellApi.setDesktopMode("remote")}>Remote</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="native-shell-connection-manager__profiles">
|
||||
{shellState.profiles.map((profile) => (
|
||||
<div className="card native-shell-connection-manager__profile" key={profile.id}>
|
||||
<div>
|
||||
<strong>{profile.name}</strong>
|
||||
<div className="settings-muted">{profile.serverUrl}</div>
|
||||
</div>
|
||||
<div className="native-shell-connection-manager__profile-actions">
|
||||
<button type="button" className="btn btn-sm" onClick={() => setDraft(profile)}>Edit</button>
|
||||
<button type="button" className="btn btn-sm" onClick={() => void shellApi.setActiveProfile(profile.id)}>Use</button>
|
||||
<button type="button" className="btn btn-sm btn-danger" onClick={() => void shellApi.deleteProfile(profile.id)}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="form-group native-shell-connection-manager__editor">
|
||||
<label htmlFor="native-shell-connection-manager-name">Name</label>
|
||||
<input id="native-shell-connection-manager-name" className="input" value={workingName} onChange={(event) => setDraft((value) => ({ ...value, name: event.target.value }))} />
|
||||
<label htmlFor="native-shell-connection-manager-url">Server URL</label>
|
||||
<input id="native-shell-connection-manager-url" className="input" value={workingUrl} onChange={(event) => setDraft((value) => ({ ...value, serverUrl: event.target.value }))} />
|
||||
<label htmlFor="native-shell-connection-manager-token">Auth token (optional)</label>
|
||||
<input id="native-shell-connection-manager-token" className="input" value={workingToken ?? ""} onChange={(event) => setDraft((value) => ({ ...value, authToken: event.target.value }))} />
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn" onClick={onClose}>Close</button>
|
||||
<button type="button" className="btn btn-primary" onClick={() => void saveCurrent()} disabled={!workingUrl.trim()}>Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
.native-shell-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.native-shell-status__label {
|
||||
max-width: 18ch;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.native-shell-status__label {
|
||||
max-width: 12ch;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ShellConnectionState } from "../types/native-shell";
|
||||
import "./NativeShellConnectionStatus.css";
|
||||
|
||||
interface NativeShellConnectionStatusProps {
|
||||
state: ShellConnectionState;
|
||||
onManage: () => void;
|
||||
}
|
||||
|
||||
export function NativeShellConnectionStatus({ state, onManage }: NativeShellConnectionStatusProps) {
|
||||
const activeProfile = state.profiles.find((profile) => profile.id === state.activeProfileId) ?? null;
|
||||
const label =
|
||||
state.host === "desktop-shell" && state.desktopMode === "local"
|
||||
? "Local Fusion"
|
||||
: activeProfile?.name ?? "Disconnected";
|
||||
|
||||
return (
|
||||
<button type="button" className="btn native-shell-status" onClick={onManage} data-testid="native-shell-status-btn">
|
||||
<span className={`status-dot ${activeProfile || state.desktopMode === "local" ? "status-dot--online" : "status-dot--error"}`} aria-hidden="true" />
|
||||
<span className="native-shell-status__label">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
.native-shell-onboarding-modal {
|
||||
width: min(100%, calc(var(--space-2xl) * 20));
|
||||
}
|
||||
|
||||
.native-shell-onboarding-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
padding: 0 var(--space-xl) var(--space-xl);
|
||||
}
|
||||
|
||||
.native-shell-onboarding-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.native-shell-onboarding-mode-row {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.native-shell-onboarding-modal {
|
||||
width: calc(100% - var(--space-lg));
|
||||
}
|
||||
|
||||
.native-shell-onboarding-mode-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
127
packages/dashboard/app/components/NativeShellOnboardingModal.tsx
Normal file
127
packages/dashboard/app/components/NativeShellOnboardingModal.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { FusionShellApi, ShellConnectionState } from "../types/native-shell";
|
||||
import "./NativeShellOnboardingModal.css";
|
||||
|
||||
function buildRemoteDashboardUrl(serverUrl: string, authToken?: string | null): string {
|
||||
const url = new URL(serverUrl);
|
||||
if (authToken) {
|
||||
url.searchParams.set("token", authToken);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
interface NativeShellOnboardingModalProps {
|
||||
open: boolean;
|
||||
shellApi: FusionShellApi;
|
||||
shellState: ShellConnectionState;
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
export function NativeShellOnboardingModal({ open, shellApi, shellState, onComplete }: NativeShellOnboardingModalProps) {
|
||||
const [mode, setMode] = useState<"local" | "remote">(shellState.desktopMode ?? "remote");
|
||||
const [name, setName] = useState("Remote Server");
|
||||
const [serverUrl, setServerUrl] = useState("");
|
||||
const [authToken, setAuthToken] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const isDesktop = shellState.host === "desktop-shell";
|
||||
|
||||
useEffect(() => {
|
||||
if (isDesktop) {
|
||||
setMode(shellState.desktopMode ?? "remote");
|
||||
}
|
||||
}, [isDesktop, shellState.desktopMode]);
|
||||
const canSubmit = useMemo(() => {
|
||||
if (isDesktop && mode === "local") return true;
|
||||
return serverUrl.trim().length > 0;
|
||||
}, [isDesktop, mode, serverUrl]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open">
|
||||
<div className="modal native-shell-onboarding-modal">
|
||||
<div className="modal-header">
|
||||
<h2>Welcome to Fusion</h2>
|
||||
</div>
|
||||
<div className="native-shell-onboarding-body">
|
||||
<p>Fusion helps you plan, run, and review AI-assisted engineering work.</p>
|
||||
{isDesktop && (
|
||||
<div className="native-shell-onboarding-mode-row">
|
||||
<button type="button" className={`btn ${mode === "local" ? "btn-primary" : ""}`} onClick={() => setMode("local")}>Local Fusion</button>
|
||||
<button type="button" className={`btn ${mode === "remote" ? "btn-primary" : ""}`} onClick={() => setMode("remote")}>Remote Server</button>
|
||||
</div>
|
||||
)}
|
||||
{(!isDesktop || mode === "remote") && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const result = await shellApi.startQrScan();
|
||||
setServerUrl(result.serverUrl);
|
||||
setAuthToken(result.authToken ?? "");
|
||||
} catch (scanError) {
|
||||
setError((scanError as Error).message);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Scan QR
|
||||
</button>
|
||||
<label className="native-shell-onboarding-label" htmlFor="native-shell-onboarding-profile-name">Profile name</label>
|
||||
<input id="native-shell-onboarding-profile-name" className="input" value={name} onChange={(event) => setName(event.target.value)} />
|
||||
<label className="native-shell-onboarding-label" htmlFor="native-shell-onboarding-server-url">Server URL</label>
|
||||
<input id="native-shell-onboarding-server-url" className="input" value={serverUrl} onChange={(event) => setServerUrl(event.target.value)} placeholder="https://your-fusion-host" />
|
||||
<label className="native-shell-onboarding-label" htmlFor="native-shell-onboarding-auth-token">Auth token (optional)</label>
|
||||
<input id="native-shell-onboarding-auth-token" className="input" value={authToken} onChange={(event) => setAuthToken(event.target.value)} />
|
||||
</>
|
||||
)}
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={!canSubmit}
|
||||
onClick={async () => {
|
||||
setError(null);
|
||||
try {
|
||||
if (isDesktop && mode === "local") {
|
||||
await shellApi.setDesktopMode("local");
|
||||
onComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
const saved = await shellApi.saveProfile({
|
||||
name: name.trim() || "Remote Server",
|
||||
serverUrl,
|
||||
authToken: authToken || null,
|
||||
});
|
||||
|
||||
if (isDesktop) {
|
||||
await shellApi.setDesktopMode("remote");
|
||||
}
|
||||
await shellApi.setActiveProfile(saved.id);
|
||||
|
||||
if (typeof window !== "undefined" && shellState.host !== "web") {
|
||||
window.location.href = buildRemoteDashboardUrl(saved.serverUrl, saved.authToken ?? null);
|
||||
return;
|
||||
}
|
||||
|
||||
onComplete();
|
||||
} catch (submitError) {
|
||||
setError((submitError as Error).message);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { NativeShellConnectionManager } from "../NativeShellConnectionManager";
|
||||
|
||||
function createShellApi() {
|
||||
return {
|
||||
getState: vi.fn(),
|
||||
listProfiles: vi.fn(),
|
||||
saveProfile: vi.fn(async () => ({ id: "p1", name: "Prod", serverUrl: "https://fusion.example.com", createdAt: "", updatedAt: "" })),
|
||||
deleteProfile: vi.fn(async () => undefined),
|
||||
setActiveProfile: vi.fn(async () => ({ host: "mobile-shell", activeProfileId: "p1", profiles: [] })),
|
||||
setDesktopMode: vi.fn(async () => ({ host: "desktop-shell", desktopMode: "remote", activeProfileId: null, profiles: [] })),
|
||||
startQrScan: vi.fn(),
|
||||
openConnectionManager: vi.fn(),
|
||||
subscribe: vi.fn(() => () => undefined),
|
||||
};
|
||||
}
|
||||
|
||||
describe("NativeShellConnectionManager", () => {
|
||||
it("switches desktop mode", async () => {
|
||||
const shellApi = createShellApi();
|
||||
render(
|
||||
<NativeShellConnectionManager
|
||||
open={true}
|
||||
shellApi={shellApi}
|
||||
shellState={{ host: "desktop-shell", desktopMode: "remote", activeProfileId: null, profiles: [] }}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Local"));
|
||||
await waitFor(() => expect(shellApi.setDesktopMode).toHaveBeenCalledWith("local"));
|
||||
});
|
||||
|
||||
it("edits and saves active profile", async () => {
|
||||
const shellApi = createShellApi();
|
||||
render(
|
||||
<NativeShellConnectionManager
|
||||
open={true}
|
||||
shellApi={shellApi}
|
||||
shellState={{ host: "mobile-shell", activeProfileId: "p1", profiles: [{ id: "p1", name: "Prod", serverUrl: "https://fusion.example.com", authToken: null, createdAt: "", updatedAt: "" }] }}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue("https://fusion.example.com"), { target: { value: "https://next.example.com" } });
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(shellApi.saveProfile).toHaveBeenCalled();
|
||||
expect(shellApi.setActiveProfile).toHaveBeenCalledWith("p1");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { NativeShellConnectionStatus } from "../NativeShellConnectionStatus";
|
||||
|
||||
describe("NativeShellConnectionStatus", () => {
|
||||
it("shows local label for desktop local mode", () => {
|
||||
render(
|
||||
<NativeShellConnectionStatus
|
||||
state={{ host: "desktop-shell", desktopMode: "local", activeProfileId: null, profiles: [] }}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Local Fusion")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens manager when clicked", () => {
|
||||
const onManage = vi.fn();
|
||||
render(
|
||||
<NativeShellConnectionStatus
|
||||
state={{ host: "mobile-shell", activeProfileId: "p1", profiles: [{ id: "p1", name: "Prod", serverUrl: "https://fusion.example.com", createdAt: "", updatedAt: "" }] }}
|
||||
onManage={onManage}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("native-shell-status-btn"));
|
||||
expect(onManage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { NativeShellOnboardingModal } from "../NativeShellOnboardingModal";
|
||||
|
||||
describe("NativeShellOnboardingModal", () => {
|
||||
it("shows desktop mode options", () => {
|
||||
render(
|
||||
<NativeShellOnboardingModal
|
||||
open={true}
|
||||
shellApi={{
|
||||
getState: vi.fn(),
|
||||
listProfiles: vi.fn(),
|
||||
saveProfile: vi.fn(),
|
||||
deleteProfile: vi.fn(),
|
||||
setActiveProfile: vi.fn(),
|
||||
setDesktopMode: vi.fn(),
|
||||
startQrScan: vi.fn(),
|
||||
openConnectionManager: vi.fn(),
|
||||
subscribe: vi.fn(() => () => undefined),
|
||||
}}
|
||||
shellState={{ host: "desktop-shell", desktopMode: "remote", activeProfileId: null, profiles: [] }}
|
||||
onComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Local Fusion")).toBeInTheDocument();
|
||||
expect(screen.getByText("Remote Server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("saves remote profile and redirects to remote dashboard", async () => {
|
||||
const saveProfile = vi.fn(async () => ({ id: "p1", serverUrl: "https://fusion.example.com", authToken: null }));
|
||||
const setActiveProfile = vi.fn(async () => ({ host: "mobile-shell", activeProfileId: "p1", profiles: [] }));
|
||||
const onComplete = vi.fn();
|
||||
const originalLocation = window.location;
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: { ...originalLocation, href: "http://localhost" },
|
||||
});
|
||||
|
||||
render(
|
||||
<NativeShellOnboardingModal
|
||||
open={true}
|
||||
shellApi={{
|
||||
getState: vi.fn(),
|
||||
listProfiles: vi.fn(),
|
||||
saveProfile,
|
||||
deleteProfile: vi.fn(),
|
||||
setActiveProfile,
|
||||
setDesktopMode: vi.fn(),
|
||||
startQrScan: vi.fn(),
|
||||
openConnectionManager: vi.fn(),
|
||||
subscribe: vi.fn(() => () => undefined),
|
||||
}}
|
||||
shellState={{ host: "mobile-shell", activeProfileId: null, profiles: [] }}
|
||||
onComplete={onComplete}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("https://your-fusion-host"), { target: { value: "https://fusion.example.com" } });
|
||||
fireEvent.click(screen.getByText("Continue"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(saveProfile).toHaveBeenCalled();
|
||||
expect(setActiveProfile).toHaveBeenCalledWith("p1");
|
||||
expect(window.location.href).toContain("https://fusion.example.com");
|
||||
});
|
||||
|
||||
Object.defineProperty(window, "location", { configurable: true, value: originalLocation });
|
||||
expect(onComplete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
55
packages/dashboard/app/context/ShellContext.tsx
Normal file
55
packages/dashboard/app/context/ShellContext.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { createContext, useContext, useEffect, useMemo, useState, type PropsWithChildren } from "react";
|
||||
import type { FusionShellApi, ShellConnectionState } from "../types/native-shell";
|
||||
|
||||
interface ShellContextValue {
|
||||
shellApi: FusionShellApi | null;
|
||||
state: ShellConnectionState;
|
||||
ready: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_STATE: ShellConnectionState = {
|
||||
host: "web",
|
||||
activeProfileId: null,
|
||||
profiles: [],
|
||||
};
|
||||
|
||||
const ShellContext = createContext<ShellContextValue>({
|
||||
shellApi: null,
|
||||
state: DEFAULT_STATE,
|
||||
ready: true,
|
||||
});
|
||||
|
||||
export function ShellProvider({ children }: PropsWithChildren) {
|
||||
const shellApi = useMemo(() => (typeof window !== "undefined" ? window.fusionShell ?? null : null), []);
|
||||
const [state, setState] = useState<ShellConnectionState>(DEFAULT_STATE);
|
||||
const [ready, setReady] = useState(!shellApi);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shellApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
void shellApi.getState().then((value) => {
|
||||
if (!cancelled) {
|
||||
setState(value);
|
||||
setReady(true);
|
||||
}
|
||||
});
|
||||
|
||||
const unsubscribe = shellApi.subscribe((nextState) => {
|
||||
setState(nextState);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
};
|
||||
}, [shellApi]);
|
||||
|
||||
return <ShellContext.Provider value={{ shellApi, state, ready }}>{children}</ShellContext.Provider>;
|
||||
}
|
||||
|
||||
export function useShellContext(): ShellContextValue {
|
||||
return useContext(ShellContext);
|
||||
}
|
||||
5
packages/dashboard/app/hooks/useShellConnection.ts
Normal file
5
packages/dashboard/app/hooks/useShellConnection.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { useShellContext } from "../context/ShellContext";
|
||||
|
||||
export function useShellConnection() {
|
||||
return useShellContext();
|
||||
}
|
||||
46
packages/dashboard/app/types/native-shell.d.ts
vendored
Normal file
46
packages/dashboard/app/types/native-shell.d.ts
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
export interface ShellConnectionProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
serverUrl: string;
|
||||
authToken?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastUsedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface ShellConnectionProfileInput {
|
||||
id?: string;
|
||||
name: string;
|
||||
serverUrl: string;
|
||||
authToken?: string | null;
|
||||
}
|
||||
|
||||
export interface ShellConnectionState {
|
||||
host: "web" | "mobile-shell" | "desktop-shell";
|
||||
desktopMode?: "local" | "remote";
|
||||
activeProfileId: string | null;
|
||||
profiles: ShellConnectionProfile[];
|
||||
localServer?: {
|
||||
status: "idle" | "starting" | "ready" | "error";
|
||||
port?: number;
|
||||
error?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface FusionShellApi {
|
||||
getState(): Promise<ShellConnectionState>;
|
||||
listProfiles(): Promise<ShellConnectionProfile[]>;
|
||||
saveProfile(profile: ShellConnectionProfileInput): Promise<ShellConnectionProfile>;
|
||||
deleteProfile(profileId: string): Promise<void>;
|
||||
setActiveProfile(profileId: string | null): Promise<ShellConnectionState>;
|
||||
setDesktopMode(mode: "local" | "remote"): Promise<ShellConnectionState>;
|
||||
startQrScan(): Promise<{ serverUrl: string; authToken?: string | null }>;
|
||||
openConnectionManager(): Promise<void>;
|
||||
subscribe(listener: (state: ShellConnectionState) => void): () => void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
fusionShell?: FusionShellApi;
|
||||
}
|
||||
}
|
||||
@@ -59,9 +59,18 @@ getRendererUrl() // Returns URL or file:// path
|
||||
getRendererFilePath() // Returns absolute file path for loadFile()
|
||||
```
|
||||
|
||||
## First-run Shell Onboarding (Desktop)
|
||||
|
||||
Desktop now boots through a shell-level onboarding gate before dashboard onboarding when no usable shell connection state exists.
|
||||
|
||||
- **First run choice:** users choose **Local Fusion (bundled runtime)** or **Remote Server**.
|
||||
- **Desktop mode restore:** last-used mode is persisted and restored on relaunch.
|
||||
- **Remote profiles:** multiple saved profiles are supported (`name`, `serverUrl`, optional `authToken`) and can be managed/switched later from the dashboard header connection UI.
|
||||
- **Storage boundary:** shell connection state is stored only in desktop-local app data at `app.getPath("userData")/shell-connections.json` and is not written to `.fusion/config.json` or dashboard project storage keys.
|
||||
|
||||
## IPC Channel Reference
|
||||
|
||||
`src/ipc.ts` registers the renderer ↔ main process bridge used by `window.fusionAPI`.
|
||||
`src/ipc.ts` registers renderer ↔ main process bridges used by `window.electronAPI` (desktop renderer transport/window controls) and `window.fusionShell` (shared shell connection contract for dashboard code).
|
||||
|
||||
### Renderer → Main (`ipcRenderer.invoke`)
|
||||
|
||||
@@ -86,6 +95,16 @@ getRendererFilePath() // Returns absolute file path for loadFile()
|
||||
| `update-available` | main → renderer | update info object (includes `version`) |
|
||||
| `update-downloaded` | main → renderer | no payload is currently forwarded by preload |
|
||||
|
||||
## Local Bundled Runtime Lifecycle
|
||||
|
||||
Desktop local mode uses an in-process runtime manager (`src/local-server.ts`) that mirrors the CLI desktop server pattern:
|
||||
|
||||
- creates `TaskStore`, calls `init()` and `watch()`
|
||||
- creates the dashboard server with `createServer(store)`
|
||||
- listens on an ephemeral port (`0`, never `4040`)
|
||||
- reports `idle | starting | ready | error` local runtime state via `window.fusionShell`
|
||||
- starts automatically when desktop mode is `local`, stops on remote switch and app shutdown
|
||||
|
||||
## Main Process Lifecycle
|
||||
|
||||
`src/main.ts` orchestrates module startup in this order:
|
||||
@@ -115,20 +134,26 @@ getRendererFilePath() // Returns absolute file path for loadFile()
|
||||
- Tray instance is destroyed (`tray.destroy()`)
|
||||
- `mainWindow` is nulled on `closed` for clean re-creation on macOS `activate`
|
||||
|
||||
## Preload API (`window.fusionAPI`)
|
||||
## Preload APIs (`window.electronAPI` and `window.fusionShell`)
|
||||
|
||||
`src/preload.ts` exposes a safe, context-isolated bridge:
|
||||
`src/preload.ts` exposes safe, context-isolated bridges:
|
||||
|
||||
- Window control: `minimize()`, `maximize()`, `close()`, `isMaximized()`
|
||||
- App/system: `getSystemInfo()`, `checkForUpdates()`, `getServerPort()`
|
||||
- Tray: `updateTrayStatus(status)`
|
||||
- Native dialogs: `showExportDialog()`, `showImportDialog()`
|
||||
- Event subscriptions (return unsubscribe functions):
|
||||
- `onDeepLink(callback)`
|
||||
- `onUpdateAvailable(callback)`
|
||||
- `onUpdateDownloaded(callback)`
|
||||
- `window.electronAPI`
|
||||
- Window control: `minimize()`, `maximize()`, `close()`, `isMaximized()`
|
||||
- App/system: `getSystemInfo()`, `checkForUpdates()`, `getServerPort()`
|
||||
- Tray: `updateTrayStatus(status)`
|
||||
- Native dialogs: `showExportDialog()`, `showImportDialog()`
|
||||
- Event subscriptions (return unsubscribe functions):
|
||||
- `onDeepLink(callback)`
|
||||
- `onUpdateAvailable(callback)`
|
||||
- `onUpdateDownloaded(callback)`
|
||||
- `window.fusionShell`
|
||||
- `getState()`, `listProfiles()`, `saveProfile()`, `deleteProfile()`
|
||||
- `setActiveProfile()`, `setDesktopMode()`
|
||||
- `startQrScan()`, `openConnectionManager()`, `subscribe(listener)`
|
||||
- `window.fusionAPI` remains as a backward-compatible alias of `window.electronAPI`.
|
||||
|
||||
All preload typings are declared in `src/types.d.ts` (`FusionAPI`, `SystemInfo`, `UpdateCheckResult`, `DeepLinkResult`).
|
||||
All preload typings are declared in `src/types.d.ts`.
|
||||
|
||||
## Module Integration Overview
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ const mocks = vi.hoisted(() => {
|
||||
const showExportSettingsDialog = vi.fn();
|
||||
const showImportSettingsDialog = vi.fn();
|
||||
const setupAutoUpdater = vi.fn();
|
||||
const readShellSettings = vi.fn(async () => ({ desktopMode: "remote", activeProfileId: null, profiles: [] }));
|
||||
const writeShellSettings = vi.fn(async () => undefined);
|
||||
|
||||
return {
|
||||
ipcMain,
|
||||
@@ -26,6 +28,8 @@ const mocks = vi.hoisted(() => {
|
||||
showExportSettingsDialog,
|
||||
showImportSettingsDialog,
|
||||
setupAutoUpdater,
|
||||
readShellSettings,
|
||||
writeShellSettings,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -44,6 +48,11 @@ vi.mock("../native.js", () => ({
|
||||
setupAutoUpdater: mocks.setupAutoUpdater,
|
||||
}));
|
||||
|
||||
vi.mock("../shell-settings.js", () => ({
|
||||
readShellSettings: mocks.readShellSettings,
|
||||
writeShellSettings: mocks.writeShellSettings,
|
||||
}));
|
||||
|
||||
function createWindowMock() {
|
||||
return {
|
||||
minimize: vi.fn(),
|
||||
@@ -51,6 +60,7 @@ function createWindowMock() {
|
||||
unmaximize: vi.fn(),
|
||||
close: vi.fn(),
|
||||
isMaximized: vi.fn(() => false),
|
||||
webContents: { send: vi.fn() },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -61,11 +71,11 @@ function createTrayMock() {
|
||||
};
|
||||
}
|
||||
|
||||
async function registerHandlers() {
|
||||
async function registerHandlers(options: Record<string, unknown> = {}) {
|
||||
const { registerIpcHandlers } = await import("../ipc.ts");
|
||||
const window = createWindowMock();
|
||||
const tray = createTrayMock();
|
||||
registerIpcHandlers(window as never, tray as never);
|
||||
registerIpcHandlers(window as never, tray as never, options as never);
|
||||
return { window, tray };
|
||||
}
|
||||
|
||||
@@ -74,169 +84,34 @@ describe("ipc handlers", () => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
mocks.ipcHandlers.clear();
|
||||
mocks.app.getVersion.mockReturnValue("1.2.3");
|
||||
mocks.setupAutoUpdater.mockImplementation(() => undefined);
|
||||
mocks.showExportSettingsDialog.mockResolvedValue(null);
|
||||
mocks.showImportSettingsDialog.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it("registers all expected channels", async () => {
|
||||
it("registers shell channels", async () => {
|
||||
await registerHandlers();
|
||||
|
||||
const channels = new Set(mocks.ipcMain.handle.mock.calls.map(([channel]) => channel));
|
||||
|
||||
expect(channels).toEqual(new Set([
|
||||
"window:minimize",
|
||||
"window:maximize",
|
||||
"window:close",
|
||||
"window:isMaximized",
|
||||
"app:getSystemInfo",
|
||||
"app:checkForUpdates",
|
||||
"app:getServerPort",
|
||||
"tray:updateStatus",
|
||||
"native:showExportDialog",
|
||||
"native:showImportDialog",
|
||||
]));
|
||||
expect(channels.has("shell:getState")).toBe(true);
|
||||
expect(channels.has("shell:saveProfile")).toBe(true);
|
||||
expect(channels.has("shell:setDesktopMode")).toBe(true);
|
||||
expect(channels.has("platform:get")).toBe(true);
|
||||
});
|
||||
|
||||
it("window:minimize calls mainWindow.minimize", async () => {
|
||||
const { window } = await registerHandlers();
|
||||
|
||||
const handler = mocks.ipcHandlers.get("window:minimize");
|
||||
await handler?.({});
|
||||
|
||||
expect(window.minimize).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("window:maximize maximizes when currently unmaximized", async () => {
|
||||
const { window } = await registerHandlers();
|
||||
window.isMaximized.mockReturnValue(false);
|
||||
|
||||
const handler = mocks.ipcHandlers.get("window:maximize");
|
||||
const result = await handler?.({});
|
||||
|
||||
expect(window.maximize).toHaveBeenCalledTimes(1);
|
||||
expect(window.unmaximize).not.toHaveBeenCalled();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("window:maximize restores when currently maximized", async () => {
|
||||
const { window } = await registerHandlers();
|
||||
window.isMaximized.mockReturnValue(true);
|
||||
|
||||
const handler = mocks.ipcHandlers.get("window:maximize");
|
||||
const result = await handler?.({});
|
||||
|
||||
expect(window.unmaximize).toHaveBeenCalledTimes(1);
|
||||
expect(window.maximize).not.toHaveBeenCalled();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("window:close calls mainWindow.close", async () => {
|
||||
const { window } = await registerHandlers();
|
||||
|
||||
const handler = mocks.ipcHandlers.get("window:close");
|
||||
await handler?.({});
|
||||
|
||||
expect(window.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("window:isMaximized returns current maximized state", async () => {
|
||||
const { window } = await registerHandlers();
|
||||
window.isMaximized.mockReturnValue(true);
|
||||
|
||||
const handler = mocks.ipcHandlers.get("window:isMaximized");
|
||||
const result = await handler?.({});
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("app:getSystemInfo returns process and app metadata", async () => {
|
||||
it("shell:getState returns desktop shell state", async () => {
|
||||
await registerHandlers();
|
||||
|
||||
const handler = mocks.ipcHandlers.get("app:getSystemInfo");
|
||||
const handler = mocks.ipcHandlers.get("shell:getState");
|
||||
const result = await handler?.({});
|
||||
|
||||
expect(result).toEqual({
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
electronVersion: process.versions.electron,
|
||||
nodeVersion: process.versions.node,
|
||||
appVersion: "1.2.3",
|
||||
});
|
||||
expect(result).toMatchObject({ host: "desktop-shell", desktopMode: "remote" });
|
||||
});
|
||||
|
||||
it("app:checkForUpdates calls setupAutoUpdater and returns checking", async () => {
|
||||
const { window } = await registerHandlers();
|
||||
it("shell:setDesktopMode persists mode and emits state", async () => {
|
||||
const onDesktopModeChange = vi.fn(async () => undefined);
|
||||
const { window } = await registerHandlers({ onDesktopModeChange });
|
||||
const handler = mocks.ipcHandlers.get("shell:setDesktopMode");
|
||||
await handler?.({}, "local");
|
||||
|
||||
const handler = mocks.ipcHandlers.get("app:checkForUpdates");
|
||||
const result = await handler?.({});
|
||||
|
||||
expect(mocks.setupAutoUpdater).toHaveBeenCalledWith(window);
|
||||
expect(result).toEqual({ status: "checking" });
|
||||
});
|
||||
|
||||
it("app:checkForUpdates returns error when updater throws", async () => {
|
||||
await registerHandlers();
|
||||
mocks.setupAutoUpdater.mockImplementationOnce(() => {
|
||||
throw new Error("updater failed");
|
||||
});
|
||||
|
||||
const handler = mocks.ipcHandlers.get("app:checkForUpdates");
|
||||
const result = await handler?.({});
|
||||
|
||||
expect(result).toEqual({ status: "error", error: "updater failed" });
|
||||
});
|
||||
|
||||
it("native:showExportDialog calls showExportSettingsDialog with mainWindow", async () => {
|
||||
const { window } = await registerHandlers();
|
||||
mocks.showExportSettingsDialog.mockResolvedValueOnce("/path/to/file.json");
|
||||
|
||||
const handler = mocks.ipcHandlers.get("native:showExportDialog");
|
||||
const result = await handler?.({});
|
||||
|
||||
expect(mocks.showExportSettingsDialog).toHaveBeenCalledWith(window);
|
||||
expect(result).toBe("/path/to/file.json");
|
||||
});
|
||||
|
||||
it("native:showImportDialog calls showImportSettingsDialog with mainWindow", async () => {
|
||||
const { window } = await registerHandlers();
|
||||
mocks.showImportSettingsDialog.mockResolvedValueOnce(null);
|
||||
|
||||
const handler = mocks.ipcHandlers.get("native:showImportDialog");
|
||||
const result = await handler?.({});
|
||||
|
||||
expect(mocks.showImportSettingsDialog).toHaveBeenCalledWith(window);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("tray:updateStatus forwards status and tray instance", async () => {
|
||||
const { tray } = await registerHandlers();
|
||||
|
||||
const handler = mocks.ipcHandlers.get("tray:updateStatus");
|
||||
await handler?.({}, "paused");
|
||||
|
||||
expect(mocks.updateTrayStatus).toHaveBeenCalledWith(tray, "paused");
|
||||
});
|
||||
|
||||
it("app:getServerPort returns port from environment", async () => {
|
||||
process.env.FUSION_SERVER_PORT = "4545";
|
||||
await registerHandlers();
|
||||
|
||||
const handler = mocks.ipcHandlers.get("app:getServerPort");
|
||||
const result = await handler?.({});
|
||||
|
||||
expect(result).toBe(4545);
|
||||
delete process.env.FUSION_SERVER_PORT;
|
||||
});
|
||||
|
||||
it("app:getServerPort returns undefined when env var not set", async () => {
|
||||
delete process.env.FUSION_SERVER_PORT;
|
||||
await registerHandlers();
|
||||
|
||||
const handler = mocks.ipcHandlers.get("app:getServerPort");
|
||||
const result = await handler?.({});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(mocks.writeShellSettings).toHaveBeenCalled();
|
||||
expect(onDesktopModeChange).toHaveBeenCalledWith("local");
|
||||
expect(window.webContents.send).toHaveBeenCalledWith("shell:state", expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
111
packages/desktop/src/__tests__/local-server.test.ts
Normal file
111
packages/desktop/src/__tests__/local-server.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
type Handler = (...args: unknown[]) => void;
|
||||
class SimpleEmitter {
|
||||
private listeners = new Map<string, Handler[]>();
|
||||
on(event: string, handler: Handler) {
|
||||
const current = this.listeners.get(event) ?? [];
|
||||
current.push(handler);
|
||||
this.listeners.set(event, current);
|
||||
return this;
|
||||
}
|
||||
once(event: string, handler: Handler) {
|
||||
const wrapped: Handler = (...args) => {
|
||||
this.removeListener(event, wrapped);
|
||||
handler(...args);
|
||||
};
|
||||
return this.on(event, wrapped);
|
||||
}
|
||||
removeListener(event: string, handler: Handler) {
|
||||
const current = this.listeners.get(event) ?? [];
|
||||
this.listeners.set(event, current.filter((item) => item !== handler));
|
||||
return this;
|
||||
}
|
||||
emit(event: string, ...args: unknown[]) {
|
||||
const current = this.listeners.get(event) ?? [];
|
||||
for (const handler of current) {
|
||||
handler(...args);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const store = {
|
||||
init: vi.fn(async () => undefined),
|
||||
watch: vi.fn(async () => undefined),
|
||||
close: vi.fn(),
|
||||
};
|
||||
|
||||
class TaskStore {
|
||||
constructor(_rootDir: string) {}
|
||||
init = store.init;
|
||||
watch = store.watch;
|
||||
close = store.close;
|
||||
}
|
||||
|
||||
const server = Object.assign(new SimpleEmitter(), {
|
||||
address: vi.fn(() => ({ port: 4545 })),
|
||||
close: vi.fn((cb: () => void) => cb()),
|
||||
});
|
||||
|
||||
const listen = vi.fn(() => {
|
||||
queueMicrotask(() => server.emit("listening"));
|
||||
return server;
|
||||
});
|
||||
|
||||
const createServer = vi.fn(() => ({ listen }));
|
||||
|
||||
return { TaskStore, createServer, store, listen };
|
||||
});
|
||||
|
||||
vi.mock("@fusion/core", () => ({ TaskStore: mocks.TaskStore }));
|
||||
vi.mock("@fusion/dashboard", () => ({ createServer: mocks.createServer }));
|
||||
|
||||
describe("DesktopLocalServerManager", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("starts local runtime and exposes port", async () => {
|
||||
const { DesktopLocalServerManager } = await import("../local-server.ts");
|
||||
const manager = new DesktopLocalServerManager("/repo");
|
||||
|
||||
const runtime = await manager.start();
|
||||
|
||||
expect(runtime.port).toBe(4545);
|
||||
expect(manager.getPort()).toBe(4545);
|
||||
expect(manager.getState().status).toBe("ready");
|
||||
});
|
||||
|
||||
it("stops local runtime and resets state", async () => {
|
||||
const { DesktopLocalServerManager } = await import("../local-server.ts");
|
||||
const manager = new DesktopLocalServerManager("/repo");
|
||||
await manager.start();
|
||||
|
||||
await manager.stop();
|
||||
|
||||
expect(mocks.store.close).toHaveBeenCalled();
|
||||
expect(manager.getState().status).toBe("idle");
|
||||
expect(manager.getPort()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sets error state when startup fails", async () => {
|
||||
mocks.store.init.mockRejectedValueOnce(new Error("init failed"));
|
||||
const { DesktopLocalServerManager } = await import("../local-server.ts");
|
||||
const manager = new DesktopLocalServerManager("/repo");
|
||||
|
||||
await expect(manager.start()).rejects.toThrow("init failed");
|
||||
expect(manager.getState()).toMatchObject({ status: "error", error: "init failed" });
|
||||
});
|
||||
|
||||
it("returns existing runtime when start is called twice", async () => {
|
||||
const { DesktopLocalServerManager } = await import("../local-server.ts");
|
||||
const manager = new DesktopLocalServerManager("/repo");
|
||||
|
||||
const first = await manager.start();
|
||||
const second = await manager.start();
|
||||
|
||||
expect(first).toBe(second);
|
||||
expect(mocks.listen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -280,7 +280,7 @@ describe("main integration", () => {
|
||||
|
||||
const [{ instance }] = mocks.windowInstances;
|
||||
const [trayInstance] = mocks.trayInstances;
|
||||
expect(mocks.registerIpcHandlers).toHaveBeenCalledWith(instance, trayInstance);
|
||||
expect(mocks.registerIpcHandlers).toHaveBeenCalledWith(instance, trayInstance, expect.any(Object));
|
||||
});
|
||||
|
||||
it("window close hides to tray when app is not quitting", async () => {
|
||||
|
||||
68
packages/desktop/src/__tests__/main-local-mode.test.ts
Normal file
68
packages/desktop/src/__tests__/main-local-mode.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const appHandlers = new Map<string, (...args: unknown[]) => void>();
|
||||
const app = {
|
||||
whenReady: vi.fn(async () => undefined),
|
||||
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
appHandlers.set(event, handler);
|
||||
return app;
|
||||
}),
|
||||
quit: vi.fn(),
|
||||
};
|
||||
|
||||
const browserWindow = {
|
||||
on: vi.fn(),
|
||||
loadURL: vi.fn(),
|
||||
loadFile: vi.fn(),
|
||||
isDestroyed: vi.fn(() => false),
|
||||
getBounds: vi.fn(() => ({ x: 0, y: 0, width: 800, height: 600 })),
|
||||
isMaximized: vi.fn(() => false),
|
||||
hide: vi.fn(),
|
||||
maximize: vi.fn(),
|
||||
webContents: { send: vi.fn() },
|
||||
};
|
||||
|
||||
const BrowserWindow = vi.fn(() => browserWindow);
|
||||
const Tray = vi.fn(() => ({ destroy: vi.fn() }));
|
||||
|
||||
const localServerManager = {
|
||||
start: vi.fn(async () => undefined),
|
||||
stop: vi.fn(async () => undefined),
|
||||
getState: vi.fn(() => ({ status: "idle", error: null })),
|
||||
getPort: vi.fn(() => undefined),
|
||||
};
|
||||
|
||||
return { app, appHandlers, BrowserWindow, Tray, browserWindow, localServerManager };
|
||||
});
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
app: mocks.app,
|
||||
BrowserWindow: mocks.BrowserWindow,
|
||||
Tray: mocks.Tray,
|
||||
nativeImage: { createEmpty: vi.fn(() => ({})) },
|
||||
}));
|
||||
|
||||
vi.mock("../renderer.js", () => ({ isUrlRenderer: vi.fn(() => true), getRendererUrl: vi.fn(() => "http://localhost"), getRendererFilePath: vi.fn(() => "index.html") }));
|
||||
vi.mock("../menu.js", () => ({ buildAppMenu: vi.fn() }));
|
||||
vi.mock("../tray.js", () => ({ setupTray: vi.fn() }));
|
||||
vi.mock("../ipc.js", () => ({ registerIpcHandlers: vi.fn() }));
|
||||
vi.mock("../native.js", () => ({ DEFAULT_WINDOW_STATE: { width: 1000, height: 800 }, loadWindowState: vi.fn(async () => null), saveWindowState: vi.fn(), setupAutoUpdater: vi.fn() }));
|
||||
vi.mock("../deep-link.js", () => ({ registerDeepLinkProtocol: vi.fn(), setupDeepLinkHandler: vi.fn() }));
|
||||
vi.mock("../shell-settings.js", () => ({ readShellSettings: vi.fn(async () => ({ desktopMode: "local", activeProfileId: null, profiles: [] })) }));
|
||||
vi.mock("../local-server.js", () => ({ DesktopLocalServerManager: vi.fn(() => mocks.localServerManager) }));
|
||||
|
||||
describe("main local mode", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
mocks.appHandlers.clear();
|
||||
});
|
||||
|
||||
it("starts local server manager when restored desktop mode is local", async () => {
|
||||
const { initializeApp } = await import("../main.ts");
|
||||
await initializeApp();
|
||||
|
||||
expect(mocks.localServerManager.start).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -23,26 +23,8 @@ async function importPreloadModule() {
|
||||
await import("../preload.ts");
|
||||
}
|
||||
|
||||
function getFusionApi() {
|
||||
const call = mocks.contextBridge.exposeInMainWorld.mock.calls.find(
|
||||
([name]) => name === "fusionAPI",
|
||||
) as [string, {
|
||||
minimize: () => Promise<void>;
|
||||
maximize: () => Promise<boolean>;
|
||||
close: () => Promise<void>;
|
||||
isMaximized: () => Promise<boolean>;
|
||||
getSystemInfo: () => Promise<unknown>;
|
||||
checkForUpdates: () => Promise<unknown>;
|
||||
getServerPort: () => Promise<number | undefined>;
|
||||
updateTrayStatus: (status: string) => Promise<void>;
|
||||
showExportDialog: () => Promise<string | null>;
|
||||
showImportDialog: () => Promise<string | null>;
|
||||
onDeepLink: (callback: (result: unknown) => void) => () => void;
|
||||
onUpdateAvailable: (callback: (info: { version: string }) => void) => () => void;
|
||||
onUpdateDownloaded: (callback: () => void) => () => void;
|
||||
}] | undefined;
|
||||
|
||||
return call?.[1];
|
||||
function getExposed<T = unknown>(name: string): T | undefined {
|
||||
return mocks.contextBridge.exposeInMainWorld.mock.calls.find(([key]) => key === name)?.[1] as T | undefined;
|
||||
}
|
||||
|
||||
describe("preload", () => {
|
||||
@@ -51,153 +33,32 @@ describe("preload", () => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("contextBridge.exposeInMainWorld is called with fusionAPI", async () => {
|
||||
it("exposes electronAPI and fusionShell", async () => {
|
||||
await importPreloadModule();
|
||||
|
||||
expect(mocks.contextBridge.exposeInMainWorld).toHaveBeenCalledWith(
|
||||
"fusionAPI",
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(getExposed("electronAPI")).toBeTruthy();
|
||||
expect(getExposed("fusionAPI")).toBeTruthy();
|
||||
expect(getExposed("fusionShell")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("minimize invokes window:minimize", async () => {
|
||||
it("electronAPI delegates getServerPort to IPC", async () => {
|
||||
await importPreloadModule();
|
||||
const api = getExposed<{ getServerPort: () => Promise<number | undefined> }>("electronAPI");
|
||||
|
||||
const api = getFusionApi();
|
||||
await api?.minimize();
|
||||
|
||||
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("window:minimize");
|
||||
});
|
||||
|
||||
it("maximize invokes window:maximize", async () => {
|
||||
await importPreloadModule();
|
||||
|
||||
const api = getFusionApi();
|
||||
await api?.maximize();
|
||||
|
||||
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("window:maximize");
|
||||
});
|
||||
|
||||
it("close invokes window:close", async () => {
|
||||
await importPreloadModule();
|
||||
|
||||
const api = getFusionApi();
|
||||
await api?.close();
|
||||
|
||||
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("window:close");
|
||||
});
|
||||
|
||||
it("isMaximized invokes window:isMaximized", async () => {
|
||||
await importPreloadModule();
|
||||
|
||||
const api = getFusionApi();
|
||||
await api?.isMaximized();
|
||||
|
||||
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("window:isMaximized");
|
||||
});
|
||||
|
||||
it("getSystemInfo invokes app:getSystemInfo", async () => {
|
||||
await importPreloadModule();
|
||||
|
||||
const api = getFusionApi();
|
||||
await api?.getSystemInfo();
|
||||
|
||||
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("app:getSystemInfo");
|
||||
});
|
||||
|
||||
it("checkForUpdates invokes app:checkForUpdates", async () => {
|
||||
await importPreloadModule();
|
||||
|
||||
const api = getFusionApi();
|
||||
await api?.checkForUpdates();
|
||||
|
||||
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("app:checkForUpdates");
|
||||
});
|
||||
|
||||
it("getServerPort invokes app:getServerPort", async () => {
|
||||
await importPreloadModule();
|
||||
|
||||
const api = getFusionApi();
|
||||
await api?.getServerPort();
|
||||
|
||||
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("app:getServerPort");
|
||||
});
|
||||
|
||||
it("updateTrayStatus invokes tray:updateStatus with status argument", async () => {
|
||||
it("fusionShell subscribes and unsubscribes state listener", async () => {
|
||||
await importPreloadModule();
|
||||
const shell = getExposed<{ subscribe: (listener: (state: unknown) => void) => () => void }>("fusionShell");
|
||||
|
||||
const api = getFusionApi();
|
||||
await api?.updateTrayStatus("paused");
|
||||
|
||||
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("tray:updateStatus", "paused");
|
||||
});
|
||||
|
||||
it("showExportDialog invokes native:showExportDialog", async () => {
|
||||
await importPreloadModule();
|
||||
|
||||
const api = getFusionApi();
|
||||
await api?.showExportDialog();
|
||||
|
||||
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("native:showExportDialog");
|
||||
});
|
||||
|
||||
it("showImportDialog invokes native:showImportDialog", async () => {
|
||||
await importPreloadModule();
|
||||
|
||||
const api = getFusionApi();
|
||||
await api?.showImportDialog();
|
||||
|
||||
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("native:showImportDialog");
|
||||
});
|
||||
|
||||
it("onDeepLink subscribes to deep-link and returns unsubscribe", async () => {
|
||||
await importPreloadModule();
|
||||
|
||||
const api = getFusionApi();
|
||||
const callback = vi.fn();
|
||||
const unsubscribe = api?.onDeepLink(callback);
|
||||
|
||||
expect(mocks.ipcRenderer.on).toHaveBeenCalledWith("deep-link", expect.any(Function));
|
||||
const unsubscribe = shell?.subscribe(() => undefined);
|
||||
expect(mocks.ipcRenderer.on).toHaveBeenCalledWith("shell:state", expect.any(Function));
|
||||
|
||||
unsubscribe?.();
|
||||
|
||||
expect(mocks.ipcRenderer.removeListener).toHaveBeenCalledWith(
|
||||
"deep-link",
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it("onUpdateAvailable subscribes to update-available and returns unsubscribe", async () => {
|
||||
await importPreloadModule();
|
||||
|
||||
const api = getFusionApi();
|
||||
const callback = vi.fn();
|
||||
const unsubscribe = api?.onUpdateAvailable(callback);
|
||||
|
||||
expect(mocks.ipcRenderer.on).toHaveBeenCalledWith("update-available", expect.any(Function));
|
||||
|
||||
unsubscribe?.();
|
||||
|
||||
expect(mocks.ipcRenderer.removeListener).toHaveBeenCalledWith(
|
||||
"update-available",
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it("onUpdateDownloaded subscribes to update-downloaded and returns unsubscribe", async () => {
|
||||
await importPreloadModule();
|
||||
|
||||
const api = getFusionApi();
|
||||
const callback = vi.fn();
|
||||
const unsubscribe = api?.onUpdateDownloaded(callback);
|
||||
|
||||
expect(mocks.ipcRenderer.on).toHaveBeenCalledWith("update-downloaded", expect.any(Function));
|
||||
|
||||
unsubscribe?.();
|
||||
|
||||
expect(mocks.ipcRenderer.removeListener).toHaveBeenCalledWith(
|
||||
"update-downloaded",
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mocks.ipcRenderer.removeListener).toHaveBeenCalledWith("shell:state", expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
72
packages/desktop/src/__tests__/shell-settings.test.ts
Normal file
72
packages/desktop/src/__tests__/shell-settings.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
content: new Map<string, string>(),
|
||||
}));
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
app: {
|
||||
getPath: vi.fn(() => "/tmp/fusion"),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("node:fs/promises", () => ({
|
||||
readFile: vi.fn(async (path: string) => {
|
||||
const value = mockState.content.get(path);
|
||||
if (!value) {
|
||||
const err = new Error("ENOENT") as NodeJS.ErrnoException;
|
||||
err.code = "ENOENT";
|
||||
throw err;
|
||||
}
|
||||
return value;
|
||||
}),
|
||||
writeFile: vi.fn(async (path: string, value: string) => {
|
||||
mockState.content.set(path, value);
|
||||
}),
|
||||
rename: vi.fn(async (from: string, to: string) => {
|
||||
const value = mockState.content.get(from) ?? "";
|
||||
mockState.content.set(to, value);
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("shell-settings", () => {
|
||||
beforeEach(() => {
|
||||
mockState.content.clear();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("returns defaults when file missing", async () => {
|
||||
const { readShellSettings } = await import("../shell-settings.ts");
|
||||
await expect(readShellSettings()).resolves.toEqual({
|
||||
desktopMode: "remote",
|
||||
activeProfileId: null,
|
||||
profiles: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("writes and reads persisted settings", async () => {
|
||||
const { writeShellSettings, readShellSettings } = await import("../shell-settings.ts");
|
||||
|
||||
await writeShellSettings({
|
||||
desktopMode: "local",
|
||||
activeProfileId: "p1",
|
||||
profiles: [
|
||||
{
|
||||
id: "p1",
|
||||
name: "Local",
|
||||
serverUrl: "http://127.0.0.1",
|
||||
authToken: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
lastUsedAt: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(readShellSettings()).resolves.toMatchObject({
|
||||
desktopMode: "local",
|
||||
activeProfileId: "p1",
|
||||
profiles: [{ id: "p1" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,28 +1,74 @@
|
||||
import { app, type BrowserWindow, ipcMain, type Tray } from "electron";
|
||||
import { setupAutoUpdater, showExportSettingsDialog, showImportSettingsDialog } from "./native.js";
|
||||
import { type EngineStatus, updateTrayStatus } from "./tray.js";
|
||||
import { readShellSettings, writeShellSettings, type ShellConnectionProfile } from "./shell-settings.js";
|
||||
import type { DesktopLocalServerState } from "./local-server.js";
|
||||
|
||||
export function registerIpcHandlers(mainWindow: BrowserWindow, tray: Tray): void {
|
||||
ipcMain.handle("window:minimize", () => {
|
||||
mainWindow.minimize();
|
||||
});
|
||||
interface ShellConnectionProfileInput {
|
||||
id?: string;
|
||||
name: string;
|
||||
serverUrl: string;
|
||||
authToken?: string | null;
|
||||
}
|
||||
|
||||
interface ShellConnectionState {
|
||||
host: "desktop-shell";
|
||||
desktopMode?: "local" | "remote";
|
||||
activeProfileId: string | null;
|
||||
profiles: ShellConnectionProfile[];
|
||||
localServer?: DesktopLocalServerState;
|
||||
}
|
||||
|
||||
interface RegisterIpcOptions {
|
||||
onDesktopModeChange?: (mode: "local" | "remote") => Promise<void>;
|
||||
getLocalServerState?: () => DesktopLocalServerState;
|
||||
getServerPort?: () => number | undefined;
|
||||
}
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function createProfileId(): string {
|
||||
return `profile_${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
function toShellState(
|
||||
settings: Awaited<ReturnType<typeof readShellSettings>>,
|
||||
localServerState?: DesktopLocalServerState,
|
||||
): ShellConnectionState {
|
||||
return {
|
||||
host: "desktop-shell",
|
||||
desktopMode: settings.desktopMode,
|
||||
activeProfileId: settings.activeProfileId,
|
||||
profiles: settings.profiles,
|
||||
localServer: localServerState ?? { status: "idle", error: null },
|
||||
};
|
||||
}
|
||||
|
||||
async function emitShellState(
|
||||
mainWindow: BrowserWindow,
|
||||
getLocalServerState?: () => DesktopLocalServerState,
|
||||
): Promise<ShellConnectionState> {
|
||||
const state = toShellState(await readShellSettings(), getLocalServerState?.());
|
||||
mainWindow.webContents.send("shell:state", state);
|
||||
return state;
|
||||
}
|
||||
|
||||
export function registerIpcHandlers(mainWindow: BrowserWindow, tray: Tray, options: RegisterIpcOptions = {}): void {
|
||||
ipcMain.handle("window:minimize", () => mainWindow.minimize());
|
||||
ipcMain.handle("window:maximize", () => {
|
||||
const isCurrentlyMaximized = mainWindow.isMaximized();
|
||||
if (isCurrentlyMaximized) {
|
||||
mainWindow.unmaximize();
|
||||
return false;
|
||||
}
|
||||
|
||||
mainWindow.maximize();
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle("window:close", () => {
|
||||
mainWindow.close();
|
||||
});
|
||||
|
||||
ipcMain.handle("window:close", () => mainWindow.close());
|
||||
ipcMain.handle("window:isMaximized", () => mainWindow.isMaximized());
|
||||
ipcMain.handle("platform:get", () => process.platform);
|
||||
|
||||
ipcMain.handle("app:getSystemInfo", () => ({
|
||||
platform: process.platform,
|
||||
@@ -37,23 +83,69 @@ export function registerIpcHandlers(mainWindow: BrowserWindow, tray: Tray): void
|
||||
setupAutoUpdater(mainWindow);
|
||||
return { status: "checking" as const };
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "error" as const,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
return { status: "error" as const, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("tray:updateStatus", (_event, status: EngineStatus) => {
|
||||
updateTrayStatus(tray, status);
|
||||
});
|
||||
|
||||
ipcMain.handle("tray:updateStatus", (_event, status: EngineStatus) => updateTrayStatus(tray, status));
|
||||
ipcMain.handle("native:showExportDialog", () => showExportSettingsDialog(mainWindow));
|
||||
ipcMain.handle("native:showImportDialog", () => showImportSettingsDialog(mainWindow));
|
||||
ipcMain.handle("app:getServerPort", () => options.getServerPort?.());
|
||||
|
||||
// Return the server port from environment variable (set by CLI)
|
||||
ipcMain.handle("app:getServerPort", () => {
|
||||
const port = process.env.FUSION_SERVER_PORT;
|
||||
return port ? parseInt(port, 10) : undefined;
|
||||
ipcMain.handle("shell:getState", () => readShellSettings().then((settings) => toShellState(settings, options.getLocalServerState?.())));
|
||||
ipcMain.handle("shell:listProfiles", async () => (await readShellSettings()).profiles);
|
||||
|
||||
ipcMain.handle("shell:saveProfile", async (_event, profile: ShellConnectionProfileInput) => {
|
||||
const settings = await readShellSettings();
|
||||
const existing = profile.id ? settings.profiles.find((item) => item.id === profile.id) : undefined;
|
||||
const timestamp = nowIso();
|
||||
const nextProfile: ShellConnectionProfile = {
|
||||
id: existing?.id ?? profile.id ?? createProfileId(),
|
||||
name: profile.name.trim(),
|
||||
serverUrl: profile.serverUrl.trim().replace(/\/$/, ""),
|
||||
authToken: profile.authToken ?? null,
|
||||
createdAt: existing?.createdAt ?? timestamp,
|
||||
updatedAt: timestamp,
|
||||
lastUsedAt: existing?.lastUsedAt ?? null,
|
||||
};
|
||||
|
||||
settings.profiles = existing ? settings.profiles.map((item) => (item.id === existing.id ? nextProfile : item)) : [...settings.profiles, nextProfile];
|
||||
await writeShellSettings(settings);
|
||||
await emitShellState(mainWindow, options.getLocalServerState);
|
||||
return nextProfile;
|
||||
});
|
||||
|
||||
ipcMain.handle("shell:deleteProfile", async (_event, profileId: string) => {
|
||||
const settings = await readShellSettings();
|
||||
settings.profiles = settings.profiles.filter((item) => item.id !== profileId);
|
||||
if (settings.activeProfileId === profileId) settings.activeProfileId = null;
|
||||
await writeShellSettings(settings);
|
||||
await emitShellState(mainWindow, options.getLocalServerState);
|
||||
});
|
||||
|
||||
ipcMain.handle("shell:setActiveProfile", async (_event, profileId: string | null) => {
|
||||
const settings = await readShellSettings();
|
||||
settings.activeProfileId = profileId && settings.profiles.some((item) => item.id === profileId) ? profileId : null;
|
||||
settings.profiles = settings.profiles.map((item) =>
|
||||
item.id === settings.activeProfileId ? { ...item, lastUsedAt: nowIso(), updatedAt: nowIso() } : item,
|
||||
);
|
||||
await writeShellSettings(settings);
|
||||
return emitShellState(mainWindow, options.getLocalServerState);
|
||||
});
|
||||
|
||||
ipcMain.handle("shell:setDesktopMode", async (_event, mode: "local" | "remote") => {
|
||||
const settings = await readShellSettings();
|
||||
settings.desktopMode = mode;
|
||||
await writeShellSettings(settings);
|
||||
await options.onDesktopModeChange?.(mode);
|
||||
return emitShellState(mainWindow, options.getLocalServerState);
|
||||
});
|
||||
|
||||
ipcMain.handle("shell:startQrScan", async () => {
|
||||
throw new Error("QR scanning is not available in desktop shell");
|
||||
});
|
||||
|
||||
ipcMain.handle("shell:openConnectionManager", () => {
|
||||
mainWindow.webContents.send("shell:open-connection-manager");
|
||||
});
|
||||
}
|
||||
|
||||
90
packages/desktop/src/local-server.ts
Normal file
90
packages/desktop/src/local-server.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { once } from "node:events";
|
||||
import type { Server } from "node:http";
|
||||
type TaskStoreLike = {
|
||||
init(): Promise<void>;
|
||||
watch(): Promise<void>;
|
||||
close(): void;
|
||||
};
|
||||
|
||||
export interface DesktopLocalRuntime {
|
||||
store: TaskStoreLike;
|
||||
server: Server;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export interface DesktopLocalServerState {
|
||||
status: "idle" | "starting" | "ready" | "error";
|
||||
port?: number;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export class DesktopLocalServerManager {
|
||||
private runtime: DesktopLocalRuntime | null = null;
|
||||
private state: DesktopLocalServerState = { status: "idle", error: null };
|
||||
|
||||
constructor(private readonly rootDir: string) {}
|
||||
|
||||
getState(): DesktopLocalServerState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
getPort(): number | undefined {
|
||||
return this.runtime?.port;
|
||||
}
|
||||
|
||||
async start(): Promise<DesktopLocalRuntime> {
|
||||
if (this.runtime) {
|
||||
this.state = { status: "ready", port: this.runtime.port, error: null };
|
||||
return this.runtime;
|
||||
}
|
||||
|
||||
this.state = { status: "starting", error: null };
|
||||
|
||||
try {
|
||||
const { TaskStore } = await import("@fusion/core");
|
||||
const { createServer } = await import("@fusion/dashboard");
|
||||
const store = new TaskStore(this.rootDir) as TaskStoreLike;
|
||||
await store.init();
|
||||
await store.watch();
|
||||
const app = createServer(store);
|
||||
const server = app.listen(0);
|
||||
|
||||
await Promise.race([
|
||||
once(server, "listening"),
|
||||
once(server, "error").then(([error]) => {
|
||||
throw error;
|
||||
}),
|
||||
]);
|
||||
|
||||
const address = server.address() as AddressInfo | null;
|
||||
if (!address?.port) {
|
||||
throw new Error("Failed to resolve local server port");
|
||||
}
|
||||
|
||||
this.runtime = { store, server, port: address.port };
|
||||
this.state = { status: "ready", port: address.port, error: null };
|
||||
return this.runtime;
|
||||
} catch (error) {
|
||||
this.state = {
|
||||
status: "error",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.runtime) {
|
||||
this.state = { status: "idle", error: null };
|
||||
return;
|
||||
}
|
||||
|
||||
const runtime = this.runtime;
|
||||
this.runtime = null;
|
||||
|
||||
await new Promise<void>((resolve) => runtime.server.close(() => resolve()));
|
||||
runtime.store.close();
|
||||
this.state = { status: "idle", error: null };
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
} from "./native.js";
|
||||
import { setupTray } from "./tray.js";
|
||||
import { getRendererUrl, getRendererFilePath, isUrlRenderer } from "./renderer.js";
|
||||
import { DesktopLocalServerManager } from "./local-server.js";
|
||||
import { readShellSettings } from "./shell-settings.js";
|
||||
|
||||
// Re-export for backward compatibility
|
||||
export { IS_DEVELOPMENT } from "./renderer.js";
|
||||
@@ -33,6 +35,7 @@ enableSourceMaps();
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let tray: Tray | null = null;
|
||||
let localServerManager: DesktopLocalServerManager | null = null;
|
||||
|
||||
function getAppWithQuitFlag(): Electron.App & AppWithQuitFlag {
|
||||
return app as Electron.App & AppWithQuitFlag;
|
||||
@@ -88,14 +91,34 @@ export async function initializeApp(): Promise<void> {
|
||||
appName: "Fusion",
|
||||
});
|
||||
|
||||
localServerManager = new DesktopLocalServerManager(process.cwd());
|
||||
|
||||
tray = new Tray(nativeImage.createEmpty());
|
||||
setupTray(createdWindow, tray);
|
||||
|
||||
registerIpcHandlers(createdWindow, tray);
|
||||
registerIpcHandlers(createdWindow, tray, {
|
||||
onDesktopModeChange: async (mode) => {
|
||||
if (!localServerManager) {
|
||||
return;
|
||||
}
|
||||
if (mode === "local") {
|
||||
await localServerManager.start();
|
||||
} else {
|
||||
await localServerManager.stop();
|
||||
}
|
||||
},
|
||||
getLocalServerState: () => localServerManager?.getState() ?? { status: "idle", error: null },
|
||||
getServerPort: () => localServerManager?.getPort(),
|
||||
});
|
||||
registerDeepLinkProtocol();
|
||||
setupDeepLinkHandler(createdWindow);
|
||||
setupAutoUpdater(createdWindow);
|
||||
|
||||
const shellSettings = await readShellSettings();
|
||||
if (shellSettings.desktopMode === "local") {
|
||||
await localServerManager.start();
|
||||
}
|
||||
|
||||
if (state?.isMaximized === true) {
|
||||
createdWindow.maximize();
|
||||
}
|
||||
@@ -119,6 +142,10 @@ export function run(): void {
|
||||
tray.destroy();
|
||||
tray = null;
|
||||
}
|
||||
|
||||
if (localServerManager) {
|
||||
void localServerManager.stop();
|
||||
}
|
||||
});
|
||||
|
||||
app.on("activate", () => {
|
||||
|
||||
@@ -1,9 +1,40 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import type { DeepLinkResult, FusionAPI, SystemInfo, UpdateCheckResult } from "./types";
|
||||
|
||||
interface ShellConnectionProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
serverUrl: string;
|
||||
authToken?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastUsedAt?: string | null;
|
||||
}
|
||||
|
||||
interface ShellConnectionProfileInput {
|
||||
id?: string;
|
||||
name: string;
|
||||
serverUrl: string;
|
||||
authToken?: string | null;
|
||||
}
|
||||
|
||||
interface ShellConnectionState {
|
||||
host: "web" | "mobile-shell" | "desktop-shell";
|
||||
desktopMode?: "local" | "remote";
|
||||
activeProfileId: string | null;
|
||||
profiles: ShellConnectionProfile[];
|
||||
localServer?: {
|
||||
status: "idle" | "starting" | "ready" | "error";
|
||||
port?: number;
|
||||
error?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export type FusionDesktopAPI = FusionAPI;
|
||||
|
||||
contextBridge.exposeInMainWorld("fusionAPI", {
|
||||
type WindowControlAction = "minimize" | "maximize" | "close" | "isMaximized";
|
||||
|
||||
const electronApi = {
|
||||
// Window control
|
||||
minimize: (): Promise<void> => ipcRenderer.invoke("window:minimize"),
|
||||
maximize: (): Promise<boolean> => ipcRenderer.invoke("window:maximize"),
|
||||
@@ -29,6 +60,22 @@ contextBridge.exposeInMainWorld("fusionAPI", {
|
||||
return () => ipcRenderer.removeListener("deep-link", handler);
|
||||
},
|
||||
|
||||
windowControl: async (action: WindowControlAction): Promise<boolean | void> => {
|
||||
switch (action) {
|
||||
case "minimize":
|
||||
return ipcRenderer.invoke("window:minimize");
|
||||
case "maximize":
|
||||
return ipcRenderer.invoke("window:maximize");
|
||||
case "close":
|
||||
return ipcRenderer.invoke("window:close");
|
||||
case "isMaximized":
|
||||
return ipcRenderer.invoke("window:isMaximized");
|
||||
}
|
||||
},
|
||||
getPlatform: (): Promise<"darwin" | "win32" | "linux"> => ipcRenderer.invoke("platform:get"),
|
||||
apiRequest: (method: string, path: string, body?: unknown): Promise<unknown> =>
|
||||
ipcRenderer.invoke("api-request", { method, path, body }),
|
||||
|
||||
// Auto-updater events (main → renderer)
|
||||
onUpdateAvailable: (callback: (info: { version: string }) => void): (() => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, info: { version: string }) => callback(info);
|
||||
@@ -40,4 +87,25 @@ contextBridge.exposeInMainWorld("fusionAPI", {
|
||||
ipcRenderer.on("update-downloaded", handler);
|
||||
return () => ipcRenderer.removeListener("update-downloaded", handler);
|
||||
},
|
||||
});
|
||||
invoke: (channel: string, payload?: unknown): Promise<unknown> => ipcRenderer.invoke(channel, payload),
|
||||
};
|
||||
|
||||
const fusionShell = {
|
||||
getState: (): Promise<ShellConnectionState> => ipcRenderer.invoke("shell:getState"),
|
||||
listProfiles: (): Promise<ShellConnectionProfile[]> => ipcRenderer.invoke("shell:listProfiles"),
|
||||
saveProfile: (profile: ShellConnectionProfileInput): Promise<ShellConnectionProfile> => ipcRenderer.invoke("shell:saveProfile", profile),
|
||||
deleteProfile: (profileId: string): Promise<void> => ipcRenderer.invoke("shell:deleteProfile", profileId),
|
||||
setActiveProfile: (profileId: string | null): Promise<ShellConnectionState> => ipcRenderer.invoke("shell:setActiveProfile", profileId),
|
||||
setDesktopMode: (mode: "local" | "remote"): Promise<ShellConnectionState> => ipcRenderer.invoke("shell:setDesktopMode", mode),
|
||||
startQrScan: (): Promise<{ serverUrl: string; authToken?: string | null }> => ipcRenderer.invoke("shell:startQrScan"),
|
||||
openConnectionManager: (): Promise<void> => ipcRenderer.invoke("shell:openConnectionManager"),
|
||||
subscribe: (listener: (state: ShellConnectionState) => void): (() => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, state: ShellConnectionState) => listener(state);
|
||||
ipcRenderer.on("shell:state", handler);
|
||||
return () => ipcRenderer.removeListener("shell:state", handler);
|
||||
},
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld("electronAPI", electronApi);
|
||||
contextBridge.exposeInMainWorld("fusionAPI", electronApi);
|
||||
contextBridge.exposeInMainWorld("fusionShell", fusionShell);
|
||||
|
||||
58
packages/desktop/src/shell-settings.ts
Normal file
58
packages/desktop/src/shell-settings.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { readFile, rename, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { app } from "electron";
|
||||
|
||||
export interface ShellConnectionProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
serverUrl: string;
|
||||
authToken?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastUsedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface DesktopShellSettings {
|
||||
desktopMode: "local" | "remote";
|
||||
activeProfileId: string | null;
|
||||
profiles: ShellConnectionProfile[];
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: DesktopShellSettings = {
|
||||
desktopMode: "remote",
|
||||
activeProfileId: null,
|
||||
profiles: [],
|
||||
};
|
||||
|
||||
function getSettingsPath(): string {
|
||||
return join(app.getPath("userData"), "shell-connections.json");
|
||||
}
|
||||
|
||||
function normalize(input: unknown): DesktopShellSettings {
|
||||
if (!input || typeof input !== "object") {
|
||||
return { ...DEFAULT_SETTINGS };
|
||||
}
|
||||
|
||||
const candidate = input as Partial<DesktopShellSettings>;
|
||||
return {
|
||||
desktopMode: candidate.desktopMode === "local" ? "local" : "remote",
|
||||
activeProfileId: typeof candidate.activeProfileId === "string" ? candidate.activeProfileId : null,
|
||||
profiles: Array.isArray(candidate.profiles) ? candidate.profiles.filter((item) => item && typeof item === "object") as ShellConnectionProfile[] : [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function readShellSettings(): Promise<DesktopShellSettings> {
|
||||
try {
|
||||
const raw = await readFile(getSettingsPath(), "utf-8");
|
||||
return normalize(JSON.parse(raw));
|
||||
} catch {
|
||||
return { ...DEFAULT_SETTINGS };
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeShellSettings(settings: DesktopShellSettings): Promise<void> {
|
||||
const path = getSettingsPath();
|
||||
const temp = `${path}.tmp`;
|
||||
await writeFile(temp, JSON.stringify(settings, null, 2), "utf-8");
|
||||
await rename(temp, path);
|
||||
}
|
||||
51
packages/desktop/src/types.d.ts
vendored
51
packages/desktop/src/types.d.ts
vendored
@@ -17,12 +17,15 @@ export interface DeepLinkResult {
|
||||
raw: string;
|
||||
}
|
||||
|
||||
export type WindowControlAction = "minimize" | "maximize" | "close" | "isMaximized";
|
||||
|
||||
export interface FusionAPI {
|
||||
// Window control
|
||||
minimize(): Promise<void>;
|
||||
maximize(): Promise<boolean>;
|
||||
close(): Promise<void>;
|
||||
isMaximized(): Promise<boolean>;
|
||||
windowControl(action: WindowControlAction): Promise<boolean | void>;
|
||||
|
||||
// App info
|
||||
getSystemInfo(): Promise<SystemInfo>;
|
||||
@@ -42,10 +45,58 @@ export interface FusionAPI {
|
||||
// Auto-updater events
|
||||
onUpdateAvailable(callback: (info: { version: string }) => void): () => void;
|
||||
onUpdateDownloaded(callback: () => void): () => void;
|
||||
|
||||
// Generic IPC invoke bridge
|
||||
invoke(channel: string, payload?: unknown): Promise<unknown>;
|
||||
apiRequest?(method: string, path: string, body?: unknown): Promise<unknown>;
|
||||
getPlatform(): Promise<"darwin" | "win32" | "linux">;
|
||||
}
|
||||
|
||||
export interface ShellConnectionProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
serverUrl: string;
|
||||
authToken?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastUsedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface ShellConnectionProfileInput {
|
||||
id?: string;
|
||||
name: string;
|
||||
serverUrl: string;
|
||||
authToken?: string | null;
|
||||
}
|
||||
|
||||
export interface ShellConnectionState {
|
||||
host: "web" | "mobile-shell" | "desktop-shell";
|
||||
desktopMode?: "local" | "remote";
|
||||
activeProfileId: string | null;
|
||||
profiles: ShellConnectionProfile[];
|
||||
localServer?: {
|
||||
status: "idle" | "starting" | "ready" | "error";
|
||||
port?: number;
|
||||
error?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface FusionShellApi {
|
||||
getState(): Promise<ShellConnectionState>;
|
||||
listProfiles(): Promise<ShellConnectionProfile[]>;
|
||||
saveProfile(profile: ShellConnectionProfileInput): Promise<ShellConnectionProfile>;
|
||||
deleteProfile(profileId: string): Promise<void>;
|
||||
setActiveProfile(profileId: string | null): Promise<ShellConnectionState>;
|
||||
setDesktopMode(mode: "local" | "remote"): Promise<ShellConnectionState>;
|
||||
startQrScan(): Promise<{ serverUrl: string; authToken?: string | null }>;
|
||||
openConnectionManager(): Promise<void>;
|
||||
subscribe(listener: (state: ShellConnectionState) => void): () => void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
fusionAPI: FusionAPI;
|
||||
electronAPI: FusionAPI;
|
||||
fusionShell?: FusionShellApi;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# @fusion/mobile
|
||||
|
||||
## Native Shell Onboarding & Remote Connections
|
||||
|
||||
Mobile uses a shell-level onboarding flow for first-run connection setup before dashboard onboarding.
|
||||
|
||||
- **Remote-first flow:** mobile onboarding goes directly to remote server connection.
|
||||
- **Connection setup options:** QR scan (`startQrScan`) or manual server URL entry, with optional auth token.
|
||||
- **Saved profiles:** multiple remote profiles are persisted in shell-local storage and can be edited/switched later from dashboard connection management.
|
||||
- **Storage boundary:** profile/mode state is stored only in mobile shell-local storage (via native plugin wrappers), not in Fusion project settings/local dashboard project storage.
|
||||
- **Bridge contract:** mobile exposes `window.fusionShell` (`getState`, `listProfiles`, `saveProfile`, `deleteProfile`, `setActiveProfile`, `startQrScan`, `openConnectionManager`, `subscribe`) so shared dashboard code can run host-neutrally.
|
||||
|
||||
Native wrappers are isolated under `src/plugins/native-shell.ts`, `src/plugins/connection-profiles.ts`, and `src/plugins/qr-scanner.ts` so dashboard code never calls vendor-specific APIs directly.
|
||||
|
||||
## Push Notifications
|
||||
|
||||
`PushNotificationManager` supports two complementary notification channels:
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"dependencies": {
|
||||
"@capacitor/app": "^7.1.2",
|
||||
"@capacitor/core": "^7.0.0",
|
||||
"@capacitor/preferences": "^7.0.0",
|
||||
"@capacitor/push-notifications": "^7.0.0",
|
||||
"@capacitor/share": "^7.0.4"
|
||||
},
|
||||
|
||||
45
packages/mobile/src/__tests__/connection-profiles.test.ts
Normal file
45
packages/mobile/src/__tests__/connection-profiles.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const storage = new Map<string, string>();
|
||||
|
||||
vi.mock("@capacitor/preferences", () => ({
|
||||
Preferences: {
|
||||
get: vi.fn(async ({ key }: { key: string }) => ({ value: storage.get(key) ?? null })),
|
||||
set: vi.fn(async ({ key, value }: { key: string; value: string }) => {
|
||||
storage.set(key, value);
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("connection-profiles", () => {
|
||||
beforeEach(() => {
|
||||
storage.clear();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("persists and lists profiles", async () => {
|
||||
const { saveShellProfile, listShellProfiles } = await import("../plugins/connection-profiles.js");
|
||||
|
||||
await saveShellProfile({ name: "Prod", serverUrl: "https://fusion.example.com/", authToken: "token" });
|
||||
const profiles = await listShellProfiles();
|
||||
|
||||
expect(profiles).toHaveLength(1);
|
||||
expect(profiles[0]).toMatchObject({
|
||||
name: "Prod",
|
||||
serverUrl: "https://fusion.example.com",
|
||||
authToken: "token",
|
||||
});
|
||||
});
|
||||
|
||||
it("clears active profile when deleted", async () => {
|
||||
const { saveShellProfile, setActiveShellProfile, loadShellProfiles, deleteShellProfile } = await import("../plugins/connection-profiles.js");
|
||||
|
||||
const profile = await saveShellProfile({ name: "Prod", serverUrl: "https://fusion.example.com" });
|
||||
await setActiveShellProfile(profile.id);
|
||||
await deleteShellProfile(profile.id);
|
||||
|
||||
const state = await loadShellProfiles();
|
||||
expect(state.activeProfileId).toBeNull();
|
||||
expect(state.profiles).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
62
packages/mobile/src/__tests__/native-shell.test.ts
Normal file
62
packages/mobile/src/__tests__/native-shell.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = {
|
||||
activeProfileId: null as string | null,
|
||||
profiles: [] as Array<{ id: string; name: string; serverUrl: string; authToken?: string | null; createdAt: string; updatedAt: string; lastUsedAt?: string | null }>,
|
||||
};
|
||||
|
||||
vi.mock("../plugins/connection-profiles.js", () => ({
|
||||
loadShellProfiles: vi.fn(async () => state),
|
||||
listShellProfiles: vi.fn(async () => state.profiles),
|
||||
saveShellProfile: vi.fn(async (profile: { name: string; serverUrl: string }) => {
|
||||
const saved = {
|
||||
id: "p1",
|
||||
name: profile.name,
|
||||
serverUrl: profile.serverUrl,
|
||||
authToken: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
lastUsedAt: null,
|
||||
};
|
||||
state.profiles = [saved];
|
||||
return saved;
|
||||
}),
|
||||
deleteShellProfile: vi.fn(async () => {
|
||||
state.profiles = [];
|
||||
state.activeProfileId = null;
|
||||
}),
|
||||
setActiveShellProfile: vi.fn(async (profileId: string | null) => {
|
||||
state.activeProfileId = profileId;
|
||||
return state;
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("MobileNativeShellBridge", () => {
|
||||
const scanner = { scanConnection: vi.fn(async () => ({ serverUrl: "https://fusion.example.com", authToken: null })) };
|
||||
|
||||
beforeEach(() => {
|
||||
state.activeProfileId = null;
|
||||
state.profiles = [];
|
||||
scanner.scanConnection.mockClear();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("emits state updates to subscribers", async () => {
|
||||
const { MobileNativeShellBridge } = await import("../plugins/native-shell.js");
|
||||
const bridge = new MobileNativeShellBridge(scanner as never);
|
||||
const listener = vi.fn();
|
||||
|
||||
const unsubscribe = bridge.subscribe(listener);
|
||||
await bridge.saveProfile({ name: "Prod", serverUrl: "https://fusion.example.com" });
|
||||
|
||||
expect(listener).toHaveBeenCalled();
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("rejects desktop mode switch", async () => {
|
||||
const { MobileNativeShellBridge } = await import("../plugins/native-shell.js");
|
||||
const bridge = new MobileNativeShellBridge(scanner as never);
|
||||
|
||||
await expect(bridge.setDesktopMode("local")).rejects.toThrow("Desktop mode is not supported");
|
||||
});
|
||||
});
|
||||
19
packages/mobile/src/__tests__/qr-scanner.test.ts
Normal file
19
packages/mobile/src/__tests__/qr-scanner.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { QrScanner, parseQrConnectionPayload } from "../plugins/qr-scanner.js";
|
||||
|
||||
describe("qr-scanner", () => {
|
||||
it("parses JSON payload", () => {
|
||||
const parsed = parseQrConnectionPayload('{"serverUrl":"https://fusion.example.com","authToken":"abc"}');
|
||||
expect(parsed).toEqual({ serverUrl: "https://fusion.example.com", authToken: "abc" });
|
||||
});
|
||||
|
||||
it("parses URL payload", () => {
|
||||
const parsed = parseQrConnectionPayload("https://fusion.example.com/dashboard?authToken=abc");
|
||||
expect(parsed).toEqual({ serverUrl: "https://fusion.example.com", authToken: "abc" });
|
||||
});
|
||||
|
||||
it("uses adapter scanning", async () => {
|
||||
const scanner = new QrScanner({ scan: vi.fn(async () => "https://fusion.example.com") });
|
||||
await expect(scanner.scanConnection()).resolves.toEqual({ serverUrl: "https://fusion.example.com", authToken: null });
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type PushNotificationManagerOptions,
|
||||
} from "./plugins/push-notifications.js";
|
||||
import { ShareManager, type ShareManagerOptions } from "./plugins/share.js";
|
||||
import { MobileNativeShellBridge } from "./plugins/native-shell.js";
|
||||
|
||||
export { DeepLinkManager } from "./plugins/deep-links.js";
|
||||
export type {
|
||||
@@ -20,12 +21,28 @@ export type {
|
||||
PushNotificationManagerOptions,
|
||||
} from "./plugins/push-notifications.js";
|
||||
export { ShareManager } from "./plugins/share.js";
|
||||
export { MobileNativeShellBridge } from "./plugins/native-shell.js";
|
||||
export { QrScanner, parseQrConnectionPayload } from "./plugins/qr-scanner.js";
|
||||
export {
|
||||
loadShellProfiles,
|
||||
listShellProfiles,
|
||||
saveShellProfile,
|
||||
deleteShellProfile,
|
||||
setActiveShellProfile,
|
||||
} from "./plugins/connection-profiles.js";
|
||||
export type {
|
||||
ShareEventMap,
|
||||
ShareManagerOptions,
|
||||
ShareTaskPayload,
|
||||
} from "./plugins/share.js";
|
||||
export type { MobilePluginManager, PluginEventMap } from "./types.js";
|
||||
export type {
|
||||
FusionShellApi,
|
||||
MobilePluginManager,
|
||||
PluginEventMap,
|
||||
ShellConnectionProfile,
|
||||
ShellConnectionProfileInput,
|
||||
ShellConnectionState,
|
||||
} from "./types.js";
|
||||
|
||||
interface LifecycleManager {
|
||||
initialize?: () => Promise<void>;
|
||||
@@ -58,6 +75,14 @@ async function initializeManager(manager: LifecycleManager): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export function installMobileShellBridge(
|
||||
target: Window & typeof globalThis = window,
|
||||
): MobileNativeShellBridge {
|
||||
const bridge = new MobileNativeShellBridge();
|
||||
(target as Window & { fusionShell?: MobileNativeShellBridge }).fusionShell = bridge;
|
||||
return bridge;
|
||||
}
|
||||
|
||||
export async function initializePlugins(
|
||||
options: InitializePluginsOptions = {},
|
||||
): Promise<InitializePluginsResult> {
|
||||
|
||||
104
packages/mobile/src/plugins/connection-profiles.ts
Normal file
104
packages/mobile/src/plugins/connection-profiles.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Preferences } from "@capacitor/preferences";
|
||||
import type { ShellConnectionProfile, ShellConnectionProfileInput } from "../types.js";
|
||||
|
||||
const STORAGE_KEY = "fusion.shell.connections.v1";
|
||||
|
||||
interface PersistedShellState {
|
||||
activeProfileId: string | null;
|
||||
profiles: ShellConnectionProfile[];
|
||||
}
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function createId(): string {
|
||||
return `profile_${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
function normalizeUrl(serverUrl: string): string {
|
||||
return serverUrl.trim().replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function toPersisted(input: unknown): PersistedShellState {
|
||||
if (!input || typeof input !== "object") {
|
||||
return { activeProfileId: null, profiles: [] };
|
||||
}
|
||||
|
||||
const candidate = input as Partial<PersistedShellState>;
|
||||
const profiles = Array.isArray(candidate.profiles) ? candidate.profiles.filter((profile) => profile && typeof profile === "object") as ShellConnectionProfile[] : [];
|
||||
return {
|
||||
activeProfileId: typeof candidate.activeProfileId === "string" ? candidate.activeProfileId : null,
|
||||
profiles,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadShellProfiles(): Promise<PersistedShellState> {
|
||||
const { value } = await Preferences.get({ key: STORAGE_KEY });
|
||||
if (!value) {
|
||||
return { activeProfileId: null, profiles: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
return toPersisted(JSON.parse(value));
|
||||
} catch {
|
||||
return { activeProfileId: null, profiles: [] };
|
||||
}
|
||||
}
|
||||
|
||||
async function saveShellState(state: PersistedShellState): Promise<void> {
|
||||
await Preferences.set({ key: STORAGE_KEY, value: JSON.stringify(state) });
|
||||
}
|
||||
|
||||
export async function listShellProfiles(): Promise<ShellConnectionProfile[]> {
|
||||
const state = await loadShellProfiles();
|
||||
return state.profiles;
|
||||
}
|
||||
|
||||
export async function saveShellProfile(input: ShellConnectionProfileInput): Promise<ShellConnectionProfile> {
|
||||
const state = await loadShellProfiles();
|
||||
const existing = input.id ? state.profiles.find((p) => p.id === input.id) : undefined;
|
||||
const timestamp = nowIso();
|
||||
|
||||
const profile: ShellConnectionProfile = {
|
||||
id: existing?.id ?? input.id ?? createId(),
|
||||
name: input.name.trim(),
|
||||
serverUrl: normalizeUrl(input.serverUrl),
|
||||
authToken: input.authToken ?? null,
|
||||
createdAt: existing?.createdAt ?? timestamp,
|
||||
updatedAt: timestamp,
|
||||
lastUsedAt: existing?.lastUsedAt ?? null,
|
||||
};
|
||||
|
||||
const profiles = existing
|
||||
? state.profiles.map((item) => (item.id === existing.id ? profile : item))
|
||||
: [...state.profiles, profile];
|
||||
|
||||
await saveShellState({ ...state, profiles });
|
||||
return profile;
|
||||
}
|
||||
|
||||
export async function deleteShellProfile(profileId: string): Promise<void> {
|
||||
const state = await loadShellProfiles();
|
||||
const profiles = state.profiles.filter((profile) => profile.id !== profileId);
|
||||
const activeProfileId = state.activeProfileId === profileId ? null : state.activeProfileId;
|
||||
await saveShellState({ activeProfileId, profiles });
|
||||
}
|
||||
|
||||
export async function setActiveShellProfile(profileId: string | null): Promise<PersistedShellState> {
|
||||
const state = await loadShellProfiles();
|
||||
const activeProfileId =
|
||||
profileId && state.profiles.some((profile) => profile.id === profileId)
|
||||
? profileId
|
||||
: null;
|
||||
|
||||
const profiles = state.profiles.map((profile) =>
|
||||
profile.id === activeProfileId
|
||||
? { ...profile, lastUsedAt: nowIso(), updatedAt: nowIso() }
|
||||
: profile,
|
||||
);
|
||||
|
||||
const next = { activeProfileId, profiles };
|
||||
await saveShellState(next);
|
||||
return next;
|
||||
}
|
||||
82
packages/mobile/src/plugins/native-shell.ts
Normal file
82
packages/mobile/src/plugins/native-shell.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type {
|
||||
FusionShellApi,
|
||||
ShellConnectionProfile,
|
||||
ShellConnectionProfileInput,
|
||||
ShellConnectionState,
|
||||
} from "../types.js";
|
||||
import {
|
||||
deleteShellProfile,
|
||||
listShellProfiles,
|
||||
loadShellProfiles,
|
||||
saveShellProfile,
|
||||
setActiveShellProfile,
|
||||
} from "./connection-profiles.js";
|
||||
import { QrScanner, type QrScanResult } from "./qr-scanner.js";
|
||||
|
||||
type Listener = (state: ShellConnectionState) => void;
|
||||
|
||||
export class MobileNativeShellBridge implements FusionShellApi {
|
||||
private listeners = new Set<Listener>();
|
||||
|
||||
constructor(private readonly qrScanner: QrScanner = new QrScanner()) {}
|
||||
|
||||
private async buildState(): Promise<ShellConnectionState> {
|
||||
const persisted = await loadShellProfiles();
|
||||
return {
|
||||
host: "mobile-shell",
|
||||
activeProfileId: persisted.activeProfileId,
|
||||
profiles: persisted.profiles,
|
||||
};
|
||||
}
|
||||
|
||||
private async emitState(): Promise<ShellConnectionState> {
|
||||
const state = await this.buildState();
|
||||
for (const listener of this.listeners) {
|
||||
listener(state);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
getState(): Promise<ShellConnectionState> {
|
||||
return this.buildState();
|
||||
}
|
||||
|
||||
listProfiles(): Promise<ShellConnectionProfile[]> {
|
||||
return listShellProfiles();
|
||||
}
|
||||
|
||||
async saveProfile(profile: ShellConnectionProfileInput): Promise<ShellConnectionProfile> {
|
||||
const saved = await saveShellProfile(profile);
|
||||
await this.emitState();
|
||||
return saved;
|
||||
}
|
||||
|
||||
async deleteProfile(profileId: string): Promise<void> {
|
||||
await deleteShellProfile(profileId);
|
||||
await this.emitState();
|
||||
}
|
||||
|
||||
setActiveProfile(profileId: string | null): Promise<ShellConnectionState> {
|
||||
return setActiveShellProfile(profileId).then(() => this.emitState());
|
||||
}
|
||||
|
||||
setDesktopMode(): Promise<ShellConnectionState> {
|
||||
return Promise.reject(new Error("Desktop mode is not supported in mobile shell"));
|
||||
}
|
||||
|
||||
startQrScan(): Promise<QrScanResult> {
|
||||
return this.qrScanner.scanConnection();
|
||||
}
|
||||
|
||||
async openConnectionManager(): Promise<void> {
|
||||
// Handled by dashboard shell context state.
|
||||
}
|
||||
|
||||
subscribe(listener: Listener): () => void {
|
||||
this.listeners.add(listener);
|
||||
void this.getState().then(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
}
|
||||
53
packages/mobile/src/plugins/qr-scanner.ts
Normal file
53
packages/mobile/src/plugins/qr-scanner.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
export interface QrScanResult {
|
||||
serverUrl: string;
|
||||
authToken?: string | null;
|
||||
}
|
||||
|
||||
export interface QrScannerAdapter {
|
||||
scan(): Promise<string>;
|
||||
}
|
||||
|
||||
function parsePayload(raw: string): QrScanResult {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("QR scan returned empty payload");
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as Partial<QrScanResult>;
|
||||
if (typeof parsed.serverUrl === "string" && parsed.serverUrl.trim().length > 0) {
|
||||
return {
|
||||
serverUrl: parsed.serverUrl.trim(),
|
||||
authToken: parsed.authToken ?? null,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Fall through to URL parsing.
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
const authToken = url.searchParams.get("authToken");
|
||||
return {
|
||||
serverUrl: `${url.protocol}//${url.host}`,
|
||||
authToken,
|
||||
};
|
||||
} catch {
|
||||
throw new Error("QR payload is not a valid Fusion connection payload");
|
||||
}
|
||||
}
|
||||
|
||||
export class QrScanner {
|
||||
constructor(private readonly adapter?: QrScannerAdapter) {}
|
||||
|
||||
async scanConnection(): Promise<QrScanResult> {
|
||||
if (!this.adapter) {
|
||||
throw new Error("QR scanner is not available on this platform");
|
||||
}
|
||||
|
||||
const raw = await this.adapter.scan();
|
||||
return parsePayload(raw);
|
||||
}
|
||||
}
|
||||
|
||||
export { parsePayload as parseQrConnectionPayload };
|
||||
@@ -6,3 +6,46 @@ export interface MobilePluginManager {
|
||||
start(): Promise<void>;
|
||||
destroy(): void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface ShellConnectionProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
serverUrl: string;
|
||||
authToken?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastUsedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface ShellConnectionProfileInput {
|
||||
id?: string;
|
||||
name: string;
|
||||
serverUrl: string;
|
||||
authToken?: string | null;
|
||||
}
|
||||
|
||||
export type ShellHost = "web" | "mobile-shell" | "desktop-shell";
|
||||
|
||||
export interface ShellConnectionState {
|
||||
host: ShellHost;
|
||||
desktopMode?: "local" | "remote";
|
||||
activeProfileId: string | null;
|
||||
profiles: ShellConnectionProfile[];
|
||||
localServer?: {
|
||||
status: "idle" | "starting" | "ready" | "error";
|
||||
port?: number;
|
||||
error?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface FusionShellApi {
|
||||
getState(): Promise<ShellConnectionState>;
|
||||
listProfiles(): Promise<ShellConnectionProfile[]>;
|
||||
saveProfile(profile: ShellConnectionProfileInput): Promise<ShellConnectionProfile>;
|
||||
deleteProfile(profileId: string): Promise<void>;
|
||||
setActiveProfile(profileId: string | null): Promise<ShellConnectionState>;
|
||||
setDesktopMode(mode: "local" | "remote"): Promise<ShellConnectionState>;
|
||||
startQrScan(): Promise<{ serverUrl: string; authToken?: string | null }>;
|
||||
openConnectionManager(): Promise<void>;
|
||||
subscribe(listener: (state: ShellConnectionState) => void): () => void;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user