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:
NimBold
2026-08-11 12:17:11 +03:30
parent ae6a00304e
commit f7bafdeb0e
28 changed files with 897 additions and 36 deletions
+151
View File
@@ -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();
});
});
+176
View File
@@ -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;
}
};
};
+47
View File
@@ -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',
});
});
});
+42
View File
@@ -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)}`,
};
};
+26
View File
@@ -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
});
}
});
});