mirror of
https://github.com/nimbold/Firelink.git
synced 2026-09-09 09:16:57 +00:00
fix(window): harden frame geometry across window states
- Remove rounded renderer corners from opaque Linux window surfaces. - Track maximized state with race-safe native reads for main and Properties windows. - Flatten maximized Windows frames without changing macOS AppKit zoom contours. - Make early platform detection explicit, testable, and fail closed on unsupported targets.
This commit is contained in:
+3
-1
@@ -57,6 +57,7 @@ import {
|
||||
} from './utils/schedulerControl';
|
||||
import { createSerialTaskQueue } from './utils/serialTaskQueue';
|
||||
import { useWindowFocusState } from './utils/windowFocus';
|
||||
import { useWindowMaximizedState } from './utils/windowMaximized';
|
||||
|
||||
const loadSettingsView = () => import('./components/SettingsView');
|
||||
const loadSchedulerView = () => import('./components/SchedulerView');
|
||||
@@ -189,6 +190,7 @@ function App() {
|
||||
const { i18n, t } = useTranslation();
|
||||
const platform = usePlatformInfo();
|
||||
const isWindowActive = useWindowFocusState();
|
||||
const isWindowMaximized = useWindowMaximizedState();
|
||||
const [filter, setFilter] = useState<SidebarFilter>('all');
|
||||
const [downloadTableSummary, setDownloadTableSummary] = useState<DownloadTableStatusSummary | null>(null);
|
||||
const [coreReady, setCoreReady] = useState(false);
|
||||
@@ -1222,7 +1224,7 @@ function App() {
|
||||
}, [autoAddClipboardLinks, coreReady, showKeychainModal]);
|
||||
|
||||
return (
|
||||
<div data-window-active={isWindowActive ? 'true' : 'false'} className={`app-shell app-shell--style-${windowControlStyle} flex h-screen w-screen overflow-hidden text-text-primary ${
|
||||
<div data-window-active={isWindowActive ? 'true' : 'false'} data-window-maximized={isWindowMaximized ? 'true' : 'false'} className={`app-shell flex h-screen w-screen overflow-hidden text-text-primary ${
|
||||
isSidebarOnRight ? 'app-shell--sidebar-right' : 'app-shell--sidebar-left'
|
||||
} ${
|
||||
hasWindowChrome ? 'app-shell--window-chrome' : ''
|
||||
|
||||
@@ -57,6 +57,7 @@ import { isTorrentLiveStatus } from '../utils/propertiesTorrentLifecycle';
|
||||
import { isTorrentWaitingForPeers } from '../utils/torrentPresentation';
|
||||
import { copyTorrentFilePath } from '../utils/torrentFilePath';
|
||||
import { useWindowFocusState } from '../utils/windowFocus';
|
||||
import { useWindowMaximizedState } from '../utils/windowMaximized';
|
||||
import { WindowControls } from './WindowControls';
|
||||
import {
|
||||
TORRENT_ENCRYPTION_POLICY_DISABLED,
|
||||
@@ -204,6 +205,7 @@ const propertiesDiagnosticLifecycleKey = (snapshot: PropertiesSnapshot): string
|
||||
export const PropertiesWindowApp = () => {
|
||||
const { t } = useTranslation();
|
||||
const isWindowActive = useWindowFocusState();
|
||||
const isWindowMaximized = useWindowMaximizedState();
|
||||
const translationRef = useRef(t);
|
||||
translationRef.current = t;
|
||||
const currentWindow = useMemo(() => getCurrentWindow(), []);
|
||||
@@ -1138,6 +1140,7 @@ export const PropertiesWindowApp = () => {
|
||||
className={windowShellClassName}
|
||||
style={windowShellStyle}
|
||||
data-window-active={isWindowActive ? 'true' : 'false'}
|
||||
data-window-maximized={isWindowMaximized ? 'true' : 'false'}
|
||||
aria-labelledby="properties-window-title"
|
||||
>
|
||||
<WindowControls side={windowChrome.side} controlStyle={windowChrome.controlStyle} />
|
||||
@@ -1253,6 +1256,7 @@ export const PropertiesWindowApp = () => {
|
||||
className={windowShellClassName}
|
||||
style={windowShellStyle}
|
||||
data-window-active={isWindowActive ? 'true' : 'false'}
|
||||
data-window-maximized={isWindowMaximized ? 'true' : 'false'}
|
||||
aria-labelledby="properties-window-title"
|
||||
>
|
||||
<WindowControls side={windowChrome.side} controlStyle={windowChrome.controlStyle} />
|
||||
|
||||
@@ -2373,6 +2373,19 @@ html[data-list-density="relaxed"] {
|
||||
border-width: 0.5px;
|
||||
}
|
||||
|
||||
/* Linux uses an opaque GTK/WebKit surface, so renderer-only curves would
|
||||
expose square native backing pixels. Maximized Windows surfaces likewise
|
||||
need to meet the work area instead of leaving transparent corner cutouts.
|
||||
macOS zoomed windows retain their native rounded AppKit contour. */
|
||||
html[data-platform="linux"] :is(.app-shell, .properties-window-shell),
|
||||
html[data-platform="windows"] :is(.app-shell, .properties-window-shell)[data-window-maximized="true"] {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
html[data-platform="windows"] :is(.app-shell, .properties-window-shell)[data-window-maximized="true"] {
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
.app-shell,
|
||||
.properties-window-shell {
|
||||
|
||||
+8
-3
@@ -14,7 +14,10 @@ import { error as logError, warn as logWarn, initLogger } from "./utils/logger";
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { invokeCommand as invoke } from './ipc';
|
||||
import { useWindowFocusState } from './utils/windowFocus';
|
||||
import './utils/platform';
|
||||
import { syncPlatformDatasetFromUserAgent } from './utils/platform';
|
||||
import { useWindowMaximizedState } from './utils/windowMaximized';
|
||||
|
||||
syncPlatformDatasetFromUserAgent(navigator.userAgent);
|
||||
|
||||
const isPropertiesWindow = getCurrentWindow().label.startsWith('properties-');
|
||||
|
||||
@@ -86,8 +89,9 @@ const renderRoot = (RootComponent: ComponentType) => {
|
||||
|
||||
const PropertiesStartupFailure = () => {
|
||||
const isWindowActive = useWindowFocusState();
|
||||
const isWindowMaximized = useWindowMaximizedState();
|
||||
return (
|
||||
<main data-window-active={isWindowActive ? 'true' : 'false'} className="properties-window-shell flex h-screen min-h-0 flex-col items-center justify-center gap-4 bg-main-bg p-6 text-text-primary">
|
||||
<main data-window-active={isWindowActive ? 'true' : 'false'} data-window-maximized={isWindowMaximized ? 'true' : 'false'} className="properties-window-shell flex h-screen min-h-0 flex-col items-center justify-center gap-4 bg-main-bg p-6 text-text-primary">
|
||||
<p role="alert">Download Properties could not be loaded.</p>
|
||||
<button
|
||||
type="button"
|
||||
@@ -106,8 +110,9 @@ const PropertiesStartupFailure = () => {
|
||||
|
||||
const MainStartupFailure = () => {
|
||||
const isWindowActive = useWindowFocusState();
|
||||
const isWindowMaximized = useWindowMaximizedState();
|
||||
return (
|
||||
<main data-window-active={isWindowActive ? 'true' : 'false'} className="app-shell flex h-screen min-h-0 flex-col items-center justify-center gap-4 bg-main-bg p-6 text-text-primary">
|
||||
<main data-window-active={isWindowActive ? 'true' : 'false'} data-window-maximized={isWindowMaximized ? 'true' : 'false'} className="app-shell flex h-screen min-h-0 flex-col items-center justify-center gap-4 bg-main-bg p-6 text-text-primary">
|
||||
<p role="alert">Firelink could not be loaded.</p>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { shouldUseCustomWindowControls, syncPlatformDataset } from './platform';
|
||||
import {
|
||||
inferDesktopPlatform,
|
||||
shouldUseCustomWindowControls,
|
||||
syncPlatformDataset,
|
||||
syncPlatformDatasetFromUserAgent,
|
||||
} from './platform';
|
||||
|
||||
describe('shouldUseCustomWindowControls', () => {
|
||||
it('keeps custom controls present while Windows/Linux detection is unresolved', () => {
|
||||
@@ -35,17 +40,39 @@ describe('syncPlatformDataset', () => {
|
||||
expect(mockDocument.documentElement.dataset.platform).toBe('linux');
|
||||
});
|
||||
|
||||
it('ignores unknown or unsupported platforms', () => {
|
||||
it('clears a stale platform when authoritative detection is unknown or unsupported', () => {
|
||||
const mockDocument = { documentElement: { dataset: { platform: 'macos' } } };
|
||||
|
||||
syncPlatformDataset('unknown', mockDocument);
|
||||
expect(mockDocument.documentElement.dataset.platform).toBe('macos');
|
||||
expect(mockDocument.documentElement.dataset.platform).toBeUndefined();
|
||||
|
||||
mockDocument.documentElement.dataset.platform = 'macos';
|
||||
syncPlatformDataset('android', mockDocument);
|
||||
expect(mockDocument.documentElement.dataset.platform).toBe('macos');
|
||||
expect(mockDocument.documentElement.dataset.platform).toBeUndefined();
|
||||
});
|
||||
|
||||
it('safely handles missing document in headless environments', () => {
|
||||
expect(() => syncPlatformDataset('macos', undefined)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('inferDesktopPlatform', () => {
|
||||
it('identifies supported desktop webviews', () => {
|
||||
expect(inferDesktopPlatform('Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)')).toBe('macos');
|
||||
expect(inferDesktopPlatform('Mozilla/5.0 (Windows NT 10.0; Win64; x64)')).toBe('windows');
|
||||
expect(inferDesktopPlatform('Mozilla/5.0 (X11; Linux x86_64)')).toBe('linux');
|
||||
});
|
||||
|
||||
it('does not misclassify mobile user agents that contain desktop tokens', () => {
|
||||
expect(inferDesktopPlatform('Mozilla/5.0 (Linux; Android 14; Mobile)')).toBe('unknown');
|
||||
expect(inferDesktopPlatform('Mozilla/5.0 (Macintosh; iPad; Mobile)')).toBe('unknown');
|
||||
});
|
||||
|
||||
it('synchronizes the inferred platform before the renderer mounts', () => {
|
||||
const mockDocument = { documentElement: { dataset: {} as Record<string, string | undefined> } };
|
||||
|
||||
syncPlatformDatasetFromUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64)', mockDocument);
|
||||
|
||||
expect(mockDocument.documentElement.dataset.platform).toBe('windows');
|
||||
});
|
||||
});
|
||||
|
||||
+14
-9
@@ -35,18 +35,23 @@ export const syncPlatformDataset = (
|
||||
if (!targetDocument) return;
|
||||
if (os === 'macos' || os === 'windows' || os === 'linux') {
|
||||
targetDocument.documentElement.dataset.platform = os;
|
||||
} else {
|
||||
delete targetDocument.documentElement.dataset.platform;
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof document !== 'undefined' && typeof navigator !== 'undefined') {
|
||||
if (/Macintosh|Mac OS X/i.test(navigator.userAgent)) {
|
||||
syncPlatformDataset('macos');
|
||||
} else if (/Windows/i.test(navigator.userAgent)) {
|
||||
syncPlatformDataset('windows');
|
||||
} else if (/Linux/i.test(navigator.userAgent)) {
|
||||
syncPlatformDataset('linux');
|
||||
}
|
||||
}
|
||||
export const inferDesktopPlatform = (userAgent: string): PlatformInfo['os'] => {
|
||||
if (/Android|iPhone|iPad|iPod|Mobile/i.test(userAgent)) return 'unknown';
|
||||
if (/Macintosh|Mac OS X/i.test(userAgent)) return 'macos';
|
||||
if (/Windows/i.test(userAgent)) return 'windows';
|
||||
if (/Linux/i.test(userAgent)) return 'linux';
|
||||
return 'unknown';
|
||||
};
|
||||
|
||||
export const syncPlatformDatasetFromUserAgent = (
|
||||
userAgent: string,
|
||||
targetDocument: TargetDocument | undefined = typeof document !== 'undefined' ? document : undefined,
|
||||
): void => syncPlatformDataset(inferDesktopPlatform(userAgent), targetDocument);
|
||||
|
||||
export const getPlatformInfo = (): Promise<PlatformInfo> => {
|
||||
if (cached) return Promise.resolve(cached);
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { subscribeToWindowMaximized, type WindowMaximizedSource } from './windowMaximized';
|
||||
|
||||
type WindowStateDisposer = () => void | Promise<void>;
|
||||
|
||||
const deferred = <T,>() => {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>(settle => { resolve = settle; });
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
describe('window maximized state', () => {
|
||||
it('reads state immediately and again after listener registration closes the startup gap', async () => {
|
||||
const onChange = vi.fn();
|
||||
const source: WindowMaximizedSource = {
|
||||
isMaximized: vi.fn()
|
||||
.mockResolvedValueOnce(false)
|
||||
.mockResolvedValueOnce(true),
|
||||
listenResized: vi.fn(async () => vi.fn()),
|
||||
};
|
||||
|
||||
const dispose = subscribeToWindowMaximized(source, onChange);
|
||||
|
||||
await vi.waitFor(() => expect(onChange).toHaveBeenLastCalledWith(true));
|
||||
expect(source.isMaximized).toHaveBeenCalledTimes(2);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('keeps the newest resize state when native reads resolve out of order', async () => {
|
||||
const initial = deferred<boolean>();
|
||||
const maximized = deferred<boolean>();
|
||||
const restored = deferred<boolean>();
|
||||
let onResize: (() => void) | undefined;
|
||||
const onChange = vi.fn();
|
||||
const source: WindowMaximizedSource = {
|
||||
isMaximized: vi.fn()
|
||||
.mockReturnValueOnce(initial.promise)
|
||||
.mockReturnValueOnce(maximized.promise)
|
||||
.mockReturnValueOnce(restored.promise)
|
||||
.mockResolvedValue(false),
|
||||
listenResized: vi.fn(async listener => {
|
||||
onResize = listener;
|
||||
return vi.fn();
|
||||
}),
|
||||
};
|
||||
|
||||
const dispose = subscribeToWindowMaximized(source, onChange);
|
||||
await vi.waitFor(() => expect(source.isMaximized).toHaveBeenCalledTimes(2));
|
||||
onResize?.();
|
||||
expect(source.isMaximized).toHaveBeenCalledTimes(3);
|
||||
|
||||
restored.resolve(false);
|
||||
maximized.resolve(true);
|
||||
initial.resolve(false);
|
||||
await vi.waitFor(() => expect(onChange).toHaveBeenCalledWith(false));
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('disposes a listener that finishes registering after unmount and ignores late state', async () => {
|
||||
const registration = deferred<WindowStateDisposer>();
|
||||
const state = deferred<boolean>();
|
||||
const disposer = vi.fn();
|
||||
const onChange = vi.fn();
|
||||
const source: WindowMaximizedSource = {
|
||||
isMaximized: () => state.promise,
|
||||
listenResized: () => registration.promise,
|
||||
};
|
||||
|
||||
const dispose = subscribeToWindowMaximized(source, onChange);
|
||||
dispose();
|
||||
registration.resolve(disposer);
|
||||
state.resolve(true);
|
||||
|
||||
await vi.waitFor(() => expect(disposer).toHaveBeenCalledOnce());
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('survives synchronous native state and listener failures', () => {
|
||||
const source: WindowMaximizedSource = {
|
||||
isMaximized: () => { throw new Error('state unavailable'); },
|
||||
listenResized: () => { throw new Error('listener unavailable'); },
|
||||
};
|
||||
|
||||
expect(() => subscribeToWindowMaximized(source, vi.fn())()).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
type WindowStateDisposer = () => void | Promise<void>;
|
||||
|
||||
export type WindowMaximizedSource = {
|
||||
isMaximized: () => boolean | Promise<boolean>;
|
||||
listenResized: (listener: () => void) => Promise<WindowStateDisposer>;
|
||||
};
|
||||
|
||||
const createCurrentWindowMaximizedSource = (): WindowMaximizedSource => {
|
||||
const currentWindow = getCurrentWindow();
|
||||
return {
|
||||
isMaximized: () => currentWindow.isMaximized(),
|
||||
listenResized: listener => currentWindow.onResized(listener),
|
||||
};
|
||||
};
|
||||
|
||||
const safelyDispose = (disposer: WindowStateDisposer | undefined) => {
|
||||
if (typeof disposer !== 'function') return;
|
||||
try {
|
||||
void Promise.resolve(disposer()).catch(() => undefined);
|
||||
} catch {
|
||||
// Window teardown remains best-effort if the native listener is already gone.
|
||||
}
|
||||
};
|
||||
|
||||
export const subscribeToWindowMaximized = (
|
||||
source: WindowMaximizedSource,
|
||||
onChange: (maximized: boolean) => void,
|
||||
): (() => void) => {
|
||||
let disposed = false;
|
||||
let readGeneration = 0;
|
||||
let lastPublished: boolean | undefined;
|
||||
let listenerDisposer: WindowStateDisposer | undefined;
|
||||
|
||||
const refresh = () => {
|
||||
const generation = ++readGeneration;
|
||||
let stateRead: boolean | Promise<boolean>;
|
||||
try {
|
||||
stateRead = source.isMaximized();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
void Promise.resolve(stateRead)
|
||||
.then(maximized => {
|
||||
if (disposed || generation !== readGeneration || maximized === lastPublished) return;
|
||||
lastPublished = maximized;
|
||||
onChange(maximized);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
};
|
||||
|
||||
let registration: Promise<WindowStateDisposer>;
|
||||
try {
|
||||
registration = Promise.resolve(source.listenResized(refresh));
|
||||
} catch {
|
||||
registration = Promise.reject();
|
||||
}
|
||||
void registration
|
||||
.then(disposer => {
|
||||
if (disposed) safelyDispose(disposer);
|
||||
else if (typeof disposer === 'function') listenerDisposer = disposer;
|
||||
// Close the registration gap with an authoritative post-listener read.
|
||||
if (!disposed) refresh();
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
// Do not delay the first state read behind asynchronous listener registration.
|
||||
refresh();
|
||||
|
||||
return () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
readGeneration += 1;
|
||||
safelyDispose(listenerDisposer);
|
||||
listenerDisposer = undefined;
|
||||
};
|
||||
};
|
||||
|
||||
export const useWindowMaximizedState = (providedSource?: WindowMaximizedSource): boolean => {
|
||||
const source = useMemo(
|
||||
() => providedSource ?? createCurrentWindowMaximizedSource(),
|
||||
[providedSource],
|
||||
);
|
||||
const [maximized, setMaximized] = useState(false);
|
||||
|
||||
useEffect(() => subscribeToWindowMaximized(source, setMaximized), [source]);
|
||||
|
||||
return maximized;
|
||||
};
|
||||
Reference in New Issue
Block a user