Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,16 @@ const mocks = vi.hoisted(() => ({
writeAppearanceSettingsPatch: vi.fn(),
resetAppearanceSettings: vi.fn(),
updateAppearanceSettingsAtomic: vi.fn(),
importAppearanceBackground: vi.fn(),
removeAppearanceBackgroundFile: vi.fn(),
removeAppearanceBackgroundFiles: vi.fn(),
}));

vi.mock('electron', () => ({
BrowserWindow: { getAllWindows: () => mocks.allWindows },
BrowserWindow: {
getAllWindows: () => mocks.allWindows,
fromWebContents: vi.fn(() => null),
},
ipcMain: { on: mocks.ipcOn, handle: mocks.ipcHandle },
}));

Expand All @@ -36,6 +42,12 @@ vi.mock('../appearance-settings-store.js', () => ({
updateAppearanceSettingsAtomic: mocks.updateAppearanceSettingsAtomic,
}));

vi.mock('../appearance-background.js', () => ({
importAppearanceBackground: mocks.importAppearanceBackground,
removeAppearanceBackgroundFile: mocks.removeAppearanceBackgroundFile,
removeAppearanceBackgroundFiles: mocks.removeAppearanceBackgroundFiles,
}));

import { registerAppearanceSettingsIpc } from '../appearance-settings-ipc.js';

