mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
If a folder pick (setSyncFolderPath) completes while the very first getSyncFolderPath disk read is still in flight, the load's resolution callback would overwrite the freshly-set cache with the now-stale disk value (null), leaving sync wrongly not-ready until restart. Only seed the cache from disk when it's still unset; the freshly-picked value wins.
440 lines
15 KiB
TypeScript
440 lines
15 KiB
TypeScript
import { IPC } from './shared-with-frontend/ipc-events.const';
|
|
import { SimpleStoreKey } from './shared-with-frontend/simple-store.const';
|
|
import { randomBytes } from 'crypto';
|
|
import {
|
|
readdirSync,
|
|
readFileSync,
|
|
realpathSync,
|
|
renameSync,
|
|
statSync,
|
|
writeFileSync,
|
|
unlinkSync,
|
|
} from 'fs';
|
|
import * as path from 'path';
|
|
import { error, log } from 'electron-log/main';
|
|
import { app, dialog, ipcMain } from 'electron';
|
|
import { getWin } from './main-window';
|
|
import { resolveSyncPath, type ResolvedSyncPath } from './sync-path-resolver';
|
|
import { loadSimpleStoreAll, saveSimpleStore } from './simple-store';
|
|
import { assertPathOutside } from './file-path-guard';
|
|
import { getImageDataUrl, importImage } from './image-cache';
|
|
|
|
// SECURITY: file-sync must never read/write/list inside the app's private dir,
|
|
// which holds settings/grants/db — touching it is a privilege-escalation
|
|
// primitive (e.g. forging the nodeExecution grant file). The path is
|
|
// renderer-supplied (untrusted plugin/XSS). Lazy getter — userData can be
|
|
// changed at startup (--user-data-dir, Snap). See file-path-guard.ts.
|
|
const getAppPrivateDir = (): string => app.getPath('userData');
|
|
|
|
// Main-owned sync folder path. Backed by simple-store under
|
|
// SimpleStoreKey.SYNC_FOLDER_PATH; cached in-memory after first load so each FS
|
|
// IPC doesn't re-read the file. The canonical form is stored so subsequent
|
|
// realpath comparisons in resolveSyncPath line up regardless of symlink/case-
|
|
// fold drift.
|
|
let _cachedSyncFolder: string | null | undefined = undefined;
|
|
let _loadPromise: Promise<string | null> | null = null;
|
|
|
|
const _loadSyncFolderFromDisk = async (): Promise<string | null> => {
|
|
const all = await loadSimpleStoreAll();
|
|
const raw = all[SimpleStoreKey.SYNC_FOLDER_PATH];
|
|
return typeof raw === 'string' && raw.length > 0 ? raw : null;
|
|
};
|
|
|
|
const getSyncFolderPath = async (): Promise<string | null> => {
|
|
if (_cachedSyncFolder !== undefined) return _cachedSyncFolder;
|
|
if (!_loadPromise) {
|
|
_loadPromise = _loadSyncFolderFromDisk().then((v) => {
|
|
// A pick (setSyncFolderPath) can complete while this first disk read is
|
|
// still in flight. Only seed the cache if it's still unset, so the now-
|
|
// stale disk value can't clobber the freshly-picked folder. The guard +
|
|
// assignment run in one synchronous tick, so setSyncFolderPath cannot
|
|
// interleave between them.
|
|
if (_cachedSyncFolder === undefined) {
|
|
_cachedSyncFolder = v;
|
|
}
|
|
return _cachedSyncFolder;
|
|
});
|
|
}
|
|
return _loadPromise;
|
|
};
|
|
|
|
const setSyncFolderPath = async (rawPath: string): Promise<string> => {
|
|
// Canonicalize at write time so the persisted value is the form
|
|
// resolveSyncPath will compare against, and so a relative or
|
|
// symlinked path is rejected before it gets stored.
|
|
const canonical = realpathSync.native(rawPath);
|
|
// Reject a folder equal to or inside userData BEFORE persisting. Otherwise
|
|
// we'd store a "configured" folder that resolveSyncPath then denies on every
|
|
// sync op — safe but confusing. Throwing here funnels into the handler's
|
|
// createSafeIpcError path; the store and the in-memory cache stay untouched.
|
|
assertPathOutside(getAppPrivateDir(), canonical);
|
|
await saveSimpleStore(SimpleStoreKey.SYNC_FOLDER_PATH, canonical);
|
|
_cachedSyncFolder = canonical;
|
|
return canonical;
|
|
};
|
|
|
|
// Same `(maybeRoot, relative, userData) → absolutePath` resolution wrapped
|
|
// so the five handlers below don't repeat the same incantation. Returns the
|
|
// absolute path on success; throws PathNotAllowedError on rejection (the
|
|
// existing handler try/catch funnels both into a safe IPC error).
|
|
const _resolveRelative = async (
|
|
relativePath: string | undefined,
|
|
): Promise<ResolvedSyncPath> =>
|
|
resolveSyncPath(
|
|
(await getSyncFolderPath()) ?? undefined,
|
|
relativePath ?? '',
|
|
getAppPrivateDir(),
|
|
);
|
|
|
|
const _pathNotAllowed = (): Error => {
|
|
const e = new Error('Path not allowed for the sync folder');
|
|
e.name = 'PathNotAllowedError';
|
|
delete e.stack;
|
|
return e;
|
|
};
|
|
|
|
const _isEnoent = (e: unknown): boolean =>
|
|
typeof e === 'object' &&
|
|
e !== null &&
|
|
'code' in e &&
|
|
(e as { code?: unknown }).code === 'ENOENT';
|
|
|
|
const _assertSaveTargetIsFilePath = (resolved: ResolvedSyncPath): void => {
|
|
if (resolved.isRoot) {
|
|
throw _pathNotAllowed();
|
|
}
|
|
try {
|
|
if (statSync(resolved.absolutePath).isDirectory()) {
|
|
throw _pathNotAllowed();
|
|
}
|
|
} catch (e) {
|
|
if (!_isEnoent(e)) {
|
|
throw e;
|
|
}
|
|
}
|
|
};
|
|
|
|
const _resolveTempPathForSave = (resolved: ResolvedSyncPath): string => {
|
|
const tempName = [
|
|
`.${path.basename(resolved.absolutePath)}`,
|
|
process.pid,
|
|
Date.now(),
|
|
randomBytes(8).toString('hex'),
|
|
'tmp',
|
|
].join('.');
|
|
const candidate = path.join(path.dirname(resolved.absolutePath), tempName);
|
|
const tempRelative = path.relative(resolved.root, candidate);
|
|
return resolveSyncPath(resolved.root, tempRelative, getAppPrivateDir()).absolutePath;
|
|
};
|
|
|
|
export const initLocalFileSyncAdapter = (): void => {
|
|
ipcMain.handle(
|
|
IPC.FILE_SYNC_SAVE,
|
|
async (
|
|
ev,
|
|
{
|
|
relativePath,
|
|
dataStr,
|
|
localRev,
|
|
}: {
|
|
relativePath: string;
|
|
dataStr: string;
|
|
localRev: string | null;
|
|
},
|
|
): Promise<string | Error> => {
|
|
let tempPath: string | null = null;
|
|
try {
|
|
const resolved = await _resolveRelative(relativePath);
|
|
_assertSaveTargetIsFilePath(resolved);
|
|
const filePath = resolved.absolutePath;
|
|
log(IPC.FILE_SYNC_SAVE, {
|
|
dataLength: dataStr.length,
|
|
hasData: dataStr.length > 0,
|
|
});
|
|
|
|
// 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.
|
|
tempPath = _resolveTempPathForSave(resolved);
|
|
writeFileSync(tempPath, dataStr, { encoding: 'utf8', flag: 'wx' });
|
|
renameSync(tempPath, filePath);
|
|
tempPath = null;
|
|
|
|
return getRev(filePath);
|
|
} catch (e) {
|
|
if (tempPath) {
|
|
try {
|
|
unlinkSync(tempPath);
|
|
} catch {
|
|
// Best-effort cleanup; the safe IPC error below is the important part.
|
|
}
|
|
}
|
|
error('Local file sync save failed', getSafeErrorMeta(e));
|
|
return createSafeIpcError(IPC.FILE_SYNC_SAVE, e);
|
|
}
|
|
},
|
|
);
|
|
|
|
ipcMain.handle(
|
|
IPC.FILE_SYNC_LOAD,
|
|
async (
|
|
ev,
|
|
{
|
|
relativePath,
|
|
localRev,
|
|
}: {
|
|
relativePath: string;
|
|
localRev: string | null;
|
|
},
|
|
): Promise<{ rev: string; dataStr: string | undefined } | Error> => {
|
|
try {
|
|
const filePath = (await _resolveRelative(relativePath)).absolutePath;
|
|
log(IPC.FILE_SYNC_LOAD, {
|
|
hasLocalRev: !!localRev,
|
|
});
|
|
const dataStr = readFileSync(filePath, { encoding: 'utf-8' });
|
|
log('Local file sync load completed', {
|
|
dataLength: dataStr.length,
|
|
});
|
|
return {
|
|
rev: getRev(filePath),
|
|
dataStr,
|
|
};
|
|
} catch (e) {
|
|
error('Local file sync load failed', getSafeErrorMeta(e));
|
|
return createSafeIpcError(IPC.FILE_SYNC_LOAD, e);
|
|
}
|
|
},
|
|
);
|
|
|
|
ipcMain.handle(
|
|
IPC.FILE_SYNC_REMOVE,
|
|
async (
|
|
ev,
|
|
{
|
|
relativePath,
|
|
}: {
|
|
relativePath: string;
|
|
},
|
|
): Promise<void | Error> => {
|
|
try {
|
|
const filePath = (await _resolveRelative(relativePath)).absolutePath;
|
|
log(IPC.FILE_SYNC_REMOVE);
|
|
unlinkSync(filePath);
|
|
return;
|
|
} catch (e) {
|
|
error('Local file sync remove failed', getSafeErrorMeta(e));
|
|
return createSafeIpcError(IPC.FILE_SYNC_REMOVE, e);
|
|
}
|
|
},
|
|
);
|
|
|
|
ipcMain.handle(
|
|
IPC.CHECK_DIR_EXISTS,
|
|
async (
|
|
ev,
|
|
{
|
|
relativePath,
|
|
}: {
|
|
relativePath?: string;
|
|
},
|
|
): Promise<true | Error> => {
|
|
try {
|
|
// Default to the sync root itself (relativePath = '') so the legacy
|
|
// "is the configured sync folder reachable?" check still works.
|
|
const dirPath = (await _resolveRelative(relativePath)).absolutePath;
|
|
const dirEntries = readdirSync(dirPath);
|
|
log(IPC.CHECK_DIR_EXISTS, {
|
|
dirEntryCount: dirEntries.length,
|
|
});
|
|
return true;
|
|
} catch (e) {
|
|
error('Local file sync directory check failed', getSafeErrorMeta(e));
|
|
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.',
|
|
);
|
|
}
|
|
return createSafeIpcError(IPC.CHECK_DIR_EXISTS, e);
|
|
}
|
|
},
|
|
);
|
|
|
|
ipcMain.handle(
|
|
IPC.FILE_SYNC_LIST_FILES,
|
|
async (
|
|
ev,
|
|
{
|
|
relativePath,
|
|
}: {
|
|
relativePath?: string;
|
|
},
|
|
): Promise<string[] | Error> => {
|
|
try {
|
|
const dirPath = (await _resolveRelative(relativePath)).absolutePath;
|
|
return readdirSync(dirPath);
|
|
} catch (e) {
|
|
error('Local file sync list files failed', getSafeErrorMeta(e));
|
|
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.',
|
|
);
|
|
}
|
|
return createSafeIpcError(IPC.FILE_SYNC_LIST_FILES, e);
|
|
}
|
|
},
|
|
);
|
|
|
|
ipcMain.handle(IPC.PICK_DIRECTORY, async (): Promise<string | Error | undefined> => {
|
|
try {
|
|
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 || !filePaths[0]) {
|
|
return undefined;
|
|
}
|
|
// Persist main-side BEFORE returning the display string. If
|
|
// canonicalization or persistence fails (deleted between pick and
|
|
// commit, EACCES on userData, etc.), surface a safe error rather than
|
|
// a silent undefined — otherwise the renderer cannot distinguish
|
|
// failure from user-cancel and the user is left wondering why their
|
|
// pick didn't take.
|
|
return await setSyncFolderPath(filePaths[0]);
|
|
} catch (e) {
|
|
error('PICK_DIRECTORY failed to persist sync folder', getSafeErrorMeta(e));
|
|
return createSafeIpcError(IPC.PICK_DIRECTORY, e);
|
|
}
|
|
});
|
|
|
|
ipcMain.handle(IPC.GET_SYNC_FOLDER_PATH, async (): Promise<string | null> => {
|
|
return getSyncFolderPath();
|
|
});
|
|
|
|
ipcMain.handle(
|
|
IPC.IMAGE_PICK_AND_IMPORT,
|
|
async (): Promise<{ id: string; mimeType: string } | null | Error> => {
|
|
// SECURITY: the dialog + import are atomic and run together in main.
|
|
// The renderer never holds the absolute path — it cannot trigger an
|
|
// image read without a user clicking through the native picker.
|
|
// (The previous shape exposed an `importImage(path)` IPC that the
|
|
// renderer could call with any image-extension path it pleased; this
|
|
// version closes that gap.)
|
|
try {
|
|
const { canceled, filePaths } = (await dialog.showOpenDialog(getWin(), {
|
|
title: 'Select image',
|
|
buttonLabel: 'Select',
|
|
properties: ['openFile'],
|
|
filters: [
|
|
{
|
|
name: 'Images',
|
|
// Keep in sync with MIME_BY_EXT in image-cache.ts (svg excluded
|
|
// on purpose — scriptable format).
|
|
extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'avif'],
|
|
},
|
|
],
|
|
})) as unknown as { canceled: boolean; filePaths: string[] };
|
|
if (canceled || !filePaths[0]) {
|
|
// User cancelled — distinct from validation failure so the renderer
|
|
// can stay silent instead of showing a "couldn't read image" snack.
|
|
return null;
|
|
}
|
|
const imported = await importImage(filePaths[0]);
|
|
if (!imported) {
|
|
// Reject as an error: validation failed (extension, size, etc.) so
|
|
// the renderer surfaces the error path, not the cancel path. The
|
|
// safe-error wrapper below strips the stack and renames the error
|
|
// so it matches the FS handlers' contract — main bundle paths
|
|
// (from a raw `e.stack`) are not leaked.
|
|
throw new Error('Selected image could not be imported');
|
|
}
|
|
return imported;
|
|
} catch (e) {
|
|
error('Image pick-and-import failed', getSafeErrorMeta(e));
|
|
return createSafeIpcError(IPC.IMAGE_PICK_AND_IMPORT, e);
|
|
}
|
|
},
|
|
);
|
|
|
|
ipcMain.handle(
|
|
IPC.IMAGE_CACHE_GET_DATA_URL,
|
|
async (_, id: string): Promise<string | null> => {
|
|
return getImageDataUrl(id);
|
|
},
|
|
);
|
|
|
|
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();
|
|
};
|
|
|
|
const getSafeErrorMeta = (
|
|
e: unknown,
|
|
): {
|
|
errorName: string;
|
|
errorCode?: string | number;
|
|
} => {
|
|
const errorName =
|
|
e instanceof Error
|
|
? e.name
|
|
: typeof e === 'object' && e !== null && 'name' in e && typeof e.name === 'string'
|
|
? e.name
|
|
: 'UnknownError';
|
|
const errorCode =
|
|
typeof e === 'object' &&
|
|
e !== null &&
|
|
'code' in e &&
|
|
(typeof e.code === 'string' || typeof e.code === 'number')
|
|
? e.code
|
|
: undefined;
|
|
|
|
return errorCode === undefined ? { errorName } : { errorName, errorCode };
|
|
};
|
|
|
|
const createSafeIpcError = (operation: IPC, e: unknown): Error => {
|
|
const { errorName, errorCode } = getSafeErrorMeta(e);
|
|
const codeMessagePart = errorCode === undefined ? '' : ` (code: ${errorCode})`;
|
|
const safeError = new Error(`${operation} failed: ${errorName}${codeMessagePart}`, {
|
|
cause: { name: errorName, code: errorCode },
|
|
}) as Error & { code?: string | number };
|
|
safeError.name = errorName;
|
|
if (errorCode !== undefined) {
|
|
safeError.code = errorCode;
|
|
}
|
|
delete safeError.stack;
|
|
|
|
return safeError;
|
|
};
|