import { existsSync } from 'node:fs' import { readdir, rename, readFile, writeFile, unlink } from 'node:fs/promises' import { dirname, join, resolve, normalize, isAbsolute } from 'node:path' import { pathToFileURL, fileURLToPath } from 'node:url' import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, shell, type MenuItemConstructorOptions, type NativeImage } from 'electron' import type { DirEntry, ListDirResult, OpenFileResult, OpenFolderResult, OpenImageResult, SaveDialogResult } from './ipc-types.js' const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) const MD_EXT = new Set(['.md', '.markdown', '.mdown', '.mkd']) function getPreloadPath(): string { const fromMainDir = join(__dirname, '..', 'preload') const fromAppRoot = join(app.getAppPath(), 'out', 'preload') const candidates = ['index.mjs', 'index.cjs', 'index.js'] for (const base of [fromMainDir, fromAppRoot]) { for (const name of candidates) { const p = resolve(base, name) if (existsSync(p)) return p } } return resolve(fromAppRoot, 'index.mjs') } function getRendererIndexHtml(): string { const fromMain = join(__dirname, '..', 'renderer', 'index.html') if (existsSync(fromMain)) return fromMain return join(app.getAppPath(), 'out', 'renderer', 'index.html') } function resolveAppIconPath(): string | undefined { const candidates = [ join(__dirname, '../../resources/app-icon.png'), join(app.getAppPath(), 'resources', 'app-icon.png'), join(process.cwd(), 'resources', 'app-icon.png') ] for (const p of candidates) { if (existsSync(p)) return p } return undefined } function isMarkdownFile(name: string): boolean { const lower = name.toLowerCase() const dot = lower.lastIndexOf('.') if (dot < 0) return false return MD_EXT.has(lower.slice(dot)) } /** 将 Markdown 内链(相对/绝对/file://)解析为本地绝对路径;外链原样返回。 */ function resolveMarkdownLinkHref(baseFilePath: string, href: string): string { let raw = href.trim() const hashIdx = raw.indexOf('#') if (hashIdx >= 0) raw = raw.slice(0, hashIdx) const queryIdx = raw.indexOf('?') if (queryIdx >= 0) raw = raw.slice(0, queryIdx) raw = raw.trim() if (!raw) return '' if (/^https?:\/\//i.test(raw) || /^mailto:/i.test(raw) || /^tel:/i.test(raw)) return raw if (raw.startsWith('file://')) { try { return normalize(fileURLToPath(raw)) } catch { return '' } } let decoded = raw try { decoded = decodeURIComponent(raw) } catch { decoded = raw } if (isAbsolute(decoded)) return normalize(decoded) return normalize(resolve(dirname(baseFilePath), decoded)) } ipcMain.handle( 'myreader:resolve-relative-path', (_evt, baseFilePath: string, href: string): string => { if (typeof baseFilePath !== 'string' || typeof href !== 'string') return '' if (!baseFilePath.trim()) return '' return resolveMarkdownLinkHref(baseFilePath, href) } ) ipcMain.handle('myreader:open-external', async (_evt, url: string): Promise => { if (typeof url !== 'string') return const u = url.trim() if (/^https?:\/\//i.test(u) || /^mailto:/i.test(u) || /^tel:/i.test(u)) { await shell.openExternal(u) } }) async function listDirectoryImpl(dirPath: string): Promise { const normalized = normalize(dirPath) try { const dirents = await readdir(normalized, { withFileTypes: true }) const entries: DirEntry[] = [] for (const d of dirents) { if (d.name.startsWith('.')) continue const full = join(normalized, d.name) if (d.isDirectory()) { entries.push({ name: d.name, path: full, kind: 'dir' }) } else if (d.isFile() && isMarkdownFile(d.name)) { entries.push({ name: d.name, path: full, kind: 'file' }) } } entries.sort((a, b) => { if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1 return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) }) return { entries } } catch (e) { const msg = e instanceof Error ? e.message : String(e) return { entries: [], error: msg } } } function sendMenu(win: BrowserWindow | null, action: string): void { const w = win ?? BrowserWindow.getFocusedWindow() w?.webContents.send('myreader:menu', action) } function buildMenu(): Menu { const template: MenuItemConstructorOptions[] = [ { label: '文件', submenu: [ { label: '新建标签', accelerator: 'CmdOrCtrl+T', click: () => sendMenu(null, 'tab-new') }, { label: '打开…', accelerator: 'CmdOrCtrl+O', click: () => sendMenu(null, 'file-open') }, { label: '保存', accelerator: 'CmdOrCtrl+S', click: () => sendMenu(null, 'file-save') }, { label: '另存为…', accelerator: 'CmdOrCtrl+Shift+S', click: () => sendMenu(null, 'file-save-as') }, { type: 'separator' }, { label: '关闭标签', accelerator: 'CmdOrCtrl+W', click: () => sendMenu(null, 'tab-close') }, { type: 'separator' }, { label: '退出', accelerator: process.platform === 'darwin' ? 'Cmd+Q' : 'Alt+F4', role: 'quit' } ] }, { label: '编辑', submenu: [ { label: '撤销', accelerator: 'CmdOrCtrl+Z', role: 'undo' }, { label: '重做', accelerator: process.platform === 'darwin' ? 'Cmd+Shift+Z' : 'Ctrl+Y', role: 'redo' }, { type: 'separator' }, { label: '剪切', accelerator: 'CmdOrCtrl+X', role: 'cut' }, { label: '复制', accelerator: 'CmdOrCtrl+C', role: 'copy' }, { label: '粘贴', accelerator: 'CmdOrCtrl+V', role: 'paste' }, { label: '全选', accelerator: 'CmdOrCtrl+A', role: 'selectAll' } ] }, { label: '视图', submenu: [ { label: '切换视图模式', accelerator: 'CmdOrCtrl+E', click: () => sendMenu(null, 'toggle-read') }, { label: '下一个标签', accelerator: 'Ctrl+Tab', click: () => sendMenu(null, 'tab-next') }, { label: '上一个标签', accelerator: 'Ctrl+Shift+Tab', click: () => sendMenu(null, 'tab-prev') } ] }, { label: '帮助', submenu: [{ label: "关于 Huangzhijun's Reader", click: () => sendMenu(null, 'help-about') }] } ] return Menu.buildFromTemplate(template) } function createWindow(): BrowserWindow { const preloadPath = getPreloadPath() const iconPath = resolveAppIconPath() let icon: NativeImage | undefined if (iconPath) { try { icon = nativeImage.createFromPath(iconPath) if (icon.isEmpty()) icon = undefined } catch { icon = undefined } } const win = new BrowserWindow({ width: 1200, height: 800, minWidth: 800, minHeight: 560, title: "Huangzhijun's Reader", ...(icon ? { icon } : {}), webPreferences: { preload: preloadPath, contextIsolation: true, sandbox: false, nodeIntegration: false, webSecurity: false } }) win.on('close', (e) => { const w = win as BrowserWindow & { __myreaderForceClose?: boolean } if (w.__myreaderForceClose) return e.preventDefault() win.webContents.send('myreader:request-close') }) if (process.env.ELECTRON_RENDERER_URL) { void win.loadURL(process.env.ELECTRON_RENDERER_URL) } else { void win.loadFile(getRendererIndexHtml()) } return win } ipcMain.handle('myreader:open-file-dialog', async (): Promise => { const win = BrowserWindow.getFocusedWindow() const { canceled, filePaths } = win ? await dialog.showOpenDialog(win, { properties: ['openFile'], filters: [{ name: 'Markdown', extensions: ['md', 'markdown', 'mdown', 'mkd', 'txt'] }] }) : await dialog.showOpenDialog({ properties: ['openFile'], filters: [{ name: 'Markdown', extensions: ['md', 'markdown', 'mdown', 'mkd', 'txt'] }] }) if (canceled || !filePaths?.[0]) return { canceled: true } const path = filePaths[0] const content = await readFile(path, 'utf8') return { canceled: false, path, content } }) ipcMain.handle('myreader:open-folder-dialog', async (): Promise => { const win = BrowserWindow.getFocusedWindow() const opts = { properties: ['openDirectory' as const] } const { canceled, filePaths } = win ? await dialog.showOpenDialog(win, opts) : await dialog.showOpenDialog(opts) if (canceled || !filePaths?.[0]) return { canceled: true } return { canceled: false, path: filePaths[0] } }) ipcMain.handle('myreader:open-image-dialog', async (event): Promise => { const win = BrowserWindow.fromWebContents(event.sender) ?? BrowserWindow.getFocusedWindow() const opts = { properties: ['openFile' as const], filters: [ { name: '图片', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif'] } ] } if (win) { win.focus() win.show() } const { canceled, filePaths } = win ? await dialog.showOpenDialog(win, opts) : await dialog.showOpenDialog(opts) if (canceled || !filePaths?.[0]) return { canceled: true } const path = filePaths[0] return { canceled: false, path, url: pathToFileURL(path).href } }) ipcMain.handle( 'myreader:save-dialog', async (_evt, defaultPath?: string): Promise => { const win = BrowserWindow.getFocusedWindow() const { canceled, filePath } = win ? await dialog.showSaveDialog(win, { defaultPath, filters: [{ name: 'Markdown', extensions: ['md'] }] }) : await dialog.showSaveDialog({ defaultPath, filters: [{ name: 'Markdown', extensions: ['md'] }] }) if (canceled || !filePath) return { canceled: true } return { canceled: false, path: filePath } } ) ipcMain.handle('myreader:read-file', async (_evt, filePath: string): Promise => { return readFile(filePath, 'utf8') }) ipcMain.handle( 'myreader:write-file', async (_evt, filePath: string, content: string): Promise => { await writeFile(filePath, content, 'utf8') } ) ipcMain.handle('myreader:list-directory', async (_evt, dirPath: string): Promise => { return listDirectoryImpl(dirPath) }) ipcMain.handle('myreader:parent-directory', (_evt, dirPath: string): string => { return dirname(dirPath) }) ipcMain.handle('myreader:default-directory', async (): Promise => { return app.getPath('documents') }) ipcMain.handle('myreader:delete-file', async (_evt, filePath: string): Promise => { if (typeof filePath !== 'string' || filePath.length === 0) { throw new Error('Invalid path') } await unlink(filePath) }) ipcMain.handle( 'myreader:rename-path', async (_evt, oldPath: string, newName: string): Promise<{ newPath: string }> => { if (typeof oldPath !== 'string' || typeof newName !== 'string') { throw new Error('Invalid arguments') } const clean = newName.replace(/[/\\]/g, '').trim() if (!clean) { throw new Error('Empty name') } const parent = dirname(oldPath) const newPath = join(parent, clean) if (normalize(newPath) === normalize(oldPath)) { return { newPath: oldPath } } await rename(oldPath, newPath) return { newPath } } ) ipcMain.on('myreader:allow-close', (event) => { const win = BrowserWindow.fromWebContents(event.sender) if (!win) return ;(win as BrowserWindow & { __myreaderForceClose?: boolean }).__myreaderForceClose = true win.close() }) app.whenReady().then(() => { Menu.setApplicationMenu(buildMenu()) createWindow() app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow() } }) }) app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit() } })