Compare commits

...

29 Commits

Author SHA1 Message Date
谢亚昕 e969ec2236 优化事件系统和配置管理,增强日志功能 2 days ago
谢亚昕 a9de1ec525 重构项目架构并优化热更新功能 3 days ago
谢亚昕 fcad3681b4 feat: 添加fs-extra依赖并优化代码结构 4 weeks ago
谢亚昕 f19c097001 add 1 month ago
谢亚昕 81f76353f6 add 2 months ago
npmrun 068777d914 refactor: 移除law-ui依赖并优化代码结构 2 months ago
谢亚昕 34a762ad2c cookie测试 2 months ago
谢亚昕 23893adc43 feat 2 months ago
谢亚昕 6692e16720 优化 2 months ago
谢亚昕 de5f511d6a fix bug 2 months ago
npmrun d4be8b22b9 feat(崩溃处理): 添加崩溃处理模块以捕获并报告应用崩溃 2 months ago
npmrun c142937af9 refactor(logger): 重构错误处理逻辑,分离渲染进程和preload进程的错误处理 2 months ago
npmrun 950bfe9060 feat(logger): 添加错误处理模块并集成到主进程和渲染进程 2 months ago
npmrun 7035429775 feat(logger): 添加固定命名空间的日志记录功能 2 months ago
npmrun 05f83e2a08 feat: 添加logger和setting模块并重构日志系统 2 months ago
npmrun fa6ef80493 Merge branch 'feat/优化' of ssh://git.xieyaxin.top:8892/topuser/electron-app into feat/优化 2 months ago
npmrun 0f093b2ef9 refactor(PlatForm): 重构 PlatForm 相关代码并添加重载功能 2 months ago
谢亚昕 80cc4fe0fe fix bug 2 months ago
npmrun 28eea56a3d refactor(config): 导出 IDefaultConfig 接口以重用类型定义 2 months ago
npmrun b6964f5fbe style: 统一代码缩进为2个空格,提升代码可读性 2 months ago
npmrun 7246ab2d9a refactor(命令模块): 将命令模块从主进程迁移到通用模块,并重构相关代码 2 months ago
npmrun bd9ac214c6 feat(updater): 实现热更新功能并优化命令处理 2 months ago
谢亚昕 dcdc4aa857 fix bug 2 months ago
npmrun 2d5a57853d feat 2 months ago
谢亚昕 3c434df31c 修改了一些东西 2 months ago
npmrun b4b975174d feat: 增加很多功能 3 months ago
npmrun 91f06eb4a1 feat: 优化 3 months ago
谢亚昕 248716be69 feat: 优化界面 3 months ago
npmrun ca363ceac9 feat: 优化 3 months ago
  1. 2
      .editorconfig
  2. 21
      .eslintrc.cjs
  3. 23
      .prettierrc
  4. 2
      .vscode/extensions.json
  5. 16
      .vscode/settings.json
  6. 47
      config/index.ts
  7. 38
      electron.vite.config.ts
  8. 40
      package.json
  9. 123
      packages/base/event/main/index copy.ts
  10. 51
      packages/base/event/main/index.ts
  11. 27
      packages/base/index.ts
  12. 13
      packages/base/package.json
  13. 13
      packages/helper/package.json
  14. 7
      packages/helper/updater/common.ts
  15. 8
      packages/helper/updater/main/handler.ts
  16. 74
      packages/helper/updater/main/hot/download.ts
  17. 118
      packages/helper/updater/main/hot/index.ts
  18. 137
      packages/helper/updater/main/index.ts
  19. 0
      packages/helper/updater/renderer.ts
  20. 41
      packages/locales/index.ts
  21. 20
      packages/locales/languages/en.json
  22. 20
      packages/locales/languages/zh.json
  23. 53
      packages/locales/main.ts
  24. 7
      packages/locales/package.json
  25. 31
      packages/logger/common.ts
  26. 486
      packages/logger/crash-handler.ts
  27. 177
      packages/logger/main-error.ts
  28. 275
      packages/logger/main.ts
  29. 7
      packages/logger/package.json
  30. 195
      packages/logger/preload-error.ts
  31. 153
      packages/logger/preload.ts
  32. 243
      packages/logger/renderer-error.ts
  33. 231
      packages/setting/main.ts
  34. 6
      packages/setting/main/event.ts
  35. 7
      packages/setting/package.json
  36. 928
      pnpm-lock.yaml
  37. 9
      resources/fuck.html
  38. 34
      src/common/_ioc.main.ts
  39. 5
      src/common/event/PlatForm/hook.ts
  40. 58
      src/common/event/PlatForm/index.ts
  41. 121
      src/common/event/PlatForm/main/command.ts
  42. 5
      src/common/event/Snippet/hook.ts
  43. 18
      src/common/event/Snippet/index.ts
  44. 44
      src/common/event/Snippet/main/command.ts
  45. 54
      src/common/event/Tabs/index.ts
  46. 65
      src/common/event/Tabs/main/command.ts
  47. 15
      src/common/event/Updater/index.ts
  48. 15
      src/common/event/Updater/main/command.ts
  49. 53
      src/common/lib/abstract.ts
  50. 29
      src/common/lib/browser.ts
  51. 20
      src/common/lib/electron.ts
  52. 7
      src/common/readme.md
  53. 21
      src/main/App.ts
  54. 2
      src/main/_ioc.ts
  55. 130
      src/main/base/LinkedList.ts
  56. 52
      src/main/base/base.ts
  57. 63
      src/main/commands/BasicCommand.ts
  58. 66
      src/main/commands/TabsCommand.ts
  59. 15
      src/main/commands/_ioc.ts
  60. 22
      src/main/controller/_ioc.ts
  61. 60
      src/main/debug.ts
  62. 6
      src/main/event.ts
  63. 84
      src/main/index.ts
  64. 6
      src/main/modules/_ioc.ts
  65. 1
      src/main/modules/api/readme.md
  66. 1
      src/main/modules/api/test.ts
  67. 23
      src/main/modules/commands/index.ts
  68. 16
      src/main/modules/db/index.ts
  69. 235
      src/main/modules/setting/index.ts
  70. 10
      src/main/modules/tabs/Tab.ts
  71. 2
      src/main/modules/tabs/index.ts
  72. 114
      src/main/modules/updater/index.ts
  73. 33
      src/main/modules/window-manager/index.ts
  74. 29
      src/main/modules/window-manager/windowsMap.ts
  75. 476
      src/main/modules/zephyr/index.ts
  76. 6
      src/main/utils/index.ts
  77. 47
      src/main/utils/session/cookies.ts
  78. 82
      src/main/utils/session/index.ts
  79. 8
      src/preload/index.d.ts
  80. 4
      src/preload/index.ts
  81. 0
      src/preload/plugin.ts
  82. 12
      src/renderer/about.html
  83. 2
      src/renderer/auto-imports.d.ts
  84. 16
      src/renderer/components.d.ts
  85. 9
      src/renderer/index.html
  86. 31
      src/renderer/src/App.vue
  87. 3
      src/renderer/src/assets/libs/scrollbot.ts
  88. 1
      src/renderer/src/assets/style/_common.scss
  89. 18
      src/renderer/src/bridge/PopupMenu.ts
  90. 2
      src/renderer/src/components/AdjustLine.vue
  91. BIN
      src/renderer/src/components/CodeEditor/120x120.png
  92. 57
      src/renderer/src/components/CodeEditor/PlaceholderContentWidget.ts
  93. 306
      src/renderer/src/components/CodeEditor/code-editor.vue
  94. 16
      src/renderer/src/components/CodeEditor/monaco.ts
  95. 3
      src/renderer/src/components/CodeEditor/readme.md
  96. 32
      src/renderer/src/components/CodeEditor/utils.ts
  97. 91
      src/renderer/src/components/NavBar.vue
  98. 1
      src/renderer/src/global.d.ts
  99. 26
      src/renderer/src/i18n/index.ts
  100. 17
      src/renderer/src/layouts/default.vue

