mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-22 08:56:44 +00:00
feat(ui): persist window state and clarify transfer telemetry
- Persist bounded logical main-window geometry with work-area-safe startup restoration. - Persist the Folders collapse preference in SQLite with guarded legacy localStorage migration. - Keep media transfer telemetry truthful and compact the transfer controls across locales. - Add bridge, presentation, persistence, geometry, and configuration regression coverage.
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
createMainWindowSizePersistence,
|
||||
normalizeMainWindowSize,
|
||||
physicalToLogicalSize,
|
||||
type MainWindowEventSource
|
||||
} from './mainWindowState';
|
||||
|
||||
const createWindowMock = () => {
|
||||
let resized: ((event: { payload: { width: number; height: number } }) => void) | undefined;
|
||||
let scaleChanged: ((event: {
|
||||
payload: { scaleFactor: number; size: { width: number; height: number } }
|
||||
}) => void) | undefined;
|
||||
const appWindow: MainWindowEventSource = {
|
||||
onResized: vi.fn(async handler => {
|
||||
resized = handler;
|
||||
return vi.fn();
|
||||
}),
|
||||
onScaleChanged: vi.fn(async handler => {
|
||||
scaleChanged = handler;
|
||||
return vi.fn();
|
||||
}),
|
||||
scaleFactor: vi.fn(async () => 2)
|
||||
};
|
||||
return {
|
||||
appWindow,
|
||||
resize: (width: number, height: number) => resized?.({ payload: { width, height } }),
|
||||
scale: (scaleFactor: number, width: number, height: number) =>
|
||||
scaleChanged?.({ payload: { scaleFactor, size: { width, height } } })
|
||||
};
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('main window geometry', () => {
|
||||
it('converts physical event sizes to bounded logical pixels', () => {
|
||||
expect(physicalToLogicalSize(2560, 1600, 2)).toEqual({ width: 1280, height: 800 });
|
||||
expect(normalizeMainWindowSize({ width: 959, height: 800 })).toBeNull();
|
||||
expect(normalizeMainWindowSize({ width: 1280, height: 16_385 })).toBeNull();
|
||||
expect(normalizeMainWindowSize({ width: 1280, height: 800 })).toEqual({ width: 1280, height: 800 });
|
||||
});
|
||||
|
||||
it('debounces resize persistence to the latest logical size', async () => {
|
||||
vi.useFakeTimers();
|
||||
const mock = createWindowMock();
|
||||
const onSize = vi.fn();
|
||||
const controller = createMainWindowSizePersistence({ appWindow: mock.appWindow, onSize });
|
||||
|
||||
mock.resize(2560, 1600);
|
||||
await vi.waitFor(() => expect(mock.appWindow.scaleFactor).toHaveBeenCalledTimes(1));
|
||||
mock.resize(2800, 1800);
|
||||
await vi.waitFor(() => expect(mock.appWindow.scaleFactor).toHaveBeenCalledTimes(2));
|
||||
await vi.runAllTicks();
|
||||
expect(onSize).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(249);
|
||||
expect(onSize).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(onSize).toHaveBeenCalledWith({ width: 1400, height: 900 });
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
it('flushes a pending scale read and ignores a stale result', async () => {
|
||||
let resolveScale!: (scale: number) => void;
|
||||
const mock = createWindowMock();
|
||||
vi.mocked(mock.appWindow.scaleFactor).mockImplementationOnce(
|
||||
() => new Promise(resolve => { resolveScale = resolve; })
|
||||
);
|
||||
const onSize = vi.fn();
|
||||
const controller = createMainWindowSizePersistence({
|
||||
appWindow: mock.appWindow,
|
||||
onSize,
|
||||
debounceMs: 10_000
|
||||
});
|
||||
|
||||
mock.resize(2560, 1600);
|
||||
mock.scale(1, 1400, 900);
|
||||
const flush = controller.flush();
|
||||
resolveScale(2);
|
||||
await flush;
|
||||
|
||||
expect(onSize).toHaveBeenCalledTimes(1);
|
||||
expect(onSize).toHaveBeenCalledWith({ width: 1400, height: 900 });
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
it('waits for scale reads started while a flush is already in progress', async () => {
|
||||
let resolveFirst!: (scale: number) => void;
|
||||
let resolveSecond!: (scale: number) => void;
|
||||
const mock = createWindowMock();
|
||||
vi.mocked(mock.appWindow.scaleFactor)
|
||||
.mockImplementationOnce(() => new Promise(resolve => { resolveFirst = resolve; }))
|
||||
.mockImplementationOnce(() => new Promise(resolve => { resolveSecond = resolve; }));
|
||||
const onSize = vi.fn();
|
||||
const controller = createMainWindowSizePersistence({
|
||||
appWindow: mock.appWindow,
|
||||
onSize,
|
||||
debounceMs: 10_000
|
||||
});
|
||||
|
||||
mock.resize(2560, 1600);
|
||||
const flush = controller.flush();
|
||||
await vi.waitFor(() => expect(mock.appWindow.scaleFactor).toHaveBeenCalledTimes(1));
|
||||
|
||||
mock.resize(2800, 1800);
|
||||
await vi.waitFor(() => expect(mock.appWindow.scaleFactor).toHaveBeenCalledTimes(2));
|
||||
resolveFirst(2);
|
||||
await Promise.resolve();
|
||||
expect(onSize).not.toHaveBeenCalled();
|
||||
|
||||
resolveSecond(2);
|
||||
await flush;
|
||||
|
||||
expect(onSize).toHaveBeenCalledTimes(1);
|
||||
expect(onSize).toHaveBeenCalledWith({ width: 1400, height: 900 });
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
it('cleans up listeners when one registration fails', async () => {
|
||||
const unlistenResize = vi.fn();
|
||||
const onResized = vi.fn(async () => unlistenResize);
|
||||
const onScaleChanged = vi.fn(async () => {
|
||||
throw new Error('scale listener unavailable');
|
||||
});
|
||||
const appWindow: MainWindowEventSource = {
|
||||
onResized,
|
||||
onScaleChanged,
|
||||
scaleFactor: vi.fn(async () => 2)
|
||||
};
|
||||
const controller = createMainWindowSizePersistence({ appWindow, onSize: vi.fn() });
|
||||
|
||||
await vi.waitFor(() => expect(unlistenResize).toHaveBeenCalledTimes(1));
|
||||
controller.dispose();
|
||||
expect(unlistenResize).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ignores a synchronous scale-factor failure without breaking resize handling', () => {
|
||||
const mock = createWindowMock();
|
||||
vi.mocked(mock.appWindow.scaleFactor).mockImplementationOnce(() => {
|
||||
throw new Error('scale factor unavailable');
|
||||
});
|
||||
const onSize = vi.fn();
|
||||
const controller = createMainWindowSizePersistence({ appWindow: mock.appWindow, onSize });
|
||||
|
||||
expect(() => mock.resize(2560, 1600)).not.toThrow();
|
||||
expect(onSize).not.toHaveBeenCalled();
|
||||
controller.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import type { MainWindowSize } from '../bindings/MainWindowSize';
|
||||
|
||||
export type { MainWindowSize } from '../bindings/MainWindowSize';
|
||||
|
||||
export const MAIN_WINDOW_DEFAULT_WIDTH = 1280;
|
||||
export const MAIN_WINDOW_DEFAULT_HEIGHT = 800;
|
||||
export const MAIN_WINDOW_MIN_WIDTH = 960;
|
||||
export const MAIN_WINDOW_MIN_HEIGHT = 640;
|
||||
export const MAIN_WINDOW_MAX_WIDTH = 16_384;
|
||||
export const MAIN_WINDOW_MAX_HEIGHT = 16_384;
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
export const normalizeMainWindowSize = (value: unknown): MainWindowSize | null => {
|
||||
if (!isRecord(value)) return null;
|
||||
const { width, height } = value;
|
||||
if (
|
||||
typeof width !== 'number'
|
||||
|| typeof height !== 'number'
|
||||
|| !Number.isInteger(width)
|
||||
|| !Number.isInteger(height)
|
||||
|| width < MAIN_WINDOW_MIN_WIDTH
|
||||
|| height < MAIN_WINDOW_MIN_HEIGHT
|
||||
|| width > MAIN_WINDOW_MAX_WIDTH
|
||||
|| height > MAIN_WINDOW_MAX_HEIGHT
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { width, height };
|
||||
};
|
||||
|
||||
export const physicalToLogicalSize = (
|
||||
width: number,
|
||||
height: number,
|
||||
scaleFactor: number
|
||||
): MainWindowSize | null => {
|
||||
if (!Number.isFinite(scaleFactor) || scaleFactor <= 0) return null;
|
||||
return normalizeMainWindowSize({
|
||||
width: Math.round(width / scaleFactor),
|
||||
height: Math.round(height / scaleFactor)
|
||||
});
|
||||
};
|
||||
|
||||
type PhysicalSize = { width: number; height: number };
|
||||
type EventPayload<T> = { payload: T };
|
||||
|
||||
export interface MainWindowEventSource {
|
||||
onResized: (handler: (event: EventPayload<PhysicalSize>) => void) => Promise<() => void>;
|
||||
onScaleChanged: (
|
||||
handler: (event: EventPayload<{ scaleFactor: number; size: PhysicalSize }>) => void
|
||||
) => Promise<() => void>;
|
||||
scaleFactor: () => Promise<number>;
|
||||
}
|
||||
|
||||
export interface MainWindowSizePersistence {
|
||||
flush: () => Promise<void>;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
export const createMainWindowSizePersistence = ({
|
||||
appWindow,
|
||||
onSize,
|
||||
debounceMs = 250
|
||||
}: {
|
||||
appWindow: MainWindowEventSource;
|
||||
onSize: (size: MainWindowSize) => void;
|
||||
debounceMs?: number;
|
||||
}): MainWindowSizePersistence => {
|
||||
let disposed = false;
|
||||
let generation = 0;
|
||||
let pendingSize: MainWindowSize | null = null;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const pendingScaleReads = new Set<Promise<void>>();
|
||||
|
||||
const clearTimer = () => {
|
||||
if (timer === null) return;
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
};
|
||||
|
||||
const commit = () => {
|
||||
if (disposed) return;
|
||||
clearTimer();
|
||||
const size = pendingSize;
|
||||
pendingSize = null;
|
||||
if (size) onSize(size);
|
||||
};
|
||||
|
||||
const scheduleCommit = () => {
|
||||
clearTimer();
|
||||
timer = setTimeout(commit, debounceMs);
|
||||
};
|
||||
|
||||
const record = (size: PhysicalSize, scaleFactor: number, eventGeneration: number) => {
|
||||
if (disposed || eventGeneration !== generation) return;
|
||||
const logicalSize = physicalToLogicalSize(size.width, size.height, scaleFactor);
|
||||
if (!logicalSize) return;
|
||||
pendingSize = logicalSize;
|
||||
scheduleCommit();
|
||||
};
|
||||
|
||||
const recordResized = (size: PhysicalSize) => {
|
||||
const eventGeneration = ++generation;
|
||||
let scaleFactorRead: Promise<number>;
|
||||
try {
|
||||
scaleFactorRead = appWindow.scaleFactor();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const read = scaleFactorRead
|
||||
.then(scaleFactor => record(size, scaleFactor, eventGeneration))
|
||||
.catch(() => undefined);
|
||||
pendingScaleReads.add(read);
|
||||
void read.then(
|
||||
() => pendingScaleReads.delete(read),
|
||||
() => pendingScaleReads.delete(read)
|
||||
);
|
||||
};
|
||||
|
||||
const recordScaleChanged = ({ scaleFactor, size }: { scaleFactor: number; size: PhysicalSize }) => {
|
||||
const eventGeneration = ++generation;
|
||||
record(size, scaleFactor, eventGeneration);
|
||||
};
|
||||
|
||||
const registerListener = <T>(register: () => Promise<T>): Promise<T> => {
|
||||
try {
|
||||
return Promise.resolve(register());
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
};
|
||||
const listenersReady = Promise.allSettled([
|
||||
registerListener(() => appWindow.onResized(({ payload }) => recordResized(payload))),
|
||||
registerListener(() => appWindow.onScaleChanged(({ payload }) => recordScaleChanged(payload)))
|
||||
]);
|
||||
let listenerDisposers: (() => void)[] | null = null;
|
||||
let listenersDisposed = false;
|
||||
const disposeListeners = (disposers: (() => void)[]) => {
|
||||
if (listenersDisposed) return;
|
||||
listenersDisposed = true;
|
||||
disposers.forEach(dispose => {
|
||||
try {
|
||||
dispose();
|
||||
} catch {
|
||||
// Listener teardown is best-effort; continue cleaning up the rest.
|
||||
}
|
||||
});
|
||||
};
|
||||
void listenersReady.then(results => {
|
||||
const disposers = results
|
||||
.filter((result): result is PromiseFulfilledResult<() => void> => result.status === 'fulfilled')
|
||||
.map(result => result.value);
|
||||
const hasRegistrationFailure = results.some(result => result.status === 'rejected');
|
||||
if (disposed || hasRegistrationFailure) disposeListeners(disposers);
|
||||
else listenerDisposers = disposers;
|
||||
});
|
||||
|
||||
return {
|
||||
flush: async () => {
|
||||
while (pendingScaleReads.size > 0) {
|
||||
await Promise.all([...pendingScaleReads]);
|
||||
}
|
||||
commit();
|
||||
},
|
||||
dispose: () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
generation += 1;
|
||||
clearTimer();
|
||||
pendingSize = null;
|
||||
if (listenerDisposers) disposeListeners(listenerDisposers);
|
||||
listenerDisposers = null;
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getPropertiesConnectionPresentation } from './propertiesPresentation';
|
||||
|
||||
describe('Properties connection presentation', () => {
|
||||
it('keeps media concurrency out of the live header metrics', () => {
|
||||
expect(getPropertiesConnectionPresentation({
|
||||
isMedia: true,
|
||||
isTorrent: false,
|
||||
connections: 16,
|
||||
activeConnections: 8,
|
||||
requestedConnections: 16,
|
||||
})).toEqual({
|
||||
kind: 'media',
|
||||
showHeaderMetric: false,
|
||||
labelKey: 'fragmentConcurrency',
|
||||
value: '16',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps Aria2 active and requested connections together', () => {
|
||||
expect(getPropertiesConnectionPresentation({
|
||||
isMedia: false,
|
||||
isTorrent: false,
|
||||
connections: 8,
|
||||
activeConnections: 3,
|
||||
requestedConnections: 8,
|
||||
})).toEqual({
|
||||
kind: 'aria2',
|
||||
showHeaderMetric: true,
|
||||
labelKey: 'connections',
|
||||
value: '3 / 8',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses connected peers for Torrents', () => {
|
||||
expect(getPropertiesConnectionPresentation({
|
||||
isMedia: false,
|
||||
isTorrent: true,
|
||||
connectedPeers: 4,
|
||||
})).toEqual({
|
||||
kind: 'torrent',
|
||||
showHeaderMetric: true,
|
||||
labelKey: 'torrentConnectedPeers',
|
||||
value: '4',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { PropertiesSnapshot } from '../propertiesBridge';
|
||||
|
||||
export type PropertiesConnectionKind = 'media' | 'torrent' | 'aria2';
|
||||
export type PropertiesConnectionLabelKey = 'fragmentConcurrency' | 'torrentConnectedPeers' | 'connections';
|
||||
|
||||
export type PropertiesConnectionPresentation = {
|
||||
kind: PropertiesConnectionKind;
|
||||
showHeaderMetric: boolean;
|
||||
labelKey: PropertiesConnectionLabelKey;
|
||||
value: string;
|
||||
};
|
||||
|
||||
const displayCount = (value: number | undefined): string => value == null ? '—' : String(value);
|
||||
|
||||
export const getPropertiesConnectionPresentation = (
|
||||
snapshot: Pick<PropertiesSnapshot, 'isMedia' | 'isTorrent' | 'connections' | 'activeConnections' | 'requestedConnections' | 'connectedPeers'>,
|
||||
): PropertiesConnectionPresentation => {
|
||||
if (snapshot.isMedia === true) {
|
||||
return {
|
||||
kind: 'media',
|
||||
showHeaderMetric: false,
|
||||
labelKey: 'fragmentConcurrency',
|
||||
value: displayCount(snapshot.connections),
|
||||
};
|
||||
}
|
||||
|
||||
if (snapshot.isTorrent === true) {
|
||||
return {
|
||||
kind: 'torrent',
|
||||
showHeaderMetric: true,
|
||||
labelKey: 'torrentConnectedPeers',
|
||||
value: displayCount(snapshot.connectedPeers),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'aria2',
|
||||
showHeaderMetric: true,
|
||||
labelKey: 'connections',
|
||||
value: `${displayCount(snapshot.activeConnections)} / ${displayCount(snapshot.requestedConnections ?? snapshot.connections)}`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import baseConfiguration from '../../src-tauri/tauri.conf.json';
|
||||
import macosConfiguration from '../../src-tauri/tauri.macos.conf.json';
|
||||
import windowsConfiguration from '../../src-tauri/tauri.windows.conf.json';
|
||||
import linuxConfiguration from '../../src-tauri/tauri.linux.conf.json';
|
||||
|
||||
const configurations = [
|
||||
['base', baseConfiguration],
|
||||
['macOS', macosConfiguration],
|
||||
['Windows', windowsConfiguration],
|
||||
['Linux', linuxConfiguration]
|
||||
] as const;
|
||||
|
||||
describe('main window configuration', () => {
|
||||
it('uses the content-appropriate first-run size on every platform', () => {
|
||||
for (const [platform, config] of configurations) {
|
||||
const mainWindow = config.app.windows[0];
|
||||
expect(mainWindow, platform).toMatchObject({
|
||||
width: 1280,
|
||||
height: 800,
|
||||
minWidth: 960,
|
||||
minHeight: 640
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user