diff --git a/src/App.tsx b/src/App.tsx
index e751e19..1321568 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -417,6 +417,101 @@ function App() {
unlistenDownload = null;
};
+ // Establish the consent barrier before registering listeners or loading
+ // persisted downloads. Those later startup steps can cause user-facing
+ // work to begin; the explanation must already be committed before any
+ // path is allowed to reach the credential store.
+ await waitForSettingsHydration();
+ if (!active) return;
+ const [currentAppVersion, currentPlatform] = await Promise.all([
+ getVersion().catch(() => ''),
+ getPlatformInfo().catch(() => null)
+ ]);
+ if (!active) return;
+ const currentKeychainConsentVersion = getKeychainConsentVersion(currentAppVersion);
+ setKeychainConsentVersion(currentKeychainConsentVersion);
+
+ try {
+ const settings = useSettingsStore.getState();
+ const isStartupActive = () => active;
+ const { deferKeychainHydration, showKeychainPrompt } = getKeychainStartupDecision({
+ portable: currentPlatform?.portable === true,
+ appVersion: currentKeychainConsentVersion,
+ approvedVersion: settings.keychainAccessVersion,
+ accessGranted: settings.keychainAccessGranted,
+ promptDismissed: settings.keychainPromptDismissed
+ });
+ let changed = false;
+ if (deferKeychainHydration) {
+ settings.setKeychainAccessReady(false);
+ if (showKeychainPrompt) settings.setShowKeychainModal(true);
+ await settings.hydrateSessionPairingToken(isStartupActive);
+ if (!active) return;
+ } else {
+ await invoke('authorize_keychain_access');
+ if (!active) return;
+ changed = await settings.hydratePairingToken(isStartupActive);
+ if (!active) return;
+ const currentSettings = useSettingsStore.getState();
+ settings.setKeychainAccessReady(getKeychainAccessReady({
+ portable: currentPlatform?.portable === true,
+ accessGranted: currentSettings.keychainAccessGranted,
+ persistent: currentSettings.isPairingTokenPersistent
+ }));
+ }
+ if (changed) {
+ addToast({
+ variant: 'warning',
+ isActionable: true,
+ message: (
+
+
{t($ => $.app.extensionDisconnected)}
+
+
+
+
+
+ )
+ });
+ }
+ } catch (error) {
+ console.error('Failed to hydrate extension pairing token:', error);
+ addToast({
+ message: t($ => $.app.credentialPersistenceFailed, { detail: String(error) }),
+ variant: 'error',
+ isActionable: true
+ });
+ }
+
try {
unlistenDownload = await initDownloadListener();
unlistenTerminalState = await listen('download-state', (event) => {
@@ -479,7 +574,20 @@ function App() {
cleanupListeners = null;
return;
}
+ } catch (error) {
+ disposeListeners();
+ cleanupListeners = null;
+ if (!active) return;
+ console.error('Failed to initialize Firelink listeners:', error);
+ addToast({
+ message: t($ => $.app.initializeDownloadsFailed, { detail: String(error) }),
+ variant: 'error',
+ isActionable: true
+ });
+ return;
+ }
+ try {
await initializeDownloadState();
if (!active) return;
} catch (error) {
@@ -495,110 +603,6 @@ function App() {
return;
}
- const [currentAppVersion, currentPlatform] = await Promise.all([
- getVersion().catch(() => ''),
- getPlatformInfo().catch(() => null)
- ]);
- if (!active) return;
- const currentKeychainConsentVersion = getKeychainConsentVersion(currentAppVersion);
- setKeychainConsentVersion(currentKeychainConsentVersion);
-
- try {
- const settings = useSettingsStore.getState();
- const isStartupActive = () => active;
- const { deferKeychainHydration, showKeychainPrompt } = getKeychainStartupDecision({
- portable: currentPlatform?.portable === true,
- appVersion: currentKeychainConsentVersion,
- approvedVersion: settings.keychainAccessVersion,
- accessGranted: settings.keychainAccessGranted,
- promptDismissed: settings.keychainPromptDismissed
- });
-
- let changed = false;
- if (deferKeychainHydration) {
- settings.setKeychainAccessReady(false);
- if (showKeychainPrompt) {
- // Commit the explanation before the harmless session-token IPC so
- // a slow startup cannot leave the user facing an unexplained
- // credential-store request.
- settings.setShowKeychainModal(true);
- }
- // This token is already owned by the backend and does not access
- // the OS credential store. Render our explanation before any native
- // Keychain/Credential Manager prompt can be user-triggered.
- await settings.hydrateSessionPairingToken(isStartupActive);
- if (!active) return;
- } else {
- // The backend keeps credential-store access disabled for every new
- // process. Arm it only after the persisted startup decision has
- // confirmed that this build was already approved; the hydrate call
- // below is then the first operation allowed to touch the OS store.
- await invoke('authorize_keychain_access');
- if (!active) return;
- changed = await settings.hydratePairingToken(isStartupActive);
- if (!active) return;
- const currentSettings = useSettingsStore.getState();
- settings.setKeychainAccessReady(getKeychainAccessReady({
- portable: currentPlatform?.portable === true,
- accessGranted: currentSettings.keychainAccessGranted,
- persistent: currentSettings.isPairingTokenPersistent
- }));
- }
- if (changed) {
- addToast({
- variant: 'warning',
- isActionable: true,
- message: (
-
-
{t($ => $.app.extensionDisconnected)}
-
-
-
-
-
- )
- });
- }
- } catch (error) {
- console.error('Failed to hydrate extension pairing token:', error);
- addToast({
- message: t($ => $.app.credentialPersistenceFailed, { detail: String(error) }),
- variant: 'error',
- isActionable: true
- });
- }
-
if (!active) return;
setCoreReady(true);
};
diff --git a/src/components/KeychainPermissionModal.tsx b/src/components/KeychainPermissionModal.tsx
index deac59c..048b4a0 100644
--- a/src/components/KeychainPermissionModal.tsx
+++ b/src/components/KeychainPermissionModal.tsx
@@ -21,9 +21,15 @@ export const KeychainPermissionModal: React.FC = (
const platform = usePlatformInfo();
const [isGranting, setIsGranting] = useState(false);
const [grantRequestPending, setGrantRequestPending] = useState(false);
+ const [grantRequestTimedOut, setGrantRequestTimedOut] = useState(false);
const [error, setError] = useState(null);
+ const isMountedRef = useRef(true);
const grantRequestRef = useRef | null>(null);
+ useEffect(() => () => {
+ isMountedRef.current = false;
+ }, []);
+
useEffect(() => {
if (!showKeychainModal || isGranting || grantRequestPending) return;
const handleEscape = (event: KeyboardEvent) => {
@@ -68,6 +74,7 @@ export const KeychainPermissionModal: React.FC = (
// launch a second OS prompt while the first one is still outstanding.
if (grantRequestRef.current) return;
setIsGranting(true);
+ setGrantRequestTimedOut(false);
setError(null);
let timeoutId: number | undefined;
@@ -95,11 +102,17 @@ export const KeychainPermissionModal: React.FC = (
void grantRequest.then(
() => {
if (grantRequestRef.current === grantRequest) grantRequestRef.current = null;
- setGrantRequestPending(false);
+ if (isMountedRef.current) {
+ setGrantRequestPending(false);
+ setGrantRequestTimedOut(false);
+ }
},
() => {
if (grantRequestRef.current === grantRequest) grantRequestRef.current = null;
- setGrantRequestPending(false);
+ if (isMountedRef.current) {
+ setGrantRequestPending(false);
+ setGrantRequestTimedOut(false);
+ }
}
);
// A native credential-store call cannot be cancelled by the webview. Keep
@@ -112,19 +125,24 @@ export const KeychainPermissionModal: React.FC = (
grantRequest,
new Promise((_, reject) => {
timeoutId = window.setTimeout(
- () => reject(new Error(t($ => $.keychain.timeout))),
+ () => {
+ if (isMountedRef.current) setGrantRequestTimedOut(true);
+ reject(new Error(t($ => $.keychain.timeout)));
+ },
KEYCHAIN_GRANT_TIMEOUT_MS
);
})
]);
if (!(await applyPersistentGrant(result))) {
- setError(result.error || t($ => $.keychain.unavailable, { store: siteCredentialStoreName }));
+ if (isMountedRef.current) {
+ setError(result.error || t($ => $.keychain.unavailable, { store: siteCredentialStoreName }));
+ }
}
} catch (e: any) {
- setError(e.toString());
+ if (isMountedRef.current) setError(e.toString());
} finally {
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
- setIsGranting(false);
+ if (isMountedRef.current) setIsGranting(false);
}
};
@@ -148,6 +166,7 @@ export const KeychainPermissionModal: React.FC = (
}}
role="dialog"
aria-modal="true"
+ aria-labelledby="keychain-permission-title"
>
= (
-
{t($ => $.keychain.title)}
+
{t($ => $.keychain.title)}
@@ -198,16 +217,18 @@ export const KeychainPermissionModal: React.FC
= (
diff --git a/src/index.css b/src/index.css
index d18c0b4..4ece700 100644
--- a/src/index.css
+++ b/src/index.css
@@ -25,7 +25,7 @@
--surface-overlay: 0 0% 100% / 0.95;
--shadow-color: 220 10% 20% / 0.1;
--sidebar-shell-bg: 0 0% 92%;
- --sidebar-panel-bg: 0 0% 93%;
+ --sidebar-panel-bg: 0 0% 96%;
--workspace-bg: 0 0% 98%;
--statusbar-bg: 0 0% 96%;
--status-completed: 136 62% 48%;
@@ -69,7 +69,7 @@
--surface-overlay: 0 0% 100% / 0.95;
--shadow-color: 220 10% 20% / 0.1;
--sidebar-shell-bg: 0 0% 92%;
- --sidebar-panel-bg: 0 0% 93%;
+ --sidebar-panel-bg: 0 0% 96%;
--workspace-bg: 0 0% 98%;
--statusbar-bg: 0 0% 96%;
--sidebar-border: 0 0% 0% / 0.18;
@@ -103,7 +103,7 @@
--status-queued: 38 95% 55%;
--status-retrying: 50 100% 60%;
--sidebar-shell-bg: 0 0% 11%;
- --sidebar-panel-bg: 0 0% 13%;
+ --sidebar-panel-bg: 0 0% 11%;
--workspace-bg: 0 0% 11%;
--statusbar-bg: 0 0% 11%;
--sidebar-border: 0 0% 100% / 0.11;
@@ -148,7 +148,7 @@
--status-queued: 38 95% 60%;
--status-retrying: 50 100% 65%;
--sidebar-shell-bg: 231 15% 15%;
- --sidebar-panel-bg: 232 14% 23%;
+ --sidebar-panel-bg: 231 15% 16%;
--workspace-bg: 231 15% 18%;
--statusbar-bg: 231 15% 16%;
--sidebar-border: 326 100% 74% / 0.16;
@@ -193,7 +193,7 @@
--status-queued: 40 71% 60%;
--status-retrying: 50 85% 55%;
--sidebar-shell-bg: 220 18% 18%;
- --sidebar-panel-bg: 220 17% 27%;
+ --sidebar-panel-bg: 220 18% 19%;
--workspace-bg: 220 16% 22%;
--statusbar-bg: 220 18% 19%;
--sidebar-border: 193 43% 67% / 0.16;
@@ -531,6 +531,35 @@ html[data-list-density="relaxed"] {
transform: scale(0.96);
}
+ .keychain-modal-action {
+ transition:
+ background-color 100ms ease,
+ border-color 100ms ease,
+ box-shadow 100ms ease,
+ color 100ms ease,
+ transform 70ms ease,
+ opacity 100ms ease;
+ }
+
+ .keychain-modal-action:active:not(:disabled) {
+ transform: scale(0.96);
+ }
+
+ .keychain-modal-action:focus-visible {
+ outline: 2px solid hsl(var(--accent-color) / 0.7);
+ outline-offset: 2px;
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ .keychain-modal-action {
+ transition: none;
+ }
+
+ .keychain-modal-action:active:not(:disabled) {
+ transform: none;
+ }
+ }
+
.app-button-primary {
border-color: transparent;
background: var(--color-accent);
@@ -1488,6 +1517,11 @@ html[data-list-density="relaxed"] {
margin-bottom: 0;
}
+ .sidebar-settings-button:not([data-active="true"]):hover {
+ background: hsl(var(--sidebar-hover));
+ color: hsl(var(--text-primary));
+ }
+
.sidebar-section-label {
padding: 0 12px;
margin-bottom: 7px;