Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8215dfe6a1 | ||
|
|
a1866747a5 | ||
|
|
78fc4628b9 | ||
|
|
c721591466 | ||
|
|
8514c75301 | ||
|
|
c30d872852 | ||
|
|
c58f018d24 | ||
|
|
dd1d97ffff |
@@ -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.',
|
||||
|
||||
@@ -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 超时,请检查网络或代理设置。',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()}
|
||||
>
|
||||
|
||||
@@ -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 A–Z
|
||||
// (CoreBrowserTerminal.ts:_keyDown A–Z 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);
|
||||
|
||||
140
domain/customKeyBindings.test.ts
Normal file
140
domain/customKeyBindings.test.ts
Normal 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
133
domain/customKeyBindings.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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 };
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
336
electron/bridges/oauthBridge.test.cjs
Normal file
336
electron/bridges/oauthBridge.test.cjs
Normal 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);
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
14
global.d.ts
vendored
@@ -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: {
|
||||
|
||||
11
index.css
11
index.css
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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%",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
BIN
public/tray-icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
30
scripts/generate-tray-ico.py
Normal file
30
scripts/generate-tray-ico.py
Normal 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()
|
||||
Reference in New Issue
Block a user