2
.editorconfig

@ -4,7 +4,7 @@ root = true
[*]
charset = utf-8
indent_style = space
indent_size = 4
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

21
.eslintrc.cjs

@ -1,6 +1,10 @@
const { readFileSync } = require("node:fs")
/* eslint-env node */
require("@rushstack/eslint-patch/modern-module-resolution")
const prettierConfig = JSON.parse(readFileSync("./.prettierrc", { encoding: "utf-8" }))
module.exports = {
extends: [
"eslint:recommended",
@ -14,21 +18,6 @@ module.exports = {
"vue/require-default-prop": "off",
"vue/multi-word-component-names": "off",
"@typescript-eslint/no-explicit-any": "off",
"prettier/prettier": [
"error",
{
tabWidth: 4,
useTabs: false,
semi: false,
singleQuote: false,
trailingComma: "all",
bracketSpacing: true,
arrowParens: "avoid",
printWidth: 140,
htmlWhitespaceSensitivity: "ignore",
proseWrap: "preserve",
endOfLine: "auto",
},
],
"prettier/prettier": ["error", prettierConfig],
},
}

23
.prettierrc

@ -1,5 +1,5 @@
{
"tabWidth": 4,
"tabWidth": 2,
"useTabs": false,
"semi": false,
"singleQuote": false,
@ -9,5 +9,24 @@
"printWidth": 140,
"htmlWhitespaceSensitivity": "ignore",
"proseWrap": "preserve",
"endOfLine": "auto"
"endOfLine": "auto",
"vueIndentScriptAndStyle": true,
"embeddedLanguageFormatting": "auto",
"jsxSingleQuote": false,
"jsxBracketSameLine": false,
"quoteProps": "as-needed",
"overrides": [
{
"files": "*.json",
"options": {
"printWidth": 80
}
},
{
"files": ["*.vue", "*.tsx"],
"options": {
"singleAttributePerLine": false
}
}
]
}

2
.vscode/extensions.json

@ -1,3 +1,3 @@
{
"recommendations": ["dbaeumer.vscode-eslint"]
"recommendations": ["dbaeumer.vscode-eslint", "lokalise.i18n-ally"]
}

16
.vscode/settings.json

@ -7,5 +7,19 @@
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
},
"i18n-ally.localesPaths": ["packages/locales/languages"],
"i18n-ally.sourceLanguage": "zh",
"i18n-ally.displayLanguage": "zh",
"i18n-ally.keystyle": "nested",
"i18n-ally.extract.autoDetect": true,
"i18n-ally.enabledFrameworks": ["vue"],
"i18n-ally.enabledParsers": ["json"],
"i18n-ally.translate.engines":[
"openai"
],
// "i18n-ally.translate.openai.apiKey": "",
"i18n-ally.translate.openai.apiModel": "Qwen/Qwen2.5-72B-Instruct",
"i18n-ally.translate.openai.apiRoot": "https://api.siliconflow.cn",
"i18n-ally.translate.overrideExisting": true
}

47
config/index.ts

@ -1,35 +1,52 @@
interface IConfig {
app_title: string
default_config: {
language: "zh" | "en" // i18n
"common.theme": "light" | "dark" | "auto" // 主题
import { LogLevel } from "logger/common"
// 定义主题类型
type ThemeType = "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
"update.repo"?: string // 更新地址
"update.owner"?: string // 更新通道
"update.hoturl": string
"update.repo"?: string
"update.owner"?: string
"update.allowDowngrade": boolean
"update.allowPrerelease": boolean
"editor.bg": string // 更新通道
"editor.logoType": "logo" | "bg" // 更新通道
"editor.fontFamily": string // 更新通道
// "snippet.storagePath": string // 代码片段保存位置
// "bookmark.storagePath": string // 书签保存位置
// backup_rule: string // 备份规则
storagePath: string // 存储地址
"editor.bg": string
"editor.logoType": LogoType
"editor.fontFamily": string
"snippet.storagePath": string
storagePath: string
}
interface IConfig {
app_title: string
default_config: IDefaultConfig
}
// 默认配置导出
export default {
app_title: "zephyr", // 和风
default_config: {
storagePath: "$storagePath$",
language: "zh",
debug: LogLevel.INFO,
"common.theme": "auto",
"desktop:wallpaper": "",
"editor.bg": "",
"editor.logoType": "logo",
"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.owner": "npmrun",
"update.allowDowngrade": false,
"update.allowPrerelease": false,
"snippet.storagePath": "$storagePath$/snippets",
},
} as IConfig
} as const satisfies IConfig

38
electron.vite.config.ts

