feat(FN-3403): add shell multi-profile support with connection manager onbo

Merges shell multi-profile support for desktop (FN-3403) — spanning new connection-manager flows, shell onboarding interoperability, mobile connection profiles, desktop IPC/shell-settings plumbing, and tokenized titlebar styles — with a secondary migration of the WhatsApp plugin to the Baileys pairi

Fusion-Task-Id: FN-3403
This commit is contained in:
Fusion
2026-05-07 18:32:09 -07:00
committed by gsxdsm
parent a3572156e3
commit 7b9e5259d3
22 changed files with 877 additions and 115 deletions

View File

@@ -85,6 +85,7 @@ For the canonical flow (QR/manual setup, saved profiles, active-profile behavior
Implementation notes: Implementation notes:
- Mobile shell profiles are persisted in shell-local storage (Capacitor Preferences), separate from Fusion project/global settings. - Mobile shell profiles are persisted in shell-local storage (Capacitor Preferences), separate from Fusion project/global settings.
- Active-profile deletion fallback is shell-owned: deleting the active profile promotes the first remaining profile, and deleting the final profile resets to a clean empty state.
- The dashboard consumes this through the shared `window.fusionShell` connection APIs. - The dashboard consumes this through the shared `window.fusionShell` connection APIs.
### Planning Mode ### Planning Mode

View File

@@ -8,7 +8,8 @@ When running inside Fusion mobile or desktop shells, the dashboard uses a host-n
- Shell host detection: `web | mobile-shell | desktop-shell` - Shell host detection: `web | mobile-shell | desktop-shell`
- Shell-first onboarding gate: native-shell connection onboarding runs before dashboard model onboarding when needed - 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 - Connection management: header status + native-shell connection manager for add/edit/delete/switch of saved profiles; desktop also supports local/remote mode switching
- Browser fallback: when `window.fusionShell` is unavailable, shell profile actions stay disabled/unsupported while dashboard onboarding and core task flows remain stable
- Desktop local mode handoff uses dynamic local server port resolution (`getServerPort`) while remote mode points to the active remote profile - 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 - Browser/PWA mode degrades cleanly when `window.fusionShell` is absent

View File

@@ -32,6 +32,26 @@ describe("App shell onboarding gating", () => {
).toBe(false); ).toBe(false);
}); });
it("skips onboarding when an active shell profile exists", () => {
expect(
requiresNativeShellOnboarding(
{ host: "mobile-shell", activeProfileId: "profile-1" },
true,
false,
),
).toBe(false);
});
it("re-requires onboarding after active profile is removed", () => {
expect(
requiresNativeShellOnboarding(
{ host: "desktop-shell", desktopMode: "remote", activeProfileId: null },
true,
false,
),
).toBe(true);
});
it("skips onboarding for web host", () => { it("skips onboarding for web host", () => {
expect( expect(
requiresNativeShellOnboarding( requiresNativeShellOnboarding(

View File

@@ -32,6 +32,7 @@ import { CustomProviderForm } from "./CustomProviderForm";
import { PluginSlot } from "./PluginSlot"; import { PluginSlot } from "./PluginSlot";
import { appendTokenQuery } from "../auth"; import { appendTokenQuery } from "../auth";
import { filterVisibleOnboardingAndSettingsProviders } from "./providerVisibility"; import { filterVisibleOnboardingAndSettingsProviders } from "./providerVisibility";
import { useShellConnection } from "../hooks/useShellConnection";
const mapLegacyCustomProviderToConfig = ( const mapLegacyCustomProviderToConfig = (
provider: CustomProvider | CustomProviderConfig, provider: CustomProvider | CustomProviderConfig,
@@ -594,6 +595,11 @@ export function ModelOnboardingModal({
const [showCustomProviderForm, setShowCustomProviderForm] = useState(false); const [showCustomProviderForm, setShowCustomProviderForm] = useState(false);
const [customProviderSaving, setCustomProviderSaving] = useState(false); const [customProviderSaving, setCustomProviderSaving] = useState(false);
const [customProviderError, setCustomProviderError] = useState<string | undefined>(); const [customProviderError, setCustomProviderError] = useState<string | undefined>();
const [shellProfileName, setShellProfileName] = useState("Remote Server");
const [shellServerUrl, setShellServerUrl] = useState("");
const [shellAuthToken, setShellAuthToken] = useState("");
const [shellConnectionSaving, setShellConnectionSaving] = useState(false);
const [shellConnectionError, setShellConnectionError] = useState<string | null>(null);
const apiKeySuccessTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({}); const apiKeySuccessTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
const onboardingContentRef = useRef<HTMLDivElement | null>(null); const onboardingContentRef = useRef<HTMLDivElement | null>(null);
const modalRef = useRef<HTMLDivElement | null>(null); const modalRef = useRef<HTMLDivElement | null>(null);
@@ -605,6 +611,7 @@ export function ModelOnboardingModal({
}); });
const pollCountRef = useRef<number>(0); const pollCountRef = useRef<number>(0);
const previousCreatedTaskRef = useRef<Task | null | undefined>(firstCreatedTask); const previousCreatedTaskRef = useRef<Task | null | undefined>(firstCreatedTask);
const { shellApi, state: shellState } = useShellConnection();
const hasTrackedWizardOpenRef = useRef(false); const hasTrackedWizardOpenRef = useRef(false);
const resumedFromStep = persistedState?.currentStep; const resumedFromStep = persistedState?.currentStep;
const isResumedFlow = !!persistedState && persistedState.currentStep !== "complete"; const isResumedFlow = !!persistedState && persistedState.currentStep !== "complete";
@@ -1624,11 +1631,10 @@ export function ModelOnboardingModal({
onComplete(); onComplete();
}, [completeOnboarding, onComplete]); }, [completeOnboarding, onComplete]);
if (!isOpen) return null;
const githubStatus = getGitHubStatus(); const githubStatus = getGitHubStatus();
const aiProviders = authProviders.filter((provider) => provider.id !== "github"); const aiProviders = authProviders.filter((provider) => provider.id !== "github");
const showShellConnectionSetup = shellState.host !== "web" && !shellState.activeProfileId;
const orderedAiProviders = [...aiProviders].sort(compareOnboardingProviders); const orderedAiProviders = [...aiProviders].sort(compareOnboardingProviders);
const hasOauthProviders = orderedAiProviders.some((provider) => !provider.type || provider.type === "oauth"); const hasOauthProviders = orderedAiProviders.some((provider) => !provider.type || provider.type === "oauth");
const hasApiKeyProviders = orderedAiProviders.some((provider) => provider.type === "api_key"); const hasApiKeyProviders = orderedAiProviders.some((provider) => provider.type === "api_key");
@@ -1638,6 +1644,36 @@ export function ModelOnboardingModal({
// True when on GitHub step but skipped AI setup (no AI provider connected) // True when on GitHub step but skipped AI setup (no AI provider connected)
const aiSetupSkipped = step === "github" && !hasAiProvider; const aiSetupSkipped = step === "github" && !hasAiProvider;
const saveShellConnectionProfile = useCallback(async () => {
if (!shellApi) {
return;
}
setShellConnectionError(null);
setShellConnectionSaving(true);
try {
const saved = await shellApi.saveProfile({
name: shellProfileName,
serverUrl: shellServerUrl,
authToken: shellAuthToken.trim() ? shellAuthToken : null,
});
try {
await shellApi.setActiveProfile(saved.id);
} catch (error) {
setShellConnectionError(getErrorMessage(error) || "Saved profile but failed to activate it");
return;
}
setShellServerUrl("");
setShellAuthToken("");
addToast("Remote server profile saved", "success");
} catch (error) {
setShellConnectionError(getErrorMessage(error) || "Failed to save shell connection");
} finally {
setShellConnectionSaving(false);
}
}, [shellApi, shellProfileName, shellServerUrl, shellAuthToken, addToast]);
if (!isOpen) return null;
const selectedModelDisplayName = (() => { const selectedModelDisplayName = (() => {
if (!selectedModel) { if (!selectedModel) {
return ""; return "";
@@ -2071,6 +2107,52 @@ export function ModelOnboardingModal({
Research runs require provider credentials and an enabled Research View. After onboarding, verify these in Settings Authentication and Settings Experimental Features. Research runs require provider credentials and an enabled Research View. After onboarding, verify these in Settings Authentication and Settings Experimental Features.
</p> </p>
{showShellConnectionSetup && (
<div className="card">
<h3 className="onboarding-section-title">Connect remote Fusion server</h3>
<p className="onboarding-helper-text">
Your native shell needs an active remote profile before dashboard handoff can complete.
</p>
<label htmlFor="onboarding-shell-profile-name" className="onboarding-apikey-field-label">Profile name</label>
<input
id="onboarding-shell-profile-name"
className="input"
value={shellProfileName}
onChange={(event) => setShellProfileName(event.target.value)}
/>
<label htmlFor="onboarding-shell-server-url" className="onboarding-apikey-field-label">Server URL</label>
<input
id="onboarding-shell-server-url"
className="input"
placeholder="https://your-fusion-host"
value={shellServerUrl}
onChange={(event) => setShellServerUrl(event.target.value)}
/>
<label htmlFor="onboarding-shell-token" className="onboarding-apikey-field-label">Auth token (optional)</label>
<input
id="onboarding-shell-token"
className="input"
type="password"
value={shellAuthToken}
onChange={(event) => setShellAuthToken(event.target.value)}
/>
{shellConnectionError && <small className="field-error">{shellConnectionError}</small>}
<div className="onboarding-apikey-input-row">
<button type="button" className="btn btn-sm" onClick={() => void shellApi?.openConnectionManager()}>
Open manager
</button>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() => void saveShellConnectionProfile()}
disabled={!shellServerUrl.trim() || shellConnectionSaving}
>
{shellConnectionSaving ? "Saving…" : "Save remote server"}
</button>
</div>
</div>
)}
{/* Provider connection status summary */} {/* Provider connection status summary */}
{!authLoading && authProviders.length > 0 && ( {!authLoading && authProviders.length > 0 && (
(() => { (() => {

View File

@@ -33,9 +33,36 @@
padding-top: var(--space-lg); padding-top: var(--space-lg);
} }
.native-shell-connection-manager__empty-state {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.native-shell-connection-manager__active-pill {
display: inline-flex;
margin-top: var(--space-xs);
padding: 0 var(--space-sm);
border-radius: var(--radius-pill);
color: var(--text);
background: var(--status-done-bg);
}
.native-shell-connection-manager__delete-confirm {
margin: 0 var(--space-xl);
padding: var(--space-md);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--color-warning) 8%, transparent);
}
@media (max-width: 768px) { @media (max-width: 768px) {
.native-shell-connection-manager__profile { .native-shell-connection-manager__profile {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
} }
.native-shell-connection-manager__mode-row {
flex-wrap: wrap;
}
} }

View File

@@ -16,6 +16,7 @@ export function NativeShellConnectionManager({ open, shellApi, shellState, onClo
); );
const [editingProfileId, setEditingProfileId] = useState<string | null>(null); const [editingProfileId, setEditingProfileId] = useState<string | null>(null);
const [draft, setDraft] = useState<Partial<ShellConnectionProfile>>({}); const [draft, setDraft] = useState<Partial<ShellConnectionProfile>>({});
const [deleteCandidate, setDeleteCandidate] = useState<ShellConnectionProfile | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
if (!open) return null; if (!open) return null;
@@ -28,10 +29,15 @@ export function NativeShellConnectionManager({ open, shellApi, shellState, onClo
const workingUrl = draft.serverUrl ?? editingProfile?.serverUrl ?? ""; const workingUrl = draft.serverUrl ?? editingProfile?.serverUrl ?? "";
const workingToken = draft.authToken ?? editingProfile?.authToken ?? ""; const workingToken = draft.authToken ?? editingProfile?.authToken ?? "";
const resetEditor = () => {
setEditingProfileId(null);
setDraft({});
setError(null);
};
const saveCurrent = async () => { const saveCurrent = async () => {
setError(null); setError(null);
try { try {
// Early validation for user feedback before bridge persistence rejects.
const parsed = new URL(workingUrl.trim()); const parsed = new URL(workingUrl.trim());
if (!/^https?:$/.test(parsed.protocol)) { if (!/^https?:$/.test(parsed.protocol)) {
throw new Error("Server URL must use http or https"); throw new Error("Server URL must use http or https");
@@ -50,6 +56,30 @@ export function NativeShellConnectionManager({ open, shellApi, shellState, onClo
} }
}; };
const handleScanQr = async () => {
setError(null);
try {
const result = await shellApi.startQrScan();
setEditingProfileId("__new__");
setDraft({
name: "",
serverUrl: result.serverUrl,
authToken: result.authToken ?? "",
});
} catch (nextError) {
setError((nextError as Error).message);
}
};
const handleConfirmDelete = async () => {
if (!deleteCandidate) {
return;
}
await shellApi.deleteProfile(deleteCandidate.id);
setDeleteCandidate(null);
resetEditor();
};
return ( return (
<div className="modal-overlay open"> <div className="modal-overlay open">
<div className="modal native-shell-connection-manager" role="dialog" aria-label="Connection Manager"> <div className="modal native-shell-connection-manager" role="dialog" aria-label="Connection Manager">
@@ -68,29 +98,54 @@ export function NativeShellConnectionManager({ open, shellApi, shellState, onClo
)} )}
<div className="native-shell-connection-manager__profiles"> <div className="native-shell-connection-manager__profiles">
{shellState.profiles.map((profile) => ( {shellState.profiles.length === 0 ? (
<div className="card native-shell-connection-manager__profile" key={profile.id}> <div className="card native-shell-connection-manager__empty-state">
<div> <p className="settings-muted">No remote servers saved yet.</p>
<strong>{profile.name}</strong>
<div className="settings-muted">{profile.serverUrl}</div>
</div>
<div className="native-shell-connection-manager__profile-actions"> <div className="native-shell-connection-manager__profile-actions">
<button <button
type="button" type="button"
className="btn btn-sm" className="btn btn-sm"
aria-label={`Edit ${profile.name}`}
onClick={() => { onClick={() => {
setEditingProfileId(profile.id); setEditingProfileId("__new__");
setDraft(profile); setDraft({ name: "", serverUrl: "", authToken: "" });
setError(null);
}} }}
> >
Edit Add server
</button> </button>
<button type="button" className="btn btn-sm" aria-label={`Use ${profile.name}`} onClick={() => void shellApi.setActiveProfile(profile.id)}>Use</button> {shellState.host === "mobile-shell" && (
<button type="button" className="btn btn-sm btn-danger" aria-label={`Delete ${profile.name}`} onClick={() => void shellApi.deleteProfile(profile.id)}>Delete</button> <button type="button" className="btn btn-sm" onClick={() => void handleScanQr()}>
Scan QR
</button>
)}
</div> </div>
</div> </div>
))} ) : (
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>
{profile.id === shellState.activeProfileId && <span className="native-shell-connection-manager__active-pill">Active</span>}
</div>
<div className="native-shell-connection-manager__profile-actions">
<button
type="button"
className="btn btn-sm"
aria-label={`Edit ${profile.name}`}
onClick={() => {
setEditingProfileId(profile.id);
setDraft(profile);
}}
>
Edit
</button>
<button type="button" className="btn btn-sm" aria-label={`Use ${profile.name}`} onClick={() => void shellApi.setActiveProfile(profile.id)}>Use</button>
<button type="button" className="btn btn-sm btn-danger" aria-label={`Delete ${profile.name}`} onClick={() => setDeleteCandidate(profile)}>Delete</button>
</div>
</div>
))
)}
</div> </div>
<div className="native-shell-connection-manager__mode-row"> <div className="native-shell-connection-manager__mode-row">
@@ -106,7 +161,7 @@ export function NativeShellConnectionManager({ open, shellApi, shellState, onClo
Add connection Add connection
</button> </button>
{shellState.host === "mobile-shell" && ( {shellState.host === "mobile-shell" && (
<button type="button" className="btn" onClick={() => void shellApi.startQrScan()}> <button type="button" className="btn" onClick={() => void handleScanQr()}>
Scan QR Scan QR
</button> </button>
)} )}
@@ -122,8 +177,19 @@ export function NativeShellConnectionManager({ open, shellApi, shellState, onClo
{error && <p className="form-error" role="alert">{error}</p>} {error && <p className="form-error" role="alert">{error}</p>}
</div> </div>
{deleteCandidate && (
<div className="native-shell-connection-manager__delete-confirm" role="alertdialog" aria-label="Delete server confirmation">
<p>Delete <strong>{deleteCandidate.name}</strong>? This removes the saved profile.</p>
<div className="native-shell-connection-manager__profile-actions">
<button type="button" className="btn btn-sm" onClick={() => setDeleteCandidate(null)}>Cancel</button>
<button type="button" className="btn btn-sm btn-danger" onClick={() => void handleConfirmDelete()}>Delete</button>
</div>
</div>
)}
<div className="modal-actions"> <div className="modal-actions">
<button type="button" className="btn" onClick={onClose}>Close</button> <button type="button" className="btn" onClick={onClose}>Close</button>
<button type="button" className="btn" onClick={resetEditor}>Cancel</button>
<button type="button" className="btn btn-primary" onClick={() => void saveCurrent()} disabled={!workingUrl.trim()}>Save</button> <button type="button" className="btn btn-primary" onClick={() => void saveCurrent()} disabled={!workingUrl.trim()}>Save</button>
</div> </div>
</div> </div>

View File

@@ -21,6 +21,7 @@ const mockFetchCustomProviders = vi.fn();
const mockCreateCustomProvider = vi.fn(); const mockCreateCustomProvider = vi.fn();
const mockFetchCursorCliStatus = vi.fn(); const mockFetchCursorCliStatus = vi.fn();
const mockSetCursorCliEnabled = vi.fn(); const mockSetCursorCliEnabled = vi.fn();
const mockUseShellConnection = vi.fn();
vi.mock("../../api", () => ({ vi.mock("../../api", () => ({
fetchAuthStatus: (...args: unknown[]) => mockFetchAuthStatus(...args), fetchAuthStatus: (...args: unknown[]) => mockFetchAuthStatus(...args),
@@ -107,6 +108,10 @@ vi.mock("../onboarding-events", () => ({
})); }));
// Mock ProviderIcon for test isolation // Mock ProviderIcon for test isolation
vi.mock("../../hooks/useShellConnection", () => ({
useShellConnection: (...args: unknown[]) => mockUseShellConnection(...args),
}));
vi.mock("../ProviderIcon", () => ({ vi.mock("../ProviderIcon", () => ({
ProviderIcon: ({ provider, size }: { provider: string; size?: string }) => ( ProviderIcon: ({ provider, size }: { provider: string; size?: string }) => (
<span data-testid="provider-icon" data-provider={provider} data-size={size}> <span data-testid="provider-icon" data-provider={provider} data-size={size}>
@@ -207,6 +212,15 @@ beforeEach(() => {
mockMarkStepSkipped.mockImplementation(() => {}); mockMarkStepSkipped.mockImplementation(() => {});
mockGetSkippedSteps.mockReturnValue([]); mockGetSkippedSteps.mockReturnValue([]);
mockGetStepData.mockReturnValue(null); mockGetStepData.mockReturnValue(null);
mockUseShellConnection.mockReturnValue({
shellApi: null,
ready: true,
openConnectionManagerSignal: 0,
state: { host: "web", activeProfileId: null, profiles: [] },
saveProfile: vi.fn(),
removeProfile: vi.fn(),
setActiveProfile: vi.fn(),
});
// Reset mockFetchAuthStatus to default - use mockImplementation for clear control // Reset mockFetchAuthStatus to default - use mockImplementation for clear control
mockFetchAuthStatus.mockReset(); mockFetchAuthStatus.mockReset();
mockFetchAuthStatus.mockImplementation(() => Promise.resolve({ providers: defaultAuthProviders })); mockFetchAuthStatus.mockImplementation(() => Promise.resolve({ providers: defaultAuthProviders }));
@@ -4300,3 +4314,4 @@ describe("Custom providers disclosure", () => {
}); });
}); });
}); });

View File

@@ -16,7 +16,7 @@ function createShellApi() {
deleteProfile: vi.fn(async () => undefined), deleteProfile: vi.fn(async () => undefined),
setActiveProfile: vi.fn(async () => ({ host: "mobile-shell", activeProfileId: "p1", profiles: [] })), setActiveProfile: vi.fn(async () => ({ host: "mobile-shell", activeProfileId: "p1", profiles: [] })),
setDesktopMode: vi.fn(async () => ({ host: "desktop-shell", desktopMode: "remote", activeProfileId: null, profiles: [] })), setDesktopMode: vi.fn(async () => ({ host: "desktop-shell", desktopMode: "remote", activeProfileId: null, profiles: [] })),
startQrScan: vi.fn(), startQrScan: vi.fn(async () => ({ serverUrl: "https://qr.example.com", authToken: "token" })),
openConnectionManager: vi.fn(), openConnectionManager: vi.fn(),
subscribe: vi.fn(() => () => undefined), subscribe: vi.fn(() => () => undefined),
}; };
@@ -38,22 +38,23 @@ describe("NativeShellConnectionManager", () => {
await waitFor(() => expect(shellApi.setDesktopMode).toHaveBeenCalledWith("local")); await waitFor(() => expect(shellApi.setDesktopMode).toHaveBeenCalledWith("local"));
}); });
it("activates and deletes profiles", async () => { it("shows active profile indicator and requires delete confirmation", async () => {
const shellApi = createShellApi(); const shellApi = createShellApi();
render( render(
<NativeShellConnectionManager <NativeShellConnectionManager
open={true} open={true}
shellApi={shellApi} shellApi={shellApi}
shellState={{ host: "mobile-shell", activeProfileId: null, profiles: [{ id: "p1", name: "Prod", serverUrl: "https://fusion.example.com", authToken: null, createdAt: "", updatedAt: "" }] }} shellState={{ host: "mobile-shell", activeProfileId: "p1", profiles: [{ id: "p1", name: "Prod", serverUrl: "https://fusion.example.com", authToken: null, createdAt: "", updatedAt: "" }] }}
onClose={vi.fn()} onClose={vi.fn()}
/>, />,
); );
fireEvent.click(screen.getByLabelText("Use Prod")); expect(screen.getByText("Active")).toBeInTheDocument();
fireEvent.click(screen.getByLabelText("Delete Prod")); fireEvent.click(screen.getByLabelText("Delete Prod"));
expect(screen.getByRole("alertdialog", { name: "Delete server confirmation" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() => { await waitFor(() => {
expect(shellApi.setActiveProfile).toHaveBeenCalledWith("p1");
expect(shellApi.deleteProfile).toHaveBeenCalledWith("p1"); expect(shellApi.deleteProfile).toHaveBeenCalledWith("p1");
}); });
}); });
@@ -78,25 +79,23 @@ describe("NativeShellConnectionManager", () => {
}); });
}); });
it("adds a new connection", async () => { it("supports empty-state recovery and QR import", async () => {
const shellApi = createShellApi(); const shellApi = createShellApi();
render( render(
<NativeShellConnectionManager <NativeShellConnectionManager
open={true} open={true}
shellApi={shellApi} shellApi={shellApi}
shellState={{ host: "mobile-shell", activeProfileId: "p1", profiles: [{ id: "p1", name: "Prod", serverUrl: "https://fusion.example.com", authToken: null, createdAt: "", updatedAt: "" }] }} shellState={{ host: "mobile-shell", activeProfileId: null, profiles: [] }}
onClose={vi.fn()} onClose={vi.fn()}
/>, />,
); );
fireEvent.click(screen.getByText("Add connection")); expect(screen.getByText("No remote servers saved yet.")).toBeInTheDocument();
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Staging" } }); fireEvent.click(screen.getAllByText("Scan QR")[0]!);
fireEvent.change(screen.getByLabelText("Server URL"), { target: { value: "https://staging.example.com" } });
fireEvent.click(screen.getByText("Save"));
await waitFor(() => { await waitFor(() => {
expect(shellApi.saveProfile).toHaveBeenCalledWith(expect.objectContaining({ id: undefined, name: "Staging", serverUrl: "https://staging.example.com" })); expect(shellApi.startQrScan).toHaveBeenCalled();
expect(shellApi.setActiveProfile).toHaveBeenCalledWith("p2"); expect(screen.getByDisplayValue("https://qr.example.com")).toBeInTheDocument();
}); });
}); });
}); });

View File

@@ -58,7 +58,14 @@ describe("NativeShellOnboardingModal", () => {
it("saves remote profile and redirects to remote dashboard", async () => { it("saves remote profile and redirects to remote dashboard", async () => {
const saveProfile = vi.fn(async () => ({ id: "p1", serverUrl: "https://fusion.example.com", authToken: "abc" })); const saveProfile = vi.fn(async () => ({ id: "p1", serverUrl: "https://fusion.example.com", authToken: "abc" }));
const setActiveProfile = vi.fn(async () => ({ host: "mobile-shell", activeProfileId: "p1", profiles: [] })); const setActiveProfile = vi.fn(async () => ({
host: "mobile-shell",
activeProfileId: "p1",
profiles: [
{ id: "existing", name: "Existing", serverUrl: "https://existing.example.com", createdAt: "", updatedAt: "" },
{ id: "p1", name: "Remote Server", serverUrl: "https://fusion.example.com", createdAt: "", updatedAt: "" },
],
}));
const onComplete = vi.fn(); const onComplete = vi.fn();
const originalLocation = window.location; const originalLocation = window.location;
Object.defineProperty(window, "location", { Object.defineProperty(window, "location", {
@@ -80,7 +87,11 @@ describe("NativeShellOnboardingModal", () => {
openConnectionManager: vi.fn(), openConnectionManager: vi.fn(),
subscribe: vi.fn(() => () => undefined), subscribe: vi.fn(() => () => undefined),
}} }}
shellState={{ host: "mobile-shell", activeProfileId: null, profiles: [] }} shellState={{
host: "mobile-shell",
activeProfileId: "existing",
profiles: [{ id: "existing", name: "Existing", serverUrl: "https://existing.example.com", createdAt: "", updatedAt: "" }],
}}
onComplete={onComplete} onComplete={onComplete}
/>, />,
); );
@@ -89,7 +100,7 @@ describe("NativeShellOnboardingModal", () => {
fireEvent.click(screen.getByText("Continue")); fireEvent.click(screen.getByText("Continue"));
await waitFor(() => { await waitFor(() => {
expect(saveProfile).toHaveBeenCalled(); expect(saveProfile).toHaveBeenCalledWith(expect.objectContaining({ name: "Remote Server", serverUrl: "https://fusion.example.com" }));
expect(setActiveProfile).toHaveBeenCalledWith("p1"); expect(setActiveProfile).toHaveBeenCalledWith("p1");
expect(window.location.href).toContain("https://fusion.example.com"); expect(window.location.href).toContain("https://fusion.example.com");
expect(window.location.href).toContain("rt=abc"); expect(window.location.href).toContain("rt=abc");

View File

@@ -0,0 +1,57 @@
import { renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { useShellConnection } from "../useShellConnection";
const { mockUseShellContext } = vi.hoisted(() => ({
mockUseShellContext: vi.fn(),
}));
vi.mock("../../context/ShellContext", () => ({
useShellContext: mockUseShellContext,
}));
describe("useShellConnection", () => {
it("normalizes invalid active profile and exposes helper actions", async () => {
const shellApi = {
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: [] })),
};
mockUseShellContext.mockReturnValue({
shellApi,
ready: true,
openConnectionManagerSignal: 0,
state: {
host: "mobile-shell",
activeProfileId: "missing",
profiles: [{ id: "p1", name: "Prod", serverUrl: "https://fusion.example.com", createdAt: "", updatedAt: "" }],
},
});
const { result } = renderHook(() => useShellConnection());
expect(result.current.state.activeProfileId).toBeNull();
await result.current.saveProfile({ name: "Prod", serverUrl: "https://fusion.example.com" });
await result.current.removeProfile("p1");
await result.current.setActiveProfile("p1");
expect(shellApi.saveProfile).toHaveBeenCalled();
expect(shellApi.deleteProfile).toHaveBeenCalledWith("p1");
expect(shellApi.setActiveProfile).toHaveBeenCalledWith("p1");
});
it("keeps browser mode stable", async () => {
mockUseShellContext.mockReturnValue({
shellApi: null,
ready: true,
openConnectionManagerSignal: 0,
state: { host: "web", activeProfileId: null, profiles: [] },
});
const { result } = renderHook(() => useShellConnection());
await expect(result.current.saveProfile({ name: "Prod", serverUrl: "https://fusion.example.com" })).rejects.toThrow(
"Saving connection profiles is only available in native shell mode",
);
});
});

View File

@@ -1,5 +1,25 @@
import { useCallback } from "react";
import { useShellContext } from "../context/ShellContext"; import { useShellContext } from "../context/ShellContext";
import {
createOrUpdateProfile,
deleteProfile,
normalizeShellState,
selectActiveProfile,
} from "../utils/shell-connection-settings";
import type { ShellConnectionProfileInput } from "../types/native-shell";
export function useShellConnection() { export function useShellConnection() {
return useShellContext(); const context = useShellContext();
const saveProfile = useCallback((profile: ShellConnectionProfileInput) => createOrUpdateProfile(context.shellApi, profile), [context.shellApi]);
const removeProfile = useCallback((profileId: string) => deleteProfile(context.shellApi, profileId), [context.shellApi]);
const setActiveProfile = useCallback((profileId: string | null) => selectActiveProfile(context.shellApi, profileId), [context.shellApi]);
return {
...context,
state: normalizeShellState(context.state),
saveProfile,
removeProfile,
setActiveProfile,
};
} }

View File

@@ -0,0 +1,51 @@
import { describe, expect, it, vi } from "vitest";
import {
createOrUpdateProfile,
DEFAULT_WEB_SHELL_STATE,
deleteProfile,
normalizeShellState,
selectActiveProfile,
} from "../shell-connection-settings";
describe("shell-connection-settings", () => {
it("normalizes missing active profile to null", () => {
const state = normalizeShellState({
host: "mobile-shell",
activeProfileId: "missing",
profiles: [{ id: "p1", name: "Prod", serverUrl: "https://fusion.example.com", createdAt: "", updatedAt: "" }],
});
expect(state.activeProfileId).toBeNull();
expect(state.profiles).toHaveLength(1);
});
it("returns web fallback for empty shell state", () => {
expect(normalizeShellState(undefined)).toEqual(DEFAULT_WEB_SHELL_STATE);
});
it("throws unsupported errors in browser mode", async () => {
await expect(createOrUpdateProfile(null, { name: "Prod", serverUrl: "https://fusion.example.com" })).rejects.toThrow(
"Saving connection profiles is only available in native shell mode",
);
await expect(deleteProfile(null, "p1")).rejects.toThrow("Deleting connection profiles is only available in native shell mode");
await expect(selectActiveProfile(null, "p1")).rejects.toThrow(
"Switching connection profiles is only available in native shell mode",
);
});
it("delegates profile actions to shell API", async () => {
const shellApi = {
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: [] })),
} as const;
await createOrUpdateProfile(shellApi as never, { name: "Prod", serverUrl: "https://fusion.example.com" });
await deleteProfile(shellApi as never, "p1");
await selectActiveProfile(shellApi as never, "p1");
expect(shellApi.saveProfile).toHaveBeenCalled();
expect(shellApi.deleteProfile).toHaveBeenCalledWith("p1");
expect(shellApi.setActiveProfile).toHaveBeenCalledWith("p1");
});
});

View File

@@ -0,0 +1,64 @@
import type {
FusionShellApi,
ShellConnectionProfile,
ShellConnectionProfileInput,
ShellConnectionState,
} from "../types/native-shell";
export const DEFAULT_WEB_SHELL_STATE: ShellConnectionState = {
host: "web",
activeProfileId: null,
profiles: [],
};
export function normalizeShellState(state: ShellConnectionState | null | undefined): ShellConnectionState {
if (!state) {
return DEFAULT_WEB_SHELL_STATE;
}
const profiles = Array.isArray(state.profiles)
? state.profiles.filter((profile): profile is ShellConnectionProfile => Boolean(profile && profile.id && profile.serverUrl))
: [];
const activeProfileId =
state.activeProfileId && profiles.some((profile) => profile.id === state.activeProfileId)
? state.activeProfileId
: null;
return {
...state,
activeProfileId,
profiles,
};
}
function unsupportedError(action: string): Error {
return new Error(`${action} is only available in native shell mode`);
}
export async function createOrUpdateProfile(
shellApi: FusionShellApi | null,
profile: ShellConnectionProfileInput,
): Promise<ShellConnectionProfile> {
if (!shellApi) {
throw unsupportedError("Saving connection profiles");
}
return shellApi.saveProfile(profile);
}
export async function deleteProfile(shellApi: FusionShellApi | null, profileId: string): Promise<void> {
if (!shellApi) {
throw unsupportedError("Deleting connection profiles");
}
await shellApi.deleteProfile(profileId);
}
export async function selectActiveProfile(
shellApi: FusionShellApi | null,
profileId: string | null,
): Promise<ShellConnectionState> {
if (!shellApi) {
throw unsupportedError("Switching connection profiles");
}
return shellApi.setActiveProfile(profileId);
}

View File

@@ -66,7 +66,8 @@ Desktop boots through a shell-owned mode chooser before mounting the dashboard a
- **First run choice:** users choose **Local Fusion (bundled runtime)** or **Remote connection path**. - **First run choice:** users choose **Local Fusion (bundled runtime)** or **Remote connection path**.
- **Mode contract:** `desktopMode` is `"local" | "remote" | null` and `hasCompletedModeSelection` determines whether the renderer treats startup as first-run. IPC also exposes a renderer-safe `{ isFirstRun, desktopMode }` shape via `shell:getDesktopModeState`. - **Mode contract:** `desktopMode` is `"local" | "remote" | null` and `hasCompletedModeSelection` determines whether the renderer treats startup as first-run. IPC also exposes a renderer-safe `{ isFirstRun, desktopMode }` shape via `shell:getDesktopModeState`.
- **Desktop mode restore:** after selection, mode is persisted and reused on relaunch. - **Desktop mode restore:** after selection, mode is persisted and reused 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. - **Remote profiles:** multiple saved profiles are supported (`name`, `serverUrl`, optional `authToken`) and can be created/edited/switched/deleted from the dashboard connection manager.
- **Delete fallback:** if the active profile is deleted, desktop shell settings automatically select the first remaining profile; deleting the final profile leaves a valid empty payload (`activeProfileId: null`, `profiles: []`).
- **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. - **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.
### Production vs dev bootstrap behavior ### Production vs dev bootstrap behavior
@@ -157,6 +158,7 @@ Desktop local mode uses an in-process runtime manager (`src/local-server.ts`) th
- `getState()`, `listProfiles()`, `saveProfile()`, `deleteProfile()` - `getState()`, `listProfiles()`, `saveProfile()`, `deleteProfile()`
- `setActiveProfile()`, `setDesktopMode()` - `setActiveProfile()`, `setDesktopMode()`
- `startQrScan()`, `openConnectionManager()`, `subscribe(listener)` - `startQrScan()`, `openConnectionManager()`, `subscribe(listener)`
- Together these cover create/delete/switch operations for shell-owned remote profiles without writing to project/global Fusion settings
- `window.fusionAPI` remains as a backward-compatible alias of `window.electronAPI`. - `window.fusionAPI` remains as a backward-compatible alias of `window.electronAPI`.
All preload typings are declared in `src/types.d.ts`. All preload typings are declared in `src/types.d.ts`.

View File

@@ -56,6 +56,27 @@ vi.mock("../native.js", () => ({
vi.mock("../shell-settings.js", () => ({ vi.mock("../shell-settings.js", () => ({
readShellSettings: mocks.readShellSettings, readShellSettings: mocks.readShellSettings,
writeShellSettings: mocks.writeShellSettings, writeShellSettings: mocks.writeShellSettings,
buildSavedProfile: (settings: { profiles: Array<{ id: string }>; }, profile: { id?: string; name: string; serverUrl: string }) => ({
id: profile.id ?? "generated-id",
name: profile.name.trim() || "Remote Server",
serverUrl: profile.serverUrl,
createdAt: "",
updatedAt: "",
authToken: null,
lastUsedAt: null,
}),
applyDeleteProfile: (settings: { activeProfileId: string | null; profiles: Array<{ id: string }> }, profileId: string) => {
const profiles = settings.profiles.filter((item) => item.id !== profileId);
return {
...settings,
profiles,
activeProfileId: settings.activeProfileId === profileId ? (profiles[0]?.id ?? null) : settings.activeProfileId,
};
},
applySetActiveProfile: (settings: { profiles: Array<{ id: string }> }, profileId: string | null) => ({
...settings,
activeProfileId: profileId && settings.profiles.some((item) => item.id === profileId) ? profileId : null,
}),
getDesktopShellModeState: (settings: { hasCompletedModeSelection?: boolean; desktopMode?: "local" | "remote" | null }) => ({ getDesktopShellModeState: (settings: { hasCompletedModeSelection?: boolean; desktopMode?: "local" | "remote" | null }) => ({
isFirstRun: !settings.hasCompletedModeSelection || !settings.desktopMode, isFirstRun: !settings.hasCompletedModeSelection || !settings.desktopMode,
desktopMode: settings.desktopMode ?? null, desktopMode: settings.desktopMode ?? null,
@@ -128,15 +149,27 @@ describe("ipc handlers", () => {
expect(window.webContents.send).toHaveBeenCalledWith("shell:state", expect.any(Object)); expect(window.webContents.send).toHaveBeenCalledWith("shell:state", expect.any(Object));
}); });
it("shell:saveProfile rejects invalid URLs", async () => { it("shell:saveProfile persists the helper-generated profile", async () => {
await registerHandlers(); await registerHandlers();
const handler = mocks.ipcHandlers.get("shell:saveProfile"); const handler = mocks.ipcHandlers.get("shell:saveProfile");
const result = await handler?.({}, { name: " Prod ", serverUrl: "https://fusion.example.com" });
await expect(handler?.({}, { name: "Prod", serverUrl: "not-a-url" })).rejects.toThrow( expect(result).toMatchObject({ id: "generated-id", name: "Prod" });
"Server URL must be a valid absolute URL", expect(mocks.writeShellSettings).toHaveBeenCalledWith(expect.objectContaining({ profiles: [expect.objectContaining({ id: "generated-id" })] }));
); });
await expect(handler?.({}, { name: "Prod", serverUrl: "ftp://fusion.example.com" })).rejects.toThrow(
"Server URL must use http or https", it("shell:deleteProfile falls back to first remaining profile when deleting active", async () => {
); mocks.readShellSettings.mockResolvedValueOnce({
desktopMode: "remote",
hasCompletedModeSelection: true,
activeProfileId: "p2",
profiles: [{ id: "p1" }, { id: "p2" }],
});
await registerHandlers();
const handler = mocks.ipcHandlers.get("shell:deleteProfile");
await handler?.({}, "p2");
expect(mocks.writeShellSettings).toHaveBeenCalledWith(expect.objectContaining({ activeProfileId: "p1" }));
}); });
}); });

View File

@@ -101,4 +101,49 @@ describe("shell-settings", () => {
desktopMode: null, desktopMode: null,
}); });
}); });
it("normalizes invalid profiles, duplicate names, and invalid active id", async () => {
mockState.content.set(
"/tmp/fusion/shell-connections.json",
JSON.stringify({
activeProfileId: "missing",
profiles: [
{ id: "p1", name: "", serverUrl: "https://fusion.example.com" },
{ id: "p2", name: "Remote Server", serverUrl: "https://staging.example.com" },
{ id: "p3", name: "Bad", serverUrl: "not-a-url" },
],
}),
);
const { readShellSettings } = await import("../shell-settings.ts");
const settings = await readShellSettings();
expect(settings.activeProfileId).toBeNull();
expect(settings.profiles).toHaveLength(2);
expect(settings.profiles[0]?.name).toBe("Remote Server");
expect(settings.profiles[1]?.name).toBe("Remote Server (2)");
});
it("deleting active profile picks fallback and deleting last clears state", async () => {
const { applyDeleteProfile } = await import("../shell-settings.ts");
const first = { id: "p1", name: "Prod", serverUrl: "https://fusion.example.com", authToken: null, createdAt: "", updatedAt: "" };
const second = { id: "p2", name: "Staging", serverUrl: "https://staging.example.com", authToken: null, createdAt: "", updatedAt: "" };
const withFallback = applyDeleteProfile({
desktopMode: "remote",
hasCompletedModeSelection: true,
activeProfileId: "p2",
profiles: [first, second],
}, "p2");
expect(withFallback.activeProfileId).toBe("p1");
const empty = applyDeleteProfile({
desktopMode: "remote",
hasCompletedModeSelection: true,
activeProfileId: "p1",
profiles: [first],
}, "p1");
expect(empty).toMatchObject({ activeProfileId: null, profiles: [] });
});
}); });

View File

@@ -2,6 +2,9 @@ import { app, type BrowserWindow, ipcMain, type Tray } from "electron";
import { setupAutoUpdater, showExportSettingsDialog, showImportSettingsDialog } from "./native.js"; import { setupAutoUpdater, showExportSettingsDialog, showImportSettingsDialog } from "./native.js";
import { type EngineStatus, updateTrayStatus } from "./tray.js"; import { type EngineStatus, updateTrayStatus } from "./tray.js";
import { import {
applyDeleteProfile,
applySetActiveProfile,
buildSavedProfile,
getDesktopShellModeState, getDesktopShellModeState,
readShellSettings, readShellSettings,
writeShellSettings, writeShellSettings,
@@ -35,27 +38,6 @@ interface RegisterIpcOptions {
getServerPort?: () => number | undefined; getServerPort?: () => number | undefined;
} }
function nowIso(): string {
return new Date().toISOString();
}
function createProfileId(): string {
return `profile_${Math.random().toString(36).slice(2, 10)}`;
}
function normalizeServerUrl(serverUrl: string): string {
const normalized = serverUrl.trim().replace(/\/$/, "");
let parsed: URL;
try {
parsed = new URL(normalized);
} catch {
throw new Error("Server URL must be a valid absolute URL");
}
if (!parsed.protocol || !/^https?:$/.test(parsed.protocol)) {
throw new Error("Server URL must use http or https");
}
return normalized;
}
function toShellState( function toShellState(
settings: Awaited<ReturnType<typeof readShellSettings>>, settings: Awaited<ReturnType<typeof readShellSettings>>,
@@ -122,38 +104,25 @@ export function registerIpcHandlers(mainWindow: BrowserWindow, tray: Tray, optio
ipcMain.handle("shell:saveProfile", async (_event, profile: ShellConnectionProfileInput) => { ipcMain.handle("shell:saveProfile", async (_event, profile: ShellConnectionProfileInput) => {
const settings = await readShellSettings(); const settings = await readShellSettings();
const existing = profile.id ? settings.profiles.find((item) => item.id === profile.id) : undefined; const nextProfile = buildSavedProfile(settings, profile);
const timestamp = nowIso(); const existing = settings.profiles.find((item) => item.id === nextProfile.id);
const nextProfile: ShellConnectionProfile = {
id: existing?.id ?? profile.id ?? createProfileId(),
name: profile.name.trim(),
serverUrl: normalizeServerUrl(profile.serverUrl),
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]; settings.profiles = existing
? settings.profiles.map((item) => (item.id === existing.id ? nextProfile : item))
: [...settings.profiles, nextProfile];
await writeShellSettings(settings); await writeShellSettings(settings);
await emitShellState(mainWindow, options.getLocalServerState); await emitShellState(mainWindow, options.getLocalServerState);
return nextProfile; return nextProfile;
}); });
ipcMain.handle("shell:deleteProfile", async (_event, profileId: string) => { ipcMain.handle("shell:deleteProfile", async (_event, profileId: string) => {
const settings = await readShellSettings(); const settings = applyDeleteProfile(await readShellSettings(), profileId);
settings.profiles = settings.profiles.filter((item) => item.id !== profileId);
if (settings.activeProfileId === profileId) settings.activeProfileId = null;
await writeShellSettings(settings); await writeShellSettings(settings);
await emitShellState(mainWindow, options.getLocalServerState); await emitShellState(mainWindow, options.getLocalServerState);
}); });
ipcMain.handle("shell:setActiveProfile", async (_event, profileId: string | null) => { ipcMain.handle("shell:setActiveProfile", async (_event, profileId: string | null) => {
const settings = await readShellSettings(); const settings = applySetActiveProfile(await readShellSettings(), profileId);
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); await writeShellSettings(settings);
return emitShellState(mainWindow, options.getLocalServerState); return emitShellState(mainWindow, options.getLocalServerState);
}); });

View File

@@ -8,8 +8,8 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 10px; gap: var(--space-md);
padding: 0 10px; padding: 0 var(--space-md);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
background: var(--surface); background: var(--surface);
color: var(--text); color: var(--text);
@@ -24,19 +24,19 @@
.desktop-titlebar__brand { .desktop-titlebar__brand {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 8px; gap: var(--space-sm);
min-width: 0; min-width: 0;
} }
.desktop-titlebar__logo { .desktop-titlebar__logo {
width: 16px; width: var(--space-lg);
height: 16px; height: var(--space-lg);
color: var(--logo-accent, var(--todo)); color: var(--logo-accent, var(--todo));
flex-shrink: 0; flex-shrink: 0;
} }
.desktop-titlebar__title { .desktop-titlebar__title {
font-size: 12px; font-size: calc(var(--space-sm) + var(--space-xs));
font-weight: 600; font-weight: 600;
letter-spacing: 0.01em; letter-spacing: 0.01em;
color: var(--text); color: var(--text);
@@ -45,7 +45,7 @@
.desktop-titlebar__controls { .desktop-titlebar__controls {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 4px; gap: var(--space-xs);
} }
.desktop-titlebar__controls--no-drag, .desktop-titlebar__controls--no-drag,
@@ -54,19 +54,19 @@
} }
.desktop-titlebar__control { .desktop-titlebar__control {
width: 28px; width: calc(var(--space-lg) + var(--space-lg));
height: 24px; height: var(--space-xl);
border: none; border: none;
border-radius: 6px; border-radius: var(--radius-sm);
background: transparent; background: transparent;
color: var(--text-muted); color: var(--text-muted);
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: calc(var(--space-sm) + var(--space-xs));
line-height: 1; line-height: 1;
transition: background-color 0.15s ease, color 0.15s ease; transition: background-color var(--transition-fast), color var(--transition-fast);
} }
.desktop-titlebar__control:hover { .desktop-titlebar__control:hover {
@@ -75,8 +75,8 @@
} }
.desktop-titlebar__control:focus-visible { .desktop-titlebar__control:focus-visible {
outline: 2px solid var(--todo); outline: none;
outline-offset: 1px; box-shadow: var(--focus-ring-strong);
} }
.desktop-titlebar__control--close:hover { .desktop-titlebar__control--close:hover {

View File

@@ -1,4 +1,5 @@
import { readFile, rename, writeFile } from "node:fs/promises"; import { readFile, rename, writeFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { join } from "node:path"; import { join } from "node:path";
import { app } from "electron"; import { app } from "electron";
@@ -37,11 +38,97 @@ function getSettingsPath(): string {
return join(app.getPath("userData"), "shell-connections.json"); return join(app.getPath("userData"), "shell-connections.json");
} }
function nowIso(): string {
return new Date().toISOString();
}
function normalizeDesktopMode(value: unknown): DesktopShellMode | null { function normalizeDesktopMode(value: unknown): DesktopShellMode | null {
if (value === "local" || value === "remote") { return value === "local" || value === "remote" ? value : null;
return value; }
function normalizeServerUrl(serverUrl: string): string {
const normalized = serverUrl.trim().replace(/\/$/, "");
let parsed: URL;
try {
parsed = new URL(normalized);
} catch {
throw new Error("Server URL must be a valid absolute URL");
} }
return null; if (!/^https?:$/.test(parsed.protocol)) {
throw new Error("Server URL must use http or https");
}
return normalized;
}
function normalizeProfileName(name: string): string {
const normalized = name.trim().replace(/\s+/g, " ");
return normalized.length > 0 ? normalized : "Remote Server";
}
function profileBaseId(name: string, serverUrl: string): string {
const hash = createHash("sha1").update(`${name}|${serverUrl}`).digest("hex").slice(0, 10);
return `profile_${hash}`;
}
function ensureUniqueProfileName(name: string, profiles: ShellConnectionProfile[], skipId?: string): string {
const used = new Set(
profiles.filter((profile) => profile.id !== skipId).map((profile) => profile.name.toLocaleLowerCase()),
);
if (!used.has(name.toLocaleLowerCase())) {
return name;
}
let suffix = 2;
let candidate = `${name} (${suffix})`;
while (used.has(candidate.toLocaleLowerCase())) {
suffix += 1;
candidate = `${name} (${suffix})`;
}
return candidate;
}
function createDeterministicProfileId(name: string, serverUrl: string, profiles: ShellConnectionProfile[], skipId?: string): string {
const used = new Set(profiles.filter((profile) => profile.id !== skipId).map((profile) => profile.id));
const base = profileBaseId(name, serverUrl);
if (!used.has(base)) {
return base;
}
let suffix = 2;
let candidate = `${base}_${suffix}`;
while (used.has(candidate)) {
suffix += 1;
candidate = `${base}_${suffix}`;
}
return candidate;
}
function normalizeProfileRecord(input: unknown, fallbackIndex: number): ShellConnectionProfile | null {
if (!input || typeof input !== "object") {
return null;
}
const candidate = input as Partial<ShellConnectionProfile>;
if (typeof candidate.serverUrl !== "string") {
return null;
}
let serverUrl: string;
try {
serverUrl = normalizeServerUrl(candidate.serverUrl);
} catch {
return null;
}
const name = normalizeProfileName(typeof candidate.name === "string" ? candidate.name : "");
const createdAt = typeof candidate.createdAt === "string" && candidate.createdAt.length > 0 ? candidate.createdAt : nowIso();
const updatedAt = typeof candidate.updatedAt === "string" && candidate.updatedAt.length > 0 ? candidate.updatedAt : createdAt;
return {
id: typeof candidate.id === "string" && candidate.id.length > 0 ? candidate.id : `profile_imported_${fallbackIndex}`,
name,
serverUrl,
authToken: typeof candidate.authToken === "string" ? candidate.authToken : null,
createdAt,
updatedAt,
lastUsedAt: typeof candidate.lastUsedAt === "string" ? candidate.lastUsedAt : null,
};
} }
function normalize(input: unknown): DesktopShellSettings { function normalize(input: unknown): DesktopShellSettings {
@@ -52,13 +139,80 @@ function normalize(input: unknown): DesktopShellSettings {
const candidate = input as Partial<DesktopShellSettings>; const candidate = input as Partial<DesktopShellSettings>;
const desktopMode = normalizeDesktopMode(candidate.desktopMode); const desktopMode = normalizeDesktopMode(candidate.desktopMode);
const inferredCompleted = desktopMode !== null; const inferredCompleted = desktopMode !== null;
const profiles: ShellConnectionProfile[] = [];
const profileSource = Array.isArray(candidate.profiles) ? candidate.profiles : [];
for (const [index, profileValue] of profileSource.entries()) {
const normalizedProfile = normalizeProfileRecord(profileValue, index);
if (!normalizedProfile) {
continue;
}
const uniqueName = ensureUniqueProfileName(normalizedProfile.name, profiles);
const idAlreadyUsed = profiles.some((profile) => profile.id === normalizedProfile.id);
const id = idAlreadyUsed
? createDeterministicProfileId(uniqueName, normalizedProfile.serverUrl, profiles)
: normalizedProfile.id;
profiles.push({ ...normalizedProfile, name: uniqueName, id });
}
const persistedActiveId = typeof candidate.activeProfileId === "string" ? candidate.activeProfileId : null;
const activeProfileId = persistedActiveId && profiles.some((profile) => profile.id === persistedActiveId)
? persistedActiveId
: null;
return { return {
desktopMode, desktopMode,
hasCompletedModeSelection: typeof candidate.hasCompletedModeSelection === "boolean" ? candidate.hasCompletedModeSelection : inferredCompleted, hasCompletedModeSelection: typeof candidate.hasCompletedModeSelection === "boolean" ? candidate.hasCompletedModeSelection : inferredCompleted,
activeProfileId: typeof candidate.activeProfileId === "string" ? candidate.activeProfileId : null, activeProfileId,
profiles: Array.isArray(candidate.profiles) profiles,
? (candidate.profiles.filter((item) => item && typeof item === "object") as ShellConnectionProfile[]) };
: [], }
export function buildSavedProfile(
settings: DesktopShellSettings,
input: { id?: string; name: string; serverUrl: string; authToken?: string | null },
): ShellConnectionProfile {
const existing = input.id ? settings.profiles.find((item) => item.id === input.id) : undefined;
const normalizedServerUrl = normalizeServerUrl(input.serverUrl);
const normalizedName = normalizeProfileName(input.name);
const name = ensureUniqueProfileName(normalizedName, settings.profiles, existing?.id);
const timestamp = nowIso();
return {
id: existing?.id ?? createDeterministicProfileId(name, normalizedServerUrl, settings.profiles, existing?.id),
name,
serverUrl: normalizedServerUrl,
authToken: input.authToken ?? null,
createdAt: existing?.createdAt ?? timestamp,
updatedAt: timestamp,
lastUsedAt: existing?.lastUsedAt ?? null,
};
}
export function applyDeleteProfile(settings: DesktopShellSettings, profileId: string): DesktopShellSettings {
const profiles = settings.profiles.filter((item) => item.id !== profileId);
const activeProfileId =
settings.activeProfileId !== profileId
? settings.activeProfileId
: profiles.length > 0
? profiles[0]?.id ?? null
: null;
return { ...settings, profiles, activeProfileId };
}
export function applySetActiveProfile(settings: DesktopShellSettings, profileId: string | null): DesktopShellSettings {
const activeProfileId = profileId && settings.profiles.some((item) => item.id === profileId) ? profileId : null;
const timestamp = nowIso();
const profiles = settings.profiles.map((item) =>
item.id === activeProfileId
? { ...item, lastUsedAt: timestamp, updatedAt: timestamp }
: item,
);
return {
...settings,
activeProfileId,
profiles,
}; };
} }

View File

@@ -6,7 +6,8 @@ Mobile uses a shell-level onboarding flow for first-run connection setup before
- **Remote-first flow:** mobile onboarding goes directly to remote server connection. - **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. - **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. - **Saved profiles:** multiple remote profiles are persisted in shell-local storage and can be added via QR/manual entry, edited, switched, and deleted later from dashboard connection management.
- **Active-profile fallback:** deleting the active profile automatically promotes the first remaining profile; deleting the last profile resets to an empty state (`activeProfileId: null`, `profiles: []`) so onboarding/manager recovery can reopen cleanly.
- **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. - **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. - **Bridge contract:** mobile exposes `window.fusionShell` (`getState`, `listProfiles`, `saveProfile`, `deleteProfile`, `setActiveProfile`, `startQrScan`, `openConnectionManager`, `subscribe`) so shared dashboard code can run host-neutrally.

View File

@@ -0,0 +1,51 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const store = new Map<string, string>();
vi.mock("@capacitor/preferences", () => ({
Preferences: {
get: vi.fn(async ({ key }: { key: string }) => ({ value: store.get(key) ?? null })),
set: vi.fn(async ({ key, value }: { key: string; value: string }) => {
store.set(key, value);
}),
},
}));
describe("connection-profiles", () => {
beforeEach(() => {
store.clear();
vi.resetModules();
});
it("recovers from invalid payloads", async () => {
store.set("fusion.shell.connections.v1", "{bad-json");
const { loadShellProfiles } = await import("../connection-profiles.js");
await expect(loadShellProfiles()).resolves.toEqual({ activeProfileId: null, profiles: [] });
});
it("normalizes empty and duplicate names", async () => {
const { saveShellProfile, listShellProfiles } = await import("../connection-profiles.js");
const first = await saveShellProfile({ name: "", serverUrl: "https://fusion.example.com" });
const second = await saveShellProfile({ name: "Remote Server", serverUrl: "https://fusion-two.example.com" });
expect(first.name).toBe("Remote Server");
expect(second.name).toBe("Remote Server (2)");
const profiles = await listShellProfiles();
expect(profiles).toHaveLength(2);
});
it("deleting active profile picks a fallback and deleting final profile clears state", async () => {
const { saveShellProfile, setActiveShellProfile, deleteShellProfile, loadShellProfiles } = await import("../connection-profiles.js");
const first = await saveShellProfile({ name: "Prod", serverUrl: "https://fusion.example.com" });
const second = await saveShellProfile({ name: "Staging", serverUrl: "https://staging.example.com" });
await setActiveShellProfile(second.id);
await deleteShellProfile(second.id);
const afterFirstDelete = await loadShellProfiles();
expect(afterFirstDelete.activeProfileId).toBe(first.id);
await deleteShellProfile(first.id);
const afterSecondDelete = await loadShellProfiles();
expect(afterSecondDelete).toEqual({ activeProfileId: null, profiles: [] });
});
});

View File

@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import { Preferences } from "@capacitor/preferences"; import { Preferences } from "@capacitor/preferences";
import type { ShellConnectionProfile, ShellConnectionProfileInput } from "../types.js"; import type { ShellConnectionProfile, ShellConnectionProfileInput } from "../types.js";
@@ -12,8 +13,9 @@ function nowIso(): string {
return new Date().toISOString(); return new Date().toISOString();
} }
function createId(): string { function normalizeName(name: string): string {
return `profile_${Math.random().toString(36).slice(2, 10)}`; const normalized = name.trim().replace(/\s+/g, " ");
return normalized.length > 0 ? normalized : "Remote Server";
} }
function normalizeUrl(serverUrl: string): string { function normalizeUrl(serverUrl: string): string {
@@ -30,15 +32,97 @@ function normalizeUrl(serverUrl: string): string {
return normalized; return normalized;
} }
function deterministicBaseId(name: string, serverUrl: string): string {
const hash = createHash("sha1").update(`${name}|${serverUrl}`).digest("hex").slice(0, 10);
return `profile_${hash}`;
}
function ensureUniqueName(name: string, profiles: ShellConnectionProfile[], skipId?: string): string {
const used = new Set(profiles.filter((profile) => profile.id !== skipId).map((profile) => profile.name.toLocaleLowerCase()));
if (!used.has(name.toLocaleLowerCase())) {
return name;
}
let suffix = 2;
let candidate = `${name} (${suffix})`;
while (used.has(candidate.toLocaleLowerCase())) {
suffix += 1;
candidate = `${name} (${suffix})`;
}
return candidate;
}
function ensureUniqueId(name: string, serverUrl: string, profiles: ShellConnectionProfile[], skipId?: string): string {
const base = deterministicBaseId(name, serverUrl);
const used = new Set(profiles.filter((profile) => profile.id !== skipId).map((profile) => profile.id));
if (!used.has(base)) {
return base;
}
let suffix = 2;
let candidate = `${base}_${suffix}`;
while (used.has(candidate)) {
suffix += 1;
candidate = `${base}_${suffix}`;
}
return candidate;
}
function normalizePersistedProfile(input: unknown, index: number): ShellConnectionProfile | null {
if (!input || typeof input !== "object") {
return null;
}
const candidate = input as Partial<ShellConnectionProfile>;
if (typeof candidate.serverUrl !== "string") {
return null;
}
let serverUrl: string;
try {
serverUrl = normalizeUrl(candidate.serverUrl);
} catch {
return null;
}
const name = normalizeName(typeof candidate.name === "string" ? candidate.name : "");
const createdAt = typeof candidate.createdAt === "string" && candidate.createdAt.length > 0 ? candidate.createdAt : nowIso();
const updatedAt = typeof candidate.updatedAt === "string" && candidate.updatedAt.length > 0 ? candidate.updatedAt : createdAt;
return {
id: typeof candidate.id === "string" && candidate.id.length > 0 ? candidate.id : `profile_imported_${index}`,
name,
serverUrl,
authToken: typeof candidate.authToken === "string" ? candidate.authToken : null,
createdAt,
updatedAt,
lastUsedAt: typeof candidate.lastUsedAt === "string" ? candidate.lastUsedAt : null,
};
}
function toPersisted(input: unknown): PersistedShellState { function toPersisted(input: unknown): PersistedShellState {
if (!input || typeof input !== "object") { if (!input || typeof input !== "object") {
return { activeProfileId: null, profiles: [] }; return { activeProfileId: null, profiles: [] };
} }
const candidate = input as Partial<PersistedShellState>; const candidate = input as Partial<PersistedShellState>;
const profiles = Array.isArray(candidate.profiles) ? candidate.profiles.filter((profile) => profile && typeof profile === "object") as ShellConnectionProfile[] : []; const source = Array.isArray(candidate.profiles) ? candidate.profiles : [];
const profiles: ShellConnectionProfile[] = [];
for (const [index, value] of source.entries()) {
const normalized = normalizePersistedProfile(value, index);
if (!normalized) {
continue;
}
const uniqueName = ensureUniqueName(normalized.name, profiles);
const idAlreadyUsed = profiles.some((profile) => profile.id === normalized.id);
const id = idAlreadyUsed ? ensureUniqueId(uniqueName, normalized.serverUrl, profiles) : normalized.id;
profiles.push({ ...normalized, name: uniqueName, id });
}
const activeProfileId =
typeof candidate.activeProfileId === "string" && profiles.some((profile) => profile.id === candidate.activeProfileId)
? candidate.activeProfileId
: null;
return { return {
activeProfileId: typeof candidate.activeProfileId === "string" ? candidate.activeProfileId : null, activeProfileId,
profiles, profiles,
}; };
} }
@@ -69,11 +153,14 @@ export async function saveShellProfile(input: ShellConnectionProfileInput): Prom
const state = await loadShellProfiles(); const state = await loadShellProfiles();
const existing = input.id ? state.profiles.find((p) => p.id === input.id) : undefined; const existing = input.id ? state.profiles.find((p) => p.id === input.id) : undefined;
const timestamp = nowIso(); const timestamp = nowIso();
const serverUrl = normalizeUrl(input.serverUrl);
const normalizedName = normalizeName(input.name);
const name = ensureUniqueName(normalizedName, state.profiles, existing?.id);
const profile: ShellConnectionProfile = { const profile: ShellConnectionProfile = {
id: existing?.id ?? input.id ?? createId(), id: existing?.id ?? ensureUniqueId(name, serverUrl, state.profiles, existing?.id),
name: input.name.trim(), name,
serverUrl: normalizeUrl(input.serverUrl), serverUrl,
authToken: input.authToken ?? null, authToken: input.authToken ?? null,
createdAt: existing?.createdAt ?? timestamp, createdAt: existing?.createdAt ?? timestamp,
updatedAt: timestamp, updatedAt: timestamp,
@@ -91,7 +178,12 @@ export async function saveShellProfile(input: ShellConnectionProfileInput): Prom
export async function deleteShellProfile(profileId: string): Promise<void> { export async function deleteShellProfile(profileId: string): Promise<void> {
const state = await loadShellProfiles(); const state = await loadShellProfiles();
const profiles = state.profiles.filter((profile) => profile.id !== profileId); const profiles = state.profiles.filter((profile) => profile.id !== profileId);
const activeProfileId = state.activeProfileId === profileId ? null : state.activeProfileId; const activeProfileId =
state.activeProfileId !== profileId
? state.activeProfileId
: profiles.length > 0
? profiles[0]?.id ?? null
: null;
await saveShellState({ activeProfileId, profiles }); await saveShellState({ activeProfileId, profiles });
} }
@@ -102,9 +194,10 @@ export async function setActiveShellProfile(profileId: string | null): Promise<P
? profileId ? profileId
: null; : null;
const timestamp = nowIso();
const profiles = state.profiles.map((profile) => const profiles = state.profiles.map((profile) =>
profile.id === activeProfileId profile.id === activeProfileId
? { ...profile, lastUsedAt: nowIso(), updatedAt: nowIso() } ? { ...profile, lastUsedAt: timestamp, updatedAt: timestamp }
: profile, : profile,
); );