Skip to content
Merged
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
1 change: 1 addition & 0 deletions apps/desktop/electron-vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export default defineConfig({
rollupOptions: {
input: {
index: resolve(__dirname, 'src/renderer/index.html'),
settings: resolve(__dirname, 'src/renderer/settings.html'),
},
},
},
Expand Down
7 changes: 5 additions & 2 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,12 @@
"@readied/commands": "workspace:*",
"@readied/core": "workspace:*",
"@readied/embeds": "workspace:*",
"@readied/tasks": "workspace:*",
"@readied/wikilinks": "workspace:*",
"@readied/licensing": "workspace:*",
"@readied/product-config": "workspace:*",
"@readied/storage-core": "workspace:*",
"@readied/storage-sqlite": "workspace:*",
"@readied/tasks": "workspace:*",
"@readied/wikilinks": "workspace:*",
"@tanstack/react-query": "^5.90.16",
"better-sqlite3": "^11.7.0",
"electron-updater": "^6.6.2",
Expand All @@ -65,10 +65,13 @@
"@vitejs/plugin-react": "^4.2.1",
"electron": "^29.1.4",
"electron-builder": "^26.0.12",
"electron-devtools-installer": "^4.0.0",
"electron-vite": "^2.1.0",
"pino-pretty": "^13.1.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-force-graph-2d": "^1.29.0",
"rehype-raw": "^7.0.0",
"typescript": "^5.7.2",
"vite": "^5.4.11",
"vitest": "^2.1.8"
Expand Down
290 changes: 285 additions & 5 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@
* Initializes the app, database, and IPC handlers.
*/

import { join } from 'path';
import { readFile, writeFile, unlink } from 'fs/promises';
import { join, normalize } from 'path';
import { readFile, writeFile, unlink, mkdir } from 'fs/promises';
import { existsSync } from 'fs';
import { app, BrowserWindow, ipcMain, dialog, shell } from 'electron';
import { app, BrowserWindow, ipcMain, dialog, shell, protocol } from 'electron';
import { autoUpdater } from 'electron-updater';
import installExtension, { REACT_DEVELOPER_TOOLS } from 'electron-devtools-installer';
import {
runMigrations,
createDataPaths,
Expand Down Expand Up @@ -166,14 +167,96 @@ function createWindow(): void {
});

// Load renderer
if (process.env.NODE_ENV === 'development') {
mainWindow.loadURL('http://localhost:5173');
if (process.env.NODE_ENV === 'development' && process.env.ELECTRON_RENDERER_URL) {
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL);
mainWindow.webContents.openDevTools();
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'));
}
}

/** Create a new window for viewing a single note */
function createNoteWindow(noteId: string, noteTitle: string): void {
const noteWindow = new BrowserWindow({
width: 800,
height: 700,
minWidth: 500,
minHeight: 400,
show: false,
titleBarStyle: 'hiddenInset',
trafficLightPosition: { x: 8, y: 8 },
backgroundColor: '#0a0b0d',
title: noteTitle || 'Note',
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
nodeIntegration: false,
contextIsolation: true,
sandbox: false,
},
});

noteWindow.on('ready-to-show', () => {
noteWindow.show();
});

// Load renderer with note ID in query param
const query = `?noteWindow=${encodeURIComponent(noteId)}`;
if (process.env.NODE_ENV === 'development' && process.env.ELECTRON_RENDERER_URL) {
noteWindow.loadURL(`${process.env.ELECTRON_RENDERER_URL}${query}`);
} else {
noteWindow.loadFile(join(__dirname, '../renderer/index.html'), {
query: { noteWindow: noteId },
});
}
}

/** Settings window singleton */
let settingsWindow: BrowserWindow | null = null;