@ -9,6 +9,10 @@ import VueMacros from "unplugin-vue-macros/vite"
import { VueRouterAutoImports } from "unplugin-vue-router"
import VueRouter from "unplugin-vue-router/vite"
import Layouts from "vite-plugin-vue-layouts"
import VueI18nPlugin from "@intlify/unplugin-vue-i18n/vite"
import monacoEditorPlugin from "vite-plugin-monaco-editor"
import IconsResolver from "unplugin-icons/resolver"
import Icons from "unplugin-icons/vite"
export default defineConfig({
main: {
@ -16,12 +20,21 @@ export default defineConfig({
alias: {
config: resolve("config"),
main: resolve("src/main"),
res: resolve("resources"),
common: resolve("src/common"),
"@res": resolve("resources"),
},
},
plugins: [externalizeDepsPlugin()],
},
preload: {
build: {
lib: {
entry: {
index: resolve(__dirname, "./src/preload/index.ts"),
plugin: resolve(__dirname, "./src/preload/plugin.ts"),
},
},
},
plugins: [externalizeDepsPlugin()],
},
renderer: {
@ -29,6 +42,7 @@ export default defineConfig({
resolve: {
alias: {
config: resolve("config"),
common: resolve("src/common"),
"@": resolve("src/renderer/src"),
"@res": resolve("resources"),
},
@ -37,6 +51,7 @@ export default defineConfig({
preprocessorOptions: {
scss: {
additionalData: `@use "@/assets/style/global" as *;\n`,
api: "modern-compiler",
},
},
},
@ -62,6 +77,10 @@ export default defineConfig({
}),
},
}),
VueI18nPlugin({
compositionOnly: false,
include: resolve(__dirname, "packages/locales/languages/**"),
}),
Layouts({
layoutsDirs: "src/layouts",
pagesDirs: "src/pages",
@ -79,6 +98,7 @@ export default defineConfig({
// add any other imports you were relying on
"vue-router/auto": ["useLink"],
},
"vue-i18n",
],
dts: true,
dirs: ["src/composables"],
@ -87,7 +107,21 @@ export default defineConfig({
// https://github.com/antfu/vite-plugin-components
Components({
dts: true,
dirs: ["src/components"],
dirs: ["src/components", "src/ui"],
resolvers: [
IconsResolver({
prefix: "icon",
}),
],
}),
Icons(),
// https://wf0.github.io/example/plugins/Formatter.html
// @ts-ignore ...
monacoEditorPlugin.default({
publicPath: "monacoeditorwork",
customDistPath() {
return resolve(__dirname, "out/renderer/monacoeditorwork")
},
}),
],
},

40
package.json

@ -2,7 +2,7 @@
"name": "zephyr",
"type": "module",
"private": true,
"version": "1.0.0",
"version": "0.0.1",
"description": "An Electron application with Vue and TypeScript",
"main": "./out/main/index.js",
"author": "example.com",
@ -27,45 +27,63 @@
"dependencies": {
"@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/utils": "^3.0.0",
"@types/debug": "^4.1.12",
"@unocss/reset": "^0.64.1",
"@vueuse/core": "^12.7.0",
"electron-updater": "^6.3.9",
"fs-extra": "^11.3.0",
"inversify": "^6.2.2",
"lowdb": "^7.0.1",
"reflect-metadata": "^0.2.2",
"sass": "^1.85.0",
"unplugin-auto-import": "^19.1.0",
"unplugin-vue-components": "^28.4.0",
"unplugin-vue-macros": "^2.14.2",
"unplugin-vue-router": "^0.11.2",
"vue-router": "^4.5.0"
"reflect-metadata": "^0.2.2"
},
"devDependencies": {
"@electron-toolkit/eslint-config": "^1.0.2",
"@electron-toolkit/eslint-config-ts": "^2.0.0",
"@electron-toolkit/tsconfig": "^1.0.1",
"@iconify/json": "^2.2.324",
"@intlify/unplugin-vue-i18n": "^6.0.3",
"@rushstack/eslint-patch": "^1.10.5",
"@types/debug": "^4.1.12",
"@types/node": "^20.17.19",
"@types/nprogress": "^0.2.3",
"@unocss/preset-rem-to-px": "^0.64.1",
"@unocss/reset": "^0.64.1",
"@vitejs/plugin-vue": "^5.2.1",
"@vitejs/plugin-vue-jsx": "^4.1.1",
"@vue/eslint-config-prettier": "^9.0.0",
"@vue/eslint-config-typescript": "^13.0.0",
"@vueuse/core": "^12.7.0",
"base": "workspace:*",
"debug": "^4.4.0",
"electron": "^31.7.7",
"electron-builder": "^24.13.3",
"electron-vite": "^2.3.0",
"eslint": "^8.57.1",
"eslint-plugin-vue": "^9.32.0",
"extract-zip": "^2.0.1",
"helper": "workspace:*",
"locales": "workspace:*",
"lodash-es": "^4.17.21",
"logger": "workspace:^",
"monaco-editor": "^0.52.2",
"nprogress": "^0.2.0",
"pinia": "^3.0.2",
"pinia-plugin-persistedstate": "^4.2.0",
"prettier": "^3.5.1",
"rotating-file-stream": "^3.2.6",
"sass": "^1.85.0",
"setting": "workspace:^",
"simplebar-vue": "^2.4.0",
"typescript": "^5.7.3",
"unocss": "^0.64.1",
"unplugin-auto-import": "^19.1.0",
"unplugin-icons": "^22.1.0",
"unplugin-vue-components": "^28.4.0",
"unplugin-vue-macros": "^2.14.2",
"unplugin-vue-router": "^0.11.2",
"vite": "^5.4.14",
"vite-plugin-monaco-editor": "^1.1.0",
"vite-plugin-vue-layouts": "^0.11.0",
"vue": "^3.5.13",
"vue-i18n": "^11.1.1",
"vue-router": "^4.5.0",
"vue-tsc": "^2.1.10"
}
}

123
packages/base/event/main/index copy.ts

@ -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>()
}

51
packages/base/event/main/index.ts

@ -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>()
}

27
packages/base/index.ts

@ -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 }

13
packages/base/package.json

@ -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"
}

13
packages/helper/package.json

@ -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"
}

7
packages/helper/updater/common.ts

@ -0,0 +1,7 @@
export const enum EventEnum {
UPDATE_PROGRESS = "update-progress",
}
export type EventMaps = {
[EventEnum.UPDATE_PROGRESS]: () => void
}

8
packages/helper/updater/main/handler.ts

@ -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)
}

74
packages/helper/updater/main/hot/download.ts

@ -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)
// },
// })

118
packages/helper/updater/main/hot/index.ts

@ -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
}

137
packages/helper/updater/main/index.ts

@ -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
packages/helper/updater/renderer.ts

41
packages/locales/index.ts

@ -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 }

20
packages/locales/languages/en.json

@ -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"
}
}
}
}

20
packages/locales/languages/zh.json

@ -0,0 +1,20 @@
{
"update": {
"ready": {
"hot": {
"title": "提示",
"desc": "新版本 v{version} 已经准备好更新, 下次启动程序即可自动更新"
}
}
},
"browser": {
"navbar": {
"menu": {
"label": "菜单",
"fullscreen": "全屏",
"quit-fullscreen": "取消全屏",
"toggleDevTools": "开发者面板"
}
}
}
}

53
packages/locales/main.ts

@ -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 }

7
packages/locales/package.json

@ -0,0 +1,7 @@
{
"name": "locales",
"version": "1.0.0",
"keywords": [],
"author": "",
"license": "ISC"
}

31
packages/logger/common.ts

@ -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]: "", // 无色
}

486
packages/logger/crash-handler.ts

@ -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 }

177
packages/logger/main-error.ts

@ -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

275
packages/logger/main.ts

@ -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

7
packages/logger/package.json

@ -0,0 +1,7 @@
{
"name": "logger",
"version": "1.0.0",
"keywords": [],
"author": "",
"license": "ISC"
}

195
packages/logger/preload-error.ts

@ -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 }

153
packages/logger/preload.ts

@ -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 }

243
packages/logger/renderer-error.ts

@ -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
* })
*/

231
packages/setting/main.ts

@ -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 }

6
packages/setting/main/event.ts

@ -0,0 +1,6 @@
import { buildEmitter } from "base/event/main"
import type { IOnFunc } from "setting/main"
export const emitter = buildEmitter<{
update: IOnFunc
}>()

7
packages/setting/package.json

@ -0,0 +1,7 @@
{
"name": "setting",
"version": "1.0.0",
"keywords": [],
"author": "",
"license": "ISC"
}

928
pnpm-lock.yaml

File diff suppressed because it is too large

9
resources/fuck.html

@ -1,11 +1,12 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
前往 <a href="https://baidu.com" target="_blank">百度</a>
前往
<a href="https://baidu.com" target="_blank">百度</a>
</body>
</html>

34
src/common/_ioc.main.ts

@ -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

5
src/common/event/PlatForm/hook.ts

@ -0,0 +1,5 @@
import { PlatForm } from "."
export function usePlatForm() {
return PlatForm.getInstance<PlatForm>()
}

58
src/common/event/PlatForm/index.ts

@ -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 }

121
src/common/event/PlatForm/main/command.ts

@ -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()
}
}

5
src/common/event/Snippet/hook.ts

@ -0,0 +1,5 @@
import { Snippet } from "."
export function useSnippet() {
return Snippet.getInstance()
}

18
src/common/event/Snippet/index.ts

@ -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 }

44
src/common/event/Snippet/main/command.ts

@ -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
}
}

54
src/common/event/Tabs/index.ts

@ -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
}
}

65
src/common/event/Tabs/main/command.ts

@ -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

15
src/common/event/Updater/index.ts

@ -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 }

15
src/common/event/Updater/main/command.ts

@ -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()
}
}

53
src/common/lib/abstract.ts

@ -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
}
}

29
src/common/lib/browser.ts

@ -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)
}
}

20
src/common/lib/electron.ts

@ -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)
}
}

7
src/common/readme.md

@ -0,0 +1,7 @@
## event
通用事件处理模块
- main/**/* 处理主进程的模块
- main/command.ts 会通过ioc收集,进入依赖管理中
- 其他 处理渲染进程的模块

21
src/main/App.ts

