2026-05-15 16:35:31 +08:00
|
|
|
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<void> => {
|
|
|
|
|
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<ListDirResult> {
|
|
|
|
|
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<OpenFileResult> => {
|
|
|
|
|
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<OpenFolderResult> => {
|
|
|
|
|
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<OpenImageResult> => {
|
|
|
|
|
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<SaveDialogResult> => {
|
|
|
|
|
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<string> => {
|
|
|
|
|
return readFile(filePath, 'utf8')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
ipcMain.handle(
|
|
|
|
|
'myreader:write-file',
|
|
|
|
|
async (_evt, filePath: string, content: string): Promise<void> => {
|
|
|
|
|
await writeFile(filePath, content, 'utf8')
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
ipcMain.handle('myreader:list-directory', async (_evt, dirPath: string): Promise<ListDirResult> => {
|
|
|
|
|
return listDirectoryImpl(dirPath)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
ipcMain.handle('myreader:parent-directory', (_evt, dirPath: string): string => {
|
|
|
|
|
return dirname(dirPath)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
ipcMain.handle('myreader:default-directory', async (): Promise<string> => {
|
|
|
|
|
return app.getPath('documents')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
ipcMain.handle('myreader:delete-file', async (_evt, filePath: string): Promise<void> => {
|
|
|
|
|
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()
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-18 14:13:16 +08:00
|
|
|
let pendingOpenPaths: string[] = []
|
|
|
|
|
|
2026-05-15 16:35:31 +08:00
|
|
|
app.whenReady().then(() => {
|
|
|
|
|
Menu.setApplicationMenu(buildMenu())
|
2026-05-18 14:13:16 +08:00
|
|
|
|
|
|
|
|
const args = process.argv.slice(1)
|
|
|
|
|
for (const arg of args) {
|
|
|
|
|
if (existsSync(arg) && isMarkdownFile(arg)) {
|
|
|
|
|
pendingOpenPaths.push(arg)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const win = createWindow()
|
|
|
|
|
|
|
|
|
|
win.webContents.on('dom-ready', () => {
|
|
|
|
|
if (pendingOpenPaths.length > 0) {
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
win.webContents.send('myreader:open-files', pendingOpenPaths)
|
|
|
|
|
pendingOpenPaths = []
|
|
|
|
|
}, 500)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-15 16:35:31 +08:00
|
|
|
app.on('activate', () => {
|
|
|
|
|
if (BrowserWindow.getAllWindows().length === 0) {
|
|
|
|
|
createWindow()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
app.on('window-all-closed', () => {
|
|
|
|
|
if (process.platform !== 'darwin') {
|
|
|
|
|
app.quit()
|
|
|
|
|
}
|
|
|
|
|
})
|