const persisted = {
Expand All @@ -44,6 +56,9 @@ const persisted = {
uiSize: 15,
codeSize: 14,
windowZoom: 1.1,
backgroundImage: '',
backgroundOverlay: 0.58,
backgroundBlur: 0,
};

describe('appearance settings IPC authorization', () => {
Expand All @@ -57,6 +72,9 @@ describe('appearance settings IPC authorization', () => {
mocks.trustedReadWindow.mockReset().mockReturnValue(false);
mocks.assertTrustedAppRendererEvent.mockReset();
mocks.readAppearanceSettings.mockReset().mockReturnValue(persisted);
mocks.importAppearanceBackground.mockReset();
mocks.removeAppearanceBackgroundFile.mockReset().mockResolvedValue(undefined);
mocks.removeAppearanceBackgroundFiles.mockReset().mockResolvedValue(undefined);
});

it('同步启动读取只向已授权的外观 reader 返回持久快照', () => {
Expand Down Expand Up @@ -124,4 +142,36 @@ describe('appearance settings IPC authorization', () => {
expect(allowedSend).toHaveBeenCalledWith('appearance-settings:changed', persisted);
expect(deniedSend).not.toHaveBeenCalled();
});

it('背景设置提交失败时只回收本次暂存文件,不清理当前背景', async () => {
const importHandler = mocks.ipcHandle.mock.calls.find(
([channel]) => channel === 'appearance-settings:background-import',
)?.[1] as (event: { sender: unknown }) => Promise<unknown>;
const failure = new Error('settings locked');
const stagedUrl =
'cindy-background://current/background-123e4567-e89b-12d3-a456-426614174000.jpg';
mocks.importAppearanceBackground.mockResolvedValue({ canceled: false, url: stagedUrl });
mocks.writeAppearanceSettingsPatch.mockRejectedValueOnce(failure);

await expect(importHandler({ sender: {} })).rejects.toBe(failure);

expect(mocks.removeAppearanceBackgroundFile).toHaveBeenCalledWith(stagedUrl);
expect(mocks.removeAppearanceBackgroundFiles).not.toHaveBeenCalled();
});

it('背景设置提交成功后才清理旧文件,并保留新文件', async () => {
const importHandler = mocks.ipcHandle.mock.calls.find(
([channel]) => channel === 'appearance-settings:background-import',
)?.[1] as (event: { sender: unknown }) => Promise<unknown>;
const stagedUrl =
'cindy-background://current/background-123e4567-e89b-12d3-a456-426614174001.png';
const next = { ...persisted, backgroundImage: stagedUrl };
mocks.importAppearanceBackground.mockResolvedValue({ canceled: false, url: stagedUrl });
mocks.writeAppearanceSettingsPatch.mockResolvedValueOnce(next);

await expect(importHandler({ sender: {} })).resolves.toMatchObject({ settings: next });

expect(mocks.removeAppearanceBackgroundFile).not.toHaveBeenCalled();
expect(mocks.removeAppearanceBackgroundFiles).toHaveBeenCalledWith(stagedUrl);
});
});
15 changes: 7 additions & 8 deletions apps/desktop/src/main/__tests__/schemePrivileges.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
* every field, supportFetchAPI in particular).
* 2. Static source scan: `registerSchemesAsPrivileged(` appears exactly
* once under src/main/**, and that one occurrence is in
* bootstrap-electron.ts with all six privilege constants in the array.
* bootstrap-electron.ts with every privilege constant in the array.
*/

import { describe, it, expect, vi } from 'vitest';
Expand Down Expand Up @@ -45,12 +45,9 @@ const { videoSchemePrivilege } = await import('../videoProtocol');
const { localFileSchemePrivilege } = await import('../localFileProtocol');
const { audioFileSchemePrivilege } = await import('../audioFileProtocol');
const { modelSchemePrivilege } = await import('../modelProtocol');
const { remoteMediaSchemePrivilege } = await import(
'../device-link/remoteMediaProtocol'
);
const { cindyMediaSchemePrivilege } = await import(
'../cindy-media/cindyMediaProtocol'
);
const { remoteMediaSchemePrivilege } = await import('../device-link/remoteMediaProtocol');
const { cindyMediaSchemePrivilege } = await import('../cindy-media/cindyMediaProtocol');
const { appearanceBackgroundSchemePrivilege } = await import('../appearance-background');

const ALL = [
{ entry: imageSchemePrivilege, scheme: 'xdt-image' },
Expand All @@ -60,6 +57,7 @@ const ALL = [
{ entry: modelSchemePrivilege, scheme: 'xdt-model' },
{ entry: remoteMediaSchemePrivilege, scheme: 'cindy-remote-media' },
{ entry: cindyMediaSchemePrivilege, scheme: 'cindy-media' },
{ entry: appearanceBackgroundSchemePrivilege, scheme: 'cindy-background' },
];

describe('scheme privilege constants', () => {
Expand Down Expand Up @@ -99,7 +97,7 @@ describe('registerSchemesAsPrivileged is called exactly once (static scan)', ()
// merely mention the API name don't count as call sites.
const CALL = 'protocol.registerSchemesAsPrivileged(';

it('single call site lives in bootstrap-electron.ts with all six schemes', () => {
it('single call site lives in bootstrap-electron.ts with every scheme', () => {
const callSites: string[] = [];
for (const file of collectTsFiles(MAIN_DIR)) {
const src = readFileSync(file, 'utf8');
Expand All @@ -124,6 +122,7 @@ describe('registerSchemesAsPrivileged is called exactly once (static scan)', ()
'modelSchemePrivilege',
'remoteMediaSchemePrivilege',
'cindyMediaSchemePrivilege',
'appearanceBackgroundSchemePrivilege',
]) {
expect(callBlock).toContain(name);
}
Expand Down
144 changes: 144 additions & 0 deletions apps/desktop/src/main/appearance-background.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/** Main-owned custom background import and read-only protocol. */
import fs from 'node:fs';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import {
app,
dialog,
protocol,
type BrowserWindow,
type CustomScheme,
type OpenDialogOptions,
} from 'electron';

import { sniffMediaMime } from './cindy-media/sniffMediaMime.js';
import { throwIpcError } from './utils/ipcValidate.js';

const SCHEME = 'cindy-background';
const MAX_BYTES = 25 * 1024 * 1024;
const MIME_EXT = new Map([
['image/jpeg', '.jpg'],
['image/png', '.png'],
['image/webp', '.webp'],
]);

export const appearanceBackgroundSchemePrivilege: CustomScheme = {
scheme: SCHEME,
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
bypassCSP: false,
stream: false,
corsEnabled: false,
},
};

function backgroundDir(): string {
return path.join(app.getPath('userData'), 'appearance-backgrounds');
}

export async function importAppearanceBackground(
parentWindow: BrowserWindow | null,
): Promise<{ canceled: true } | { canceled: false; url: string }> {
const options: OpenDialogOptions = {
properties: ['openFile'],
filters: [{ name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'webp'] }],
};
const result = parentWindow
? await dialog.showOpenDialog(parentWindow, options)
: await dialog.showOpenDialog(options);
if (result.canceled || result.filePaths.length === 0) return { canceled: true };
const source = result.filePaths[0];
let stat: fs.Stats;
try {
stat = await fs.promises.stat(source);
} catch {
throwIpcError('INVALID_PARAMS', 'selected background image is unavailable');
}
if (!stat.isFile()) throwIpcError('INVALID_PARAMS', 'background must be a file');
if (stat.size > MAX_BYTES) throwIpcError('INVALID_PARAMS', 'background image exceeds 25 MB');
const bytes = await fs.promises.readFile(source);
const mime = sniffMediaMime(bytes.subarray(0, 4096));
const ext = mime ? MIME_EXT.get(mime) : undefined;
if (!ext) throwIpcError('INVALID_PARAMS', 'unsupported background image');
const dir = backgroundDir();
await fs.promises.mkdir(dir, { recursive: true });
const id = randomUUID();
const fileName = `background-${id}${ext}`;
const target = path.join(dir, fileName);
const temp = path.join(dir, `.${fileName}.tmp`);
await fs.promises.writeFile(temp, bytes, { flag: 'wx' });
try {
await fs.promises.rename(temp, target);
} catch (error) {
await unlinkIfPresent(temp).catch(() => undefined);
throw error;
}
return { canceled: false, url: `${SCHEME}://current/${fileName}` };
}

export async function removeAppearanceBackgroundFile(url: string): Promise<void> {
const fileName = backgroundFileNameFromUrl(url);
if (!fileName) return;
await unlinkIfPresent(path.join(backgroundDir(), fileName));
}

export async function removeAppearanceBackgroundFiles(keepUrl = ''): Promise<void> {
const dir = backgroundDir();
const keep = backgroundFileNameFromUrl(keepUrl);
let names: string[];
try {
names = await fs.promises.readdir(dir);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return;
throw error;
}
await Promise.all(
names.map(async (name) => {
if (name === keep || !BACKGROUND_FILE_NAME.test(name)) return;
await unlinkIfPresent(path.join(dir, name));
}),
);
}

const BACKGROUND_FILE_NAME = /^background(?:-[0-9a-f-]+)?\.(?:png|jpg|webp)$/;

function backgroundFileNameFromUrl(url: string): string | null {
const match =
/^cindy-background:\/\/current\/(background(?:-[0-9a-f-]+)?\.(?:png|jpg|webp))(?:\?v=\d+)?$/.exec(
url,
);
return match?.[1] ?? null;
}

async function unlinkIfPresent(filePath: string): Promise<void> {
await fs.promises.unlink(filePath).catch((error: NodeJS.ErrnoException) => {
if (error.code !== 'ENOENT') throw error;
});
}

export function registerAppearanceBackgroundProtocolHandler(): void {
protocol.handle(SCHEME, async (request) => {
const match =
/^cindy-background:\/\/current\/(background(?:-[0-9a-f-]+)?\.(?:png|jpg|webp))(?:\?v=\d+)?$/.exec(
request.url,
);
if (!match) return new Response(null, { status: 403 });
try {
const filePath = path.join(backgroundDir(), match[1]);
const bytes = await fs.promises.readFile(filePath);
const mime = sniffMediaMime(bytes.subarray(0, 4096));
if (!mime || !MIME_EXT.has(mime)) return new Response(null, { status: 403 });
return new Response(bytes, {
headers: { 'Content-Type': mime, 'Cache-Control': 'no-cache' },
});
} catch (error) {
return new Response(null, {
status: (error as NodeJS.ErrnoException).code === 'ENOENT' ? 404 : 500,
});
}
});
}

export const __testing = { backgroundDir, backgroundFileNameFromUrl };
Loading