@ -3,14 +3,14 @@ import { inject, injectable } from "inversify"
// import DB from "./modules/db"
import Api from "./modules/api"
import WindowManager from "./modules/window-manager"
import { app, nativeTheme, protocol } from "electron"
import { app, protocol } from "electron"
import { electronApp } from "@electron-toolkit/utils"
import Command from "./modules/commands"
import BaseClass from "./base/base"
import IOC from "./_ioc"
import DB from "./modules/db"
import Zephyr from "./modules/zephyr"
import Updater from "./modules/updater"
import { crashHandler } from "logger/crash-handler"
protocol.registerSchemesAsPrivileged([
// {
@ -55,13 +55,17 @@ class App extends BaseClass {
@inject(DB) private _DB: DB,
@inject(WindowManager) private _WindowManager: WindowManager,
@inject(Zephyr) private _Zephyr: Zephyr,
@inject(Updater) private _Updater: Updater,
) {
super()
}
async init() {
this._Updater.init()
// 新开窗口的时候,会有个窗口闪烁的问题,也可以理解为渐入效果
// 主进程中添加如下代码即可
app.commandLine.appendSwitch("wm-window-animations-disabled")
// 开启硬件加速
app.disableHardwareAcceleration()
crashHandler.init()
this._DB.init()
this._Command.init()
this._WindowManager.init()
@ -70,15 +74,12 @@ class App extends BaseClass {
this._Zephyr.init()
electronApp.setAppUserModelId("top.xieyaxin")
this._WindowManager.showMainWindow()
const mainWindow = this._WindowManager.getMainWindow()
if (mainWindow) {
nativeTheme.themeSource = "light"
mainWindow.setTitleBarOverlay({
height: 29, // the smallest size of the title bar on windows accounting for the border on windows 11
this._Command.invoke("PlatFormCommand.setTheme", "light")
this._Command.invoke("PlatFormCommand.setTitlBar", {
height: 29,
color: "#F8F8F8",
symbolColor: "#000000",
})
}
})
app.on("will-quit", () => {
this.destroy()

2
src/main/_ioc.ts

@ -2,7 +2,7 @@ import IOC from "./_iocClass"
import { Container } from "inversify"
import iocModules, { destroyAllModules } from "./modules/_ioc"
import iocController, { destroyAllController } from "./controller/_ioc"
import iocCommand, { destroyAllCommand } from "./commands/_ioc"
import iocCommand, { destroyAllCommand } from "common/_ioc.main"
import App from "./App"
async function destroyAll() {

130
src/main/base/LinkedList.ts

@ -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
}
}
}

52
src/main/base/base.ts

@ -1,7 +1,55 @@
import EventEmitter from "node:events"
// import EventEmitter from "node:events"
// import { LinkedList } from "./LinkedList"
// abstract class BaseClass {
// constructor() {}
// public _events = new EventEmitter()
// private _event: Function | undefined
// private _listeners: LinkedList<any> | undefined
// abstract fire(event: string, ...args: any[])
// // 允许大家订阅此发射器的事件
// get event() {
// if (!this._event) {
// this._event = (listener, thisArgs?: any, disposables?) => {
// if (!this._listeners) {
// this._listeners = new LinkedList()
// }
// // 往队列中添加该 Listener,同时返回一个移除该 Listener 的方法
// const remove = this._listeners.push(!thisArgs ? listener : [listener, thisArgs])
// let result
// // 返回一个带 dispose 方法的结果,dispose 执行时会移除该 Listener
// result = {
// dispose: () => {
// result.dispose = Emitter._noop
// if (!this._disposed) {
// remove()
// }
// },
// }
// if (disposables instanceof DisposableStore) {
// disposables.add(result)
// } else if (Array.isArray(disposables)) {
// disposables.push(result)
// }
// return result
// }
// }
// return
// }
// dispose() {}
// abstract init(...argus: any[])
// abstract destroy()
// }
// export { BaseClass }
// export default BaseClass
abstract class BaseClass {
public _events = new EventEmitter()
abstract init(...argus: any[])
abstract destroy()
}

63
src/main/commands/BasicCommand.ts

@ -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()
}
}

66
src/main/commands/TabsCommand.ts

@ -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

15
src/main/commands/_ioc.ts

@ -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

22
src/main/controller/_ioc.ts

@ -1,12 +1,26 @@
import { Container, ContainerModule } from "inversify"
import BasicService from "./BasicService"
import TabsService from "./TabsService"
/**
*
*/
const serviceModules = import.meta.glob("./*.{ts,js}", { eager: true })
const modules = new ContainerModule(bind => {
bind("BasicService").to(BasicService).inSingletonScope()
bind("TabsService").to(TabsService).inSingletonScope()
// 自动绑定所有服务类
Object.values(serviceModules).forEach(module => {
// 由于 module 类型为 unknown,需要进行类型断言
const ServiceClass = (module as { default: any }).default
if (ServiceClass && ServiceClass.name.endsWith("Service")) {
const serviceName = ServiceClass.name
bind(serviceName).to(ServiceClass).inSingletonScope()
}
})
})
/**
*
* @param ioc - Inversify
*/
async function destroyAllController(ioc: Container) {
await ioc.unloadAsync(modules)
}

60
src/main/debug.ts

@ -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
}
})

6
src/main/event.ts

@ -0,0 +1,6 @@
import EventEmitter from "events"
const globalEvent = new EventEmitter()
export default globalEvent
export { globalEvent as eventbus }

84
src/main/index.ts

@ -1,85 +1,11 @@
import "reflect-metadata"
import "./debug"
import "setting/main"
import "logger/main"
import "logger/main-error"
import { _ioc } from "main/_ioc"
import { App } from "main/App"
import debug from "debug"
import fs from "fs"
import path from "path"
import * as rfs from "rotating-file-stream"
import { app } from "electron"
// 配置根目录
const logsPath = app.getPath("logs")
console.log(`日志地址:${logsPath}`)
const LOG_ROOT = path.join(logsPath)
// 缓存已创建的文件流(避免重复创建)
const streams = new Map()
// 转换命名空间为安全路径
function sanitizeNamespace(namespace) {
return namespace
.split(":") // 按层级分隔符拆分
.map(part => part.replace(/[\\/:*?"<>|]/g, "_")) // 替换非法字符
.join(path.sep) // 拼接为系统路径分隔符(如 / 或 \)
}
// 覆盖 debug.log 方法
const originalLog = debug.log
debug.log = function (...args) {
// 保留原始控制台输出(可选)
originalLog.apply(this, args)
// 获取当前命名空间
// @ts-ignore ...
const namespace = this.namespace
if (!namespace) {
// TODO 增加容错机制,如果没有命名空间就输出到一个默认文件中
return
}
// 生成日志文件路径(示例:logs/app/server.log)
const sanitizedPath = sanitizeNamespace(namespace)
// const logFilePath = path.join(LOG_ROOT, `${sanitizedPath}.log`)
const today = new Date().toISOString().split("T")[0]
const logFilePath = path.join(LOG_ROOT, sanitizedPath, `${today}.log`)
// 确保目录存在
const dir = path.dirname(logFilePath)
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true }) // 自动创建多级目录
}
// 获取或创建文件流
let stream = streams.get(logFilePath)
if (!stream) {
// stream = fs.createWriteStream(logFilePath, { flags: "a" }) // 追加模式
stream = rfs.createStream(path.parse(logFilePath).base, {
path: dir,
size: "10M", // 单个文件最大 10MB
rotate: 5, // 保留最近 5 个文件
})
streams.set(logFilePath, stream)
}
// 写入日志(添加时间戳)
const message = args.join(" ")
stream.write(`${message}\n`)
// const timestamp = new Date().toISOString()
// stream.write(`[${timestamp}] ${message}\n`)
}
const curApp = _ioc.get(App)
curApp.init()
const _debug = debug("app:app")
app.on("before-quit", () => {
_debug("应用关闭")
streams.forEach(stream => {
stream.end()
stream.destroy()
})
streams.clear()
})

6
src/main/modules/_ioc.ts