/** Create or focus the settings window */
function createSettingsWindow(): void {
// If window exists, focus it
if (settingsWindow && !settingsWindow.isDestroyed()) {
settingsWindow.focus();
return;
}

settingsWindow = new BrowserWindow({
width: 700,
height: 500,
minWidth: 500,
minHeight: 400,
show: false,
titleBarStyle: 'hiddenInset',
trafficLightPosition: { x: 8, y: 8 },
backgroundColor: '#0a0b0d',
title: 'Settings',
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
nodeIntegration: false,
contextIsolation: true,
sandbox: false,
},
});

settingsWindow.on('ready-to-show', () => {
settingsWindow?.show();
});

settingsWindow.on('closed', () => {
settingsWindow = null;
});

// Load settings page
if (process.env.NODE_ENV === 'development' && process.env.ELECTRON_RENDERER_URL) {
// Replace index.html with settings.html in the URL
const settingsUrl = process.env.ELECTRON_RENDERER_URL.replace(/\/?$/, '/settings.html');
settingsWindow.loadURL(settingsUrl);
} else {
settingsWindow.loadFile(join(__dirname, '../renderer/settings.html'));
}
}

/** Helper to convert a Note to a snapshot for IPC */
function noteToSnapshot(note: {
id: string;
Expand Down Expand Up @@ -414,6 +497,11 @@ function registerIpcHandlers(): void {
return { ok: true };
});

// Rename tag across all notes
ipcMain.handle('tags:rename', async (_event, oldName: string, newName: string) => {
return repo.renameTag(oldName, newName);
});

// ═══════════════════════════════════════════════════════════════════════════
// Links (Wikilinks / Backlinks)
// ═══════════════════════════════════════════════════════════════════════════
Expand All @@ -434,6 +522,145 @@ function registerIpcHandlers(): void {
return repo.getOutgoingLinks(createNoteId(noteId));
});

// Get graph data (all notes and links for visualization)
ipcMain.handle('links:graph', async () => {
try {
return repo.getGraphData();
} catch (error) {
console.error('Failed to get graph data:', error);
// Return empty data on error
return { nodes: [], edges: [] };
}
});

// ═══════════════════════════════════════════════════════════════════════════
// Window Management
// ═══════════════════════════════════════════════════════════════════════════

// Open a note in a new window
ipcMain.handle('window:openNote', async (_event, noteId: string, noteTitle: string) => {
createNoteWindow(noteId, noteTitle);
return { ok: true };
});

// Open settings window
ipcMain.handle('window:openSettings', async () => {
createSettingsWindow();
return { ok: true };
});

// ═══════════════════════════════════════════════════════════════════════════
// Embeds (File Resolution)
// ═══════════════════════════════════════════════════════════════════════════

// Resolve embed target to asset:// URL
ipcMain.handle('embeds:resolve', async (_event, target: string, noteId: string) => {
if (!dataPaths) return null;

// Build path to note's assets folder: /assets/{noteId}/{target}
const assetPath = join(dataPaths.assets, noteId, target);

// Check if file exists
if (existsSync(assetPath)) {
// Return asset:// URL with host (required for browser to recognize protocol)
return `asset://local/${noteId}/${target}`;
}

// File not found
return null;
});

// Batch resolve multiple embed targets (more efficient)
ipcMain.handle(
'embeds:resolveBatch',
async (_event, targets: string[], noteId: string): Promise<Record<string, string | null>> => {
if (!dataPaths) return {};

const result: Record<string, string | null> = {};
for (const target of targets) {
const assetPath = join(dataPaths.assets, noteId, target);
// Return asset:// URL with host (required for browser to recognize protocol)
result[target] = existsSync(assetPath) ? `asset://local/${noteId}/${target}` : null;
}
return result;
}
);

