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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 && (
|
||||||
(() => {
|
(() => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
64
packages/dashboard/app/utils/shell-connection-settings.ts
Normal file
64
packages/dashboard/app/utils/shell-connection-settings.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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" }));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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: [] });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user