@ -1,17 +1,13 @@
import { Container, ContainerModule } from "inversify"
import { Setting } from "./setting"
import { DB } from "./db"
import { Api } from "./api"
import { WindowManager } from "./window-manager"
import { Tabs } from "./tabs"
import Commands from "./commands"
import Zephyr from "./zephyr"
import Updater from "./updater"
const modules = new ContainerModule(bind => {
bind(Setting).toConstantValue(new Setting())
bind(Zephyr).toSelf().inSingletonScope()
bind(Updater).toSelf().inSingletonScope()
bind(Api).toSelf().inSingletonScope()
bind(WindowManager).toSelf().inSingletonScope()
bind(Commands).toSelf().inSingletonScope()
@ -21,10 +17,8 @@ const modules = new ContainerModule(bind => {
async function destroyAllModules(ioc: Container) {
await Promise.all([
ioc.get(Setting).destroy(),
ioc.get(WindowManager).destroy(),
ioc.get(Commands).destroy(),
ioc.get(Updater).destroy(),
ioc.get(Zephyr).destroy(),
ioc.get(Tabs).destroy(),
ioc.get(Api).destroy(),

1
src/main/modules/api/readme.md

@ -1,4 +1,3 @@
## 资源
- https://juejin.cn/post/7311619723317657611#heading-6

1
src/main/modules/api/test.ts

@ -69,4 +69,3 @@
// };
// ses.protocol.interceptBufferProtocol("https", interceptHandler);

23
src/main/modules/commands/index.ts

@ -1,4 +1,4 @@
import { IMenuItemOption, IPopupMenuOption } from "#"
import { IMenuItemOption, IPopupMenuOption } from "#/popup-menu"
import { ipcMain, Menu, MenuItem } from "electron"
import { inject } from "inversify"
import IOC from "main/_ioc"
@ -18,14 +18,27 @@ export default class Commands extends BaseClass {
super()
}
init() {
ipcMain.addListener("command", async (event, key, command: string, ...argus) => {
// console.log(event.sender);
try {
private async handleCommand(command: string, ...argus) {
const splitClass = command.split(".")
const run = await this._IOC.getAsync<any>(splitClass[0])
if (run) {
const result: Promise<any> | any = run[splitClass[1]](...argus)
return [true, result]
}
return [false]
}
public async invoke(command, ...argus) {
const result = await this.handleCommand(command, ...argus)
return result
}
init() {
ipcMain.addListener("command", async (event, key, command: string, ...argus) => {
// console.log(event.sender);
try {
const [isExist, result] = await this.handleCommand(command, ...argus)
if (isExist) {
if (isPromise(result)) {
result
.then((res: any) => {

16
src/main/modules/db/index.ts

@ -1,20 +1,20 @@
import { inject, injectable } from "inversify"
import Setting from "../setting"
import { injectable } from "inversify"
import Setting from "setting/main"
import { CustomAdapter, CustomLow } from "./custom"
import path from "node:path"
import BaseClass from "main/base/base"
import _debug from "debug"
import _logger from "logger/main"
const debug = _debug("app:db")
const logger = _logger.createNamespace("db")
@injectable()
class DB extends BaseClass {
destroy() {
debug(`DB destroy`)
logger.debug(`DB destroy`)
}
Modules: Record<string, CustomLow<any>> = {}
constructor(@inject(Setting) private _setting: Setting) {
constructor() {
super()
}
@ -31,12 +31,12 @@ class DB extends BaseClass {
getDB(dbName: string) {
if (this.Modules[dbName] === undefined) {
const filepath = path.resolve(this._setting.values("storagePath"), "./db/" + dbName + ".json")
const filepath = path.resolve(Setting.values("storagePath"), "./db/" + dbName + ".json")
this.Modules[dbName] = this.create(filepath)
return this.Modules[dbName]
} else {
const cur = this.Modules[dbName]
const filepath = path.resolve(this._setting.values("storagePath"), "./db/" + dbName + ".json")
const filepath = path.resolve(Setting.values("storagePath"), "./db/" + dbName + ".json")
if (cur.filepath != filepath) {
this.Modules[dbName] = this.create(filepath)
}

235
src/main/modules/setting/index.ts

@ -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 }

10
src/main/modules/tabs/Tab.ts

@ -3,10 +3,11 @@ import { join } from "node:path"
import BaseClass from "main/base/base"
import _debug from "debug"
// import { Layout } from "./Constant"
import FuckHTML from "res/fuck.html?asset"
import FuckHTML from "@res/fuck.html?asset"
import { fileURLToPath, pathToFileURL } from "node:url"
import EventEmitter from "node:events"
const debug = _debug("app:tab")
const debug = _debug("tab")
interface IOption {
url: string
@ -24,6 +25,7 @@ class Tab extends BaseClass {
init() {
// TODO
}
public events = new EventEmitter()
public url: string = ""
public showUrl: string = ""
public title: string = ""
@ -55,10 +57,6 @@ class Tab extends BaseClass {
return this.active
}
get events() {
return this._events
}
constructor(options = {}, window: BrowserWindow, curRect?: IRect) {
super()
this.listenResize = this.listenResize.bind(this)

2
src/main/modules/tabs/index.ts

@ -11,7 +11,7 @@ interface IRect {
height: number
}
const debug = _debug("app:tabs")
const debug = _debug("tabs")
class Tabs extends BaseClass {
destroy() {

114
src/main/modules/updater/index.ts

@ -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

33
src/main/modules/window-manager/index.ts

@ -3,9 +3,9 @@ import { cloneDeep, merge } from "lodash"
import { defaultConfig, defaultWindowConfig, getWindowsMap, IConfig, Param } from "./windowsMap"
import { optimizer } from "@electron-toolkit/utils"
import BaseClass from "main/base/base"
import _debug from "debug"
import _logger from "logger/main"
const debug = _debug("app:window-manager")
const logger = _logger.createNamespace("modlue:window-manager") // _debug("app:window-manager")
declare module "electron" {
interface BrowserWindow {
@ -18,6 +18,9 @@ export { WindowManager }
export default class WindowManager extends BaseClass {
constructor() {
super()
this.isMainShowReady = new Promise(resolve => {
this.isMainShowResolve = resolve
})
}
destroy() {
@ -33,6 +36,7 @@ export default class WindowManager extends BaseClass {
dialog.showErrorBox("错误", "窗口未指定唯一key")
return
}
logger.debug("创建窗口的参数:", info)
const index = this.findIndex(info.name)
if (index === -1) {
this.#windows.push(this.#add(info))
@ -51,6 +55,23 @@ export default class WindowManager extends BaseClass {
showMainWindow() {
this.#showWin(this.mainInfo)
this.isMainShowResolve()
}
private isMainShowResolve
private isMainShowReady
async waitMainShowReady() {
await this.isMainShowReady
}
createWindow(name: string, opts?: Partial<IConfig>){
const info = opts as Param
info.name = name
if (!info.ignoreEmptyUrl && !info.url) {
dialog.showErrorBox("错误", name + "窗口未提供url")
return
}
this.#showWin(info as Param)
}
showWindow(name: string, opts?: Partial<IConfig>) {
@ -235,7 +256,7 @@ export default class WindowManager extends BaseClass {
if (curConfig.windowOpts?.show === false) {
if (curConfig.url) {
browserWin.once("ready-to-show", () => {
debug(`准备展示:`, curConfig.url)
logger.debug(`准备展示:`, curConfig.url)
browserWin?.show()
})
} else {
@ -246,7 +267,11 @@ export default class WindowManager extends BaseClass {
}
showCurrentWindow() {
debug(`current open window: ${this.#windows.map(v => v.$$opts!.name).join(",")}`)
if (this.#windows.length) {
logger.debug(`current open window: ${this.#windows.map(v => v.$$opts!.name).join(",")}`)
} else {
logger.debug(`all closed`)
}
}
#onClose(name: string) {

29
src/main/modules/window-manager/windowsMap.ts

@ -1,8 +1,7 @@
import config from "config"
import { BrowserWindowConstructorOptions } from "electron"
import { getFileUrl } from "main/utils"
import icon from "res/icon.png?asset"
import { join } from "path"
import { getFileUrl, getPreloadUrl } from "main/utils"
import icon from "@res/icon.png?asset"
export type Param = Partial<IConfig> & Required<Pick<IConfig, "name">>
@ -64,7 +63,7 @@ export function getWindowsMap(): Record<string, IConfig> {
...(process.platform === "linux" ? { icon } : {}),
webPreferences: {
webviewTag: false,
preload: join(__dirname, "../preload/index.mjs"),
preload: getPreloadUrl("index"),
nodeIntegration: true,
contextIsolation: true,
},
@ -104,27 +103,5 @@ export function getWindowsMap(): Record<string, IConfig> {
},
},
},
"^about": {
url: getFileUrl("about.html"),
overideWindowOpts: true,
confrimWindowClose: false,
type: "info",
windowOpts: {
width: 600,
height: 200,
minimizable: false,
darkTheme: true,
modal: true,
show: false,
resizable: false,
icon: icon,
webPreferences: {
devTools: false,
sandbox: false,
nodeIntegration: false,
contextIsolation: true,
},
},
},
}
}

476
src/main/modules/zephyr/index.ts

@ -1,49 +1,475 @@
import { session, net } from "electron"
import { injectable } from "inversify"
import BaseClass from "main/base/base"
import _debug from "debug"
import _logger from "logger/main"
import fs from "fs"
import path from "path"
import { app } from "electron"
const debug = _debug("app:zephyr")
const logger = _logger.createNamespace("zephyr")
/**
* Zephyr - 访
*
* 使
* 1. 访zephyr://<操作>/<文件路径>
*
* - r/ : 访read
* - w/ : 访write[]
* - rw/ : 访read-write[]
* - t/ : 访temp[]
*
* 2. 访
* - zephyr://r/D:/documents/test.txt
* - zephyr://r/app-data/config.json
* - zephyr://t/cache/temp.json
*
* 3.
* - .txt, .json, .md
* - ALLOWED_PATHS 访
* -
*
* 4.
* ```typescript
* zephyr.setAllowedPaths({
* // 只读路径
* read: [
* "D:/documents",
* app.getPath("documents")
* ],
* // 临时文件路径
* temp: [
* app.getPath("temp")
* ]
* });
* ```
*/
/**
*
*
zephyr.setAllowedPaths({
write: [
path.join(app.getPath("userData"), "data"),
"D:/allowed-write-path"
]
})
// 写入文件
fetch("zephyr://w/path/to/file.json", {
method: "POST",
body: JSON.stringify({ data: "test" })
})
// 写入文本
fetch("zephyr://w/path/to/file.txt", {
method: "POST",
body: "Hello World"
})
*/
@injectable()
class Zephyr extends BaseClass {
constructor(
// @inject(IOC) private _IOC: IOC
) {
// private readonly ALLOWED_PATHS: string[] = [] // 可以在这里定义允许访问的路径白名单
private readonly ALLOWED_EXTENSIONS: string[] = [".txt", ".json", ".md"] // 允许的文件类型
private readonly MAX_FILE_SIZE = 50 * 1024 * 1024 // 50MB
// private readonly SAFE_PATH_PATTERN = /^[a-zA-Z0-9\s\-_\/\\:\.]+$/
// 定义操作类型
private readonly OPERATIONS = {
READ: "r",
WRITE: "w",
READWRITE: "rw",
TEMP: "t",
} as const
private readonly pathConfig: {
read: string[]
temp: string[]
write: string[]
} = {
read: [],
temp: [],
write: [],
}
// 文件锁定相关
private readonly fileLocks = new Map<string, boolean>()
// 访问频率限制相关
private readonly rateLimiter = new Map<string, number>()
private readonly MAX_REQUESTS = 10 // 每个文件在时间窗口内的最大请求次数
private readonly WINDOW_MS = 60000 // 时间窗口:1分钟
// 审计日志相关
private readonly LOG_FILE = path.join(app.getPath("logs"), "zephyr-access.log")
constructor() {
super()
this.interceptHandlerZephyr = this.interceptHandlerZephyr.bind(this)
debug("zephyr init")
this.initLogFile()
logger.debug("zephyr init")
}
private async initLogFile() {
const logDir = path.dirname(this.LOG_FILE)
await fs.promises.mkdir(logDir, { recursive: true })
}
// 文件锁定机制
private async acquireFileLock(filePath: string): Promise<boolean> {
if (this.fileLocks.get(filePath)) {
return false
}
this.fileLocks.set(filePath, true)
return true
}
private releaseFileLock(filePath: string): void {
this.fileLocks.delete(filePath)
}
// 访问频率限制
private isRateLimited(filePath: string): boolean {
// const now = Date.now()
const count = this.rateLimiter.get(filePath) || 0
if (count >= this.MAX_REQUESTS) {
logger.debug("访问频率超限:", filePath)
return true
}
this.rateLimiter.set(filePath, count + 1)
setTimeout(() => {
const currentCount = this.rateLimiter.get(filePath)
if (currentCount && currentCount > 0) {
this.rateLimiter.set(filePath, currentCount - 1)
}
}, this.WINDOW_MS)
return false
}
// 审计日志
private async logAccess(operation: string, filePath: string, success: boolean, details?: string) {
const timestamp = new Date().toISOString()
const logEntry = {
timestamp,
operation,
filePath,
success,
details,
}
try {
await fs.promises.appendFile(this.LOG_FILE, JSON.stringify(logEntry) + "\n", "utf8")
} catch (error) {
logger.debug("写入审计日志失败:", error)
}
}
// 文件内容验证
private async validateFileContent(filePath: string): Promise<boolean> {
try {
const ext = path.extname(filePath).toLowerCase()
const content = await fs.promises.readFile(filePath, "utf8")
switch (ext) {
case ".json":
JSON.parse(content)
return true
case ".md":
// 可以添加 Markdown 验证逻辑
return content.length > 0
case ".txt":
// 文本文件验证
return content.length > 0
default:
return false
}
} catch {
return false
}
}
destroy() {
// TODO
const ses = session.defaultSession
ses.protocol.unhandle("zephyr")
this.fileLocks.clear()
this.rateLimiter.clear()
}
init(partition?: string) {
const ses = partition ? session.fromPartition(partition) : session.defaultSession
ses.protocol.handle("zephyr", this.interceptHandlerZephyr)
console.log(32423)
}
async interceptHandlerZephyr(request: Request) {
if (request.url.startsWith("zephyr://")) {
let curPath = request.url.replace(/^zephyr:\/\//, "")
let isPathRead = false
if (curPath.startsWith("$path/")) {
isPathRead = true
curPath = curPath.replace(/^\$path\//, "")
}
if (isPathRead) {
console.log("安全读取本地目录")
// 检查文件的安全性
const headers: HeadersInit = {}
headers["content-type"] = "text/txt"
return new Response(curPath, {
status: 200,
headers: Object.keys(headers).length ? headers : undefined,
logger.debug("zephyr initialized with partition:", partition)
}
setAllowedPaths(config: Partial<typeof this.pathConfig>) {
Object.assign(this.pathConfig, config)
logger.debug("Updated allowed paths:", this.pathConfig)
}
private isValidPath(filePath: string): boolean {
try {
// 规范化路径
const normalizedPath = path.normalize(filePath)
// Windows 路径特殊处理
const isWindowsPath = /^[a-z]:/i.test(normalizedPath)
// 检查基本字符(排除特殊字符)
// 允许驱动器冒号,但排除其他特殊字符
const basicCheck = isWindowsPath ? /^[a-z]:[^<>"|?*]+$/i.test(normalizedPath) : /^[^<>:"|?*]+$/i.test(normalizedPath)
return basicCheck && (isWindowsPath || normalizedPath.startsWith("/"))
} catch {
return false
}
}
private async isPathSafe(filePath: string, operation: string): Promise<boolean> {
try {
// 1. 基本路径检查
if (!this.isValidPath(filePath)) {
logger.debug("不安全的路径字符:", filePath)
return false
}
// 2. 检查是否包含 .. 路径
if (filePath.includes("..")) {
logger.debug("检测到路径遍历尝试")
return false
}
// 3. 检查符号链接
if (await this.isSymlink(filePath)) {
logger.debug("不允许访问符号链接")
return false
}
// 4. 检查文件大小
if (!(await this.checkFileSize(filePath))) {
logger.debug("文件超出大小限制")
return false
}
// 5. 文件类型检查
const ext = path.extname(filePath).toLowerCase()
if (!this.ALLOWED_EXTENSIONS.includes(ext)) {
logger.debug("不允许的文件类型:", ext)
return false
}
// 6. 权限检查
const allowedPaths = this.getPathsByOperation(operation)
if (!allowedPaths) return false
// 7. 确保路径在允许范围内
const isInAllowedPath = allowedPaths.some(allowedPath => {
const resolvedAllowed = path.resolve(allowedPath)
const resolvedTarget = path.resolve(filePath)
return resolvedTarget.startsWith(resolvedAllowed)
})
if (!isInAllowedPath) {
logger.debug("路径不在允许范围内")
return false
}
// 添加频率限制检查
if (this.isRateLimited(filePath)) {
await this.logAccess(operation, filePath, false, "访问频率超限")
return false
}
// 添加文件内容验证
if (!(await this.validateFileContent(filePath))) {
await this.logAccess(operation, filePath, false, "文件内容验证失败")
return false
}
await this.logAccess(operation, filePath, true)
return true
} catch (error: any) {
await this.logAccess(operation, filePath, false, error.message)
logger.debug("路径安全检查错误:", error)
return false
}
}
async interceptHandlerZephyr(request: Request): Promise<Response> {
try {
if (!request.url.startsWith("zephyr://")) {
return net.fetch(request.url, request)
}
const urlParts = request.url.replace(/^zephyr:\/\//, "").split("/")
const operation = urlParts[0]
const filePath = path.normalize(urlParts.slice(1).join("/"))
if (!operation || !filePath) {
return new Response("Invalid URL format", { status: 400 })
}
if (!(await this.isPathSafe(filePath, operation))) {
logger.debug("访问被拒绝:", filePath)
return new Response("Access Denied", { status: 403 })
}
// 处理不同的操作类型
switch (operation) {
case this.OPERATIONS.READ:
return await this.handleReadOperation(filePath)
case this.OPERATIONS.WRITE:
return await this.handleWriteOperation(filePath, request)
default:
return new Response("Operation not supported", { status: 400 })
}
} catch (error) {
logger.debug("处理请求错误:", error)
return new Response("Internal Server Error", { status: 500 })
}
}
private async handleReadOperation(filePath: string): Promise<Response> {
const cleanup = async (error?: Error): Promise<Response> => {
this.releaseFileLock(filePath)
if (error) {
await this.logAccess("READ", filePath, false, error.message)
return new Response("Internal Server Error", { status: 500 })
}
return new Response("OK", { status: 200 })
}
try {
if (!(await this.acquireFileLock(filePath))) {
await this.logAccess("READ", filePath, false, "文件已锁定")
return new Response("File is locked", { status: 423 })
}
const stream = fs.createReadStream(filePath, {
flags: "r",
encoding: "utf8",
})
const timeout = setTimeout(() => {
stream.destroy()
cleanup(new Error("读取超时"))
}, 5000)
const response = new Response(stream as any, {
status: 200,
headers: {
"content-type": this.getContentType(filePath),
"cache-control": "no-cache",
},
})
stream.on("error", error => {
clearTimeout(timeout)
cleanup(error)
})
stream.on("end", () => {
clearTimeout(timeout)
cleanup()
})
return response
} catch (error) {
return await cleanup(error as Error)
}
}
private getContentType(filePath: string): string {
const ext = path.extname(filePath).toLowerCase()
const contentTypes: Record<string, string> = {
".txt": "text/plain",
".json": "application/json",
".md": "text/markdown",
}
return contentTypes[ext] || "application/octet-stream"
}
private async isSymlink(filePath: string): Promise<boolean> {
try {
const stats = await fs.promises.lstat(filePath)
return stats.isSymbolicLink()
} catch {
return false
}
}
private async checkFileSize(filePath: string): Promise<boolean> {
try {
const stats = await fs.promises.stat(filePath)
return stats.size <= this.MAX_FILE_SIZE
} catch {
return false
}
}
private getPathsByOperation(operation: string): string[] | null {
switch (operation) {
case this.OPERATIONS.READ:
return this.pathConfig.read
case this.OPERATIONS.TEMP:
return this.pathConfig.temp
case this.OPERATIONS.WRITE:
return this.pathConfig.write
default:
logger.debug("未知的操作类型:", operation)
return null
}
}
private async handleWriteOperation(filePath: string, request: Request): Promise<Response> {
const cleanup = async (error?: Error): Promise<Response> => {
this.releaseFileLock(filePath)
if (error) {
await this.logAccess("WRITE", filePath, false, error.message)
return new Response("Write failed: " + error.message, { status: 500 })
}
return new Response("Write successful", { status: 200 })
}
try {
// 1. 获取文件锁
if (!(await this.acquireFileLock(filePath))) {
return new Response("File is locked", { status: 423 })
}
// 2. 确保目标目录存在
await fs.promises.mkdir(path.dirname(filePath), { recursive: true })
// 3. 获取请求内容
const content = await request.text()
// 4. 验证内容大小
if (content.length > this.MAX_FILE_SIZE) {
return cleanup(new Error("Content too large"))
}
// 5. 验证文件类型和内容
const ext = path.extname(filePath).toLowerCase()
if (ext === ".json") {
try {
JSON.parse(content)
} catch {
return cleanup(new Error("Invalid JSON content"))
}
}
// 6. 写入文件
await fs.promises.writeFile(filePath, content, "utf8")
await this.logAccess("WRITE", filePath, true)
return cleanup()
} catch (error) {
return cleanup(error as Error)
}
}
}
export default Zephyr

6
src/main/utils/index.ts

@ -12,11 +12,15 @@ export function getFileUrl(app: string) {
return slash(winURL)
}
export function getPreloadUrl(file) {
return join(__dirname, `../preload/${file}.mjs`)
}
export function isPromise(value: () => any) {
return value && Object.prototype.toString.call(value) === "[object Promise]"
}
export const broadcast = (event: string, ...args: any[]) => {
export const broadcast = <T extends string>(event: T, ...args: any[]) => {
webContents.getAllWebContents().forEach(browser => browser.send(event, ...args))
}

47
src/main/utils/session/cookies.ts

@ -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

82
src/main/utils/session/index.ts

@ -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
}

8
src/preload/index.d.ts

@ -1,8 +0,0 @@
import { ElectronAPI } from "@electron-toolkit/preload"
declare global {
interface Window {
electron: ElectronAPI
api: unknown
}
}

4
src/preload/index.ts

@ -1,7 +1,9 @@
import { contextBridge, ipcRenderer, IpcRendererEvent } from "electron"
import { electronAPI } from "@electron-toolkit/preload"
import { call, callLong, callSync } from "./call"
import { IPopupMenuOption } from "#"
import "logger/preload"
import "logger/preload-error"
import { IPopupMenuOption } from "#/popup-menu"
document.addEventListener("DOMContentLoaded", () => {
const initStyle = document.createElement("style")
initStyle.textContent = `

0
src/preload/plugin.ts

12
src/renderer/about.html

@ -1,5 +1,6 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
@ -13,16 +14,19 @@
padding: 0;
outline: none;
border: 0;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
padding: 10px 20px;
}
</style>
</head>
<body>
<article>
<h1 id="demo" style="text-align: center">您好,亲爱的冒险者!</h1>
<h1>环境</h1>
<ul>
<li>111</li>
</ul>
</article>
</body>
</html>

2
src/renderer/auto-imports.d.ts

@ -177,6 +177,7 @@ declare global {
const useFullscreen: typeof import('@vueuse/core')['useFullscreen']
const useGamepad: typeof import('@vueuse/core')['useGamepad']
const useGeolocation: typeof import('@vueuse/core')['useGeolocation']
const useI18n: typeof import('vue-i18n')['useI18n']
const useId: typeof import('vue')['useId']
const useIdle: typeof import('@vueuse/core')['useIdle']
const useImage: typeof import('@vueuse/core')['useImage']
@ -477,6 +478,7 @@ declare module 'vue' {
readonly useFullscreen: UnwrapRef<typeof import('@vueuse/core')['useFullscreen']>
readonly useGamepad: UnwrapRef<typeof import('@vueuse/core')['useGamepad']>
readonly useGeolocation: UnwrapRef<typeof import('@vueuse/core')['useGeolocation']>
readonly useI18n: UnwrapRef<typeof import('vue-i18n')['useI18n']>
readonly useId: UnwrapRef<typeof import('vue')['useId']>
readonly useIdle: UnwrapRef<typeof import('@vueuse/core')['useIdle']>
readonly useImage: UnwrapRef<typeof import('@vueuse/core')['useImage']>

16
src/renderer/components.d.ts

@ -9,7 +9,21 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
AdjustLine: typeof import('./src/components/AdjustLine.vue')['default']
NavBar: typeof import('./src/components/NavBar.vue')['default']
CodeEditor: typeof import('./src/components/CodeEditor/code-editor.vue')['default']
IconBiCollectionFill: typeof import('~icons/bi/collection-fill')['default']
IconBiTrashFill: typeof import('~icons/bi/trash-fill')['default']
IconFluentCollections24Regular: typeof import('~icons/fluent/collections24-regular')['default']
IconFluentCollectionsEmpty16Filled: typeof import('~icons/fluent/collections-empty16-filled')['default']
IconHugeiconsInbox: typeof import('~icons/hugeicons/inbox')['default']
IconIconParkSolidCollectComputer: typeof import('~icons/icon-park-solid/collect-computer')['default']
IconIconParkTwotoneDataAll: typeof import('~icons/icon-park-twotone/data-all')['default']
IconMdiLightInbox: typeof import('~icons/mdi-light/inbox')['default']
IconMynauiTrash: typeof import('~icons/mynaui/trash')['default']
IconSolarBoxBoldDuotone: typeof import('~icons/solar/box-bold-duotone')['default']
IconSolarHome2Outline: typeof import('~icons/solar/home2-outline')['default']
'IconStash:arrowReplyDuotone': typeof import('~icons/stash/arrow-reply-duotone')['default']
IconTdesignMapCollection: typeof import('~icons/tdesign/map-collection')['default']
NavBar: typeof import('./src/ui/NavBar.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
Versions: typeof import('./src/components/Versions.vue')['default']

9
src/renderer/index.html

@ -1,14 +1,16 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>Electron</title>
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self' api: 'unsafe-inline';
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self' api: 'unsafe-inline';
script-src 'self' api:;
style-src 'self' 'unsafe-inline';
img-src 'self' data: *;" />
img-src 'self' data: *;"
/>
</head>
<body>
@ -22,5 +24,4 @@
</noscript>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

31
src/renderer/src/App.vue

@ -1,9 +1,32 @@
<script setup lang="ts"></script>
<script setup lang="ts">
</script>
<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 }">
<transition name="slide">
<component :is="Component" :key="route" />
</transition>
<Transition name="slide-fade" mode="out-in">
<component :is="Component" :key="route.fullPath" />
</Transition>
</router-view>
</div>
</div>
</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>

3
src/renderer/src/assets/libs/scrollbot.ts

@ -205,8 +205,7 @@ class Scrollbot {
this.scrollBar.style.top = `${this.pC}%`
this.inP.scrollTop =
((parseFloat(this.scrollBar.style.top) -
((this.sbHeight - parseFloat(this.sB.height)) * this.inP.scrollTop) /
(this.inP.scrollHeight - this.inP.clientHeight)) *
((this.sbHeight - parseFloat(this.sB.height)) * this.inP.scrollTop) / (this.inP.scrollHeight - this.inP.clientHeight)) *
this.inP.scrollHeight) /
100
} else if (this.pC < 0 && parseFloat(this.scrollBar.style.top) > 0) {

1
src/renderer/src/assets/style/_common.scss

@ -10,6 +10,7 @@ html {
--text-normal: #6b6b6b;
--text-hover: #000000;
height: 100%;
color: var(--text-normal);
}
body {

18
src/renderer/src/bridge/PopupMenu.ts

@ -4,7 +4,7 @@
* @homepage: https://oldj.net
*/
import { IMenuItemOption } from "#"
import { IMenuItemOption } from "#/popup-menu"
import type { PopupOptions } from "electron"
let _idx: number = 0
@ -15,12 +15,17 @@ export class PopupMenu {
private _id: string
private _items: IMenuItemOption[]
private _offs: any[] = []
private clickEvent: Function = ()=>{}
constructor(menu_items: IMenuItemOption[]) {
this._id = `popup_menu_${Math.floor(Math.random() * 1e8)}`
this._items = menu_items
}
setClickEvent(fn: Function) {
this.clickEvent = fn
}
show(popupOptions?: PopupOptions) {
// console.log('show')
this.onHide()
@ -29,6 +34,17 @@ export class PopupMenu {
function readMenu(_items: IMenuItemOption[]) {
return _items.map(i => {
const d = { ...i }
if(!d.click) {
const r = Math.floor(Math.random() * 1e8)
const evt = `popup_menu_item_${_idx++}_${r}`
const off = api.once(evt, (...argus)=>{
console.log(1231);
that.clickEvent(i, ...argus)
})
that._offs.push(off)
d._click_evt = evt
delete d.click
}
if (typeof d.click === "function") {
const r = Math.floor(Math.random() * 1e8)
const evt = `popup_menu_item_${_idx++}_${r}`

2
src/renderer/src/components/AdjustLine.vue

@ -377,7 +377,7 @@ function handleError(error: Error, context: string) {
//
onErrorCaptured((err, instance, info) => {
handleError(err as Error, info)
console.log(instance);
console.log(instance)
return false
})
</script>

BIN
src/renderer/src/components/CodeEditor/120x120.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

57
src/renderer/src/components/CodeEditor/PlaceholderContentWidget.ts

@ -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)
}
}

306
src/renderer/src/components/CodeEditor/code-editor.vue

@ -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>

16
src/renderer/src/components/CodeEditor/monaco.ts

@ -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 }

3
src/renderer/src/components/CodeEditor/readme.md

@ -0,0 +1,3 @@
占位符
https://github.com/Microsoft/monaco-editor/issues/1228
https://github.com/microsoft/monaco-editor/issues/568#issuecomment-1499966160

32
src/renderer/src/components/CodeEditor/utils.ts

@ -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
}

91
src/renderer/src/components/NavBar.vue

@ -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
src/renderer/src/global.d.ts

@ -1 +0,0 @@
declare const api

26
src/renderer/src/i18n/index.ts

@ -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

17
src/renderer/src/layouts/default.vue

@ -1,10 +1,15 @@
<script lang="ts" setup></script>
<script lang="ts" setup>
import Simplebar from "simplebar-vue"
</script>
<template>
<div h-full flex flex-col>
<NavBar></NavBar>
<div flex-1 h-0 overflow="auto">
<Simplebar h-full>
<RouterView></RouterView>
</div>
</div>
</Simplebar>
</template>
<style scoped>
:deep(.simplebar-content) {
height: 100%;
}
</style>

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save