// Save asset (image/file) for a note via drag & drop or paste
ipcMain.handle(
'embeds:saveAsset',
async (
_event,
noteId: string,
mime: string,
bytes: ArrayBuffer,
originalName?: string
): Promise<{ ok: true; filename: string; relPath: string } | { ok: false; error: string }> => {
if (!dataPaths) {
return { ok: false, error: 'Data paths not initialized' };
}

// Validate noteId (non-empty, alphanumeric with hyphens/underscores)
if (!noteId || !/^[\w-]+$/.test(noteId)) {
return { ok: false, error: 'Invalid noteId' };
}

// Validate size (max 20MB)
const MAX_SIZE = 20 * 1024 * 1024;
if (bytes.byteLength > MAX_SIZE) {
return { ok: false, error: 'File too large (max 20MB)' };
}

// Derive extension from mime type
const mimeToExt: Record<string, string> = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/jpg': 'jpg',
'image/gif': 'gif',
'image/webp': 'webp',
'image/svg+xml': 'svg',
'image/bmp': 'bmp',
'image/ico': 'ico',
'video/mp4': 'mp4',
'video/webm': 'webm',
'video/quicktime': 'mov',
'audio/mpeg': 'mp3',
'audio/wav': 'wav',
'audio/ogg': 'ogg',
'application/pdf': 'pdf',
};

let ext = mimeToExt[mime];
if (!ext && originalName) {
// Fallback to originalName extension
const match = originalName.match(/\.([a-zA-Z0-9]+)$/);
ext = match?.[1]?.toLowerCase() ?? 'bin';
}
if (!ext) {
ext = 'bin';
}

// Generate unique filename: timestamp-random.ext
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 8);
const filename = `${timestamp}-${random}.${ext}`;

// Ensure note's assets directory exists
const noteAssetsDir = join(dataPaths.assets, noteId);
await mkdir(noteAssetsDir, { recursive: true });

// Write file
const assetPath = join(noteAssetsDir, filename);
await writeFile(assetPath, Buffer.from(bytes));

return {
ok: true,
filename,
relPath: `${noteId}/${filename}`,
};
}
);

// Count notes
ipcMain.handle('notes:count', async () => {
// Get all notes to compute counts
Expand Down Expand Up @@ -960,13 +1187,59 @@ function initAutoUpdater(): void {
}, 3000);
}

/**
* asset:// protocol
*
* Invariant:
* - Renderer NEVER accesses filesystem paths directly
* - All local assets are resolved via asset:// URLs
*
* Rationale:
* - Avoids file:// which is blocked in dev (http://localhost)
* - Same behavior in dev and production
* - Enables secure embeds (images, video, pdf)
*/
protocol.registerSchemesAsPrivileged([
{
scheme: 'asset',
privileges: {
secure: true,
standard: true,
supportFetchAPI: true,
stream: true,
},
},
]);

// App lifecycle
app
.whenReady()
.then(() => {
// Initialize data paths first (creates directories)
dataPaths = initDataPaths();

// Register asset:// protocol handler
protocol.registerFileProtocol('asset', (request, callback) => {
// asset://local/noteId/filename → assets/noteId/filename
// Strip protocol and host (local/)
let urlPath = decodeURIComponent(request.url.slice('asset://'.length));
if (urlPath.startsWith('local/')) {
urlPath = urlPath.slice('local/'.length);
}

// Sanitize: prevent path traversal attacks
const safePath = normalize(urlPath).replace(/^(\.\.[/\\])+/, '');

const filePath = join(dataPaths!.assets, safePath);

if (!existsSync(filePath)) {
callback({ error: -6 }); // FILE_NOT_FOUND
return;
}

callback({ path: filePath });
});

// Initialize logger (must be after dataPaths)
const log = initLogger({
logsDir: dataPaths.logs,
Expand All @@ -987,6 +1260,13 @@ app
registerLogHandlers();
log.info('All IPC handlers registered');

// Install React DevTools in development
if (process.env.NODE_ENV === 'development') {
installExtension(REACT_DEVELOPER_TOOLS)
.then(name => log.info({ extension: name }, 'DevTools extension installed'))
.catch(err => log.warn({ error: err.message }, 'Failed to install DevTools extension'));
}

// Create window and start auto-updater
createWindow();
initAutoUpdater();
Expand Down
Loading
Loading