super-productivity/electron/local-file-sync.ts
Het Savani a3560a5615
feat(settings): add image picker for background image selection (#7564)
* feat(settings): add image picker for background image selection

* fix(settings): use electron file paths for background image selection

* fix(settings): validate and normalize local background image paths

* fix(theme): validate local background image types before reading files
2026-05-13 12:58:07 +02:00

252 lines
6.4 KiB
TypeScript

import { IPC } from './shared-with-frontend/ipc-events.const';
import {
readdirSync,
readFileSync,
renameSync,
statSync,
writeFileSync,
unlinkSync,
} from 'fs';
import { error, log } from 'electron-log/main';
import { dialog, ipcMain } from 'electron';
import { getWin } from './main-window';
import { fileURLToPath, pathToFileURL } from 'url';
export const initLocalFileSyncAdapter = (): void => {
ipcMain.handle(
IPC.READ_LOCAL_IMAGE_AS_DATA_URL,
async (_, filePathOrUrl: string): Promise<string | null> => {
try {
const normalized = filePathOrUrl.startsWith('file://')
? fileURLToPath(filePathOrUrl)
: filePathOrUrl;
const ext = normalized.toLowerCase().split('.').pop() || '';
const mimeTypeByExt: Record<string, string> = {
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
svg: 'image/svg+xml',
bmp: 'image/bmp',
avif: 'image/avif',
};
const mimeType = mimeTypeByExt[ext];
// Reject unsupported file types before reading
if (!mimeType) {
return null;
}
const fs = await import('fs');
const stat = await fs.promises.stat(normalized);
// 200 KB limit
const MAX_FILE_SIZE = 200 * 1024;
if (stat.size > MAX_FILE_SIZE) {
throw new Error('Background image exceeds 200 KB limit');
}
const buffer = await fs.promises.readFile(normalized);
return `data:${mimeType};base64,${buffer.toString('base64')}`;
} catch (e) {
error(e);
return null;
}
},
);
ipcMain.handle(IPC.TO_FILE_URL, (_, filePath: string): string => {
return pathToFileURL(filePath).href;
});
ipcMain.handle(
IPC.FILE_SYNC_SAVE,
(
ev,
{
filePath,
dataStr,
localRev,
}: {
filePath: string;
dataStr: string;
localRev: string | null;
},
): string | Error => {
try {
console.log(IPC.FILE_SYNC_SAVE, filePath);
console.log('writeFileSync', filePath, !!dataStr);
// Atomic write: write to temp file first, then rename.
// renameSync is atomic on ext4/APFS/NTFS, so a crash mid-write
// won't corrupt the original file.
const tempPath = filePath + '.tmp';
writeFileSync(tempPath, dataStr);
renameSync(tempPath, filePath);
return getRev(filePath);
} catch (e) {
log('ERR: Sync error while writing to ' + filePath);
error(e);
return e instanceof Error ? e : new Error(String(e));
}
},
);
ipcMain.handle(
IPC.FILE_SYNC_LOAD,
(
ev,
{
filePath,
localRev,
}: {
filePath: string;
localRev: string | null;
},
): { rev: string; dataStr: string | undefined } | Error => {
try {
console.log(IPC.FILE_SYNC_LOAD, filePath, localRev);
const dataStr = readFileSync(filePath, { encoding: 'utf-8' });
console.log('READ ', dataStr.length);
return {
rev: getRev(filePath),
dataStr,
};
} catch (e) {
log('ERR: Sync error while loading file from ' + filePath);
error(e);
return e instanceof Error ? e : new Error(String(e));
}
},
);
ipcMain.handle(
IPC.FILE_SYNC_REMOVE,
(
ev,
{
filePath,
}: {
filePath: string;
},
): void | Error => {
try {
console.log(IPC.FILE_SYNC_REMOVE, filePath);
unlinkSync(filePath);
return;
} catch (e) {
log('ERR: Sync error while loading file from ' + filePath);
error(e);
return e instanceof Error ? e : new Error(String(e));
}
},
);
ipcMain.handle(
IPC.CHECK_DIR_EXISTS,
(
ev,
{
dirPath,
}: {
dirPath: string;
},
): true | Error => {
try {
const r = readdirSync(dirPath);
console.log(r);
return true;
} catch (e) {
log('ERR: error while checking dir ' + dirPath);
if ((e as NodeJS.ErrnoException).code === 'EACCES') {
log(
'ERR: Permission denied. If running as a snap, ensure the "home" or "removable-media" interface is connected.',
);
}
error(e);
return e instanceof Error ? e : new Error(String(e));
}
},
);
ipcMain.handle(
IPC.FILE_SYNC_LIST_FILES,
(
ev,
{
dirPath,
}: {
dirPath: string;
},
): string[] | Error => {
try {
return readdirSync(dirPath);
} catch (e) {
log('ERR: Sync error while listing files in ' + dirPath);
if ((e as NodeJS.ErrnoException).code === 'EACCES') {
log(
'ERR: Permission denied. If running as a snap, ensure the "home" or "removable-media" interface is connected.',
);
}
error(e);
return e instanceof Error ? e : new Error(String(e));
}
},
);
ipcMain.handle(IPC.PICK_DIRECTORY, async (): Promise<string | undefined> => {
const { canceled, filePaths } = (await dialog.showOpenDialog(getWin(), {
title: 'Select sync folder',
buttonLabel: 'Select Folder',
properties: [
'openDirectory',
'createDirectory',
'promptToCreate',
'dontAddToRecent',
],
})) as unknown as { canceled: boolean; filePaths: string[] };
if (canceled) {
return undefined;
} else {
return filePaths[0];
}
});
ipcMain.handle(
IPC.SHOW_OPEN_DIALOG,
async (
_,
options: {
properties: string[];
title?: string;
defaultPath?: string;
filters?: { name: string; extensions: string[] }[];
},
): Promise<string[] | undefined> => {
const { canceled, filePaths } = (await dialog.showOpenDialog(getWin(), {
title: options.title || 'Select folder',
buttonLabel: 'Select',
properties: options.properties as any,
defaultPath: options.defaultPath,
filters: options.filters,
})) as unknown as { canceled: boolean; filePaths: string[] };
if (canceled) {
return undefined;
} else {
return filePaths;
}
},
);
};
const getRev = (filePath: string): string => {
const fileStat = statSync(filePath);
return fileStat.mtime.getTime().toString();
};