Compare commits
29 Commits
Author | SHA1 | Date |
---|---|---|
|
e969ec2236 | 2 days ago |
|
a9de1ec525 | 4 days ago |
|
fcad3681b4 | 1 month ago |
|
f19c097001 | 1 month ago |
|
81f76353f6 | 2 months ago |
|
068777d914 | 2 months ago |
|
34a762ad2c | 2 months ago |
|
23893adc43 | 2 months ago |
|
6692e16720 | 2 months ago |
|
de5f511d6a | 2 months ago |
|
d4be8b22b9 | 2 months ago |
|
c142937af9 | 2 months ago |
|
950bfe9060 | 2 months ago |
|
7035429775 | 2 months ago |
|
05f83e2a08 | 2 months ago |
|
fa6ef80493 | 2 months ago |
|
0f093b2ef9 | 2 months ago |
|
80cc4fe0fe | 2 months ago |
|
28eea56a3d | 2 months ago |
|
b6964f5fbe | 2 months ago |
|
7246ab2d9a | 2 months ago |
|
bd9ac214c6 | 2 months ago |
|
dcdc4aa857 | 2 months ago |
|
2d5a57853d | 2 months ago |
|
3c434df31c | 3 months ago |
|
b4b975174d | 3 months ago |
|
91f06eb4a1 | 3 months ago |
|
248716be69 | 3 months ago |
|
ca363ceac9 | 3 months ago |
141 changed files with 8859 additions and 3692 deletions
@ -1,3 +1,3 @@ |
|||||
{ |
{ |
||||
"recommendations": ["dbaeumer.vscode-eslint"] |
"recommendations": ["dbaeumer.vscode-eslint", "lokalise.i18n-ally"] |
||||
} |
} |
||||
|
@ -1,35 +1,52 @@ |
|||||
interface IConfig { |
import { LogLevel } from "logger/common" |
||||
app_title: string |
// 定义主题类型
|
||||
default_config: { |
type ThemeType = "light" | "dark" | "auto" |
||||
language: "zh" | "en" // i18n
|
// 定义语言类型
|
||||
"common.theme": "light" | "dark" | "auto" // 主题
|
type LanguageType = "zh" | "en" |
||||
|
// 定义编辑器logo类型
|
||||
|
type LogoType = "logo" | "bg" |
||||
|
|
||||
|
// 配置接口定义
|
||||
|
export interface IDefaultConfig { |
||||
|
language: LanguageType |
||||
|
"common.theme": ThemeType |
||||
|
debug: LogLevel |
||||
"desktop:wallpaper": string |
"desktop:wallpaper": string |
||||
"update.repo"?: string // 更新地址
|
"update.hoturl": string |
||||
"update.owner"?: string // 更新通道
|
"update.repo"?: string |
||||
|
"update.owner"?: string |
||||
"update.allowDowngrade": boolean |
"update.allowDowngrade": boolean |
||||
"update.allowPrerelease": boolean |
"update.allowPrerelease": boolean |
||||
"editor.bg": string // 更新通道
|
"editor.bg": string |
||||
"editor.logoType": "logo" | "bg" // 更新通道
|
"editor.logoType": LogoType |
||||
"editor.fontFamily": string // 更新通道
|
"editor.fontFamily": string |
||||
// "snippet.storagePath": string // 代码片段保存位置
|
"snippet.storagePath": string |
||||
// "bookmark.storagePath": string // 书签保存位置
|
storagePath: string |
||||
// backup_rule: string // 备份规则
|
|
||||
storagePath: string // 存储地址
|
|
||||
} |
} |
||||
|
|
||||
|
interface IConfig { |
||||
|
app_title: string |
||||
|
default_config: IDefaultConfig |
||||
} |
} |
||||
|
|
||||
|
// 默认配置导出
|
||||
export default { |
export default { |
||||
app_title: "zephyr", // 和风
|
app_title: "zephyr", // 和风
|
||||
default_config: { |
default_config: { |
||||
storagePath: "$storagePath$", |
storagePath: "$storagePath$", |
||||
language: "zh", |
language: "zh", |
||||
|
debug: LogLevel.INFO, |
||||
"common.theme": "auto", |
"common.theme": "auto", |
||||
"desktop:wallpaper": "", |
"desktop:wallpaper": "", |
||||
"editor.bg": "", |
"editor.bg": "", |
||||
"editor.logoType": "logo", |
"editor.logoType": "logo", |
||||
"editor.fontFamily": "Cascadia Mono, Consolas, 'Courier New', monospace", |
"editor.fontFamily": "Cascadia Mono, Consolas, 'Courier New', monospace", |
||||
|
"update.hoturl": |
||||
|
"https://alist.xieyaxin.top/d/%E8%B5%84%E6%BA%90/%E6%B5%8B%E8%AF%95%E6%96%87%E4%BB%B6.zip?sign=eqy35CR-J1SOQZz0iUN2P3B0BiyZPdYH0362nLXbUhE=:1749085071", |
||||
"update.repo": "wood-desktop", |
"update.repo": "wood-desktop", |
||||
"update.owner": "npmrun", |
"update.owner": "npmrun", |
||||
"update.allowDowngrade": false, |
"update.allowDowngrade": false, |
||||
"update.allowPrerelease": false, |
"update.allowPrerelease": false, |
||||
|
"snippet.storagePath": "$storagePath$/snippets", |
||||
}, |
}, |
||||
} as IConfig |
} as const satisfies IConfig |
||||
|
@ -0,0 +1,123 @@ |
|||||
|
type FireFN = (...argu: any[]) => void |
||||
|
|
||||
|
// 监听器类型定义,支持优先级
|
||||
|
interface Listener<F extends FireFN> { |
||||
|
fn: F |
||||
|
once: boolean |
||||
|
priority: number |
||||
|
} |
||||
|
|
||||
|
class FireEvent<T extends Record<string | symbol, FireFN>> { |
||||
|
// 使用 Map 存储事件监听器,支持 symbol 键
|
||||
|
private events = new Map<keyof T, Array<Listener<T[keyof T]>>>() |
||||
|
|
||||
|
// 获取事件监听器列表,如果不存在则创建
|
||||
|
private getListeners<S extends keyof T>(name: S): Array<Listener<T[S]>> { |
||||
|
if (!this.events.has(name)) { |
||||
|
this.events.set(name, []) |
||||
|
} |
||||
|
return this.events.get(name) as Array<Listener<T[S]>> |
||||
|
} |
||||
|
|
||||
|
// 按优先级排序监听器
|
||||
|
private sortListeners<S extends keyof T>(name: S) { |
||||
|
const listeners = this.getListeners(name) |
||||
|
listeners.sort((a, b) => b.priority - a.priority) |
||||
|
} |
||||
|
|
||||
|
// 打印事件和监听器信息
|
||||
|
print() { |
||||
|
console.log("Registered Events:") |
||||
|
this.events.forEach((listeners, name) => { |
||||
|
// 显式处理 symbol 类型
|
||||
|
const keyType = typeof name === "symbol" ? `Symbol(${name.description || ""})` : String(name) |
||||
|
console.log(` ${keyType}: ${listeners.length} listeners`) |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
// 添加事件监听器,支持优先级
|
||||
|
on<S extends keyof T>(name: S, fn: T[S], priority = 0): this { |
||||
|
const listeners = this.getListeners(name) |
||||
|
listeners.push({ fn, once: false, priority }) |
||||
|
this.sortListeners(name) |
||||
|
return this // 支持链式调用
|
||||
|
} |
||||
|
|
||||
|
// 触发事件
|
||||
|
emit<S extends keyof T>(name: S, ...args: Parameters<T[S]>): this { |
||||
|
const listeners = this.getListeners(name).slice() // 创建副本以避免移除时的问题
|
||||
|
|
||||
|
for (const { fn } of listeners) { |
||||
|
try { |
||||
|
fn(...args) |
||||
|
} catch (error) { |
||||
|
console.error(`Error in event handler for ${String(name)}:`, error) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 移除一次性监听器
|
||||
|
if (listeners.some(l => l.once)) { |
||||
|
this.events.set( |
||||
|
name, |
||||
|
this.getListeners(name).filter(l => !l.once), |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
return this |
||||
|
} |
||||
|
|
||||
|
// 移除事件监听器
|
||||
|
off<S extends keyof T>(name: S, fn?: T[S]): this { |
||||
|
if (!this.events.has(name)) return this |
||||
|
|
||||
|
const listeners = this.getListeners(name) |
||||
|
|
||||
|
if (!fn) { |
||||
|
// 移除所有监听器
|
||||
|
this.events.delete(name) |
||||
|
} else { |
||||
|
// 移除特定监听器
|
||||
|
const filtered = listeners.filter(l => l.fn !== fn) |
||||
|
if (filtered.length === 0) { |
||||
|
this.events.delete(name) |
||||
|
} else { |
||||
|
this.events.set(name, filtered) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return this |
||||
|
} |
||||
|
|
||||
|
// 添加一次性事件监听器
|
||||
|
once<S extends keyof T>(name: S, fn: T[S], priority = 0): this { |
||||
|
const listeners = this.getListeners(name) |
||||
|
listeners.push({ fn, once: true, priority }) |
||||
|
this.sortListeners(name) |
||||
|
return this |
||||
|
} |
||||
|
|
||||
|
// 清除所有事件监听器
|
||||
|
clear(): this { |
||||
|
this.events.clear() |
||||
|
return this |
||||
|
} |
||||
|
|
||||
|
// 获取指定事件的监听器数量
|
||||
|
listenerCount<S extends keyof T>(name: S): number { |
||||
|
return this.events.get(name)?.length || 0 |
||||
|
} |
||||
|
|
||||
|
// 检查事件是否有监听器
|
||||
|
hasListeners<S extends keyof T>(name: S): boolean { |
||||
|
return this.listenerCount(name) > 0 |
||||
|
} |
||||
|
|
||||
|
// 获取所有事件名称
|
||||
|
eventNames(): Array<keyof T> { |
||||
|
return Array.from(this.events.keys()) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export function buildEmitter<T extends Record<string | symbol, FireFN>>() { |
||||
|
return new FireEvent<T>() |
||||
|
} |
@ -0,0 +1,51 @@ |
|||||
|
// type FireKey = string
|
||||
|
type FireFN = (...argu: any[]) => void |
||||
|
|
||||
|
class FireEvent<T extends Record<string | symbol, FireFN>> { |
||||
|
#events: Record<keyof T, FireFN[]> = {} as any |
||||
|
print() { |
||||
|
Object.keys(this.#events).forEach(key => { |
||||
|
console.log(`${key}: ${this.#events[key]}\n`) |
||||
|
}) |
||||
|
} |
||||
|
on<S extends keyof T>(name: S, fn: T[S]) { |
||||
|
if (!this.#events[name]) { |
||||
|
this.#events[name] = [] |
||||
|
} |
||||
|
this.#events[name].push(fn) |
||||
|
} |
||||
|
emit<S extends keyof T>(name: S, ...argu: Parameters<T[S]>) { |
||||
|
if (this.#events[name]) { |
||||
|
this.#events[name].forEach(fn => { |
||||
|
fn(...argu) |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
off<S extends keyof T>(name: S, fn?: T[S]) { |
||||
|
const len = this.#events[name].length |
||||
|
if (!len) { |
||||
|
return |
||||
|
} |
||||
|
if (!fn) { |
||||
|
this.#events[name] = [] |
||||
|
} else { |
||||
|
for (let i = len - 1; i >= 0; i--) { |
||||
|
const _fn = this.#events[name][i] |
||||
|
if (_fn === fn) { |
||||
|
this.#events[name].splice(i, 1) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
once<S extends keyof T>(name: S, fn: T[S]) { |
||||
|
const _fn: any = (...argu: any[]) => { |
||||
|
fn(...argu) |
||||
|
this.off<S>(name, _fn) |
||||
|
} |
||||
|
this.on(name, _fn) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export function buildEmitter<T extends Record<string | symbol, FireFN>>() { |
||||
|
return new FireEvent<T>() |
||||
|
} |
@ -0,0 +1,27 @@ |
|||||
|
// 抽象基类,使用泛型来正确推导子类类型
|
||||
|
abstract class BaseSingleton { |
||||
|
private static _instance: any |
||||
|
|
||||
|
public constructor() { |
||||
|
if (this.constructor === BaseSingleton) { |
||||
|
throw new Error("禁止直接实例化 BaseOne 抽象类") |
||||
|
} |
||||
|
|
||||
|
if ((this.constructor as any)._instance) { |
||||
|
throw new Error("构造函数私有化失败,禁止重复 new") |
||||
|
} |
||||
|
|
||||
|
// this.constructor 是子类,所以这里设为 instance
|
||||
|
;(this.constructor as any)._instance = this |
||||
|
} |
||||
|
|
||||
|
public static getInstance<T extends BaseSingleton>(this: new () => T): T { |
||||
|
const clazz = this as any as typeof BaseSingleton |
||||
|
if (!clazz._instance) { |
||||
|
clazz._instance = new this() |
||||
|
} |
||||
|
return clazz._instance as T |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export { BaseSingleton } |
@ -0,0 +1,13 @@ |
|||||
|
{ |
||||
|
"name": "base", |
||||
|
"version": "1.0.0", |
||||
|
"description": "", |
||||
|
"main": "index.js", |
||||
|
"scripts": { |
||||
|
"test": "echo \"Error: no test specified\" && exit 1" |
||||
|
}, |
||||
|
"keywords": [], |
||||
|
"author": "", |
||||
|
"license": "ISC", |
||||
|
"packageManager": "pnpm@10.4.1" |
||||
|
} |
@ -0,0 +1,13 @@ |
|||||
|
{ |
||||
|
"name": "helper", |
||||
|
"version": "1.0.0", |
||||
|
"description": "", |
||||
|
"main": "index.js", |
||||
|
"scripts": { |
||||
|
"test": "echo \"Error: no test specified\" && exit 1" |
||||
|
}, |
||||
|
"keywords": [], |
||||
|
"author": "", |
||||
|
"license": "ISC", |
||||
|
"packageManager": "pnpm@10.4.1" |
||||
|
} |
@ -0,0 +1,7 @@ |
|||||
|
export const enum EventEnum { |
||||
|
UPDATE_PROGRESS = "update-progress", |
||||
|
} |
||||
|
|
||||
|
export type EventMaps = { |
||||
|
[EventEnum.UPDATE_PROGRESS]: () => void |
||||
|
} |
@ -0,0 +1,8 @@ |
|||||
|
import { broadcast } from "main/utils" |
||||
|
import { EventEnum } from "../common" |
||||
|
|
||||
|
export { EventEnum } |
||||
|
|
||||
|
export function emit(key: EventEnum, ...args: any[]) { |
||||
|
broadcast(key, ...args) |
||||
|
} |
@ -0,0 +1,74 @@ |
|||||
|
type DownloadPercent = { |
||||
|
url: string |
||||
|
option?: object |
||||
|
onprocess?: (now: number, all: number) => void |
||||
|
onsuccess?: (data: any) => void |
||||
|
onerror?: (res: Response) => void |
||||
|
} |
||||
|
const RequestPercent = async ({ |
||||
|
url = "", |
||||
|
option = { |
||||
|
headers: { |
||||
|
responseType: "arraybuffer", |
||||
|
}, |
||||
|
}, |
||||
|
onsuccess, |
||||
|
onerror, |
||||
|
onprocess, |
||||
|
}: DownloadPercent) => { |
||||
|
const response = (await fetch(url, option)) as any |
||||
|
if (!response.ok) { |
||||
|
onerror?.(response) |
||||
|
throw new Error(`下载失败`) |
||||
|
} |
||||
|
const reader = response?.body.getReader() |
||||
|
|
||||
|
// 文件总长度
|
||||
|
const contentLength = +response.headers.get("content-length") |
||||
|
|
||||
|
let receivedLength = 0 |
||||
|
const chunks: any[] = [] |
||||
|
// eslint-disable-next-line no-constant-condition
|
||||
|
while (true) { |
||||
|
const { done, value } = await reader.read() |
||||
|
|
||||
|
if (done) { |
||||
|
break |
||||
|
} |
||||
|
|
||||
|
chunks.push(value) |
||||
|
receivedLength += value.length |
||||
|
onprocess?.(receivedLength, contentLength) |
||||
|
} |
||||
|
// 这里的chunksAll 已经是ArrayBuffer的数据类型了,可以直接返回,也可以转为blob处理
|
||||
|
const chunksAll = new Uint8Array(receivedLength) |
||||
|
let position = 0 |
||||
|
for (const chunk of chunks) { |
||||
|
chunksAll.set(chunk, position) |
||||
|
position += chunk.length |
||||
|
} |
||||
|
|
||||
|
onsuccess?.(chunksAll) |
||||
|
|
||||
|
return chunksAll |
||||
|
} |
||||
|
|
||||
|
export { RequestPercent } |
||||
|
export default RequestPercent |
||||
|
|
||||
|
// RequestPercent({
|
||||
|
// url: "http://117.21.250.136:9812/ZxqyGateway/biz/file/downApk/%E6%98%93%E4%BC%81%E6%95%B0%E8%BD%AC%E5%B9%B3%E5%8F%B0app-1.2.7.apk",
|
||||
|
// option: {
|
||||
|
// headers: {
|
||||
|
// responseType: "arraybuffer",
|
||||
|
// },
|
||||
|
// },
|
||||
|
// onerror: () => {},
|
||||
|
// onsuccess: data => {
|
||||
|
// fs.writeFileSync("./aaa.apk", Buffer.from(data))
|
||||
|
// console.log("success", data)
|
||||
|
// },
|
||||
|
// onprocess: (receivedLength, contentLength) => {
|
||||
|
// console.log(receivedLength, contentLength)
|
||||
|
// },
|
||||
|
// })
|
@ -0,0 +1,118 @@ |
|||||
|
import { spawn } from "node:child_process" |
||||
|
import fs from "node:fs" |
||||
|
import path from "node:path" |
||||
|
import os from "node:os" |
||||
|
import { app } from "electron" |
||||
|
import download from "./download" |
||||
|
import extract from "extract-zip" |
||||
|
|
||||
|
import _logger from "logger/main" |
||||
|
import { emit, EventEnum } from "../handler" |
||||
|
|
||||
|
const logger = _logger.createNamespace("hot-updater") |
||||
|
|
||||
|
function getUpdateScriptTemplate() { |
||||
|
return process.platform === "win32" |
||||
|
? ` |
||||
|
@echo off |
||||
|
timeout /t 2 |
||||
|
taskkill /IM "{{EXE_NAME}}" /F |
||||
|
xcopy /Y /E "{{UPDATE_DIR}}\\*" "{{APP_PATH}}" |
||||
|
start "" "{{EXE_PATH}}" |
||||
|
` |
||||
|
: ` |
||||
|
#!/bin/bash |
||||
|
sleep 2 |
||||
|
pkill -f "{{EXE_NAME}}" |
||||
|
cp -Rf "{{UPDATE_DIR}}/*" "{{APP_PATH}}/" |
||||
|
open "{{EXE_PATH}}" |
||||
|
` |
||||
|
} |
||||
|
|
||||
|
function generateUpdateScript() { |
||||
|
const scriptContent = getUpdateScriptTemplate() |
||||
|
.replace(/{{APP_PATH}}/g, process.platform === "win32" ? "%APP_PATH%" : "$APP_PATH") |
||||
|
.replace(/{{UPDATE_DIR}}/g, process.platform === "win32" ? "%UPDATE_DIR%" : "$UPDATE_DIR") |
||||
|
.replace(/{{EXE_PATH}}/g, process.platform === "win32" ? "%EXE_PATH%" : "$EXE_PATH") |
||||
|
.replace(/{{EXE_NAME}}/g, process.platform === "win32" ? "%EXE_NAME%" : "$EXE_NAME") |
||||
|
|
||||
|
const scriptPath = path.join(os.tmpdir(), `update.${process.platform === "win32" ? "bat" : "sh"}`) |
||||
|
fs.writeFileSync(scriptPath, scriptContent) |
||||
|
return scriptPath |
||||
|
} |
||||
|
// 标记是否需要热更新
|
||||
|
let shouldPerformHotUpdate = false |
||||
|
let isReadyUpdate = false |
||||
|
// 更新临时目录路径
|
||||
|
// 使用应用名称和随机字符串创建唯一的临时目录
|
||||
|
const updateTempDirPath = path.join(os.tmpdir(), `${app.getName()}-update-${Math.random().toString(36).substring(2, 15)}`) |
||||
|
app.once("will-quit", event => { |
||||
|
if (!shouldPerformHotUpdate) return |
||||
|
event.preventDefault() |
||||
|
const appPath = app.getAppPath() |
||||
|
const appExePath = process.execPath |
||||
|
const exeName = path.basename(appExePath) |
||||
|
// 生成动态脚本
|
||||
|
const scriptPath = generateUpdateScript() |
||||
|
|
||||
|
fs.chmodSync(scriptPath, 0o755) |
||||
|
|
||||
|
// 执行脚本
|
||||
|
const child = spawn(scriptPath, [], { |
||||
|
detached: true, |
||||
|
shell: true, |
||||
|
env: { |
||||
|
APP_PATH: appPath, |
||||
|
UPDATE_DIR: updateTempDirPath, |
||||
|
EXE_PATH: appExePath, |
||||
|
EXE_NAME: exeName, |
||||
|
}, |
||||
|
}) |
||||
|
child.unref() |
||||
|
app.exit() |
||||
|
}) |
||||
|
|
||||
|
// 下载热更新包
|
||||
|
export async function fetchHotUpdatePackage(updatePackageUrl: string) { |
||||
|
if (isReadyUpdate) return |
||||
|
|
||||
|
// 清除临时目录
|
||||
|
clearUpdateTempDir() |
||||
|
// 创建临时目录
|
||||
|
if (!fs.existsSync(updateTempDirPath)) { |
||||
|
fs.mkdirSync(updateTempDirPath, { recursive: true }) |
||||
|
} |
||||
|
|
||||
|
// 下载文件的本地保存路径
|
||||
|
const downloadPath = path.join(updateTempDirPath, "update.zip") |
||||
|
|
||||
|
try { |
||||
|
// 使用 fetch 下载更新包
|
||||
|
const arrayBuffer = await download({ |
||||
|
url: updatePackageUrl, |
||||
|
onprocess(now, all) { |
||||
|
logger.debug(`下载进度: ${((now / all) * 100).toFixed(2)}%`) |
||||
|
emit(EventEnum.UPDATE_PROGRESS, { percent: (now / all) * 100, now, all }) |
||||
|
}, |
||||
|
}) |
||||
|
fs.writeFileSync(downloadPath, Buffer.from(arrayBuffer)) |
||||
|
// 解压更新包
|
||||
|
await extract(downloadPath, { dir: updateTempDirPath }) |
||||
|
|
||||
|
// 删除下载的zip文件
|
||||
|
fs.unlinkSync(downloadPath) |
||||
|
isReadyUpdate = true |
||||
|
} catch (error) { |
||||
|
logger.debug("热更新包下载失败:", error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function clearUpdateTempDir() { |
||||
|
if (!fs.existsSync(updateTempDirPath)) return |
||||
|
fs.rmSync(updateTempDirPath, { recursive: true }) |
||||
|
} |
||||
|
|
||||
|
export function flagNeedUpdate() { |
||||
|
shouldPerformHotUpdate = true |
||||
|
} |
@ -0,0 +1,137 @@ |
|||||
|
import pkg from "electron-updater" |
||||
|
import { app, dialog } from "electron" |
||||
|
import Setting from "setting/main" |
||||
|
import EventEmitter from "events" |
||||
|
import { BaseSingleton } from "base" |
||||
|
import { fetchHotUpdatePackage, flagNeedUpdate } from "./hot" |
||||
|
import Locales from "locales/main" |
||||
|
import _logger from "logger/main" |
||||
|
|
||||
|
const logger = _logger.createNamespace("updater") |
||||
|
const { autoUpdater } = pkg |
||||
|
|
||||
|
class _Updater extends BaseSingleton { |
||||
|
public events = new EventEmitter() |
||||
|
private timer: ReturnType<typeof setInterval> | null = null |
||||
|
// autoReplace = false
|
||||
|
async triggerHotUpdate(autoReplace = false) { |
||||
|
const url = Setting.values("update.hoturl") |
||||
|
await fetchHotUpdatePackage(url) |
||||
|
flagNeedUpdate() |
||||
|
if (!autoReplace) { |
||||
|
dialog.showMessageBox({ |
||||
|
title: Locales.t("update.ready.hot.title"), |
||||
|
message: Locales.t("update.ready.hot.desc", { version: app.getVersion() }), |
||||
|
}) |
||||
|
} else { |
||||
|
app.quit() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
constructor() { |
||||
|
super() |
||||
|
// 配置自动更新
|
||||
|
autoUpdater.autoDownload = false |
||||
|
autoUpdater.autoInstallOnAppQuit = true |
||||
|
|
||||
|
// 检查更新错误
|
||||
|
autoUpdater.on("error", error => { |
||||
|
logger.debug("Update error:", error) |
||||
|
}) |
||||
|
|
||||
|
// 检查更新
|
||||
|
autoUpdater.on("checking-for-update", () => { |
||||
|
logger.debug("Checking for updates...") |
||||
|
}) |
||||
|
|
||||
|
// 有可用更新
|
||||
|
autoUpdater.on("update-available", info => { |
||||
|
logger.debug("Update available:", info) |
||||
|
this.promptUserToUpdate() |
||||
|
}) |
||||
|
|
||||
|
// 没有可用更新
|
||||
|
autoUpdater.on("update-not-available", info => { |
||||
|
logger.debug("Update not available:", info) |
||||
|
}) |
||||
|
|
||||
|
// 更新下载进度
|
||||
|
autoUpdater.on("download-progress", progressObj => { |
||||
|
logger.debug( |
||||
|
`Download speed: ${progressObj.bytesPerSecond} - Downloaded ${progressObj.percent}% (${progressObj.transferred}/${progressObj.total})`, |
||||
|
) |
||||
|
}) |
||||
|
|
||||
|
// 更新下载完成
|
||||
|
autoUpdater.on("update-downloaded", info => { |
||||
|
logger.debug("Update downloaded:", info) |
||||
|
this.promptUserToInstall() |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
init() { |
||||
|
// 定期检查更新
|
||||
|
this.checkForUpdates() |
||||
|
this.timer && clearInterval(this.timer) |
||||
|
this.timer = setInterval( |
||||
|
() => { |
||||
|
this.checkForUpdates() |
||||
|
}, |
||||
|
1000 * 60 * 60, |
||||
|
) // 每小时检查一次
|
||||
|
} |
||||
|
|
||||
|
destroy() { |
||||
|
// 清理工作
|
||||
|
if (this.timer) { |
||||
|
clearInterval(this.timer) |
||||
|
this.timer = null |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private async checkForUpdates() { |
||||
|
if (app.isPackaged) { |
||||
|
try { |
||||
|
await autoUpdater.checkForUpdates() |
||||
|
logger.debug("Updater初始化检查成功.") |
||||
|
} catch (error) { |
||||
|
logger.debug("Failed to check for updates:", error) |
||||
|
} |
||||
|
} else { |
||||
|
logger.debug("正在开发模式,跳过更新检查.") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private async promptUserToUpdate() { |
||||
|
const result = await dialog.showMessageBox({ |
||||
|
type: "info", |
||||
|
title: "发现新版本", |
||||
|
message: "是否下载新版本?", |
||||
|
buttons: ["下载", "暂不更新"], |
||||
|
defaultId: 0, |
||||
|
}) |
||||
|
|
||||
|
if (result.response === 0) { |
||||
|
autoUpdater.downloadUpdate() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private async promptUserToInstall() { |
||||
|
const result = await dialog.showMessageBox({ |
||||
|
type: "info", |
||||
|
title: "更新已就绪", |
||||
|
message: "新版本已下载完成,是否立即安装?", |
||||
|
buttons: ["立即安装", "稍后安装"], |
||||
|
defaultId: 0, |
||||
|
}) |
||||
|
|
||||
|
if (result.response === 0) { |
||||
|
autoUpdater.quitAndInstall(false, true) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const Updater = _Updater.getInstance() |
||||
|
|
||||
|
export { Updater } |
||||
|
export default Updater |
@ -0,0 +1,41 @@ |
|||||
|
if (import.meta.env.DEV) { |
||||
|
// 引入之后可以热更新
|
||||
|
import("./languages/zh.json") |
||||
|
import("./languages/en.json") |
||||
|
} |
||||
|
|
||||
|
const datetimeFormats = { |
||||
|
en: { |
||||
|
short: { |
||||
|
year: "numeric", |
||||
|
month: "short", |
||||
|
day: "numeric", |
||||
|
}, |
||||
|
long: { |
||||
|
year: "numeric", |
||||
|
month: "short", |
||||
|
day: "numeric", |
||||
|
weekday: "short", |
||||
|
hour: "numeric", |
||||
|
minute: "numeric", |
||||
|
}, |
||||
|
}, |
||||
|
zh: { |
||||
|
short: { |
||||
|
year: "numeric", |
||||
|
month: "short", |
||||
|
day: "numeric", |
||||
|
}, |
||||
|
long: { |
||||
|
year: "numeric", |
||||
|
month: "short", |
||||
|
day: "numeric", |
||||
|
weekday: "short", |
||||
|
hour: "numeric", |
||||
|
minute: "numeric", |
||||
|
hour12: true, |
||||
|
}, |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
export { datetimeFormats } |
@ -0,0 +1,20 @@ |
|||||
|
{ |
||||
|
"update": { |
||||
|
"ready": { |
||||
|
"hot": { |
||||
|
"desc": "The new version v{version} is ready for update and will be automatically updated the next time you launch the program.", |
||||
|
"title": "Prompt" |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
"browser": { |
||||
|
"navbar": { |
||||
|
"menu": { |
||||
|
"fullscreen": "Full screen", |
||||
|
"quit-fullscreen": "Exit Full Screen", |
||||
|
"toggleDevTools": "Developer panel", |
||||
|
"label": "Menu" |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
@ -0,0 +1,20 @@ |
|||||
|
{ |
||||
|
"update": { |
||||
|
"ready": { |
||||
|
"hot": { |
||||
|
"title": "提示", |
||||
|
"desc": "新版本 v{version} 已经准备好更新, 下次启动程序即可自动更新" |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
"browser": { |
||||
|
"navbar": { |
||||
|
"menu": { |
||||
|
"label": "菜单", |
||||
|
"fullscreen": "全屏", |
||||
|
"quit-fullscreen": "取消全屏", |
||||
|
"toggleDevTools": "开发者面板" |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
@ -0,0 +1,53 @@ |
|||||
|
import { app } from "electron" |
||||
|
import { get } from "lodash-es" |
||||
|
|
||||
|
import zh from "./languages/zh.json" |
||||
|
import en from "./languages/en.json" |
||||
|
|
||||
|
type FlattenObject<T, Prefix extends string = ""> = T extends object |
||||
|
? { |
||||
|
[K in keyof T & (string | number)]: FlattenObject<T[K], Prefix extends "" ? `${K}` : `${Prefix}.${K}`> |
||||
|
}[keyof T & (string | number)] |
||||
|
: Prefix |
||||
|
|
||||
|
type FlattenKeys<T> = FlattenObject<T> |
||||
|
|
||||
|
type TranslationKey = FlattenKeys<typeof zh> |
||||
|
|
||||
|
class Locale { |
||||
|
locale: string = "zh" |
||||
|
|
||||
|
constructor() { |
||||
|
try { |
||||
|
this.locale = app.getLocale() |
||||
|
} catch (e) { |
||||
|
console.log(e) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
isCN(): boolean { |
||||
|
return this.locale.startsWith("zh") |
||||
|
} |
||||
|
|
||||
|
t(key: TranslationKey, replacements?: Record<string, string>): string { |
||||
|
let text: string = this.isCN() ? get(zh, key) : get(en, key) |
||||
|
if (!text) { |
||||
|
text = get(zh, key) |
||||
|
if (!text) { |
||||
|
return key |
||||
|
} |
||||
|
} |
||||
|
if (replacements) { |
||||
|
// 替换所有形如 {key} 的占位符
|
||||
|
Object.entries(replacements).forEach(([key, value]) => { |
||||
|
console.log(text) |
||||
|
text = text.replace(new RegExp(`{${key}}`, "g"), value) |
||||
|
}) |
||||
|
} |
||||
|
return text |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const Locales = new Locale() |
||||
|
export default Locales |
||||
|
export { Locales } |
@ -0,0 +1,7 @@ |
|||||
|
{ |
||||
|
"name": "locales", |
||||
|
"version": "1.0.0", |
||||
|
"keywords": [], |
||||
|
"author": "", |
||||
|
"license": "ISC" |
||||
|
} |
@ -0,0 +1,31 @@ |
|||||
|
// 日志级别定义
|
||||
|
export enum LogLevel { |
||||
|
TRACE = 0, |
||||
|
DEBUG = 1, |
||||
|
INFO = 2, |
||||
|
WARN = 3, |
||||
|
ERROR = 4, |
||||
|
FATAL = 5, |
||||
|
OFF = 6, |
||||
|
} |
||||
|
// 日志级别名称映射
|
||||
|
export const LogLevelName: Record<LogLevel, string> = { |
||||
|
[LogLevel.TRACE]: "TRACE", |
||||
|
[LogLevel.DEBUG]: "DEBUG", |
||||
|
[LogLevel.INFO]: "INFO", |
||||
|
[LogLevel.WARN]: "WARN", |
||||
|
[LogLevel.ERROR]: "ERROR", |
||||
|
[LogLevel.FATAL]: "FATAL", |
||||
|
[LogLevel.OFF]: "OFF", |
||||
|
} |
||||
|
|
||||
|
// 日志颜色映射(控制台输出用)
|
||||
|
export const LogLevelColor: Record<LogLevel, string> = { |
||||
|
[LogLevel.TRACE]: "\x1b[90m", // 灰色
|
||||
|
[LogLevel.DEBUG]: "\x1b[36m", // 青色
|
||||
|
[LogLevel.INFO]: "\x1b[32m", // 绿色
|
||||
|
[LogLevel.WARN]: "\x1b[33m", // 黄色
|
||||
|
[LogLevel.ERROR]: "\x1b[31m", // 红色
|
||||
|
[LogLevel.FATAL]: "\x1b[35m", // 紫色
|
||||
|
[LogLevel.OFF]: "", // 无色
|
||||
|
} |
@ -0,0 +1,486 @@ |
|||||
|
import { app, dialog } from "electron" |
||||
|
import fs from "fs" |
||||
|
import path from "path" |
||||
|
import os from "os" |
||||
|
import logger from "./main" |
||||
|
import errorHandler, { ErrorDetail } from "./main-error" |
||||
|
|
||||
|
/** |
||||
|
* 崩溃报告接口 |
||||
|
*/ |
||||
|
export interface CrashReport { |
||||
|
timestamp: string |
||||
|
error: ErrorDetail |
||||
|
systemInfo: { |
||||
|
platform: string |
||||
|
release: string |
||||
|
arch: string |
||||
|
totalMemory: number |
||||
|
freeMemory: number |
||||
|
uptime: number |
||||
|
} |
||||
|
appInfo: { |
||||
|
version: string |
||||
|
name: string |
||||
|
path: string |
||||
|
argv: string[] |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 崩溃处理配置 |
||||
|
*/ |
||||
|
export interface CrashHandlerOptions { |
||||
|
crashReportDir?: string |
||||
|
maxReports?: number |
||||
|
showDialog?: boolean |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 默认崩溃处理配置 |
||||
|
*/ |
||||
|
const DEFAULT_OPTIONS: CrashHandlerOptions = { |
||||
|
maxReports: 10, |
||||
|
showDialog: true, |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 崩溃处理类 |
||||
|
*/ |
||||
|
export class CrashHandler { |
||||
|
private static instance: CrashHandler |
||||
|
private options: CrashHandlerOptions |
||||
|
private crashReportDir: string |
||||
|
private initialized: boolean = false |
||||
|
private startTime: number = Date.now() |
||||
|
// private normalShutdown: boolean = false
|
||||
|
|
||||
|
/** |
||||
|
* 获取单例实例 |
||||
|
*/ |
||||
|
public static getInstance(): CrashHandler { |
||||
|
if (!CrashHandler.instance) { |
||||
|
CrashHandler.instance = new CrashHandler() |
||||
|
} |
||||
|
return CrashHandler.instance |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 构造函数 |
||||
|
*/ |
||||
|
private constructor() { |
||||
|
this.options = { ...DEFAULT_OPTIONS } |
||||
|
this.crashReportDir = path.join(app.getPath("userData"), "crash-reports") |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 初始化崩溃处理器 |
||||
|
*/ |
||||
|
public init(options?: CrashHandlerOptions): void { |
||||
|
if (this.initialized) { |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
this.options = { ...this.options, ...options } |
||||
|
|
||||
|
if (options?.crashReportDir) { |
||||
|
this.crashReportDir = options.crashReportDir |
||||
|
} |
||||
|
|
||||
|
// 确保崩溃报告目录存在
|
||||
|
if (!fs.existsSync(this.crashReportDir)) { |
||||
|
fs.mkdirSync(this.crashReportDir, { recursive: true }) |
||||
|
} |
||||
|
|
||||
|
// 检查上次是否崩溃
|
||||
|
this.checkPreviousCrash() |
||||
|
|
||||
|
// 记录应用启动时间
|
||||
|
this.startTime = Date.now() |
||||
|
this.saveStartupMarker() |
||||
|
|
||||
|
// 设置全局未捕获异常处理器
|
||||
|
this.setupGlobalHandlers() |
||||
|
|
||||
|
// 设置应用退出处理
|
||||
|
app.on("before-quit", () => { |
||||
|
// this.normalShutdown = true
|
||||
|
this.clearStartupMarker() |
||||
|
}) |
||||
|
|
||||
|
this.initialized = true |
||||
|
logger.info("crash-handler", "Crash handler initialized") |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 设置全局未捕获异常处理器 |
||||
|
*/ |
||||
|
private setupGlobalHandlers(): void { |
||||
|
// 增强现有的错误处理器
|
||||
|
const originalCaptureError = errorHandler.captureError.bind(errorHandler) |
||||
|
errorHandler.captureError = (error: any, componentInfo?: string, additionalInfo?: Record<string, any>) => { |
||||
|
// 调用原始方法记录错误
|
||||
|
originalCaptureError(error, componentInfo, additionalInfo) |
||||
|
|
||||
|
// 对于严重错误,生成崩溃报告
|
||||
|
if (error instanceof Error && error.stack) { |
||||
|
this.generateCrashReport(error, componentInfo, additionalInfo) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 捕获未处理的Promise异常
|
||||
|
process.on("unhandledRejection", (reason, promise) => { |
||||
|
logger.error("crash-handler", `Unhandled Promise Rejection: ${reason}`) |
||||
|
this.generateCrashReport(reason, "unhandledRejection", { promise: String(promise) }) |
||||
|
}) |
||||
|
|
||||
|
// 捕获未捕获的异常
|
||||
|
process.on("uncaughtException", error => { |
||||
|
logger.error("crash-handler", `Uncaught Exception: ${error.message}`) |
||||
|
this.generateCrashReport(error, "uncaughtException") |
||||
|
|
||||
|
// 显示错误对话框
|
||||
|
if (this.options.showDialog) { |
||||
|
this.showCrashDialog(error) |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 生成崩溃报告 |
||||
|
*/ |
||||
|
private generateCrashReport(error: any, componentInfo?: string, additionalInfo?: Record<string, any>): void { |
||||
|
try { |
||||
|
// 格式化错误信息
|
||||
|
const errorDetail = this.formatError(error, componentInfo, additionalInfo) |
||||
|
|
||||
|
// 创建崩溃报告
|
||||
|
const crashReport: CrashReport = { |
||||
|
timestamp: new Date().toISOString(), |
||||
|
error: errorDetail, |
||||
|
systemInfo: { |
||||
|
platform: os.platform(), |
||||
|
release: os.release(), |
||||
|
arch: os.arch(), |
||||
|
totalMemory: os.totalmem(), |
||||
|
freeMemory: os.freemem(), |
||||
|
uptime: os.uptime(), |
||||
|
}, |
||||
|
appInfo: { |
||||
|
version: app.getVersion(), |
||||
|
name: app.getName(), |
||||
|
path: app.getAppPath(), |
||||
|
argv: process.argv, |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
// 保存崩溃报告
|
||||
|
this.saveCrashReport(crashReport) |
||||
|
} catch (e) { |
||||
|
logger.error("crash-handler", `Failed to generate crash report: ${e}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 格式化错误信息 |
||||
|
*/ |
||||
|
private formatError(error: any, componentInfo?: string, additionalInfo?: Record<string, any>): ErrorDetail { |
||||
|
// 基本错误信息
|
||||
|
const errorDetail: ErrorDetail = { |
||||
|
message: "", |
||||
|
timestamp: new Date().toISOString(), |
||||
|
type: "Unknown", |
||||
|
} |
||||
|
|
||||
|
// 处理不同类型的错误
|
||||
|
if (error instanceof Error) { |
||||
|
errorDetail.message = error.message |
||||
|
errorDetail.type = error.name || error.constructor.name |
||||
|
errorDetail.stack = error.stack |
||||
|
} else if (typeof error === "string") { |
||||
|
errorDetail.message = error |
||||
|
errorDetail.type = "String" |
||||
|
} else if (error === null) { |
||||
|
errorDetail.message = "Null error received" |
||||
|
errorDetail.type = "Null" |
||||
|
} else if (error === undefined) { |
||||
|
errorDetail.message = "Undefined error received" |
||||
|
errorDetail.type = "Undefined" |
||||
|
} else if (typeof error === "object") { |
||||
|
try { |
||||
|
errorDetail.message = error.message || JSON.stringify(error) |
||||
|
errorDetail.type = "Object" |
||||
|
errorDetail.additionalInfo = { ...error } |
||||
|
} catch (e) { |
||||
|
errorDetail.message = "Unserializable error object" |
||||
|
errorDetail.type = "Unserializable" |
||||
|
} |
||||
|
} else { |
||||
|
try { |
||||
|
errorDetail.message = String(error) |
||||
|
errorDetail.type = typeof error |
||||
|
} catch (e) { |
||||
|
errorDetail.message = "Error converting to string" |
||||
|
errorDetail.type = "Unknown" |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 添加组件信息
|
||||
|
if (componentInfo) { |
||||
|
errorDetail.componentInfo = componentInfo |
||||
|
} |
||||
|
|
||||
|
// 添加额外信息
|
||||
|
if (additionalInfo) { |
||||
|
errorDetail.additionalInfo = { |
||||
|
...errorDetail.additionalInfo, |
||||
|
...additionalInfo, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return errorDetail |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 保存崩溃报告 |
||||
|
*/ |
||||
|
private saveCrashReport(report: CrashReport): void { |
||||
|
try { |
||||
|
// 生成唯一的崩溃报告文件名
|
||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-") |
||||
|
const filename = `crash-${timestamp}.json` |
||||
|
const filepath = path.join(this.crashReportDir, filename) |
||||
|
|
||||
|
// 写入崩溃报告
|
||||
|
fs.writeFileSync(filepath, JSON.stringify(report, null, 2)) |
||||
|
logger.info("crash-handler", `Crash report saved: ${filepath}`) |
||||
|
|
||||
|
// 清理旧的崩溃报告
|
||||
|
this.cleanupOldReports() |
||||
|
} catch (e) { |
||||
|
logger.error("crash-handler", `Failed to save crash report: ${e}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 清理旧的崩溃报告 |
||||
|
*/ |
||||
|
private cleanupOldReports(): void { |
||||
|
try { |
||||
|
// 获取所有崩溃报告文件
|
||||
|
const files = fs |
||||
|
.readdirSync(this.crashReportDir) |
||||
|
.filter(file => file.startsWith("crash-") && file.endsWith(".json")) |
||||
|
.map(file => ({ |
||||
|
name: file, |
||||
|
path: path.join(this.crashReportDir, file), |
||||
|
time: fs.statSync(path.join(this.crashReportDir, file)).mtime.getTime(), |
||||
|
})) |
||||
|
.sort((a, b) => b.time - a.time) // 按时间降序排序
|
||||
|
|
||||
|
// 删除超出最大数量的旧报告
|
||||
|
if (files.length > this.options.maxReports!) { |
||||
|
const filesToDelete = files.slice(this.options.maxReports!) |
||||
|
filesToDelete.forEach(file => { |
||||
|
fs.unlinkSync(file.path) |
||||
|
logger.debug("crash-handler", `Deleted old crash report: ${file.name}`) |
||||
|
}) |
||||
|
} |
||||
|
} catch (e) { |
||||
|
logger.error("crash-handler", `Failed to cleanup old reports: ${e}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 保存启动标记 |
||||
|
*/ |
||||
|
private saveStartupMarker(): void { |
||||
|
try { |
||||
|
const markerPath = path.join(this.crashReportDir, "startup-marker.json") |
||||
|
const marker = { |
||||
|
startTime: this.startTime, |
||||
|
pid: process.pid, |
||||
|
} |
||||
|
fs.writeFileSync(markerPath, JSON.stringify(marker)) |
||||
|
} catch (e) { |
||||
|
logger.error("crash-handler", `Failed to save startup marker: ${e}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 清除启动标记 |
||||
|
*/ |
||||
|
private clearStartupMarker(): void { |
||||
|
try { |
||||
|
const markerPath = path.join(this.crashReportDir, "startup-marker.json") |
||||
|
if (fs.existsSync(markerPath)) { |
||||
|
fs.unlinkSync(markerPath) |
||||
|
} |
||||
|
} catch (e) { |
||||
|
logger.error("crash-handler", `Failed to clear startup marker: ${e}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 检查上次是否崩溃 |
||||
|
*/ |
||||
|
private checkPreviousCrash(): boolean { |
||||
|
try { |
||||
|
const markerPath = path.join(this.crashReportDir, "startup-marker.json") |
||||
|
// 如果存在启动标记,说明上次可能崩溃了
|
||||
|
if (fs.existsSync(markerPath)) { |
||||
|
const markerData = JSON.parse(fs.readFileSync(markerPath, "utf8")) |
||||
|
const lastStartTime = markerData.startTime |
||||
|
const lastPid = markerData.pid |
||||
|
|
||||
|
logger.warn( |
||||
|
"crash-handler", |
||||
|
`Found previous startup marker. App may have crashed. Last PID: ${lastPid}, Last start time: ${new Date(lastStartTime).toISOString()}`, |
||||
|
) |
||||
|
|
||||
|
// 查找最近的崩溃报告
|
||||
|
const recentCrash = this.getRecentCrashReport() |
||||
|
|
||||
|
// 显示崩溃恢复对话框
|
||||
|
if (recentCrash && this.options.showDialog) { |
||||
|
app.whenReady().then(() => { |
||||
|
this.showRecoveryDialog(recentCrash) |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
// 清除旧的启动标记
|
||||
|
fs.unlinkSync(markerPath) |
||||
|
return true |
||||
|
} |
||||
|
} catch (e) { |
||||
|
logger.error("crash-handler", `Failed to check previous crash: ${e}`) |
||||
|
} |
||||
|
|
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取最近的崩溃报告 |
||||
|
*/ |
||||
|
private getRecentCrashReport(): CrashReport | null { |
||||
|
try { |
||||
|
// 获取所有崩溃报告文件
|
||||
|
const files = fs |
||||
|
.readdirSync(this.crashReportDir) |
||||
|
.filter(file => file.startsWith("crash-") && file.endsWith(".json")) |
||||
|
.map(file => ({ |
||||
|
name: file, |
||||
|
path: path.join(this.crashReportDir, file), |
||||
|
time: fs.statSync(path.join(this.crashReportDir, file)).mtime.getTime(), |
||||
|
})) |
||||
|
.sort((a, b) => b.time - a.time) // 按时间降序排序
|
||||
|
|
||||
|
// 读取最近的崩溃报告
|
||||
|
if (files.length > 0) { |
||||
|
const recentFile = files[0] |
||||
|
const reportData = fs.readFileSync(recentFile.path, "utf8") |
||||
|
return JSON.parse(reportData) as CrashReport |
||||
|
} |
||||
|
} catch (e) { |
||||
|
logger.error("crash-handler", `Failed to get recent crash report: ${e}`) |
||||
|
} |
||||
|
|
||||
|
return null |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 显示崩溃对话框 |
||||
|
*/ |
||||
|
private showCrashDialog(error: Error): void { |
||||
|
try { |
||||
|
const options = { |
||||
|
type: "error" as const, |
||||
|
title: "应用崩溃", |
||||
|
message: "应用遇到了一个严重错误,即将关闭", |
||||
|
detail: `错误信息: ${error.message}\n\n堆栈信息: ${error.stack}\n\n崩溃报告已保存,应用将在您点击确定后关闭。`, |
||||
|
buttons: ["确定"], |
||||
|
defaultId: 0, |
||||
|
} |
||||
|
|
||||
|
dialog.showMessageBoxSync(options) |
||||
|
|
||||
|
// 强制退出应用
|
||||
|
setTimeout(() => { |
||||
|
app.exit(1) |
||||
|
}, 1000) |
||||
|
} catch (e) { |
||||
|
logger.error("crash-handler", `Failed to show crash dialog: ${e}`) |
||||
|
app.exit(1) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 显示恢复对话框 |
||||
|
*/ |
||||
|
private showRecoveryDialog(crashReport: CrashReport): void { |
||||
|
try { |
||||
|
const crashTime = new Date(crashReport.timestamp).toLocaleString() |
||||
|
const errorMessage = crashReport.error.message |
||||
|
const errorType = crashReport.error.type |
||||
|
|
||||
|
const options = { |
||||
|
type: "warning" as const, |
||||
|
title: "应用恢复", |
||||
|
message: "应用上次异常退出", |
||||
|
detail: `应用在 ${crashTime} 因 ${errorType} 错误崩溃: ${errorMessage}\n\n崩溃报告已保存,您可以继续使用应用或联系开发者报告此问题。`, |
||||
|
buttons: ["继续", "查看详情"], |
||||
|
defaultId: 0, |
||||
|
} |
||||
|
|
||||
|
const response = dialog.showMessageBoxSync(options) |
||||
|
|
||||
|
// 如果用户选择查看详情
|
||||
|
if (response === 1) { |
||||
|
// 显示详细的崩溃报告
|
||||
|
this.showDetailedCrashInfo(crashReport) |
||||
|
} |
||||
|
} catch (e) { |
||||
|
logger.error("crash-handler", `Failed to show recovery dialog: ${e}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 显示详细的崩溃信息 |
||||
|
*/ |
||||
|
private showDetailedCrashInfo(crashReport: CrashReport): void { |
||||
|
try { |
||||
|
const options = { |
||||
|
type: "info" as const, |
||||
|
title: "崩溃详情", |
||||
|
message: "应用崩溃详细信息", |
||||
|
detail: JSON.stringify(crashReport, null, 2), |
||||
|
buttons: ["关闭"], |
||||
|
defaultId: 0, |
||||
|
} |
||||
|
|
||||
|
dialog.showMessageBoxSync(options) |
||||
|
} catch (e) { |
||||
|
logger.error("crash-handler", `Failed to show detailed crash info: ${e}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 设置崩溃处理选项 |
||||
|
*/ |
||||
|
public setOptions(options: Partial<CrashHandlerOptions>): void { |
||||
|
this.options = { ...this.options, ...options } |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取当前选项 |
||||
|
*/ |
||||
|
public getOptions(): CrashHandlerOptions { |
||||
|
return { ...this.options } |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 创建默认实例
|
||||
|
const crashHandler = CrashHandler.getInstance() |
||||
|
|
||||
|
export default crashHandler |
||||
|
export { crashHandler } |
@ -0,0 +1,177 @@ |
|||||
|
import { LogLevel, LogLevelName } from "./common" |
||||
|
import logger from "./main" |
||||
|
|
||||
|
/** |
||||
|
* 错误详情接口 |
||||
|
*/ |
||||
|
export interface ErrorDetail { |
||||
|
message: string |
||||
|
stack?: string |
||||
|
componentInfo?: string |
||||
|
additionalInfo?: Record<string, any> |
||||
|
timestamp: string |
||||
|
type: string |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 错误处理配置 |
||||
|
*/ |
||||
|
export interface ErrorHandlerOptions { |
||||
|
namespace?: string |
||||
|
level?: LogLevel |
||||
|
includeStack?: boolean |
||||
|
includeComponentInfo?: boolean |
||||
|
formatError?: (error: any) => ErrorDetail |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 默认错误处理配置 |
||||
|
*/ |
||||
|
const DEFAULT_OPTIONS: ErrorHandlerOptions = { |
||||
|
namespace: "error", |
||||
|
level: LogLevel.ERROR, |
||||
|
includeStack: true, |
||||
|
includeComponentInfo: true, |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 格式化错误信息 |
||||
|
*/ |
||||
|
const formatError = (error: any, options: ErrorHandlerOptions): ErrorDetail => { |
||||
|
// 如果已经是ErrorDetail格式,直接返回
|
||||
|
if (error && typeof error === "object" && error.type && error.message && error.timestamp) { |
||||
|
return error as ErrorDetail |
||||
|
} |
||||
|
|
||||
|
// 基本错误信息
|
||||
|
const errorDetail: ErrorDetail = { |
||||
|
message: "", |
||||
|
timestamp: new Date().toISOString(), |
||||
|
type: "Unknown", |
||||
|
} |
||||
|
|
||||
|
// 处理不同类型的错误
|
||||
|
if (error instanceof Error) { |
||||
|
errorDetail.message = error.message |
||||
|
errorDetail.type = error.name || error.constructor.name |
||||
|
if (options.includeStack) { |
||||
|
errorDetail.stack = error.stack |
||||
|
} |
||||
|
} else if (typeof error === "string") { |
||||
|
errorDetail.message = error |
||||
|
errorDetail.type = "String" |
||||
|
} else if (error === null) { |
||||
|
errorDetail.message = "Null error received" |
||||
|
errorDetail.type = "Null" |
||||
|
} else if (error === undefined) { |
||||
|
errorDetail.message = "Undefined error received" |
||||
|
errorDetail.type = "Undefined" |
||||
|
} else if (typeof error === "object") { |
||||
|
try { |
||||
|
errorDetail.message = error.message || JSON.stringify(error) |
||||
|
errorDetail.type = "Object" |
||||
|
errorDetail.additionalInfo = { ...error } |
||||
|
} catch (e) { |
||||
|
errorDetail.message = "Unserializable error object" |
||||
|
errorDetail.type = "Unserializable" |
||||
|
} |
||||
|
} else { |
||||
|
try { |
||||
|
errorDetail.message = String(error) |
||||
|
errorDetail.type = typeof error |
||||
|
} catch (e) { |
||||
|
errorDetail.message = "Error converting to string" |
||||
|
errorDetail.type = "Unknown" |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return errorDetail |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 错误处理类 |
||||
|
*/ |
||||
|
export class ErrorHandler { |
||||
|
private options: ErrorHandlerOptions |
||||
|
|
||||
|
constructor(options?: Partial<ErrorHandlerOptions>) { |
||||
|
this.options = { ...DEFAULT_OPTIONS, ...options } |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 记录错误 |
||||
|
*/ |
||||
|
public captureError(error: any, componentInfo?: string, additionalInfo?: Record<string, any>): void { |
||||
|
const errorDetail = formatError(error, this.options) |
||||
|
|
||||
|
// 添加组件信息
|
||||
|
if (this.options.includeComponentInfo && componentInfo) { |
||||
|
errorDetail.componentInfo = componentInfo |
||||
|
} |
||||
|
|
||||
|
// 添加额外信息
|
||||
|
if (additionalInfo) { |
||||
|
errorDetail.additionalInfo = { |
||||
|
...errorDetail.additionalInfo, |
||||
|
...additionalInfo, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 使用logger记录错误
|
||||
|
const namespace = this.options.namespace || "error" |
||||
|
const level = LogLevelName[this.options.level || LogLevel.ERROR].toLowerCase() |
||||
|
|
||||
|
// 构建错误消息
|
||||
|
let errorMessage = `${errorDetail.type}: ${errorDetail.message}` |
||||
|
if (errorDetail.componentInfo) { |
||||
|
errorMessage += ` | Component: ${errorDetail.componentInfo}` |
||||
|
} |
||||
|
|
||||
|
// 记录错误
|
||||
|
logger[level](namespace, errorMessage) |
||||
|
|
||||
|
// 如果有堆栈信息,单独记录
|
||||
|
if (errorDetail.stack) { |
||||
|
logger[level](namespace, `Stack: ${errorDetail.stack}`) |
||||
|
} |
||||
|
|
||||
|
// 如果有额外信息,单独记录
|
||||
|
if (errorDetail.additionalInfo) { |
||||
|
try { |
||||
|
const additionalInfoStr = JSON.stringify(errorDetail.additionalInfo, null, 2) |
||||
|
logger[level](namespace, `Additional Info: ${additionalInfoStr}`) |
||||
|
} catch (e) { |
||||
|
logger[level](namespace, "Additional Info: [Unserializable]") |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 设置错误处理选项 |
||||
|
*/ |
||||
|
public setOptions(options: Partial<ErrorHandlerOptions>): void { |
||||
|
this.options = { ...this.options, ...options } |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取当前选项 |
||||
|
*/ |
||||
|
public getOptions(): ErrorHandlerOptions { |
||||
|
return { ...this.options } |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 创建默认实例
|
||||
|
const errorHandler = new ErrorHandler() |
||||
|
|
||||
|
// 捕获未处理的Promise异常
|
||||
|
process.on("unhandledRejection", reason => { |
||||
|
errorHandler.captureError(reason) |
||||
|
}) |
||||
|
|
||||
|
// 捕获未捕获的异常
|
||||
|
process.on("uncaughtException", error => { |
||||
|
errorHandler.captureError(error) |
||||
|
}) |
||||
|
|
||||
|
export default errorHandler |
@ -0,0 +1,275 @@ |
|||||
|
import { app, ipcMain } from "electron" |
||||
|
import fs from "fs" |
||||
|
import path from "path" |
||||
|
import config from "config" |
||||
|
import * as rfs from "rotating-file-stream" |
||||
|
import { LogLevel, LogLevelColor, LogLevelName } from "./common" |
||||
|
import { emitter } from "setting/main/event" |
||||
|
|
||||
|
// 重置颜色的ANSI代码
|
||||
|
const RESET_COLOR = "\x1b[0m" |
||||
|
|
||||
|
// 日志配置接口
|
||||
|
export interface LoggerOptions { |
||||
|
level?: LogLevel // 日志级别
|
||||
|
namespace?: string // 日志命名空间
|
||||
|
console?: boolean // 是否输出到控制台
|
||||
|
file?: boolean // 是否输出到文件
|
||||
|
maxSize?: string // 单个日志文件最大大小
|
||||
|
maxFiles?: number // 保留的最大日志文件数量
|
||||
|
} |
||||
|
|
||||
|
// 默认配置
|
||||
|
const DEFAULT_OPTIONS: LoggerOptions = { |
||||
|
level: config.default_config.debug, |
||||
|
namespace: "app", |
||||
|
console: true, |
||||
|
file: true, |
||||
|
maxSize: "10M", |
||||
|
maxFiles: 10, |
||||
|
} |
||||
|
|
||||
|
let logDir |
||||
|
const isElectronApp = !!process.versions.electron |
||||
|
if (isElectronApp && app) { |
||||
|
logDir = path.join(app.getPath("logs")) |
||||
|
} else { |
||||
|
// 非Electron环境下使用当前目录下的logs文件夹
|
||||
|
logDir = path.join(process.cwd(), "logs") |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 日志管理类 |
||||
|
*/ |
||||
|
export class Logger { |
||||
|
private static instance: Logger |
||||
|
private options: LoggerOptions = DEFAULT_OPTIONS |
||||
|
private logStream: rfs.RotatingFileStream | null = null |
||||
|
private logDir: string = logDir |
||||
|
private currentLogFile: string = "" |
||||
|
private isElectronApp: boolean = !!process.versions.electron |
||||
|
private callInitialize: boolean = false |
||||
|
|
||||
|
/** |
||||
|
* 获取单例实例 |
||||
|
*/ |
||||
|
public static getInstance(): Logger { |
||||
|
if (!Logger.instance) { |
||||
|
Logger.instance = new Logger() |
||||
|
} |
||||
|
if (Logger.instance.callInitialize) { |
||||
|
return Logger.instance |
||||
|
} else { |
||||
|
// 创建代理对象,确保只有在初始化后才能访问除init之外的方法
|
||||
|
const handler = { |
||||
|
get: function (target: any, prop: string) { |
||||
|
if (prop === "init") { |
||||
|
return target[prop] |
||||
|
} |
||||
|
if (!target.callInitialize) { |
||||
|
throw new Error(`Logger未初始化,不能调用${prop}方法,请先调用init()方法`) |
||||
|
} |
||||
|
return target[prop] |
||||
|
}, |
||||
|
} |
||||
|
Logger.instance = new Proxy(new Logger(), handler) |
||||
|
} |
||||
|
return Logger.instance |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 构造函数 |
||||
|
*/ |
||||
|
// private constructor() {}
|
||||
|
|
||||
|
public init(options?: LoggerOptions): void { |
||||
|
this.callInitialize = true |
||||
|
this.options = { ...this.options, ...options } |
||||
|
|
||||
|
// 确保日志目录存在
|
||||
|
if (!fs.existsSync(this.logDir)) { |
||||
|
fs.mkdirSync(this.logDir, { recursive: true }) |
||||
|
} |
||||
|
|
||||
|
// 初始化日志文件
|
||||
|
this.initLogFile() |
||||
|
|
||||
|
// 如果在主进程中,设置IPC监听器接收渲染进程的日志
|
||||
|
if (this.isElectronApp && process.type === "browser") { |
||||
|
this.setupIPC() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 初始化日志文件 |
||||
|
*/ |
||||
|
private initLogFile(): void { |
||||
|
if (!this.options.file) return |
||||
|
|
||||
|
// 生成日志文件名
|
||||
|
const now = new Date() |
||||
|
const timestamp = now.toISOString().replace(/[:.]/g, "-") |
||||
|
this.currentLogFile = `app-logger-${timestamp}.log` |
||||
|
|
||||
|
// 创建日志流
|
||||
|
this.logStream = rfs.createStream(this.currentLogFile, { |
||||
|
path: this.logDir, |
||||
|
size: this.options.maxSize, |
||||
|
rotate: this.options.maxFiles, |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 设置IPC通信,接收渲染进程日志 |
||||
|
*/ |
||||
|
private setupIPC(): void { |
||||
|
if (!ipcMain) return |
||||
|
ipcMain.on("logger:log", (_, level: LogLevel, namespace: string, ...messages: any[]) => { |
||||
|
this.logWithLevel(level, namespace, ...messages) |
||||
|
}) |
||||
|
|
||||
|
// 处理日志级别设置请求
|
||||
|
ipcMain.on("logger:setLevel", (_, level: LogLevel) => { |
||||
|
this.setLevel(level) |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 关闭日志流 |
||||
|
*/ |
||||
|
public close(): void { |
||||
|
if (this.logStream) { |
||||
|
this.logStream.end() |
||||
|
this.logStream.destroy() |
||||
|
this.logStream = null |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 设置日志级别 |
||||
|
*/ |
||||
|
public setLevel(level: LogLevel): void { |
||||
|
this.options.level = level |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取当前日志级别 |
||||
|
*/ |
||||
|
public getLevel(): LogLevel { |
||||
|
return this.options.level ?? LogLevel.INFO |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 根据级别记录日志 |
||||
|
*/ |
||||
|
private logWithLevel(level: LogLevel, namespace: string, ...messages: any[]): void { |
||||
|
// 检查日志级别
|
||||
|
if (level < this.getLevel() || level === LogLevel.OFF) return |
||||
|
|
||||
|
const timestamp = new Date().toISOString() |
||||
|
const levelName = LogLevelName[level] |
||||
|
const prefix = `[${timestamp}] [${namespace}] [${levelName}]` |
||||
|
|
||||
|
// 格式化消息
|
||||
|
const formattedMessages = messages.map(msg => { |
||||
|
if (typeof msg === "object") { |
||||
|
try { |
||||
|
return JSON.stringify(msg) |
||||
|
} catch (e) { |
||||
|
return String(msg) |
||||
|
} |
||||
|
} |
||||
|
return String(msg) |
||||
|
}) |
||||
|
|
||||
|
const message = formattedMessages.join(" ") |
||||
|
|
||||
|
// 输出到控制台
|
||||
|
if (this.options.console) { |
||||
|
const color = LogLevelColor[level] |
||||
|
console.log(`${color}${prefix} ${message}${RESET_COLOR}`) |
||||
|
} |
||||
|
|
||||
|
// 写入日志文件
|
||||
|
if (this.options.file && this.logStream) { |
||||
|
this.logStream.write(`${prefix} ${message}\n`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 记录跟踪级别日志 |
||||
|
*/ |
||||
|
public trace(namespace: string, ...messages: any[]): void { |
||||
|
this.logWithLevel(LogLevel.TRACE, namespace, ...messages) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 记录调试级别日志 |
||||
|
*/ |
||||
|
public debug(namespace: string, ...messages: any[]): void { |
||||
|
this.logWithLevel(LogLevel.DEBUG, namespace, ...messages) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 记录信息级别日志 |
||||
|
*/ |
||||
|
public info(namespace: string, ...messages: any[]): void { |
||||
|
this.logWithLevel(LogLevel.INFO, namespace, ...messages) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 记录警告级别日志 |
||||
|
*/ |
||||
|
public warn(namespace: string, ...messages: any[]): void { |
||||
|
this.logWithLevel(LogLevel.WARN, namespace, ...messages) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 记录错误级别日志 |
||||
|
*/ |
||||
|
public error(namespace: string, ...messages: any[]): void { |
||||
|
this.logWithLevel(LogLevel.ERROR, namespace, ...messages) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 记录致命错误级别日志 |
||||
|
*/ |
||||
|
public fatal(namespace: string, ...messages: any[]): void { |
||||
|
this.logWithLevel(LogLevel.FATAL, namespace, ...messages) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 创建一个固定命名空间的日志记录器 |
||||
|
* @param namespace 命名空间 |
||||
|
* @returns 带有固定命名空间的日志记录器 |
||||
|
*/ |
||||
|
public createNamespace(namespace: string) { |
||||
|
return { |
||||
|
trace: (...messages: any[]) => this.trace(namespace, ...messages), |
||||
|
debug: (...messages: any[]) => this.debug(namespace, ...messages), |
||||
|
info: (...messages: any[]) => this.info(namespace, ...messages), |
||||
|
warn: (...messages: any[]) => this.warn(namespace, ...messages), |
||||
|
error: (...messages: any[]) => this.error(namespace, ...messages), |
||||
|
fatal: (...messages: any[]) => this.fatal(namespace, ...messages), |
||||
|
setLevel: (level: LogLevel) => this.setLevel(level), |
||||
|
getLevel: () => this.getLevel(), |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 默认实例
|
||||
|
const logger = Logger.getInstance() |
||||
|
logger.init() |
||||
|
emitter.on("update", setting => { |
||||
|
logger.setLevel(setting.debug) |
||||
|
}) |
||||
|
|
||||
|
// 应用退出时关闭日志流
|
||||
|
if (process.type === "browser" && app) { |
||||
|
app.on("before-quit", () => { |
||||
|
logger.info("app", "应用关闭") |
||||
|
logger.close() |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
export default logger |
@ -0,0 +1,7 @@ |
|||||
|
{ |
||||
|
"name": "logger", |
||||
|
"version": "1.0.0", |
||||
|
"keywords": [], |
||||
|
"author": "", |
||||
|
"license": "ISC" |
||||
|
} |
@ -0,0 +1,195 @@ |
|||||
|
import { contextBridge, ipcRenderer } from "electron" |
||||
|
import { LogLevel, LogLevelName } from "./common" |
||||
|
import logger from "./preload" |
||||
|
|
||||
|
/** |
||||
|
* 错误详情接口 |
||||
|
*/ |
||||
|
interface ErrorDetail { |
||||
|
message: string |
||||
|
stack?: string |
||||
|
componentInfo?: string |
||||
|
additionalInfo?: Record<string, any> |
||||
|
timestamp: string |
||||
|
type: string |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 错误处理配置 |
||||
|
*/ |
||||
|
interface ErrorHandlerOptions { |
||||
|
namespace?: string |
||||
|
level?: LogLevel |
||||
|
includeStack?: boolean |
||||
|
includeComponentInfo?: boolean |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 渲染进程错误处理接口 |
||||
|
*/ |
||||
|
interface IRendererErrorHandler { |
||||
|
/** |
||||
|
* 捕获错误 |
||||
|
*/ |
||||
|
captureError(error: any, componentInfo?: string, additionalInfo?: Record<string, any>): void |
||||
|
|
||||
|
/** |
||||
|
* 设置错误处理选项 |
||||
|
*/ |
||||
|
setOptions(options: Partial<ErrorHandlerOptions>): void |
||||
|
|
||||
|
/** |
||||
|
* 获取当前选项 |
||||
|
*/ |
||||
|
getOptions(): ErrorHandlerOptions |
||||
|
|
||||
|
/** |
||||
|
* 安装全局错误处理器 |
||||
|
*/ |
||||
|
installGlobalHandlers(): void |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 默认错误处理配置 |
||||
|
*/ |
||||
|
const DEFAULT_OPTIONS: ErrorHandlerOptions = { |
||||
|
namespace: "error", |
||||
|
level: LogLevel.ERROR, |
||||
|
includeStack: true, |
||||
|
includeComponentInfo: true, |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 格式化错误信息 |
||||
|
*/ |
||||
|
const formatError = (error: any, options: ErrorHandlerOptions): ErrorDetail => { |
||||
|
// 基本错误信息
|
||||
|
const errorDetail: ErrorDetail = { |
||||
|
message: "", |
||||
|
timestamp: new Date().toISOString(), |
||||
|
type: "Unknown", |
||||
|
} |
||||
|
// 处理不同类型的错误
|
||||
|
if (error instanceof Error) { |
||||
|
errorDetail.message = error.message |
||||
|
errorDetail.type = error.name || error.constructor.name |
||||
|
if (options.includeStack) { |
||||
|
errorDetail.stack = error.stack |
||||
|
} |
||||
|
} else if (typeof error === "string") { |
||||
|
errorDetail.message = error |
||||
|
errorDetail.type = "String" |
||||
|
} else if (error === null) { |
||||
|
errorDetail.message = "Null error received" |
||||
|
errorDetail.type = "Null" |
||||
|
} else if (error === undefined) { |
||||
|
errorDetail.message = "Undefined error received" |
||||
|
errorDetail.type = "Undefined" |
||||
|
} else if (typeof error === "object") { |
||||
|
try { |
||||
|
errorDetail.message = error.message || JSON.stringify(error) |
||||
|
errorDetail.type = "Object" |
||||
|
errorDetail.additionalInfo = { ...error } |
||||
|
} catch (e) { |
||||
|
errorDetail.message = "Unserializable error object" |
||||
|
errorDetail.type = "Unserializable" |
||||
|
} |
||||
|
} else { |
||||
|
try { |
||||
|
errorDetail.message = String(error) |
||||
|
errorDetail.type = typeof error |
||||
|
} catch (e) { |
||||
|
errorDetail.message = "Error converting to string" |
||||
|
errorDetail.type = "Unknown" |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return errorDetail |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 创建渲染进程错误处理器 |
||||
|
*/ |
||||
|
const createRendererErrorHandler = (): IRendererErrorHandler => { |
||||
|
// 当前错误处理选项
|
||||
|
let options: ErrorHandlerOptions = { ...DEFAULT_OPTIONS } |
||||
|
|
||||
|
/** |
||||
|
* 处理并转发错误到主进程 |
||||
|
*/ |
||||
|
const handleError = (error: any, componentInfo?: string, additionalInfo?: Record<string, any>) => { |
||||
|
// 如果已经是ErrorDetail格式,直接使用
|
||||
|
let errorDetail: ErrorDetail |
||||
|
if (error && typeof error === "object" && error.type && error.message && error.timestamp) { |
||||
|
errorDetail = error as ErrorDetail |
||||
|
} else { |
||||
|
// 否则格式化错误
|
||||
|
errorDetail = formatError(error, options) |
||||
|
} |
||||
|
|
||||
|
// 添加组件信息
|
||||
|
if (options.includeComponentInfo && componentInfo) { |
||||
|
errorDetail.componentInfo = componentInfo |
||||
|
} |
||||
|
|
||||
|
// 使用logger记录错误
|
||||
|
const namespace = options.namespace || "error" |
||||
|
const level = LogLevelName[options.level || LogLevel.ERROR].toLowerCase() |
||||
|
|
||||
|
// 添加额外信息
|
||||
|
if (additionalInfo) { |
||||
|
errorDetail.additionalInfo = { |
||||
|
...errorDetail.additionalInfo, |
||||
|
...additionalInfo, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 记录完整的错误信息
|
||||
|
logger[level](namespace, JSON.stringify(errorDetail)) |
||||
|
|
||||
|
// 同时在控制台输出错误信息
|
||||
|
logger[level](namespace, `${errorDetail.type}: ${errorDetail.message}`) |
||||
|
if (errorDetail.stack) { |
||||
|
logger[level](namespace, `Stack: ${errorDetail.stack}`) |
||||
|
} |
||||
|
|
||||
|
// 如果有额外信息,单独记录
|
||||
|
if (errorDetail.additionalInfo) { |
||||
|
try { |
||||
|
const additionalInfoStr = JSON.stringify(errorDetail.additionalInfo, null, 2) |
||||
|
logger[level](namespace, `Additional Info: ${additionalInfoStr}`) |
||||
|
} catch (e) { |
||||
|
logger[level](namespace, "Additional Info: [Unserializable]") |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 空的安装全局错误处理器方法 |
||||
|
* 实际的全局错误处理由renderer-error.ts负责 |
||||
|
*/ |
||||
|
const installGlobalHandlers = () => { |
||||
|
// 不再在preload层安装全局错误处理器
|
||||
|
// 仅记录日志表明该方法被调用
|
||||
|
logger.info("[ErrorHandler] Global error handlers should be installed in renderer process") |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
captureError: handleError, |
||||
|
setOptions: (newOptions: Partial<ErrorHandlerOptions>) => { |
||||
|
options = { ...options, ...newOptions } |
||||
|
// 同步选项到主进程
|
||||
|
ipcRenderer.send("logger:errorOptions", options) |
||||
|
}, |
||||
|
getOptions: () => ({ ...options }), |
||||
|
installGlobalHandlers, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const errorHandler = createRendererErrorHandler() |
||||
|
|
||||
|
// 暴露错误处理器到渲染进程全局
|
||||
|
contextBridge.exposeInMainWorld("preloadErrorHandler", errorHandler) |
||||
|
|
||||
|
// 导出类型定义,方便在渲染进程中使用
|
||||
|
export type { IRendererErrorHandler, ErrorDetail, ErrorHandlerOptions } |
@ -0,0 +1,153 @@ |
|||||
|
import { contextBridge, ipcRenderer } from "electron" |
||||
|
import { LogLevel } from "./common" |
||||
|
|
||||
|
/** |
||||
|
* 渲染进程日志接口 |
||||
|
*/ |
||||
|
interface IRendererLogger { |
||||
|
trace(namespace: string, ...messages: any[]): void |
||||
|
debug(namespace: string, ...messages: any[]): void |
||||
|
info(namespace: string, ...messages: any[]): void |
||||
|
warn(namespace: string, ...messages: any[]): void |
||||
|
error(namespace: string, ...messages: any[]): void |
||||
|
fatal(namespace: string, ...messages: any[]): void |
||||
|
setLevel(level: LogLevel): void |
||||
|
createNamespace(namespace: string): INamespacedLogger |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 命名空间作用域日志接口 |
||||
|
*/ |
||||
|
interface INamespacedLogger { |
||||
|
trace(...messages: any[]): void |
||||
|
debug(...messages: any[]): void |
||||
|
info(...messages: any[]): void |
||||
|
warn(...messages: any[]): void |
||||
|
error(...messages: any[]): void |
||||
|
fatal(...messages: any[]): void |
||||
|
setLevel(level: LogLevel): void |
||||
|
} |
||||
|
|
||||
|
// 日志级别名称映射
|
||||
|
const LogLevelName: Record<LogLevel, string> = { |
||||
|
[LogLevel.TRACE]: "TRACE", |
||||
|
[LogLevel.DEBUG]: "DEBUG", |
||||
|
[LogLevel.INFO]: "INFO", |
||||
|
[LogLevel.WARN]: "WARN", |
||||
|
[LogLevel.ERROR]: "ERROR", |
||||
|
[LogLevel.FATAL]: "FATAL", |
||||
|
[LogLevel.OFF]: "OFF", |
||||
|
} |
||||
|
|
||||
|
// 日志颜色映射(控制台输出用)
|
||||
|
const LogLevelColor: Record<LogLevel, string> = { |
||||
|
[LogLevel.TRACE]: "\x1b[90m", // 灰色
|
||||
|
[LogLevel.DEBUG]: "\x1b[36m", // 青色
|
||||
|
[LogLevel.INFO]: "\x1b[32m", // 绿色
|
||||
|
[LogLevel.WARN]: "\x1b[33m", // 黄色
|
||||
|
[LogLevel.ERROR]: "\x1b[31m", // 红色
|
||||
|
[LogLevel.FATAL]: "\x1b[35m", // 紫色
|
||||
|
[LogLevel.OFF]: "", // 无色
|
||||
|
} |
||||
|
|
||||
|
// 重置颜色的ANSI代码
|
||||
|
const RESET_COLOR = "\x1b[0m" |
||||
|
|
||||
|
/** |
||||
|
* 创建渲染进程日志对象 |
||||
|
*/ |
||||
|
const createRendererLogger = (): IRendererLogger => { |
||||
|
// 当前日志级别
|
||||
|
let currentLevel: LogLevel = LogLevel.INFO |
||||
|
|
||||
|
// 格式化消息
|
||||
|
const formatMessages = (messages: any[]): string => { |
||||
|
return messages |
||||
|
.map(msg => { |
||||
|
if (typeof msg === "object") { |
||||
|
try { |
||||
|
return JSON.stringify(msg) |
||||
|
} catch (e) { |
||||
|
return String(msg) |
||||
|
} |
||||
|
} |
||||
|
return String(msg) |
||||
|
}) |
||||
|
.join(" ") |
||||
|
} |
||||
|
|
||||
|
// 本地打印日志
|
||||
|
const printLog = (level: LogLevel, namespace: string, ...messages: any[]): void => { |
||||
|
// 检查日志级别
|
||||
|
if (level < currentLevel || level === LogLevel.OFF) return |
||||
|
|
||||
|
const timestamp = new Date().toISOString() |
||||
|
const levelName = LogLevelName[level] |
||||
|
const prefix = `[${timestamp}] [${namespace}] [${levelName}]` |
||||
|
const message = formatMessages(messages) |
||||
|
|
||||
|
// 输出到控制台
|
||||
|
const color = LogLevelColor[level] |
||||
|
console.log(`${color}${prefix} ${message}${RESET_COLOR}`) |
||||
|
} |
||||
|
|
||||
|
// 通过IPC发送日志到主进程
|
||||
|
const sendLog = (level: LogLevel, namespace: string, ...messages: any[]) => { |
||||
|
// 本地打印
|
||||
|
printLog(level, namespace, ...messages) |
||||
|
|
||||
|
// 发送到主进程
|
||||
|
ipcRenderer.send("logger:log", level, namespace, ...messages) |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
trace(namespace: string, ...messages: any[]): void { |
||||
|
sendLog(LogLevel.TRACE, namespace, ...messages) |
||||
|
}, |
||||
|
debug(namespace: string, ...messages: any[]): void { |
||||
|
sendLog(LogLevel.DEBUG, namespace, ...messages) |
||||
|
}, |
||||
|
info(namespace: string, ...messages: any[]): void { |
||||
|
sendLog(LogLevel.INFO, namespace, ...messages) |
||||
|
}, |
||||
|
warn(namespace: string, ...messages: any[]): void { |
||||
|
sendLog(LogLevel.WARN, namespace, ...messages) |
||||
|
}, |
||||
|
error(namespace: string, ...messages: any[]): void { |
||||
|
sendLog(LogLevel.ERROR, namespace, ...messages) |
||||
|
}, |
||||
|
fatal(namespace: string, ...messages: any[]): void { |
||||
|
sendLog(LogLevel.FATAL, namespace, ...messages) |
||||
|
}, |
||||
|
setLevel(level: LogLevel): void { |
||||
|
// 更新本地日志级别
|
||||
|
currentLevel = level |
||||
|
// 设置日志级别(可选,如果需要在渲染进程中动态调整日志级别)
|
||||
|
ipcRenderer.send("logger:setLevel", level) |
||||
|
}, |
||||
|
createNamespace(namespace: string): INamespacedLogger { |
||||
|
return { |
||||
|
trace: (...messages: any[]) => sendLog(LogLevel.TRACE, namespace, ...messages), |
||||
|
debug: (...messages: any[]) => sendLog(LogLevel.DEBUG, namespace, ...messages), |
||||
|
info: (...messages: any[]) => sendLog(LogLevel.INFO, namespace, ...messages), |
||||
|
warn: (...messages: any[]) => sendLog(LogLevel.WARN, namespace, ...messages), |
||||
|
error: (...messages: any[]) => sendLog(LogLevel.ERROR, namespace, ...messages), |
||||
|
fatal: (...messages: any[]) => sendLog(LogLevel.FATAL, namespace, ...messages), |
||||
|
setLevel: (level: LogLevel) => { |
||||
|
currentLevel = level |
||||
|
ipcRenderer.send("logger:setLevel", level) |
||||
|
}, |
||||
|
} |
||||
|
}, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const logger = createRendererLogger() |
||||
|
|
||||
|
// 暴露logger对象到渲染进程全局
|
||||
|
contextBridge.exposeInMainWorld("logger", logger) |
||||
|
|
||||
|
export { logger } |
||||
|
export default logger |
||||
|
// 导出类型定义,方便在渲染进程中使用
|
||||
|
export type { IRendererLogger } |
@ -0,0 +1,243 @@ |
|||||
|
import { LogLevel } from "./common" |
||||
|
|
||||
|
/** |
||||
|
* 错误详情接口 |
||||
|
*/ |
||||
|
interface ErrorDetail { |
||||
|
message: string |
||||
|
stack?: string |
||||
|
componentInfo?: string |
||||
|
additionalInfo?: Record<string, any> |
||||
|
timestamp: string |
||||
|
type: string |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 错误处理配置 |
||||
|
*/ |
||||
|
interface ErrorHandlerOptions { |
||||
|
namespace?: string |
||||
|
level?: LogLevel |
||||
|
includeStack?: boolean |
||||
|
includeComponentInfo?: boolean |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 渲染进程错误处理接口 |
||||
|
*/ |
||||
|
export interface IRendererErrorHandler { |
||||
|
/** |
||||
|
* 捕获错误 |
||||
|
*/ |
||||
|
captureError(error: any, componentInfo?: string, additionalInfo?: Record<string, any>): void |
||||
|
|
||||
|
/** |
||||
|
* 设置错误处理选项 |
||||
|
*/ |
||||
|
setOptions(options: Partial<ErrorHandlerOptions>): void |
||||
|
|
||||
|
/** |
||||
|
* 获取当前选项 |
||||
|
*/ |
||||
|
getOptions(): ErrorHandlerOptions |
||||
|
|
||||
|
/** |
||||
|
* 安装全局错误处理器 |
||||
|
*/ |
||||
|
installGlobalHandlers(): void |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 默认错误处理配置 |
||||
|
*/ |
||||
|
const DEFAULT_OPTIONS: ErrorHandlerOptions = { |
||||
|
namespace: "error", |
||||
|
level: LogLevel.ERROR, |
||||
|
includeStack: true, |
||||
|
includeComponentInfo: true, |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 格式化错误信息 |
||||
|
*/ |
||||
|
const formatError = (error: any, options: ErrorHandlerOptions): ErrorDetail => { |
||||
|
// 基本错误信息
|
||||
|
const errorDetail: ErrorDetail = { |
||||
|
message: "", |
||||
|
timestamp: new Date().toISOString(), |
||||
|
type: "Unknown", |
||||
|
} |
||||
|
console.log(error) |
||||
|
|
||||
|
// 处理不同类型的错误
|
||||
|
if (error instanceof Error) { |
||||
|
errorDetail.message = error.message |
||||
|
errorDetail.type = error.name || error.constructor.name |
||||
|
if (options.includeStack) { |
||||
|
errorDetail.stack = error.stack |
||||
|
} |
||||
|
} else if (typeof error === "string") { |
||||
|
errorDetail.message = error |
||||
|
errorDetail.type = "String" |
||||
|
} else if (error === null) { |
||||
|
errorDetail.message = "Null error received" |
||||
|
errorDetail.type = "Null" |
||||
|
} else if (error === undefined) { |
||||
|
errorDetail.message = "Undefined error received" |
||||
|
errorDetail.type = "Undefined" |
||||
|
} else if (typeof error === "object") { |
||||
|
try { |
||||
|
errorDetail.message = error.message || JSON.stringify(error) |
||||
|
errorDetail.type = "Object" |
||||
|
errorDetail.additionalInfo = { ...error } |
||||
|
} catch (e) { |
||||
|
errorDetail.message = "Unserializable error object" |
||||
|
errorDetail.type = "Unserializable" |
||||
|
} |
||||
|
} else { |
||||
|
try { |
||||
|
errorDetail.message = String(error) |
||||
|
errorDetail.type = typeof error |
||||
|
} catch (e) { |
||||
|
errorDetail.message = "Error converting to string" |
||||
|
errorDetail.type = "Unknown" |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return errorDetail |
||||
|
} |
||||
|
|
||||
|
// @ts-ignore
|
||||
|
const preloadErrorHandler = window.preloadErrorHandler |
||||
|
|
||||
|
/** |
||||
|
* 创建渲染进程错误处理器 |
||||
|
*/ |
||||
|
export const createRendererErrorHandler = (): IRendererErrorHandler => { |
||||
|
// 当前错误处理选项
|
||||
|
let options: ErrorHandlerOptions = { ...DEFAULT_OPTIONS } |
||||
|
|
||||
|
/** |
||||
|
* 处理错误并序列化 |
||||
|
*/ |
||||
|
const processError = (error: any, componentInfo?: string, additionalInfo?: Record<string, any>): ErrorDetail => { |
||||
|
const errorDetail = formatError(error, options) |
||||
|
|
||||
|
// 添加组件信息
|
||||
|
if (options.includeComponentInfo && componentInfo) { |
||||
|
errorDetail.componentInfo = componentInfo |
||||
|
} |
||||
|
|
||||
|
// 添加额外信息
|
||||
|
if (additionalInfo) { |
||||
|
errorDetail.additionalInfo = { |
||||
|
...errorDetail.additionalInfo, |
||||
|
...additionalInfo, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return errorDetail |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 发送错误到preload层 |
||||
|
*/ |
||||
|
const sendError = (error: any, componentInfo?: string, additionalInfo?: Record<string, any>) => { |
||||
|
// 处理并序列化错误
|
||||
|
const errorDetail = processError(error, componentInfo, additionalInfo) |
||||
|
|
||||
|
// 调用window.errorHandler.captureError发送错误
|
||||
|
// 这里假设preload层已经暴露了errorHandler对象
|
||||
|
if (preloadErrorHandler && typeof preloadErrorHandler.captureError === "function") { |
||||
|
preloadErrorHandler.captureError(errorDetail) |
||||
|
} else { |
||||
|
// 如果errorHandler不可用,则降级到控制台输出
|
||||
|
console.error("[ErrorHandler]", errorDetail) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 安装全局错误处理器 |
||||
|
*/ |
||||
|
const installGlobalHandlers = () => { |
||||
|
// 捕获未处理的异常
|
||||
|
window.addEventListener("error", event => { |
||||
|
event.preventDefault() |
||||
|
sendError(event.error || event.message, "window.onerror", { |
||||
|
filename: event.filename, |
||||
|
lineno: event.lineno, |
||||
|
colno: event.colno, |
||||
|
}) |
||||
|
return true |
||||
|
}) |
||||
|
|
||||
|
// 捕获未处理的Promise拒绝
|
||||
|
window.addEventListener("unhandledrejection", event => { |
||||
|
event.preventDefault() |
||||
|
sendError(event.reason, "unhandledrejection", { |
||||
|
promise: "[Promise]", // 不能直接序列化Promise对象
|
||||
|
}) |
||||
|
return true |
||||
|
}) |
||||
|
|
||||
|
// 捕获资源加载错误
|
||||
|
document.addEventListener( |
||||
|
"error", |
||||
|
event => { |
||||
|
// 只处理资源加载错误
|
||||
|
if (event.target && (event.target as HTMLElement).tagName) { |
||||
|
const target = event.target as HTMLElement |
||||
|
sendError(`Resource load failed: ${(target as any).src || (target as any).href}`, "resource.error", { |
||||
|
tagName: target.tagName, |
||||
|
src: (target as any).src, |
||||
|
href: (target as any).href, |
||||
|
}) |
||||
|
} |
||||
|
}, |
||||
|
true, |
||||
|
) // 使用捕获阶段
|
||||
|
|
||||
|
console.info("[ErrorHandler] Global error handlers installed") |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
captureError: sendError, |
||||
|
setOptions: (newOptions: Partial<ErrorHandlerOptions>) => { |
||||
|
options = { ...options, ...newOptions } |
||||
|
// 同步选项到preload层
|
||||
|
if (preloadErrorHandler && typeof preloadErrorHandler.setOptions === "function") { |
||||
|
preloadErrorHandler.setOptions(options) |
||||
|
} |
||||
|
}, |
||||
|
getOptions: () => ({ ...options }), |
||||
|
installGlobalHandlers, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 导出类型定义,方便在渲染进程中使用
|
||||
|
export type { ErrorDetail, ErrorHandlerOptions } |
||||
|
|
||||
|
// 创建渲染进程错误处理器
|
||||
|
const errorHandler = createRendererErrorHandler() |
||||
|
|
||||
|
// 安装全局错误处理器
|
||||
|
errorHandler.installGlobalHandlers() |
||||
|
|
||||
|
window.errorHandler = errorHandler |
||||
|
|
||||
|
/** |
||||
|
* 使用示例: |
||||
|
* |
||||
|
* // 捕获特定错误
|
||||
|
* try { |
||||
|
* // 可能出错的代码
|
||||
|
* } catch (error) { |
||||
|
* errorHandler.captureError(error, 'ComponentName', { additionalInfo: 'value' }) |
||||
|
* } |
||||
|
* |
||||
|
* // 设置错误处理选项
|
||||
|
* errorHandler.setOptions({ |
||||
|
* namespace: 'custom-error', |
||||
|
* includeComponentInfo: true |
||||
|
* }) |
||||
|
*/ |
@ -0,0 +1,231 @@ |
|||||
|
import fs from "fs-extra" |
||||
|
import { app } from "electron" |
||||
|
import path from "path" |
||||
|
import { cloneDeep } from "lodash" |
||||
|
import Config from "config" |
||||
|
import type { IDefaultConfig } from "config" |
||||
|
import _logger from "logger/main" |
||||
|
|
||||
|
const logger = _logger.createNamespace("setting") |
||||
|
|
||||
|
type IConfig = IDefaultConfig |
||||
|
|
||||
|
type IOnFunc = (n: IConfig, c: IConfig, keys?: (keyof IConfig)[]) => void |
||||
|
type IT = (keyof IConfig)[] | keyof IConfig | "_" |
||||
|
|
||||
|
let storagePath = path.join(app.getPath("documents"), Config.app_title) |
||||
|
const storagePathDev = path.join(app.getPath("documents"), Config.app_title + "-dev") |
||||
|
|
||||
|
if (process.env.NODE_ENV === "development") { |
||||
|
storagePath = storagePathDev |
||||
|
} |
||||
|
|
||||
|
const _tempConfig = cloneDeep(Config.default_config as IConfig) |
||||
|
Object.keys(_tempConfig).forEach(key => { |
||||
|
if (typeof _tempConfig[key] === "string" && _tempConfig[key].includes("$storagePath$")) { |
||||
|
_tempConfig[key] = _tempConfig[key].replace(/\$storagePath\$/g, storagePath) |
||||
|
if (_tempConfig[key] && path.isAbsolute(_tempConfig[key])) { |
||||
|
_tempConfig[key] = path.normalize(_tempConfig[key]) |
||||
|
} |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
function isPath(str) { |
||||
|
// 使用正则表达式检查字符串是否以斜杠或盘符开头
|
||||
|
return /^(?:\/|[a-zA-Z]:\\)/.test(str) |
||||
|
} |
||||
|
|
||||
|
function init(config: IConfig) { |
||||
|
// 在配置初始化后执行
|
||||
|
Object.keys(config).forEach(key => { |
||||
|
if (config[key] && isPath(config[key]) && path.isAbsolute(config[key])) { |
||||
|
fs.ensureDirSync(config[key]) |
||||
|
} |
||||
|
}) |
||||
|
// 在配置初始化后执行
|
||||
|
// fs.ensureDirSync(config["snippet.storagePath"])
|
||||
|
// fs.ensureDirSync(config["bookmark.storagePath"])
|
||||
|
} |
||||
|
|
||||
|
// 判断是否是空文件夹
|
||||
|
function isEmptyDir(fPath: string) { |
||||
|
const pa = fs.readdirSync(fPath) |
||||
|
if (pa.length === 0) { |
||||
|
return true |
||||
|
} else { |
||||
|
return false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
class SettingClass { |
||||
|
constructor() { |
||||
|
logger.debug(`Setting inited`) |
||||
|
this.init() |
||||
|
} |
||||
|
|
||||
|
#cb: [IT, IOnFunc][] = [] |
||||
|
|
||||
|
onChange(fn: IOnFunc, that?: any) |
||||
|
onChange(key: IT, fn: IOnFunc, that?: any) |
||||
|
onChange(fnOrType: IT | IOnFunc, fnOrThat: IOnFunc | any = null, that: any = null) { |
||||
|
if (typeof fnOrType === "function") { |
||||
|
this.#cb.push(["_", fnOrType.bind(fnOrThat)]) |
||||
|
} else { |
||||
|
this.#cb.push([fnOrType, fnOrThat.bind(that)]) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
#runCB(n: IConfig, c: IConfig, keys: (keyof IConfig)[]) { |
||||
|
for (let i = 0; i < this.#cb.length; i++) { |
||||
|
const temp = this.#cb[i] |
||||
|
const k = temp[0] |
||||
|
const fn = temp[1] |
||||
|
if (k === "_") { |
||||
|
fn(n, c, keys) |
||||
|
} |
||||
|
if (typeof k === "string" && keys.includes(k as keyof IConfig)) { |
||||
|
fn(n, c) |
||||
|
} |
||||
|
if (Array.isArray(k) && k.filter(v => keys.indexOf(v) !== -1).length) { |
||||
|
fn(n, c) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
#pathFile: string = |
||||
|
process.env.NODE_ENV === "development" |
||||
|
? path.resolve(app.getPath("userData"), "./config_path-dev") |
||||
|
: path.resolve(app.getPath("userData"), "./config_path") |
||||
|
#config: IConfig = cloneDeep(_tempConfig) |
||||
|
#configPath(storagePath?: string): string { |
||||
|
return path.join(storagePath || this.#config.storagePath, "./config.json") |
||||
|
} |
||||
|
/** |
||||
|
* 读取配置文件变量同步 |
||||
|
* @param confingPath 配置文件路径 |
||||
|
*/ |
||||
|
#syncVar(confingPath?: string) { |
||||
|
const configFile = this.#configPath(confingPath) |
||||
|
if (!fs.pathExistsSync(configFile)) { |
||||
|
fs.ensureFileSync(configFile) |
||||
|
fs.writeJSONSync(configFile, {}) |
||||
|
} |
||||
|
const config = fs.readJSONSync(configFile) as IConfig |
||||
|
confingPath && (config.storagePath = confingPath) |
||||
|
// 优先取本地的值
|
||||
|
for (const key in config) { |
||||
|
// if (Object.prototype.hasOwnProperty.call(this.#config, key)) {
|
||||
|
// this.#config[key] = config[key] || this.#config[key]
|
||||
|
// }
|
||||
|
// 删除配置时本地的配置不会改变,想一下哪种方式更好
|
||||
|
this.#config[key] = config[key] ?? this.#config[key] |
||||
|
} |
||||
|
} |
||||
|
init() { |
||||
|
logger.debug(`位置:${this.#pathFile}`) |
||||
|
|
||||
|
if (fs.pathExistsSync(this.#pathFile)) { |
||||
|
const confingPath = fs.readFileSync(this.#pathFile, { encoding: "utf8" }) |
||||
|
if (confingPath && fs.pathExistsSync(this.#configPath(confingPath))) { |
||||
|
this.#syncVar(confingPath) |
||||
|
// 防止增加了配置本地却没变的情况
|
||||
|
this.#sync(confingPath) |
||||
|
} else { |
||||
|
this.#syncVar(confingPath) |
||||
|
this.#sync(confingPath) |
||||
|
} |
||||
|
} else { |
||||
|
this.#syncVar() |
||||
|
this.#sync() |
||||
|
} |
||||
|
init.call(this, this.#config) |
||||
|
} |
||||
|
config() { |
||||
|
return this.#config |
||||
|
} |
||||
|
#sync(c?: string) { |
||||
|
const config = cloneDeep(this.#config) |
||||
|
delete config.storagePath |
||||
|
const p = this.#configPath(c) |
||||
|
fs.ensureFileSync(p) |
||||
|
fs.writeJSONSync(this.#configPath(c), config) |
||||
|
} |
||||
|
#change(p: string) { |
||||
|
const storagePath = this.#config.storagePath |
||||
|
if (fs.existsSync(storagePath) && !fs.existsSync(p)) { |
||||
|
fs.moveSync(storagePath, p) |
||||
|
} |
||||
|
if (fs.existsSync(p) && fs.existsSync(storagePath) && isEmptyDir(p)) { |
||||
|
fs.moveSync(storagePath, p, { overwrite: true }) |
||||
|
} |
||||
|
fs.writeFileSync(this.#pathFile, p, { encoding: "utf8" }) |
||||
|
} |
||||
|
reset(key: keyof IConfig) { |
||||
|
this.set(key, cloneDeep(_tempConfig[key])) |
||||
|
} |
||||
|
set(key: keyof IConfig | Partial<IConfig>, value?: any) { |
||||
|
const oldMainConfig = Object.assign({}, this.#config) |
||||
|
let isChange = false |
||||
|
const changeKeys: (keyof IConfig)[] = [] |
||||
|
const canChangeStorage = (targetPath: string) => { |
||||
|
if (fs.existsSync(oldMainConfig.storagePath) && fs.existsSync(targetPath) && !isEmptyDir(targetPath)) { |
||||
|
if (fs.existsSync(path.join(targetPath, "./config.json"))) { |
||||
|
return true |
||||
|
} |
||||
|
return false |
||||
|
} |
||||
|
return true |
||||
|
} |
||||
|
if (typeof key === "string") { |
||||
|
if (value != undefined && value !== this.#config[key]) { |
||||
|
if (key === "storagePath") { |
||||
|
if (!canChangeStorage(value)) { |
||||
|
throw "无法改变存储地址" |
||||
|
return |
||||
|
} |
||||
|
this.#change(value) |
||||
|
changeKeys.push("storagePath") |
||||
|
this.#config["storagePath"] = value |
||||
|
} else { |
||||
|
changeKeys.push(key) |
||||
|
this.#config[key as string] = value |
||||
|
} |
||||
|
isChange = true |
||||
|
} |
||||
|
} else { |
||||
|
if (key["storagePath"] !== undefined && key["storagePath"] !== this.#config["storagePath"]) { |
||||
|
if (!canChangeStorage(key["storagePath"])) { |
||||
|
throw "无法改变存储地址" |
||||
|
return |
||||
|
} |
||||
|
this.#change(key["storagePath"]) |
||||
|
this.#config["storagePath"] = key["storagePath"] |
||||
|
changeKeys.push("storagePath") |
||||
|
isChange = true |
||||
|
} |
||||
|
for (const _ in key) { |
||||
|
if (Object.prototype.hasOwnProperty.call(key, _)) { |
||||
|
const v = key[_] |
||||
|
if (v != undefined && _ !== "storagePath" && v !== this.#config[_]) { |
||||
|
this.#config[_] = v |
||||
|
changeKeys.push(_ as keyof IConfig) |
||||
|
isChange = true |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
if (isChange) { |
||||
|
this.#sync() |
||||
|
this.#runCB(this.#config, oldMainConfig, changeKeys) |
||||
|
} |
||||
|
} |
||||
|
values<T extends keyof IConfig>(key: T): IConfig[T] { |
||||
|
return this.#config[key] |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const Setting = new SettingClass() |
||||
|
|
||||
|
export default Setting |
||||
|
export { Setting } |
||||
|
export type { IConfig, IOnFunc } |
@ -0,0 +1,6 @@ |
|||||
|
import { buildEmitter } from "base/event/main" |
||||
|
import type { IOnFunc } from "setting/main" |
||||
|
|
||||
|
export const emitter = buildEmitter<{ |
||||
|
update: IOnFunc |
||||
|
}>() |
@ -0,0 +1,7 @@ |
|||||
|
{ |
||||
|
"name": "setting", |
||||
|
"version": "1.0.0", |
||||
|
"keywords": [], |
||||
|
"author": "", |
||||
|
"license": "ISC" |
||||
|
} |
File diff suppressed because it is too large
@ -1,11 +1,12 @@ |
|||||
<!DOCTYPE html> |
<!doctype html> |
||||
<html lang="en"> |
<html lang="en"> |
||||
<head> |
<head> |
||||
<meta charset="UTF-8"> |
<meta charset="UTF-8" /> |
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
||||
<title>Document</title> |
<title>Document</title> |
||||
</head> |
</head> |
||||
<body> |
<body> |
||||
前往 <a href="https://baidu.com" target="_blank">百度</a> |
前往 |
||||
|
<a href="https://baidu.com" target="_blank">百度</a> |
||||
</body> |
</body> |
||||
</html> |
</html> |
||||
|
@ -0,0 +1,34 @@ |
|||||
|
import { Container, ContainerModule } from "inversify" |
||||
|
|
||||
|
/** |
||||
|
* 自动加载所有命令模块 |
||||
|
*/ |
||||
|
const commandModules = import.meta.glob("./event/**/main/command.{ts,js}", { eager: true }) |
||||
|
|
||||
|
const modules = new ContainerModule(bind => { |
||||
|
// 自动绑定所有命令类
|
||||
|
Object.values(commandModules).forEach(module => { |
||||
|
// 由于 module 类型为 unknown,先进行类型断言为包含 default 属性的对象
|
||||
|
const CommandClass = (module as { default: any }).default |
||||
|
if (CommandClass) { |
||||
|
const className = CommandClass.name.replace("Command", "") |
||||
|
if (CommandClass["init"]) { |
||||
|
CommandClass["init"]() |
||||
|
} |
||||
|
bind(className + "Command") |
||||
|
.to(CommandClass) |
||||
|
.inSingletonScope() |
||||
|
} |
||||
|
}) |
||||
|
}) |
||||
|
|
||||
|
/** |
||||
|
* 销毁所有命令绑定 |
||||
|
* @param ioc - Inversify 容器实例 |
||||
|
*/ |
||||
|
async function destroyAllCommand(ioc: Container) { |
||||
|
await ioc.unloadAsync(modules) |
||||
|
} |
||||
|
|
||||
|
export { modules, destroyAllCommand } |
||||
|
export default modules |
@ -0,0 +1,5 @@ |
|||||
|
import { PlatForm } from "." |
||||
|
|
||||
|
export function usePlatForm() { |
||||
|
return PlatForm.getInstance<PlatForm>() |
||||
|
} |
@ -0,0 +1,58 @@ |
|||||
|
import { ApiFactory } from "common/lib/abstract" |
||||
|
import { BaseSingleton } from "base" |
||||
|
import { LogLevel } from "packages/logger/common" |
||||
|
|
||||
|
class PlatForm extends BaseSingleton { |
||||
|
constructor() { |
||||
|
super() |
||||
|
} |
||||
|
|
||||
|
private get api() { |
||||
|
return ApiFactory.getApiClient() |
||||
|
} |
||||
|
|
||||
|
async logSetLevel(level: LogLevel) { |
||||
|
return this.api.call("PlatFormCommand.logSetLevel", level) |
||||
|
} |
||||
|
|
||||
|
async logGetLevel() { |
||||
|
return this.api.call("PlatFormCommand.logGetLevel") |
||||
|
} |
||||
|
|
||||
|
async showAbout() { |
||||
|
// return this.api.call("BasicService.showAbout")
|
||||
|
return this.api.call("PlatFormCommand.showAbout") |
||||
|
} |
||||
|
|
||||
|
async showSrd() { |
||||
|
// return this.api.call("BasicService.showAbout")
|
||||
|
return this.api.call("PlatFormCommand.showSrd") |
||||
|
} |
||||
|
|
||||
|
async getSrdCookie() { |
||||
|
// return this.api.call("BasicService.showAbout")
|
||||
|
return this.api.call("PlatFormCommand.getSrdCookie") |
||||
|
} |
||||
|
|
||||
|
async crash() { |
||||
|
return this.api.call("PlatFormCommand.crash") |
||||
|
} |
||||
|
|
||||
|
async isFullScreen() { |
||||
|
return this.api.call("PlatFormCommand.isFullscreen") |
||||
|
} |
||||
|
|
||||
|
async toggleFullScreen() { |
||||
|
return this.api.call("PlatFormCommand.fullscreen") |
||||
|
} |
||||
|
|
||||
|
async reload() { |
||||
|
return this.api.call("PlatFormCommand.reload") |
||||
|
} |
||||
|
|
||||
|
async toggleDevTools() { |
||||
|
return this.api.call("PlatFormCommand.toggleDevTools") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export { PlatForm } |
@ -0,0 +1,121 @@ |
|||||
|
import { app, dialog, nativeTheme, TitleBarOverlayOptions } from "electron" |
||||
|
import { inject } from "inversify" |
||||
|
import errorHandler from "logger/main-error" |
||||
|
import Tabs from "main/modules/tabs" |
||||
|
import WindowManager from "main/modules/window-manager" |
||||
|
import { getFileUrl } from "main/utils" |
||||
|
import icon from "@res/icon.png?asset" |
||||
|
import setting from "setting/main" |
||||
|
import { LogLevel } from "logger/common" |
||||
|
|
||||
|
export default class PlatFormCommand { |
||||
|
constructor( |
||||
|
@inject(WindowManager) private _WindowManager: WindowManager, |
||||
|
@inject(Tabs) private _Tabs: Tabs, |
||||
|
) {} |
||||
|
|
||||
|
setTheme(theme: typeof nativeTheme.themeSource) { |
||||
|
nativeTheme.themeSource = theme |
||||
|
} |
||||
|
|
||||
|
logSetLevel(level: LogLevel) { |
||||
|
return setting.set("debug", level) |
||||
|
} |
||||
|
|
||||
|
logGetLevel() { |
||||
|
return setting.values("debug") |
||||
|
} |
||||
|
|
||||
|
setTitlBar(options: TitleBarOverlayOptions) { |
||||
|
const mainWindow = this._WindowManager.getMainWindow() |
||||
|
if (mainWindow) { |
||||
|
mainWindow.setTitleBarOverlay(options) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
showAbout() { |
||||
|
this._WindowManager.createWindow("about", { |
||||
|
url: getFileUrl("about.html"), |
||||
|
overideWindowOpts: true, |
||||
|
confrimWindowClose: false, |
||||
|
type: "info", |
||||
|
windowOpts: { |
||||
|
width: 600, |
||||
|
height: 400, |
||||
|
minimizable: false, |
||||
|
darkTheme: true, |
||||
|
modal: true, |
||||
|
title: "关于我", |
||||
|
show: true, |
||||
|
resizable: false, |
||||
|
icon: icon, |
||||
|
webPreferences: { |
||||
|
devTools: false, |
||||
|
sandbox: false, |
||||
|
nodeIntegration: false, |
||||
|
contextIsolation: true, |
||||
|
}, |
||||
|
}, |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
toggleDevTools() { |
||||
|
const focusedWindow = this._WindowManager.getFocusWindow() |
||||
|
if (focusedWindow) { |
||||
|
// @ts-ignore ...
|
||||
|
focusedWindow.toggleDevTools() |
||||
|
} |
||||
|
} |
||||
|
fullscreen() { |
||||
|
const focusedWindow = this._WindowManager.getFocusWindow() |
||||
|
if (focusedWindow) { |
||||
|
const isFullScreen = focusedWindow!.isFullScreen() |
||||
|
focusedWindow!.setFullScreen(!isFullScreen) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
crash() { |
||||
|
errorHandler.captureError(new Error("手动触发的崩溃")) |
||||
|
process.crash() |
||||
|
} |
||||
|
|
||||
|
isFullscreen() { |
||||
|
const focusedWindow = this._WindowManager.getFocusWindow() |
||||
|
if (focusedWindow) { |
||||
|
return focusedWindow!.isFullScreen() |
||||
|
} |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
relunch() { |
||||
|
app.relaunch() |
||||
|
app.exit() |
||||
|
} |
||||
|
|
||||
|
reload() { |
||||
|
const focusedWindow = this._WindowManager.getFocusWindow() |
||||
|
// 重载之后, 刷新并关闭所有的次要窗体
|
||||
|
if (this._WindowManager.length() > 1 && focusedWindow && focusedWindow.$$opts!.name === this._WindowManager.mainInfo.name) { |
||||
|
const choice = dialog.showMessageBoxSync(focusedWindow, { |
||||
|
type: "question", |
||||
|
buttons: ["取消", "是的,继续", "不,算了"], |
||||
|
title: "警告", |
||||
|
defaultId: 2, |
||||
|
cancelId: 0, |
||||
|
message: "警告", |
||||
|
detail: "重载主窗口将关闭所有子窗口,是否继续", |
||||
|
}) |
||||
|
if (choice == 1) { |
||||
|
this._WindowManager.getWndows().forEach(win => { |
||||
|
if (win.$$opts!.name !== this._WindowManager.mainInfo.name) { |
||||
|
win.close() |
||||
|
} |
||||
|
}) |
||||
|
} else { |
||||
|
return |
||||
|
} |
||||
|
} |
||||
|
this._Tabs.closeAll() |
||||
|
focusedWindow!.reload() |
||||
|
} |
||||
|
} |
@ -0,0 +1,5 @@ |
|||||
|
import { Snippet } from "." |
||||
|
|
||||
|
export function useSnippet() { |
||||
|
return Snippet.getInstance() |
||||
|
} |
@ -0,0 +1,18 @@ |
|||||
|
import { BaseSingleton } from "base" |
||||
|
import { ApiFactory } from "common/lib/abstract" |
||||
|
|
||||
|
class Snippet extends BaseSingleton { |
||||
|
constructor() { |
||||
|
super() |
||||
|
} |
||||
|
|
||||
|
private get api() { |
||||
|
return ApiFactory.getApiClient() |
||||
|
} |
||||
|
|
||||
|
getTree = async () => { |
||||
|
return this.api.call("SnippetCommand.getTree") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export { Snippet } |
@ -0,0 +1,44 @@ |
|||||
|
import fs from "fs-extra" |
||||
|
import path from "path/posix" |
||||
|
import Setting from "setting/main" |
||||
|
|
||||
|
// 代码片段命令处理器
|
||||
|
// base/__snippet__.json 基础信息
|
||||
|
// 路径做为ID, 当前文件夹的信息
|
||||
|
|
||||
|
export default class SnippetCommand { |
||||
|
storagePath: string = Setting.values("snippet.storagePath") |
||||
|
|
||||
|
constructor() { |
||||
|
const handler = { |
||||
|
get: function (target, prop, receiver) { |
||||
|
if (!target["check"]()) { |
||||
|
throw new Error(`代码片段路径存在问题`) |
||||
|
} |
||||
|
const value = target[prop] |
||||
|
if (typeof value === "function") { |
||||
|
return (...args) => Reflect.apply(value, receiver, args) |
||||
|
} |
||||
|
return value |
||||
|
}, |
||||
|
} |
||||
|
return new Proxy(this, handler) |
||||
|
} |
||||
|
|
||||
|
async check() { |
||||
|
const stat = await fs.statSync(this.storagePath) |
||||
|
const inforFile = path.resolve(this.storagePath, "__snippet__.json") |
||||
|
if (stat.isDirectory() && stat.size == 0) { |
||||
|
await fs.writeJSON(inforFile, {}) |
||||
|
// 空文件夹, 初始化信息
|
||||
|
return true |
||||
|
} else { |
||||
|
const isExist = await fs.pathExists(inforFile) |
||||
|
return isExist |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
getTree() { |
||||
|
return this.storagePath |
||||
|
} |
||||
|
} |
@ -0,0 +1,54 @@ |
|||||
|
import { BaseSingleton } from "base" |
||||
|
|
||||
|
export class Tabs extends BaseSingleton { |
||||
|
constructor() { |
||||
|
super() |
||||
|
} |
||||
|
|
||||
|
private isListen: boolean = false |
||||
|
|
||||
|
private execUpdate = (...args) => { |
||||
|
this.#fnList.forEach(v => v(...args)) |
||||
|
} |
||||
|
|
||||
|
#fnList: ((...args) => void)[] = [] |
||||
|
listenUpdate(cb: (...args) => void) { |
||||
|
if (!this.isListen) { |
||||
|
api.on("main:TabsCommand.update", this.execUpdate) |
||||
|
this.isListen = true |
||||
|
} |
||||
|
this.#fnList.push(cb) |
||||
|
} |
||||
|
|
||||
|
unListenUpdate(fn: (...args) => void) { |
||||
|
this.#fnList = this.#fnList.filter(v => { |
||||
|
return v !== fn |
||||
|
}) |
||||
|
if (!this.#fnList.length) { |
||||
|
api.off("main:TabsCommand.update", this.execUpdate) |
||||
|
this.isListen = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
bindPosition(data) { |
||||
|
api.call("TabsCommand.bindElement", data) |
||||
|
} |
||||
|
|
||||
|
closeAll() { |
||||
|
api.call("TabsCommand.closeAll") |
||||
|
} |
||||
|
|
||||
|
sync() { |
||||
|
api.call("TabsCommand.sync") |
||||
|
} |
||||
|
|
||||
|
unListenerAll() { |
||||
|
this.#fnList = [] |
||||
|
api.offAll("main:TabsCommand.update") |
||||
|
} |
||||
|
|
||||
|
async getAllTabs() { |
||||
|
const res = await api.call("TabsCommand.getAllTabs") |
||||
|
return res |
||||
|
} |
||||
|
} |
@ -0,0 +1,65 @@ |
|||||
|
import { inject } from "inversify" |
||||
|
import Tabs from "main/modules/tabs" |
||||
|
import WindowManager from "main/modules/window-manager" |
||||
|
import { broadcast } from "main/utils" |
||||
|
|
||||
|
class TabsCommand { |
||||
|
constructor( |
||||
|
@inject(Tabs) private _Tabs: Tabs, |
||||
|
@inject(WindowManager) private _WindowManager: WindowManager, |
||||
|
) { |
||||
|
this._Tabs.events.on("update", this.listenerTabActive) |
||||
|
} |
||||
|
|
||||
|
listenerTabActive = () => { |
||||
|
broadcast("main:TabsCommand.update", this.getAllTabs()) |
||||
|
} |
||||
|
|
||||
|
bindElement(rect) { |
||||
|
this._Tabs.updateRect(rect) |
||||
|
} |
||||
|
|
||||
|
reload() { |
||||
|
this._WindowManager.getMainWindow()?.reload() |
||||
|
} |
||||
|
|
||||
|
sync() { |
||||
|
this.listenerTabActive() |
||||
|
if (!this.getAllTabs().length) { |
||||
|
this.add("about:blank") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
add(url) { |
||||
|
this._Tabs.add(url, true, this._WindowManager.getMainWindow()!) |
||||
|
} |
||||
|
|
||||
|
nagivate(index: number, url: string) { |
||||
|
this._Tabs.navigate(+index, url) |
||||
|
} |
||||
|
|
||||
|
closeAll() { |
||||
|
this._Tabs.closeAll() |
||||
|
} |
||||
|
|
||||
|
setActive(index) { |
||||
|
this._Tabs.changeActive(index) |
||||
|
} |
||||
|
|
||||
|
closeTab(e) { |
||||
|
this._Tabs.remove(e.body.active) |
||||
|
} |
||||
|
|
||||
|
getAllTabs() { |
||||
|
return this._Tabs._tabs.map(v => ({ |
||||
|
url: v.url, |
||||
|
showUrl: v.showUrl, |
||||
|
title: v.title, |
||||
|
favicons: v.favicons, |
||||
|
isActive: v.isActive, |
||||
|
})) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export { TabsCommand } |
||||
|
export default TabsCommand |
@ -0,0 +1,15 @@ |
|||||
|
import { EventEnum } from "helper/updater/common" |
||||
|
|
||||
|
const curProgress = ref(0) |
||||
|
|
||||
|
api.on(EventEnum.UPDATE_PROGRESS, ({ percent, now, all }) => { |
||||
|
curProgress.value = percent |
||||
|
}) |
||||
|
|
||||
|
function useUpdate() { |
||||
|
return { |
||||
|
curProgress, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export { useUpdate } |
@ -0,0 +1,15 @@ |
|||||
|
import Updater from "helper/updater/main" |
||||
|
import _logger from "logger/main" |
||||
|
|
||||
|
const logger = _logger.createNamespace("UpdaterCommand") |
||||
|
|
||||
|
export default class UpdaterCommand { |
||||
|
static init() { |
||||
|
// 命令初始化
|
||||
|
logger.debug("UpdaterCommand init") |
||||
|
} |
||||
|
|
||||
|
async triggerHotUpdate() { |
||||
|
Updater.triggerHotUpdate() |
||||
|
} |
||||
|
} |
@ -0,0 +1,53 @@ |
|||||
|
import { ElectronApiClient } from "common/lib/electron" |
||||
|
import { BrowserApiClient } from "common/lib/browser" |
||||
|
|
||||
|
// 定义抽象 API 接口
|
||||
|
export interface IApiClient { |
||||
|
call<T = any>(command: string, ...args: any[]): Promise<T> |
||||
|
on<K extends string>(channel: K, callback: (...args: any[]) => void): void |
||||
|
off<K extends string>(channel: K, callback: (...args: any[]) => void): void |
||||
|
offAll<K extends string>(channel: K): void |
||||
|
} |
||||
|
|
||||
|
class NullApiClient implements IApiClient { |
||||
|
async call<T = any>(command: string, ...args: any[]): Promise<T> { |
||||
|
args |
||||
|
console.warn(`API call to ${command} failed: API client not initialized`) |
||||
|
return undefined as any |
||||
|
} |
||||
|
|
||||
|
on<K extends string>(channel: K, callback: (...args: any[]) => void): void { |
||||
|
callback |
||||
|
console.warn(`Failed to register listener for ${channel}: API client not initialized`) |
||||
|
} |
||||
|
|
||||
|
off<K extends string>(channel: K, callback: (...args: any[]) => void): void { |
||||
|
callback |
||||
|
console.warn(`Failed to unregister listener for ${channel}: API client not initialized`) |
||||
|
} |
||||
|
|
||||
|
offAll<K extends string>(channel: K): void { |
||||
|
console.warn(`Failed to unregister all listeners for ${channel}: API client not initialized`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 创建 API 工厂
|
||||
|
export class ApiFactory { |
||||
|
private static instance: IApiClient = new NullApiClient() // 默认使用空实现
|
||||
|
|
||||
|
static setApiClient(client: IApiClient) { |
||||
|
this.instance = client |
||||
|
} |
||||
|
|
||||
|
static getApiClient(): IApiClient { |
||||
|
if (this.instance instanceof NullApiClient) { |
||||
|
// 根据环境选择合适的 API 客户端
|
||||
|
if (window.api && window.electron) { |
||||
|
this.instance = new ElectronApiClient() |
||||
|
} else { |
||||
|
this.instance = new BrowserApiClient() |
||||
|
} |
||||
|
} |
||||
|
return this.instance |
||||
|
} |
||||
|
} |
@ -0,0 +1,29 @@ |
|||||
|
import { IApiClient } from "./abstract" |
||||
|
|
||||
|
export class BrowserApiClient implements IApiClient { |
||||
|
call<T = any>(command: string, ...args: any[]): Promise<T> { |
||||
|
// 浏览器特定实现,可能使用 fetch 或其他方式
|
||||
|
const [service, method] = command.split(".") |
||||
|
return fetch(`/api/${service}/${method}`, { |
||||
|
method: "POST", |
||||
|
body: JSON.stringify(args), |
||||
|
headers: { "Content-Type": "application/json" }, |
||||
|
}).then(res => res.json()) |
||||
|
} |
||||
|
|
||||
|
// 实现其他方法...
|
||||
|
on<K extends string>(channel: K, callback: (...args: any[]) => void): void { |
||||
|
// 浏览器中可能使用 WebSocket 或其他方式
|
||||
|
console.log("不支持 on 方法", channel, callback) |
||||
|
} |
||||
|
|
||||
|
off<K extends string>(channel: K, callback: (...args: any[]) => void): void { |
||||
|
// 相应的解绑实现
|
||||
|
console.log("不支持 on 方法", channel, callback) |
||||
|
} |
||||
|
|
||||
|
offAll<K extends string>(channel: K): void { |
||||
|
// 相应的全部解绑实现
|
||||
|
console.log("不支持 on 方法", channel) |
||||
|
} |
||||
|
} |
@ -0,0 +1,20 @@ |
|||||
|
import { IApiClient } from "./abstract" |
||||
|
|
||||
|
export class ElectronApiClient implements IApiClient { |
||||
|
call<T = any>(command: string, ...args: any[]): Promise<T> { |
||||
|
// Electron 特定实现
|
||||
|
return window.api.call(command, ...args) |
||||
|
} |
||||
|
|
||||
|
on<K extends string>(channel: K, callback: (...args: any[]) => void): void { |
||||
|
window.api.on(channel, callback) |
||||
|
} |
||||
|
|
||||
|
off<K extends string>(channel: K, callback: (...args: any[]) => void): void { |
||||
|
window.api.off(channel, callback) |
||||
|
} |
||||
|
|
||||
|
offAll<K extends string>(channel: K): void { |
||||
|
window.api.offAll(channel) |
||||
|
} |
||||
|
} |
@ -0,0 +1,7 @@ |
|||||
|
## event |
||||
|
|
||||
|
通用事件处理模块 |
||||
|
|
||||
|
- main/**/* 处理主进程的模块 |
||||
|
- main/command.ts 会通过ioc收集,进入依赖管理中 |
||||
|
- 其他 处理渲染进程的模块 |
@ -0,0 +1,130 @@ |
|||||
|
class Node<E> { |
||||
|
static readonly Undefined = new Node<any>(undefined) |
||||
|
|
||||
|
element: E |
||||
|
next: Node<E> |
||||
|
prev: Node<E> |
||||
|
|
||||
|
constructor(element: E) { |
||||
|
this.element = element |
||||
|
this.next = Node.Undefined |
||||
|
this.prev = Node.Undefined |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export class LinkedList<E> { |
||||
|
private _first: Node<E> = Node.Undefined |
||||
|
private _last: Node<E> = Node.Undefined |
||||
|
private _size: number = 0 |
||||
|
|
||||
|
get size(): number { |
||||
|
return this._size |
||||
|
} |
||||
|
|
||||
|
isEmpty(): boolean { |
||||
|
return this._first === Node.Undefined |
||||
|
} |
||||
|
|
||||
|
clear(): void { |
||||
|
let node = this._first |
||||
|
while (node !== Node.Undefined) { |
||||
|
const next = node.next |
||||
|
node.prev = Node.Undefined |
||||
|
node.next = Node.Undefined |
||||
|
node = next |
||||
|
} |
||||
|
|
||||
|
this._first = Node.Undefined |
||||
|
this._last = Node.Undefined |
||||
|
this._size = 0 |
||||
|
} |
||||
|
|
||||
|
unshift(element: E): () => void { |
||||
|
return this._insert(element, false) |
||||
|
} |
||||
|
|
||||
|
push(element: E): () => void { |
||||
|
return this._insert(element, true) |
||||
|
} |
||||
|
|
||||
|
private _insert(element: E, atTheEnd: boolean): () => void { |
||||
|
const newNode = new Node(element) |
||||
|
if (this._first === Node.Undefined) { |
||||
|
this._first = newNode |
||||
|
this._last = newNode |
||||
|
} else if (atTheEnd) { |
||||
|
// push
|
||||
|
const oldLast = this._last |
||||
|
this._last = newNode |
||||
|
newNode.prev = oldLast |
||||
|
oldLast.next = newNode |
||||
|
} else { |
||||
|
// unshift
|
||||
|
const oldFirst = this._first |
||||
|
this._first = newNode |
||||
|
newNode.next = oldFirst |
||||
|
oldFirst.prev = newNode |
||||
|
} |
||||
|
this._size += 1 |
||||
|
|
||||
|
let didRemove = false |
||||
|
return () => { |
||||
|
if (!didRemove) { |
||||
|
didRemove = true |
||||
|
this._remove(newNode) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
shift(): E | undefined { |
||||
|
if (this._first === Node.Undefined) { |
||||
|
return undefined |
||||
|
} else { |
||||
|
const res = this._first.element |
||||
|
this._remove(this._first) |
||||
|
return res |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
pop(): E | undefined { |
||||
|
if (this._last === Node.Undefined) { |
||||
|
return undefined |
||||
|
} else { |
||||
|
const res = this._last.element |
||||
|
this._remove(this._last) |
||||
|
return res |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private _remove(node: Node<E>): void { |
||||
|
if (node.prev !== Node.Undefined && node.next !== Node.Undefined) { |
||||
|
// middle
|
||||
|
const anchor = node.prev |
||||
|
anchor.next = node.next |
||||
|
node.next.prev = anchor |
||||
|
} else if (node.prev === Node.Undefined && node.next === Node.Undefined) { |
||||
|
// only node
|
||||
|
this._first = Node.Undefined |
||||
|
this._last = Node.Undefined |
||||
|
} else if (node.next === Node.Undefined) { |
||||
|
// last
|
||||
|
this._last = this._last.prev! |
||||
|
this._last.next = Node.Undefined |
||||
|
} else if (node.prev === Node.Undefined) { |
||||
|
// first
|
||||
|
this._first = this._first.next! |
||||
|
this._first.prev = Node.Undefined |
||||
|
} |
||||
|
|
||||
|
// done
|
||||
|
this._size -= 1 |
||||
|
} |
||||
|
|
||||
|
*[Symbol.iterator](): Iterator<E> { |
||||
|
let node = this._first |
||||
|
while (node !== Node.Undefined) { |
||||
|
yield node.element |
||||
|
node = node.next |
||||
|
} |
||||
|
} |
||||
|
} |
@ -1,63 +0,0 @@ |
|||||
import { app, dialog } from "electron" |
|
||||
import { inject } from "inversify" |
|
||||
import Tabs from "main/modules/tabs" |
|
||||
import WindowManager from "main/modules/window-manager" |
|
||||
|
|
||||
export default class BasicCommand { |
|
||||
constructor( |
|
||||
@inject(WindowManager) private _WindowManager: WindowManager, |
|
||||
@inject(Tabs) private _Tabs: Tabs, |
|
||||
) { |
|
||||
//
|
|
||||
} |
|
||||
|
|
||||
toggleDevTools() { |
|
||||
const focusedWindow = this._WindowManager.getFocusWindow() |
|
||||
if (focusedWindow) { |
|
||||
// @ts-ignore ...
|
|
||||
focusedWindow.toggleDevTools() |
|
||||
} |
|
||||
} |
|
||||
fullscreen() { |
|
||||
const focusedWindow = this._WindowManager.getFocusWindow() |
|
||||
const isFullScreen = focusedWindow!.isFullScreen() |
|
||||
focusedWindow!.setFullScreen(!isFullScreen) |
|
||||
return !isFullScreen |
|
||||
} |
|
||||
isFullscreen() { |
|
||||
const focusedWindow = this._WindowManager.getFocusWindow() |
|
||||
return focusedWindow!.isFullScreen() |
|
||||
} |
|
||||
|
|
||||
relunch() { |
|
||||
app.relaunch() |
|
||||
app.exit() |
|
||||
} |
|
||||
|
|
||||
reload() { |
|
||||
const focusedWindow = this._WindowManager.getFocusWindow() |
|
||||
// 重载之后, 刷新并关闭所有的次要窗体
|
|
||||
if (this._WindowManager.length() > 1 && focusedWindow && focusedWindow.$$opts!.name === this._WindowManager.mainInfo.name) { |
|
||||
const choice = dialog.showMessageBoxSync(focusedWindow, { |
|
||||
type: "question", |
|
||||
buttons: ["取消", "是的,继续", "不,算了"], |
|
||||
title: "警告", |
|
||||
defaultId: 2, |
|
||||
cancelId: 0, |
|
||||
message: "警告", |
|
||||
detail: "重载主窗口将关闭所有子窗口,是否继续", |
|
||||
}) |
|
||||
if (choice == 1) { |
|
||||
this._WindowManager.getWndows().forEach(win => { |
|
||||
if (win.$$opts!.name !== this._WindowManager.mainInfo.name) { |
|
||||
win.close() |
|
||||
} |
|
||||
}) |
|
||||
} else { |
|
||||
return |
|
||||
} |
|
||||
} |
|
||||
this._Tabs.closeAll() |
|
||||
focusedWindow!.reload() |
|
||||
} |
|
||||
} |
|
@ -1,66 +0,0 @@ |
|||||
import { inject } from "inversify" |
|
||||
import Tabs from "main/modules/tabs" |
|
||||
import WindowManager from "main/modules/window-manager" |
|
||||
import { broadcast } from "main/utils" |
|
||||
|
|
||||
class TabsCommand { |
|
||||
constructor( |
|
||||
@inject(Tabs) private _Tabs: Tabs, |
|
||||
@inject(WindowManager) private _WindowManager: WindowManager, |
|
||||
) { |
|
||||
this.listenerTabActive = this.listenerTabActive.bind(this) |
|
||||
this._Tabs.events.on("update", this.listenerTabActive) |
|
||||
} |
|
||||
|
|
||||
listenerTabActive() { |
|
||||
broadcast("main:TabsCommand.update", this.getAllTabs()) |
|
||||
} |
|
||||
|
|
||||
bindElement(rect) { |
|
||||
this._Tabs.updateRect(rect) |
|
||||
} |
|
||||
|
|
||||
reload() { |
|
||||
this._WindowManager.getMainWindow()?.reload() |
|
||||
} |
|
||||
|
|
||||
sync() { |
|
||||
this.listenerTabActive() |
|
||||
if (!this.getAllTabs().length) { |
|
||||
this.add("about:blank") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
add(url) { |
|
||||
this._Tabs.add(url, true, this._WindowManager.getMainWindow()!) |
|
||||
} |
|
||||
|
|
||||
nagivate(index: number, url: string) { |
|
||||
this._Tabs.navigate(+index, url) |
|
||||
} |
|
||||
|
|
||||
closeAll() { |
|
||||
this._Tabs.closeAll() |
|
||||
} |
|
||||
|
|
||||
setActive(index) { |
|
||||
this._Tabs.changeActive(index) |
|
||||
} |
|
||||
|
|
||||
closeTab(e) { |
|
||||
this._Tabs.remove(e.body.active) |
|
||||
} |
|
||||
|
|
||||
getAllTabs() { |
|
||||
return this._Tabs._tabs.map(v => ({ |
|
||||
url: v.url, |
|
||||
showUrl: v.showUrl, |
|
||||
title: v.title, |
|
||||
favicons: v.favicons, |
|
||||
isActive: v.isActive, |
|
||||
})) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
export { TabsCommand } |
|
||||
export default TabsCommand |
|
@ -1,15 +0,0 @@ |
|||||
import { Container, ContainerModule } from "inversify" |
|
||||
import BasicCommand from "./BasicCommand" |
|
||||
import TabsCommand from "./TabsCommand" |
|
||||
|
|
||||
const modules = new ContainerModule(bind => { |
|
||||
bind("BasicCommand").to(BasicCommand).inSingletonScope() |
|
||||
bind("TabsCommand").to(TabsCommand).inSingletonScope() |
|
||||
}) |
|
||||
|
|
||||
async function destroyAllCommand(ioc: Container) { |
|
||||
await ioc.unloadAsync(modules) |
|
||||
} |
|
||||
|
|
||||
export { modules, destroyAllCommand } |
|
||||
export default modules |
|
@ -0,0 +1,60 @@ |
|||||
|
import debug from "debug" |
||||
|
import { app } from "electron" |
||||
|
import path from "node:path" |
||||
|
import logger from "logger/main" |
||||
|
import * as rfs from "rotating-file-stream" |
||||
|
import fs from "fs" |
||||
|
|
||||
|
// 配置根目录
|
||||
|
const logsPath = app.getPath("logs") |
||||
|
logger.debug(`日志地址:${logsPath}`) |
||||
|
|
||||
|
const LOG_ROOT = path.join(logsPath) |
||||
|
|
||||
|
// 缓存当前应用启动的日志文件流
|
||||
|
let currentLogStream: rfs.RotatingFileStream | null = null |
||||
|
|
||||
|
// 生成当前启动时的日志文件名
|
||||
|
const getLogFileName = () => { |
||||
|
const now = new Date() |
||||
|
const timestamp = now.toISOString().replace(/[:.]/g, "-") |
||||
|
return `app-${timestamp}.log` |
||||
|
} |
||||
|
|
||||
|
// 覆盖 debug.log 方法
|
||||
|
const originalLog = debug.log |
||||
|
debug.log = function (...args) { |
||||
|
// 保留原始控制台输出
|
||||
|
originalLog.apply(this, args) |
||||
|
|
||||
|
// 确保日志目录存在
|
||||
|
if (!fs.existsSync(LOG_ROOT)) { |
||||
|
fs.mkdirSync(LOG_ROOT, { recursive: true }) |
||||
|
} |
||||
|
|
||||
|
// 延迟初始化日志流,直到第一次写入
|
||||
|
if (!currentLogStream) { |
||||
|
const logFileName = getLogFileName() |
||||
|
currentLogStream = rfs.createStream(logFileName, { |
||||
|
path: LOG_ROOT, |
||||
|
size: "10M", // 单个文件最大 10MB
|
||||
|
rotate: 10, // 保留最近 10 个文件
|
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
// @ts-ignore 获取当前命名空间
|
||||
|
const namespace = this.namespace || "unknown" |
||||
|
|
||||
|
// 写入日志(添加时间戳和命名空间)
|
||||
|
const timestamp = new Date().toISOString() |
||||
|
const message = args.join(" ") |
||||
|
currentLogStream.write(`[${timestamp}] [${namespace}] ${message}\n`) |
||||
|
} |
||||
|
|
||||
|
app.on("before-quit", () => { |
||||
|
if (currentLogStream) { |
||||
|
currentLogStream.end() |
||||
|
currentLogStream.destroy() |
||||
|
currentLogStream = null |
||||
|
} |
||||
|
}) |
@ -0,0 +1,6 @@ |
|||||
|
import EventEmitter from "events" |
||||
|
|
||||
|
const globalEvent = new EventEmitter() |
||||
|
|
||||
|
export default globalEvent |
||||
|
export { globalEvent as eventbus } |
@ -1,235 +0,0 @@ |
|||||
import fs from "fs-extra" |
|
||||
import { app } from "electron" |
|
||||
import path from "path" |
|
||||
import { cloneDeep } from "lodash" |
|
||||
import { injectable } from "inversify" |
|
||||
import Config from "config" |
|
||||
import _debug from "debug" |
|
||||
import BaseClass from "main/base/base" |
|
||||
|
|
||||
const debug = _debug("app:setting") |
|
||||
|
|
||||
type IConfig = typeof Config.default_config |
|
||||
|
|
||||
type IOnFunc = (n: IConfig, c: IConfig, keys?: (keyof IConfig)[]) => void |
|
||||
type IT = (keyof IConfig)[] | keyof IConfig | "_" |
|
||||
|
|
||||
let storagePath = path.join(app.getPath("documents"), Config.app_title) |
|
||||
const storagePathDev = path.join(app.getPath("documents"), Config.app_title + "-dev") |
|
||||
|
|
||||
if (process.env.NODE_ENV === "development") { |
|
||||
storagePath = storagePathDev |
|
||||
} |
|
||||
|
|
||||
const _tempConfig = cloneDeep(Config.default_config as IConfig) |
|
||||
Object.keys(_tempConfig).forEach(key => { |
|
||||
if (typeof _tempConfig[key] === "string" && _tempConfig[key].includes("$storagePath$")) { |
|
||||
_tempConfig[key] = _tempConfig[key].replace(/\$storagePath\$/g, storagePath) |
|
||||
if (_tempConfig[key] && path.isAbsolute(_tempConfig[key])) { |
|
||||
_tempConfig[key] = path.normalize(_tempConfig[key]) |
|
||||
} |
|
||||
} |
|
||||
}) |
|
||||
|
|
||||
function isPath(str) { |
|
||||
// 使用正则表达式检查字符串是否以斜杠或盘符开头
|
|
||||
return /^(?:\/|[a-zA-Z]:\\)/.test(str) |
|
||||
} |
|
||||
|
|
||||
function init(config: IConfig) { |
|
||||
// 在配置初始化后执行
|
|
||||
Object.keys(config).forEach(key => { |
|
||||
if (config[key] && isPath(config[key]) && path.isAbsolute(config[key])) { |
|
||||
fs.ensureDirSync(config[key]) |
|
||||
} |
|
||||
}) |
|
||||
// 在配置初始化后执行
|
|
||||
// fs.ensureDirSync(config["snippet.storagePath"])
|
|
||||
// fs.ensureDirSync(config["bookmark.storagePath"])
|
|
||||
} |
|
||||
|
|
||||
// 判断是否是空文件夹
|
|
||||
function isEmptyDir(fPath: string) { |
|
||||
const pa = fs.readdirSync(fPath) |
|
||||
if (pa.length === 0) { |
|
||||
return true |
|
||||
} else { |
|
||||
return false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
@injectable() |
|
||||
class Setting extends BaseClass { |
|
||||
constructor() { |
|
||||
super() |
|
||||
debug(`Setting inited`) |
|
||||
this.init() |
|
||||
} |
|
||||
|
|
||||
destroy() { |
|
||||
// TODO
|
|
||||
} |
|
||||
|
|
||||
#cb: [IT, IOnFunc][] = [] |
|
||||
|
|
||||
onChange(fn: IOnFunc, that?: any) |
|
||||
onChange(key: IT, fn: IOnFunc, that?: any) |
|
||||
onChange(fnOrType: IT | IOnFunc, fnOrThat: IOnFunc | any = null, that: any = null) { |
|
||||
if (typeof fnOrType === "function") { |
|
||||
this.#cb.push(["_", fnOrType.bind(fnOrThat)]) |
|
||||
} else { |
|
||||
this.#cb.push([fnOrType, fnOrThat.bind(that)]) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
#runCB(n: IConfig, c: IConfig, keys: (keyof IConfig)[]) { |
|
||||
for (let i = 0; i < this.#cb.length; i++) { |
|
||||
const temp = this.#cb[i] |
|
||||
const k = temp[0] |
|
||||
const fn = temp[1] |
|
||||
if (k === "_") { |
|
||||
fn(n, c, keys) |
|
||||
} |
|
||||
if (typeof k === "string" && keys.includes(k as keyof IConfig)) { |
|
||||
fn(n, c) |
|
||||
} |
|
||||
if (Array.isArray(k) && k.filter(v => keys.indexOf(v) !== -1).length) { |
|
||||
fn(n, c) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
#pathFile: string = |
|
||||
process.env.NODE_ENV === "development" |
|
||||
? path.resolve(app.getPath("userData"), "./config_path-dev") |
|
||||
: path.resolve(app.getPath("userData"), "./config_path") |
|
||||
#config: IConfig = cloneDeep(_tempConfig) |
|
||||
#configPath(storagePath?: string): string { |
|
||||
return path.join(storagePath || this.#config.storagePath, "./config.json") |
|
||||
} |
|
||||
/** |
|
||||
* 读取配置文件变量同步 |
|
||||
* @param confingPath 配置文件路径 |
|
||||
*/ |
|
||||
#syncVar(confingPath?: string) { |
|
||||
const configFile = this.#configPath(confingPath) |
|
||||
if (!fs.pathExistsSync(configFile)) { |
|
||||
fs.ensureFileSync(configFile) |
|
||||
fs.writeJSONSync(configFile, {}) |
|
||||
} |
|
||||
const config = fs.readJSONSync(configFile) as IConfig |
|
||||
confingPath && (config.storagePath = confingPath) |
|
||||
// 优先取本地的值
|
|
||||
for (const key in config) { |
|
||||
// if (Object.prototype.hasOwnProperty.call(this.#config, key)) {
|
|
||||
// this.#config[key] = config[key] || this.#config[key]
|
|
||||
// }
|
|
||||
// 删除配置时本地的配置不会改变,想一下哪种方式更好
|
|
||||
this.#config[key] = config[key] || this.#config[key] |
|
||||
} |
|
||||
} |
|
||||
init() { |
|
||||
debug(`位置:${this.#pathFile}`) |
|
||||
|
|
||||
if (fs.pathExistsSync(this.#pathFile)) { |
|
||||
const confingPath = fs.readFileSync(this.#pathFile, { encoding: "utf8" }) |
|
||||
if (confingPath && fs.pathExistsSync(this.#configPath(confingPath))) { |
|
||||
this.#syncVar(confingPath) |
|
||||
// 防止增加了配置本地却没变的情况
|
|
||||
this.#sync(confingPath) |
|
||||
} else { |
|
||||
this.#syncVar(confingPath) |
|
||||
this.#sync(confingPath) |
|
||||
} |
|
||||
} else { |
|
||||
this.#syncVar() |
|
||||
this.#sync() |
|
||||
} |
|
||||
init.call(this, this.#config) |
|
||||
} |
|
||||
config() { |
|
||||
return this.#config |
|
||||
} |
|
||||
#sync(c?: string) { |
|
||||
const config = cloneDeep(this.#config) |
|
||||
delete config.storagePath |
|
||||
const p = this.#configPath(c) |
|
||||
fs.ensureFileSync(p) |
|
||||
fs.writeJSONSync(this.#configPath(c), config) |
|
||||
} |
|
||||
#change(p: string) { |
|
||||
const storagePath = this.#config.storagePath |
|
||||
if (fs.existsSync(storagePath) && !fs.existsSync(p)) { |
|
||||
fs.moveSync(storagePath, p) |
|
||||
} |
|
||||
if (fs.existsSync(p) && fs.existsSync(storagePath) && isEmptyDir(p)) { |
|
||||
fs.moveSync(storagePath, p, { overwrite: true }) |
|
||||
} |
|
||||
fs.writeFileSync(this.#pathFile, p, { encoding: "utf8" }) |
|
||||
} |
|
||||
reset(key: keyof IConfig) { |
|
||||
this.set(key, cloneDeep(_tempConfig[key])) |
|
||||
} |
|
||||
set(key: keyof IConfig | Partial<IConfig>, value?: any) { |
|
||||
const oldMainConfig = Object.assign({}, this.#config) |
|
||||
let isChange = false |
|
||||
const changeKeys: (keyof IConfig)[] = [] |
|
||||
const canChangeStorage = (targetPath: string) => { |
|
||||
if (fs.existsSync(oldMainConfig.storagePath) && fs.existsSync(targetPath) && !isEmptyDir(targetPath)) { |
|
||||
if (fs.existsSync(path.join(targetPath, "./config.json"))) { |
|
||||
return true |
|
||||
} |
|
||||
return false |
|
||||
} |
|
||||
return true |
|
||||
} |
|
||||
if (typeof key === "string") { |
|
||||
if (value != undefined && value !== this.#config[key]) { |
|
||||
if (key === "storagePath") { |
|
||||
if (!canChangeStorage(value)) { |
|
||||
throw "无法改变存储地址" |
|
||||
return |
|
||||
} |
|
||||
this.#change(value) |
|
||||
changeKeys.push("storagePath") |
|
||||
this.#config["storagePath"] = value |
|
||||
} else { |
|
||||
changeKeys.push(key) |
|
||||
this.#config[key as string] = value |
|
||||
} |
|
||||
isChange = true |
|
||||
} |
|
||||
} else { |
|
||||
if (key["storagePath"] !== undefined && key["storagePath"] !== this.#config["storagePath"]) { |
|
||||
if (!canChangeStorage(key["storagePath"])) { |
|
||||
throw "无法改变存储地址" |
|
||||
return |
|
||||
} |
|
||||
this.#change(key["storagePath"]) |
|
||||
this.#config["storagePath"] = key["storagePath"] |
|
||||
changeKeys.push("storagePath") |
|
||||
isChange = true |
|
||||
} |
|
||||
for (const _ in key) { |
|
||||
if (Object.prototype.hasOwnProperty.call(key, _)) { |
|
||||
const v = key[_] |
|
||||
if (v != undefined && _ !== "storagePath" && v !== this.#config[_]) { |
|
||||
this.#config[_] = v |
|
||||
changeKeys.push(_ as keyof IConfig) |
|
||||
isChange = true |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
if (isChange) { |
|
||||
this.#sync() |
|
||||
this.#runCB(this.#config, oldMainConfig, changeKeys) |
|
||||
} |
|
||||
} |
|
||||
values<T extends keyof IConfig>(key: T): IConfig[T] { |
|
||||
return this.#config[key] |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
export default Setting |
|
||||
export { Setting } |
|
@ -1,114 +0,0 @@ |
|||||
import pkg from "electron-updater" |
|
||||
import { app, dialog } from "electron" |
|
||||
import { injectable } from "inversify" |
|
||||
import BaseClass from "main/base/base" |
|
||||
// import { Setting } from "../setting"
|
|
||||
import _debug from "debug" |
|
||||
import EventEmitter from "events" |
|
||||
|
|
||||
const debug = _debug("app:updater") |
|
||||
const { autoUpdater } = pkg |
|
||||
|
|
||||
@injectable() |
|
||||
export class Updater extends BaseClass { |
|
||||
public events = new EventEmitter() |
|
||||
|
|
||||
constructor( |
|
||||
// @inject(Setting) private _Setting: Setting
|
|
||||
) { |
|
||||
super() |
|
||||
|
|
||||
// 配置自动更新
|
|
||||
autoUpdater.autoDownload = false |
|
||||
autoUpdater.autoInstallOnAppQuit = true |
|
||||
|
|
||||
// 检查更新错误
|
|
||||
autoUpdater.on("error", error => { |
|
||||
debug("Update error:", error) |
|
||||
}) |
|
||||
|
|
||||
// 检查更新
|
|
||||
autoUpdater.on("checking-for-update", () => { |
|
||||
debug("Checking for updates...") |
|
||||
}) |
|
||||
|
|
||||
// 有可用更新
|
|
||||
autoUpdater.on("update-available", info => { |
|
||||
debug("Update available:", info) |
|
||||
this.promptUserToUpdate() |
|
||||
}) |
|
||||
|
|
||||
// 没有可用更新
|
|
||||
autoUpdater.on("update-not-available", info => { |
|
||||
debug("Update not available:", info) |
|
||||
}) |
|
||||
|
|
||||
// 更新下载进度
|
|
||||
autoUpdater.on("download-progress", progressObj => { |
|
||||
debug( |
|
||||
`Download speed: ${progressObj.bytesPerSecond} - Downloaded ${progressObj.percent}% (${progressObj.transferred}/${progressObj.total})`, |
|
||||
) |
|
||||
}) |
|
||||
|
|
||||
// 更新下载完成
|
|
||||
autoUpdater.on("update-downloaded", info => { |
|
||||
debug("Update downloaded:", info) |
|
||||
this.promptUserToInstall() |
|
||||
}) |
|
||||
} |
|
||||
|
|
||||
init() { |
|
||||
// 定期检查更新
|
|
||||
this.checkForUpdates() |
|
||||
setInterval( |
|
||||
() => { |
|
||||
this.checkForUpdates() |
|
||||
}, |
|
||||
1000 * 60 * 60, |
|
||||
) // 每小时检查一次
|
|
||||
} |
|
||||
|
|
||||
destroy() { |
|
||||
// 清理工作
|
|
||||
} |
|
||||
|
|
||||
private async checkForUpdates() { |
|
||||
if (app.isPackaged) { |
|
||||
try { |
|
||||
await autoUpdater.checkForUpdates() |
|
||||
} catch (error) { |
|
||||
debug("Failed to check for updates:", error) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
private async promptUserToUpdate() { |
|
||||
const result = await dialog.showMessageBox({ |
|
||||
type: "info", |
|
||||
title: "发现新版本", |
|
||||
message: "是否下载新版本?", |
|
||||
buttons: ["下载", "暂不更新"], |
|
||||
defaultId: 0, |
|
||||
}) |
|
||||
|
|
||||
if (result.response === 0) { |
|
||||
autoUpdater.downloadUpdate() |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
private async promptUserToInstall() { |
|
||||
const result = await dialog.showMessageBox({ |
|
||||
type: "info", |
|
||||
title: "更新已就绪", |
|
||||
message: "新版本已下载完成,是否立即安装?", |
|
||||
buttons: ["立即安装", "稍后安装"], |
|
||||
defaultId: 0, |
|
||||
}) |
|
||||
|
|
||||
if (result.response === 0) { |
|
||||
autoUpdater.quitAndInstall(false, true) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
export default Updater |
|
@ -0,0 +1,47 @@ |
|||||
|
import { BrowserView, BrowserWindow } from "electron" |
||||
|
|
||||
|
const cookies = { |
||||
|
getCurrCookies(params = {}, currWin: BrowserView | BrowserWindow) { |
||||
|
let currSession = currWin.webContents.session |
||||
|
return currSession.cookies.get(Object.assign({}, params)) |
||||
|
}, |
||||
|
removeCurrCookies(cookies = [], currWin: BrowserView | BrowserWindow) { |
||||
|
let currSession = currWin.webContents.session |
||||
|
let err = [] |
||||
|
let apiCount = 0 |
||||
|
return new Promise((resove, reject) => { |
||||
|
cookies.forEach(async (item: any) => { |
||||
|
await currSession.cookies.remove(`http://${item.domain}`, item.name) |
||||
|
apiCount = apiCount + 1 |
||||
|
if (err.length === apiCount) { |
||||
|
resove({ message: "cookie 清除成功" }) |
||||
|
} else { |
||||
|
reject(err) |
||||
|
} |
||||
|
}) |
||||
|
}) |
||||
|
}, |
||||
|
setCurrCookies(cookies = [], currWin: BrowserView | BrowserWindow) { |
||||
|
let currSession = currWin.webContents.session |
||||
|
let err = [] |
||||
|
let apiCount = 0 |
||||
|
return new Promise((resove, reject) => { |
||||
|
cookies.forEach(async (item: any) => { |
||||
|
await currSession.cookies.set( |
||||
|
Object.assign({}, item, { |
||||
|
url: `http://${item.domain}`, |
||||
|
name: item.name, |
||||
|
}), |
||||
|
) |
||||
|
apiCount = apiCount + 1 |
||||
|
if (err.length === apiCount) { |
||||
|
resove({ message: "cookie 设置成功!" }) |
||||
|
} else { |
||||
|
reject(err) |
||||
|
} |
||||
|
}) |
||||
|
}) |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
export default cookies |
@ -0,0 +1,82 @@ |
|||||
|
// https://blog.guowenfh.com/2017/10/21/2017/electron-multiple-session/#%E5%9C%A8-webview-%E4%B8%AD
|
||||
|
|
||||
|
import { BrowserWindow } from "electron" |
||||
|
/** |
||||
|
* 创建一个 登录 的窗口。 |
||||
|
* 用于 session 隔离 |
||||
|
* Promise 中有 {partition,userinfo,cookies} |
||||
|
* @returns Promise |
||||
|
*/ |
||||
|
function createLoginWin(partition) { |
||||
|
partition = partition || `persist:${Math.random()}` |
||||
|
// const charset = require("superagent-charset")
|
||||
|
// const request = charset(require("superagent")) // HTTP
|
||||
|
let presWindow = new BrowserWindow({ |
||||
|
width: 1280, |
||||
|
height: 768, |
||||
|
title: "用户登陆", |
||||
|
webPreferences: { |
||||
|
webSecurity: false, |
||||
|
allowRunningInsecureContent: true, |
||||
|
partition, |
||||
|
}, |
||||
|
}) |
||||
|
let webContents = presWindow.webContents |
||||
|
return new Promise(function (resove, _) { |
||||
|
// webContents.openDevTools();
|
||||
|
presWindow.loadURL("https://login.taobao.com/member/login.jhtml") |
||||
|
webContents.on("did-navigate-in-page", async function () { |
||||
|
// 这里可以看情况进行参数的传递,获取制定的 cookies
|
||||
|
const cookies = await webContents.session.cookies.get({}) |
||||
|
let obj = { partition, cookies } |
||||
|
resove(obj) |
||||
|
// webContents.session.cookies.get({}, function (err, cookies) {
|
||||
|
// if (err) {
|
||||
|
// presWindow.close() // 关闭登陆窗口
|
||||
|
// return reject(err)
|
||||
|
// }
|
||||
|
// let obj = { partition, cookies }
|
||||
|
// resove(obj)
|
||||
|
// fetch("https://login.taobao.com/member/login.jhtml", {
|
||||
|
// method: "GET",
|
||||
|
// credentials: "include",
|
||||
|
// headers: {
|
||||
|
// Cookie: cookies.map(item => `${item.name}=${item.value};`).join(" "),
|
||||
|
// "Content-Type": "application/json",
|
||||
|
// },
|
||||
|
// })
|
||||
|
// .then(response => response.json())
|
||||
|
// .then(data => {
|
||||
|
// console.log(data)
|
||||
|
// presWindow.close()
|
||||
|
// resove(obj)
|
||||
|
// })
|
||||
|
// .catch(err => {
|
||||
|
// presWindow.close()
|
||||
|
// reject(err)
|
||||
|
// })
|
||||
|
// })
|
||||
|
// 这一步并不是必需的。
|
||||
|
// request
|
||||
|
// .get("http://taobao.com/userinfo")
|
||||
|
// .query({ _: Date.now() }) // query string
|
||||
|
// .set("Cookie", cookies.map(item => `${item.name}=${item.value};`).join(" "))
|
||||
|
// .end(function (err, res) {
|
||||
|
// presWindow.close()
|
||||
|
// if (err) {
|
||||
|
// return reject(err)
|
||||
|
// }
|
||||
|
// if (!res || !res.body || !res.body.result !== 1) {
|
||||
|
// return reject(res.body)
|
||||
|
// }
|
||||
|
// let obj = { partition, cookies, userinfo: res.body.data }
|
||||
|
// resove(obj)
|
||||
|
// })
|
||||
|
}) |
||||
|
// })
|
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
export { |
||||
|
createLoginWin |
||||
|
} |
@ -1,8 +0,0 @@ |
|||||
import { ElectronAPI } from "@electron-toolkit/preload" |
|
||||
|
|
||||
declare global { |
|
||||
interface Window { |
|
||||
electron: ElectronAPI |
|
||||
api: unknown |
|
||||
} |
|
||||
} |
|
@ -1,9 +1,32 @@ |
|||||
<script setup lang="ts"></script> |
<script setup lang="ts"> |
||||
|
|
||||
|
</script> |
||||
|
|
||||
<template> |
<template> |
||||
|
<div h-full flex flex-col overflow-hidden> |
||||
|
<NavBar></NavBar> |
||||
|
<div flex-1 h-0 overflow-hidden flex flex-col relative id="page-container" style="transform: scale(1);"> |
||||
<router-view v-slot="{ Component, route }"> |
<router-view v-slot="{ Component, route }"> |
||||
<transition name="slide"> |
<Transition name="slide-fade" mode="out-in"> |
||||
<component :is="Component" :key="route" /> |
<component :is="Component" :key="route.fullPath" /> |
||||
</transition> |
</Transition> |
||||
</router-view> |
</router-view> |
||||
|
</div> |
||||
|
</div> |
||||
</template> |
</template> |
||||
|
|
||||
|
<style lang="scss" scoped> |
||||
|
.slide-fade-enter-active { |
||||
|
transition: all 0.2s ease-out; |
||||
|
} |
||||
|
|
||||
|
.slide-fade-leave-active { |
||||
|
transition: all 0.2s cubic-bezier(1, 0.5, 0.8, 1); |
||||
|
} |
||||
|
|
||||
|
.slide-fade-enter-from, |
||||
|
.slide-fade-leave-to { |
||||
|
// transform: translateX(20px); |
||||
|
opacity: 0; |
||||
|
} |
||||
|
</style> |
||||
|
After Width: | Height: | Size: 13 KiB |
@ -0,0 +1,57 @@ |
|||||
|
import { monaco } from "./monaco" |
||||
|
|
||||
|
/** |
||||
|
* Represents an placeholder renderer for monaco editor |
||||
|
* Roughly based on https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/codeEditor/browser/untitledTextEditorHint/untitledTextEditorHint.ts
|
||||
|
*/ |
||||
|
export class PlaceholderContentWidget implements monaco.editor.IContentWidget { |
||||
|
private static readonly ID = "editor.widget.placeholderHint" |
||||
|
|
||||
|
private domNode: HTMLElement | undefined |
||||
|
|
||||
|
constructor( |
||||
|
private readonly placeholder: string, |
||||
|
private readonly editor: monaco.editor.ICodeEditor, |
||||
|
) { |
||||
|
// register a listener for editor code changes
|
||||
|
editor.onDidChangeModelContent(() => this.onDidChangeModelContent()) |
||||
|
// ensure that on initial load the placeholder is shown
|
||||
|
this.onDidChangeModelContent() |
||||
|
} |
||||
|
|
||||
|
private onDidChangeModelContent(): void { |
||||
|
if (this.editor.getValue() === "") { |
||||
|
this.editor.addContentWidget(this) |
||||
|
} else { |
||||
|
this.editor.removeContentWidget(this) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
getId(): string { |
||||
|
return PlaceholderContentWidget.ID |
||||
|
} |
||||
|
|
||||
|
getDomNode(): HTMLElement { |
||||
|
if (!this.domNode) { |
||||
|
this.domNode = document.createElement("div") |
||||
|
this.domNode.style.width = "max-content" |
||||
|
this.domNode.style.pointerEvents = "none" |
||||
|
this.domNode.textContent = this.placeholder |
||||
|
this.domNode.style.fontStyle = "italic" |
||||
|
this.editor.applyFontInfo(this.domNode) |
||||
|
} |
||||
|
|
||||
|
return this.domNode |
||||
|
} |
||||
|
|
||||
|
getPosition(): monaco.editor.IContentWidgetPosition | null { |
||||
|
return { |
||||
|
position: { lineNumber: 1, column: 1 }, |
||||
|
preference: [monaco.editor.ContentWidgetPositionPreference.EXACT], |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
dispose(): void { |
||||
|
this.editor.removeContentWidget(this) |
||||
|
} |
||||
|
} |
@ -0,0 +1,306 @@ |
|||||
|
<script lang="ts" setup> |
||||
|
import { judgeFile } from "./utils" |
||||
|
import { monaco } from "./monaco" |
||||
|
import { computed, getCurrentScope, onBeforeUnmount, onMounted, onScopeDispose, ref, watch } from "vue" |
||||
|
import DefaultLogo from "./120x120.png" |
||||
|
import { PlaceholderContentWidget } from "./PlaceholderContentWidget" |
||||
|
const editorRef = ref<HTMLDivElement>() |
||||
|
let editor: monaco.editor.IStandaloneCodeEditor | null = null |
||||
|
let placeholderWidget: PlaceholderContentWidget | null = null |
||||
|
const props = withDefaults( |
||||
|
defineProps<{ |
||||
|
modelValue?: string |
||||
|
name?: string |
||||
|
logoType?: "bg" | "logo" |
||||
|
logo?: string |
||||
|
placeholder?: string |
||||
|
fontFamily?: string |
||||
|
readonly?: boolean |
||||
|
}>(), |
||||
|
{ |
||||
|
logo: DefaultLogo, |
||||
|
readonly: false, |
||||
|
logoType: "logo", |
||||
|
modelValue: "", |
||||
|
name: "", |
||||
|
}, |
||||
|
) |
||||
|
const emit = defineEmits<{ |
||||
|
(e: "update:modelValue", code: string): void |
||||
|
(e: "change", code: string): void |
||||
|
(e: "cursor:position", position: [number, number]): void |
||||
|
}>() |
||||
|
defineExpose({ |
||||
|
scrollTop() { |
||||
|
editor?.setScrollTop(0) |
||||
|
}, |
||||
|
insertText(text: string, type = "cursor") { |
||||
|
if (editor) { |
||||
|
const m = editor.getModel() |
||||
|
const currentPosition = editor.getPosition() |
||||
|
if (m) { |
||||
|
console.log(currentPosition) |
||||
|
if (type === "cursor" && currentPosition) { |
||||
|
m.pushEditOperations( |
||||
|
[], |
||||
|
[ |
||||
|
{ |
||||
|
range: new monaco.Range( |
||||
|
currentPosition.lineNumber, |
||||
|
currentPosition.column, |
||||
|
currentPosition.lineNumber, |
||||
|
currentPosition.column, |
||||
|
), |
||||
|
text, |
||||
|
}, |
||||
|
], |
||||
|
() => [ |
||||
|
new monaco.Selection( |
||||
|
currentPosition.lineNumber, |
||||
|
currentPosition.column, |
||||
|
currentPosition.lineNumber, |
||||
|
currentPosition.column, |
||||
|
), |
||||
|
], |
||||
|
) |
||||
|
} else { |
||||
|
const lineCount = m.getLineCount() |
||||
|
const lastLineLength = m.getLineLength(lineCount) |
||||
|
const range = new monaco.Selection(lineCount, lastLineLength + 1, lineCount, lastLineLength + 1) |
||||
|
const text = "your text" |
||||
|
const op = { |
||||
|
range: range, |
||||
|
text: text, |
||||
|
} |
||||
|
m.pushEditOperations([], [op], () => [range]) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
setContent(content: string) { |
||||
|
if (editorRef.value && editor) { |
||||
|
editor.setValue(content) |
||||
|
} |
||||
|
}, |
||||
|
}) |
||||
|
|
||||
|
let isInnerChange = "first" // waitting, out, in |
||||
|
function updateModel(name: string, content: string) { |
||||
|
if (editor) { |
||||
|
const oldModel = editor.getModel() //获取旧模型 |
||||
|
const file = judgeFile(name) |
||||
|
// 这样定义的话model无法清除 |
||||
|
// monaco.editor.createModel("const a = 111","typescript", monaco.Uri.parse('file://root/file3.ts')) |
||||
|
const model: monaco.editor.ITextModel = monaco.editor.createModel(content ?? "", file?.language ?? "txt") |
||||
|
model.onDidChangeContent(() => { |
||||
|
if (model) { |
||||
|
if(isInnerChange === "out") { |
||||
|
isInnerChange = "waitting" |
||||
|
return |
||||
|
} |
||||
|
isInnerChange = "in" |
||||
|
const code = model.getValue() |
||||
|
emit("update:modelValue", code) |
||||
|
emit("change", code) |
||||
|
} |
||||
|
}) |
||||
|
if (oldModel) { |
||||
|
oldModel.dispose() |
||||
|
} |
||||
|
editor.setModel(model) |
||||
|
} |
||||
|
} |
||||
|
function resizeLayout() { |
||||
|
if (editor) { |
||||
|
editor.layout() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
onMounted(() => { |
||||
|
if (editorRef.value && !editor) { |
||||
|
editor = monaco.editor.create(editorRef.value, { |
||||
|
theme: "vs-light", |
||||
|
fontFamily: props.fontFamily ?? "Cascadia Mono, Consolas, 'Courier New', monospace", |
||||
|
readOnly: props.readonly, |
||||
|
minimap: { |
||||
|
autohide: true, |
||||
|
}, |
||||
|
}) as monaco.editor.IStandaloneCodeEditor |
||||
|
editor.onDidChangeCursorPosition(e => { |
||||
|
emit("cursor:position", [e.position.lineNumber, e.position.column]) |
||||
|
}) |
||||
|
editorRef.value.addEventListener("resize", resizeLayout) |
||||
|
} |
||||
|
watch( |
||||
|
() => props.placeholder, |
||||
|
() => { |
||||
|
if (editor) { |
||||
|
if (placeholderWidget) { |
||||
|
placeholderWidget.dispose() |
||||
|
placeholderWidget = null |
||||
|
} |
||||
|
if (props.placeholder) { |
||||
|
placeholderWidget = new PlaceholderContentWidget(props.placeholder, editor) |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
{ |
||||
|
immediate: true, |
||||
|
}, |
||||
|
) |
||||
|
// 如果不需要从动态外部更改代码的话应该就不需要这个 |
||||
|
watch( |
||||
|
() => props.modelValue, |
||||
|
async str => { |
||||
|
if(isInnerChange === "waitting") { |
||||
|
isInnerChange = "out" |
||||
|
} |
||||
|
if (editor && isInnerChange === "out") { |
||||
|
editor.setValue(str) |
||||
|
} else { |
||||
|
isInnerChange = "waitting" |
||||
|
} |
||||
|
}, |
||||
|
{ immediate: true }, |
||||
|
) |
||||
|
watch( |
||||
|
() => props.name, |
||||
|
async name => { |
||||
|
if (editor) { |
||||
|
updateModel(name, props.modelValue) |
||||
|
} |
||||
|
}, |
||||
|
{ immediate: true }, |
||||
|
) |
||||
|
watch( |
||||
|
() => props.readonly, |
||||
|
() => { |
||||
|
if (editor) { |
||||
|
editor.updateOptions({ |
||||
|
readOnly: props.readonly, |
||||
|
}) |
||||
|
} |
||||
|
}, |
||||
|
) |
||||
|
watch( |
||||
|
() => props.fontFamily, |
||||
|
() => { |
||||
|
if (editor) { |
||||
|
editor.updateOptions({ |
||||
|
fontFamily: props.fontFamily, |
||||
|
}) |
||||
|
} |
||||
|
}, |
||||
|
) |
||||
|
}) |
||||
|
if (import.meta.hot) { |
||||
|
import.meta.hot.accept(newModule => { |
||||
|
console.log(newModule) |
||||
|
}) |
||||
|
} |
||||
|
onBeforeUnmount(() => { |
||||
|
if (editorRef.value) { |
||||
|
editorRef.value.removeEventListener("resize", resizeLayout) |
||||
|
} |
||||
|
if (editor) { |
||||
|
const oldModel = editor.getModel() |
||||
|
if (oldModel) { |
||||
|
oldModel.dispose() |
||||
|
} |
||||
|
editor.dispose() |
||||
|
editor = null |
||||
|
} |
||||
|
}) |
||||
|
const style = computed(() => { |
||||
|
if (props.logo && props.logoType === "bg") { |
||||
|
return { |
||||
|
backgroundImage: `url(${props.logo})`, |
||||
|
backgroundSize: "cover", |
||||
|
backgroundRepeat: "no-repeat", |
||||
|
backgroundPosition: "center center", |
||||
|
} |
||||
|
} |
||||
|
return {} |
||||
|
}) |
||||
|
|
||||
|
const getLogo = computed(() => { |
||||
|
if (props.logo) return props.logo |
||||
|
return DefaultLogo |
||||
|
}) |
||||
|
|
||||
|
function useResizeObserver(callback: ResizeObserverCallback) { |
||||
|
const isSupported = window && "ResizeObserver" in window |
||||
|
let observer: ResizeObserver | undefined |
||||
|
const cleanup = () => { |
||||
|
if (observer) { |
||||
|
observer.disconnect() |
||||
|
observer = undefined |
||||
|
} |
||||
|
} |
||||
|
const stopWatch = watch( |
||||
|
() => editorRef.value, |
||||
|
el => { |
||||
|
cleanup() |
||||
|
if (isSupported && window && el) { |
||||
|
observer = new ResizeObserver(callback) |
||||
|
observer!.observe(el, {}) |
||||
|
} |
||||
|
}, |
||||
|
{ immediate: true }, |
||||
|
) |
||||
|
const stop = () => { |
||||
|
cleanup() |
||||
|
stopWatch() |
||||
|
} |
||||
|
function tryOnScopeDispose(fn: () => void) { |
||||
|
if (getCurrentScope()) { |
||||
|
onScopeDispose(fn) |
||||
|
return true |
||||
|
} |
||||
|
return false |
||||
|
} |
||||
|
tryOnScopeDispose(() => { |
||||
|
stop() |
||||
|
}) |
||||
|
} |
||||
|
useResizeObserver(() => { |
||||
|
if (editor) { |
||||
|
editor.layout() |
||||
|
} |
||||
|
}) |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<div class="monaco-wrapper"> |
||||
|
<div class="monaco-editor" ref="editorRef"></div> |
||||
|
<div class="monaco-bg" :style="style"> |
||||
|
<img v-if="logoType === 'logo' && getLogo" class="monaco-logo" :src="getLogo" alt="" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<style lang="scss" scoped> |
||||
|
.monaco-wrapper { |
||||
|
height: 100%; |
||||
|
position: relative; |
||||
|
|
||||
|
.monaco-editor { |
||||
|
height: 100%; |
||||
|
} |
||||
|
|
||||
|
.monaco-bg { |
||||
|
position: absolute; |
||||
|
top: 0; |
||||
|
left: 0; |
||||
|
right: 0; |
||||
|
bottom: 0; |
||||
|
pointer-events: none; |
||||
|
opacity: 0.1; |
||||
|
overflow: hidden; |
||||
|
|
||||
|
.monaco-logo { |
||||
|
@apply absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
</style> |
@ -0,0 +1,16 @@ |
|||||
|
// import 'monaco-editor/esm/vs/editor/editor.all.js';
|
||||
|
|
||||
|
// import 'monaco-editor/esm/vs/editor/standalone/browser/accessibilityHelp/accessibilityHelp.js';
|
||||
|
// import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
|
||||
|
// import 'monaco-editor/esm/vs/basic-languages/monaco.contribution.js';
|
||||
|
|
||||
|
// import 'monaco-editor/esm/vs/basic-languages/typescript/typescript.contribution.js';
|
||||
|
// import 'monaco-editor/esm/vs/basic-languages/javascript/javascript.contribution.js';
|
||||
|
// import 'monaco-editor/esm/vs/basic-languages/html/html.contribution.js';
|
||||
|
// import 'monaco-editor/esm/vs/basic-languages/css/css.contribution.js';
|
||||
|
// import 'monaco-editor/esm/vs/basic-languages/java/java.contribution.js';
|
||||
|
|
||||
|
// 导入全部特性
|
||||
|
import * as monaco from "monaco-editor" |
||||
|
|
||||
|
export { monaco } |
@ -0,0 +1,3 @@ |
|||||
|
占位符 |
||||
|
https://github.com/Microsoft/monaco-editor/issues/1228 |
||||
|
https://github.com/microsoft/monaco-editor/issues/568#issuecomment-1499966160 |
@ -0,0 +1,32 @@ |
|||||
|
export function judgeFile(filename: string) { |
||||
|
if (!filename) return |
||||
|
let ext = [ |
||||
|
{ language: "vue", ext: ".vue", index: -1 }, |
||||
|
{ language: "javascript", ext: ".js", index: -1 }, |
||||
|
{ language: "css", ext: ".css", index: -1 }, |
||||
|
{ language: "html", ext: ".html", index: -1 }, |
||||
|
{ language: "tsx", ext: ".tsx", index: -1 }, |
||||
|
{ language: "typescript", ext: ".ts", index: -1 }, |
||||
|
{ language: "markdown", ext: ".md", index: -1 }, |
||||
|
{ language: "json", ext: ".json", index: -1 }, |
||||
|
{ language: "web", ext: ".web", index: -1 }, |
||||
|
{ language: "dot", pre: ".", index: -1 }, |
||||
|
] |
||||
|
let cur |
||||
|
for (let i = 0; i < ext.length; i++) { |
||||
|
const e = ext[i] |
||||
|
if (e.ext && filename.endsWith(e.ext)) { |
||||
|
let index = filename.lastIndexOf(e.ext) |
||||
|
e.index = index |
||||
|
cur = e |
||||
|
break |
||||
|
} |
||||
|
if (e.pre && filename.startsWith(e.pre)) { |
||||
|
let index = filename.indexOf(e.pre) |
||||
|
e.index = index |
||||
|
cur = e |
||||
|
break |
||||
|
} |
||||
|
} |
||||
|
return cur |
||||
|
} |
@ -1,91 +0,0 @@ |
|||||
<template> |
|
||||
<div |
|
||||
relative |
|
||||
h="30px" |
|
||||
leading="29px" |
|
||||
pr="137px" |
|
||||
:style="{ paddingRight: isFullScreen ? '0' : '' }" |
|
||||
select-none |
|
||||
border-b="1px solid #E5E5E5" |
|
||||
bg="#F8F8F8" |
|
||||
> |
|
||||
<div absolute top-0 right-0 bottom-0 left-0 style="-webkit-app-region: drag"></div> |
|
||||
<div h-full px-2 flex items-center gap-1 justify-between> |
|
||||
<div flex items-center gap-1> |
|
||||
<img w="16px" h="16px" :src="icon" /> |
|
||||
<div relative h-full inline-flex items-center text-sm>{{ config.app_title }}</div> |
|
||||
<div relative class="list"> |
|
||||
<div class="item" @click="onClickMenu">菜单</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
<div float-right h-full flex items-center relative style="-webkit-app-region: no-drag"> |
|
||||
<div text-sm px-2 hover:rounded-md hover:bg-gray-2 hover:cursor-pointer text="hover:hover" @click="onClickAbout">关于</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
</template> |
|
||||
|
|
||||
<script setup lang="ts"> |
|
||||
import icon from "@res/icon.png" |
|
||||
import config from "config" |
|
||||
import { PopupMenu } from "@/bridge/PopupMenu" |
|
||||
|
|
||||
const router = useRouter() |
|
||||
const isFullScreen = ref(false) |
|
||||
onBeforeMount(async () => { |
|
||||
isFullScreen.value = await api.call("BasicCommand.isFullscreen") |
|
||||
}) |
|
||||
const onClickMenu = e => { |
|
||||
const menu = new PopupMenu([ |
|
||||
{ |
|
||||
label: "返回", |
|
||||
async click() { |
|
||||
router.back() |
|
||||
}, |
|
||||
}, |
|
||||
{ |
|
||||
label: isFullScreen.value ? "取消全屏" : "全屏", |
|
||||
async click() { |
|
||||
isFullScreen.value = await api.call("BasicCommand.fullscreen") |
|
||||
}, |
|
||||
}, |
|
||||
{ |
|
||||
label: "切换开发者工具", |
|
||||
async click() { |
|
||||
isFullScreen.value = await api.call("BasicCommand.toggleDevTools") |
|
||||
}, |
|
||||
}, |
|
||||
{ |
|
||||
type: "separator", |
|
||||
}, |
|
||||
{ |
|
||||
label: "重载", |
|
||||
click() { |
|
||||
api.call("BasicCommand.reload") |
|
||||
}, |
|
||||
}, |
|
||||
{ |
|
||||
label: "重启", |
|
||||
click() { |
|
||||
api.call("BasicCommand.relunch") |
|
||||
}, |
|
||||
}, |
|
||||
]) |
|
||||
const obj = e.target.getBoundingClientRect() |
|
||||
menu.show({ x: ~~obj.x, y: ~~(obj.y + obj.height) }) |
|
||||
} |
|
||||
|
|
||||
const onClickAbout = () => { |
|
||||
fetch("api://fuck/BasicService/showAbout") |
|
||||
} |
|
||||
</script> |
|
||||
|
|
||||
<style lang="scss" scoped> |
|
||||
.list { |
|
||||
@apply: flex gap="5px"; |
|
||||
-webkit-app-region: no-drag; |
|
||||
.item { |
|
||||
@apply: text-sm px-2 hover:rounded-md hover:bg-gray-2 hover:cursor-pointer text="hover:hover"; |
|
||||
} |
|
||||
} |
|
||||
</style> |
|
@ -1 +0,0 @@ |
|||||
declare const api |
|
@ -0,0 +1,26 @@ |
|||||
|
import { createI18n } from "vue-i18n" |
||||
|
import messages from "@intlify/unplugin-vue-i18n/messages" |
||||
|
import { datetimeFormats } from "locales" // 引入以便热更新同时提供datetimeFormats
|
||||
|
// https://vue-i18n.intlify.dev/guide/essentials/syntax.html
|
||||
|
// let locale = "zh"
|
||||
|
|
||||
|
// const curConfig = useGetConfig()
|
||||
|
// if (curConfig.value.language) {
|
||||
|
// locale = curConfig.value.language
|
||||
|
// }
|
||||
|
|
||||
|
// console.log(locale)
|
||||
|
console.log(messages) |
||||
|
|
||||
|
const i18n = createI18n({ |
||||
|
legacy: false, |
||||
|
allowComposition: true, |
||||
|
locale: "zh", |
||||
|
fallbackLocale: "zh", |
||||
|
messages: messages, |
||||
|
// @ts-ignore ...
|
||||
|
datetimeFormats, |
||||
|
}) |
||||
|
|
||||
|
export { i18n } |
||||
|
export default i18n |
@ -1,10 +1,15 @@ |
|||||
<script lang="ts" setup></script> |
<script lang="ts" setup> |
||||
|
import Simplebar from "simplebar-vue" |
||||
|
</script> |
||||
|
|
||||
<template> |
<template> |
||||
<div h-full flex flex-col> |
<Simplebar h-full> |
||||
<NavBar></NavBar> |
|
||||
<div flex-1 h-0 overflow="auto"> |
|
||||
<RouterView></RouterView> |
<RouterView></RouterView> |
||||
</div> |
</Simplebar> |
||||
</div> |
|
||||
</template> |
</template> |
||||
|
|
||||
|
<style scoped> |
||||
|
:deep(.simplebar-content) { |
||||
|
height: 100%; |
||||
|
} |
||||
|
</style> |
||||
|
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue