feat: Security
This commit is contained in:
@@ -20,6 +20,8 @@ export async function handleSaveMessage(command: SaveMessageCommand, dataSource:
|
||||
content: message.content,
|
||||
timestamp: message.timestamp,
|
||||
editedAt: message.editedAt ?? null,
|
||||
revision: message.revision ?? 0,
|
||||
headHash: message.headHash ?? null,
|
||||
isDeleted: message.isDeleted ? 1 : 0,
|
||||
replyToId: message.replyToId ?? null,
|
||||
linkMetadata: message.linkMetadata ? JSON.stringify(message.linkMetadata) : null
|
||||
|
||||
@@ -36,7 +36,8 @@ export async function handleUpdateMessage(command: UpdateMessageCommand, dataSou
|
||||
const nullableFields = [
|
||||
'channelId',
|
||||
'editedAt',
|
||||
'replyToId'
|
||||
'replyToId',
|
||||
'headHash'
|
||||
] as const;
|
||||
|
||||
for (const field of nullableFields) {
|
||||
@@ -44,8 +45,13 @@ export async function handleUpdateMessage(command: UpdateMessageCommand, dataSou
|
||||
entity[field] = updates[field] ?? null;
|
||||
}
|
||||
|
||||
if (updates.isDeleted !== undefined)
|
||||
if (updates.revision !== undefined) {
|
||||
existing.revision = updates.revision;
|
||||
}
|
||||
|
||||
if (updates.isDeleted !== undefined) {
|
||||
existing.isDeleted = updates.isDeleted ? 1 : 0;
|
||||
}
|
||||
|
||||
if (updates.linkMetadata !== undefined)
|
||||
existing.linkMetadata = updates.linkMetadata ? JSON.stringify(updates.linkMetadata) : null;
|
||||
|
||||
@@ -34,6 +34,8 @@ export function rowToMessage(row: MessageEntity, reactions: ReactionPayload[] =
|
||||
content: isDeleted ? DELETED_MESSAGE_CONTENT : row.content,
|
||||
timestamp: row.timestamp,
|
||||
editedAt: row.editedAt ?? undefined,
|
||||
revision: row.revision ?? 0,
|
||||
headHash: row.headHash ?? undefined,
|
||||
reactions: isDeleted ? [] : reactions,
|
||||
isDeleted,
|
||||
replyToId: row.replyToId ?? undefined,
|
||||
|
||||
@@ -57,6 +57,8 @@ export interface MessagePayload {
|
||||
content: string;
|
||||
timestamp: number;
|
||||
editedAt?: number;
|
||||
revision?: number;
|
||||
headHash?: string;
|
||||
reactions?: ReactionPayload[];
|
||||
isDeleted?: boolean;
|
||||
replyToId?: string;
|
||||
|
||||
@@ -41,4 +41,10 @@ export class MessageEntity {
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
linkMetadata!: string | null;
|
||||
|
||||
@Column('integer', { default: 0 })
|
||||
revision!: number;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
headHash!: string | null;
|
||||
}
|
||||
|
||||
@@ -7,13 +7,24 @@ import {
|
||||
afterEach
|
||||
} from 'vitest';
|
||||
|
||||
// Mock Electron modules before importing the module under test
|
||||
const mockGetSystemIdleTime = vi.fn(() => 0);
|
||||
const mockSend = vi.fn();
|
||||
const mockGetMainWindow = vi.fn(() => ({
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: mockSend }
|
||||
}));
|
||||
const {
|
||||
mockGetSystemIdleTime,
|
||||
mockSend,
|
||||
mockGetMainWindow
|
||||
} = vi.hoisted(() => {
|
||||
const send = vi.fn();
|
||||
const getSystemIdleTime = vi.fn(() => 0);
|
||||
const getMainWindow = vi.fn(() => ({
|
||||
isDestroyed: () => false,
|
||||
webContents: { send }
|
||||
}));
|
||||
|
||||
return {
|
||||
mockGetSystemIdleTime: getSystemIdleTime,
|
||||
mockSend: send,
|
||||
mockGetMainWindow: getMainWindow
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
powerMonitor: {
|
||||
|
||||
@@ -61,6 +61,8 @@ import {
|
||||
import { listRunningProcessNames } from '../process-list';
|
||||
import { detectActiveGame } from '../game-detection';
|
||||
import { collectAppMetricsSnapshot } from '../app-metrics';
|
||||
import { clearAllTokens } from '../api/auth-store';
|
||||
import { assertPathUnderUserData, grantPluginReadRoot, resolveReadablePath } from '../path-jail';
|
||||
|
||||
const DEFAULT_MIME_TYPE = 'application/octet-stream';
|
||||
const MAX_ACTIVE_DESKTOP_NOTIFICATIONS = 20;
|
||||
@@ -72,6 +74,19 @@ const FILE_CLIPBOARD_FORMATS = [
|
||||
'public.file-url',
|
||||
'FileNameW'
|
||||
] as const;
|
||||
|
||||
async function resolveUserDataFilePath(filePath: string): Promise<string | null> {
|
||||
return await resolveReadablePath(filePath);
|
||||
}
|
||||
|
||||
async function resolveWritableUserDataFilePath(filePath: string): Promise<string | null> {
|
||||
try {
|
||||
return await assertPathUnderUserData(filePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const MIME_TYPES_BY_EXTENSION: Record<string, string> = {
|
||||
'.7z': 'application/x-7z-compressed',
|
||||
'.aac': 'audio/aac',
|
||||
@@ -496,6 +511,10 @@ export function setupSystemHandlers(): void {
|
||||
ipcMain.handle('set-desktop-settings', async (_event, patch: Partial<DesktopSettings>) => {
|
||||
const snapshot = updateDesktopSettings(patch);
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'allowedSignalingServers')) {
|
||||
clearAllTokens();
|
||||
}
|
||||
|
||||
await synchronizeAutoStartSetting(snapshot.autoStart);
|
||||
updateCloseToTraySetting(snapshot.closeToTray);
|
||||
await handleDesktopSettingsChanged();
|
||||
@@ -565,6 +584,12 @@ export function setupSystemHandlers(): void {
|
||||
return false;
|
||||
}
|
||||
|
||||
const scopedDestination = await resolveWritableUserDataFilePath(destinationFilePath);
|
||||
|
||||
if (!scopedDestination) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = await fsp.stat(sourceFilePath);
|
||||
|
||||
@@ -572,7 +597,7 @@ export function setupSystemHandlers(): void {
|
||||
return false;
|
||||
}
|
||||
|
||||
await fsp.copyFile(sourceFilePath, destinationFilePath);
|
||||
await fsp.copyFile(sourceFilePath, scopedDestination);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -580,8 +605,14 @@ export function setupSystemHandlers(): void {
|
||||
});
|
||||
|
||||
ipcMain.handle('file-exists', async (_event, filePath: string) => {
|
||||
const scopedPath = await resolveUserDataFilePath(filePath);
|
||||
|
||||
if (!scopedPath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await fsp.access(filePath, fs.constants.F_OK);
|
||||
await fsp.access(scopedPath, fs.constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -589,26 +620,40 @@ export function setupSystemHandlers(): void {
|
||||
});
|
||||
|
||||
ipcMain.handle('get-file-url', async (_event, filePath: string) => {
|
||||
if (typeof filePath !== 'string' || !filePath.trim()) {
|
||||
const scopedPath = await resolveUserDataFilePath(filePath);
|
||||
|
||||
if (!scopedPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await fsp.access(filePath, fs.constants.F_OK);
|
||||
return pathToFileURL(filePath).toString();
|
||||
await fsp.access(scopedPath, fs.constants.F_OK);
|
||||
return pathToFileURL(scopedPath).toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('read-file', async (_event, filePath: string) => {
|
||||
const data = await fsp.readFile(filePath);
|
||||
const scopedPath = await resolveUserDataFilePath(filePath);
|
||||
|
||||
if (!scopedPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await fsp.readFile(scopedPath);
|
||||
|
||||
return data.toString('base64');
|
||||
});
|
||||
|
||||
ipcMain.handle('read-file-chunk', async (_event, filePath: string, start: number, end: number) => {
|
||||
const fileHandle = await fsp.open(filePath, 'r');
|
||||
const scopedPath = await resolveUserDataFilePath(filePath);
|
||||
|
||||
if (!scopedPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileHandle = await fsp.open(scopedPath, 'r');
|
||||
|
||||
try {
|
||||
const safeStart = Math.max(0, Math.trunc(start));
|
||||
@@ -623,7 +668,13 @@ export function setupSystemHandlers(): void {
|
||||
});
|
||||
|
||||
ipcMain.handle('get-file-size', async (_event, filePath: string) => {
|
||||
const stats = await fsp.stat(filePath);
|
||||
const scopedPath = await resolveUserDataFilePath(filePath);
|
||||
|
||||
if (!scopedPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stats = await fsp.stat(scopedPath);
|
||||
|
||||
return stats.size;
|
||||
});
|
||||
@@ -632,23 +683,47 @@ export function setupSystemHandlers(): void {
|
||||
return await readClipboardFiles();
|
||||
});
|
||||
|
||||
ipcMain.handle('grant-plugin-read-root', (_event, rootPath: string) => {
|
||||
grantPluginReadRoot(rootPath);
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('write-file', async (_event, filePath: string, base64Data: string) => {
|
||||
const scopedPath = await resolveWritableUserDataFilePath(filePath);
|
||||
|
||||
if (!scopedPath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(base64Data, 'base64');
|
||||
|
||||
await fsp.writeFile(filePath, buffer);
|
||||
await fsp.writeFile(scopedPath, buffer);
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('append-file', async (_event, filePath: string, base64Data: string) => {
|
||||
const scopedPath = await resolveWritableUserDataFilePath(filePath);
|
||||
|
||||
if (!scopedPath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(base64Data, 'base64');
|
||||
|
||||
await fsp.appendFile(filePath, buffer);
|
||||
await fsp.appendFile(scopedPath, buffer);
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('delete-file', async (_event, filePath: string) => {
|
||||
const scopedPath = await resolveWritableUserDataFilePath(filePath);
|
||||
|
||||
if (!scopedPath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await fsp.unlink(filePath);
|
||||
await fsp.unlink(scopedPath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ((error as { code?: string }).code === 'ENOENT') {
|
||||
@@ -683,7 +758,14 @@ export function setupSystemHandlers(): void {
|
||||
cancelled: false };
|
||||
}
|
||||
|
||||
const stats = await fsp.stat(sourceFilePath);
|
||||
const scopedSourcePath = await resolveUserDataFilePath(sourceFilePath);
|
||||
|
||||
if (!scopedSourcePath) {
|
||||
return { saved: false,
|
||||
cancelled: false };
|
||||
}
|
||||
|
||||
const stats = await fsp.stat(scopedSourcePath);
|
||||
|
||||
if (!stats.isFile()) {
|
||||
return { saved: false,
|
||||
@@ -691,7 +773,7 @@ export function setupSystemHandlers(): void {
|
||||
}
|
||||
|
||||
const result = await dialog.showSaveDialog({
|
||||
defaultPath: defaultFileName || path.basename(sourceFilePath)
|
||||
defaultPath: defaultFileName || path.basename(scopedSourcePath)
|
||||
});
|
||||
|
||||
if (result.canceled || !result.filePath) {
|
||||
@@ -699,7 +781,7 @@ export function setupSystemHandlers(): void {
|
||||
cancelled: true };
|
||||
}
|
||||
|
||||
await fsp.copyFile(sourceFilePath, result.filePath);
|
||||
await fsp.copyFile(scopedSourcePath, result.filePath);
|
||||
|
||||
return { saved: true,
|
||||
cancelled: false };
|
||||
@@ -711,15 +793,22 @@ export function setupSystemHandlers(): void {
|
||||
reason: 'missing-path' };
|
||||
}
|
||||
|
||||
const scopedPath = await resolveUserDataFilePath(filePath);
|
||||
|
||||
if (!scopedPath) {
|
||||
return { opened: false,
|
||||
reason: 'outside-app-data' };
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = await fsp.stat(filePath);
|
||||
const stats = await fsp.stat(scopedPath);
|
||||
|
||||
if (!stats.isFile()) {
|
||||
return { opened: false,
|
||||
reason: 'not-a-file' };
|
||||
}
|
||||
|
||||
const error = await shell.openPath(filePath);
|
||||
const error = await shell.openPath(scopedPath);
|
||||
|
||||
return error
|
||||
? { opened: false,
|
||||
@@ -732,7 +821,13 @@ export function setupSystemHandlers(): void {
|
||||
});
|
||||
|
||||
ipcMain.handle('ensure-dir', async (_event, dirPath: string) => {
|
||||
await fsp.mkdir(dirPath, { recursive: true });
|
||||
const scopedPath = await resolveWritableUserDataFilePath(dirPath);
|
||||
|
||||
if (!scopedPath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await fsp.mkdir(scopedPath, { recursive: true });
|
||||
return true;
|
||||
});
|
||||
|
||||
|
||||
15
electron/migrations/1000000000013-MessageIntegrity.ts
Normal file
15
electron/migrations/1000000000013-MessageIntegrity.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class MessageIntegrity1000000000013 implements MigrationInterface {
|
||||
name = 'MessageIntegrity1000000000013';
|
||||
|
||||
async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query('ALTER TABLE "messages" ADD COLUMN "revision" integer NOT NULL DEFAULT 0');
|
||||
await queryRunner.query('ALTER TABLE "messages" ADD COLUMN "headHash" text');
|
||||
}
|
||||
|
||||
async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query('ALTER TABLE "messages" DROP COLUMN "headHash"');
|
||||
await queryRunner.query('ALTER TABLE "messages" DROP COLUMN "revision"');
|
||||
}
|
||||
}
|
||||
70
electron/path-jail.spec.ts
Normal file
70
electron/path-jail.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeEach,
|
||||
afterEach
|
||||
} from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
assertPathUnderRoot,
|
||||
clearGrantedPluginReadRoots,
|
||||
grantPluginReadRoot,
|
||||
resolveReadablePath
|
||||
} from './path-jail';
|
||||
|
||||
describe('path-jail', () => {
|
||||
let tempRoot = '';
|
||||
|
||||
beforeEach(() => {
|
||||
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'metoyou-path-jail-'));
|
||||
fs.mkdirSync(path.join(tempRoot, 'server', 'room-1'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tempRoot, 'server', 'room-1', 'file.txt'), 'ok');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearGrantedPluginReadRoots();
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('accepts paths inside allowed subdirectories', async () => {
|
||||
const allowedPath = path.join(tempRoot, 'server', 'room-1', 'file.txt');
|
||||
|
||||
await expect(assertPathUnderRoot(tempRoot, allowedPath, ['server'])).resolves.toBe(allowedPath);
|
||||
});
|
||||
|
||||
it('accepts cached plugin bundle paths under plugin-bundles', async () => {
|
||||
const bundleDir = path.join(tempRoot, 'plugin-bundles', 'example.plugin', '1.0.0');
|
||||
fs.mkdirSync(bundleDir, { recursive: true });
|
||||
const bundlePath = path.join(bundleDir, 'main.js');
|
||||
fs.writeFileSync(bundlePath, 'export default {}');
|
||||
|
||||
await expect(assertPathUnderRoot(tempRoot, bundlePath)).resolves.toBe(bundlePath);
|
||||
});
|
||||
|
||||
it('rejects paths outside the user data root', async () => {
|
||||
const outsidePath = path.join(os.tmpdir(), 'outside.txt');
|
||||
|
||||
await expect(assertPathUnderRoot(tempRoot, outsidePath, ['server'])).rejects.toThrow('outside allowed app-data paths');
|
||||
});
|
||||
|
||||
it('rejects paths outside allowed subdirectories', async () => {
|
||||
const pluginsPath = path.join(tempRoot, 'plugins', 'evil.txt');
|
||||
|
||||
await expect(assertPathUnderRoot(tempRoot, pluginsPath, ['server'])).rejects.toThrow('outside allowed app-data paths');
|
||||
});
|
||||
|
||||
it('allows user-granted plugin source roots outside app data', async () => {
|
||||
const externalRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'metoyou-plugin-source-'));
|
||||
const manifestPath = path.join(externalRoot, 'plugin-source.json');
|
||||
fs.writeFileSync(manifestPath, '{}');
|
||||
|
||||
grantPluginReadRoot(externalRoot);
|
||||
|
||||
await expect(resolveReadablePath(manifestPath)).resolves.toBe(manifestPath);
|
||||
|
||||
fs.rmSync(externalRoot, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
108
electron/path-jail.ts
Normal file
108
electron/path-jail.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { app } from 'electron';
|
||||
import * as fsp from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
export const DEFAULT_USER_DATA_SUBDIRS = [
|
||||
'server',
|
||||
'direct-messages',
|
||||
'plugins',
|
||||
'plugin-bundles',
|
||||
'plugin-cache',
|
||||
'themes',
|
||||
'metoyou'
|
||||
] as const;
|
||||
|
||||
export function isPathInside(parentPath: string, candidatePath: string): boolean {
|
||||
const relativePath = path.relative(parentPath, candidatePath);
|
||||
|
||||
return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath));
|
||||
}
|
||||
|
||||
async function realpathOrSelf(filePath: string): Promise<string> {
|
||||
try {
|
||||
return await fsp.realpath(filePath);
|
||||
} catch {
|
||||
return path.resolve(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAllowedSubdirs(allowedSubdirs: readonly string[]): string[] {
|
||||
return allowedSubdirs.map((entry) => entry.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '')).filter(Boolean);
|
||||
}
|
||||
|
||||
export async function assertPathUnderRoot(
|
||||
rootPath: string,
|
||||
candidatePath: string,
|
||||
allowedSubdirs: readonly string[] = DEFAULT_USER_DATA_SUBDIRS
|
||||
): Promise<string> {
|
||||
if (typeof candidatePath !== 'string' || !candidatePath.trim()) {
|
||||
throw new Error('Invalid file path');
|
||||
}
|
||||
|
||||
const [realRoot, realCandidate] = await Promise.all([realpathOrSelf(rootPath), realpathOrSelf(candidatePath)]);
|
||||
|
||||
if (!isPathInside(realRoot, realCandidate)) {
|
||||
throw new Error('Path is outside allowed app-data paths');
|
||||
}
|
||||
|
||||
const relativePath = path.relative(realRoot, realCandidate).replace(/\\/g, '/');
|
||||
const [topLevelSegment] = relativePath.split('/');
|
||||
|
||||
if (!topLevelSegment || !normalizeAllowedSubdirs(allowedSubdirs).includes(topLevelSegment)) {
|
||||
throw new Error('Path is outside allowed app-data paths');
|
||||
}
|
||||
|
||||
return realCandidate;
|
||||
}
|
||||
|
||||
export async function assertPathUnderUserData(
|
||||
candidatePath: string,
|
||||
allowedSubdirs: readonly string[] = DEFAULT_USER_DATA_SUBDIRS
|
||||
): Promise<string> {
|
||||
return assertPathUnderRoot(app.getPath('userData'), candidatePath, allowedSubdirs);
|
||||
}
|
||||
|
||||
const grantedPluginReadRoots = new Set<string>();
|
||||
|
||||
export function grantPluginReadRoot(rootPath: string): void {
|
||||
if (typeof rootPath !== 'string' || !rootPath.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
grantedPluginReadRoots.add(path.resolve(rootPath));
|
||||
}
|
||||
|
||||
export function clearGrantedPluginReadRoots(): void {
|
||||
grantedPluginReadRoots.clear();
|
||||
}
|
||||
|
||||
async function assertPathUnderGrantedPluginRoot(candidatePath: string): Promise<string> {
|
||||
const realCandidate = await realpathOrSelf(candidatePath);
|
||||
|
||||
for (const rootPath of grantedPluginReadRoots) {
|
||||
const realRoot = await realpathOrSelf(rootPath);
|
||||
|
||||
if (isPathInside(realRoot, realCandidate)) {
|
||||
return realCandidate;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Path is outside allowed app-data paths');
|
||||
}
|
||||
|
||||
/** Resolves readable paths under app data or user-granted plugin source roots. */
|
||||
export async function resolveReadablePath(candidatePath: string): Promise<string | null> {
|
||||
if (typeof candidatePath !== 'string' || !candidatePath.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await assertPathUnderUserData(candidatePath);
|
||||
} catch {
|
||||
try {
|
||||
return await assertPathUnderGrantedPluginRoot(candidatePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -314,9 +314,10 @@ export interface ElectronAPI {
|
||||
relaunchApp: () => Promise<boolean>;
|
||||
onDeepLinkReceived: (listener: (url: string) => void) => () => void;
|
||||
readClipboardFiles: () => Promise<ClipboardFilePayload[]>;
|
||||
readFile: (filePath: string) => Promise<string>;
|
||||
readFileChunk: (filePath: string, start: number, end: number) => Promise<string>;
|
||||
getFileSize: (filePath: string) => Promise<number>;
|
||||
readFile: (filePath: string) => Promise<string | null>;
|
||||
readFileChunk: (filePath: string, start: number, end: number) => Promise<string | null>;
|
||||
getFileSize: (filePath: string) => Promise<number | null>;
|
||||
grantPluginReadRoot: (rootPath: string) => Promise<boolean>;
|
||||
writeFile: (filePath: string, data: string) => Promise<boolean>;
|
||||
appendFile: (filePath: string, data: string) => Promise<boolean>;
|
||||
saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>;
|
||||
@@ -451,6 +452,7 @@ const electronAPI: ElectronAPI = {
|
||||
readFile: (filePath) => ipcRenderer.invoke('read-file', filePath),
|
||||
readFileChunk: (filePath, start, end) => ipcRenderer.invoke('read-file-chunk', filePath, start, end),
|
||||
getFileSize: (filePath) => ipcRenderer.invoke('get-file-size', filePath),
|
||||
grantPluginReadRoot: (rootPath) => ipcRenderer.invoke('grant-plugin-read-root', rootPath),
|
||||
writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data),
|
||||
appendFile: (filePath, data) => ipcRenderer.invoke('append-file', filePath, data),
|
||||
saveFileAs: (defaultFileName, data) => ipcRenderer.invoke('save-file-as', defaultFileName, data),
|
||||
|
||||
Reference in New Issue
Block a user