Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions src/components/layout/SidebarAccountButton.switcher.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ vi.mock("@/services/savedAccounts", () => ({
import { SidebarAccountButton } from "./SidebarAccountButton";
import { useUIStore } from "@/stores/uiStore";
import { useSecurityStore } from "@/stores/securityStore";
import { useNotificationStore } from "@/stores/notificationStore";

const CURRENT = {
account_id: "current", mode: "server", master_password: ["master", "for", "current"].join("-"),
Expand All @@ -50,6 +51,7 @@ beforeEach(() => {
vi.clearAllMocks();
h.accountMode = "server";
h.keychain = { email: CURRENT.email, account_id: CURRENT.account_id };
useNotificationStore.setState({ toasts: [] });
});
afterEach(cleanup);

Expand Down Expand Up @@ -154,3 +156,35 @@ test("a local account is not offered the add-account route", async () => {
await openMenu();
expect(screen.queryByText("layout.sidebarAccount.addAccount")).toBeNull();
});

/**
* A keychain that refuses the write is how an account went missing from the
* switcher without a word — the save failure was caught and dropped.
*/
test("a switcher save the keychain refuses is reported", async () => {
h.saveCurrentAccount.mockRejectedValueOnce(new Error("Keychain write error: too long"));
await openMenu();

await waitFor(() =>
expect(
useNotificationStore.getState().toasts.some((toast) =>
toast.message.startsWith("layout.sidebarAccount.saveFailed"),
),
).toBe(true),
);
});

test("adding another account stops when the current one could not be saved", async () => {
h.signOutToAddAccount.mockRejectedValueOnce(new Error("Saved accounts could not be read"));
await openMenu();

await userEvent.click(await screen.findByText("layout.sidebarAccount.addAccount"));

await waitFor(() =>
expect(
useNotificationStore.getState().toasts.some((toast) =>
toast.message.startsWith("layout.sidebarAccount.saveFailed"),
),
).toBe(true),
);
});
33 changes: 24 additions & 9 deletions src/components/layout/SidebarAccountButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,27 @@ export function SidebarAccountButton() {
const rect = buttonRef.current.getBoundingClientRect();
setPos({ bottom: window.innerHeight - rect.bottom, left: rect.right + 8 });
}
await Promise.all([refreshAccountInfo(), saveCurrentAccount().catch(() => {})]);
await Promise.all([
refreshAccountInfo(),
// A keychain that refuses this write is exactly how an account goes
// missing from the switcher, so it is said out loud rather than swallowed.
saveCurrentAccount().catch((e) => reportAccountError("saveFailed", e)),
]);
const accounts = await getSavedAccounts().catch(() => [] as SavedAccount[]);
setSavedAccounts(accounts);
setOpen(true);
};

const reportAccountError = (key: "switchFailed" | "saveFailed", e: unknown) => {
useNotificationStore.getState().addToast({
source: { kind: "plugin", id: "system", name: "Voltius" },
type: "toast",
message: t(`layout.sidebarAccount.${key}`, { error: e instanceof Error ? e.message : String(e) }),
severity: "error",
duration: 8000,
});
};

const handleLockVault = async () => {
setOpen(false);
await lockVaultSession();
Expand All @@ -93,7 +108,13 @@ export function SidebarAccountButton() {

const handleAddAccount = async () => {
setOpen(false);
await signOutToAddAccount();
try {
await signOutToAddAccount();
} catch (e) {
// It signs this account out to reach the auth screen, so it must not run
// when the switcher could not keep it: that is a one-way trip out.
reportAccountError("saveFailed", e);
}
};

const handleSwitchAccount = async (account: SavedAccount) => {
Expand All @@ -103,13 +124,7 @@ export function SidebarAccountButton() {
} catch (e) {
// The switch tears the session down before it rebuilds it; a silent
// rejection would leave the user staring at an unchanged window.
useNotificationStore.getState().addToast({
source: { kind: "plugin", id: "system", name: "Voltius" },
type: "toast",
message: t("layout.sidebarAccount.switchFailed", { error: e instanceof Error ? e.message : String(e) }),
severity: "error",
duration: 8000,
});
reportAccountError("switchFailed", e);
}
};

Expand Down
17 changes: 13 additions & 4 deletions src/components/layout/SplashScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { usePortForwardingStore } from "@/stores/portForwardingStore";
import { autoLogin, consumeForceLockFlag, isServerMode } from "@/services/account";
import { saveCurrentAccount } from "@/services/savedAccounts";
import { syncOnLogin, syncOnLoginReplace, startRealtimeSync } from "@/services/sync";
import { setLoginSyncPending, resolveLoginSync } from "@/plugins/runtime";
import { setLoginSyncPending, resolveLoginSync } from "@/services/loginSyncGate";
import { loadSeededPlugins } from "@/plugins/seeded";
import { loadInstalledPlugins, loadPluginMeta, supersedeStaleFirstPartyShadows } from "@/stores/marketplaceStore";
import { usePluginRegistryStore } from "@/stores/pluginRegistryStore";
Expand All @@ -28,6 +28,15 @@ interface Props { onReady: () => void; }

const STEP_IDS = ["init", "vault", "connections"] as const;

/**
* Keep the quick switcher's copy of this account current. Nothing on the splash
* can show a toast yet, so the failure is logged rather than dropped: a write
* refused in silence is how an account went missing from the switcher.
*/
function keepSwitcherFresh(): void {
saveCurrentAccount().catch((e) => console.warn("[splash] could not save this account to the switcher:", e));
}

export default function SplashScreen({ onReady }: Props) {
const { t } = useTranslation();
const [steps, setSteps] = useState<Step[]>(() =>
Expand Down Expand Up @@ -64,7 +73,7 @@ export default function SplashScreen({ onReady }: Props) {
if (outcome === "ok") {
setStep("vault", "done", t("layout.splash.sessionRestored"));
setPhase("finishing");
saveCurrentAccount().catch(() => {}); // keep saved accounts list fresh
keepSwitcherFresh();
await finishLoading();
return;
}
Expand Down Expand Up @@ -121,7 +130,7 @@ export default function SplashScreen({ onReady }: Props) {
// Gate plugins behind this promise so they see post-merge data.
// vault_reset (logout) wipes the config dir including plugin storage,
// so plugins must not run their initial sync before server data lands.
setLoginSyncPending();
setLoginSyncPending({ replace: useReplace });
(useReplace ? syncOnLoginReplace() : syncOnLogin())
.catch(() => {})
.finally(() => resolveLoginSync());
Expand All @@ -148,7 +157,7 @@ export default function SplashScreen({ onReady }: Props) {

const handleAuthReady = async () => {
setPhase("finishing");
saveCurrentAccount().catch(() => {}); // keep saved accounts list fresh
keepSwitcherFresh();
await finishLoading();
};

Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/en/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@
"savedAccountCloud": "Cloud",
"savedAccountLocal": "Local",
"switchFailed": "Account switch failed: {{error}}",
"saveFailed": "This account could not be saved to the switcher: {{error}}",
"leaveLocalTitle": "Leave this local account?",
"leaveLocalMessage": "This account's hosts, keys and snippets are stored only on this machine. Switching to {{account}} erases them and they cannot be recovered.",
"leaveLocalConfirm": "Erase and switch",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/fr/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@
"savedAccountCloud": "Cloud",
"savedAccountLocal": "Local",
"switchFailed": "Échec du changement de compte : {{error}}",
"saveFailed": "Ce compte n'a pas pu être enregistré dans le sélecteur : {{error}}",
"leaveLocalTitle": "Quitter ce compte local ?",
"leaveLocalMessage": "Les hôtes, clés et snippets de ce compte sont stockés uniquement sur cette machine. Passer à {{account}} les efface définitivement.",
"leaveLocalConfirm": "Effacer et changer",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/ru/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@
"savedAccountCloud": "Облако",
"savedAccountLocal": "Локально",
"switchFailed": "Не удалось переключить аккаунт: {{error}}",
"saveFailed": "Не удалось сохранить аккаунт в переключателе: {{error}}",
"leaveLocalTitle": "Выйти из локального аккаунта?",
"leaveLocalMessage": "Хосты, ключи и сниппеты этого аккаунта хранятся только на этом устройстве. Переход на {{account}} удалит их безвозвратно.",
"leaveLocalConfirm": "Удалить и переключиться",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/zh/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@
"savedAccountCloud": "云",
"savedAccountLocal": "本地",
"switchFailed": "切换账户失败:{{error}}",
"saveFailed": "无法将此账户保存到切换器:{{error}}",
"leaveLocalTitle": "离开此本地账户?",
"leaveLocalMessage": "此账户的主机、密钥和代码片段仅存储在本机。切换到 {{account}} 将永久删除它们。",
"leaveLocalConfirm": "删除并切换",
Expand Down
19 changes: 2 additions & 17 deletions src/plugins/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { invoke } from "@tauri-apps/api/core";
import { openUrl } from "@tauri-apps/plugin-opener";
import { closePfTunnel, getPfState, openPfTunnel } from "@/services/portForwardingTunnels";
import { whenLoginSyncSettled } from "@/services/loginSyncGate";
import { resolvePort } from "@/plugins/domains/ports";
import { runSnippetSequence, previewSnippetSequence } from "@/services/snippetSequence";
import type { RunTarget } from "@/services/sftpTarget";
Expand Down Expand Up @@ -148,22 +149,6 @@ const _exposedApis = new Map<string, unknown>();
// opened. Without this a disabled plugin's sockets outlive it silently.
const _sftpDisposers = new Map<string, () => void>();

// ─── Login-sync readiness gate ────────────────────────────────────────────
// Resolves immediately for local/offline users; SplashScreen holds it pending
// while syncOnLogin / syncOnLoginReplace runs so plugins don't race the merge.

let _loginSyncResolve: (() => void) | null = null;
let _loginSyncReady: Promise<void> = Promise.resolve();

export function setLoginSyncPending(): void {
_loginSyncReady = new Promise<void>((resolve) => { _loginSyncResolve = resolve; });
}

export function resolveLoginSync(): void {
_loginSyncResolve?.();
_loginSyncResolve = null;
}

// ─── Per-plugin settings-change listeners ─────────────────────────────────

const _settingsListeners = new Map<string, Set<(key: string, value: unknown) => void>>();
Expand Down Expand Up @@ -2311,7 +2296,7 @@ function createPluginAPI(manifest: PluginManifest): PluginAPI {
_onBeforeQuit.add(cb);
return () => _onBeforeQuit.delete(cb);
},
waitForLoginSync: () => _loginSyncReady,
waitForLoginSync: whenLoginSyncSettled,
},

sync: {
Expand Down
35 changes: 35 additions & 0 deletions src/services/loginSyncGate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* Login-sync readiness gate.
*
* Resolves immediately for local/offline users; SplashScreen holds it pending
* while syncOnLogin / syncOnLoginReplace runs, so nothing that needs the
* account's own data races the merge.
*
* Replace-mode is called out separately because it follows a wiped config dir:
* an account switch deletes every entity file, so until this settles the app
* knows of no connections at all — which is not the same as owning none.
*/

let resolveReady: (() => void) | null = null;
let ready: Promise<void> = Promise.resolve();
let replacePending = false;

export function setLoginSyncPending(options?: { replace?: boolean }): void {
replacePending = options?.replace ?? false;
ready = new Promise<void>((resolve) => { resolveReady = resolve; });
}

export function resolveLoginSync(): void {
replacePending = false;
resolveReady?.();
resolveReady = null;
}

export function whenLoginSyncSettled(): Promise<void> {
return ready;
}

/** True while a wiped local cache is still waiting to be refilled from the cloud. */
export function isReplaceSyncPending(): boolean {
return replacePending;
}
Loading