Compare commits

...

8 Commits

Author SHA1 Message Date
陈大猫
8215dfe6a1 Merge pull request #824 from binaricat/fix/cloud-sync-oauth-port-fallback-823
Some checks failed
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
fix(cloud-sync): fall back to OS-assigned OAuth port when 45678 is busy (closes #823)
2026-04-23 17:24:54 +08:00
bincxz
a1866747a5 fix(cloud-sync): harden auth cancellation flow 2026-04-23 17:24:28 +08:00
bincxz
78fc4628b9 refactor(cloud-sync): simplify OAuth callback flow 2026-04-23 14:51:50 +08:00
bincxz
c721591466 fix(cloud-sync): fall back to OS-assigned OAuth port when 45678 is busy (#823)
The Google Drive / OneDrive PKCE flow bound a temporary callback server on
a hardcoded 127.0.0.1:45678. If anything on the user's machine already
holds that port (another desktop app, a leftover process, a firewall rule)
the listen fails with EADDRINUSE and the user sees
"Error invoking remote method 'oauth:startCallback': EADDRINUSE".

Split the bridge into a two-step flow so the chosen port is known before
we build the authorization URL:

- oauthBridge.prepareOAuthCallback(): tries the preferred 45678 first,
  falls back to an OS-assigned free port (listen(0)) if it's in use, and
  returns { port, redirectUri }.
- oauthBridge.awaitOAuthCallback(state): awaits the code on the
  already-prepared server.

CloudSyncManager.startProviderAuth now requires the redirectUri to be
passed in; useCloudSync calls prepare → startProviderAuth(redirectUri) →
await, and cancels the prepared server if anything fails before the
browser hop.

windowManager's in-app-popup allow-list reads the active port from
oauthBridge at popup-open time instead of hardcoding 45678, so the
loopback callback keeps working regardless of which port was chosen.

Also: unref() the callback server and closeAllConnections() on teardown
so the OS port is released promptly between flows and test runs don't
leave zombie listeners.

Tests: new electron/bridges/oauthBridge.test.cjs covers the preferred-
port path, the busy-port fallback (#823 regression), the state-mismatch
rejection, the provider-error rejection, the "await without prepare"
guard, and cancel/release semantics. All 85 bridge tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 14:12:16 +08:00
陈大猫
8514c75301 fix(tray): ship multi-size .ico for Windows to fix HiDPI blur (#794) (#822)
The previous fix attached a 32x32 @2x representation to the 16x16 PNG,
which only covers 100% and 200% scale factors. Users on 125/150/175/
250%+ still got a blurry tray icon because Windows had to resample from
one of those two sizes.

Ship a proper multi-size tray-icon.ico (16, 20, 24, 32, 40, 48, 64) and
point the Windows tray loader at it. Windows picks the closest size per
DPI scale on its own, so no addRepresentation / resize juggling is
needed. Linux keeps the existing PNG + @2x path; macOS is unchanged.

Also add scripts/generate-tray-ico.py so the .ico can be regenerated
from public/icon-win.png whenever the source artwork changes.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:54:31 +08:00
陈大猫
c30d872852 fix(settings): guard customKeyBindings sync against echo loop (closes #818) (#821)
* fix(settings): guard customKeyBindings cross-window sync against echo loop (closes #818)

customKeyBindings was the only synced setting whose two cross-window
handlers (DOM storage event + IPC onSettingsChanged) called
setCustomKeyBindings unconditionally. Every broadcast landed with a
fresh parsed object reference, so React re-rendered and the persist
effect re-broadcast, echoing across windows indefinitely.

While the echoes carry the same content, a rapid second click from
the user can arrive between the outbound broadcast and an older
in-flight echo — the echo's setState then clobbers the latest click
and the UI "bounces" from Disabled back to the original binding.
This matches the report in #818 (disable and reset operations
flicker between values when clicked in quick succession).

Fix: mirror the equality guards used by every other synced field.
Compare the incoming payload (stringified for objects) against the
current value from settingsSnapshotRef, and skip setCustomKeyBindings
when they match. Add customKeyBindings to settingsSnapshotRef so the
IPC handler has access without pulling it into the effect's closure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(settings): stop shortcut sync bounce flicker

* fix(settings): harden shortcut sync ordering

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:34:38 +08:00
陈大猫
c58f018d24 fix(terminal): preserve selection when typing Space or uppercase letters (closes #819) (#820)
PR #763 captured and restored the mouse selection in a keydown-only
microtask. That covers lowercase letters — xterm's _keyDown calls
triggerDataEvent synchronously, so the selection is cleared before the
microtask drains and the restore runs.

Space (keyCode 32) and A–Z (the _keyDown macOS-IME HACK) are instead
routed through the keypress event, which fires in a *later* macrotask.
The keydown microtask drains first, sees the selection still intact, and
no-ops. Then keypress clears it without any restore.

Fix: hook both keydown and keypress in attachCustomKeyEventHandler. The
keypress path gives us a second microtask that drains after _keyPress
has cleared the selection, so the restore actually runs for those keys.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:38:23 +08:00
libalpm64
dd1d97ffff Fix Midnight brightness, optimize backdrop-blur, and remove unused radials. (#817)
- Fixed 8% brightness causes compositers to have severe rendering issues. (Only effected on the Midnight color scheme) 10% seems to be okay.
- Reduced backdrop-blur as it's expensive CSS.
- Removed radial-gradient backgrounds (they don't show up)
2026-04-23 10:01:02 +08:00
34 changed files with 1955 additions and 462 deletions

View File

@@ -1518,6 +1518,7 @@ const en: Messages = {
'cloudSync.conflict.keepLocal': 'Overwrite cloud (keep local)',
'cloudSync.conflict.useCloud': 'Download cloud (overwrite local)',
'cloudSync.connect.browserContinue': 'Complete authorization in browser',
'cloudSync.connect.browserCancelled': 'Previous browser authorization was cancelled',
'cloudSync.connect.github.success': 'GitHub connected successfully',
'cloudSync.connect.github.failedTitle': 'GitHub connection failed',
'cloudSync.connect.github.timeout': 'GitHub connection timed out. Check your network or proxy settings.',

View File

@@ -1123,6 +1123,7 @@ const zhCN: Messages = {
'cloudSync.conflict.keepLocal': '覆盖云端(保留本地)',
'cloudSync.conflict.useCloud': '下载云端(覆盖本地)',
'cloudSync.connect.browserContinue': '请在浏览器中完成授权',
'cloudSync.connect.browserCancelled': '已取消上一个浏览器授权流程',
'cloudSync.connect.github.success': 'GitHub 已连接',
'cloudSync.connect.github.failedTitle': 'GitHub 连接失败',
'cloudSync.connect.github.timeout': '连接 GitHub 超时,请检查网络或代理设置。',

View File

@@ -53,6 +53,7 @@ export interface CloudSyncHook {
remoteVersion: number;
remoteUpdatedAt: number;
syncHistory: SyncHistoryEntry[];
pendingBrowserAuthProvider: 'google' | 'onedrive' | null;
// Computed
hasAnyConnectedProvider: boolean;
@@ -72,7 +73,9 @@ export interface CloudSyncHook {
deviceCode: string,
interval: number,
expiresAt: number,
onPending?: () => void
onPending?: () => void,
signal?: AbortSignal,
authAttemptId?: number
) => Promise<void>;
connectGoogle: () => Promise<string>;
connectOneDrive: () => Promise<string>;
@@ -126,6 +129,47 @@ export interface CloudSyncHook {
getShrinkBlockedFinding: () => Extract<ShrinkFinding, { suspicious: true }> | null;
}
type PendingBrowserAuthState = {
provider: 'google' | 'onedrive';
sessionId: string;
authAttemptId?: number;
} | null;
let pendingBrowserAuthState: PendingBrowserAuthState = null;
const pendingBrowserAuthListeners = new Set<() => void>();
let activeOAuthBrowserHandoff:
| { sessionId: string; cancel: () => void }
| null = null;
const cancelledOAuthSessionIds = new Set<string>();
const getPendingBrowserAuthState = (): PendingBrowserAuthState => pendingBrowserAuthState;
const subscribePendingBrowserAuthState = (callback: () => void) => {
pendingBrowserAuthListeners.add(callback);
return () => pendingBrowserAuthListeners.delete(callback);
};
const setPendingBrowserAuthState = (next: PendingBrowserAuthState) => {
pendingBrowserAuthState = next;
pendingBrowserAuthListeners.forEach((callback) => callback());
};
const clearPendingBrowserAuthState = (
match?: { provider: 'google' | 'onedrive'; sessionId: string; authAttemptId?: number }
) => {
if (!match) {
setPendingBrowserAuthState(null);
return;
}
if (
pendingBrowserAuthState &&
pendingBrowserAuthState.provider === match.provider &&
pendingBrowserAuthState.sessionId === match.sessionId
) {
setPendingBrowserAuthState(null);
}
};
// ============================================================================
// Hook Implementation
// ============================================================================
@@ -146,6 +190,15 @@ const getSnapshot = (): SyncManagerState => {
export const useCloudSync = (): CloudSyncHook => {
// Use useSyncExternalStore for real-time state sync across all components
const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const pendingBrowserAuth = useSyncExternalStore(
subscribePendingBrowserAuthState,
getPendingBrowserAuthState,
getPendingBrowserAuthState
);
const activeOAuthSessionIdRef = useRef<string | null>(null);
const activeOAuthProviderRef = useRef<'google' | 'onedrive' | null>(null);
const activeGitHubAuthAbortRef = useRef<AbortController | null>(null);
const activeGitHubAuthAttemptIdRef = useRef<number | null>(null);
// Auto-unlock: if a master key exists, retrieve the persisted password (Electron safeStorage)
// and unlock silently so users don't have to manage a LOCKED state in the UI.
@@ -262,107 +315,274 @@ export const useCloudSync = (): CloudSyncHook => {
if (result.type !== 'device_code') {
throw new Error('Unexpected auth type');
}
return result.data as DeviceFlowState;
activeGitHubAuthAttemptIdRef.current = result.data.authAttemptId ?? null;
return result.data;
}, []);
const completeGitHubAuth = useCallback(async (
deviceCode: string,
interval: number,
expiresAt: number,
onPending?: () => void
onPending?: () => void,
signal?: AbortSignal,
authAttemptId?: number
): Promise<void> => {
await manager.completeGitHubAuth(deviceCode, interval, expiresAt, onPending);
}, []);
const connectGoogle = useCallback(async (): Promise<string> => {
const result = await manager.startProviderAuth('google');
if (result.type !== 'url') {
throw new Error('Unexpected auth type');
const controller = new AbortController();
const abort = () => controller.abort();
if (signal?.aborted) {
abort();
} else if (signal) {
signal.addEventListener('abort', abort, { once: true });
}
const data = result.data as { url: string; redirectUri: string };
// Start OAuth callback server in Electron and wait for authorization
const bridge = netcattyBridge.get();
const startCallback = bridge?.startOAuthCallback;
if (startCallback) {
// Get state from adapter for CSRF protection
const adapter = manager.getAdapter('google') as { getPKCEState?: () => string | null } | undefined;
const expectedState = adapter?.getPKCEState?.() || undefined;
activeGitHubAuthAbortRef.current = controller;
// Start callback server and open system browser
const callbackPromise = startCallback(expectedState);
// Use system browser to avoid white-screen issues in popup windows (#563)
// Race: if browser launch fails, surface the error immediately
let openTimer: ReturnType<typeof setTimeout> | null = null;
const browserPromise = new Promise<never>((_resolve, reject) => {
openTimer = setTimeout(async () => {
try {
await bridge?.openExternal(data.url);
} catch (err) {
bridge?.cancelOAuthCallback?.();
reject(err instanceof Error ? err : new Error('Failed to open browser for authentication'));
}
}, 100);
});
try {
const { code } = await Promise.race([callbackPromise, browserPromise]);
// Complete auth with the received code
await manager.completePKCEAuth('google', code, data.redirectUri);
} finally {
if (openTimer) clearTimeout(openTimer);
try {
await manager.completeGitHubAuth(
deviceCode,
interval,
expiresAt,
onPending,
controller.signal,
authAttemptId
);
} finally {
if (signal) {
signal.removeEventListener('abort', abort);
}
if (activeGitHubAuthAbortRef.current === controller) {
activeGitHubAuthAbortRef.current = null;
}
if (activeGitHubAuthAttemptIdRef.current === (authAttemptId ?? null)) {
activeGitHubAuthAttemptIdRef.current = null;
}
}
return data.url;
}, []);
const cancelActivePKCEAuth = useCallback(async () => {
const pending = getPendingBrowserAuthState();
const sessionId = pending?.sessionId ?? activeOAuthSessionIdRef.current;
const provider = pending?.provider ?? activeOAuthProviderRef.current;
const authAttemptId = pending?.authAttemptId;
if (!sessionId || !provider) return;
cancelledOAuthSessionIds.add(sessionId);
if (activeOAuthBrowserHandoff?.sessionId === sessionId) {
activeOAuthBrowserHandoff.cancel();
activeOAuthBrowserHandoff = null;
}
manager.cancelProviderAuthAttempt(provider, authAttemptId);
activeOAuthSessionIdRef.current = null;
activeOAuthProviderRef.current = null;
clearPendingBrowserAuthState(
pending
? {
provider: pending.provider,
sessionId: pending.sessionId,
authAttemptId: pending.authAttemptId,
}
: undefined
);
try {
await netcattyBridge.get()?.cancelOAuthCallback?.(sessionId);
} catch {
// Best-effort cleanup
}
}, []);
const runPKCEAuth = useCallback(
async (provider: 'google' | 'onedrive'): Promise<string> => {
const bridge = netcattyBridge.get();
const prepare = bridge?.prepareOAuthCallback;
const awaitCallback = bridge?.awaitOAuthCallback;
const openExternal = bridge?.openExternal;
if (!prepare || !awaitCallback || !openExternal) {
throw new Error('OAuth bridge is unavailable');
}
// Only one loopback OAuth flow can be active at a time. If the user
// starts another provider while a previous browser hop is still pending,
// cancel the stale one first so the new attempt owns the callback port.
await cancelActivePKCEAuth();
// Bind the loopback callback server first so we know which port to put
// in the provider's redirect_uri (#823: 45678 may be in use).
const { redirectUri, sessionId } = await prepare();
activeOAuthSessionIdRef.current = sessionId;
activeOAuthProviderRef.current = provider;
setPendingBrowserAuthState({ provider, sessionId });
try {
const result = await manager.startProviderAuth(provider, redirectUri);
if (result.type !== 'url') {
throw new Error('Unexpected auth type');
}
const data = result.data;
if (cancelledOAuthSessionIds.has(sessionId)) {
throw new Error('OAuth flow cancelled');
}
const adapter = manager.getAdapter(provider) as
| { getPKCEState?: () => string | null }
| undefined;
const expectedState = adapter?.getPKCEState?.() || undefined;
const callbackPromise = awaitCallback(expectedState, sessionId);
// Use system browser to avoid white-screen issues in popup windows (#563).
// Once the browser has opened, let the rest of the PKCE handshake
// continue in the background so closing the browser later does not
// leave the whole settings page locked waiting on a timeout.
let openTimer: ReturnType<typeof setTimeout> | null = null;
let browserOpened = false;
let rejectBrowserPromise: ((error: Error) => void) | null = null;
const browserPromise = new Promise<void>((resolve, reject) => {
rejectBrowserPromise = reject;
openTimer = setTimeout(async () => {
try {
await openExternal(data.url);
browserOpened = true;
resolve();
} catch (err) {
bridge?.cancelOAuthCallback?.(sessionId);
reject(
err instanceof Error
? err
: new Error('Failed to open browser for authentication')
);
}
}, 100);
});
activeOAuthBrowserHandoff = {
sessionId,
cancel: () => {
if (openTimer) {
clearTimeout(openTimer);
openTimer = null;
}
if (rejectBrowserPromise) {
rejectBrowserPromise(new Error('OAuth flow cancelled'));
rejectBrowserPromise = null;
}
},
};
try {
await Promise.race([
browserPromise,
callbackPromise.then(
() => {
throw new Error('OAuth callback completed before browser handoff');
},
(error) => {
if (browserOpened) {
return new Promise<void>(() => {});
}
throw error;
}
),
]);
} finally {
if (openTimer) clearTimeout(openTimer);
if (activeOAuthBrowserHandoff?.sessionId === sessionId) {
activeOAuthBrowserHandoff = null;
}
}
setPendingBrowserAuthState({
provider,
sessionId,
authAttemptId: data.authAttemptId,
});
const completionPromise = (async () => {
try {
const { code } = await callbackPromise;
await manager.completePKCEAuth(provider, code, data.redirectUri, data.authAttemptId);
} catch (error) {
const ownsActiveSession =
activeOAuthSessionIdRef.current === sessionId &&
activeOAuthProviderRef.current === provider;
const message = error instanceof Error ? error.message : String(error);
const cancelledOrSuperseded =
message.includes('cancelled') || message.includes('auth superseded');
const timedOut = message.toLowerCase().includes('timeout');
if (ownsActiveSession && (cancelledOrSuperseded || timedOut)) {
activeOAuthSessionIdRef.current = null;
activeOAuthProviderRef.current = null;
cancelledOAuthSessionIds.delete(sessionId);
clearPendingBrowserAuthState({
provider,
sessionId,
authAttemptId: data.authAttemptId,
});
manager.resetProviderStatus(provider);
} else if (ownsActiveSession) {
activeOAuthSessionIdRef.current = null;
activeOAuthProviderRef.current = null;
cancelledOAuthSessionIds.delete(sessionId);
clearPendingBrowserAuthState({
provider,
sessionId,
authAttemptId: data.authAttemptId,
});
manager.setProviderError(provider, message);
}
} finally {
if (
activeOAuthSessionIdRef.current === sessionId &&
activeOAuthProviderRef.current === provider
) {
activeOAuthSessionIdRef.current = null;
activeOAuthProviderRef.current = null;
}
cancelledOAuthSessionIds.delete(sessionId);
clearPendingBrowserAuthState({
provider,
sessionId,
authAttemptId: data.authAttemptId,
});
}
})();
// Release the transient "connecting" UI once the browser handoff has
// happened. The callback session remains active in the background and
// will mark the provider connected when the redirect completes.
manager.resetProviderStatus(provider);
manager.clearProviderError(provider);
void completionPromise;
return data.url;
} catch (err) {
const ownsActiveSession =
activeOAuthSessionIdRef.current === sessionId &&
activeOAuthProviderRef.current === provider;
try {
await bridge?.cancelOAuthCallback?.(sessionId);
} catch {
// Best-effort cleanup
}
if (ownsActiveSession) {
activeOAuthSessionIdRef.current = null;
activeOAuthProviderRef.current = null;
manager.cancelProviderAuthAttempt(provider);
manager.resetProviderStatus(provider);
}
throw err;
}
},
[cancelActivePKCEAuth]
);
const connectGoogle = useCallback(async (): Promise<string> => {
return runPKCEAuth('google');
}, [runPKCEAuth]);
const connectOneDrive = useCallback(async (): Promise<string> => {
const result = await manager.startProviderAuth('onedrive');
if (result.type !== 'url') {
throw new Error('Unexpected auth type');
}
const data = result.data as { url: string; redirectUri: string };
return runPKCEAuth('onedrive');
}, [runPKCEAuth]);
// Start OAuth callback server in Electron and wait for authorization
const bridge = netcattyBridge.get();
const startCallback = bridge?.startOAuthCallback;
if (startCallback) {
// Get state from adapter for CSRF protection
const adapter = manager.getAdapter('onedrive') as { getPKCEState?: () => string | null } | undefined;
const expectedState = adapter?.getPKCEState?.() || undefined;
// Start callback server and open system browser
const callbackPromise = startCallback(expectedState);
// Use system browser to avoid white-screen issues in popup windows (#563)
let openTimer: ReturnType<typeof setTimeout> | null = null;
const browserPromise = new Promise<never>((_resolve, reject) => {
openTimer = setTimeout(async () => {
try {
await bridge?.openExternal(data.url);
} catch (err) {
bridge?.cancelOAuthCallback?.();
reject(err instanceof Error ? err : new Error('Failed to open browser for authentication'));
}
}, 100);
});
try {
const { code } = await Promise.race([callbackPromise, browserPromise]);
// Complete auth with the received code
await manager.completePKCEAuth('onedrive', code, data.redirectUri);
} finally {
if (openTimer) clearTimeout(openTimer);
}
}
return data.url;
}, []);
const completePKCEAuth = useCallback(async (
provider: 'google' | 'onedrive',
code: string,
@@ -388,9 +608,16 @@ export const useCloudSync = (): CloudSyncHook => {
}, []);
const cancelOAuthConnect = useCallback(() => {
const bridge = netcattyBridge.get();
bridge?.cancelOAuthCallback?.();
}, []);
const githubAbort = activeGitHubAuthAbortRef.current;
if (githubAbort) {
manager.cancelProviderAuthAttempt('github', activeGitHubAuthAttemptIdRef.current ?? undefined);
activeGitHubAuthAttemptIdRef.current = null;
githubAbort.abort();
return;
}
void cancelActivePKCEAuth();
}, [cancelActivePKCEAuth]);
// ========== Settings ==========
@@ -478,6 +705,7 @@ export const useCloudSync = (): CloudSyncHook => {
remoteVersion: state.remoteVersion,
remoteUpdatedAt: state.remoteUpdatedAt,
syncHistory: state.syncHistory,
pendingBrowserAuthProvider: pendingBrowserAuth?.provider ?? null,
// Computed
hasAnyConnectedProvider,

View File

@@ -40,6 +40,15 @@ import {
} from '../../infrastructure/config/storageKeys';
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
import {
areCustomKeyBindingsEqual,
nextCustomKeyBindingsSyncVersion,
parseCustomKeyBindingsStorageRecord,
resetCustomKeyBinding,
serializeCustomKeyBindingsStorageRecord,
shouldApplyIncomingCustomKeyBindingsRecord,
updateCustomKeyBinding as updateCustomKeyBindingRecord,
} from '../../domain/customKeyBindings';
import { getTerminalThemeForUiTheme } from '../../domain/terminalAppearance';
import { customThemeStore, useCustomThemes } from '../state/customThemeStore';
import { DEFAULT_FONT_SIZE } from '../../infrastructure/config/fonts';
@@ -124,6 +133,14 @@ const serializeTerminalSettings = (settings: TerminalSettings): string =>
const areTerminalSettingsEqual = (a: TerminalSettings, b: TerminalSettings): boolean =>
serializeTerminalSettings(a) === serializeTerminalSettings(b);
const createCustomKeyBindingsSyncOrigin = (): string => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
};
const applyThemeTokens = (
themeSource: 'light' | 'dark' | 'system',
resolvedTheme: 'light' | 'dark',
@@ -169,6 +186,8 @@ const applyThemeTokens = (
};
export const useSettingsState = () => {
const initialCustomKeyBindingsRecord =
parseCustomKeyBindingsStorageRecord(localStorageAdapter.readString(STORAGE_KEY_CUSTOM_KEY_BINDINGS));
const uiFontsLoaded = useUIFontsLoaded();
const [theme, setTheme] = useState<'dark' | 'light' | 'system'>(() => {
const stored = readStoredString(STORAGE_KEY_THEME);
@@ -231,8 +250,8 @@ export const useSettingsState = () => {
}
return DEFAULT_HOTKEY_SCHEME;
});
const [customKeyBindings, setCustomKeyBindings] = useState<CustomKeyBindings>(() =>
localStorageAdapter.read<CustomKeyBindings>(STORAGE_KEY_CUSTOM_KEY_BINDINGS) || {}
const [customKeyBindings, setCustomKeyBindingsState] = useState<CustomKeyBindings>(() =>
initialCustomKeyBindingsRecord?.bindings || {}
);
const [isHotkeyRecording, setIsHotkeyRecordingState] = useState(false);
const [customCSS, setCustomCSS] = useState<string>(() =>
@@ -330,6 +349,10 @@ export const useSettingsState = () => {
const incomingTerminalSettingsSignatureRef = useRef<string | null>(null);
const localTerminalSettingsVersionRef = useRef(0);
const broadcastedLocalTerminalSettingsVersionRef = useRef(0);
const customKeyBindingsVersionRef = useRef(initialCustomKeyBindingsRecord?.version || 0);
const customKeyBindingsOriginRef = useRef(initialCustomKeyBindingsRecord?.origin || 'legacy');
const customKeyBindingsLocalOriginRef = useRef(createCustomKeyBindingsSyncOrigin());
const customKeyBindingsMutationSourceRef = useRef<'local' | 'incoming'>('local');
// Fix 1: Mount guard — skip redundant IPC broadcasts & localStorage writes on initial mount.
// Set to true by the LAST useEffect declaration; all persist effects see false on first render.
@@ -361,6 +384,51 @@ export const useSettingsState = () => {
});
}, []);
const setCustomKeyBindings = useCallback((nextValue: SetStateAction<CustomKeyBindings>) => {
setCustomKeyBindingsState((prev) => {
const candidate = typeof nextValue === 'function'
? (nextValue as (prevState: CustomKeyBindings) => CustomKeyBindings)(prev)
: nextValue;
if (areCustomKeyBindingsEqual(prev, candidate)) {
return prev;
}
customKeyBindingsVersionRef.current = nextCustomKeyBindingsSyncVersion(
customKeyBindingsVersionRef.current,
);
customKeyBindingsOriginRef.current = customKeyBindingsLocalOriginRef.current;
customKeyBindingsMutationSourceRef.current = 'local';
return candidate;
});
}, []);
const applyIncomingCustomKeyBindings = useCallback((incoming: {
bindings: CustomKeyBindings;
version: number;
origin: string;
}) => {
setCustomKeyBindingsState((prev) => {
if (!shouldApplyIncomingCustomKeyBindingsRecord(
{
version: customKeyBindingsVersionRef.current,
origin: customKeyBindingsOriginRef.current,
},
{
version: incoming.version,
origin: incoming.origin,
},
)) {
return prev;
}
customKeyBindingsVersionRef.current = incoming.version;
customKeyBindingsOriginRef.current = incoming.origin;
customKeyBindingsMutationSourceRef.current = 'incoming';
if (areCustomKeyBindingsEqual(prev, incoming.bindings)) {
return prev;
}
return incoming.bindings;
});
}, []);
// Helper to notify other windows about settings changes via IPC
const notifySettingsChanged = useCallback((key: string, value: unknown) => {
try {
@@ -456,11 +524,11 @@ export const useSettingsState = () => {
}
// Keyboard
const storedKb = readStoredString(STORAGE_KEY_CUSTOM_KEY_BINDINGS);
const storedKb = parseCustomKeyBindingsStorageRecord(
localStorageAdapter.readString(STORAGE_KEY_CUSTOM_KEY_BINDINGS),
);
if (storedKb) {
try {
setCustomKeyBindings(JSON.parse(storedKb));
} catch { /* ignore */ }
applyIncomingCustomKeyBindings(storedKb);
}
// Editor
@@ -493,7 +561,7 @@ export const useSettingsState = () => {
// Custom terminal themes
customThemeStore.loadFromStorage();
}, [syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings]);
}, [applyIncomingCustomKeyBindings, syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings]);
useLayoutEffect(() => {
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
@@ -616,14 +684,9 @@ export const useSettingsState = () => {
setHotkeyScheme(value);
}
if (key === STORAGE_KEY_CUSTOM_KEY_BINDINGS) {
if (typeof value === 'string') {
try {
setCustomKeyBindings(JSON.parse(value) as CustomKeyBindings);
} catch {
// ignore parse errors
}
} else if (value && typeof value === 'object') {
setCustomKeyBindings(value as CustomKeyBindings);
const parsed = parseCustomKeyBindingsStorageRecord(value);
if (parsed) {
applyIncomingCustomKeyBindings(parsed);
}
}
if (key === STORAGE_KEY_HOTKEY_RECORDING && typeof value === 'boolean') {
@@ -657,7 +720,7 @@ export const useSettingsState = () => {
// ignore
}
};
}, [mergeIncomingTerminalSettings, syncAppearanceFromStorage, syncCustomCssFromStorage]);
}, [applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings, syncAppearanceFromStorage, syncCustomCssFromStorage]);
useEffect(() => {
const bridge = netcattyBridge.get();
@@ -752,11 +815,9 @@ export const useSettingsState = () => {
}
}
if (e.key === STORAGE_KEY_CUSTOM_KEY_BINDINGS && e.newValue) {
try {
const newBindings = JSON.parse(e.newValue) as CustomKeyBindings;
setCustomKeyBindings(newBindings);
} catch {
// ignore parse errors
const parsed = parseCustomKeyBindingsStorageRecord(e.newValue);
if (parsed) {
applyIncomingCustomKeyBindings(parsed);
}
}
// Sync terminal settings from other windows
@@ -908,7 +969,7 @@ export const useSettingsState = () => {
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [mergeIncomingTerminalSettings]); // Fix 4: stable deps only — state comparisons use settingsSnapshotRef
}, [applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings]); // Fix 4: stable deps only — state comparisons use settingsSnapshotRef
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
@@ -956,9 +1017,21 @@ export const useSettingsState = () => {
}, [hotkeyScheme, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.write(STORAGE_KEY_CUSTOM_KEY_BINDINGS, customKeyBindings);
const payload = serializeCustomKeyBindingsStorageRecord({
version: customKeyBindingsVersionRef.current,
origin: customKeyBindingsOriginRef.current,
bindings: customKeyBindings,
});
if (localStorageAdapter.readString(STORAGE_KEY_CUSTOM_KEY_BINDINGS) !== payload) {
localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_KEY_BINDINGS, payload);
}
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_CUSTOM_KEY_BINDINGS, customKeyBindings);
if (customKeyBindingsMutationSourceRef.current === 'incoming') return;
notifySettingsChanged(STORAGE_KEY_CUSTOM_KEY_BINDINGS, {
version: customKeyBindingsVersionRef.current,
origin: customKeyBindingsOriginRef.current,
bindings: customKeyBindings,
});
}, [customKeyBindings, notifySettingsChanged]);
const setIsHotkeyRecording = useCallback((isRecording: boolean) => {
@@ -1170,37 +1243,18 @@ export const useSettingsState = () => {
// Update a single key binding
const updateKeyBinding = useCallback((bindingId: string, scheme: 'mac' | 'pc', newKey: string) => {
setCustomKeyBindings(prev => ({
...prev,
[bindingId]: {
...prev[bindingId],
[scheme]: newKey,
},
}));
}, []);
setCustomKeyBindings(prev => updateCustomKeyBindingRecord(prev, bindingId, scheme, newKey));
}, [setCustomKeyBindings]);
// Reset a key binding to default
const resetKeyBinding = useCallback((bindingId: string, scheme?: 'mac' | 'pc') => {
setCustomKeyBindings(prev => {
const next = { ...prev };
if (scheme) {
if (next[bindingId]) {
delete next[bindingId][scheme];
if (Object.keys(next[bindingId]).length === 0) {
delete next[bindingId];
}
}
} else {
delete next[bindingId];
}
return next;
});
}, []);
setCustomKeyBindings(prev => resetCustomKeyBinding(prev, bindingId, scheme));
}, [setCustomKeyBindings]);
// Reset all key bindings to defaults
const resetAllKeyBindings = useCallback(() => {
setCustomKeyBindings({});
}, []);
}, [setCustomKeyBindings]);
const updateSyncConfig = useCallback((config: SyncConfig | null) => {
setSyncConfig(config);

View File

@@ -18,6 +18,11 @@ import type {
SSHKey,
} from '../domain/models';
import type { SyncPayload } from '../domain/sync';
import {
nextCustomKeyBindingsSyncVersion,
parseCustomKeyBindingsStorageRecord,
serializeCustomKeyBindingsStorageRecord,
} from '../domain/customKeyBindings';
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
import { rehydrateGlobalBookmarks } from '../components/sftp/hooks/useGlobalSftpBookmarks';
import {
@@ -51,6 +56,8 @@ import {
// Input types
// ---------------------------------------------------------------------------
const CUSTOM_KEY_BINDINGS_SYNC_PAYLOAD_ORIGIN = 'sync-payload';
/** All vault-owned data that participates in cloud sync. */
export interface SyncableVaultData {
hosts: Host[];
@@ -171,9 +178,8 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
// Keyboard
const kb = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_KEY_BINDINGS);
if (kb) {
try {
settings.customKeyBindings = JSON.parse(kb);
} catch { /* ignore */ }
const parsed = parseCustomKeyBindingsStorageRecord(kb);
if (parsed) settings.customKeyBindings = parsed.bindings;
}
// Editor
@@ -250,7 +256,17 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
// Keyboard
if (settings.customKeyBindings != null) {
localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_KEY_BINDINGS, JSON.stringify(settings.customKeyBindings));
const previous = parseCustomKeyBindingsStorageRecord(
localStorageAdapter.readString(STORAGE_KEY_CUSTOM_KEY_BINDINGS),
);
localStorageAdapter.writeString(
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
serializeCustomKeyBindingsStorageRecord({
version: nextCustomKeyBindingsSyncVersion(previous?.version || 0),
origin: CUSTOM_KEY_BINDINGS_SYNC_PAYLOAD_ORIGIN,
bindings: settings.customKeyBindings,
}),
);
}
// Editor

View File

@@ -433,7 +433,7 @@ const ProviderCard: React.FC<ProviderCardProps> = ({
size="sm"
variant="outline"
onClick={onCancelConnect}
className="gap-1"
className="gap-1 min-w-[136px] justify-center"
>
<X size={14} />
{t('common.cancel')}
@@ -442,7 +442,7 @@ const ProviderCard: React.FC<ProviderCardProps> = ({
<Button
size="sm"
onClick={() => { onConnect(); }}
className="gap-1"
className="gap-1 min-w-[136px] justify-center"
disabled={disabled || isConnecting}
>
{isConnecting ? <Loader2 size={14} className="animate-spin" /> : <Cloud size={14} />}
@@ -1121,6 +1121,10 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
};
const disconnectOtherProviders = async (current: CloudProvider) => {
if (sync.pendingBrowserAuthProvider && sync.pendingBrowserAuthProvider !== current) {
toast.info(t('cloudSync.connect.browserCancelled'));
}
sync.cancelOAuthConnect();
const providers: CloudProvider[] = ['github', 'google', 'onedrive', 'webdav', 's3'];
for (const provider of providers) {
if (provider === current) continue;
@@ -1135,6 +1139,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
const [gitHubUserCode, setGitHubUserCode] = useState('');
const [gitHubVerificationUri, setGitHubVerificationUri] = useState('');
const [isPollingGitHub, setIsPollingGitHub] = useState(false);
const activeGitHubAttemptIdRef = useRef<number | null>(null);
// Conflict modal
const [showConflictModal, setShowConflictModal] = useState(false);
@@ -1152,6 +1157,40 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
} | null>(null);
const [historyPreviewLoading, setHistoryPreviewLoading] = useState(false);
const [historyError, setHistoryError] = useState<string | null>(null);
const [pendingConnectProvider, setPendingConnectProvider] = useState<CloudProvider | null>(null);
const pendingConnectProviderRef = useRef<CloudProvider | null>(null);
const hasConnectingProvider = (Object.values(sync.providers) as Array<{ status: string }>).some(
(provider) => provider.status === 'connecting'
);
const isConnectDisabled = (provider: CloudProvider): boolean => {
if (pendingConnectProvider && pendingConnectProvider !== provider) {
return true;
}
if (pendingConnectProvider === provider) {
return true;
}
if (hasConnectingProvider && sync.providers[provider].status !== 'connecting') {
return true;
}
return sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers[provider]);
};
const beginPendingConnect = (provider: CloudProvider): boolean => {
if (pendingConnectProviderRef.current) {
return false;
}
pendingConnectProviderRef.current = provider;
setPendingConnectProvider(provider);
return true;
};
const endPendingConnect = (provider: CloudProvider) => {
if (pendingConnectProviderRef.current !== provider) return;
pendingConnectProviderRef.current = null;
setPendingConnectProvider((current) => (current === provider ? null : current));
};
// Change master key dialog
const [showChangeKeyDialog, setShowChangeKeyDialog] = useState(false);
@@ -1275,9 +1314,14 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
// Connect GitHub (disconnect others first - single provider only)
const handleConnectGitHub = async () => {
if (!beginPendingConnect('github')) return;
const cancelController = new AbortController();
let authAttemptId: number | null = null;
try {
await disconnectOtherProviders('github');
const deviceFlow = await sync.connectGitHub();
authAttemptId = deviceFlow.authAttemptId ?? null;
activeGitHubAttemptIdRef.current = authAttemptId;
setGitHubUserCode(deviceFlow.userCode);
setGitHubVerificationUri(deviceFlow.verificationUri);
setShowGitHubModal(true);
@@ -1287,59 +1331,78 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
deviceFlow.deviceCode,
deviceFlow.interval,
deviceFlow.expiresAt,
() => { } // onPending callback
() => { }, // onPending callback
cancelController.signal,
authAttemptId ?? undefined
);
setIsPollingGitHub(false);
setShowGitHubModal(false);
if (activeGitHubAttemptIdRef.current === authAttemptId) {
activeGitHubAttemptIdRef.current = null;
setIsPollingGitHub(false);
setShowGitHubModal(false);
}
toast.success(t('cloudSync.connect.github.success'));
} catch (error) {
setIsPollingGitHub(false);
setShowGitHubModal(false);
// Reset provider status so button is clickable again (without tearing down existing connections)
sync.resetProviderStatus('github');
if (activeGitHubAttemptIdRef.current === authAttemptId) {
activeGitHubAttemptIdRef.current = null;
setIsPollingGitHub(false);
setShowGitHubModal(false);
}
const message = getNetworkErrorMessage(error, t('common.unknownError'));
toast.error(message, t('cloudSync.connect.github.failedTitle'));
if (!message.toLowerCase().includes('cancelled')) {
toast.error(message, t('cloudSync.connect.github.failedTitle'));
}
} finally {
cancelController.abort();
if (activeGitHubAttemptIdRef.current == null) {
endPendingConnect('github');
}
}
};
// Connect Google (disconnect others first - single provider only)
const handleConnectGoogle = async () => {
if (!beginPendingConnect('google')) return;
try {
await disconnectOtherProviders('google');
await sync.connectGoogle();
// Note: Auth flow is handled automatically by oauthBridge
toast.info(t('cloudSync.connect.browserContinue'));
} catch (error) {
// Reset provider status so button is clickable again (without tearing down existing connections)
sync.resetProviderStatus('google');
const msg = error instanceof Error ? error.message : t('common.unknownError');
// Don't show toast for user-initiated cancellation (popup closed)
if (!msg.includes('cancelled')) {
toast.error(msg, t('cloudSync.connect.google.failedTitle'));
}
} finally {
endPendingConnect('google');
}
};
// Connect OneDrive (disconnect others first - single provider only)
const handleConnectOneDrive = async () => {
if (!beginPendingConnect('onedrive')) return;
try {
await disconnectOtherProviders('onedrive');
await sync.connectOneDrive();
// Note: Auth flow is handled automatically by oauthBridge
toast.info(t('cloudSync.connect.browserContinue'));
} catch (error) {
// Reset provider status so button is clickable again (without tearing down existing connections)
sync.resetProviderStatus('onedrive');
const msg = error instanceof Error ? error.message : t('common.unknownError');
// Don't show toast for user-initiated cancellation (popup closed)
if (!msg.includes('cancelled')) {
toast.error(msg, t('cloudSync.connect.onedrive.failedTitle'));
}
} finally {
endPendingConnect('onedrive');
}
};
const openWebdavDialog = () => {
if (sync.pendingBrowserAuthProvider) {
toast.info(t('cloudSync.connect.browserCancelled'));
}
sync.cancelOAuthConnect();
const config = sync.providers.webdav.config as WebDAVConfig | undefined;
setWebdavEndpoint(config?.endpoint || '');
setWebdavAuthType(config?.authType || 'basic');
@@ -1354,6 +1417,10 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
};
const openS3Dialog = () => {
if (sync.pendingBrowserAuthProvider) {
toast.info(t('cloudSync.connect.browserCancelled'));
}
sync.cancelOAuthConnect();
const config = sync.providers.s3.config as S3Config | undefined;
setS3Endpoint(config?.endpoint || '');
setS3Region(config?.region || '');
@@ -1673,7 +1740,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
account={sync.providers.github.account}
lastSync={sync.providers.github.lastSync}
error={sync.providers.github.error}
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.github)}
disabled={isConnectDisabled('github')}
onConnect={handleConnectGitHub}
onDisconnect={() => sync.disconnectProvider('github')}
onSync={() => handleSync('github')}
@@ -1693,11 +1760,14 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
icon={<GoogleDriveIcon className="w-6 h-6" />}
isConnected={isProviderReadyForSync(sync.providers.google)}
isSyncing={sync.providers.google.status === 'syncing'}
isConnecting={sync.providers.google.status === 'connecting'}
isConnecting={
sync.providers.google.status === 'connecting' ||
sync.pendingBrowserAuthProvider === 'google'
}
account={sync.providers.google.account}
lastSync={sync.providers.google.lastSync}
error={sync.providers.google.error}
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.google)}
disabled={isConnectDisabled('google')}
onConnect={handleConnectGoogle}
onCancelConnect={sync.cancelOAuthConnect}
onDisconnect={() => sync.disconnectProvider('google')}
@@ -1710,11 +1780,14 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
icon={<OneDriveIcon className="w-6 h-6" />}
isConnected={isProviderReadyForSync(sync.providers.onedrive)}
isSyncing={sync.providers.onedrive.status === 'syncing'}
isConnecting={sync.providers.onedrive.status === 'connecting'}
isConnecting={
sync.providers.onedrive.status === 'connecting' ||
sync.pendingBrowserAuthProvider === 'onedrive'
}
account={sync.providers.onedrive.account}
lastSync={sync.providers.onedrive.lastSync}
error={sync.providers.onedrive.error}
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.onedrive)}
disabled={isConnectDisabled('onedrive')}
onConnect={handleConnectOneDrive}
onCancelConnect={sync.cancelOAuthConnect}
onDisconnect={() => sync.disconnectProvider('onedrive')}
@@ -1731,7 +1804,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
account={sync.providers.webdav.account}
lastSync={sync.providers.webdav.lastSync}
error={sync.providers.webdav.error}
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.webdav)}
disabled={isConnectDisabled('webdav')}
onEdit={openWebdavDialog}
onConnect={openWebdavDialog}
onDisconnect={() => sync.disconnectProvider('webdav')}
@@ -1748,7 +1821,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
account={sync.providers.s3.account}
lastSync={sync.providers.s3.lastSync}
error={sync.providers.s3.error}
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.s3)}
disabled={isConnectDisabled('s3')}
onEdit={openS3Dialog}
onConnect={openS3Dialog}
onDisconnect={() => sync.disconnectProvider('s3')}
@@ -1876,11 +1949,11 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
verificationUri={gitHubVerificationUri}
isPolling={isPollingGitHub}
onClose={() => {
activeGitHubAttemptIdRef.current = null;
setShowGitHubModal(false);
setIsPollingGitHub(false);
// Reset provider status so button is clickable again.
// The background polling will continue until expiry but is harmless.
sync.resetProviderStatus('github');
endPendingConnect('github');
sync.cancelOAuthConnect();
}}
/>

View File

@@ -520,7 +520,7 @@ echo $3 >> "$FILE"`);
)}
>
{/* Toolbar */}
<div className="h-14 px-4 py-2 flex items-center gap-3 bg-secondary/80 backdrop-blur border-b border-border/50 shrink-0">
<div className="h-14 px-4 py-2 flex items-center gap-3 bg-secondary/80 supports-[backdrop-filter]:backdrop-blur-sm border-b border-border/50 shrink-0">
{/* Filter Tabs */}
<div className="flex items-center gap-1">
{/* KEY button with split interaction: left=switch view, right=dropdown */}

View File

@@ -455,7 +455,7 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
return (
<div className="h-full flex flex-col">
{/* Header */}
<div className="h-14 px-4 py-2 flex items-center gap-3 border-b border-border/50 bg-secondary/80 backdrop-blur">
<div className="h-14 px-4 py-2 flex items-center gap-3 border-b border-border/50 bg-secondary/80 supports-[backdrop-filter]:backdrop-blur-sm">
<div className="flex-1 min-w-0 flex items-center gap-2">
<div className="relative flex-1 max-w-xs">
<Search

View File

@@ -567,7 +567,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
)}
>
{/* Toolbar */}
<div className="h-14 px-4 py-2 flex items-center gap-3 bg-secondary/80 backdrop-blur border-b border-border/50 relative z-20">
<div className="h-14 px-4 py-2 flex items-center gap-3 bg-secondary/80 supports-[backdrop-filter]:backdrop-blur-sm border-b border-border/50 relative z-20">
<Dropdown open={showNewMenu} onOpenChange={setShowNewMenu}>
<DropdownTrigger asChild>
<Button

View File

@@ -983,7 +983,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
<TooltipProvider delayDuration={300}>
<div className="h-full min-h-0 flex relative">
<div className="flex-1 flex flex-col min-h-0 min-w-0 overflow-hidden">
<header className="border-b border-border/50 bg-secondary/80 backdrop-blur">
<header className="border-b border-border/50 bg-secondary/80 supports-[backdrop-filter]:backdrop-blur-sm">
<div className="h-14 px-4 py-2 flex items-center gap-3">
{/* Search box */}
<div className="relative w-64">

View File

@@ -202,7 +202,7 @@ const TrayPanelContent: React.FC = () => {
}, [quitApp]);
return (
<div id="tray-panel-root" className="w-full h-full bg-background/95 backdrop-blur border border-border/60 rounded-lg shadow-lg overflow-hidden flex flex-col">
<div id="tray-panel-root" className="w-full h-full bg-background/95 supports-[backdrop-filter]:backdrop-blur-sm border border-border/60 rounded-lg shadow-lg overflow-hidden flex flex-col">
<div className="px-3 py-2 border-b border-border/60 flex items-center justify-between app-no-drag">
<div className="flex items-center gap-2">
<AppLogo className="w-5 h-5" />

View File

@@ -217,7 +217,7 @@ const AgentSelector: React.FC<AgentSelectorProps> = ({
<DropdownContent
align="start"
sideOffset={6}
className="w-[288px] overflow-hidden rounded-2xl border border-border/50 bg-popover p-0 text-foreground shadow-lg supports-[backdrop-filter]:backdrop-blur-xl"
className="w-[288px] overflow-hidden rounded-2xl border border-border/50 bg-popover p-0 text-foreground shadow-lg supports-[backdrop-filter]:backdrop-blur-sm"
>
{BUILTIN_AGENTS.map((agent) => (
<AgentMenuRow

View File

@@ -512,6 +512,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
{showSlashSkillPicker && filteredUserSkills.length > 0 && inputPanelPos && createPortal(
<>
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
<div className="fixed inset-0 z-[999] cursor-default" onClick={closeAllMenus} />
<div
role="listbox"
aria-label="Insert user skill"
@@ -578,6 +579,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
{showAttachMenu && menuPos && createPortal(
<>
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
<div className="fixed inset-0 z-[999] cursor-default" onClick={closeAllMenus} />
<div
role="menu"
className="fixed z-[1000] min-w-[170px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
@@ -658,10 +660,11 @@ const ChatInput: React.FC<ChatInputProps> = ({
{hasModelPicker && <ChevronDown size={9} className="text-muted-foreground/50" />}
</button>
{showModelPicker && hasModelPicker && menuPos && createPortal(
<>
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
<div
role="listbox"
<>
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
<div className="fixed inset-0 z-[999] cursor-default" onClick={closeAllMenus} />
<div
role="listbox"
aria-label="Select model"
className="fixed z-[1000] w-max min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
style={{ left: menuPos.left, bottom: menuPos.bottom, maxWidth: MODEL_PICKER_MAX_WIDTH }}
@@ -770,6 +773,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
{showPermPicker && menuPos && createPortal(
<>
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
<div className="fixed inset-0 z-[999] cursor-default" onClick={closeAllMenus} />
<div
role="listbox"
aria-label="Permission mode"

View File

@@ -59,7 +59,7 @@ const ConversationExport: React.FC<ConversationExportProps> = ({
<DropdownContent
align="end"
sideOffset={6}
className="w-40 rounded-xl border border-border/45 bg-[#111111]/98 p-1.5 text-foreground shadow-[0_20px_48px_rgba(0,0,0,0.48)] supports-[backdrop-filter]:bg-[#111111]/92 supports-[backdrop-filter]:backdrop-blur-xl"
className="w-40 rounded-xl border border-border/45 bg-[#111111]/98 p-1.5 text-foreground shadow-[0_20px_48px_rgba(0,0,0,0.48)] supports-[backdrop-filter]:bg-[#111111]/92 supports-[backdrop-filter]:backdrop-blur-sm"
>
<div className="px-2 py-1 text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground/48">
{t('ai.chat.exportAs')}

View File

@@ -270,7 +270,7 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
);
return (
<div className="border-t border-border/40 bg-background/60 px-3 py-2.5 backdrop-blur-sm">
<div className="border-t border-border/40 bg-background/60 px-3 py-2.5 supports-[backdrop-filter]:backdrop-blur-sm">
<div className="flex items-center gap-1">
<div className="flex h-5 w-5 items-center justify-center shrink-0 -translate-y-px">
{statusIcon}

View File

@@ -325,7 +325,7 @@ export const SftpTransferQueue: React.FC<SftpTransferQueueProps> = ({
return (
<div
className="border-t border-border/70 bg-secondary/80 backdrop-blur-sm shrink-0"
className="border-t border-border/70 bg-secondary/80 supports-[backdrop-filter]:backdrop-blur-sm shrink-0"
style={{ height: clampPanelHeight(panelHeight) }}
>
<div

View File

@@ -148,10 +148,10 @@ export const CustomThemeModal: React.FC<CustomThemeModalProps> = ({
className="fixed inset-0 z-[300] flex items-center justify-center"
>
{/* Backdrop — clicking it dismisses the modal */}
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onCancel} />
<div className="absolute inset-0 bg-black/60 supports-[backdrop-filter]:backdrop-blur-sm" onClick={onCancel} />
{/* Modal */}
<div className="relative z-10 bg-popover/95 backdrop-blur-xl rounded-xl shadow-2xl border border-border/50 flex flex-col"
<div className="relative z-10 bg-popover/95 supports-[backdrop-filter]:backdrop-blur-sm rounded-xl shadow-2xl border border-border/50 flex flex-col"
style={{ width: 'min(820px, 90vw)', height: 'min(600px, 85vh)' }}
onClick={(e) => e.stopPropagation()}
>

View File

@@ -419,10 +419,6 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
};
term.attachCustomKeyEventHandler((e: KeyboardEvent) => {
if (e.type !== "keydown") {
return true;
}
// Preserve mouse selection across keystrokes when enabled. xterm.js
// unconditionally clears the selection on user input
// (SelectionService.ts: coreService.onUserInput → clearSelection).
@@ -430,7 +426,17 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
// processed the key + cleared. The microtask runs after both
// synchronous listeners, so by then either the selection is gone (and
// we restore) or it's still there (we no-op).
//
// Both keydown AND keypress must be hooked: xterm routes Space
// (keyCode 32 fails Keyboard.ts: `ev.keyCode >= 48`) and AZ
// (CoreBrowserTerminal.ts:_keyDown AZ IME HACK) through the
// `keypress` event, calling triggerDataEvent in _keyPress rather
// than _keyDown. For those keys, keydown's microtask drains before
// keypress fires, so hasSelection is still true → no-op. Attaching
// to keypress gives us a second microtask that drains after
// _keyPress clears the selection, so the restore runs.
if (
(e.type === "keydown" || e.type === "keypress") &&
ctx.terminalSettingsRef.current?.preserveSelectionOnInput &&
term.hasSelection()
) {
@@ -455,6 +461,10 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
}
}
if (e.type !== "keydown") {
return true;
}
// Autocomplete key handler (must be checked before other handlers)
if (ctx.onAutocompleteKeyEvent) {
const consumed = ctx.onAutocompleteKeyEvent(e);

View File

@@ -0,0 +1,140 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
areCustomKeyBindingsEqual,
nextCustomKeyBindingsSyncVersion,
parseCustomKeyBindingsStorageRecord,
resetCustomKeyBinding,
serializeCustomKeyBindingsStorageRecord,
shouldApplyIncomingCustomKeyBindingsRecord,
updateCustomKeyBinding,
} from './customKeyBindings.ts';
test('parses legacy stored custom key bindings without sync metadata', () => {
const parsed = parseCustomKeyBindingsStorageRecord('{"open":{"mac":"Cmd+K"}}');
assert.deepEqual(parsed, {
version: 0,
origin: 'legacy',
bindings: {
open: { mac: 'Cmd+K' },
},
});
});
test('round-trips versioned stored custom key bindings', () => {
const raw = serializeCustomKeyBindingsStorageRecord({
version: 42,
origin: 'window-b',
bindings: {
open: { pc: 'Ctrl+K' },
},
});
assert.deepEqual(parseCustomKeyBindingsStorageRecord(raw), {
version: 42,
origin: 'window-b',
bindings: {
open: { pc: 'Ctrl+K' },
},
});
});
test('parses plain IPC custom key binding sync payloads', () => {
const parsed = parseCustomKeyBindingsStorageRecord({
version: 7,
origin: 'window-a',
bindings: {
open: { pc: 'Ctrl+K' },
},
});
assert.deepEqual(parsed, {
version: 7,
origin: 'window-a',
bindings: {
open: { pc: 'Ctrl+K' },
},
});
});
test('next sync version is monotonic even within the same millisecond', () => {
assert.equal(nextCustomKeyBindingsSyncVersion(100, 90), 101);
assert.equal(nextCustomKeyBindingsSyncVersion(100, 150), 150);
});
test('newer incoming records apply and older ones are ignored', () => {
assert.equal(
shouldApplyIncomingCustomKeyBindingsRecord(
{ version: 10, origin: 'window-a' },
{ version: 11, origin: 'window-b' },
),
true,
);
assert.equal(
shouldApplyIncomingCustomKeyBindingsRecord(
{ version: 10, origin: 'window-a' },
{ version: 10, origin: 'window-a' },
),
false,
);
assert.equal(
shouldApplyIncomingCustomKeyBindingsRecord(
{ version: 10, origin: 'window-b' },
{ version: 10, origin: 'window-a' },
),
false,
);
});
test('same-version updates converge by origin tie-breaker', () => {
assert.equal(
shouldApplyIncomingCustomKeyBindingsRecord(
{ version: 10, origin: 'window-a' },
{ version: 10, origin: 'window-b' },
),
true,
);
});
test('update custom key binding keeps other bindings intact', () => {
const prev = {
open: { mac: 'Cmd+K' },
close: { pc: 'Ctrl+W' },
};
const next = updateCustomKeyBinding(prev, 'open', 'pc', 'Ctrl+K');
assert.deepEqual(next, {
open: { mac: 'Cmd+K', pc: 'Ctrl+K' },
close: { pc: 'Ctrl+W' },
});
assert.equal(areCustomKeyBindingsEqual(prev, {
open: { mac: 'Cmd+K' },
close: { pc: 'Ctrl+W' },
}), true);
});
test('resetting one side of a shortcut does not mutate the previous bindings', () => {
const prev = {
open: { mac: 'Cmd+K', pc: 'Ctrl+K' },
};
const next = resetCustomKeyBinding(prev, 'open', 'mac');
assert.deepEqual(next, {
open: { pc: 'Ctrl+K' },
});
assert.deepEqual(prev, {
open: { mac: 'Cmd+K', pc: 'Ctrl+K' },
});
});
test('resetting the last side removes the binding entry entirely', () => {
const next = resetCustomKeyBinding({
open: { mac: 'Cmd+K' },
}, 'open', 'mac');
assert.deepEqual(next, {});
});

133
domain/customKeyBindings.ts Normal file
View File

@@ -0,0 +1,133 @@
import { CustomKeyBindings } from './models';
const SYNC_VERSION_FIELD = '__netcattySyncVersion';
const SYNC_ORIGIN_FIELD = '__netcattySyncOrigin';
export interface CustomKeyBindingsStorageRecord {
bindings: CustomKeyBindings;
version: number;
origin: string;
}
export const serializeCustomKeyBindings = (bindings: CustomKeyBindings): string =>
JSON.stringify(bindings);
export const areCustomKeyBindingsEqual = (a: CustomKeyBindings, b: CustomKeyBindings): boolean =>
serializeCustomKeyBindings(a) === serializeCustomKeyBindings(b);
export const parseCustomKeyBindingsStorageRecord = (
value: unknown,
): CustomKeyBindingsStorageRecord | null => {
let candidate = value;
if (typeof candidate === 'string') {
try {
candidate = JSON.parse(candidate);
} catch {
return null;
}
}
if (!candidate || typeof candidate !== 'object') {
return null;
}
const record = candidate as Record<string, unknown>;
if (
typeof record.version === 'number' &&
typeof record.origin === 'string' &&
record.bindings &&
typeof record.bindings === 'object'
) {
return {
version: record.version,
origin: record.origin,
bindings: record.bindings as CustomKeyBindings,
};
}
if (
typeof record[SYNC_VERSION_FIELD] === 'number' &&
typeof record[SYNC_ORIGIN_FIELD] === 'string' &&
record.bindings &&
typeof record.bindings === 'object'
) {
return {
version: record[SYNC_VERSION_FIELD] as number,
origin: record[SYNC_ORIGIN_FIELD] as string,
bindings: record.bindings as CustomKeyBindings,
};
}
return {
version: 0,
origin: 'legacy',
bindings: candidate as CustomKeyBindings,
};
};
export const serializeCustomKeyBindingsStorageRecord = (
record: CustomKeyBindingsStorageRecord,
): string =>
JSON.stringify({
[SYNC_VERSION_FIELD]: record.version,
[SYNC_ORIGIN_FIELD]: record.origin,
bindings: record.bindings,
});
export const nextCustomKeyBindingsSyncVersion = (
currentVersion: number,
now: number = Date.now(),
): number => Math.max(now, currentVersion + 1);
export const shouldApplyIncomingCustomKeyBindingsRecord = (
current: Pick<CustomKeyBindingsStorageRecord, 'version' | 'origin'>,
incoming: Pick<CustomKeyBindingsStorageRecord, 'version' | 'origin'>,
): boolean => {
if (incoming.version !== current.version) {
return incoming.version > current.version;
}
return incoming.origin > current.origin;
};
export const updateCustomKeyBinding = (
bindings: CustomKeyBindings,
bindingId: string,
scheme: 'mac' | 'pc',
newKey: string,
): CustomKeyBindings => ({
...bindings,
[bindingId]: {
...bindings[bindingId],
[scheme]: newKey,
},
});
export const resetCustomKeyBinding = (
bindings: CustomKeyBindings,
bindingId: string,
scheme?: 'mac' | 'pc',
): CustomKeyBindings => {
if (!scheme) {
const { [bindingId]: _removed, ...rest } = bindings;
return rest;
}
const existing = bindings[bindingId];
if (!existing) {
return bindings;
}
const nextBinding = { ...existing };
delete nextBinding[scheme];
if (Object.keys(nextBinding).length === 0) {
const { [bindingId]: _removed, ...rest } = bindings;
return rest;
}
return {
...bindings,
[bindingId]: nextBinding,
};
};

View File

@@ -8,6 +8,7 @@
const GITHUB_CLIENT_ID = process.env.VITE_SYNC_GITHUB_CLIENT_ID || "";
const GITHUB_DEVICE_CODE_URL = "https://github.com/login/device/code";
const GITHUB_ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
const pendingPollControllers = new Map();
/**
* @param {Electron.IpcMain} ipcMain
@@ -53,32 +54,53 @@ function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:github:deviceFlow:poll", async (_event, payload) => {
const clientId = payload?.clientId || GITHUB_CLIENT_ID;
const deviceCode = payload?.deviceCode;
const pollId = payload?.pollId;
if (!deviceCode) throw new Error("Missing deviceCode");
const res = await fetch(GITHUB_ACCESS_TOKEN_URL, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
client_id: clientId,
device_code: deviceCode,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
}).toString(),
});
const text = await res.text();
if (!res.ok) {
throw new Error(`GitHub token polling failed: ${res.status} - ${text}`);
const controller = new AbortController();
if (pollId) {
pendingPollControllers.set(pollId, controller);
}
try {
return JSON.parse(text);
} catch {
throw new Error(`GitHub token polling invalid JSON: ${text.slice(0, 200)}`);
const res = await fetch(GITHUB_ACCESS_TOKEN_URL, {
method: "POST",
signal: controller.signal,
headers: {
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
client_id: clientId,
device_code: deviceCode,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
}).toString(),
});
const text = await res.text();
if (!res.ok) {
throw new Error(`GitHub token polling failed: ${res.status} - ${text}`);
}
try {
return JSON.parse(text);
} catch {
throw new Error(`GitHub token polling invalid JSON: ${text.slice(0, 200)}`);
}
} finally {
if (pollId) {
pendingPollControllers.delete(pollId);
}
}
});
ipcMain.handle("netcatty:github:deviceFlow:cancelPoll", async (_event, pollId) => {
if (!pollId) return;
const controller = pendingPollControllers.get(pollId);
if (!controller) return;
pendingPollControllers.delete(pollId);
controller.abort();
});
}
module.exports = { registerHandlers };

View File

@@ -297,11 +297,19 @@ function toggleTrayPanel() {
function resolveTrayIconPath() {
const { app } = electronModule;
// Use different icons for different platforms
// macOS: template image (black + transparent, system handles color)
// Windows/Linux: colored icon
const isMac = process.platform === "darwin";
const iconName = isMac ? "tray-iconTemplate.png" : "tray-icon.png";
// Platform-specific tray source:
// - macOS: template image (black + transparent, system handles tint)
// - Windows: multi-size .ico so the shell can pick the right pixel size
// per DPI scale (avoids blur at 125/150/175/250 % scale)
// - Linux: colored PNG (with an @2x representation attached at load time)
let iconName;
if (process.platform === "darwin") {
iconName = "tray-iconTemplate.png";
} else if (process.platform === "win32") {
iconName = "tray-icon.ico";
} else {
iconName = "tray-icon.png";
}
// Security: Only use known packaged icon locations, ignore renderer-provided paths
const candidates = [
@@ -592,11 +600,13 @@ function createTray() {
if (process.platform === "darwin") {
trayIcon = trayIcon.resize({ width: 16, height: 16 });
trayIcon.setTemplateImage(true);
} else if (process.platform === "win32") {
// The .ico already carries 16/20/24/32/40/48/64 — Windows picks the
// right size per DPI scale on its own. Do not resize.
} else {
// Windows/Linux: attach the @2x representation so the OS can pick
// the right pixel size per DPI scale. Force-resizing to 16x16 here
// produces blurry icons on HiDPI displays where the tray slot is
// rendered larger than 16px.
// Linux: attach the @2x representation so the shell can pick the
// right pixel size on HiDPI. Leaving the base at its native size
// (no force resize) keeps it crisp at 100 % too.
const hiDpiPath = resolvedIconPath.replace(/\.png$/i, "@2x.png");
if (fs.existsSync(hiDpiPath)) {
trayIcon.addRepresentation({

View File

@@ -2,18 +2,17 @@
* OAuth Callback Bridge
*
* Handles OAuth loopback redirects for Google Drive and OneDrive.
* Starts a temporary HTTP server on 127.0.0.1:45678 to receive authorization codes.
* Prepares a temporary HTTP server on 127.0.0.1 (preferred port 45678; falls
* back to an OS-assigned free port if 45678 is already in use — issue #823)
* and waits on it for the authorization code.
*/
const http = require("node:http");
const url = require("node:url");
let server = null;
let pendingResolve = null;
let pendingReject = null;
let serverTimeout = null;
const OAUTH_PORT = 45678;
let activeSession = null;
let nextSessionId = 0;
const PREFERRED_OAUTH_PORT = 45678;
const OAUTH_CALLBACK_PATH = "/oauth/callback";
const OAUTH_TIMEOUT = 5 * 60 * 1000; // 5 minutes
const escapeHtml = (value) =>
@@ -187,166 +186,346 @@ const renderOAuthPage = ({ title, message, detail, status, autoClose }) => {
</html>`;
};
function handleOAuthRequest(session, req, res) {
const parsedUrl = new URL(req.url, "http://127.0.0.1");
// Only handle the callback path
if (parsedUrl.pathname !== OAUTH_CALLBACK_PATH) {
res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" });
res.end("<h1>404 Not Found</h1>");
return;
}
const code = parsedUrl.searchParams.get("code");
const state = parsedUrl.searchParams.get("state");
const error = parsedUrl.searchParams.get("error");
const errorDescription = parsedUrl.searchParams.get("error_description");
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
// We deliberately ignore callbacks until awaitOAuthCallback() has armed the
// session. In the real flow the browser only opens after that point, so any
// earlier localhost hit is stale/noise and must not consume the active retry.
if (!session.resolve || !session.reject) {
res.end(
renderOAuthPage({
title: "Authorization Not Ready",
message: "This sign-in request is not active yet. Return to Netcatty and try again.",
status: "warning",
})
);
return;
}
if (error) {
res.end(
renderOAuthPage({
title: "Authorization Failed",
message: "We could not complete the sign-in flow.",
detail: errorDescription || error || "Unknown error",
status: "error",
})
);
finishSessionWithError(
session,
new Error(errorDescription || error || "Authorization failed")
);
return;
}
if (!code) {
res.end(
renderOAuthPage({
title: "Missing Authorization Code",
message: "The authorization response did not include a code.",
status: "error",
})
);
finishSessionWithError(session, new Error("Missing authorization code"));
return;
}
if (session.expectedState && state !== session.expectedState) {
res.end(
renderOAuthPage({
title: "Security Check Failed",
message: "State parameter mismatch. This may indicate a CSRF attack.",
status: "error",
})
);
finishSessionWithError(session, new Error("State mismatch - possible CSRF attack"));
return;
}
res.end(
renderOAuthPage({
title: "Authorization Complete",
message: "You are signed in and ready to sync. You can close this tab now.",
status: "success",
})
);
resolveSession(session, { code, state });
}
function resolveSession(session, payload) {
if (!session) return;
const resolve = session.resolve;
disposeSession(session);
if (resolve) {
resolve(payload);
}
}
function rejectSession(session, err) {
if (!session) return;
const reject = session.reject;
disposeSession(session);
if (reject) {
reject(err);
}
}
function finishSessionWithError(session, err) {
if (!session) return;
if (session.reject) {
rejectSession(session, err);
return;
}
storeSessionError(session, err);
}
function disposeSession(session) {
if (!session) return;
if (session.timeout) {
clearTimeout(session.timeout);
session.timeout = null;
}
session.resolve = null;
session.reject = null;
session.expectedState = null;
session.error = null;
closeServer(session);
if (activeSession === session) {
activeSession = null;
}
}
function storeSessionError(session, err) {
if (!session) return;
if (session.timeout) {
clearTimeout(session.timeout);
session.timeout = null;
}
session.resolve = null;
session.reject = null;
session.expectedState = null;
session.error = err;
closeServer(session);
}
function armSessionTimeout(session) {
session.timeout = setTimeout(() => {
rejectSession(
session,
new Error("OAuth timeout - user did not complete authorization in time")
);
}, OAUTH_TIMEOUT);
}
/**
* Start OAuth callback server and wait for authorization code
* @param {string} expectedState - State parameter to validate
* @returns {Promise<{code: string, state: string}>}
* Try to bind an HTTP server on the loopback interface. Prefers
* PREFERRED_OAUTH_PORT; if the port is already in use (EADDRINUSE) or the OS
* otherwise refuses it (EACCES), falls back to letting the OS pick a free
* ephemeral port.
*
* @returns {Promise<{server: http.Server, port: number}>}
*/
function startOAuthCallback(expectedState) {
return new Promise((resolve, reject) => {
// Clean up any existing server
if (server) {
function bindLoopbackServer(session) {
const ports = [PREFERRED_OAUTH_PORT, 0];
return (async () => {
let lastErr;
for (const port of ports) {
try {
server.close();
} catch (e) {
console.warn("Failed to close existing OAuth server:", e);
const s = http.createServer((req, res) => {
handleOAuthRequest(session, req, res);
});
await new Promise((resolve, reject) => {
const onError = (err) => {
s.removeListener("listening", onListening);
reject(err);
};
const onListening = () => {
s.removeListener("error", onError);
resolve();
};
s.once("error", onError);
s.once("listening", onListening);
s.listen(port, "127.0.0.1");
});
const bound = s.address();
return { server: s, port: bound && typeof bound === "object" ? bound.port : port };
} catch (err) {
lastErr = err;
if (err && (err.code === "EADDRINUSE" || err.code === "EACCES")) {
// Try the next candidate (OS-assigned)
continue;
}
throw err;
}
}
throw lastErr || new Error("Failed to bind OAuth loopback server");
})();
}
pendingResolve = resolve;
pendingReject = reject;
/**
* Bind a loopback HTTP server for the OAuth callback. Returns the chosen
* port and fully-qualified redirect URI so the caller can build the
* provider's authorization URL against it.
*
* @returns {Promise<{port: number, redirectUri: string}>}
*/
async function prepareOAuthCallback() {
// Replace any previously-prepared flow so it cannot leak a bound port or
// leave an await Promise hanging forever.
rejectSession(activeSession, new Error("OAuth flow cancelled"));
server = http.createServer((req, res) => {
const parsedUrl = url.parse(req.url, true);
const session = {
id: `oauth-${++nextSessionId}`,
server: null,
port: null,
resolve: null,
reject: null,
expectedState: null,
timeout: null,
error: null,
};
activeSession = session;
armSessionTimeout(session);
// Only handle the callback path
if (parsedUrl.pathname !== "/oauth/callback") {
res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" });
res.end("<h1>404 Not Found</h1>");
return;
}
let bound;
try {
bound = await bindLoopbackServer(session);
} catch (err) {
disposeSession(session);
throw err;
}
const { code, state, error, error_description } = parsedUrl.query;
session.server = bound.server;
session.port = bound.port;
// Send response to browser
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
if (activeSession !== session || session.error) {
const err = session.error || new Error("OAuth flow cancelled");
disposeSession(session);
throw err;
}
if (error) {
res.end(
renderOAuthPage({
title: "Authorization Failed",
message: "We could not complete the sign-in flow.",
detail: error_description || error || "Unknown error",
status: "error",
})
);
// Don't let the callback server itself keep the process alive — the
// awaitOAuthCallback Promise is what should pin the event loop. In Electron
// main this is a no-op (the UI keeps things alive), but in tests and CLI
// contexts it means the process can exit cleanly between runs.
if (typeof session.server.unref === "function") session.server.unref();
cleanup();
if (pendingReject) {
pendingReject(new Error(error_description || error || "Authorization failed"));
pendingReject = null;
pendingResolve = null;
}
return;
}
if (!code) {
res.end(
renderOAuthPage({
title: "Missing Authorization Code",
message: "The authorization response did not include a code.",
status: "error",
})
);
cleanup();
if (pendingReject) {
pendingReject(new Error("Missing authorization code"));
pendingReject = null;
pendingResolve = null;
}
return;
}
// Validate state if provided
if (expectedState && state !== expectedState) {
res.end(
renderOAuthPage({
title: "Security Check Failed",
message: "State parameter mismatch. This may indicate a CSRF attack.",
status: "error",
})
);
cleanup();
if (pendingReject) {
pendingReject(new Error("State mismatch - possible CSRF attack"));
pendingReject = null;
pendingResolve = null;
}
return;
}
// Success!
res.end(
renderOAuthPage({
title: "Authorization Complete",
message: "You are signed in and ready to sync. You can close this tab now.",
status: "success",
})
);
cleanup();
if (pendingResolve) {
pendingResolve({ code, state });
pendingResolve = null;
pendingReject = null;
}
});
server.on("error", (err) => {
console.error("OAuth server error:", err);
cleanup();
if (pendingReject) {
pendingReject(err);
pendingReject = null;
pendingResolve = null;
}
});
server.listen(OAUTH_PORT, "127.0.0.1", () => {
console.log(`OAuth callback server listening on http://127.0.0.1:${OAUTH_PORT}`);
});
// Set timeout
serverTimeout = setTimeout(() => {
cleanup();
if (pendingReject) {
pendingReject(new Error("OAuth timeout - user did not complete authorization in time"));
pendingReject = null;
pendingResolve = null;
}
}, OAUTH_TIMEOUT);
session.server.on("error", (err) => {
console.error("OAuth server error:", err);
rejectSession(session, err);
});
const redirectUri = `http://127.0.0.1:${session.port}${OAUTH_CALLBACK_PATH}`;
console.log(`OAuth callback server listening on ${redirectUri}`);
return { sessionId: session.id, port: session.port, redirectUri };
}
/**
* Wait for the authorization code to arrive at the prepared callback
* server. Must be called after prepareOAuthCallback().
*
* @param {string} [expectedState] - State parameter to validate
* @param {string} [sessionId] - Session returned by prepareOAuthCallback
* @returns {Promise<{code: string, state?: string}>}
*/
function awaitOAuthCallback(expectedState, sessionId) {
return new Promise((resolve, reject) => {
const session = activeSession;
if (!session) {
reject(new Error("OAuth callback server not prepared"));
return;
}
if (sessionId && session.id !== sessionId) {
reject(new Error("OAuth flow cancelled"));
return;
}
// Only one await may be outstanding at a time.
if (session.resolve || session.reject) {
reject(new Error("An OAuth callback is already in progress"));
return;
}
if (session.error) {
const err = session.error;
disposeSession(session);
reject(err);
return;
}
if (!session.server) {
reject(new Error("OAuth callback server not prepared"));
return;
}
session.resolve = resolve;
session.reject = reject;
session.expectedState = expectedState || null;
});
}
/**
* Return the port the OAuth callback server is currently listening on, or
* null when no callback is in flight. Used by windowManager to decide
* whether a loopback `/oauth/callback` URL is trustworthy for the in-app
* popup fallback.
*/
function getActiveOAuthPort() {
return activeSession?.port ?? null;
}
/**
* Cancel pending OAuth flow
*/
function cancelOAuthCallback() {
cleanup();
if (pendingReject) {
pendingReject(new Error("OAuth flow cancelled"));
pendingReject = null;
pendingResolve = null;
}
function cancelOAuthCallback(sessionId) {
if (!activeSession) return;
if (sessionId && activeSession.id !== sessionId) return;
finishSessionWithError(activeSession, new Error("OAuth flow cancelled"));
}
/**
* Clean up server and timeout
*/
function cleanup() {
if (serverTimeout) {
clearTimeout(serverTimeout);
serverTimeout = null;
}
function closeServer(session) {
const server = session?.server;
if (server) {
try {
// closeAllConnections (Node 18.2+) forces any keep-alive sockets shut
// so the port is immediately reusable — otherwise server.close() would
// wait on idle keep-alive connections before fully releasing.
if (typeof server.closeAllConnections === "function") {
try {
server.closeAllConnections();
} catch {
// ignore
}
}
server.close();
} catch (e) {
} catch {
// Ignore
}
server = null;
}
if (session) {
session.server = null;
session.port = null;
}
}
@@ -355,17 +534,23 @@ function cleanup() {
* @param {Electron.IpcMain} ipcMain
*/
function setupOAuthBridge(ipcMain) {
ipcMain.handle("oauth:startCallback", async (_event, expectedState) => {
return startOAuthCallback(expectedState);
ipcMain.handle("oauth:prepareCallback", async () => {
return prepareOAuthCallback();
});
ipcMain.handle("oauth:cancelCallback", async () => {
cancelOAuthCallback();
ipcMain.handle("oauth:awaitCallback", async (_event, expectedState, sessionId) => {
return awaitOAuthCallback(expectedState, sessionId);
});
ipcMain.handle("oauth:cancelCallback", async (_event, sessionId) => {
cancelOAuthCallback(sessionId);
});
}
module.exports = {
setupOAuthBridge,
startOAuthCallback,
prepareOAuthCallback,
awaitOAuthCallback,
cancelOAuthCallback,
getActiveOAuthPort,
};

View File

@@ -0,0 +1,336 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const http = require("node:http");
const BRIDGE_PATH = require.resolve("./oauthBridge.cjs");
function loadBridge() {
// Isolate module state between tests — oauthBridge keeps the bound server
// and pending promise in module-level closures.
delete require.cache[BRIDGE_PATH];
return require("./oauthBridge.cjs");
}
async function freshModule() {
const bridge = loadBridge();
const cleanup = async () => {
try {
bridge.cancelOAuthCallback();
} catch {
// ignore
}
// Give the server a beat to actually close before the next test binds
// the same port.
await new Promise((r) => setTimeout(r, 20));
};
return { bridge, cleanup };
}
function tryBindPort(port) {
return new Promise((resolve) => {
const s = http.createServer(() => {});
s.once("error", () => resolve(null));
s.once("listening", () => resolve(s));
s.listen(port, "127.0.0.1");
});
}
async function closeServer(s) {
if (!s) return;
await new Promise((resolve) => s.close(resolve));
}
function fetchCallback(port, query) {
return new Promise((resolve, reject) => {
const search = new URLSearchParams(query).toString();
const req = http.request(
{
hostname: "127.0.0.1",
port,
path: `/oauth/callback?${search}`,
method: "GET",
},
(res) => {
res.resume();
res.on("end", () => resolve(res.statusCode));
}
);
req.on("error", reject);
req.end();
});
}
test("prepareOAuthCallback binds and reports port + redirectUri", async () => {
const { bridge, cleanup } = await freshModule();
try {
const { port, redirectUri } = await bridge.prepareOAuthCallback();
assert.ok(Number.isInteger(port) && port > 0, `got port=${port}`);
assert.equal(redirectUri, `http://127.0.0.1:${port}/oauth/callback`);
assert.equal(bridge.getActiveOAuthPort(), port);
} finally {
await cleanup();
}
});
test("prepareOAuthCallback prefers port 45678 when it is free", async () => {
// Probe: if 45678 is already held by something external, skip this test —
// the fallback path is exercised in the next test.
const probe = await tryBindPort(45678);
if (!probe) {
return; // treated as pass; environment doesn't permit this assertion
}
await closeServer(probe);
const { bridge, cleanup } = await freshModule();
try {
const { port } = await bridge.prepareOAuthCallback();
assert.equal(port, 45678);
} finally {
await cleanup();
}
});
test("prepareOAuthCallback falls back to an OS-assigned port when 45678 is busy (#823)", async () => {
// Hold port 45678 ourselves so the bridge MUST fall back.
const squatter = await tryBindPort(45678);
if (!squatter) {
// Something else is already holding 45678 — just ensure the bridge
// still produces a working port rather than crashing.
const { bridge, cleanup } = await freshModule();
try {
const { port } = await bridge.prepareOAuthCallback();
assert.ok(port > 0 && port !== 45678);
} finally {
await cleanup();
}
return;
}
const { bridge, cleanup } = await freshModule();
try {
const { port, redirectUri } = await bridge.prepareOAuthCallback();
assert.notEqual(port, 45678, "expected a different port when 45678 is in use");
assert.ok(port > 0);
assert.equal(redirectUri, `http://127.0.0.1:${port}/oauth/callback`);
assert.equal(bridge.getActiveOAuthPort(), port);
} finally {
await cleanup();
await closeServer(squatter);
}
});
test("awaitOAuthCallback resolves with the code when the browser hits the redirect URI", async () => {
const { bridge, cleanup } = await freshModule();
try {
const { port } = await bridge.prepareOAuthCallback();
const pending = bridge.awaitOAuthCallback();
const status = await fetchCallback(port, { code: "test-code", state: "anything" });
assert.equal(status, 200);
const result = await pending;
assert.equal(result.code, "test-code");
} finally {
await cleanup();
}
});
test("awaitOAuthCallback rejects on state mismatch", async () => {
const { bridge, cleanup } = await freshModule();
try {
const { port } = await bridge.prepareOAuthCallback();
const pending = bridge.awaitOAuthCallback("expected-state");
// Attach the assertion BEFORE triggering the callback so the rejection
// is never unhandled.
const rejectsCheck = assert.rejects(pending, /State mismatch/);
await fetchCallback(port, { code: "test-code", state: "different-state" });
await rejectsCheck;
} finally {
await cleanup();
}
});
test("awaitOAuthCallback rejects when the provider returns an error", async () => {
const { bridge, cleanup } = await freshModule();
try {
const { port } = await bridge.prepareOAuthCallback();
const pending = bridge.awaitOAuthCallback();
const rejectsCheck = assert.rejects(pending, /access_denied/);
await fetchCallback(port, { error: "access_denied", error_description: "access_denied" });
await rejectsCheck;
} finally {
await cleanup();
}
});
test("awaitOAuthCallback errors if called before prepareOAuthCallback", async () => {
const { bridge, cleanup } = await freshModule();
try {
await assert.rejects(bridge.awaitOAuthCallback(), /not prepared/);
} finally {
await cleanup();
}
});
test("cancelOAuthCallback releases the bound port", async () => {
const { bridge, cleanup } = await freshModule();
try {
const { port } = await bridge.prepareOAuthCallback();
assert.equal(bridge.getActiveOAuthPort(), port);
bridge.cancelOAuthCallback();
assert.equal(bridge.getActiveOAuthPort(), null);
// Give the OS a beat to release the socket, then confirm re-bindable.
await new Promise((r) => setTimeout(r, 30));
const rebound = await tryBindPort(port);
assert.ok(rebound, `expected ${port} to be re-bindable after cancel`);
await closeServer(rebound);
} finally {
await cleanup();
}
});
test("callbacks that arrive before awaitOAuthCallback do not consume the session", async () => {
const { bridge, cleanup } = await freshModule();
try {
const { port } = await bridge.prepareOAuthCallback();
const earlyStatus = await fetchCallback(port, { code: "stale-code", state: "stale-state" });
assert.equal(earlyStatus, 200);
assert.equal(bridge.getActiveOAuthPort(), port);
const pending = bridge.awaitOAuthCallback("expected-state");
const status = await fetchCallback(port, {
code: "fresh-code",
state: "expected-state",
});
assert.equal(status, 200);
const result = await pending;
assert.equal(result.code, "fresh-code");
} finally {
await cleanup();
}
});
test("cancelOAuthCallback before awaitOAuthCallback is replayed as a cancellation", async () => {
const { bridge, cleanup } = await freshModule();
try {
const { sessionId } = await bridge.prepareOAuthCallback();
bridge.cancelOAuthCallback(sessionId);
await assert.rejects(bridge.awaitOAuthCallback(undefined, sessionId), /cancelled/);
} finally {
await cleanup();
}
});
test("cancelOAuthCallback during prepare rejects the pending prepare", async () => {
const { bridge, cleanup } = await freshModule();
try {
const pendingPrepare = bridge.prepareOAuthCallback();
bridge.cancelOAuthCallback();
await assert.rejects(pendingPrepare, /cancelled/);
assert.equal(bridge.getActiveOAuthPort(), null);
} finally {
await cleanup();
}
});
test("cancelOAuthCallback ignores stale session ids", async () => {
const { bridge, cleanup } = await freshModule();
try {
const stale = await bridge.prepareOAuthCallback();
const fresh = await bridge.prepareOAuthCallback();
bridge.cancelOAuthCallback(stale.sessionId);
assert.equal(bridge.getActiveOAuthPort(), fresh.port);
const pending = bridge.awaitOAuthCallback(undefined, fresh.sessionId);
const status = await fetchCallback(fresh.port, { code: "fresh-code" });
assert.equal(status, 200);
const result = await pending;
assert.equal(result.code, "fresh-code");
} finally {
await cleanup();
}
});
test("awaitOAuthCallback rejects stale session ids instead of attaching to the new flow", async () => {
const { bridge, cleanup } = await freshModule();
try {
const stale = await bridge.prepareOAuthCallback();
const fresh = await bridge.prepareOAuthCallback();
await assert.rejects(
bridge.awaitOAuthCallback(undefined, stale.sessionId),
/cancelled/
);
const pending = bridge.awaitOAuthCallback(undefined, fresh.sessionId);
const status = await fetchCallback(fresh.port, { code: "fresh-code" });
assert.equal(status, 200);
const result = await pending;
assert.equal(result.code, "fresh-code");
} finally {
await cleanup();
}
});
test("concurrent prepareOAuthCallback calls cancel the superseded attempt", async () => {
const { bridge, cleanup } = await freshModule();
try {
const stalePrepare = bridge.prepareOAuthCallback();
const staleRejects = assert.rejects(stalePrepare, /cancelled/);
const freshPrepare = bridge.prepareOAuthCallback();
const fresh = await freshPrepare;
await staleRejects;
const pending = bridge.awaitOAuthCallback(undefined, fresh.sessionId);
const status = await fetchCallback(fresh.port, { code: "fresh-code" });
assert.equal(status, 200);
const result = await pending;
assert.equal(result.code, "fresh-code");
} finally {
await cleanup();
}
});
test("prepareOAuthCallback replaces an in-flight await cleanly", async () => {
const { bridge, cleanup } = await freshModule();
try {
const stale = await bridge.prepareOAuthCallback();
const stalePending = bridge.awaitOAuthCallback(undefined, stale.sessionId);
const staleRejects = assert.rejects(stalePending, /cancelled/);
const fresh = await bridge.prepareOAuthCallback();
await staleRejects;
const nextPending = bridge.awaitOAuthCallback(undefined, fresh.sessionId);
const status = await fetchCallback(fresh.port, { code: "fresh-code" });
assert.equal(status, 200);
const result = await nextPending;
assert.equal(result.code, "fresh-code");
} finally {
await cleanup();
}
});
test("getActiveOAuthPort is null before prepare and after cleanup", async () => {
const { bridge, cleanup } = await freshModule();
try {
assert.equal(bridge.getActiveOAuthPort(), null);
await bridge.prepareOAuthCallback();
assert.notEqual(bridge.getActiveOAuthPort(), null);
} finally {
await cleanup();
}
assert.equal(require("./oauthBridge.cjs").getActiveOAuthPort(), null);
});

View File

@@ -43,7 +43,11 @@ const DEBUG_WINDOWS = process.env.NETCATTY_DEBUG_WINDOWS === "1";
const OAUTH_DEFAULT_WIDTH = 600;
const OAUTH_DEFAULT_HEIGHT = 700;
const OAUTH_OVERLAY_ID = "__netcatty_oauth_loading__";
const OAUTH_LOOPBACK_PORT = 45678; // must match electron/bridges/oauthBridge.cjs
// The OAuth callback port is chosen dynamically by oauthBridge (prefers
// 45678, falls back to an OS-assigned free port if that is in use, #823),
// so the in-app popup allow-list has to consult the bridge at popup-open
// time instead of a hardcoded constant.
const oauthBridge = require("./oauthBridge.cjs");
const WINDOW_STATE_FILE = "window-state.json";
const DEFAULT_WINDOW_WIDTH = 1400;
const DEFAULT_WINDOW_HEIGHT = 900;
@@ -595,10 +599,14 @@ function createAppWindowOpenHandler(shell, { backgroundColor, appIcon }) {
return allowedPopupHosts.has(u.hostname);
}
if (u.protocol === "http:") {
// Allow ONLY the loopback OAuth callback page.
// Allow ONLY the loopback OAuth callback page, and only while an
// OAuth flow is actively prepared — the acceptable port matches
// whatever oauthBridge just bound for this session.
const isLoopback =
u.hostname === "127.0.0.1" || u.hostname === "localhost";
return isLoopback && u.port === String(OAUTH_LOOPBACK_PORT) && u.pathname === "/oauth/callback";
if (!isLoopback || u.pathname !== "/oauth/callback") return false;
const activePort = oauthBridge.getActiveOAuthPort?.();
return activePort != null && u.port === String(activePort);
}
return false;
} catch {

View File

@@ -959,13 +959,18 @@ const api = {
};
},
// OAuth callback server
startOAuthCallback: (expectedState) => ipcRenderer.invoke("oauth:startCallback", expectedState),
cancelOAuthCallback: () => ipcRenderer.invoke("oauth:cancelCallback"),
// OAuth callback server — two-step so the renderer can learn the bound
// port (which may differ from the preferred 45678 if it was in use) and
// embed it into the provider's redirect_uri before opening the browser.
prepareOAuthCallback: () => ipcRenderer.invoke("oauth:prepareCallback"),
awaitOAuthCallback: (expectedState, sessionId) =>
ipcRenderer.invoke("oauth:awaitCallback", expectedState, sessionId),
cancelOAuthCallback: (sessionId) => ipcRenderer.invoke("oauth:cancelCallback", sessionId),
// GitHub Device Flow (proxied via main process to avoid CORS)
githubStartDeviceFlow: (options) => ipcRenderer.invoke("netcatty:github:deviceFlow:start", options),
githubPollDeviceFlowToken: (options) => ipcRenderer.invoke("netcatty:github:deviceFlow:poll", options),
githubCancelDeviceFlowPoll: (pollId) => ipcRenderer.invoke("netcatty:github:deviceFlow:cancelPoll", pollId),
// Google OAuth (proxied via main process to avoid CORS)
googleExchangeCodeForTokens: (options) =>

14
global.d.ts vendored
View File

@@ -602,9 +602,14 @@ declare global {
// SFTP connection progress listener (auth method logs)
onSftpConnectionProgress?(cb: (sessionId: string, label: string, status: string, detail?: string) => void): () => void;
// OAuth callback server for cloud sync
startOAuthCallback?(expectedState?: string): Promise<{ code: string; state?: string }>;
cancelOAuthCallback?(): Promise<void>;
// OAuth callback server for cloud sync. `prepareOAuthCallback` binds the
// loopback listener and returns the chosen port (preferred 45678, falls
// back to an OS-assigned free port if busy). The caller then builds the
// OAuth URL against `redirectUri`, opens the browser, and finally awaits
// the code via `awaitOAuthCallback`.
prepareOAuthCallback?(): Promise<{ sessionId: string; port: number; redirectUri: string }>;
awaitOAuthCallback?(expectedState?: string, sessionId?: string): Promise<{ code: string; state?: string }>;
cancelOAuthCallback?(sessionId?: string): Promise<void>;
// GitHub Device Flow (cloud sync)
githubStartDeviceFlow?(options?: { clientId?: string; scope?: string }): Promise<{
@@ -614,13 +619,14 @@ declare global {
expiresAt: number;
interval: number;
}>;
githubPollDeviceFlowToken?(options: { clientId?: string; deviceCode: string }): Promise<{
githubPollDeviceFlowToken?(options: { clientId?: string; deviceCode: string; pollId?: string }): Promise<{
access_token?: string;
token_type?: string;
scope?: string;
error?: string;
error_description?: string;
}>;
githubCancelDeviceFlowPoll?(pollId: string): Promise<void>;
// Google OAuth (cloud sync) - proxied via main process to avoid CORS
googleExchangeCodeForTokens?(options: {

View File

@@ -150,10 +150,7 @@
body {
min-height: 100vh;
font-family: var(--font-sans);
background:
radial-gradient(900px circle at 15% 0%, hsl(var(--primary) / 0.10), transparent 38%),
radial-gradient(1200px circle at 85% 10%, hsl(var(--accent) / 0.16), transparent 40%),
hsl(var(--background));
background-color: hsl(var(--background));
color: hsl(var(--foreground));
}
@@ -169,10 +166,7 @@ body {
}
.dark body {
background:
radial-gradient(1200px circle at 10% 0%, hsl(var(--primary) / 0.08), transparent 32%),
radial-gradient(900px circle at 85% 10%, hsl(var(--accent) / 0.12), transparent 36%),
hsl(var(--background));
background-color: hsl(var(--background));
color: hsl(var(--foreground));
}
@@ -258,7 +252,6 @@ body {
.glass-panel {
background: hsl(var(--secondary) / 0.95);
border: 1px solid hsl(var(--border) / 0.8);
backdrop-filter: blur(12px);
box-shadow: 0 14px 40px hsl(var(--foreground) / 0.12);
}

View File

@@ -141,8 +141,8 @@ export const TERMINAL_THEMES: TerminalTheme[] = [
brightBlue: '#79c0ff', brightMagenta: '#d2a8ff', brightCyan: '#56d4dd', brightWhite: '#f0f6fc',
}},
{ id: 'ui-midnight', name: 'Midnight (UI Match)', type: 'dark', colors: {
background: '#0f121a', foreground: '#c9d1d9', cursor: '#58a6ff', selection: '#264f78',
black: '#0f121a', red: '#ff7b72', green: '#3fb950', yellow: '#d29922',
background: '#141822', foreground: '#c9d1d9', cursor: '#58a6ff', selection: '#264f78',
black: '#141822', red: '#ff7b72', green: '#3fb950', yellow: '#d29922',
blue: '#58a6ff', magenta: '#bc8cff', cyan: '#39c5cf', white: '#b1bac4',
brightBlack: '#6e7681', brightRed: '#ffa198', brightGreen: '#56d364', brightYellow: '#e3b341',
brightBlue: '#79c0ff', brightMagenta: '#d2a8ff', brightCyan: '#56d4dd', brightWhite: '#f0f6fc',

View File

@@ -234,7 +234,7 @@ export const DARK_UI_THEMES: UiThemePreset[] = [
id: "midnight",
name: "Midnight",
tokens: {
background: "220 28% 8%",
background: "220 28% 10%",
foreground: "210 40% 95%",
card: "220 22% 12%",
cardForeground: "210 40% 95%",

View File

@@ -37,7 +37,7 @@ import packageJson from '../../package.json';
import { EncryptionService } from './EncryptionService';
import { createAdapter, type CloudAdapter } from './adapters';
import { localStorageAdapter } from '../persistence/localStorageAdapter';
import type { GitHubAdapter } from './adapters/GitHubAdapter';
import type { DeviceFlowState, GitHubAdapter } from './adapters/GitHubAdapter';
import type { GoogleDriveAdapter } from './adapters/GoogleDriveAdapter';
import type { OneDriveAdapter } from './adapters/OneDriveAdapter';
import {
@@ -97,6 +97,16 @@ interface ProviderSyncAnchor {
observedAt: number;
}
interface ProviderAuthRestoreState {
attemptId: number;
connection: ProviderConnection;
adapter: CloudAdapter | null;
}
export type StartProviderAuthResult =
| { type: 'device_code'; data: DeviceFlowState & { authAttemptId: number } }
| { type: 'url'; data: { url: string; redirectUri: string; authAttemptId: number } };
// ============================================================================
// CloudSyncManager Class
// ============================================================================
@@ -125,6 +135,12 @@ export class CloudSyncManager {
private providerDecryptSeq: Record<CloudProvider, number> = {
github: 0, google: 0, onedrive: 0, webdav: 0, s3: 0,
};
private providerAuthAttemptSeq: Record<CloudProvider, number> = {
github: 0, google: 0, onedrive: 0, webdav: 0, s3: 0,
};
private providerAuthRestoreState: Record<CloudProvider, ProviderAuthRestoreState | null> = {
github: null, google: null, onedrive: null, webdav: null, s3: null,
};
// Per-provider write sequence counters for saveProviderConnection.
// Only bumped when a new save is initiated, so status-only updates
// (which don't persist) cannot discard an in-flight encrypted write.
@@ -251,14 +267,21 @@ export class CloudSyncManager {
this.notifyStateChange();
}
private async saveProviderConnection(provider: CloudProvider, connection: ProviderConnection): Promise<void> {
private async saveProviderConnection(
provider: CloudProvider,
connection: ProviderConnection,
authAttemptId?: number
): Promise<void> {
const key = SYNC_STORAGE_KEYS[`PROVIDER_${provider.toUpperCase()}` as keyof typeof SYNC_STORAGE_KEYS];
// Use write-specific counter so status-only updates cannot discard
// an in-flight encrypted write that must be persisted.
const seq = ++this.providerWriteSeq[provider];
const encrypted = await encryptProviderSecrets(connection);
// Only persist if no newer save has started during the async gap
if (seq === this.providerWriteSeq[provider]) {
if (
seq === this.providerWriteSeq[provider] &&
(authAttemptId == null || this.isActiveAuthAttempt(provider, authAttemptId))
) {
this.saveToStorage(key, encrypted);
}
}
@@ -695,16 +718,29 @@ export class CloudSyncManager {
/**
* Start authentication flow for a provider
* Returns data needed for the auth flow (device code for GitHub, URL for others)
* Returns data needed for the auth flow (device code for GitHub, URL for others).
*
* For PKCE providers (Google / OneDrive) the caller must supply the
* redirect URI the loopback callback server bound to — the port is chosen
* dynamically by the main process (#823) so it can't be hardcoded here.
*/
async startProviderAuth(provider: CloudProvider): Promise<{
type: 'device_code' | 'url';
data: unknown;
}> {
async startProviderAuth(
provider: CloudProvider,
redirectUri?: string
): Promise<StartProviderAuthResult> {
if (provider === 'webdav' || provider === 's3') {
throw new Error('Provider requires manual configuration');
}
const authAttemptId = ++this.providerAuthAttemptSeq[provider];
this.providerAuthRestoreState[provider] = {
attemptId: authAttemptId,
connection: { ...this.state.providers[provider] },
adapter: this.adapters.get(provider) ?? null,
};
const adapter = await createAdapter(provider);
if (!this.isActiveAuthAttempt(provider, authAttemptId)) {
throw new Error(`${provider} auth superseded`);
}
this.adapters.set(provider, adapter);
this.updateProviderStatus(provider, 'connecting');
@@ -716,23 +752,31 @@ export class CloudSyncManager {
return {
type: 'device_code',
data: deviceFlow,
data: { ...deviceFlow, authAttemptId },
};
} else {
// Google and OneDrive use PKCE with redirect
const redirectUri = 'http://127.0.0.1:45678/oauth/callback';
if (!redirectUri) {
throw new Error(
`startProviderAuth('${provider}') requires a redirectUri — ` +
'call prepareOAuthCallback on the bridge first and pass its redirectUri through.'
);
}
if (provider === 'google') {
const gdAdapter = adapter as GoogleDriveAdapter;
const url = await gdAdapter.startAuth(redirectUri);
return { type: 'url', data: { url, redirectUri } };
return { type: 'url', data: { url, redirectUri, authAttemptId } };
} else {
const odAdapter = adapter as OneDriveAdapter;
const url = await odAdapter.startAuth(redirectUri);
return { type: 'url', data: { url, redirectUri } };
return { type: 'url', data: { url, redirectUri, authAttemptId } };
}
}
} catch (error) {
if (!this.isActiveAuthAttempt(provider, authAttemptId)) {
throw error;
}
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`[CloudSync] ${provider} connect failed`, {
error: errorMessage,
@@ -749,8 +793,13 @@ export class CloudSyncManager {
deviceCode: string,
interval: number,
expiresAt: number,
onPending?: () => void
onPending?: () => void,
signal?: AbortSignal,
authAttemptId?: number
): Promise<void> {
if (authAttemptId != null && !this.isActiveAuthAttempt('github', authAttemptId)) {
throw new Error('github auth superseded');
}
const adapter = this.adapters.get('github');
if (!adapter) {
throw new Error('GitHub adapter not initialized');
@@ -765,7 +814,15 @@ export class CloudSyncManager {
// version, where the key didn't exist yet).
const previousAccount = this.state.providers.github?.account;
const tokens = await ghAdapter.completeAuth(deviceCode, interval, expiresAt, onPending);
const tokens = await ghAdapter.completeAuth(deviceCode, interval, expiresAt, onPending, signal);
if (authAttemptId != null && !this.isActiveAuthAttempt('github', authAttemptId)) {
throw new Error('github auth superseded');
}
const resourceId = await ghAdapter.initializeSync(signal);
if (authAttemptId != null && !this.isActiveAuthAttempt('github', authAttemptId)) {
throw new Error('github auth superseded');
}
++this.providerDecryptSeq.github;
this.state.providers.github = {
@@ -775,13 +832,14 @@ export class CloudSyncManager {
account: ghAdapter.accountInfo || undefined,
};
// Initialize sync (find or create gist)
const resourceId = await ghAdapter.initializeSync();
if (resourceId) {
this.state.providers.github.resourceId = resourceId;
}
await this.saveProviderConnection('github', this.state.providers.github);
await this.saveProviderConnection('github', this.state.providers.github, authAttemptId);
if (authAttemptId != null && !this.isActiveAuthAttempt('github', authAttemptId)) {
throw new Error('github auth superseded');
}
// Only clear the merge base if the authenticated account identity differs
// from the previously-stored one. See notes in completePKCEAuth.
@@ -801,8 +859,20 @@ export class CloudSyncManager {
provider: 'github',
account: ghAdapter.accountInfo!,
});
this.providerAuthRestoreState.github = null;
} catch (error) {
this.updateProviderStatus('github', 'error', String(error));
if (authAttemptId != null && !this.isActiveAuthAttempt('github', authAttemptId)) {
throw error;
}
if (error instanceof Error && error.message.includes('auth superseded')) {
throw error;
}
if (error instanceof Error && error.name === 'AbortError') {
this.resetProviderStatus('github', authAttemptId);
throw error;
}
this.resetProviderStatus('github', authAttemptId);
this.setProviderError('github', String(error));
throw error;
}
}
@@ -813,8 +883,12 @@ export class CloudSyncManager {
async completePKCEAuth(
provider: 'google' | 'onedrive',
code: string,
redirectUri: string
redirectUri: string,
authAttemptId?: number
): Promise<void> {
if (authAttemptId != null && !this.isActiveAuthAttempt(provider, authAttemptId)) {
throw new Error(`${provider} auth superseded`);
}
const adapter = this.adapters.get(provider);
if (!adapter) {
throw new Error(`${provider} adapter not initialized`);
@@ -840,6 +914,16 @@ export class CloudSyncManager {
account = odAdapter.accountInfo;
}
if (authAttemptId != null && !this.isActiveAuthAttempt(provider, authAttemptId)) {
throw new Error(`${provider} auth superseded`);
}
const resourceId = await adapter.initializeSync();
if (authAttemptId != null && !this.isActiveAuthAttempt(provider, authAttemptId)) {
throw new Error(`${provider} auth superseded`);
}
++this.providerDecryptSeq[provider];
this.state.providers[provider] = {
...this.state.providers[provider],
@@ -848,13 +932,14 @@ export class CloudSyncManager {
account: account || undefined,
};
// Initialize sync
const resourceId = await adapter.initializeSync();
if (resourceId) {
this.state.providers[provider].resourceId = resourceId;
}
await this.saveProviderConnection(provider, this.state.providers[provider]);
await this.saveProviderConnection(provider, this.state.providers[provider], authAttemptId);
if (authAttemptId != null && !this.isActiveAuthAttempt(provider, authAttemptId)) {
throw new Error(`${provider} auth superseded`);
}
// Only clear the merge base if the authenticated account identity differs
// from the previously-stored one. Same-account re-auth preserves the base
@@ -876,8 +961,16 @@ export class CloudSyncManager {
provider,
account: account!,
});
this.providerAuthRestoreState[provider] = null;
} catch (error) {
this.updateProviderStatus(provider, 'error', String(error));
if (authAttemptId != null && !this.isActiveAuthAttempt(provider, authAttemptId)) {
throw error;
}
if (error instanceof Error && error.message.includes('auth superseded')) {
throw error;
}
this.resetProviderStatus(provider, authAttemptId);
this.setProviderError(provider, String(error));
throw error;
}
}
@@ -926,11 +1019,62 @@ export class CloudSyncManager {
* Used when an auth attempt is cancelled/fails — avoids destroying a previously
* working connection if the user was re-authenticating.
*/
resetProviderStatus(provider: CloudProvider): void {
// Only reset if currently 'connecting' — don't drop an already authenticated
// provider back to 'disconnected' (e.g., if auth succeeded but sync init failed).
if (this.state.providers[provider]?.status === 'connecting') {
resetProviderStatus(provider: CloudProvider, authAttemptId?: number): void {
const restoreState = this.providerAuthRestoreState[provider];
if (
authAttemptId != null &&
restoreState &&
restoreState.attemptId !== authAttemptId
) {
return;
}
if (restoreState) {
this.state.providers[provider] = { ...restoreState.connection };
if (restoreState.adapter) {
this.adapters.set(provider, restoreState.adapter);
} else {
this.adapters.delete(provider);
}
this.notifyStateChange();
} else if (this.state.providers[provider]?.status === 'connecting') {
this.updateProviderStatus(provider, 'disconnected');
return;
}
if (!restoreState || authAttemptId == null || restoreState.attemptId === authAttemptId) {
this.providerAuthRestoreState[provider] = null;
}
}
setProviderError(provider: CloudProvider, error: string): void {
this.updateProviderStatus(provider, 'error', error);
}
clearProviderError(provider: CloudProvider): void {
const connection = this.state.providers[provider];
if (!connection?.error && connection?.status !== 'error') {
return;
}
this.state.providers[provider] = {
...connection,
status: connection.status === 'error' ? 'disconnected' : connection.status,
error: undefined,
};
this.notifyStateChange();
}
cancelProviderAuthAttempt(provider: CloudProvider, authAttemptId?: number): void {
if (
authAttemptId != null &&
!this.isActiveAuthAttempt(provider, authAttemptId)
) {
return;
}
this.resetProviderStatus(provider, authAttemptId);
++this.providerAuthAttemptSeq[provider];
const restoreState = this.providerAuthRestoreState[provider];
if (!restoreState || authAttemptId == null || restoreState.attemptId === authAttemptId) {
this.providerAuthRestoreState[provider] = null;
}
}
@@ -938,6 +1082,7 @@ export class CloudSyncManager {
* Disconnect a provider
*/
async disconnectProvider(provider: CloudProvider): Promise<void> {
this.cancelProviderAuthAttempt(provider);
const adapter = this.adapters.get(provider);
if (adapter) {
adapter.signOut();
@@ -980,6 +1125,10 @@ export class CloudSyncManager {
this.notifyStateChange(); // Notify UI of status change
}
private isActiveAuthAttempt(provider: CloudProvider, authAttemptId: number): boolean {
return this.providerAuthAttemptSeq[provider] === authAttemptId;
}
private buildAccountFromConfig(
provider: 'webdav' | 's3',
config: WebDAVConfig | S3Config

View File

@@ -50,8 +50,54 @@ export interface DeviceFlowState {
verificationUri: string;
expiresAt: number;
interval: number;
authAttemptId?: number;
}
const createGitHubPollId = (): string => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `github-poll-${Date.now()}-${Math.random().toString(36).slice(2)}`;
};
const createGitHubCancelError = (): Error => {
const error = new Error('GitHub auth cancelled');
error.name = 'AbortError';
return error;
};
const throwIfAborted = (signal?: AbortSignal): void => {
if (signal?.aborted) {
throw createGitHubCancelError();
}
};
const delayWithSignal = (ms: number, signal?: AbortSignal): Promise<void> => {
if (!signal) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(createGitHubCancelError());
return;
}
const timer = setTimeout(() => {
signal.removeEventListener('abort', onAbort);
resolve();
}, ms);
const onAbort = () => {
clearTimeout(timer);
signal.removeEventListener('abort', onAbort);
reject(createGitHubCancelError());
};
signal.addEventListener('abort', onAbort, { once: true });
});
};
// ============================================================================
// Device Flow Authentication
// ============================================================================
@@ -117,64 +163,96 @@ export const pollForToken = async (
deviceCode: string,
interval: number,
expiresAt: number,
onPending?: () => void
onPending?: () => void,
signal?: AbortSignal
): Promise<OAuthTokens | null> => {
const pollInterval = Math.max(interval, 5) * 1000; // Minimum 5 seconds
const bridge = netcattyBridge.get();
while (Date.now() < expiresAt) {
await new Promise(resolve => setTimeout(resolve, pollInterval));
await delayWithSignal(pollInterval, signal);
throwIfAborted(signal);
const pollId = createGitHubPollId();
const cancelPoll = () => {
void bridge?.githubCancelDeviceFlowPoll?.(pollId);
};
const data = bridge?.githubPollDeviceFlowToken
? await bridge.githubPollDeviceFlowToken({
clientId: SYNC_CONSTANTS.GITHUB_CLIENT_ID,
deviceCode,
})
: await (async () => {
const response = await fetch(SYNC_CONSTANTS.GITHUB_ACCESS_TOKEN_URL, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
client_id: SYNC_CONSTANTS.GITHUB_CLIENT_ID,
device_code: deviceCode,
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
}).toString(),
});
return response.json();
})();
if (data.access_token) {
return {
accessToken: data.access_token,
tokenType: data.token_type || 'bearer',
scope: data.scope,
};
if (signal) {
signal.addEventListener('abort', cancelPoll, { once: true });
}
if (data.error === 'authorization_pending') {
onPending?.();
continue;
}
try {
let data;
try {
data = bridge?.githubPollDeviceFlowToken
? await bridge.githubPollDeviceFlowToken({
clientId: SYNC_CONSTANTS.GITHUB_CLIENT_ID,
deviceCode,
pollId,
})
: await (async () => {
const response = await fetch(SYNC_CONSTANTS.GITHUB_ACCESS_TOKEN_URL, {
method: 'POST',
signal,
headers: {
'Accept': 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
client_id: SYNC_CONSTANTS.GITHUB_CLIENT_ID,
device_code: deviceCode,
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
}).toString(),
});
return response.json();
})();
} catch (error) {
if (
signal?.aborted ||
(error instanceof Error &&
(error.name === 'AbortError' || error.message.toLowerCase().includes('abort')))
) {
throw createGitHubCancelError();
}
throw error;
}
if (data.error === 'slow_down') {
// Increase interval as requested
await new Promise(resolve => setTimeout(resolve, 5000));
continue;
}
throwIfAborted(signal);
if (data.error === 'expired_token') {
throw new Error('Device code expired. Please try again.');
}
if (data.access_token) {
return {
accessToken: data.access_token,
tokenType: data.token_type || 'bearer',
scope: data.scope,
};
}
if (data.error === 'access_denied') {
throw new Error('User denied authorization.');
}
if (data.error === 'authorization_pending') {
onPending?.();
continue;
}
if (data.error) {
throw new Error(`GitHub auth error: ${data.error_description || data.error}`);
if (data.error === 'slow_down') {
// Increase interval as requested
await delayWithSignal(5000, signal);
continue;
}
if (data.error === 'expired_token') {
throw new Error('Device code expired. Please try again.');
}
if (data.error === 'access_denied') {
throw new Error('User denied authorization.');
}
if (data.error) {
throw new Error(`GitHub auth error: ${data.error_description || data.error}`);
}
} finally {
if (signal) {
signal.removeEventListener('abort', cancelPoll);
}
}
}
@@ -188,8 +266,12 @@ export const pollForToken = async (
/**
* Get authenticated user info
*/
export const getUserInfo = async (accessToken: string): Promise<ProviderAccount> => {
export const getUserInfo = async (
accessToken: string,
signal?: AbortSignal
): Promise<ProviderAccount> => {
const response = await fetch(`${SYNC_CONSTANTS.GITHUB_API_BASE}/user`, {
signal,
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/vnd.github.v3+json',
@@ -234,9 +316,13 @@ export const validateToken = async (accessToken: string): Promise<boolean> => {
/**
* Find existing Netcatty sync gist
*/
export const findSyncGist = async (accessToken: string): Promise<string | null> => {
export const findSyncGist = async (
accessToken: string,
signal?: AbortSignal
): Promise<string | null> => {
// List user's gists and find ours
const response = await fetch(`${SYNC_CONSTANTS.GITHUB_API_BASE}/gists?per_page=100`, {
signal,
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/vnd.github.v3+json',
@@ -471,15 +557,18 @@ export class GitHubAdapter {
deviceCode: string,
interval: number,
expiresAt: number,
onPending?: () => void
onPending?: () => void,
signal?: AbortSignal
): Promise<OAuthTokens> {
const tokens = await pollForToken(deviceCode, interval, expiresAt, onPending);
const tokens = await pollForToken(deviceCode, interval, expiresAt, onPending, signal);
if (!tokens) {
throw new Error('Failed to obtain access token');
}
throwIfAborted(signal);
this.accessToken = tokens.accessToken;
this.account = await getUserInfo(tokens.accessToken);
this.account = await getUserInfo(tokens.accessToken, signal);
throwIfAborted(signal);
return tokens;
}
@@ -509,12 +598,12 @@ export class GitHubAdapter {
/**
* Initialize or find sync gist
*/
async initializeSync(): Promise<string | null> {
async initializeSync(signal?: AbortSignal): Promise<string | null> {
if (!this.accessToken) {
throw new Error('Not authenticated');
}
this.gistId = await findSyncGist(this.accessToken);
this.gistId = await findSyncGist(this.accessToken, signal);
return this.gistId;
}

BIN
public/tray-icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,30 @@
#!/usr/bin/env python3
"""Generate public/tray-icon.ico from public/icon-win.png with multiple
sizes so Windows can pick the right pixel dimensions per DPI scale.
Sizes mirror what Explorer requests for the notification area on typical
DPI scale factors (100/125/150/175/200/250/300/400 %):
16, 20, 24, 32, 40, 48, 64
Run: python3 scripts/generate-tray-ico.py
Requires: Pillow (pip install Pillow)
"""
from pathlib import Path
from PIL import Image
ROOT = Path(__file__).resolve().parents[1]
SOURCE = ROOT / "public" / "icon-win.png"
OUT = ROOT / "public" / "tray-icon.ico"
SIZES = [(16, 16), (20, 20), (24, 24), (32, 32), (40, 40), (48, 48), (64, 64)]
def main() -> None:
if not SOURCE.exists():
raise SystemExit(f"source icon not found: {SOURCE}")
src = Image.open(SOURCE).convert("RGBA")
src.save(OUT, format="ICO", sizes=SIZES)
print(f"wrote {OUT.relative_to(ROOT)} ({', '.join(f'{w}x{h}' for w, h in SIZES)})")
if __name__ == "__main__":
main()