Compare commits
29 Commits
Author | SHA1 | Date |
---|---|---|
|
e969ec2236 | 2 days ago |
|
a9de1ec525 | 3 days ago |
|
fcad3681b4 | 4 weeks ago |
|
f19c097001 | 1 month ago |
|
81f76353f6 | 2 months ago |
|
068777d914 | 2 months ago |
|
34a762ad2c | 2 months ago |
|
23893adc43 | 2 months ago |
|
6692e16720 | 2 months ago |
|
de5f511d6a | 2 months ago |
|
d4be8b22b9 | 2 months ago |
|
c142937af9 | 2 months ago |
|
950bfe9060 | 2 months ago |
|
7035429775 | 2 months ago |
|
05f83e2a08 | 2 months ago |
|
fa6ef80493 | 2 months ago |
|
0f093b2ef9 | 2 months ago |
|
80cc4fe0fe | 2 months ago |
|
28eea56a3d | 2 months ago |
|
b6964f5fbe | 2 months ago |
|
7246ab2d9a | 2 months ago |
|
bd9ac214c6 | 2 months ago |
|
dcdc4aa857 | 2 months ago |
|
2d5a57853d | 2 months ago |
|
3c434df31c | 2 months ago |
|
b4b975174d | 3 months ago |
|
91f06eb4a1 | 3 months ago |
|
248716be69 | 3 months ago |
|
ca363ceac9 | 3 months ago |
141 changed files with 8859 additions and 3692 deletions
@ -1,34 +1,23 @@ |
|||||
|
const { readFileSync } = require("node:fs") |
||||
|
|
||||
/* eslint-env node */ |
/* eslint-env node */ |
||||
require("@rushstack/eslint-patch/modern-module-resolution") |
require("@rushstack/eslint-patch/modern-module-resolution") |
||||
|
|
||||
|
const prettierConfig = JSON.parse(readFileSync("./.prettierrc", { encoding: "utf-8" })) |
||||
|
|
||||
module.exports = { |
module.exports = { |
||||
extends: [ |
extends: [ |
||||
"eslint:recommended", |
"eslint:recommended", |
||||
"plugin:vue/vue3-recommended", |
"plugin:vue/vue3-recommended", |
||||
"@electron-toolkit", |
"@electron-toolkit", |
||||
"@electron-toolkit/eslint-config-ts/eslint-recommended", |
"@electron-toolkit/eslint-config-ts/eslint-recommended", |
||||
"@vue/eslint-config-typescript/recommended", |
"@vue/eslint-config-typescript/recommended", |
||||
"@vue/eslint-config-prettier", |
"@vue/eslint-config-prettier", |
||||
], |
], |
||||
rules: { |
rules: { |
||||
"vue/require-default-prop": "off", |
"vue/require-default-prop": "off", |
||||
"vue/multi-word-component-names": "off", |
"vue/multi-word-component-names": "off", |
||||
"@typescript-eslint/no-explicit-any": "off", |
"@typescript-eslint/no-explicit-any": "off", |
||||
"prettier/prettier": [ |
"prettier/prettier": ["error", prettierConfig], |
||||
"error", |
}, |
||||
{ |
|
||||
tabWidth: 4, |
|
||||
useTabs: false, |
|
||||
semi: false, |
|
||||
singleQuote: false, |
|
||||
trailingComma: "all", |
|
||||
bracketSpacing: true, |
|
||||
arrowParens: "avoid", |
|
||||
printWidth: 140, |
|
||||
htmlWhitespaceSensitivity: "ignore", |
|
||||
proseWrap: "preserve", |
|
||||
endOfLine: "auto", |
|
||||
}, |
|
||||
], |
|
||||
}, |
|
||||
} |
} |
||||
|
@ -1,13 +1,32 @@ |
|||||
{ |
{ |
||||
"tabWidth": 4, |
"tabWidth": 2, |
||||
"useTabs": false, |
"useTabs": false, |
||||
"semi": false, |
"semi": false, |
||||
"singleQuote": false, |
"singleQuote": false, |
||||
"trailingComma": "all", |
"trailingComma": "all", |
||||
"bracketSpacing": true, |
"bracketSpacing": true, |
||||
"arrowParens": "avoid", |
"arrowParens": "avoid", |
||||
"printWidth": 140, |
"printWidth": 140, |
||||
"htmlWhitespaceSensitivity": "ignore", |
"htmlWhitespaceSensitivity": "ignore", |
||||
"proseWrap": "preserve", |
"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 |
||||
|
} |
||||
|
} |
||||
|
] |
||||
} |
} |
||||
|
@ -1,3 +1,3 @@ |
|||||
{ |
{ |
||||
"recommendations": ["dbaeumer.vscode-eslint"] |
"recommendations": ["dbaeumer.vscode-eslint", "lokalise.i18n-ally"] |
||||
} |
} |
||||
|
@ -1,39 +1,39 @@ |
|||||
{ |
{ |
||||
"version": "0.2.0", |
"version": "0.2.0", |
||||
"configurations": [ |
"configurations": [ |
||||
{ |
{ |
||||
"name": "Debug Main Process", |
"name": "Debug Main Process", |
||||
"type": "node", |
"type": "node", |
||||
"request": "launch", |
"request": "launch", |
||||
"cwd": "${workspaceRoot}", |
"cwd": "${workspaceRoot}", |
||||
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite", |
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite", |
||||
"windows": { |
"windows": { |
||||
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite.cmd" |
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite.cmd" |
||||
}, |
}, |
||||
"runtimeArgs": ["--sourcemap"], |
"runtimeArgs": ["--sourcemap"], |
||||
"env": { |
"env": { |
||||
"REMOTE_DEBUGGING_PORT": "9222" |
"REMOTE_DEBUGGING_PORT": "9222" |
||||
} |
} |
||||
}, |
}, |
||||
{ |
{ |
||||
"name": "Debug Renderer Process", |
"name": "Debug Renderer Process", |
||||
"port": 9222, |
"port": 9222, |
||||
"request": "attach", |
"request": "attach", |
||||
"type": "chrome", |
"type": "chrome", |
||||
"webRoot": "${workspaceFolder}/src/renderer", |
"webRoot": "${workspaceFolder}/src/renderer", |
||||
"timeout": 60000, |
"timeout": 60000, |
||||
"presentation": { |
"presentation": { |
||||
"hidden": true |
"hidden": true |
||||
} |
} |
||||
} |
} |
||||
], |
], |
||||
"compounds": [ |
"compounds": [ |
||||
{ |
{ |
||||
"name": "Debug All", |
"name": "Debug All", |
||||
"configurations": ["Debug Main Process", "Debug Renderer Process"], |
"configurations": ["Debug Main Process", "Debug Renderer Process"], |
||||
"presentation": { |
"presentation": { |
||||
"order": 1 |
"order": 1 |
||||
} |
} |
||||
} |
} |
||||
] |
] |
||||
} |
} |
||||
|
@ -1,11 +1,25 @@ |
|||||
{ |
{ |
||||
"[typescript]": { |
"[typescript]": { |
||||
"editor.defaultFormatter": "esbenp.prettier-vscode" |
"editor.defaultFormatter": "esbenp.prettier-vscode" |
||||
}, |
}, |
||||
"[javascript]": { |
"[javascript]": { |
||||
"editor.defaultFormatter": "esbenp.prettier-vscode" |
"editor.defaultFormatter": "esbenp.prettier-vscode" |
||||
}, |
}, |
||||
"[json]": { |
"[json]": { |
||||
"editor.defaultFormatter": "esbenp.prettier-vscode" |
"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 |
||||
} |
} |
||||
|
@ -1,35 +1,52 @@ |
|||||
|
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.hoturl": string |
||||
|
"update.repo"?: string |
||||
|
"update.owner"?: string |
||||
|
"update.allowDowngrade": boolean |
||||
|
"update.allowPrerelease": boolean |
||||
|
"editor.bg": string |
||||
|
"editor.logoType": LogoType |
||||
|
"editor.fontFamily": string |
||||
|
"snippet.storagePath": string |
||||
|
storagePath: string |
||||
|
} |
||||
|
|
||||
interface IConfig { |
interface IConfig { |
||||
app_title: string |
app_title: string |
||||
default_config: { |
default_config: IDefaultConfig |
||||
language: "zh" | "en" // i18n
|
|
||||
"common.theme": "light" | "dark" | "auto" // 主题
|
|
||||
"desktop:wallpaper": 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 // 存储地址
|
|
||||
} |
|
||||
} |
} |
||||
|
|
||||
|
// 默认配置导出
|
||||
export default { |
export default { |
||||
app_title: "zephyr", // 和风
|
app_title: "zephyr", // 和风
|
||||
default_config: { |
default_config: { |
||||
storagePath: "$storagePath$", |
storagePath: "$storagePath$", |
||||
language: "zh", |
language: "zh", |
||||
"common.theme": "auto", |
debug: LogLevel.INFO, |
||||
"desktop:wallpaper": "", |
"common.theme": "auto", |
||||
"editor.bg": "", |
"desktop:wallpaper": "", |
||||
"editor.logoType": "logo", |
"editor.bg": "", |
||||
"editor.fontFamily": "Cascadia Mono, Consolas, 'Courier New', monospace", |
"editor.logoType": "logo", |
||||
"update.repo": "wood-desktop", |
"editor.fontFamily": "Cascadia Mono, Consolas, 'Courier New', monospace", |
||||
"update.owner": "npmrun", |
"update.hoturl": |
||||
"update.allowDowngrade": false, |
"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.allowPrerelease": false, |
"update.repo": "wood-desktop", |
||||
}, |
"update.owner": "npmrun", |
||||
} as IConfig |
"update.allowDowngrade": false, |
||||
|
"update.allowPrerelease": false, |
||||
|
"snippet.storagePath": "$storagePath$/snippets", |
||||
|
}, |
||||
|
} as const satisfies IConfig |
||||
|
@ -1,45 +1,45 @@ |
|||||
appId: com.zephyr.app |
appId: com.zephyr.app |
||||
productName: zephyr |
productName: zephyr |
||||
directories: |
directories: |
||||
buildResources: build |
buildResources: build |
||||
files: |
files: |
||||
- "!**/.vscode/*" |
- "!**/.vscode/*" |
||||
- "!src/*" |
- "!src/*" |
||||
- "!electron.vite.config.{js,ts,mjs,cjs}" |
- "!electron.vite.config.{js,ts,mjs,cjs}" |
||||
- "!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}" |
- "!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}" |
||||
- "!{.env,.env.*,.npmrc,pnpm-lock.yaml}" |
- "!{.env,.env.*,.npmrc,pnpm-lock.yaml}" |
||||
- "!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}" |
- "!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}" |
||||
asarUnpack: |
asarUnpack: |
||||
- resources/** |
- resources/** |
||||
win: |
win: |
||||
executableName: zephyr |
executableName: zephyr |
||||
nsis: |
nsis: |
||||
artifactName: ${name}-${version}-setup.${ext} |
artifactName: ${name}-${version}-setup.${ext} |
||||
shortcutName: ${productName} |
shortcutName: ${productName} |
||||
uninstallDisplayName: ${productName} |
uninstallDisplayName: ${productName} |
||||
createDesktopShortcut: always |
createDesktopShortcut: always |
||||
mac: |
mac: |
||||
entitlementsInherit: build/entitlements.mac.plist |
entitlementsInherit: build/entitlements.mac.plist |
||||
extendInfo: |
extendInfo: |
||||
- NSCameraUsageDescription: Application requests access to the device's camera. |
- NSCameraUsageDescription: Application requests access to the device's camera. |
||||
- NSMicrophoneUsageDescription: Application requests access to the device's microphone. |
- NSMicrophoneUsageDescription: Application requests access to the device's microphone. |
||||
- NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder. |
- NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder. |
||||
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder. |
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder. |
||||
notarize: false |
notarize: false |
||||
dmg: |
dmg: |
||||
artifactName: ${name}-${version}.${ext} |
artifactName: ${name}-${version}.${ext} |
||||
linux: |
linux: |
||||
target: |
target: |
||||
- AppImage |
- AppImage |
||||
- snap |
- snap |
||||
- deb |
- deb |
||||
maintainer: electronjs.org |
maintainer: electronjs.org |
||||
category: Utility |
category: Utility |
||||
appImage: |
appImage: |
||||
artifactName: ${name}-${version}.${ext} |
artifactName: ${name}-${version}.${ext} |
||||
npmRebuild: false |
npmRebuild: false |
||||
publish: |
publish: |
||||
provider: generic |
provider: generic |
||||
url: https://example.com/auto-updates |
url: https://example.com/auto-updates |
||||
electronDownload: |
electronDownload: |
||||
mirror: https://npmmirror.com/mirrors/electron/ |
mirror: https://npmmirror.com/mirrors/electron/ |
||||
|
@ -1,71 +1,89 @@ |
|||||
{ |
{ |
||||
"name": "zephyr", |
"name": "zephyr", |
||||
"type": "module", |
"type": "module", |
||||
"private": true, |
"private": true, |
||||
"version": "1.0.0", |
"version": "0.0.1", |
||||
"description": "An Electron application with Vue and TypeScript", |
"description": "An Electron application with Vue and TypeScript", |
||||
"main": "./out/main/index.js", |
"main": "./out/main/index.js", |
||||
"author": "example.com", |
"author": "example.com", |
||||
"homepage": "https://electron-vite.org", |
"homepage": "https://electron-vite.org", |
||||
"scripts": { |
"scripts": { |
||||
"runInstall": "node node_modules/electron/install.js", |
"runInstall": "node node_modules/electron/install.js", |
||||
"format": "prettier --write .", |
"format": "prettier --write .", |
||||
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts,.vue --fix", |
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts,.vue --fix", |
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", |
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", |
||||
"typecheck:web": "vue-tsc --noEmit -p tsconfig.web.json --composite false", |
"typecheck:web": "vue-tsc --noEmit -p tsconfig.web.json --composite false", |
||||
"typecheck": "npm run typecheck:node && npm run typecheck:web", |
"typecheck": "npm run typecheck:node && npm run typecheck:web", |
||||
"start": "electron-vite preview", |
"start": "electron-vite preview", |
||||
"dev": "chcp 65001 && set DEBUG=app:*&& electron-vite dev", |
"dev": "chcp 65001 && set DEBUG=app:*&& electron-vite dev", |
||||
"dev:watch": "chcp 65001 & set DEBUG=app:*& electron-vite dev --watch", |
"dev:watch": "chcp 65001 & set DEBUG=app:*& electron-vite dev --watch", |
||||
"build": "npm run typecheck && electron-vite build", |
"build": "npm run typecheck && electron-vite build", |
||||
"postinstall": "electron-builder install-app-deps", |
"postinstall": "electron-builder install-app-deps", |
||||
"build:unpack": "npm run build && electron-builder --dir", |
"build:unpack": "npm run build && electron-builder --dir", |
||||
"build:win": "npm run build && electron-builder --win", |
"build:win": "npm run build && electron-builder --win", |
||||
"build:mac": "npm run build && electron-builder --mac", |
"build:mac": "npm run build && electron-builder --mac", |
||||
"build:linux": "npm run build && electron-builder --linux" |
"build:linux": "npm run build && electron-builder --linux" |
||||
}, |
}, |
||||
"dependencies": { |
"dependencies": { |
||||
"@electron-toolkit/preload": "^3.0.1", |
"@electron-toolkit/preload": "^3.0.1", |
||||
"@electron-toolkit/utils": "^3.0.0", |
"@electron-toolkit/utils": "^3.0.0", |
||||
"@types/debug": "^4.1.12", |
"electron-updater": "^6.3.9", |
||||
"@unocss/reset": "^0.64.1", |
"fs-extra": "^11.3.0", |
||||
"@vueuse/core": "^12.7.0", |
"inversify": "^6.2.2", |
||||
"electron-updater": "^6.3.9", |
"lowdb": "^7.0.1", |
||||
"inversify": "^6.2.2", |
"reflect-metadata": "^0.2.2" |
||||
"lowdb": "^7.0.1", |
}, |
||||
"reflect-metadata": "^0.2.2", |
"devDependencies": { |
||||
"sass": "^1.85.0", |
"@electron-toolkit/eslint-config": "^1.0.2", |
||||
"unplugin-auto-import": "^19.1.0", |
"@electron-toolkit/eslint-config-ts": "^2.0.0", |
||||
"unplugin-vue-components": "^28.4.0", |
"@electron-toolkit/tsconfig": "^1.0.1", |
||||
"unplugin-vue-macros": "^2.14.2", |
"@iconify/json": "^2.2.324", |
||||
"unplugin-vue-router": "^0.11.2", |
"@intlify/unplugin-vue-i18n": "^6.0.3", |
||||
"vue-router": "^4.5.0" |
"@rushstack/eslint-patch": "^1.10.5", |
||||
}, |
"@types/debug": "^4.1.12", |
||||
"devDependencies": { |
"@types/node": "^20.17.19", |
||||
"@electron-toolkit/eslint-config": "^1.0.2", |
"@types/nprogress": "^0.2.3", |
||||
"@electron-toolkit/eslint-config-ts": "^2.0.0", |
"@unocss/preset-rem-to-px": "^0.64.1", |
||||
"@electron-toolkit/tsconfig": "^1.0.1", |
"@unocss/reset": "^0.64.1", |
||||
"@rushstack/eslint-patch": "^1.10.5", |
"@vitejs/plugin-vue": "^5.2.1", |
||||
"@types/node": "^20.17.19", |
"@vitejs/plugin-vue-jsx": "^4.1.1", |
||||
"@unocss/preset-rem-to-px": "^0.64.1", |
"@vue/eslint-config-prettier": "^9.0.0", |
||||
"@vitejs/plugin-vue": "^5.2.1", |
"@vue/eslint-config-typescript": "^13.0.0", |
||||
"@vitejs/plugin-vue-jsx": "^4.1.1", |
"@vueuse/core": "^12.7.0", |
||||
"@vue/eslint-config-prettier": "^9.0.0", |
"base": "workspace:*", |
||||
"@vue/eslint-config-typescript": "^13.0.0", |
"debug": "^4.4.0", |
||||
"debug": "^4.4.0", |
"electron": "^31.7.7", |
||||
"electron": "^31.7.7", |
"electron-builder": "^24.13.3", |
||||
"electron-builder": "^24.13.3", |
"electron-vite": "^2.3.0", |
||||
"electron-vite": "^2.3.0", |
"eslint": "^8.57.1", |
||||
"eslint": "^8.57.1", |
"eslint-plugin-vue": "^9.32.0", |
||||
"eslint-plugin-vue": "^9.32.0", |
"extract-zip": "^2.0.1", |
||||
"prettier": "^3.5.1", |
"helper": "workspace:*", |
||||
"rotating-file-stream": "^3.2.6", |
"locales": "workspace:*", |
||||
"simplebar-vue": "^2.4.0", |
"lodash-es": "^4.17.21", |
||||
"typescript": "^5.7.3", |
"logger": "workspace:^", |
||||
"unocss": "^0.64.1", |
"monaco-editor": "^0.52.2", |
||||
"vite": "^5.4.14", |
"nprogress": "^0.2.0", |
||||
"vite-plugin-vue-layouts": "^0.11.0", |
"pinia": "^3.0.2", |
||||
"vue": "^3.5.13", |
"pinia-plugin-persistedstate": "^4.2.0", |
||||
"vue-tsc": "^2.1.10" |
"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" |
||||
|
} |
||||
} |
} |
||||
|
@ -0,0 +1,123 @@ |
|||||
|
type FireFN = (...argu: any[]) => void |
||||
|
|
||||
|
// 监听器类型定义,支持优先级
|
||||
|
interface Listener<F extends FireFN> { |
||||
|
fn: F |
||||
|
once: boolean |
||||
|
priority: number |
||||
|
} |
||||
|
|
||||
|
class FireEvent<T extends Record<string | symbol, FireFN>> { |
||||
|
// 使用 Map 存储事件监听器,支持 symbol 键
|
||||
|
private events = new Map<keyof T, Array<Listener<T[keyof T]>>>() |
||||
|
|
||||
|
// 获取事件监听器列表,如果不存在则创建
|
||||
|
private getListeners<S extends keyof T>(name: S): Array<Listener<T[S]>> { |
||||
|
if (!this.events.has(name)) { |
||||
|
this.events.set(name, []) |
||||
|
} |
||||
|
return this.events.get(name) as Array<Listener<T[S]>> |
||||
|
} |
||||
|
|
||||
|
// 按优先级排序监听器
|
||||
|
private sortListeners<S extends keyof T>(name: S) { |
||||
|
const listeners = this.getListeners(name) |
||||
|
listeners.sort((a, b) => b.priority - a.priority) |
||||
|
} |
||||
|
|
||||
|
// 打印事件和监听器信息
|
||||
|
print() { |
||||
|
console.log("Registered Events:") |
||||
|
this.events.forEach((listeners, name) => { |
||||
|
// 显式处理 symbol 类型
|
||||
|
const keyType = typeof name === "symbol" ? `Symbol(${name.description || ""})` : String(name) |
||||
|
console.log(` ${keyType}: ${listeners.length} listeners`) |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
// 添加事件监听器,支持优先级
|
||||
|
on<S extends keyof T>(name: S, fn: T[S], priority = 0): this { |
||||
|
const listeners = this.getListeners(name) |
||||
|
listeners.push({ fn, once: false, priority }) |
||||
|
this.sortListeners(name) |
||||
|
return this // 支持链式调用
|
||||
|
} |
||||
|
|
||||
|
// 触发事件
|
||||
|
emit<S extends keyof T>(name: S, ...args: Parameters<T[S]>): this { |
||||
|
const listeners = this.getListeners(name).slice() // 创建副本以避免移除时的问题
|
||||
|
|
||||
|
for (const { fn } of listeners) { |
||||
|
try { |
||||
|
fn(...args) |
||||
|
} catch (error) { |
||||
|
console.error(`Error in event handler for ${String(name)}:`, error) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 移除一次性监听器
|
||||
|
if (listeners.some(l => l.once)) { |
||||
|
this.events.set( |
||||
|
name, |
||||
|
this.getListeners(name).filter(l => !l.once), |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
return this |
||||
|
} |
||||
|
|
||||
|
// 移除事件监听器
|
||||
|
off<S extends keyof T>(name: S, fn?: T[S]): this { |
||||
|
if (!this.events.has(name)) return this |
||||
|
|
||||
|
const listeners = this.getListeners(name) |
||||
|
|
||||
|
if (!fn) { |
||||
|
// 移除所有监听器
|
||||
|
this.events.delete(name) |
||||
|
} else { |
||||
|
// 移除特定监听器
|
||||
|
const filtered = listeners.filter(l => l.fn !== fn) |
||||
|
if (filtered.length === 0) { |
||||
|
this.events.delete(name) |
||||
|
} else { |
||||
|
this.events.set(name, filtered) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return this |
||||
|
} |
||||
|
|
||||
|
// 添加一次性事件监听器
|
||||
|
once<S extends keyof T>(name: S, fn: T[S], priority = 0): this { |
||||
|
const listeners = this.getListeners(name) |
||||
|
listeners.push({ fn, once: true, priority }) |
||||
|
this.sortListeners(name) |
||||
|
return this |
||||
|
} |
||||
|
|
||||
|
// 清除所有事件监听器
|
||||
|
clear(): this { |
||||
|
this.events.clear() |
||||
|
return this |
||||
|
} |
||||
|
|
||||
|
// 获取指定事件的监听器数量
|
||||
|
listenerCount<S extends keyof T>(name: S): number { |
||||
|
return this.events.get(name)?.length || 0 |
||||
|
} |
||||
|
|
||||
|
// 检查事件是否有监听器
|
||||
|
hasListeners<S extends keyof T>(name: S): boolean { |
||||
|
return this.listenerCount(name) > 0 |
||||
|
} |
||||
|
|
||||
|
// 获取所有事件名称
|
||||
|
eventNames(): Array<keyof T> { |
||||
|
return Array.from(this.events.keys()) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export function buildEmitter<T extends Record<string | symbol, FireFN>>() { |
||||
|
return new FireEvent<T>() |
||||
|
} |
@ -0,0 +1,51 @@ |
|||||
|
// type FireKey = string
|
||||
|
type FireFN = (...argu: any[]) => void |
||||
|
|
||||
|
class FireEvent<T extends Record<string | symbol, FireFN>> { |
||||
|
#events: Record<keyof T, FireFN[]> = {} as any |
||||
|
print() { |
||||
|
Object.keys(this.#events).forEach(key => { |
||||
|
console.log(`${key}: ${this.#events[key]}\n`) |
||||
|
}) |
||||
|
} |
||||
|
on<S extends keyof T>(name: S, fn: T[S]) { |
||||
|
if (!this.#events[name]) { |
||||
|
this.#events[name] = [] |
||||
|
} |
||||
|
this.#events[name].push(fn) |
||||
|
} |
||||
|
emit<S extends keyof T>(name: S, ...argu: Parameters<T[S]>) { |
||||
|
if (this.#events[name]) { |
||||
|
this.#events[name].forEach(fn => { |
||||
|
fn(...argu) |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
off<S extends keyof T>(name: S, fn?: T[S]) { |
||||
|
const len = this.#events[name].length |
||||
|
if (!len) { |
||||
|
return |
||||
|
} |
||||
|
if (!fn) { |
||||
|
this.#events[name] = [] |
||||
|
} else { |
||||
|
for (let i = len - 1; i >= 0; i--) { |
||||
|
const _fn = this.#events[name][i] |
||||
|
if (_fn === fn) { |
||||
|
this.#events[name].splice(i, 1) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
once<S extends keyof T>(name: S, fn: T[S]) { |
||||
|
const _fn: any = (...argu: any[]) => { |
||||
|
fn(...argu) |
||||
|
this.off<S>(name, _fn) |
||||
|
} |
||||
|
this.on(name, _fn) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export function buildEmitter<T extends Record<string | symbol, FireFN>>() { |
||||
|
return new FireEvent<T>() |
||||
|
} |
@ -0,0 +1,27 @@ |
|||||
|
// 抽象基类,使用泛型来正确推导子类类型
|
||||
|
abstract class BaseSingleton { |
||||
|
private static _instance: any |
||||
|
|
||||
|
public constructor() { |
||||
|
if (this.constructor === BaseSingleton) { |
||||
|
throw new Error("禁止直接实例化 BaseOne 抽象类") |
||||
|
} |
||||
|
|
||||
|
if ((this.constructor as any)._instance) { |
||||
|
throw new Error("构造函数私有化失败,禁止重复 new") |
||||
|
} |
||||
|
|
||||
|
// this.constructor 是子类,所以这里设为 instance
|
||||
|
;(this.constructor as any)._instance = this |
||||
|
} |
||||
|
|
||||
|
public static getInstance<T extends BaseSingleton>(this: new () => T): T { |
||||
|
const clazz = this as any as typeof BaseSingleton |
||||
|
if (!clazz._instance) { |
||||
|
clazz._instance = new this() |
||||
|
} |
||||
|
return clazz._instance as T |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export { BaseSingleton } |
@ -0,0 +1,13 @@ |
|||||
|
{ |
||||
|
"name": "base", |
||||
|
"version": "1.0.0", |
||||
|
"description": "", |
||||
|
"main": "index.js", |
||||
|
"scripts": { |
||||
|
"test": "echo \"Error: no test specified\" && exit 1" |
||||
|
}, |
||||
|
"keywords": [], |
||||
|
"author": "", |
||||
|
"license": "ISC", |
||||
|
"packageManager": "pnpm@10.4.1" |
||||
|
} |
@ -0,0 +1,13 @@ |
|||||
|
{ |
||||
|
"name": "helper", |
||||
|
"version": "1.0.0", |
||||
|
"description": "", |
||||
|
"main": "index.js", |
||||
|
"scripts": { |
||||
|
"test": "echo \"Error: no test specified\" && exit 1" |
||||
|
}, |
||||
|
"keywords": [], |
||||
|
"author": "", |
||||
|
"license": "ISC", |
||||
|
"packageManager": "pnpm@10.4.1" |
||||
|
} |
@ -0,0 +1,7 @@ |
|||||
|
export const enum EventEnum { |
||||
|
UPDATE_PROGRESS = "update-progress", |
||||
|
} |
||||
|
|
||||
|
export type EventMaps = { |
||||
|
[EventEnum.UPDATE_PROGRESS]: () => void |
||||
|
} |
@ -0,0 +1,8 @@ |
|||||
|
import { broadcast } from "main/utils" |
||||
|
import { EventEnum } from "../common" |
||||
|
|
||||
|
export { EventEnum } |
||||
|
|
||||
|
export function emit(key: EventEnum, ...args: any[]) { |
||||
|
broadcast(key, ...args) |
||||
|
} |
@ -0,0 +1,74 @@ |
|||||
|
type DownloadPercent = { |
||||
|
url: string |
||||
|
option?: object |
||||
|
onprocess?: (now: number, all: number) => void |
||||
|
onsuccess?: (data: any) => void |
||||
|
onerror?: (res: Response) => void |
||||
|
} |
||||
|
const RequestPercent = async ({ |
||||
|
url = "", |
||||
|
option = { |
||||
|
headers: { |
||||
|
responseType: "arraybuffer", |
||||
|
}, |
||||
|
}, |
||||
|
onsuccess, |
||||
|
onerror, |
||||
|
onprocess, |
||||
|
}: DownloadPercent) => { |
||||
|
const response = (await fetch(url, option)) as any |
||||
|
if (!response.ok) { |
||||
|
onerror?.(response) |
||||
|
throw new Error(`下载失败`) |
||||
|
} |
||||
|
const reader = response?.body.getReader() |
||||
|
|
||||
|
// 文件总长度
|
||||
|
const contentLength = +response.headers.get("content-length") |
||||
|
|
||||
|
let receivedLength = 0 |
||||
|
const chunks: any[] = [] |
||||
|
// eslint-disable-next-line no-constant-condition
|
||||
|
while (true) { |
||||
|
const { done, value } = await reader.read() |
||||
|
|
||||
|
if (done) { |
||||
|
break |
||||
|
} |
||||
|
|
||||
|
chunks.push(value) |
||||
|
receivedLength += value.length |
||||
|
onprocess?.(receivedLength, contentLength) |
||||
|
} |
||||
|
// 这里的chunksAll 已经是ArrayBuffer的数据类型了,可以直接返回,也可以转为blob处理
|
||||
|
const chunksAll = new Uint8Array(receivedLength) |
||||
|
let position = 0 |
||||
|
for (const chunk of chunks) { |
||||
|
chunksAll.set(chunk, position) |
||||
|
position += chunk.length |
||||
|
} |
||||
|
|
||||
|
onsuccess?.(chunksAll) |
||||
|
|
||||
|
return chunksAll |
||||
|
} |
||||
|
|
||||
|
export { RequestPercent } |
||||
|
export default RequestPercent |
||||
|
|
||||
|
// RequestPercent({
|
||||
|
// url: "http://117.21.250.136:9812/ZxqyGateway/biz/file/downApk/%E6%98%93%E4%BC%81%E6%95%B0%E8%BD%AC%E5%B9%B3%E5%8F%B0app-1.2.7.apk",
|
||||
|
// option: {
|
||||
|
// headers: {
|
||||
|
// responseType: "arraybuffer",
|
||||
|
// },
|
||||
|
// },
|
||||
|
// onerror: () => {},
|
||||
|
// onsuccess: data => {
|
||||
|
// fs.writeFileSync("./aaa.apk", Buffer.from(data))
|
||||
|
// console.log("success", data)
|
||||
|
// },
|
||||
|
// onprocess: (receivedLength, contentLength) => {
|
||||
|
// console.log(receivedLength, contentLength)
|
||||
|
// },
|
||||
|
// })
|
@ -0,0 +1,118 @@ |
|||||
|
import { spawn } from "node:child_process" |
||||
|
import fs from "node:fs" |
||||
|
import path from "node:path" |
||||
|
import os from "node:os" |
||||
|
import { app } from "electron" |
||||
|
import download from "./download" |
||||
|
import extract from "extract-zip" |
||||
|
|
||||
|
import _logger from "logger/main" |
||||
|
import { emit, EventEnum } from "../handler" |
||||
|
|
||||
|
const logger = _logger.createNamespace("hot-updater") |
||||
|
|
||||
|
function getUpdateScriptTemplate() { |
||||
|
return process.platform === "win32" |
||||
|
? ` |
||||
|
@echo off |
||||
|
timeout /t 2 |
||||
|
taskkill /IM "{{EXE_NAME}}" /F |
||||
|
xcopy /Y /E "{{UPDATE_DIR}}\\*" "{{APP_PATH}}" |
||||
|
start "" "{{EXE_PATH}}" |
||||
|
` |
||||
|
: ` |
||||
|
#!/bin/bash |
||||
|
sleep 2 |
||||
|
pkill -f "{{EXE_NAME}}" |
||||
|
cp -Rf "{{UPDATE_DIR}}/*" "{{APP_PATH}}/" |
||||
|
open "{{EXE_PATH}}" |
||||
|
` |
||||
|
} |
||||
|
|
||||
|
function generateUpdateScript() { |
||||
|
const scriptContent = getUpdateScriptTemplate() |
||||
|
.replace(/{{APP_PATH}}/g, process.platform === "win32" ? "%APP_PATH%" : "$APP_PATH") |
||||
|
.replace(/{{UPDATE_DIR}}/g, process.platform === "win32" ? "%UPDATE_DIR%" : "$UPDATE_DIR") |
||||
|
.replace(/{{EXE_PATH}}/g, process.platform === "win32" ? "%EXE_PATH%" : "$EXE_PATH") |
||||
|
.replace(/{{EXE_NAME}}/g, process.platform === "win32" ? "%EXE_NAME%" : "$EXE_NAME") |
||||
|
|
||||
|
const scriptPath = path.join(os.tmpdir(), `update.${process.platform === "win32" ? "bat" : "sh"}`) |
||||
|
fs.writeFileSync(scriptPath, scriptContent) |
||||
|
return scriptPath |
||||
|
} |
||||
|
// 标记是否需要热更新
|
||||
|
let shouldPerformHotUpdate = false |
||||
|
let isReadyUpdate = false |
||||
|
// 更新临时目录路径
|
||||
|
// 使用应用名称和随机字符串创建唯一的临时目录
|
||||
|
const updateTempDirPath = path.join(os.tmpdir(), `${app.getName()}-update-${Math.random().toString(36).substring(2, 15)}`) |
||||
|
app.once("will-quit", event => { |
||||
|
if (!shouldPerformHotUpdate) return |
||||
|
event.preventDefault() |
||||
|
const appPath = app.getAppPath() |
||||
|
const appExePath = process.execPath |
||||
|
const exeName = path.basename(appExePath) |
||||
|
// 生成动态脚本
|
||||
|
const scriptPath = generateUpdateScript() |
||||
|
|
||||
|
fs.chmodSync(scriptPath, 0o755) |
||||
|
|
||||
|
// 执行脚本
|
||||
|
const child = spawn(scriptPath, [], { |
||||
|
detached: true, |
||||
|
shell: true, |
||||
|
env: { |
||||
|
APP_PATH: appPath, |
||||
|
UPDATE_DIR: updateTempDirPath, |
||||
|
EXE_PATH: appExePath, |
||||
|
EXE_NAME: exeName, |
||||
|
}, |
||||
|
}) |
||||
|
child.unref() |
||||
|
app.exit() |
||||
|
}) |
||||
|
|
||||
|
// 下载热更新包
|
||||
|
export async function fetchHotUpdatePackage(updatePackageUrl: string) { |
||||
|
if (isReadyUpdate) return |
||||
|
|
||||
|
// 清除临时目录
|
||||
|
clearUpdateTempDir() |
||||
|
// 创建临时目录
|
||||
|
if (!fs.existsSync(updateTempDirPath)) { |
||||
|
fs.mkdirSync(updateTempDirPath, { recursive: true }) |
||||
|
} |
||||
|
|
||||
|
// 下载文件的本地保存路径
|
||||
|
const downloadPath = path.join(updateTempDirPath, "update.zip") |
||||
|
|
||||
|
try { |
||||
|
// 使用 fetch 下载更新包
|
||||
|
const arrayBuffer = await download({ |
||||
|
url: updatePackageUrl, |
||||
|
onprocess(now, all) { |
||||
|
logger.debug(`下载进度: ${((now / all) * 100).toFixed(2)}%`) |
||||
|
emit(EventEnum.UPDATE_PROGRESS, { percent: (now / all) * 100, now, all }) |
||||
|
}, |
||||
|
}) |
||||
|
fs.writeFileSync(downloadPath, Buffer.from(arrayBuffer)) |
||||
|
// 解压更新包
|
||||
|
await extract(downloadPath, { dir: updateTempDirPath }) |
||||
|
|
||||
|
// 删除下载的zip文件
|
||||
|
fs.unlinkSync(downloadPath) |
||||
|
isReadyUpdate = true |
||||
|
} catch (error) { |
||||
|
logger.debug("热更新包下载失败:", error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function clearUpdateTempDir() { |
||||
|
if (!fs.existsSync(updateTempDirPath)) return |
||||
|
fs.rmSync(updateTempDirPath, { recursive: true }) |
||||
|
} |
||||
|
|
||||
|
export function flagNeedUpdate() { |
||||
|
shouldPerformHotUpdate = true |
||||
|
} |
@ -0,0 +1,137 @@ |
|||||
|
import pkg from "electron-updater" |
||||
|
import { app, dialog } from "electron" |
||||
|
import Setting from "setting/main" |
||||
|
import EventEmitter from "events" |
||||
|
import { BaseSingleton } from "base" |
||||
|
import { fetchHotUpdatePackage, flagNeedUpdate } from "./hot" |
||||
|
import Locales from "locales/main" |
||||
|
import _logger from "logger/main" |
||||
|
|
||||
|
const logger = _logger.createNamespace("updater") |
||||
|
const { autoUpdater } = pkg |
||||
|
|
||||
|
class _Updater extends BaseSingleton { |
||||
|
public events = new EventEmitter() |
||||
|
private timer: ReturnType<typeof setInterval> | null = null |
||||
|
// autoReplace = false
|
||||
|
async triggerHotUpdate(autoReplace = false) { |
||||
|
const url = Setting.values("update.hoturl") |
||||
|
await fetchHotUpdatePackage(url) |
||||
|
flagNeedUpdate() |
||||
|
if (!autoReplace) { |
||||
|
dialog.showMessageBox({ |
||||
|
title: Locales.t("update.ready.hot.title"), |
||||
|
message: Locales.t("update.ready.hot.desc", { version: app.getVersion() }), |
||||
|
}) |
||||
|
} else { |
||||
|
app.quit() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
constructor() { |
||||
|
super() |
||||
|
// 配置自动更新
|
||||
|
autoUpdater.autoDownload = false |
||||
|
autoUpdater.autoInstallOnAppQuit = true |
||||
|
|
||||
|
// 检查更新错误
|
||||
|
autoUpdater.on("error", error => { |
||||
|
logger.debug("Update error:", error) |
||||
|
}) |
||||
|
|
||||
|
// 检查更新
|
||||
|
autoUpdater.on("checking-for-update", () => { |
||||
|
logger.debug("Checking for updates...") |
||||
|
}) |
||||
|
|
||||
|
// 有可用更新
|
||||
|
autoUpdater.on("update-available", info => { |
||||
|
logger.debug("Update available:", info) |
||||
|
this.promptUserToUpdate() |
||||
|
}) |
||||
|
|
||||
|
// 没有可用更新
|
||||
|
autoUpdater.on("update-not-available", info => { |
||||
|
logger.debug("Update not available:", info) |
||||
|
}) |
||||
|
|
||||
|
// 更新下载进度
|
||||
|
autoUpdater.on("download-progress", progressObj => { |
||||
|
logger.debug( |
||||
|
`Download speed: ${progressObj.bytesPerSecond} - Downloaded ${progressObj.percent}% (${progressObj.transferred}/${progressObj.total})`, |
||||
|
) |
||||
|
}) |
||||
|
|
||||
|
// 更新下载完成
|
||||
|
autoUpdater.on("update-downloaded", info => { |
||||
|
logger.debug("Update downloaded:", info) |
||||
|
this.promptUserToInstall() |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
init() { |
||||
|
// 定期检查更新
|
||||
|
this.checkForUpdates() |
||||
|
this.timer && clearInterval(this.timer) |
||||
|
this.timer = setInterval( |
||||
|
() => { |
||||
|
this.checkForUpdates() |
||||
|
}, |
||||
|
1000 * 60 * 60, |
||||
|
) // 每小时检查一次
|
||||
|
} |
||||
|
|
||||
|
destroy() { |
||||
|
// 清理工作
|
||||
|
if (this.timer) { |
||||
|
clearInterval(this.timer) |
||||
|
this.timer = null |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private async checkForUpdates() { |
||||
|
if (app.isPackaged) { |
||||
|
try { |
||||
|
await autoUpdater.checkForUpdates() |
||||
|
logger.debug("Updater初始化检查成功.") |
||||
|
} catch (error) { |
||||
|
logger.debug("Failed to check for updates:", error) |
||||
|
} |
||||
|
} else { |
||||
|
logger.debug("正在开发模式,跳过更新检查.") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private async promptUserToUpdate() { |
||||
|
const result = await dialog.showMessageBox({ |
||||
|
type: "info", |
||||
|
title: "发现新版本", |
||||
|
message: "是否下载新版本?", |
||||
|
buttons: ["下载", "暂不更新"], |
||||
|
defaultId: 0, |
||||
|
}) |
||||
|
|
||||
|
if (result.response === 0) { |
||||
|
autoUpdater.downloadUpdate() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private async promptUserToInstall() { |
||||
|
const result = await dialog.showMessageBox({ |
||||
|
type: "info", |
||||
|
title: "更新已就绪", |
||||
|
message: "新版本已下载完成,是否立即安装?", |
||||
|
buttons: ["立即安装", "稍后安装"], |
||||
|
defaultId: 0, |
||||
|
}) |
||||
|
|
||||
|
if (result.response === 0) { |
||||
|
autoUpdater.quitAndInstall(false, true) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const Updater = _Updater.getInstance() |
||||
|
|
||||
|
export { Updater } |
||||
|
export default Updater |
@ -0,0 +1,41 @@ |
|||||
|
if (import.meta.env.DEV) { |
||||
|
// 引入之后可以热更新
|
||||
|
import("./languages/zh.json") |
||||
|
import("./languages/en.json") |
||||
|
} |
||||
|
|
||||
|
const datetimeFormats = { |
||||
|
en: { |
||||
|
short: { |
||||
|
year: "numeric", |
||||
|
month: "short", |
||||
|
day: "numeric", |
||||
|
}, |
||||
|
long: { |
||||
|
year: "numeric", |
||||
|
month: "short", |
||||
|
day: "numeric", |
||||
|
weekday: "short", |
||||
|
hour: "numeric", |
||||
|
minute: "numeric", |
||||
|
}, |
||||
|
}, |
||||
|
zh: { |
||||
|
short: { |
||||
|
year: "numeric", |
||||
|
month: "short", |
||||
|
day: "numeric", |
||||
|
}, |
||||
|
long: { |
||||
|
year: "numeric", |
||||
|
month: "short", |
||||
|
day: "numeric", |
||||
|
weekday: "short", |
||||
|
hour: "numeric", |
||||
|
minute: "numeric", |
||||
|
hour12: true, |
||||
|
}, |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
export { datetimeFormats } |
@ -0,0 +1,20 @@ |
|||||
|
{ |
||||
|
"update": { |
||||
|
"ready": { |
||||
|
"hot": { |
||||
|
"desc": "The new version v{version} is ready for update and will be automatically updated the next time you launch the program.", |
||||
|
"title": "Prompt" |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
"browser": { |
||||
|
"navbar": { |
||||
|
"menu": { |
||||
|
"fullscreen": "Full screen", |
||||
|
"quit-fullscreen": "Exit Full Screen", |
||||
|
"toggleDevTools": "Developer panel", |
||||
|
"label": "Menu" |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
@ -0,0 +1,20 @@ |
|||||
|
{ |
||||
|
"update": { |
||||
|
"ready": { |
||||
|
"hot": { |
||||
|
"title": "提示", |
||||
|
"desc": "新版本 v{version} 已经准备好更新, 下次启动程序即可自动更新" |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
"browser": { |
||||
|
"navbar": { |
||||
|
"menu": { |
||||
|
"label": "菜单", |
||||
|
"fullscreen": "全屏", |
||||
|
"quit-fullscreen": "取消全屏", |
||||
|
"toggleDevTools": "开发者面板" |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
@ -0,0 +1,53 @@ |
|||||
|
import { app } from "electron" |
||||
|
import { get } from "lodash-es" |
||||
|
|
||||
|
import zh from "./languages/zh.json" |
||||
|
import en from "./languages/en.json" |
||||
|
|
||||
|
type FlattenObject<T, Prefix extends string = ""> = T extends object |
||||
|
? { |
||||
|
[K in keyof T & (string | number)]: FlattenObject<T[K], Prefix extends "" ? `${K}` : `${Prefix}.${K}`> |
||||
|
}[keyof T & (string | number)] |
||||
|
: Prefix |
||||
|
|
||||
|
type FlattenKeys<T> = FlattenObject<T> |
||||
|
|
||||
|
type TranslationKey = FlattenKeys<typeof zh> |
||||
|
|
||||
|
class Locale { |
||||
|
locale: string = "zh" |
||||
|
|
||||
|
constructor() { |
||||
|
try { |
||||
|
this.locale = app.getLocale() |
||||
|
} catch (e) { |
||||
|
console.log(e) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
isCN(): boolean { |
||||
|
return this.locale.startsWith("zh") |
||||
|
} |
||||
|
|
||||
|
t(key: TranslationKey, replacements?: Record<string, string>): string { |
||||
|
let text: string = this.isCN() ? get(zh, key) : get(en, key) |
||||
|
if (!text) { |
||||
|
text = get(zh, key) |
||||
|
if (!text) { |
||||
|
return key |
||||
|
} |
||||
|
} |
||||
|
if (replacements) { |
||||
|
// 替换所有形如 {key} 的占位符
|
||||
|
Object.entries(replacements).forEach(([key, value]) => { |
||||
|
console.log(text) |
||||
|
text = text.replace(new RegExp(`{${key}}`, "g"), value) |
||||
|
}) |
||||
|
} |
||||
|
return text |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const Locales = new Locale() |
||||
|
export default Locales |
||||
|
export { Locales } |
@ -0,0 +1,7 @@ |
|||||
|
{ |
||||
|
"name": "locales", |
||||
|
"version": "1.0.0", |
||||
|
"keywords": [], |
||||
|
"author": "", |
||||
|
"license": "ISC" |
||||
|
} |
@ -0,0 +1,31 @@ |
|||||
|
// 日志级别定义
|
||||
|
export enum LogLevel { |
||||
|
TRACE = 0, |
||||
|
DEBUG = 1, |
||||
|
INFO = 2, |
||||
|
WARN = 3, |
||||
|
ERROR = 4, |
||||
|
FATAL = 5, |
||||
|
OFF = 6, |
||||
|
} |
||||
|
// 日志级别名称映射
|
||||
|
export const LogLevelName: Record<LogLevel, string> = { |
||||
|
[LogLevel.TRACE]: "TRACE", |
||||
|
[LogLevel.DEBUG]: "DEBUG", |
||||
|
[LogLevel.INFO]: "INFO", |
||||
|
[LogLevel.WARN]: "WARN", |
||||
|
[LogLevel.ERROR]: "ERROR", |
||||
|
[LogLevel.FATAL]: "FATAL", |
||||
|
[LogLevel.OFF]: "OFF", |
||||
|
} |
||||
|
|
||||
|
// 日志颜色映射(控制台输出用)
|
||||
|
export const LogLevelColor: Record<LogLevel, string> = { |
||||
|
[LogLevel.TRACE]: "\x1b[90m", // 灰色
|
||||
|
[LogLevel.DEBUG]: "\x1b[36m", // 青色
|
||||
|
[LogLevel.INFO]: "\x1b[32m", // 绿色
|
||||
|
[LogLevel.WARN]: "\x1b[33m", // 黄色
|
||||
|
[LogLevel.ERROR]: "\x1b[31m", // 红色
|
||||
|
[LogLevel.FATAL]: "\x1b[35m", // 紫色
|
||||
|
[LogLevel.OFF]: "", // 无色
|
||||
|
} |
@ -0,0 +1,486 @@ |
|||||
|
import { app, dialog } from "electron" |
||||
|
import fs from "fs" |
||||
|
import path from "path" |
||||
|
import os from "os" |
||||
|
import logger from "./main" |
||||
|
import errorHandler, { ErrorDetail } from "./main-error" |
||||
|
|
||||
|
/** |
||||
|
* 崩溃报告接口 |
||||
|
*/ |
||||
|
export interface CrashReport { |
||||
|
timestamp: string |
||||
|
error: ErrorDetail |
||||
|
systemInfo: { |
||||
|
platform: string |
||||
|
release: string |
||||
|
arch: string |
||||
|
totalMemory: number |
||||
|
freeMemory: number |
||||
|
uptime: number |
||||
|
} |
||||
|
appInfo: { |
||||
|
version: string |
||||
|
name: string |
||||
|
path: string |
||||
|
argv: string[] |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 崩溃处理配置 |
||||
|
*/ |
||||
|
export interface CrashHandlerOptions { |
||||
|
crashReportDir?: string |
||||
|
maxReports?: number |
||||
|
showDialog?: boolean |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 默认崩溃处理配置 |
||||
|
*/ |
||||
|
const DEFAULT_OPTIONS: CrashHandlerOptions = { |
||||
|
maxReports: 10, |
||||
|
showDialog: true, |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 崩溃处理类 |
||||
|
*/ |
||||
|
export class CrashHandler { |
||||
|
private static instance: CrashHandler |
||||
|
private options: CrashHandlerOptions |
||||
|
private crashReportDir: string |
||||
|
private initialized: boolean = false |
||||
|
private startTime: number = Date.now() |
||||
|
// private normalShutdown: boolean = false
|
||||
|
|
||||
|
/** |
||||
|
* 获取单例实例 |
||||
|
*/ |
||||
|
public static getInstance(): CrashHandler { |
||||
|
if (!CrashHandler.instance) { |
||||
|
CrashHandler.instance = new CrashHandler() |
||||
|
} |
||||
|
return CrashHandler.instance |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 构造函数 |
||||
|
*/ |
||||
|
private constructor() { |
||||
|
this.options = { ...DEFAULT_OPTIONS } |
||||
|
this.crashReportDir = path.join(app.getPath("userData"), "crash-reports") |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 初始化崩溃处理器 |
||||
|
*/ |
||||
|
public init(options?: CrashHandlerOptions): void { |
||||
|
if (this.initialized) { |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
this.options = { ...this.options, ...options } |
||||
|
|
||||
|
if (options?.crashReportDir) { |
||||
|
this.crashReportDir = options.crashReportDir |
||||
|
} |
||||
|
|
||||
|
// 确保崩溃报告目录存在
|
||||
|
if (!fs.existsSync(this.crashReportDir)) { |
||||
|
fs.mkdirSync(this.crashReportDir, { recursive: true }) |
||||
|
} |
||||
|
|
||||
|
// 检查上次是否崩溃
|
||||
|
this.checkPreviousCrash() |
||||
|
|
||||
|
// 记录应用启动时间
|
||||
|
this.startTime = Date.now() |
||||
|
this.saveStartupMarker() |
||||
|
|
||||
|
// 设置全局未捕获异常处理器
|
||||
|
this.setupGlobalHandlers() |
||||
|
|
||||
|
// 设置应用退出处理
|
||||
|
app.on("before-quit", () => { |
||||
|
// this.normalShutdown = true
|
||||
|
this.clearStartupMarker() |
||||
|
}) |
||||
|
|
||||
|
this.initialized = true |
||||
|
logger.info("crash-handler", "Crash handler initialized") |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 设置全局未捕获异常处理器 |
||||
|
*/ |
||||
|
private setupGlobalHandlers(): void { |
||||
|
// 增强现有的错误处理器
|
||||
|
const originalCaptureError = errorHandler.captureError.bind(errorHandler) |
||||
|
errorHandler.captureError = (error: any, componentInfo?: string, additionalInfo?: Record<string, any>) => { |
||||
|
// 调用原始方法记录错误
|
||||
|
originalCaptureError(error, componentInfo, additionalInfo) |
||||
|
|
||||
|
// 对于严重错误,生成崩溃报告
|
||||
|
if (error instanceof Error && error.stack) { |
||||
|
this.generateCrashReport(error, componentInfo, additionalInfo) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 捕获未处理的Promise异常
|
||||
|
process.on("unhandledRejection", (reason, promise) => { |
||||
|
logger.error("crash-handler", `Unhandled Promise Rejection: ${reason}`) |
||||
|
this.generateCrashReport(reason, "unhandledRejection", { promise: String(promise) }) |
||||
|
}) |
||||
|
|
||||
|
// 捕获未捕获的异常
|
||||
|
process.on("uncaughtException", error => { |
||||
|
logger.error("crash-handler", `Uncaught Exception: ${error.message}`) |
||||
|
this.generateCrashReport(error, "uncaughtException") |
||||
|
|
||||
|
// 显示错误对话框
|
||||
|
if (this.options.showDialog) { |
||||
|
this.showCrashDialog(error) |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 生成崩溃报告 |
||||
|
*/ |
||||
|
private generateCrashReport(error: any, componentInfo?: string, additionalInfo?: Record<string, any>): void { |
||||
|
try { |
||||
|
// 格式化错误信息
|
||||
|
const errorDetail = this.formatError(error, componentInfo, additionalInfo) |
||||
|
|
||||
|
// 创建崩溃报告
|
||||
|
const crashReport: CrashReport = { |
||||
|
timestamp: new Date().toISOString(), |
||||
|
error: errorDetail, |
||||
|
systemInfo: { |
||||
|
platform: os.platform(), |
||||
|
release: os.release(), |
||||
|
arch: os.arch(), |
||||
|
totalMemory: os.totalmem(), |
||||
|
freeMemory: os.freemem(), |
||||
|
uptime: os.uptime(), |
||||
|
}, |
||||
|
appInfo: { |
||||
|
version: app.getVersion(), |
||||
|
name: app.getName(), |
||||
|
path: app.getAppPath(), |
||||
|
argv: process.argv, |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
// 保存崩溃报告
|
||||
|
this.saveCrashReport(crashReport) |
||||
|
} catch (e) { |
||||
|
logger.error("crash-handler", `Failed to generate crash report: ${e}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 格式化错误信息 |
||||
|
*/ |
||||
|
private formatError(error: any, componentInfo?: string, additionalInfo?: Record<string, any>): ErrorDetail { |
||||
|
// 基本错误信息
|
||||
|
const errorDetail: ErrorDetail = { |
||||
|
message: "", |
||||
|
timestamp: new Date().toISOString(), |
||||
|
type: "Unknown", |
||||
|
} |
||||
|
|
||||
|
// 处理不同类型的错误
|
||||
|
if (error instanceof Error) { |
||||
|
errorDetail.message = error.message |
||||
|
errorDetail.type = error.name || error.constructor.name |
||||
|
errorDetail.stack = error.stack |
||||
|
} else if (typeof error === "string") { |
||||
|
errorDetail.message = error |
||||
|
errorDetail.type = "String" |
||||
|
} else if (error === null) { |
||||
|
errorDetail.message = "Null error received" |
||||
|
errorDetail.type = "Null" |
||||
|
} else if (error === undefined) { |
||||
|
errorDetail.message = "Undefined error received" |
||||
|
errorDetail.type = "Undefined" |
||||
|
} else if (typeof error === "object") { |
||||
|
try { |
||||
|
errorDetail.message = error.message || JSON.stringify(error) |
||||
|
errorDetail.type = "Object" |
||||
|
errorDetail.additionalInfo = { ...error } |
||||
|
} catch (e) { |
||||
|
errorDetail.message = "Unserializable error object" |
||||
|
errorDetail.type = "Unserializable" |
||||
|
} |
||||
|
} else { |
||||
|
try { |
||||
|
errorDetail.message = String(error) |
||||
|
errorDetail.type = typeof error |
||||
|
} catch (e) { |
||||
|
errorDetail.message = "Error converting to string" |
||||
|
errorDetail.type = "Unknown" |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 添加组件信息
|
||||
|
if (componentInfo) { |
||||
|
errorDetail.componentInfo = componentInfo |
||||
|
} |
||||
|
|
||||
|
// 添加额外信息
|
||||
|
if (additionalInfo) { |
||||
|
errorDetail.additionalInfo = { |
||||
|
...errorDetail.additionalInfo, |
||||
|
...additionalInfo, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return errorDetail |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 保存崩溃报告 |
||||
|
*/ |
||||
|
private saveCrashReport(report: CrashReport): void { |
||||
|
try { |
||||
|
// 生成唯一的崩溃报告文件名
|
||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-") |
||||
|
const filename = `crash-${timestamp}.json` |
||||
|
const filepath = path.join(this.crashReportDir, filename) |
||||
|
|
||||
|
// 写入崩溃报告
|
||||
|
fs.writeFileSync(filepath, JSON.stringify(report, null, 2)) |
||||
|
logger.info("crash-handler", `Crash report saved: ${filepath}`) |
||||
|
|
||||
|
// 清理旧的崩溃报告
|
||||
|
this.cleanupOldReports() |
||||
|
} catch (e) { |
||||
|
logger.error("crash-handler", `Failed to save crash report: ${e}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 清理旧的崩溃报告 |
||||
|
*/ |
||||
|
private cleanupOldReports(): void { |
||||
|
try { |
||||
|
// 获取所有崩溃报告文件
|
||||
|
const files = fs |
||||
|
.readdirSync(this.crashReportDir) |
||||
|
.filter(file => file.startsWith("crash-") && file.endsWith(".json")) |
||||
|
.map(file => ({ |
||||
|
name: file, |
||||
|
path: path.join(this.crashReportDir, file), |
||||
|
time: fs.statSync(path.join(this.crashReportDir, file)).mtime.getTime(), |
||||
|
})) |
||||
|
.sort((a, b) => b.time - a.time) // 按时间降序排序
|
||||
|
|
||||
|
// 删除超出最大数量的旧报告
|
||||
|
if (files.length > this.options.maxReports!) { |
||||
|
const filesToDelete = files.slice(this.options.maxReports!) |
||||
|
filesToDelete.forEach(file => { |
||||
|
fs.unlinkSync(file.path) |
||||
|
logger.debug("crash-handler", `Deleted old crash report: ${file.name}`) |
||||
|
}) |
||||
|
} |
||||
|
} catch (e) { |
||||
|
logger.error("crash-handler", `Failed to cleanup old reports: ${e}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 保存启动标记 |
||||
|
*/ |
||||
|
private saveStartupMarker(): void { |
||||
|
try { |
||||
|
const markerPath = path.join(this.crashReportDir, "startup-marker.json") |
||||
|
const marker = { |
||||
|
startTime: this.startTime, |
||||
|
pid: process.pid, |
||||
|
} |
||||
|
fs.writeFileSync(markerPath, JSON.stringify(marker)) |
||||
|
} catch (e) { |
||||
|
logger.error("crash-handler", `Failed to save startup marker: ${e}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 清除启动标记 |
||||
|
*/ |
||||
|
private clearStartupMarker(): void { |
||||
|
try { |
||||
|
const markerPath = path.join(this.crashReportDir, "startup-marker.json") |
||||
|
if (fs.existsSync(markerPath)) { |
||||
|
fs.unlinkSync(markerPath) |
||||
|
} |
||||
|
} catch (e) { |
||||
|
logger.error("crash-handler", `Failed to clear startup marker: ${e}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 检查上次是否崩溃 |
||||
|
*/ |
||||
|
private checkPreviousCrash(): boolean { |
||||
|
try { |
||||
|
const markerPath = path.join(this.crashReportDir, "startup-marker.json") |
||||
|
// 如果存在启动标记,说明上次可能崩溃了
|
||||
|
if (fs.existsSync(markerPath)) { |
||||
|
const markerData = JSON.parse(fs.readFileSync(markerPath, "utf8")) |
||||
|
const lastStartTime = markerData.startTime |
||||
|
const lastPid = markerData.pid |
||||
|
|
||||
|
logger.warn( |
||||
|
"crash-handler", |
||||
|
`Found previous startup marker. App may have crashed. Last PID: ${lastPid}, Last start time: ${new Date(lastStartTime).toISOString()}`, |
||||
|
) |
||||
|
|
||||
|
// 查找最近的崩溃报告
|
||||
|
const recentCrash = this.getRecentCrashReport() |
||||
|
|
||||
|
// 显示崩溃恢复对话框
|
||||
|
if (recentCrash && this.options.showDialog) { |
||||
|
app.whenReady().then(() => { |
||||
|
this.showRecoveryDialog(recentCrash) |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
// 清除旧的启动标记
|
||||
|
fs.unlinkSync(markerPath) |
||||
|
return true |
||||
|
} |
||||
|
} catch (e) { |
||||
|
logger.error("crash-handler", `Failed to check previous crash: ${e}`) |
||||
|
} |
||||
|
|
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取最近的崩溃报告 |
||||
|
*/ |
||||
|
private getRecentCrashReport(): CrashReport | null { |
||||
|
try { |
||||
|
// 获取所有崩溃报告文件
|
||||
|
const files = fs |
||||
|
.readdirSync(this.crashReportDir) |
||||
|
.filter(file => file.startsWith("crash-") && file.endsWith(".json")) |
||||
|
.map(file => ({ |
||||
|
name: file, |
||||
|
path: path.join(this.crashReportDir, file), |
||||
|
time: fs.statSync(path.join(this.crashReportDir, file)).mtime.getTime(), |
||||
|
})) |
||||
|
.sort((a, b) => b.time - a.time) // 按时间降序排序
|
||||
|
|
||||
|
// 读取最近的崩溃报告
|
||||
|
if (files.length > 0) { |
||||
|
const recentFile = files[0] |
||||
|
const reportData = fs.readFileSync(recentFile.path, "utf8") |
||||
|
return JSON.parse(reportData) as CrashReport |
||||
|
} |
||||
|
} catch (e) { |
||||
|
logger.error("crash-handler", `Failed to get recent crash report: ${e}`) |
||||
|
} |
||||
|
|
||||
|
return null |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 显示崩溃对话框 |
||||
|
*/ |
||||
|
private showCrashDialog(error: Error): void { |
||||
|
try { |
||||
|
const options = { |
||||
|
type: "error" as const, |
||||
|
title: "应用崩溃", |
||||
|
message: "应用遇到了一个严重错误,即将关闭", |
||||
|
detail: `错误信息: ${error.message}\n\n堆栈信息: ${error.stack}\n\n崩溃报告已保存,应用将在您点击确定后关闭。`, |
||||
|
buttons: ["确定"], |
||||
|
defaultId: 0, |
||||
|
} |
||||
|
|
||||
|
dialog.showMessageBoxSync(options) |
||||
|
|
||||
|
// 强制退出应用
|
||||
|
setTimeout(() => { |
||||
|
app.exit(1) |
||||
|
}, 1000) |
||||
|
} catch (e) { |
||||
|
logger.error("crash-handler", `Failed to show crash dialog: ${e}`) |
||||
|
app.exit(1) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 显示恢复对话框 |
||||
|
*/ |
||||
|
private showRecoveryDialog(crashReport: CrashReport): void { |
||||
|
try { |
||||
|
const crashTime = new Date(crashReport.timestamp).toLocaleString() |
||||
|
const errorMessage = crashReport.error.message |
||||
|
const errorType = crashReport.error.type |
||||
|
|
||||
|
const options = { |
||||
|
type: "warning" as const, |
||||
|
title: "应用恢复", |
||||
|
message: "应用上次异常退出", |
||||
|
detail: `应用在 ${crashTime} 因 ${errorType} 错误崩溃: ${errorMessage}\n\n崩溃报告已保存,您可以继续使用应用或联系开发者报告此问题。`, |
||||
|
buttons: ["继续", "查看详情"], |
||||
|
defaultId: 0, |
||||
|
} |
||||
|
|
||||
|
const response = dialog.showMessageBoxSync(options) |
||||
|
|
||||
|
// 如果用户选择查看详情
|
||||
|
if (response === 1) { |
||||
|
// 显示详细的崩溃报告
|
||||
|
this.showDetailedCrashInfo(crashReport) |
||||
|
} |
||||
|
} catch (e) { |
||||
|
logger.error("crash-handler", `Failed to show recovery dialog: ${e}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 显示详细的崩溃信息 |
||||
|
*/ |
||||
|
private showDetailedCrashInfo(crashReport: CrashReport): void { |
||||
|
try { |
||||
|
const options = { |
||||
|
type: "info" as const, |
||||
|
title: "崩溃详情", |
||||
|
message: "应用崩溃详细信息", |
||||
|
detail: JSON.stringify(crashReport, null, 2), |
||||
|
buttons: ["关闭"], |
||||
|
defaultId: 0, |
||||
|
} |
||||
|
|
||||
|
dialog.showMessageBoxSync(options) |
||||
|
} catch (e) { |
||||
|
logger.error("crash-handler", `Failed to show detailed crash info: ${e}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 设置崩溃处理选项 |
||||
|
*/ |
||||
|
public setOptions(options: Partial<CrashHandlerOptions>): void { |
||||
|
this.options = { ...this.options, ...options } |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取当前选项 |
||||
|
*/ |
||||
|
public getOptions(): CrashHandlerOptions { |
||||
|
return { ...this.options } |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 创建默认实例
|
||||
|
const crashHandler = CrashHandler.getInstance() |
||||
|
|
||||
|
export default crashHandler |
||||
|
export { crashHandler } |
@ -0,0 +1,177 @@ |
|||||
|
import { LogLevel, LogLevelName } from "./common" |
||||
|
import logger from "./main" |
||||
|
|
||||
|
/** |
||||
|
* 错误详情接口 |
||||
|
*/ |
||||
|
export interface ErrorDetail { |
||||
|
message: string |
||||
|
stack?: string |
||||
|
componentInfo?: string |
||||
|
additionalInfo?: Record<string, any> |
||||
|
timestamp: string |
||||
|
type: string |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 错误处理配置 |
||||
|
*/ |
||||
|
export interface ErrorHandlerOptions { |
||||
|
namespace?: string |
||||
|
level?: LogLevel |
||||
|
includeStack?: boolean |
||||
|
includeComponentInfo?: boolean |
||||
|
formatError?: (error: any) => ErrorDetail |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 默认错误处理配置 |
||||
|
*/ |
||||
|
const DEFAULT_OPTIONS: ErrorHandlerOptions = { |
||||
|
namespace: "error", |
||||
|
level: LogLevel.ERROR, |
||||
|
includeStack: true, |
||||
|
includeComponentInfo: true, |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 格式化错误信息 |
||||
|
*/ |
||||
|
const formatError = (error: any, options: ErrorHandlerOptions): ErrorDetail => { |
||||
|
// 如果已经是ErrorDetail格式,直接返回
|
||||
|
if (error && typeof error === "object" && error.type && error.message && error.timestamp) { |
||||
|
return error as ErrorDetail |
||||
|
} |
||||
|
|
||||
|
// 基本错误信息
|
||||
|
const errorDetail: ErrorDetail = { |
||||
|
message: "", |
||||
|
timestamp: new Date().toISOString(), |
||||
|
type: "Unknown", |
||||
|
} |
||||
|
|
||||
|
// 处理不同类型的错误
|
||||
|
if (error instanceof Error) { |
||||
|
errorDetail.message = error.message |
||||
|
errorDetail.type = error.name || error.constructor.name |
||||
|
if (options.includeStack) { |
||||
|
errorDetail.stack = error.stack |
||||
|
} |
||||
|
} else if (typeof error === "string") { |
||||
|
errorDetail.message = error |
||||
|
errorDetail.type = "String" |
||||
|
} else if (error === null) { |
||||
|
errorDetail.message = "Null error received" |
||||
|
errorDetail.type = "Null" |
||||
|
} else if (error === undefined) { |
||||
|
errorDetail.message = "Undefined error received" |
||||
|
errorDetail.type = "Undefined" |
||||
|
} else if (typeof error === "object") { |
||||
|
try { |
||||
|
errorDetail.message = error.message || JSON.stringify(error) |
||||
|
errorDetail.type = "Object" |
||||
|
errorDetail.additionalInfo = { ...error } |
||||
|
} catch (e) { |
||||
|
errorDetail.message = "Unserializable error object" |
||||
|
errorDetail.type = "Unserializable" |
||||
|
} |
||||
|
} else { |
||||
|
try { |
||||
|
errorDetail.message = String(error) |
||||
|
errorDetail.type = typeof error |
||||
|
} catch (e) { |
||||
|
errorDetail.message = "Error converting to string" |
||||
|
errorDetail.type = "Unknown" |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return errorDetail |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 错误处理类 |
||||
|
*/ |
||||
|
export class ErrorHandler { |
||||
|
private options: ErrorHandlerOptions |
||||
|
|
||||
|
constructor(options?: Partial<ErrorHandlerOptions>) { |
||||
|
this.options = { ...DEFAULT_OPTIONS, ...options } |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 记录错误 |
||||
|
*/ |
||||
|
public captureError(error: any, componentInfo?: string, additionalInfo?: Record<string, any>): void { |
||||
|
const errorDetail = formatError(error, this.options) |
||||
|
|
||||
|
// 添加组件信息
|
||||
|
if (this.options.includeComponentInfo && componentInfo) { |
||||
|
errorDetail.componentInfo = componentInfo |
||||
|
} |
||||
|
|
||||
|
// 添加额外信息
|
||||
|
if (additionalInfo) { |
||||
|
errorDetail.additionalInfo = { |
||||
|
...errorDetail.additionalInfo, |
||||
|
...additionalInfo, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 使用logger记录错误
|
||||
|
const namespace = this.options.namespace || "error" |
||||
|
const level = LogLevelName[this.options.level || LogLevel.ERROR].toLowerCase() |
||||
|
|
||||
|
// 构建错误消息
|
||||
|
let errorMessage = `${errorDetail.type}: ${errorDetail.message}` |
||||
|
if (errorDetail.componentInfo) { |
||||
|
errorMessage += ` | Component: ${errorDetail.componentInfo}` |
||||
|
} |
||||
|
|
||||
|
// 记录错误
|
||||
|
logger[level](namespace, errorMessage) |
||||
|
|
||||
|
// 如果有堆栈信息,单独记录
|
||||
|
if (errorDetail.stack) { |
||||
|
logger[level](namespace, `Stack: ${errorDetail.stack}`) |
||||
|
} |
||||
|
|
||||
|
// 如果有额外信息,单独记录
|
||||
|
if (errorDetail.additionalInfo) { |
||||
|
try { |
||||
|
const additionalInfoStr = JSON.stringify(errorDetail.additionalInfo, null, 2) |
||||
|
logger[level](namespace, `Additional Info: ${additionalInfoStr}`) |
||||
|
} catch (e) { |
||||
|
logger[level](namespace, "Additional Info: [Unserializable]") |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 设置错误处理选项 |
||||
|
*/ |
||||
|
public setOptions(options: Partial<ErrorHandlerOptions>): void { |
||||
|
this.options = { ...this.options, ...options } |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取当前选项 |
||||
|
*/ |
||||
|
public getOptions(): ErrorHandlerOptions { |
||||
|
return { ...this.options } |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 创建默认实例
|
||||
|
const errorHandler = new ErrorHandler() |
||||
|
|
||||
|
// 捕获未处理的Promise异常
|
||||
|
process.on("unhandledRejection", reason => { |
||||
|
errorHandler.captureError(reason) |
||||
|
}) |
||||
|
|
||||
|
// 捕获未捕获的异常
|
||||
|
process.on("uncaughtException", error => { |
||||
|
errorHandler.captureError(error) |
||||
|
}) |
||||
|
|
||||
|
export default errorHandler |
@ -0,0 +1,275 @@ |
|||||
|
import { app, ipcMain } from "electron" |
||||
|
import fs from "fs" |
||||
|
import path from "path" |
||||
|
import config from "config" |
||||
|
import * as rfs from "rotating-file-stream" |
||||
|
import { LogLevel, LogLevelColor, LogLevelName } from "./common" |
||||
|
import { emitter } from "setting/main/event" |
||||
|
|
||||
|
// 重置颜色的ANSI代码
|
||||
|
const RESET_COLOR = "\x1b[0m" |
||||
|
|
||||
|
// 日志配置接口
|
||||
|
export interface LoggerOptions { |
||||
|
level?: LogLevel // 日志级别
|
||||
|
namespace?: string // 日志命名空间
|
||||
|
console?: boolean // 是否输出到控制台
|
||||
|
file?: boolean // 是否输出到文件
|
||||
|
maxSize?: string // 单个日志文件最大大小
|
||||
|
maxFiles?: number // 保留的最大日志文件数量
|
||||
|
} |
||||
|
|
||||
|
// 默认配置
|
||||
|
const DEFAULT_OPTIONS: LoggerOptions = { |
||||
|
level: config.default_config.debug, |
||||
|
namespace: "app", |
||||
|
console: true, |
||||
|
file: true, |
||||
|
maxSize: "10M", |
||||
|
maxFiles: 10, |
||||
|
} |
||||
|
|
||||
|
let logDir |
||||
|
const isElectronApp = !!process.versions.electron |
||||
|
if (isElectronApp && app) { |
||||
|
logDir = path.join(app.getPath("logs")) |
||||
|
} else { |
||||
|
// 非Electron环境下使用当前目录下的logs文件夹
|
||||
|
logDir = path.join(process.cwd(), "logs") |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 日志管理类 |
||||
|
*/ |
||||
|
export class Logger { |
||||
|
private static instance: Logger |
||||
|
private options: LoggerOptions = DEFAULT_OPTIONS |
||||
|
private logStream: rfs.RotatingFileStream | null = null |
||||
|
private logDir: string = logDir |
||||
|
private currentLogFile: string = "" |
||||
|
private isElectronApp: boolean = !!process.versions.electron |
||||
|
private callInitialize: boolean = false |
||||
|
|
||||
|
/** |
||||
|
* 获取单例实例 |
||||
|
*/ |
||||
|
public static getInstance(): Logger { |
||||
|
if (!Logger.instance) { |
||||
|
Logger.instance = new Logger() |
||||
|
} |
||||
|
if (Logger.instance.callInitialize) { |
||||
|
return Logger.instance |
||||
|
} else { |
||||
|
// 创建代理对象,确保只有在初始化后才能访问除init之外的方法
|
||||
|
const handler = { |
||||
|
get: function (target: any, prop: string) { |
||||
|
if (prop === "init") { |
||||
|
return target[prop] |
||||
|
} |
||||
|
if (!target.callInitialize) { |
||||
|
throw new Error(`Logger未初始化,不能调用${prop}方法,请先调用init()方法`) |
||||
|
} |
||||
|
return target[prop] |
||||
|
}, |
||||
|
} |
||||
|
Logger.instance = new Proxy(new Logger(), handler) |
||||
|
} |
||||
|
return Logger.instance |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 构造函数 |
||||
|
*/ |
||||
|
// private constructor() {}
|
||||
|
|
||||
|
public init(options?: LoggerOptions): void { |
||||
|
this.callInitialize = true |
||||
|
this.options = { ...this.options, ...options } |
||||
|
|
||||
|
// 确保日志目录存在
|
||||
|
if (!fs.existsSync(this.logDir)) { |
||||
|
fs.mkdirSync(this.logDir, { recursive: true }) |
||||
|
} |
||||
|
|
||||
|
// 初始化日志文件
|
||||
|
this.initLogFile() |
||||
|
|
||||
|
// 如果在主进程中,设置IPC监听器接收渲染进程的日志
|
||||
|
if (this.isElectronApp && process.type === "browser") { |
||||
|
this.setupIPC() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 初始化日志文件 |
||||
|
*/ |
||||
|
private initLogFile(): void { |
||||
|
if (!this.options.file) return |
||||
|
|
||||
|
// 生成日志文件名
|
||||
|
const now = new Date() |
||||
|
const timestamp = now.toISOString().replace(/[:.]/g, "-") |
||||
|
this.currentLogFile = `app-logger-${timestamp}.log` |
||||
|
|
||||
|
// 创建日志流
|
||||
|
this.logStream = rfs.createStream(this.currentLogFile, { |
||||
|
path: this.logDir, |
||||
|
size: this.options.maxSize, |
||||
|
rotate: this.options.maxFiles, |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 设置IPC通信,接收渲染进程日志 |
||||
|
*/ |
||||
|
private setupIPC(): void { |
||||
|
if (!ipcMain) return |
||||
|
ipcMain.on("logger:log", (_, level: LogLevel, namespace: string, ...messages: any[]) => { |
||||
|
this.logWithLevel(level, namespace, ...messages) |
||||
|
}) |
||||
|
|
||||
|
// 处理日志级别设置请求
|
||||
|
ipcMain.on("logger:setLevel", (_, level: LogLevel) => { |
||||
|
this.setLevel(level) |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 关闭日志流 |
||||
|
*/ |
||||
|
public close(): void { |
||||
|
if (this.logStream) { |
||||
|
this.logStream.end() |
||||
|
this.logStream.destroy() |
||||
|
this.logStream = null |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 设置日志级别 |
||||
|
*/ |
||||
|
public setLevel(level: LogLevel): void { |
||||
|
this.options.level = level |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取当前日志级别 |
||||
|
*/ |
||||
|
public getLevel(): LogLevel { |
||||
|
return this.options.level ?? LogLevel.INFO |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 根据级别记录日志 |
||||
|
*/ |
||||
|
private logWithLevel(level: LogLevel, namespace: string, ...messages: any[]): void { |
||||
|
// 检查日志级别
|
||||
|
if (level < this.getLevel() || level === LogLevel.OFF) return |
||||
|
|
||||
|
const timestamp = new Date().toISOString() |
||||
|
const levelName = LogLevelName[level] |
||||
|
const prefix = `[${timestamp}] [${namespace}] [${levelName}]` |
||||
|
|
||||
|
// 格式化消息
|
||||
|
const formattedMessages = messages.map(msg => { |
||||
|
if (typeof msg === "object") { |
||||
|
try { |
||||
|
return JSON.stringify(msg) |
||||
|
} catch (e) { |
||||
|
return String(msg) |
||||
|
} |
||||
|
} |
||||
|
return String(msg) |
||||
|
}) |
||||
|
|
||||
|
const message = formattedMessages.join(" ") |
||||
|
|
||||
|
// 输出到控制台
|
||||
|
if (this.options.console) { |
||||
|
const color = LogLevelColor[level] |
||||
|
console.log(`${color}${prefix} ${message}${RESET_COLOR}`) |
||||
|
} |
||||
|
|
||||
|
// 写入日志文件
|
||||
|
if (this.options.file && this.logStream) { |
||||
|
this.logStream.write(`${prefix} ${message}\n`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 记录跟踪级别日志 |
||||
|
*/ |
||||
|
public trace(namespace: string, ...messages: any[]): void { |
||||
|
this.logWithLevel(LogLevel.TRACE, namespace, ...messages) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 记录调试级别日志 |
||||
|
*/ |
||||
|
public debug(namespace: string, ...messages: any[]): void { |
||||
|
this.logWithLevel(LogLevel.DEBUG, namespace, ...messages) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 记录信息级别日志 |
||||
|
*/ |
||||
|
public info(namespace: string, ...messages: any[]): void { |
||||
|
this.logWithLevel(LogLevel.INFO, namespace, ...messages) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 记录警告级别日志 |
||||
|
*/ |
||||
|
public warn(namespace: string, ...messages: any[]): void { |
||||
|
this.logWithLevel(LogLevel.WARN, namespace, ...messages) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 记录错误级别日志 |
||||
|
*/ |
||||
|
public error(namespace: string, ...messages: any[]): void { |
||||
|
this.logWithLevel(LogLevel.ERROR, namespace, ...messages) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 记录致命错误级别日志 |
||||
|
*/ |
||||
|
public fatal(namespace: string, ...messages: any[]): void { |
||||
|
this.logWithLevel(LogLevel.FATAL, namespace, ...messages) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 创建一个固定命名空间的日志记录器 |
||||
|
* @param namespace 命名空间 |
||||
|
* @returns 带有固定命名空间的日志记录器 |
||||
|
*/ |
||||
|
public createNamespace(namespace: string) { |
||||
|
return { |
||||
|
trace: (...messages: any[]) => this.trace(namespace, ...messages), |
||||
|
debug: (...messages: any[]) => this.debug(namespace, ...messages), |
||||
|
info: (...messages: any[]) => this.info(namespace, ...messages), |
||||
|
warn: (...messages: any[]) => this.warn(namespace, ...messages), |
||||
|
error: (...messages: any[]) => this.error(namespace, ...messages), |
||||
|
fatal: (...messages: any[]) => this.fatal(namespace, ...messages), |
||||
|
setLevel: (level: LogLevel) => this.setLevel(level), |
||||
|
getLevel: () => this.getLevel(), |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 默认实例
|
||||
|
const logger = Logger.getInstance() |
||||
|
logger.init() |
||||
|
emitter.on("update", setting => { |
||||
|
logger.setLevel(setting.debug) |
||||
|
}) |
||||
|
|
||||
|
// 应用退出时关闭日志流
|
||||
|
if (process.type === "browser" && app) { |
||||
|
app.on("before-quit", () => { |
||||
|
logger.info("app", "应用关闭") |
||||
|
logger.close() |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
export default logger |
@ -0,0 +1,7 @@ |
|||||
|
{ |
||||
|
"name": "logger", |
||||
|
"version": "1.0.0", |
||||
|
"keywords": [], |
||||
|
"author": "", |
||||
|
"license": "ISC" |
||||
|
} |
@ -0,0 +1,195 @@ |
|||||
|
import { contextBridge, ipcRenderer } from "electron" |
||||
|
import { LogLevel, LogLevelName } from "./common" |
||||
|
import logger from "./preload" |
||||
|
|
||||
|
/** |
||||
|
* 错误详情接口 |
||||
|
*/ |
||||
|
interface ErrorDetail { |
||||
|
message: string |
||||
|
stack?: string |
||||
|
componentInfo?: string |
||||
|
additionalInfo?: Record<string, any> |
||||
|
timestamp: string |
||||
|
type: string |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 错误处理配置 |
||||
|
*/ |
||||
|
interface ErrorHandlerOptions { |
||||
|
namespace?: string |
||||
|
level?: LogLevel |
||||
|
includeStack?: boolean |
||||
|
includeComponentInfo?: boolean |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 渲染进程错误处理接口 |
||||
|
*/ |
||||
|
interface IRendererErrorHandler { |
||||
|
/** |
||||
|
* 捕获错误 |
||||
|
*/ |
||||
|
captureError(error: any, componentInfo?: string, additionalInfo?: Record<string, any>): void |
||||
|
|
||||
|
/** |
||||
|
* 设置错误处理选项 |
||||
|
*/ |
||||
|
setOptions(options: Partial<ErrorHandlerOptions>): void |
||||
|
|
||||
|
/** |
||||
|
* 获取当前选项 |
||||
|
*/ |
||||
|
getOptions(): ErrorHandlerOptions |
||||
|
|
||||
|
/** |
||||
|
* 安装全局错误处理器 |
||||
|
*/ |
||||
|
installGlobalHandlers(): void |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 默认错误处理配置 |
||||
|
*/ |
||||
|
const DEFAULT_OPTIONS: ErrorHandlerOptions = { |
||||
|
namespace: "error", |
||||
|
level: LogLevel.ERROR, |
||||
|
includeStack: true, |
||||
|
includeComponentInfo: true, |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 格式化错误信息 |
||||
|
*/ |
||||
|
const formatError = (error: any, options: ErrorHandlerOptions): ErrorDetail => { |
||||
|
// 基本错误信息
|
||||
|
const errorDetail: ErrorDetail = { |
||||
|
message: "", |
||||
|
timestamp: new Date().toISOString(), |
||||
|
type: "Unknown", |
||||
|
} |
||||
|
// 处理不同类型的错误
|
||||
|
if (error instanceof Error) { |
||||
|
errorDetail.message = error.message |
||||
|
errorDetail.type = error.name || error.constructor.name |
||||
|
if (options.includeStack) { |
||||
|
errorDetail.stack = error.stack |
||||
|
} |
||||
|
} else if (typeof error === "string") { |
||||
|
errorDetail.message = error |
||||
|
errorDetail.type = "String" |
||||
|
} else if (error === null) { |
||||
|
errorDetail.message = "Null error received" |
||||
|
errorDetail.type = "Null" |
||||
|
} else if (error === undefined) { |
||||
|
errorDetail.message = "Undefined error received" |
||||
|
errorDetail.type = "Undefined" |
||||
|
} else if (typeof error === "object") { |
||||
|
try { |
||||
|
errorDetail.message = error.message || JSON.stringify(error) |
||||
|
errorDetail.type = "Object" |
||||
|
errorDetail.additionalInfo = { ...error } |
||||
|
} catch (e) { |
||||
|
errorDetail.message = "Unserializable error object" |
||||
|
errorDetail.type = "Unserializable" |
||||
|
} |
||||
|
} else { |
||||
|
try { |
||||
|
errorDetail.message = String(error) |
||||
|
errorDetail.type = typeof error |
||||
|
} catch (e) { |
||||
|
errorDetail.message = "Error converting to string" |
||||
|
errorDetail.type = "Unknown" |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return errorDetail |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 创建渲染进程错误处理器 |
||||
|
*/ |
||||
|
const createRendererErrorHandler = (): IRendererErrorHandler => { |
||||
|
// 当前错误处理选项
|
||||
|
let options: ErrorHandlerOptions = { ...DEFAULT_OPTIONS } |
||||
|
|
||||
|
/** |
||||
|
* 处理并转发错误到主进程 |
||||
|
*/ |
||||
|
const handleError = (error: any, componentInfo?: string, additionalInfo?: Record<string, any>) => { |
||||
|
// 如果已经是ErrorDetail格式,直接使用
|
||||
|
let errorDetail: ErrorDetail |
||||
|
if (error && typeof error === "object" && error.type && error.message && error.timestamp) { |
||||
|
errorDetail = error as ErrorDetail |
||||
|
} else { |
||||
|
// 否则格式化错误
|
||||
|
errorDetail = formatError(error, options) |
||||
|
} |
||||
|
|
||||
|
// 添加组件信息
|
||||
|
if (options.includeComponentInfo && componentInfo) { |
||||
|
errorDetail.componentInfo = componentInfo |
||||
|
} |
||||
|
|
||||
|
// 使用logger记录错误
|
||||
|
const namespace = options.namespace || "error" |
||||
|
const level = LogLevelName[options.level || LogLevel.ERROR].toLowerCase() |
||||
|
|
||||
|
// 添加额外信息
|
||||
|
if (additionalInfo) { |
||||
|
errorDetail.additionalInfo = { |
||||
|
...errorDetail.additionalInfo, |
||||
|
...additionalInfo, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 记录完整的错误信息
|
||||
|
logger[level](namespace, JSON.stringify(errorDetail)) |
||||
|
|
||||
|
// 同时在控制台输出错误信息
|
||||
|
logger[level](namespace, `${errorDetail.type}: ${errorDetail.message}`) |
||||
|
if (errorDetail.stack) { |
||||
|
logger[level](namespace, `Stack: ${errorDetail.stack}`) |
||||
|
} |
||||
|
|
||||
|
// 如果有额外信息,单独记录
|
||||
|
if (errorDetail.additionalInfo) { |
||||
|
try { |
||||
|
const additionalInfoStr = JSON.stringify(errorDetail.additionalInfo, null, 2) |
||||
|
logger[level](namespace, `Additional Info: ${additionalInfoStr}`) |
||||
|
} catch (e) { |
||||
|
logger[level](namespace, "Additional Info: [Unserializable]") |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 空的安装全局错误处理器方法 |
||||
|
* 实际的全局错误处理由renderer-error.ts负责 |
||||
|
*/ |
||||
|
const installGlobalHandlers = () => { |
||||
|
// 不再在preload层安装全局错误处理器
|
||||
|
// 仅记录日志表明该方法被调用
|
||||
|
logger.info("[ErrorHandler] Global error handlers should be installed in renderer process") |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
captureError: handleError, |
||||
|
setOptions: (newOptions: Partial<ErrorHandlerOptions>) => { |
||||
|
options = { ...options, ...newOptions } |
||||
|
// 同步选项到主进程
|
||||
|
ipcRenderer.send("logger:errorOptions", options) |
||||
|
}, |
||||
|
getOptions: () => ({ ...options }), |
||||
|
installGlobalHandlers, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const errorHandler = createRendererErrorHandler() |
||||
|
|
||||
|
// 暴露错误处理器到渲染进程全局
|
||||
|
contextBridge.exposeInMainWorld("preloadErrorHandler", errorHandler) |
||||
|
|
||||
|
// 导出类型定义,方便在渲染进程中使用
|
||||
|
export type { IRendererErrorHandler, ErrorDetail, ErrorHandlerOptions } |
@ -0,0 +1,153 @@ |
|||||
|
import { contextBridge, ipcRenderer } from "electron" |
||||
|
import { LogLevel } from "./common" |
||||
|
|
||||
|
/** |
||||
|
* 渲染进程日志接口 |
||||
|
*/ |
||||
|
interface IRendererLogger { |
||||
|
trace(namespace: string, ...messages: any[]): void |
||||
|
debug(namespace: string, ...messages: any[]): void |
||||
|
info(namespace: string, ...messages: any[]): void |
||||
|
warn(namespace: string, ...messages: any[]): void |
||||
|
error(namespace: string, ...messages: any[]): void |
||||
|
fatal(namespace: string, ...messages: any[]): void |
||||
|
setLevel(level: LogLevel): void |
||||
|
createNamespace(namespace: string): INamespacedLogger |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 命名空间作用域日志接口 |
||||
|
*/ |
||||
|
interface INamespacedLogger { |
||||
|
trace(...messages: any[]): void |
||||
|
debug(...messages: any[]): void |
||||
|
info(...messages: any[]): void |
||||
|
warn(...messages: any[]): void |
||||
|
error(...messages: any[]): void |
||||
|
fatal(...messages: any[]): void |
||||
|
setLevel(level: LogLevel): void |
||||
|
} |
||||
|
|
||||
|
// 日志级别名称映射
|
||||
|
const LogLevelName: Record<LogLevel, string> = { |
||||
|
[LogLevel.TRACE]: "TRACE", |
||||
|
[LogLevel.DEBUG]: "DEBUG", |
||||
|
[LogLevel.INFO]: "INFO", |
||||
|
[LogLevel.WARN]: "WARN", |
||||
|
[LogLevel.ERROR]: "ERROR", |
||||
|
[LogLevel.FATAL]: "FATAL", |
||||
|
[LogLevel.OFF]: "OFF", |
||||
|
} |
||||
|
|
||||
|
// 日志颜色映射(控制台输出用)
|
||||
|
const LogLevelColor: Record<LogLevel, string> = { |
||||
|
[LogLevel.TRACE]: "\x1b[90m", // 灰色
|
||||
|
[LogLevel.DEBUG]: "\x1b[36m", // 青色
|
||||
|
[LogLevel.INFO]: "\x1b[32m", // 绿色
|
||||
|
[LogLevel.WARN]: "\x1b[33m", // 黄色
|
||||
|
[LogLevel.ERROR]: "\x1b[31m", // 红色
|
||||
|
[LogLevel.FATAL]: "\x1b[35m", // 紫色
|
||||
|
[LogLevel.OFF]: "", // 无色
|
||||
|
} |
||||
|
|
||||
|
// 重置颜色的ANSI代码
|
||||
|
const RESET_COLOR = "\x1b[0m" |
||||
|
|
||||
|
/** |
||||
|
* 创建渲染进程日志对象 |
||||
|
*/ |
||||
|
const createRendererLogger = (): IRendererLogger => { |
||||
|
// 当前日志级别
|
||||
|
let currentLevel: LogLevel = LogLevel.INFO |
||||
|
|
||||
|
// 格式化消息
|
||||
|
const formatMessages = (messages: any[]): string => { |
||||
|
return messages |
||||
|
.map(msg => { |
||||
|
if (typeof msg === "object") { |
||||
|
try { |
||||
|
return JSON.stringify(msg) |
||||
|
} catch (e) { |
||||
|
return String(msg) |
||||
|
} |
||||
|
} |
||||
|
return String(msg) |
||||
|
}) |
||||
|
.join(" ") |
||||
|
} |
||||
|
|
||||
|
// 本地打印日志
|
||||
|
const printLog = (level: LogLevel, namespace: string, ...messages: any[]): void => { |
||||
|
// 检查日志级别
|
||||
|
if (level < currentLevel || level === LogLevel.OFF) return |
||||
|
|
||||
|
const timestamp = new Date().toISOString() |
||||
|
const levelName = LogLevelName[level] |
||||
|
const prefix = `[${timestamp}] [${namespace}] [${levelName}]` |
||||
|
const message = formatMessages(messages) |
||||
|
|
||||
|
// 输出到控制台
|
||||
|
const color = LogLevelColor[level] |
||||
|
console.log(`${color}${prefix} ${message}${RESET_COLOR}`) |
||||
|
} |
||||
|
|
||||
|
// 通过IPC发送日志到主进程
|
||||
|
const sendLog = (level: LogLevel, namespace: string, ...messages: any[]) => { |
||||
|
// 本地打印
|
||||
|
printLog(level, namespace, ...messages) |
||||
|
|
||||
|
// 发送到主进程
|
||||
|
ipcRenderer.send("logger:log", level, namespace, ...messages) |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
trace(namespace: string, ...messages: any[]): void { |
||||
|
sendLog(LogLevel.TRACE, namespace, ...messages) |
||||
|
}, |
||||
|
debug(namespace: string, ...messages: any[]): void { |
||||
|
sendLog(LogLevel.DEBUG, namespace, ...messages) |
||||
|
}, |
||||
|
info(namespace: string, ...messages: any[]): void { |
||||
|
sendLog(LogLevel.INFO, namespace, ...messages) |
||||
|
}, |
||||
|
warn(namespace: string, ...messages: any[]): void { |
||||
|
sendLog(LogLevel.WARN, namespace, ...messages) |
||||
|
}, |
||||
|
error(namespace: string, ...messages: any[]): void { |
||||
|
sendLog(LogLevel.ERROR, namespace, ...messages) |
||||
|
}, |
||||
|
fatal(namespace: string, ...messages: any[]): void { |
||||
|
sendLog(LogLevel.FATAL, namespace, ...messages) |
||||
|
}, |
||||
|
setLevel(level: LogLevel): void { |
||||
|
// 更新本地日志级别
|
||||
|
currentLevel = level |
||||
|
// 设置日志级别(可选,如果需要在渲染进程中动态调整日志级别)
|
||||
|
ipcRenderer.send("logger:setLevel", level) |
||||
|
}, |
||||
|
createNamespace(namespace: string): INamespacedLogger { |
||||
|
return { |
||||
|
trace: (...messages: any[]) => sendLog(LogLevel.TRACE, namespace, ...messages), |
||||
|
debug: (...messages: any[]) => sendLog(LogLevel.DEBUG, namespace, ...messages), |
||||
|
info: (...messages: any[]) => sendLog(LogLevel.INFO, namespace, ...messages), |
||||
|
warn: (...messages: any[]) => sendLog(LogLevel.WARN, namespace, ...messages), |
||||
|
error: (...messages: any[]) => sendLog(LogLevel.ERROR, namespace, ...messages), |
||||
|
fatal: (...messages: any[]) => sendLog(LogLevel.FATAL, namespace, ...messages), |
||||
|
setLevel: (level: LogLevel) => { |
||||
|
currentLevel = level |
||||
|
ipcRenderer.send("logger:setLevel", level) |
||||
|
}, |
||||
|
} |
||||
|
}, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const logger = createRendererLogger() |
||||
|
|
||||
|
// 暴露logger对象到渲染进程全局
|
||||
|
contextBridge.exposeInMainWorld("logger", logger) |
||||
|
|
||||
|
export { logger } |
||||
|
export default logger |
||||
|
// 导出类型定义,方便在渲染进程中使用
|
||||
|
export type { IRendererLogger } |
@ -0,0 +1,243 @@ |
|||||
|
import { LogLevel } from "./common" |
||||
|
|
||||
|
/** |
||||
|
* 错误详情接口 |
||||
|
*/ |
||||
|
interface ErrorDetail { |
||||
|
message: string |
||||
|
stack?: string |
||||
|
componentInfo?: string |
||||
|
additionalInfo?: Record<string, any> |
||||
|
timestamp: string |
||||
|
type: string |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 错误处理配置 |
||||
|
*/ |
||||
|
interface ErrorHandlerOptions { |
||||
|
namespace?: string |
||||
|
level?: LogLevel |
||||
|
includeStack?: boolean |
||||
|
includeComponentInfo?: boolean |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 渲染进程错误处理接口 |
||||
|
*/ |
||||
|
export interface IRendererErrorHandler { |
||||
|
/** |
||||
|
* 捕获错误 |
||||
|
*/ |
||||
|
captureError(error: any, componentInfo?: string, additionalInfo?: Record<string, any>): void |
||||
|
|
||||
|
/** |
||||
|
* 设置错误处理选项 |
||||
|
*/ |
||||
|
setOptions(options: Partial<ErrorHandlerOptions>): void |
||||
|
|
||||
|
/** |
||||
|
* 获取当前选项 |
||||
|
*/ |
||||
|
getOptions(): ErrorHandlerOptions |
||||
|
|
||||
|
/** |
||||
|
* 安装全局错误处理器 |
||||
|
*/ |
||||
|
installGlobalHandlers(): void |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 默认错误处理配置 |
||||
|
*/ |
||||
|
const DEFAULT_OPTIONS: ErrorHandlerOptions = { |
||||
|
namespace: "error", |
||||
|
level: LogLevel.ERROR, |
||||
|
includeStack: true, |
||||
|
includeComponentInfo: true, |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 格式化错误信息 |
||||
|
*/ |
||||
|
const formatError = (error: any, options: ErrorHandlerOptions): ErrorDetail => { |
||||
|
// 基本错误信息
|
||||
|
const errorDetail: ErrorDetail = { |
||||
|
message: "", |
||||
|
timestamp: new Date().toISOString(), |
||||
|
type: "Unknown", |
||||
|
} |
||||
|
console.log(error) |
||||
|
|
||||
|
// 处理不同类型的错误
|
||||
|
if (error instanceof Error) { |
||||
|
errorDetail.message = error.message |
||||
|
errorDetail.type = error.name || error.constructor.name |
||||
|
if (options.includeStack) { |
||||
|
errorDetail.stack = error.stack |
||||
|
} |
||||
|
} else if (typeof error === "string") { |
||||
|
errorDetail.message = error |
||||
|
errorDetail.type = "String" |
||||
|
} else if (error === null) { |
||||
|
errorDetail.message = "Null error received" |
||||
|
errorDetail.type = "Null" |
||||
|
} else if (error === undefined) { |
||||
|
errorDetail.message = "Undefined error received" |
||||
|
errorDetail.type = "Undefined" |
||||
|
} else if (typeof error === "object") { |
||||
|
try { |
||||
|
errorDetail.message = error.message || JSON.stringify(error) |
||||
|
errorDetail.type = "Object" |
||||
|
errorDetail.additionalInfo = { ...error } |
||||
|
} catch (e) { |
||||
|
errorDetail.message = "Unserializable error object" |
||||
|
errorDetail.type = "Unserializable" |
||||
|
} |
||||
|
} else { |
||||
|
try { |
||||
|
errorDetail.message = String(error) |
||||
|
errorDetail.type = typeof error |
||||
|
} catch (e) { |
||||
|
errorDetail.message = "Error converting to string" |
||||
|
errorDetail.type = "Unknown" |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return errorDetail |
||||
|
} |
||||
|
|
||||
|
// @ts-ignore
|
||||
|
const preloadErrorHandler = window.preloadErrorHandler |
||||
|
|
||||
|
/** |
||||
|
* 创建渲染进程错误处理器 |
||||
|
*/ |
||||
|
export const createRendererErrorHandler = (): IRendererErrorHandler => { |
||||
|
// 当前错误处理选项
|
||||
|
let options: ErrorHandlerOptions = { ...DEFAULT_OPTIONS } |
||||
|
|
||||
|
/** |
||||
|
* 处理错误并序列化 |
||||
|
*/ |
||||
|
const processError = (error: any, componentInfo?: string, additionalInfo?: Record<string, any>): ErrorDetail => { |
||||
|
const errorDetail = formatError(error, options) |
||||
|
|
||||
|
// 添加组件信息
|
||||
|
if (options.includeComponentInfo && componentInfo) { |
||||
|
errorDetail.componentInfo = componentInfo |
||||
|
} |
||||
|
|
||||
|
// 添加额外信息
|
||||
|
if (additionalInfo) { |
||||
|
errorDetail.additionalInfo = { |
||||
|
...errorDetail.additionalInfo, |
||||
|
...additionalInfo, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return errorDetail |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 发送错误到preload层 |
||||
|
*/ |
||||
|
const sendError = (error: any, componentInfo?: string, additionalInfo?: Record<string, any>) => { |
||||
|
// 处理并序列化错误
|
||||
|
const errorDetail = processError(error, componentInfo, additionalInfo) |
||||
|
|
||||
|
// 调用window.errorHandler.captureError发送错误
|
||||
|
// 这里假设preload层已经暴露了errorHandler对象
|
||||
|
if (preloadErrorHandler && typeof preloadErrorHandler.captureError === "function") { |
||||
|
preloadErrorHandler.captureError(errorDetail) |
||||
|
} else { |
||||
|
// 如果errorHandler不可用,则降级到控制台输出
|
||||
|
console.error("[ErrorHandler]", errorDetail) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 安装全局错误处理器 |
||||
|
*/ |
||||
|
const installGlobalHandlers = () => { |
||||
|
// 捕获未处理的异常
|
||||
|
window.addEventListener("error", event => { |
||||
|
event.preventDefault() |
||||
|
sendError(event.error || event.message, "window.onerror", { |
||||
|
filename: event.filename, |
||||
|
lineno: event.lineno, |
||||
|
colno: event.colno, |
||||
|
}) |
||||
|
return true |
||||
|
}) |
||||
|
|
||||
|
// 捕获未处理的Promise拒绝
|
||||
|
window.addEventListener("unhandledrejection", event => { |
||||
|
event.preventDefault() |
||||
|
sendError(event.reason, "unhandledrejection", { |
||||
|
promise: "[Promise]", // 不能直接序列化Promise对象
|
||||
|
}) |
||||
|
return true |
||||
|
}) |
||||
|
|
||||
|
// 捕获资源加载错误
|
||||
|
document.addEventListener( |
||||
|
"error", |
||||
|
event => { |
||||
|
// 只处理资源加载错误
|
||||
|
if (event.target && (event.target as HTMLElement).tagName) { |
||||
|
const target = event.target as HTMLElement |
||||
|
sendError(`Resource load failed: ${(target as any).src || (target as any).href}`, "resource.error", { |
||||
|
tagName: target.tagName, |
||||
|
src: (target as any).src, |
||||
|
href: (target as any).href, |
||||
|
}) |
||||
|
} |
||||
|
}, |
||||
|
true, |
||||
|
) // 使用捕获阶段
|
||||
|
|
||||
|
console.info("[ErrorHandler] Global error handlers installed") |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
captureError: sendError, |
||||
|
setOptions: (newOptions: Partial<ErrorHandlerOptions>) => { |
||||
|
options = { ...options, ...newOptions } |
||||
|
// 同步选项到preload层
|
||||
|
if (preloadErrorHandler && typeof preloadErrorHandler.setOptions === "function") { |
||||
|
preloadErrorHandler.setOptions(options) |
||||
|
} |
||||
|
}, |
||||
|
getOptions: () => ({ ...options }), |
||||
|
installGlobalHandlers, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 导出类型定义,方便在渲染进程中使用
|
||||
|
export type { ErrorDetail, ErrorHandlerOptions } |
||||
|
|
||||
|
// 创建渲染进程错误处理器
|
||||
|
const errorHandler = createRendererErrorHandler() |
||||
|
|
||||
|
// 安装全局错误处理器
|
||||
|
errorHandler.installGlobalHandlers() |
||||
|
|
||||
|
window.errorHandler = errorHandler |
||||
|
|
||||
|
/** |
||||
|
* 使用示例: |
||||
|
* |
||||
|
* // 捕获特定错误
|
||||
|
* try { |
||||
|
* // 可能出错的代码
|
||||
|
* } catch (error) { |
||||
|
* errorHandler.captureError(error, 'ComponentName', { additionalInfo: 'value' }) |
||||
|
* } |
||||
|
* |
||||
|
* // 设置错误处理选项
|
||||
|
* errorHandler.setOptions({ |
||||
|
* namespace: 'custom-error', |
||||
|
* includeComponentInfo: true |
||||
|
* }) |
||||
|
*/ |
@ -0,0 +1,231 @@ |
|||||
|
import fs from "fs-extra" |
||||
|
import { app } from "electron" |
||||
|
import path from "path" |
||||
|
import { cloneDeep } from "lodash" |
||||
|
import Config from "config" |
||||
|
import type { IDefaultConfig } from "config" |
||||
|
import _logger from "logger/main" |
||||
|
|
||||
|
const logger = _logger.createNamespace("setting") |
||||
|
|
||||
|
type IConfig = IDefaultConfig |
||||
|
|
||||
|
type IOnFunc = (n: IConfig, c: IConfig, keys?: (keyof IConfig)[]) => void |
||||
|
type IT = (keyof IConfig)[] | keyof IConfig | "_" |
||||
|
|
||||
|
let storagePath = path.join(app.getPath("documents"), Config.app_title) |
||||
|
const storagePathDev = path.join(app.getPath("documents"), Config.app_title + "-dev") |
||||
|
|
||||
|
if (process.env.NODE_ENV === "development") { |
||||
|
storagePath = storagePathDev |
||||
|
} |
||||
|
|
||||
|
const _tempConfig = cloneDeep(Config.default_config as IConfig) |
||||
|
Object.keys(_tempConfig).forEach(key => { |
||||
|
if (typeof _tempConfig[key] === "string" && _tempConfig[key].includes("$storagePath$")) { |
||||
|
_tempConfig[key] = _tempConfig[key].replace(/\$storagePath\$/g, storagePath) |
||||
|
if (_tempConfig[key] && path.isAbsolute(_tempConfig[key])) { |
||||
|
_tempConfig[key] = path.normalize(_tempConfig[key]) |
||||
|
} |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
function isPath(str) { |
||||
|
// 使用正则表达式检查字符串是否以斜杠或盘符开头
|
||||
|
return /^(?:\/|[a-zA-Z]:\\)/.test(str) |
||||
|
} |
||||
|
|
||||
|
function init(config: IConfig) { |
||||
|
// 在配置初始化后执行
|
||||
|
Object.keys(config).forEach(key => { |
||||
|
if (config[key] && isPath(config[key]) && path.isAbsolute(config[key])) { |
||||
|
fs.ensureDirSync(config[key]) |
||||
|
} |
||||
|
}) |
||||
|
// 在配置初始化后执行
|
||||
|
// fs.ensureDirSync(config["snippet.storagePath"])
|
||||
|
// fs.ensureDirSync(config["bookmark.storagePath"])
|
||||
|
} |
||||
|
|
||||
|
// 判断是否是空文件夹
|
||||
|
function isEmptyDir(fPath: string) { |
||||
|
const pa = fs.readdirSync(fPath) |
||||
|
if (pa.length === 0) { |
||||
|
return true |
||||
|
} else { |
||||
|
return false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
class SettingClass { |
||||
|
constructor() { |
||||
|
logger.debug(`Setting inited`) |
||||
|
this.init() |
||||
|
} |
||||
|
|
||||
|
#cb: [IT, IOnFunc][] = [] |
||||
|
|
||||
|
onChange(fn: IOnFunc, that?: any) |
||||
|
onChange(key: IT, fn: IOnFunc, that?: any) |
||||
|
onChange(fnOrType: IT | IOnFunc, fnOrThat: IOnFunc | any = null, that: any = null) { |
||||
|
if (typeof fnOrType === "function") { |
||||
|
this.#cb.push(["_", fnOrType.bind(fnOrThat)]) |
||||
|
} else { |
||||
|
this.#cb.push([fnOrType, fnOrThat.bind(that)]) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
#runCB(n: IConfig, c: IConfig, keys: (keyof IConfig)[]) { |
||||
|
for (let i = 0; i < this.#cb.length; i++) { |
||||
|
const temp = this.#cb[i] |
||||
|
const k = temp[0] |
||||
|
const fn = temp[1] |
||||
|
if (k === "_") { |
||||
|
fn(n, c, keys) |
||||
|
} |
||||
|
if (typeof k === "string" && keys.includes(k as keyof IConfig)) { |
||||
|
fn(n, c) |
||||
|
} |
||||
|
if (Array.isArray(k) && k.filter(v => keys.indexOf(v) !== -1).length) { |
||||
|
fn(n, c) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
#pathFile: string = |
||||
|
process.env.NODE_ENV === "development" |
||||
|
? path.resolve(app.getPath("userData"), "./config_path-dev") |
||||
|
: path.resolve(app.getPath("userData"), "./config_path") |
||||
|
#config: IConfig = cloneDeep(_tempConfig) |
||||
|
#configPath(storagePath?: string): string { |
||||
|
return path.join(storagePath || this.#config.storagePath, "./config.json") |
||||
|
} |
||||
|
/** |
||||
|
* 读取配置文件变量同步 |
||||
|
* @param confingPath 配置文件路径 |
||||
|
*/ |
||||
|
#syncVar(confingPath?: string) { |
||||
|
const configFile = this.#configPath(confingPath) |
||||
|
if (!fs.pathExistsSync(configFile)) { |
||||
|
fs.ensureFileSync(configFile) |
||||
|
fs.writeJSONSync(configFile, {}) |
||||
|
} |
||||
|
const config = fs.readJSONSync(configFile) as IConfig |
||||
|
confingPath && (config.storagePath = confingPath) |
||||
|
// 优先取本地的值
|
||||
|
for (const key in config) { |
||||
|
// if (Object.prototype.hasOwnProperty.call(this.#config, key)) {
|
||||
|
// this.#config[key] = config[key] || this.#config[key]
|
||||
|
// }
|
||||
|
// 删除配置时本地的配置不会改变,想一下哪种方式更好
|
||||
|
this.#config[key] = config[key] ?? this.#config[key] |
||||
|
} |
||||
|
} |
||||
|
init() { |
||||
|
logger.debug(`位置:${this.#pathFile}`) |
||||
|
|
||||
|
if (fs.pathExistsSync(this.#pathFile)) { |
||||
|
const confingPath = fs.readFileSync(this.#pathFile, { encoding: "utf8" }) |
||||
|
if (confingPath && fs.pathExistsSync(this.#configPath(confingPath))) { |
||||
|
this.#syncVar(confingPath) |
||||
|
// 防止增加了配置本地却没变的情况
|
||||
|
this.#sync(confingPath) |
||||
|
} else { |
||||
|
this.#syncVar(confingPath) |
||||
|
this.#sync(confingPath) |
||||
|
} |
||||
|
} else { |
||||
|
this.#syncVar() |
||||
|
this.#sync() |
||||
|
} |
||||
|
init.call(this, this.#config) |
||||
|
} |
||||
|
config() { |
||||
|
return this.#config |
||||
|
} |
||||
|
#sync(c?: string) { |
||||
|
const config = cloneDeep(this.#config) |
||||
|
delete config.storagePath |
||||
|
const p = this.#configPath(c) |
||||
|
fs.ensureFileSync(p) |
||||
|
fs.writeJSONSync(this.#configPath(c), config) |
||||
|
} |
||||
|
#change(p: string) { |
||||
|
const storagePath = this.#config.storagePath |
||||
|
if (fs.existsSync(storagePath) && !fs.existsSync(p)) { |
||||
|
fs.moveSync(storagePath, p) |
||||
|
} |
||||
|
if (fs.existsSync(p) && fs.existsSync(storagePath) && isEmptyDir(p)) { |
||||
|
fs.moveSync(storagePath, p, { overwrite: true }) |
||||
|
} |
||||
|
fs.writeFileSync(this.#pathFile, p, { encoding: "utf8" }) |
||||
|
} |
||||
|
reset(key: keyof IConfig) { |
||||
|
this.set(key, cloneDeep(_tempConfig[key])) |
||||
|
} |
||||
|
set(key: keyof IConfig | Partial<IConfig>, value?: any) { |
||||
|
const oldMainConfig = Object.assign({}, this.#config) |
||||
|
let isChange = false |
||||
|
const changeKeys: (keyof IConfig)[] = [] |
||||
|
const canChangeStorage = (targetPath: string) => { |
||||
|
if (fs.existsSync(oldMainConfig.storagePath) && fs.existsSync(targetPath) && !isEmptyDir(targetPath)) { |
||||
|
if (fs.existsSync(path.join(targetPath, "./config.json"))) { |
||||
|
return true |
||||
|
} |
||||
|
return false |
||||
|
} |
||||
|
return true |
||||
|
} |
||||
|
if (typeof key === "string") { |
||||
|
if (value != undefined && value !== this.#config[key]) { |
||||
|
if (key === "storagePath") { |
||||
|
if (!canChangeStorage(value)) { |
||||
|
throw "无法改变存储地址" |
||||
|
return |
||||
|
} |
||||
|
this.#change(value) |
||||
|
changeKeys.push("storagePath") |
||||
|
this.#config["storagePath"] = value |
||||
|
} else { |
||||
|
changeKeys.push(key) |
||||
|
this.#config[key as string] = value |
||||
|
} |
||||
|
isChange = true |
||||
|
} |
||||
|
} else { |
||||
|
if (key["storagePath"] !== undefined && key["storagePath"] !== this.#config["storagePath"]) { |
||||
|
if (!canChangeStorage(key["storagePath"])) { |
||||
|
throw "无法改变存储地址" |
||||
|
return |
||||
|
} |
||||
|
this.#change(key["storagePath"]) |
||||
|
this.#config["storagePath"] = key["storagePath"] |
||||
|
changeKeys.push("storagePath") |
||||
|
isChange = true |
||||
|
} |
||||
|
for (const _ in key) { |
||||
|
if (Object.prototype.hasOwnProperty.call(key, _)) { |
||||
|
const v = key[_] |
||||
|
if (v != undefined && _ !== "storagePath" && v !== this.#config[_]) { |
||||
|
this.#config[_] = v |
||||
|
changeKeys.push(_ as keyof IConfig) |
||||
|
isChange = true |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
if (isChange) { |
||||
|
this.#sync() |
||||
|
this.#runCB(this.#config, oldMainConfig, changeKeys) |
||||
|
} |
||||
|
} |
||||
|
values<T extends keyof IConfig>(key: T): IConfig[T] { |
||||
|
return this.#config[key] |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const Setting = new SettingClass() |
||||
|
|
||||
|
export default Setting |
||||
|
export { Setting } |
||||
|
export type { IConfig, IOnFunc } |
@ -0,0 +1,6 @@ |
|||||
|
import { buildEmitter } from "base/event/main" |
||||
|
import type { IOnFunc } from "setting/main" |
||||
|
|
||||
|
export const emitter = buildEmitter<{ |
||||
|
update: IOnFunc |
||||
|
}>() |
@ -0,0 +1,7 @@ |
|||||
|
{ |
||||
|
"name": "setting", |
||||
|
"version": "1.0.0", |
||||
|
"keywords": [], |
||||
|
"author": "", |
||||
|
"license": "ISC" |
||||
|
} |
File diff suppressed because it is too large
@ -1,2 +1,2 @@ |
|||||
packages: |
packages: |
||||
- "packages/*" |
- "packages/*" |
||||
|
@ -1,11 +1,12 @@ |
|||||
<!DOCTYPE html> |
<!doctype html> |
||||
<html lang="en"> |
<html lang="en"> |
||||
<head> |
<head> |
||||
<meta charset="UTF-8"> |
<meta charset="UTF-8" /> |
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
||||
<title>Document</title> |
<title>Document</title> |
||||
</head> |
</head> |
||||
<body> |
<body> |
||||
前往 <a href="https://baidu.com" target="_blank">百度</a> |
前往 |
||||
</body> |
<a href="https://baidu.com" target="_blank">百度</a> |
||||
|
</body> |
||||
</html> |
</html> |
||||
|
@ -0,0 +1,34 @@ |
|||||
|
import { Container, ContainerModule } from "inversify" |
||||
|
|
||||
|
/** |
||||
|
* 自动加载所有命令模块 |
||||
|
*/ |
||||
|
const commandModules = import.meta.glob("./event/**/main/command.{ts,js}", { eager: true }) |
||||
|
|
||||
|
const modules = new ContainerModule(bind => { |
||||
|
// 自动绑定所有命令类
|
||||
|
Object.values(commandModules).forEach(module => { |
||||
|
// 由于 module 类型为 unknown,先进行类型断言为包含 default 属性的对象
|
||||
|
const CommandClass = (module as { default: any }).default |
||||
|
if (CommandClass) { |
||||
|
const className = CommandClass.name.replace("Command", "") |
||||
|
if (CommandClass["init"]) { |
||||
|
CommandClass["init"]() |
||||
|
} |
||||
|
bind(className + "Command") |
||||
|
.to(CommandClass) |
||||
|
.inSingletonScope() |
||||
|
} |
||||
|
}) |
||||
|
}) |
||||
|
|
||||
|
/** |
||||
|
* 销毁所有命令绑定 |
||||
|
* @param ioc - Inversify 容器实例 |
||||
|
*/ |
||||
|
async function destroyAllCommand(ioc: Container) { |
||||
|
await ioc.unloadAsync(modules) |
||||
|
} |
||||
|
|
||||
|
export { modules, destroyAllCommand } |
||||
|
export default modules |
@ -0,0 +1,5 @@ |
|||||
|
import { PlatForm } from "." |
||||
|
|
||||
|
export function usePlatForm() { |
||||
|
return PlatForm.getInstance<PlatForm>() |
||||
|
} |
@ -0,0 +1,58 @@ |
|||||
|
import { ApiFactory } from "common/lib/abstract" |
||||
|
import { BaseSingleton } from "base" |
||||
|
import { LogLevel } from "packages/logger/common" |
||||
|
|
||||
|
class PlatForm extends BaseSingleton { |
||||
|
constructor() { |
||||
|
super() |
||||
|
} |
||||
|
|
||||
|
private get api() { |
||||
|
return ApiFactory.getApiClient() |
||||
|
} |
||||
|
|
||||
|
async logSetLevel(level: LogLevel) { |
||||
|
return this.api.call("PlatFormCommand.logSetLevel", level) |
||||
|
} |
||||
|
|
||||
|
async logGetLevel() { |
||||
|
return this.api.call("PlatFormCommand.logGetLevel") |
||||
|
} |
||||
|
|
||||
|
async showAbout() { |
||||
|
// return this.api.call("BasicService.showAbout")
|
||||
|
return this.api.call("PlatFormCommand.showAbout") |
||||
|
} |
||||
|
|
||||
|
async showSrd() { |
||||
|
// return this.api.call("BasicService.showAbout")
|
||||
|
return this.api.call("PlatFormCommand.showSrd") |
||||
|
} |
||||
|
|
||||
|
async getSrdCookie() { |
||||
|
// return this.api.call("BasicService.showAbout")
|
||||
|
return this.api.call("PlatFormCommand.getSrdCookie") |
||||
|
} |
||||
|
|
||||
|
async crash() { |
||||
|
return this.api.call("PlatFormCommand.crash") |
||||
|
} |
||||
|
|
||||
|
async isFullScreen() { |
||||
|
return this.api.call("PlatFormCommand.isFullscreen") |
||||
|
} |
||||
|
|
||||
|
async toggleFullScreen() { |
||||
|
return this.api.call("PlatFormCommand.fullscreen") |
||||
|
} |
||||
|
|
||||
|
async reload() { |
||||
|
return this.api.call("PlatFormCommand.reload") |
||||
|
} |
||||
|
|
||||
|
async toggleDevTools() { |
||||
|
return this.api.call("PlatFormCommand.toggleDevTools") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export { PlatForm } |
@ -0,0 +1,121 @@ |
|||||
|
import { app, dialog, nativeTheme, TitleBarOverlayOptions } from "electron" |
||||
|
import { inject } from "inversify" |
||||
|
import errorHandler from "logger/main-error" |
||||
|
import Tabs from "main/modules/tabs" |
||||
|
import WindowManager from "main/modules/window-manager" |
||||
|
import { getFileUrl } from "main/utils" |
||||
|
import icon from "@res/icon.png?asset" |
||||
|
import setting from "setting/main" |
||||
|
import { LogLevel } from "logger/common" |
||||
|
|
||||
|
export default class PlatFormCommand { |
||||
|
constructor( |
||||
|
@inject(WindowManager) private _WindowManager: WindowManager, |
||||
|
@inject(Tabs) private _Tabs: Tabs, |
||||
|
) {} |
||||
|
|
||||
|
setTheme(theme: typeof nativeTheme.themeSource) { |
||||
|
nativeTheme.themeSource = theme |
||||
|
} |
||||
|
|
||||
|
logSetLevel(level: LogLevel) { |
||||
|
return setting.set("debug", level) |
||||
|
} |
||||
|
|
||||
|
logGetLevel() { |
||||
|
return setting.values("debug") |
||||
|
} |
||||
|
|
||||
|
setTitlBar(options: TitleBarOverlayOptions) { |
||||
|
const mainWindow = this._WindowManager.getMainWindow() |
||||
|
if (mainWindow) { |
||||
|
mainWindow.setTitleBarOverlay(options) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
showAbout() { |
||||
|
this._WindowManager.createWindow("about", { |
||||
|
url: getFileUrl("about.html"), |
||||
|
overideWindowOpts: true, |
||||
|
confrimWindowClose: false, |
||||
|
type: "info", |
||||
|
windowOpts: { |
||||
|
width: 600, |
||||
|
height: 400, |
||||
|
minimizable: false, |
||||
|
darkTheme: true, |
||||
|
modal: true, |
||||
|
title: "关于我", |
||||
|
show: true, |
||||
|
resizable: false, |
||||
|
icon: icon, |
||||
|
webPreferences: { |
||||
|
devTools: false, |
||||
|
sandbox: false, |
||||
|
nodeIntegration: false, |
||||
|
contextIsolation: true, |
||||
|
}, |
||||
|
}, |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
toggleDevTools() { |
||||
|
const focusedWindow = this._WindowManager.getFocusWindow() |
||||
|
if (focusedWindow) { |
||||
|
// @ts-ignore ...
|
||||
|
focusedWindow.toggleDevTools() |
||||
|
} |
||||
|
} |
||||
|
fullscreen() { |
||||
|
const focusedWindow = this._WindowManager.getFocusWindow() |
||||
|
if (focusedWindow) { |
||||
|
const isFullScreen = focusedWindow!.isFullScreen() |
||||
|
focusedWindow!.setFullScreen(!isFullScreen) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
crash() { |
||||
|
errorHandler.captureError(new Error("手动触发的崩溃")) |
||||
|
process.crash() |
||||
|
} |
||||
|
|
||||
|
isFullscreen() { |
||||
|
const focusedWindow = this._WindowManager.getFocusWindow() |
||||
|
if (focusedWindow) { |
||||
|
return focusedWindow!.isFullScreen() |
||||
|
} |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
relunch() { |
||||
|
app.relaunch() |
||||
|
app.exit() |
||||
|
} |
||||
|
|
||||
|
reload() { |
||||
|
const focusedWindow = this._WindowManager.getFocusWindow() |
||||
|
// 重载之后, 刷新并关闭所有的次要窗体
|
||||
|
if (this._WindowManager.length() > 1 && focusedWindow && focusedWindow.$$opts!.name === this._WindowManager.mainInfo.name) { |
||||
|
const choice = dialog.showMessageBoxSync(focusedWindow, { |
||||
|
type: "question", |
||||
|
buttons: ["取消", "是的,继续", "不,算了"], |
||||
|
title: "警告", |
||||
|
defaultId: 2, |
||||
|
cancelId: 0, |
||||
|
message: "警告", |
||||
|
detail: "重载主窗口将关闭所有子窗口,是否继续", |
||||
|
}) |
||||
|
if (choice == 1) { |
||||
|
this._WindowManager.getWndows().forEach(win => { |
||||
|
if (win.$$opts!.name !== this._WindowManager.mainInfo.name) { |
||||
|
win.close() |
||||
|
} |
||||
|
}) |
||||
|
} else { |
||||
|
return |
||||
|
} |
||||
|
} |
||||
|
this._Tabs.closeAll() |
||||
|
focusedWindow!.reload() |
||||
|
} |
||||
|
} |
@ -0,0 +1,5 @@ |
|||||
|
import { Snippet } from "." |
||||
|
|
||||
|
export function useSnippet() { |
||||
|
return Snippet.getInstance() |
||||
|
} |
@ -0,0 +1,18 @@ |
|||||
|
import { BaseSingleton } from "base" |
||||
|
import { ApiFactory } from "common/lib/abstract" |
||||
|
|
||||
|
class Snippet extends BaseSingleton { |
||||
|
constructor() { |
||||
|
super() |
||||
|
} |
||||
|
|
||||
|
private get api() { |
||||
|
return ApiFactory.getApiClient() |
||||
|
} |
||||
|
|
||||
|
getTree = async () => { |
||||
|
return this.api.call("SnippetCommand.getTree") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export { Snippet } |
@ -0,0 +1,44 @@ |
|||||
|
import fs from "fs-extra" |
||||
|
import path from "path/posix" |
||||
|
import Setting from "setting/main" |
||||
|
|
||||
|
// 代码片段命令处理器
|
||||
|
// base/__snippet__.json 基础信息
|
||||
|
// 路径做为ID, 当前文件夹的信息
|
||||
|
|
||||
|
export default class SnippetCommand { |
||||
|
storagePath: string = Setting.values("snippet.storagePath") |
||||
|
|
||||
|
constructor() { |
||||
|
const handler = { |
||||
|
get: function (target, prop, receiver) { |
||||
|
if (!target["check"]()) { |
||||
|
throw new Error(`代码片段路径存在问题`) |
||||
|
} |
||||
|
const value = target[prop] |
||||
|
if (typeof value === "function") { |
||||
|
return (...args) => Reflect.apply(value, receiver, args) |
||||
|
} |
||||
|
return value |
||||
|
}, |
||||
|
} |
||||
|
return new Proxy(this, handler) |
||||
|
} |
||||
|
|
||||
|
async check() { |
||||
|
const stat = await fs.statSync(this.storagePath) |
||||
|
const inforFile = path.resolve(this.storagePath, "__snippet__.json") |
||||
|
if (stat.isDirectory() && stat.size == 0) { |
||||
|
await fs.writeJSON(inforFile, {}) |
||||
|
// 空文件夹, 初始化信息
|
||||
|
return true |
||||
|
} else { |
||||
|
const isExist = await fs.pathExists(inforFile) |
||||
|
return isExist |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
getTree() { |
||||
|
return this.storagePath |
||||
|
} |
||||
|
} |
@ -0,0 +1,54 @@ |
|||||
|
import { BaseSingleton } from "base" |
||||
|
|
||||
|
export class Tabs extends BaseSingleton { |
||||
|
constructor() { |
||||
|
super() |
||||
|
} |
||||
|
|
||||
|
private isListen: boolean = false |
||||
|
|
||||
|
private execUpdate = (...args) => { |
||||
|
this.#fnList.forEach(v => v(...args)) |
||||
|
} |
||||
|
|
||||
|
#fnList: ((...args) => void)[] = [] |
||||
|
listenUpdate(cb: (...args) => void) { |
||||
|
if (!this.isListen) { |
||||
|
api.on("main:TabsCommand.update", this.execUpdate) |
||||
|
this.isListen = true |
||||
|
} |
||||
|
this.#fnList.push(cb) |
||||
|
} |
||||
|
|
||||
|
unListenUpdate(fn: (...args) => void) { |
||||
|
this.#fnList = this.#fnList.filter(v => { |
||||
|
return v !== fn |
||||
|
}) |
||||
|
if (!this.#fnList.length) { |
||||
|
api.off("main:TabsCommand.update", this.execUpdate) |
||||
|
this.isListen = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
bindPosition(data) { |
||||
|
api.call("TabsCommand.bindElement", data) |
||||
|
} |
||||
|
|
||||
|
closeAll() { |
||||
|
api.call("TabsCommand.closeAll") |
||||
|
} |
||||
|
|
||||
|
sync() { |
||||
|
api.call("TabsCommand.sync") |
||||
|
} |
||||
|
|
||||
|
unListenerAll() { |
||||
|
this.#fnList = [] |
||||
|
api.offAll("main:TabsCommand.update") |
||||
|
} |
||||
|
|
||||
|
async getAllTabs() { |
||||
|
const res = await api.call("TabsCommand.getAllTabs") |
||||
|
return res |
||||
|
} |
||||
|
} |
@ -0,0 +1,65 @@ |
|||||
|
import { inject } from "inversify" |
||||
|
import Tabs from "main/modules/tabs" |
||||
|
import WindowManager from "main/modules/window-manager" |
||||
|
import { broadcast } from "main/utils" |
||||
|
|
||||
|
class TabsCommand { |
||||
|
constructor( |
||||
|
@inject(Tabs) private _Tabs: Tabs, |
||||
|
@inject(WindowManager) private _WindowManager: WindowManager, |
||||
|
) { |
||||
|
this._Tabs.events.on("update", this.listenerTabActive) |
||||
|
} |
||||
|
|
||||
|
listenerTabActive = () => { |
||||
|
broadcast("main:TabsCommand.update", this.getAllTabs()) |
||||
|
} |
||||
|
|
||||
|
bindElement(rect) { |
||||
|
this._Tabs.updateRect(rect) |
||||
|
} |
||||
|
|
||||
|
reload() { |
||||
|
this._WindowManager.getMainWindow()?.reload() |
||||
|
} |
||||
|
|
||||
|
sync() { |
||||
|
this.listenerTabActive() |
||||
|
if (!this.getAllTabs().length) { |
||||
|
this.add("about:blank") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
add(url) { |
||||
|
this._Tabs.add(url, true, this._WindowManager.getMainWindow()!) |
||||
|
} |
||||
|
|
||||
|
nagivate(index: number, url: string) { |
||||
|
this._Tabs.navigate(+index, url) |
||||
|
} |
||||
|
|
||||
|
closeAll() { |
||||
|
this._Tabs.closeAll() |
||||
|
} |
||||
|
|
||||
|
setActive(index) { |
||||
|
this._Tabs.changeActive(index) |
||||
|
} |
||||
|
|
||||
|
closeTab(e) { |
||||
|
this._Tabs.remove(e.body.active) |
||||
|
} |
||||
|
|
||||
|
getAllTabs() { |
||||
|
return this._Tabs._tabs.map(v => ({ |
||||
|
url: v.url, |
||||
|
showUrl: v.showUrl, |
||||
|
title: v.title, |
||||
|
favicons: v.favicons, |
||||
|
isActive: v.isActive, |
||||
|
})) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export { TabsCommand } |
||||
|
export default TabsCommand |
@ -0,0 +1,15 @@ |
|||||
|
import { EventEnum } from "helper/updater/common" |
||||
|
|
||||
|
const curProgress = ref(0) |
||||
|
|
||||
|
api.on(EventEnum.UPDATE_PROGRESS, ({ percent, now, all }) => { |
||||
|
curProgress.value = percent |
||||
|
}) |
||||
|
|
||||
|
function useUpdate() { |
||||
|
return { |
||||
|
curProgress, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export { useUpdate } |
@ -0,0 +1,15 @@ |
|||||
|
import Updater from "helper/updater/main" |
||||
|
import _logger from "logger/main" |
||||
|
|
||||
|
const logger = _logger.createNamespace("UpdaterCommand") |
||||
|
|
||||
|
export default class UpdaterCommand { |
||||
|
static init() { |
||||
|
// 命令初始化
|
||||
|
logger.debug("UpdaterCommand init") |
||||
|
} |
||||
|
|
||||
|
async triggerHotUpdate() { |
||||
|
Updater.triggerHotUpdate() |
||||
|
} |
||||
|
} |
@ -0,0 +1,53 @@ |
|||||
|
import { ElectronApiClient } from "common/lib/electron" |
||||
|
import { BrowserApiClient } from "common/lib/browser" |
||||
|
|
||||
|
// 定义抽象 API 接口
|
||||
|
export interface IApiClient { |
||||
|
call<T = any>(command: string, ...args: any[]): Promise<T> |
||||
|
on<K extends string>(channel: K, callback: (...args: any[]) => void): void |
||||
|
off<K extends string>(channel: K, callback: (...args: any[]) => void): void |
||||
|
offAll<K extends string>(channel: K): void |
||||
|
} |
||||
|
|
||||
|
class NullApiClient implements IApiClient { |
||||
|
async call<T = any>(command: string, ...args: any[]): Promise<T> { |
||||
|
args |
||||
|
console.warn(`API call to ${command} failed: API client not initialized`) |
||||
|
return undefined as any |
||||
|
} |
||||
|
|
||||
|
on<K extends string>(channel: K, callback: (...args: any[]) => void): void { |
||||
|
callback |
||||
|
console.warn(`Failed to register listener for ${channel}: API client not initialized`) |
||||
|
} |
||||
|
|
||||
|
off<K extends string>(channel: K, callback: (...args: any[]) => void): void { |
||||
|
callback |
||||
|
console.warn(`Failed to unregister listener for ${channel}: API client not initialized`) |
||||
|
} |
||||
|
|
||||
|
offAll<K extends string>(channel: K): void { |
||||
|
console.warn(`Failed to unregister all listeners for ${channel}: API client not initialized`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 创建 API 工厂
|
||||
|
export class ApiFactory { |
||||
|
private static instance: IApiClient = new NullApiClient() // 默认使用空实现
|
||||
|
|
||||
|
static setApiClient(client: IApiClient) { |
||||
|
this.instance = client |
||||
|
} |
||||
|
|
||||
|
static getApiClient(): IApiClient { |
||||
|
if (this.instance instanceof NullApiClient) { |
||||
|
// 根据环境选择合适的 API 客户端
|
||||
|
if (window.api && window.electron) { |
||||
|
this.instance = new ElectronApiClient() |
||||
|
} else { |
||||
|
this.instance = new BrowserApiClient() |
||||
|
} |
||||
|
} |
||||
|
return this.instance |
||||
|
} |
||||
|
} |
@ -0,0 +1,29 @@ |
|||||
|
import { IApiClient } from "./abstract" |
||||
|
|
||||
|
export class BrowserApiClient implements IApiClient { |
||||
|
call<T = any>(command: string, ...args: any[]): Promise<T> { |
||||
|
// 浏览器特定实现,可能使用 fetch 或其他方式
|
||||
|
const [service, method] = command.split(".") |
||||
|
return fetch(`/api/${service}/${method}`, { |
||||
|
method: "POST", |
||||
|
body: JSON.stringify(args), |
||||
|
headers: { "Content-Type": "application/json" }, |
||||
|
}).then(res => res.json()) |
||||
|
} |
||||
|
|
||||
|
// 实现其他方法...
|
||||
|
on<K extends string>(channel: K, callback: (...args: any[]) => void): void { |
||||
|
// 浏览器中可能使用 WebSocket 或其他方式
|
||||
|
console.log("不支持 on 方法", channel, callback) |
||||
|
} |
||||
|
|
||||
|
off<K extends string>(channel: K, callback: (...args: any[]) => void): void { |
||||
|
// 相应的解绑实现
|
||||
|
console.log("不支持 on 方法", channel, callback) |
||||
|
} |
||||
|
|
||||
|
offAll<K extends string>(channel: K): void { |
||||
|
// 相应的全部解绑实现
|
||||
|
console.log("不支持 on 方法", channel) |
||||
|
} |
||||
|
} |
@ -0,0 +1,20 @@ |
|||||
|
import { IApiClient } from "./abstract" |
||||
|
|
||||
|
export class ElectronApiClient implements IApiClient { |
||||
|
call<T = any>(command: string, ...args: any[]): Promise<T> { |
||||
|
// Electron 特定实现
|
||||
|
return window.api.call(command, ...args) |
||||
|
} |
||||
|
|
||||
|
on<K extends string>(channel: K, callback: (...args: any[]) => void): void { |
||||
|
window.api.on(channel, callback) |
||||
|
} |
||||
|
|
||||
|
off<K extends string>(channel: K, callback: (...args: any[]) => void): void { |
||||
|
window.api.off(channel, callback) |
||||
|
} |
||||
|
|
||||
|
offAll<K extends string>(channel: K): void { |
||||
|
window.api.offAll(channel) |
||||
|
} |
||||
|
} |
@ -0,0 +1,7 @@ |
|||||
|
## event |
||||
|
|
||||
|
通用事件处理模块 |
||||
|
|
||||
|
- main/**/* 处理主进程的模块 |
||||
|
- main/command.ts 会通过ioc收集,进入依赖管理中 |
||||
|
- 其他 处理渲染进程的模块 |
@ -0,0 +1,130 @@ |
|||||
|
class Node<E> { |
||||
|
static readonly Undefined = new Node<any>(undefined) |
||||
|
|
||||
|
element: E |
||||
|
next: Node<E> |
||||
|
prev: Node<E> |
||||
|
|
||||
|
constructor(element: E) { |
||||
|
this.element = element |
||||
|
this.next = Node.Undefined |
||||
|
this.prev = Node.Undefined |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export class LinkedList<E> { |
||||
|
private _first: Node<E> = Node.Undefined |
||||
|
private _last: Node<E> = Node.Undefined |
||||
|
private _size: number = 0 |
||||
|
|
||||
|
get size(): number { |
||||
|
return this._size |
||||
|
} |
||||
|
|
||||
|
isEmpty(): boolean { |
||||
|
return this._first === Node.Undefined |
||||
|
} |
||||
|
|
||||
|
clear(): void { |
||||
|
let node = this._first |
||||
|
while (node !== Node.Undefined) { |
||||
|
const next = node.next |
||||
|
node.prev = Node.Undefined |
||||
|
node.next = Node.Undefined |
||||
|
node = next |
||||
|
} |
||||
|
|
||||
|
this._first = Node.Undefined |
||||
|
this._last = Node.Undefined |
||||
|
this._size = 0 |
||||
|
} |
||||
|
|
||||
|
unshift(element: E): () => void { |
||||
|
return this._insert(element, false) |
||||
|
} |
||||
|
|
||||
|
push(element: E): () => void { |
||||
|
return this._insert(element, true) |
||||
|
} |
||||
|
|
||||
|
private _insert(element: E, atTheEnd: boolean): () => void { |
||||
|
const newNode = new Node(element) |
||||
|
if (this._first === Node.Undefined) { |
||||
|
this._first = newNode |
||||
|
this._last = newNode |
||||
|
} else if (atTheEnd) { |
||||
|
// push
|
||||
|
const oldLast = this._last |
||||
|
this._last = newNode |
||||
|
newNode.prev = oldLast |
||||
|
oldLast.next = newNode |
||||
|
} else { |
||||
|
// unshift
|
||||
|
const oldFirst = this._first |
||||
|
this._first = newNode |
||||
|
newNode.next = oldFirst |
||||
|
oldFirst.prev = newNode |
||||
|
} |
||||
|
this._size += 1 |
||||
|
|
||||
|
let didRemove = false |
||||
|
return () => { |
||||
|
if (!didRemove) { |
||||
|
didRemove = true |
||||
|
this._remove(newNode) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
shift(): E | undefined { |
||||
|
if (this._first === Node.Undefined) { |
||||
|
return undefined |
||||
|
} else { |
||||
|
const res = this._first.element |
||||
|
this._remove(this._first) |
||||
|
return res |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
pop(): E | undefined { |
||||
|
if (this._last === Node.Undefined) { |
||||
|
return undefined |
||||
|
} else { |
||||
|
const res = this._last.element |
||||
|
this._remove(this._last) |
||||
|
return res |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private _remove(node: Node<E>): void { |
||||
|
if (node.prev !== Node.Undefined && node.next !== Node.Undefined) { |
||||
|
// middle
|
||||
|
const anchor = node.prev |
||||
|
anchor.next = node.next |
||||
|
node.next.prev = anchor |
||||
|
} else if (node.prev === Node.Undefined && node.next === Node.Undefined) { |
||||
|
// only node
|
||||
|
this._first = Node.Undefined |
||||
|
this._last = Node.Undefined |
||||
|
} else if (node.next === Node.Undefined) { |
||||
|
// last
|
||||
|
this._last = this._last.prev! |
||||
|
this._last.next = Node.Undefined |
||||
|
} else if (node.prev === Node.Undefined) { |
||||
|
// first
|
||||
|
this._first = this._first.next! |
||||
|
this._first.prev = Node.Undefined |
||||
|
} |
||||
|
|
||||
|
// done
|
||||
|
this._size -= 1 |
||||
|
} |
||||
|
|
||||
|
*[Symbol.iterator](): Iterator<E> { |
||||
|
let node = this._first |
||||
|
while (node !== Node.Undefined) { |
||||
|
yield node.element |
||||
|
node = node.next |
||||
|
} |
||||
|
} |
||||
|
} |
@ -1,63 +0,0 @@ |
|||||
import { app, dialog } from "electron" |
|
||||
import { inject } from "inversify" |
|
||||
import Tabs from "main/modules/tabs" |
|
||||
import WindowManager from "main/modules/window-manager" |
|
||||
|
|
||||
export default class BasicCommand { |
|
||||
constructor( |
|
||||
@inject(WindowManager) private _WindowManager: WindowManager, |
|
||||
@inject(Tabs) private _Tabs: Tabs, |
|
||||
) { |
|
||||
//
|
|
||||
} |
|
||||
|
|
||||
toggleDevTools() { |
|
||||
const focusedWindow = this._WindowManager.getFocusWindow() |
|
||||
if (focusedWindow) { |
|
||||
// @ts-ignore ...
|
|
||||
focusedWindow.toggleDevTools() |
|
||||
} |
|
||||
} |
|
||||
fullscreen() { |
|
||||
const focusedWindow = this._WindowManager.getFocusWindow() |
|
||||
const isFullScreen = focusedWindow!.isFullScreen() |
|
||||
focusedWindow!.setFullScreen(!isFullScreen) |
|
||||
return !isFullScreen |
|
||||
} |
|
||||
isFullscreen() { |
|
||||
const focusedWindow = this._WindowManager.getFocusWindow() |
|
||||
return focusedWindow!.isFullScreen() |
|
||||
} |
|
||||
|
|
||||
relunch() { |
|
||||
app.relaunch() |
|
||||
app.exit() |
|
||||
} |
|
||||
|
|
||||
reload() { |
|
||||
const focusedWindow = this._WindowManager.getFocusWindow() |
|
||||
// 重载之后, 刷新并关闭所有的次要窗体
|
|
||||
if (this._WindowManager.length() > 1 && focusedWindow && focusedWindow.$$opts!.name === this._WindowManager.mainInfo.name) { |
|
||||
const choice = dialog.showMessageBoxSync(focusedWindow, { |
|
||||
type: "question", |
|
||||
buttons: ["取消", "是的,继续", "不,算了"], |
|
||||
title: "警告", |
|
||||
defaultId: 2, |
|
||||
cancelId: 0, |
|
||||
message: "警告", |
|
||||
detail: "重载主窗口将关闭所有子窗口,是否继续", |
|
||||
}) |
|
||||
if (choice == 1) { |
|
||||
this._WindowManager.getWndows().forEach(win => { |
|
||||
if (win.$$opts!.name !== this._WindowManager.mainInfo.name) { |
|
||||
win.close() |
|
||||
} |
|
||||
}) |
|
||||
} else { |
|
||||
return |
|
||||
} |
|
||||
} |
|
||||
this._Tabs.closeAll() |
|
||||
focusedWindow!.reload() |
|
||||
} |
|
||||
} |
|
@ -1,66 +0,0 @@ |
|||||
import { inject } from "inversify" |
|
||||
import Tabs from "main/modules/tabs" |
|
||||
import WindowManager from "main/modules/window-manager" |
|
||||
import { broadcast } from "main/utils" |
|
||||
|
|
||||
class TabsCommand { |
|
||||
constructor( |
|
||||
@inject(Tabs) private _Tabs: Tabs, |
|
||||
@inject(WindowManager) private _WindowManager: WindowManager, |
|
||||
) { |
|
||||
this.listenerTabActive = this.listenerTabActive.bind(this) |
|
||||
this._Tabs.events.on("update", this.listenerTabActive) |
|
||||
} |
|
||||
|
|
||||
listenerTabActive() { |
|
||||
broadcast("main:TabsCommand.update", this.getAllTabs()) |
|
||||
} |
|
||||
|
|
||||
bindElement(rect) { |
|
||||
this._Tabs.updateRect(rect) |
|
||||
} |
|
||||
|
|
||||
reload() { |
|
||||
this._WindowManager.getMainWindow()?.reload() |
|
||||
} |
|
||||
|
|
||||
sync() { |
|
||||
this.listenerTabActive() |
|
||||
if (!this.getAllTabs().length) { |
|
||||
this.add("about:blank") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
add(url) { |
|
||||
this._Tabs.add(url, true, this._WindowManager.getMainWindow()!) |
|
||||
} |
|
||||
|
|
||||
nagivate(index: number, url: string) { |
|
||||
this._Tabs.navigate(+index, url) |
|
||||
} |
|
||||
|
|
||||
closeAll() { |
|
||||
this._Tabs.closeAll() |
|
||||
} |
|
||||
|
|
||||
setActive(index) { |
|
||||
this._Tabs.changeActive(index) |
|
||||
} |
|
||||
|
|
||||
closeTab(e) { |
|
||||
this._Tabs.remove(e.body.active) |
|
||||
} |
|
||||
|
|
||||
getAllTabs() { |
|
||||
return this._Tabs._tabs.map(v => ({ |
|
||||
url: v.url, |
|
||||
showUrl: v.showUrl, |
|
||||
title: v.title, |
|
||||
favicons: v.favicons, |
|
||||
isActive: v.isActive, |
|
||||
})) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
export { TabsCommand } |
|
||||
export default TabsCommand |
|
@ -1,15 +0,0 @@ |
|||||
import { Container, ContainerModule } from "inversify" |
|
||||
import BasicCommand from "./BasicCommand" |
|
||||
import TabsCommand from "./TabsCommand" |
|
||||
|
|
||||
const modules = new ContainerModule(bind => { |
|
||||
bind("BasicCommand").to(BasicCommand).inSingletonScope() |
|
||||
bind("TabsCommand").to(TabsCommand).inSingletonScope() |
|
||||
}) |
|
||||
|
|
||||
async function destroyAllCommand(ioc: Container) { |
|
||||
await ioc.unloadAsync(modules) |
|
||||
} |
|
||||
|
|
||||
export { modules, destroyAllCommand } |
|
||||
export default modules |
|
@ -0,0 +1,60 @@ |
|||||
|
import debug from "debug" |
||||
|
import { app } from "electron" |
||||
|
import path from "node:path" |
||||
|
import logger from "logger/main" |
||||
|
import * as rfs from "rotating-file-stream" |
||||
|
import fs from "fs" |
||||
|
|
||||
|
// 配置根目录
|
||||
|
const logsPath = app.getPath("logs") |
||||
|
logger.debug(`日志地址:${logsPath}`) |
||||
|
|
||||
|
const LOG_ROOT = path.join(logsPath) |
||||
|
|
||||
|
// 缓存当前应用启动的日志文件流
|
||||
|
let currentLogStream: rfs.RotatingFileStream | null = null |
||||
|
|
||||
|
// 生成当前启动时的日志文件名
|
||||
|
const getLogFileName = () => { |
||||
|
const now = new Date() |
||||
|
const timestamp = now.toISOString().replace(/[:.]/g, "-") |
||||
|
return `app-${timestamp}.log` |
||||
|
} |
||||
|
|
||||
|
// 覆盖 debug.log 方法
|
||||
|
const originalLog = debug.log |
||||
|
debug.log = function (...args) { |
||||
|
// 保留原始控制台输出
|
||||
|
originalLog.apply(this, args) |
||||
|
|
||||
|
// 确保日志目录存在
|
||||
|
if (!fs.existsSync(LOG_ROOT)) { |
||||
|
fs.mkdirSync(LOG_ROOT, { recursive: true }) |
||||
|
} |
||||
|
|
||||
|
// 延迟初始化日志流,直到第一次写入
|
||||
|
if (!currentLogStream) { |
||||
|
const logFileName = getLogFileName() |
||||
|
currentLogStream = rfs.createStream(logFileName, { |
||||
|
path: LOG_ROOT, |
||||
|
size: "10M", // 单个文件最大 10MB
|
||||
|
rotate: 10, // 保留最近 10 个文件
|
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
// @ts-ignore 获取当前命名空间
|
||||
|
const namespace = this.namespace || "unknown" |
||||
|
|
||||
|
// 写入日志(添加时间戳和命名空间)
|
||||
|
const timestamp = new Date().toISOString() |
||||
|
const message = args.join(" ") |
||||
|
currentLogStream.write(`[${timestamp}] [${namespace}] ${message}\n`) |
||||
|
} |
||||
|
|
||||
|
app.on("before-quit", () => { |
||||
|
if (currentLogStream) { |
||||
|
currentLogStream.end() |
||||
|
currentLogStream.destroy() |
||||
|
currentLogStream = null |
||||
|
} |
||||
|
}) |
@ -0,0 +1,6 @@ |
|||||
|
import EventEmitter from "events" |
||||
|
|
||||
|
const globalEvent = new EventEmitter() |
||||
|
|
||||
|
export default globalEvent |
||||
|
export { globalEvent as eventbus } |
@ -1,235 +0,0 @@ |
|||||
import fs from "fs-extra" |
|
||||
import { app } from "electron" |
|
||||
import path from "path" |
|
||||
import { cloneDeep } from "lodash" |
|
||||
import { injectable } from "inversify" |
|
||||
import Config from "config" |
|
||||
import _debug from "debug" |
|
||||
import BaseClass from "main/base/base" |
|
||||
|
|
||||
const debug = _debug("app:setting") |
|
||||
|
|
||||
type IConfig = typeof Config.default_config |
|
||||
|
|
||||
type IOnFunc = (n: IConfig, c: IConfig, keys?: (keyof IConfig)[]) => void |
|
||||
type IT = (keyof IConfig)[] | keyof IConfig | "_" |
|
||||
|
|
||||
let storagePath = path.join(app.getPath("documents"), Config.app_title) |
|
||||
const storagePathDev = path.join(app.getPath("documents"), Config.app_title + "-dev") |
|
||||
|
|
||||
if (process.env.NODE_ENV === "development") { |
|
||||
storagePath = storagePathDev |
|
||||
} |
|
||||
|
|
||||
const _tempConfig = cloneDeep(Config.default_config as IConfig) |
|
||||
Object.keys(_tempConfig).forEach(key => { |
|
||||
if (typeof _tempConfig[key] === "string" && _tempConfig[key].includes("$storagePath$")) { |
|
||||
_tempConfig[key] = _tempConfig[key].replace(/\$storagePath\$/g, storagePath) |
|
||||
if (_tempConfig[key] && path.isAbsolute(_tempConfig[key])) { |
|
||||
_tempConfig[key] = path.normalize(_tempConfig[key]) |
|
||||
} |
|
||||
} |
|
||||
}) |
|
||||
|
|
||||
function isPath(str) { |
|
||||
// 使用正则表达式检查字符串是否以斜杠或盘符开头
|
|
||||
return /^(?:\/|[a-zA-Z]:\\)/.test(str) |
|
||||
} |
|
||||
|
|
||||
function init(config: IConfig) { |
|
||||
// 在配置初始化后执行
|
|
||||
Object.keys(config).forEach(key => { |
|
||||
if (config[key] && isPath(config[key]) && path.isAbsolute(config[key])) { |
|
||||
fs.ensureDirSync(config[key]) |
|
||||
} |
|
||||
}) |
|
||||
// 在配置初始化后执行
|
|
||||
// fs.ensureDirSync(config["snippet.storagePath"])
|
|
||||
// fs.ensureDirSync(config["bookmark.storagePath"])
|
|
||||
} |
|
||||
|
|
||||
// 判断是否是空文件夹
|
|
||||
function isEmptyDir(fPath: string) { |
|
||||
const pa = fs.readdirSync(fPath) |
|
||||
if (pa.length === 0) { |
|
||||
return true |
|
||||
} else { |
|
||||
return false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
@injectable() |
|
||||
class Setting extends BaseClass { |
|
||||
constructor() { |
|
||||
super() |
|
||||
debug(`Setting inited`) |
|
||||
this.init() |
|
||||
} |
|
||||
|
|
||||
destroy() { |
|
||||
// TODO
|
|
||||
} |
|
||||
|
|
||||
#cb: [IT, IOnFunc][] = [] |
|
||||
|
|
||||
onChange(fn: IOnFunc, that?: any) |
|
||||
onChange(key: IT, fn: IOnFunc, that?: any) |
|
||||
onChange(fnOrType: IT | IOnFunc, fnOrThat: IOnFunc | any = null, that: any = null) { |
|
||||
if (typeof fnOrType === "function") { |
|
||||
this.#cb.push(["_", fnOrType.bind(fnOrThat)]) |
|
||||
} else { |
|
||||
this.#cb.push([fnOrType, fnOrThat.bind(that)]) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
#runCB(n: IConfig, c: IConfig, keys: (keyof IConfig)[]) { |
|
||||
for (let i = 0; i < this.#cb.length; i++) { |
|
||||
const temp = this.#cb[i] |
|
||||
const k = temp[0] |
|
||||
const fn = temp[1] |
|
||||
if (k === "_") { |
|
||||
fn(n, c, keys) |
|
||||
} |
|
||||
if (typeof k === "string" && keys.includes(k as keyof IConfig)) { |
|
||||
fn(n, c) |
|
||||
} |
|
||||
if (Array.isArray(k) && k.filter(v => keys.indexOf(v) !== -1).length) { |
|
||||
fn(n, c) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
#pathFile: string = |
|
||||
process.env.NODE_ENV === "development" |
|
||||
? path.resolve(app.getPath("userData"), "./config_path-dev") |
|
||||
: path.resolve(app.getPath("userData"), "./config_path") |
|
||||
#config: IConfig = cloneDeep(_tempConfig) |
|
||||
#configPath(storagePath?: string): string { |
|
||||
return path.join(storagePath || this.#config.storagePath, "./config.json") |
|
||||
} |
|
||||
/** |
|
||||
* 读取配置文件变量同步 |
|
||||
* @param confingPath 配置文件路径 |
|
||||
*/ |
|
||||
#syncVar(confingPath?: string) { |
|
||||
const configFile = this.#configPath(confingPath) |
|
||||
if (!fs.pathExistsSync(configFile)) { |
|
||||
fs.ensureFileSync(configFile) |
|
||||
fs.writeJSONSync(configFile, {}) |
|
||||
} |
|
||||
const config = fs.readJSONSync(configFile) as IConfig |
|
||||
confingPath && (config.storagePath = confingPath) |
|
||||
// 优先取本地的值
|
|
||||
for (const key in config) { |
|
||||
// if (Object.prototype.hasOwnProperty.call(this.#config, key)) {
|
|
||||
// this.#config[key] = config[key] || this.#config[key]
|
|
||||
// }
|
|
||||
// 删除配置时本地的配置不会改变,想一下哪种方式更好
|
|
||||
this.#config[key] = config[key] || this.#config[key] |
|
||||
} |
|
||||
} |
|
||||
init() { |
|
||||
debug(`位置:${this.#pathFile}`) |
|
||||
|
|
||||
if (fs.pathExistsSync(this.#pathFile)) { |
|
||||
const confingPath = fs.readFileSync(this.#pathFile, { encoding: "utf8" }) |
|
||||
if (confingPath && fs.pathExistsSync(this.#configPath(confingPath))) { |
|
||||
this.#syncVar(confingPath) |
|
||||
// 防止增加了配置本地却没变的情况
|
|
||||
this.#sync(confingPath) |
|
||||
} else { |
|
||||
this.#syncVar(confingPath) |
|
||||
this.#sync(confingPath) |
|
||||
} |
|
||||
} else { |
|
||||
this.#syncVar() |
|
||||
this.#sync() |
|
||||
} |
|
||||
init.call(this, this.#config) |
|
||||
} |
|
||||
config() { |
|
||||
return this.#config |
|
||||
} |
|
||||
#sync(c?: string) { |
|
||||
const config = cloneDeep(this.#config) |
|
||||
delete config.storagePath |
|
||||
const p = this.#configPath(c) |
|
||||
fs.ensureFileSync(p) |
|
||||
fs.writeJSONSync(this.#configPath(c), config) |
|
||||
} |
|
||||
#change(p: string) { |
|
||||
const storagePath = this.#config.storagePath |
|
||||
if (fs.existsSync(storagePath) && !fs.existsSync(p)) { |
|
||||
fs.moveSync(storagePath, p) |
|
||||
} |
|
||||
if (fs.existsSync(p) && fs.existsSync(storagePath) && isEmptyDir(p)) { |
|
||||
fs.moveSync(storagePath, p, { overwrite: true }) |
|
||||
} |
|
||||
fs.writeFileSync(this.#pathFile, p, { encoding: "utf8" }) |
|
||||
} |
|
||||
reset(key: keyof IConfig) { |
|
||||
this.set(key, cloneDeep(_tempConfig[key])) |
|
||||
} |
|
||||
set(key: keyof IConfig | Partial<IConfig>, value?: any) { |
|
||||
const oldMainConfig = Object.assign({}, this.#config) |
|
||||
let isChange = false |
|
||||
const changeKeys: (keyof IConfig)[] = [] |
|
||||
const canChangeStorage = (targetPath: string) => { |
|
||||
if (fs.existsSync(oldMainConfig.storagePath) && fs.existsSync(targetPath) && !isEmptyDir(targetPath)) { |
|
||||
if (fs.existsSync(path.join(targetPath, "./config.json"))) { |
|
||||
return true |
|
||||
} |
|
||||
return false |
|
||||
} |
|
||||
return true |
|
||||
} |
|
||||
if (typeof key === "string") { |
|
||||
if (value != undefined && value !== this.#config[key]) { |
|
||||
if (key === "storagePath") { |
|
||||
if (!canChangeStorage(value)) { |
|
||||
throw "无法改变存储地址" |
|
||||
return |
|
||||
} |
|
||||
this.#change(value) |
|
||||
changeKeys.push("storagePath") |
|
||||
this.#config["storagePath"] = value |
|
||||
} else { |
|
||||
changeKeys.push(key) |
|
||||
this.#config[key as string] = value |
|
||||
} |
|
||||
isChange = true |
|
||||
} |
|
||||
} else { |
|
||||
if (key["storagePath"] !== undefined && key["storagePath"] !== this.#config["storagePath"]) { |
|
||||
if (!canChangeStorage(key["storagePath"])) { |
|
||||
throw "无法改变存储地址" |
|
||||
return |
|
||||
} |
|
||||
this.#change(key["storagePath"]) |
|
||||
this.#config["storagePath"] = key["storagePath"] |
|
||||
changeKeys.push("storagePath") |
|
||||
isChange = true |
|
||||
} |
|
||||
for (const _ in key) { |
|
||||
if (Object.prototype.hasOwnProperty.call(key, _)) { |
|
||||
const v = key[_] |
|
||||
if (v != undefined && _ !== "storagePath" && v !== this.#config[_]) { |
|
||||
this.#config[_] = v |
|
||||
changeKeys.push(_ as keyof IConfig) |
|
||||
isChange = true |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
if (isChange) { |
|
||||
this.#sync() |
|
||||
this.#runCB(this.#config, oldMainConfig, changeKeys) |
|
||||
} |
|
||||
} |
|
||||
values<T extends keyof IConfig>(key: T): IConfig[T] { |
|
||||
return this.#config[key] |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
export default Setting |
|
||||
export { Setting } |
|
@ -1,6 +1,6 @@ |
|||||
export function Layout(width, height) { |
export function Layout(width, height) { |
||||
// Tab布局位置
|
// Tab布局位置
|
||||
const NavbarHeight = 30 |
const NavbarHeight = 30 |
||||
const OffsetHeight = NavbarHeight + 100 |
const OffsetHeight = NavbarHeight + 100 |
||||
return { x: 0, y: OffsetHeight, width: width, height: height - OffsetHeight } |
return { x: 0, y: OffsetHeight, width: width, height: height - OffsetHeight } |
||||
} |
} |
||||
|
@ -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 |
|
@ -1,130 +1,107 @@ |
|||||
import config from "config" |
import config from "config" |
||||
import { BrowserWindowConstructorOptions } from "electron" |
import { BrowserWindowConstructorOptions } from "electron" |
||||
import { getFileUrl } from "main/utils" |
import { getFileUrl, getPreloadUrl } from "main/utils" |
||||
import icon from "res/icon.png?asset" |
import icon from "@res/icon.png?asset" |
||||
import { join } from "path" |
|
||||
|
|
||||
export type Param = Partial<IConfig> & Required<Pick<IConfig, "name">> |
export type Param = Partial<IConfig> & Required<Pick<IConfig, "name">> |
||||
|
|
||||
export interface IConfig { |
export interface IConfig { |
||||
name?: string |
name?: string |
||||
url?: string |
url?: string |
||||
loadURLInSameWin?: boolean |
loadURLInSameWin?: boolean |
||||
type?: "info" |
type?: "info" |
||||
windowOpts?: BrowserWindowConstructorOptions |
windowOpts?: BrowserWindowConstructorOptions |
||||
overideWindowOpts?: boolean |
overideWindowOpts?: boolean |
||||
ignoreEmptyUrl?: boolean |
ignoreEmptyUrl?: boolean |
||||
denyWindowOpen?: boolean |
denyWindowOpen?: boolean |
||||
confrimWindowClose?: boolean |
confrimWindowClose?: boolean |
||||
confrimWindowCloseText?: { |
confrimWindowCloseText?: { |
||||
title: string |
title: string |
||||
message: string |
message: string |
||||
buttons: string[] |
buttons: string[] |
||||
defaultId: number |
defaultId: number |
||||
cancelId: number |
cancelId: number |
||||
} |
} |
||||
} |
} |
||||
|
|
||||
export const defaultConfig: IConfig = { |
export const defaultConfig: IConfig = { |
||||
denyWindowOpen: true, |
denyWindowOpen: true, |
||||
} |
} |
||||
|
|
||||
export const defaultWindowConfig = { |
export const defaultWindowConfig = { |
||||
height: 600, |
height: 600, |
||||
useContentSize: true, |
useContentSize: true, |
||||
width: 800, |
width: 800, |
||||
show: true, |
show: true, |
||||
resizable: true, |
resizable: true, |
||||
minWidth: 900, |
minWidth: 900, |
||||
minHeight: 600, |
minHeight: 600, |
||||
frame: true, |
frame: true, |
||||
transparent: false, |
transparent: false, |
||||
alwaysOnTop: false, |
alwaysOnTop: false, |
||||
webPreferences: {}, |
webPreferences: {}, |
||||
} |
} |
||||
|
|
||||
export function getWindowsMap(): Record<string, IConfig> { |
export function getWindowsMap(): Record<string, IConfig> { |
||||
return { |
return { |
||||
main: { |
main: { |
||||
name: "main", |
name: "main", |
||||
url: getFileUrl("index.html"), |
url: getFileUrl("index.html"), |
||||
confrimWindowClose: true, |
confrimWindowClose: true, |
||||
confrimWindowCloseText: { |
confrimWindowCloseText: { |
||||
title: config.app_title, |
title: config.app_title, |
||||
defaultId: 0, |
defaultId: 0, |
||||
cancelId: 0, |
cancelId: 0, |
||||
message: "确定要关闭吗?", |
message: "确定要关闭吗?", |
||||
buttons: ["没事", "直接退出"], |
buttons: ["没事", "直接退出"], |
||||
}, |
}, |
||||
windowOpts: { |
windowOpts: { |
||||
show: false, |
show: false, |
||||
titleBarStyle: "hidden", |
titleBarStyle: "hidden", |
||||
titleBarOverlay: true, |
titleBarOverlay: true, |
||||
icon: icon, |
icon: icon, |
||||
...(process.platform === "linux" ? { icon } : {}), |
...(process.platform === "linux" ? { icon } : {}), |
||||
webPreferences: { |
webPreferences: { |
||||
webviewTag: false, |
webviewTag: false, |
||||
preload: join(__dirname, "../preload/index.mjs"), |
preload: getPreloadUrl("index"), |
||||
nodeIntegration: true, |
nodeIntegration: true, |
||||
contextIsolation: true, |
contextIsolation: true, |
||||
}, |
|
||||
}, |
|
||||
}, |
}, |
||||
_blank: { |
}, |
||||
overideWindowOpts: false, |
}, |
||||
confrimWindowClose: true, |
_blank: { |
||||
confrimWindowCloseText: { |
overideWindowOpts: false, |
||||
title: config.app_title, |
confrimWindowClose: true, |
||||
defaultId: 0, |
confrimWindowCloseText: { |
||||
cancelId: 0, |
title: config.app_title, |
||||
message: "确定要关闭吗?", |
defaultId: 0, |
||||
buttons: ["没事", "直接退出"], |
cancelId: 0, |
||||
}, |
message: "确定要关闭吗?", |
||||
type: "info", |
buttons: ["没事", "直接退出"], |
||||
windowOpts: { |
}, |
||||
height: 600, |
type: "info", |
||||
useContentSize: true, |
windowOpts: { |
||||
width: 800, |
height: 600, |
||||
show: true, |
useContentSize: true, |
||||
resizable: true, |
width: 800, |
||||
minWidth: 900, |
show: true, |
||||
minHeight: 600, |
resizable: true, |
||||
frame: true, |
minWidth: 900, |
||||
transparent: false, |
minHeight: 600, |
||||
alwaysOnTop: false, |
frame: true, |
||||
icon: icon, |
transparent: false, |
||||
title: config.app_title, |
alwaysOnTop: false, |
||||
webPreferences: { |
icon: icon, |
||||
devTools: false, |
title: config.app_title, |
||||
sandbox: true, |
webPreferences: { |
||||
nodeIntegration: false, |
devTools: false, |
||||
contextIsolation: true, |
sandbox: true, |
||||
webviewTag: false, |
nodeIntegration: false, |
||||
preload: undefined, |
contextIsolation: true, |
||||
}, |
webviewTag: false, |
||||
}, |
preload: undefined, |
||||
}, |
}, |
||||
"^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, |
|
||||
}, |
|
||||
}, |
|
||||
}, |
|
||||
} |
|
||||
} |
} |
||||
|
@ -0,0 +1,47 @@ |
|||||
|
import { BrowserView, BrowserWindow } from "electron" |
||||
|
|
||||
|
const cookies = { |
||||
|
getCurrCookies(params = {}, currWin: BrowserView | BrowserWindow) { |
||||
|
let currSession = currWin.webContents.session |
||||
|
return currSession.cookies.get(Object.assign({}, params)) |
||||
|
}, |
||||
|
removeCurrCookies(cookies = [], currWin: BrowserView | BrowserWindow) { |
||||
|
let currSession = currWin.webContents.session |
||||
|
let err = [] |
||||
|
let apiCount = 0 |
||||
|
return new Promise((resove, reject) => { |
||||
|
cookies.forEach(async (item: any) => { |
||||
|
await currSession.cookies.remove(`http://${item.domain}`, item.name) |
||||
|
apiCount = apiCount + 1 |
||||
|
if (err.length === apiCount) { |
||||
|
resove({ message: "cookie 清除成功" }) |
||||
|
} else { |
||||
|
reject(err) |
||||
|
} |
||||
|
}) |
||||
|
}) |
||||
|
}, |
||||
|
setCurrCookies(cookies = [], currWin: BrowserView | BrowserWindow) { |
||||
|
let currSession = currWin.webContents.session |
||||
|
let err = [] |
||||
|
let apiCount = 0 |
||||
|
return new Promise((resove, reject) => { |
||||
|
cookies.forEach(async (item: any) => { |
||||
|
await currSession.cookies.set( |
||||
|
Object.assign({}, item, { |
||||
|
url: `http://${item.domain}`, |
||||
|
name: item.name, |
||||
|
}), |
||||
|
) |
||||
|
apiCount = apiCount + 1 |
||||
|
if (err.length === apiCount) { |
||||
|
resove({ message: "cookie 设置成功!" }) |
||||
|
} else { |
||||
|
reject(err) |
||||
|
} |
||||
|
}) |
||||
|
}) |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
export default cookies |
@ -0,0 +1,82 @@ |
|||||
|
// https://blog.guowenfh.com/2017/10/21/2017/electron-multiple-session/#%E5%9C%A8-webview-%E4%B8%AD
|
||||
|
|
||||
|
import { BrowserWindow } from "electron" |
||||
|
/** |
||||
|
* 创建一个 登录 的窗口。 |
||||
|
* 用于 session 隔离 |
||||
|
* Promise 中有 {partition,userinfo,cookies} |
||||
|
* @returns Promise |
||||
|
*/ |
||||
|
function createLoginWin(partition) { |
||||
|
partition = partition || `persist:${Math.random()}` |
||||
|
// const charset = require("superagent-charset")
|
||||
|
// const request = charset(require("superagent")) // HTTP
|
||||
|
let presWindow = new BrowserWindow({ |
||||
|
width: 1280, |
||||
|
height: 768, |
||||
|
title: "用户登陆", |
||||
|
webPreferences: { |
||||
|
webSecurity: false, |
||||
|
allowRunningInsecureContent: true, |
||||
|
partition, |
||||
|
}, |
||||
|
}) |
||||
|
let webContents = presWindow.webContents |
||||
|
return new Promise(function (resove, _) { |
||||
|
// webContents.openDevTools();
|
||||
|
presWindow.loadURL("https://login.taobao.com/member/login.jhtml") |
||||
|
webContents.on("did-navigate-in-page", async function () { |
||||
|
// 这里可以看情况进行参数的传递,获取制定的 cookies
|
||||
|
const cookies = await webContents.session.cookies.get({}) |
||||
|
let obj = { partition, cookies } |
||||
|
resove(obj) |
||||
|
// webContents.session.cookies.get({}, function (err, cookies) {
|
||||
|
// if (err) {
|
||||
|
// presWindow.close() // 关闭登陆窗口
|
||||
|
// return reject(err)
|
||||
|
// }
|
||||
|
// let obj = { partition, cookies }
|
||||
|
// resove(obj)
|
||||
|
// fetch("https://login.taobao.com/member/login.jhtml", {
|
||||
|
// method: "GET",
|
||||
|
// credentials: "include",
|
||||
|
// headers: {
|
||||
|
// Cookie: cookies.map(item => `${item.name}=${item.value};`).join(" "),
|
||||
|
// "Content-Type": "application/json",
|
||||
|
// },
|
||||
|
// })
|
||||
|
// .then(response => response.json())
|
||||
|
// .then(data => {
|
||||
|
// console.log(data)
|
||||
|
// presWindow.close()
|
||||
|
// resove(obj)
|
||||
|
// })
|
||||
|
// .catch(err => {
|
||||
|
// presWindow.close()
|
||||
|
// reject(err)
|
||||
|
// })
|
||||
|
// })
|
||||
|
// 这一步并不是必需的。
|
||||
|
// request
|
||||
|
// .get("http://taobao.com/userinfo")
|
||||
|
// .query({ _: Date.now() }) // query string
|
||||
|
// .set("Cookie", cookies.map(item => `${item.name}=${item.value};`).join(" "))
|
||||
|
// .end(function (err, res) {
|
||||
|
// presWindow.close()
|
||||
|
// if (err) {
|
||||
|
// return reject(err)
|
||||
|
// }
|
||||
|
// if (!res || !res.body || !res.body.result !== 1) {
|
||||
|
// return reject(res.body)
|
||||
|
// }
|
||||
|
// let obj = { partition, cookies, userinfo: res.body.data }
|
||||
|
// resove(obj)
|
||||
|
// })
|
||||
|
}) |
||||
|
// })
|
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
export { |
||||
|
createLoginWin |
||||
|
} |
@ -1,8 +0,0 @@ |
|||||
import { ElectronAPI } from "@electron-toolkit/preload" |
|
||||
|
|
||||
declare global { |
|
||||
interface Window { |
|
||||
electron: ElectronAPI |
|
||||
api: unknown |
|
||||
} |
|
||||
} |
|
@ -1,28 +1,32 @@ |
|||||
<!doctype html> |
<!doctype html> |
||||
<html lang="en"> |
<html lang="en"> |
||||
<head> |
|
||||
<meta charset="UTF-8" /> |
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|
||||
<title>关于我</title> |
|
||||
<style> |
|
||||
html, |
|
||||
body { |
|
||||
height: 100%; |
|
||||
width: 100%; |
|
||||
margin: 0; |
|
||||
padding: 0; |
|
||||
outline: none; |
|
||||
border: 0; |
|
||||
display: flex; |
|
||||
justify-content: center; |
|
||||
align-items: center; |
|
||||
} |
|
||||
</style> |
|
||||
</head> |
|
||||
|
|
||||
<body> |
<head> |
||||
<article> |
<meta charset="UTF-8" /> |
||||
<h1 id="demo" style="text-align: center">您好,亲爱的冒险者!</h1> |
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
||||
</article> |
<title>关于我</title> |
||||
</body> |
<style> |
||||
</html> |
html, |
||||
|
body { |
||||
|
height: 100%; |
||||
|
width: 100%; |
||||
|
margin: 0; |
||||
|
padding: 0; |
||||
|
outline: none; |
||||
|
border: 0; |
||||
|
overflow: hidden; |
||||
|
padding: 10px 20px; |
||||
|
} |
||||
|
</style> |
||||
|
</head> |
||||
|
|
||||
|
<body> |
||||
|
<article> |
||||
|
<h1>环境</h1> |
||||
|
<ul> |
||||
|
<li>111</li> |
||||
|
</ul> |
||||
|
</article> |
||||
|
</body> |
||||
|
|
||||
|
</html> |
@ -1,26 +1,27 @@ |
|||||
<!doctype html> |
<!doctype html> |
||||
<html> |
<html> |
||||
|
<head> |
||||
<head> |
|
||||
<meta charset="UTF-8" /> |
<meta charset="UTF-8" /> |
||||
<title>Electron</title> |
<title>Electron</title> |
||||
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP --> |
<!-- 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:; |
script-src 'self' api:; |
||||
style-src 'self' 'unsafe-inline'; |
style-src 'self' 'unsafe-inline'; |
||||
img-src 'self' data: *;" /> |
img-src 'self' data: *;" |
||||
</head> |
/> |
||||
|
</head> |
||||
|
|
||||
<body> |
<body> |
||||
<div id="app"></div> |
<div id="app"></div> |
||||
<noscript> |
<noscript> |
||||
<style> |
<style> |
||||
[data-simplebar] { |
[data-simplebar] { |
||||
overflow: auto; |
overflow: auto; |
||||
} |
} |
||||
</style> |
</style> |
||||
</noscript> |
</noscript> |
||||
<script type="module" src="/src/main.ts"></script> |
<script type="module" src="/src/main.ts"></script> |
||||
</body> |
</body> |
||||
|
|
||||
</html> |
</html> |
||||
|
@ -1,9 +1,32 @@ |
|||||
<script setup lang="ts"></script> |
<script setup lang="ts"> |
||||
|
|
||||
|
</script> |
||||
|
|
||||
<template> |
<template> |
||||
<router-view v-slot="{ Component, route }"> |
<div h-full flex flex-col overflow-hidden> |
||||
<transition name="slide"> |
<NavBar></NavBar> |
||||
<component :is="Component" :key="route" /> |
<div flex-1 h-0 overflow-hidden flex flex-col relative id="page-container" style="transform: scale(1);"> |
||||
</transition> |
<router-view v-slot="{ Component, route }"> |
||||
</router-view> |
<Transition name="slide-fade" mode="out-in"> |
||||
|
<component :is="Component" :key="route.fullPath" /> |
||||
|
</Transition> |
||||
|
</router-view> |
||||
|
</div> |
||||
|
</div> |
||||
</template> |
</template> |
||||
|
|
||||
|
<style lang="scss" scoped> |
||||
|
.slide-fade-enter-active { |
||||
|
transition: all 0.2s ease-out; |
||||
|
} |
||||
|
|
||||
|
.slide-fade-leave-active { |
||||
|
transition: all 0.2s cubic-bezier(1, 0.5, 0.8, 1); |
||||
|
} |
||||
|
|
||||
|
.slide-fade-enter-from, |
||||
|
.slide-fade-leave-to { |
||||
|
// transform: translateX(20px); |
||||
|
opacity: 0; |
||||
|
} |
||||
|
</style> |
||||
|
@ -1,246 +1,245 @@ |
|||||
interface ScrollStyle { |
interface ScrollStyle { |
||||
[key: string]: string |
[key: string]: string |
||||
} |
} |
||||
|
|
||||
class Scrollbot { |
class Scrollbot { |
||||
private orgPar!: HTMLElement |
private orgPar!: HTMLElement |
||||
private sbw: number = 5 |
private sbw: number = 5 |
||||
private scrollSpeed: number = 200 |
private scrollSpeed: number = 200 |
||||
private parContent!: string |
private parContent!: string |
||||
private newPar!: HTMLDivElement |
private newPar!: HTMLDivElement |
||||
// private sbContainer!: HTMLDivElement
|
// private sbContainer!: HTMLDivElement
|
||||
private scrollBarHolder!: HTMLDivElement |
private scrollBarHolder!: HTMLDivElement |
||||
private scrollBar!: HTMLDivElement |
private scrollBar!: HTMLDivElement |
||||
private inP!: HTMLDivElement |
private inP!: HTMLDivElement |
||||
private sbHeight: number = 0 |
private sbHeight: number = 0 |
||||
private mdown: boolean = false |
private mdown: boolean = false |
||||
private customHeight: boolean = false |
private customHeight: boolean = false |
||||
// private scrollElement!: HTMLElement
|
// private scrollElement!: HTMLElement
|
||||
private onScrollF?: () => void |
private onScrollF?: () => void |
||||
private sB: ScrollStyle = {} |
private sB: ScrollStyle = {} |
||||
private sBH: ScrollStyle = {} |
private sBH: ScrollStyle = {} |
||||
private posCorrection: number = 0 |
private posCorrection: number = 0 |
||||
private btmCorrection: number = 0 |
private btmCorrection: number = 0 |
||||
private relY: number = 0 |
private relY: number = 0 |
||||
private pC: number = 0 |
private pC: number = 0 |
||||
|
|
||||
getDom(selector: string | HTMLElement) { |
getDom(selector: string | HTMLElement) { |
||||
if (typeof selector === "string") { |
if (typeof selector === "string") { |
||||
return document.querySelector<HTMLElement>(selector) |
return document.querySelector<HTMLElement>(selector) |
||||
} |
|
||||
return selector |
|
||||
} |
} |
||||
|
return selector |
||||
|
} |
||||
|
|
||||
constructor(selector: string | HTMLElement, width?: number) { |
constructor(selector: string | HTMLElement, width?: number) { |
||||
const element = this.getDom(selector) |
const element = this.getDom(selector) |
||||
if (!element) throw new Error("Element not found") |
if (!element) throw new Error("Element not found") |
||||
this.orgPar = element |
this.orgPar = element |
||||
|
|
||||
const ieVersion = this.isIE() |
const ieVersion = this.isIE() |
||||
if (!ieVersion || (ieVersion && ieVersion < 9)) { |
if (!ieVersion || (ieVersion && ieVersion < 9)) { |
||||
this.init(width) |
this.init(width) |
||||
} |
|
||||
} |
} |
||||
|
} |
||||
private init(width?: number): void { |
|
||||
this.sbw = width ?? 5 |
private init(width?: number): void { |
||||
this.parContent = this.orgPar.innerHTML |
this.sbw = width ?? 5 |
||||
this.orgPar.innerHTML = "" |
this.parContent = this.orgPar.innerHTML |
||||
|
this.orgPar.innerHTML = "" |
||||
this.setupElements() |
|
||||
this.setupStyles() |
this.setupElements() |
||||
this.setupEventListeners() |
this.setupStyles() |
||||
this.refresh() |
this.setupEventListeners() |
||||
|
this.refresh() |
||||
|
} |
||||
|
|
||||
|
private setupElements(): void { |
||||
|
this.newPar = document.createElement("div") |
||||
|
// this.sbContainer = document.createElement("div")
|
||||
|
this.scrollBarHolder = document.createElement("div") |
||||
|
this.scrollBar = document.createElement("div") |
||||
|
this.inP = document.createElement("div") |
||||
|
|
||||
|
this.newPar.className = "scrollbot-outer-parent" |
||||
|
this.scrollBarHolder.className = "scrollbot-scrollbar-holder" |
||||
|
this.scrollBar.className = "scrollbot-scrollbar" |
||||
|
this.inP.className = "scrollbot-inner-parent" |
||||
|
|
||||
|
this.inP.innerHTML = this.parContent |
||||
|
this.newPar.appendChild(this.inP) |
||||
|
this.scrollBarHolder.appendChild(this.scrollBar) |
||||
|
this.newPar.appendChild(this.scrollBarHolder) |
||||
|
this.orgPar.appendChild(this.newPar) |
||||
|
} |
||||
|
|
||||
|
private setupStyles(): void { |
||||
|
this.newPar.style.position = "relative" |
||||
|
this.newPar.style.paddingRight = `${this.sbw}px` |
||||
|
this.newPar.style.zIndex = "9999999" |
||||
|
this.newPar.style.height = "100%" |
||||
|
this.newPar.style.overflow = "hidden" |
||||
|
|
||||
|
this.inP.style.cssText = `height:100%;overflow-y:auto;overflow-x:hidden;padding-right:${ |
||||
|
this.sbw + 20 |
||||
|
}px;width:100%;box-sizing:content-box;` |
||||
|
|
||||
|
this.sbHeight = (this.inP.clientHeight * 100) / this.inP.scrollHeight |
||||
|
// this.scrollElement = this.inP
|
||||
|
|
||||
|
this.updateScrollbarStyles() |
||||
|
} |
||||
|
|
||||
|
private updateScrollbarStyles(): void { |
||||
|
this.sB = { |
||||
|
width: `${this.sbw}px`, |
||||
|
height: `${this.sbHeight}%`, |
||||
|
position: "absolute", |
||||
|
right: "0", |
||||
|
top: "0", |
||||
|
backgroundColor: "#444444", |
||||
|
borderRadius: "15px", |
||||
} |
} |
||||
|
|
||||
private setupElements(): void { |
this.sBH = { |
||||
this.newPar = document.createElement("div") |
width: `${this.sbw}px`, |
||||
// this.sbContainer = document.createElement("div")
|
height: "100%", |
||||
this.scrollBarHolder = document.createElement("div") |
position: "absolute", |
||||
this.scrollBar = document.createElement("div") |
right: "0", |
||||
this.inP = document.createElement("div") |
top: "0", |
||||
|
backgroundColor: "#ADADAD", |
||||
this.newPar.className = "scrollbot-outer-parent" |
borderRadius: "15px", |
||||
this.scrollBarHolder.className = "scrollbot-scrollbar-holder" |
|
||||
this.scrollBar.className = "scrollbot-scrollbar" |
|
||||
this.inP.className = "scrollbot-inner-parent" |
|
||||
|
|
||||
this.inP.innerHTML = this.parContent |
|
||||
this.newPar.appendChild(this.inP) |
|
||||
this.scrollBarHolder.appendChild(this.scrollBar) |
|
||||
this.newPar.appendChild(this.scrollBarHolder) |
|
||||
this.orgPar.appendChild(this.newPar) |
|
||||
} |
|
||||
|
|
||||
private setupStyles(): void { |
|
||||
this.newPar.style.position = "relative" |
|
||||
this.newPar.style.paddingRight = `${this.sbw}px` |
|
||||
this.newPar.style.zIndex = "9999999" |
|
||||
this.newPar.style.height = "100%" |
|
||||
this.newPar.style.overflow = "hidden" |
|
||||
|
|
||||
this.inP.style.cssText = `height:100%;overflow-y:auto;overflow-x:hidden;padding-right:${ |
|
||||
this.sbw + 20 |
|
||||
}px;width:100%;box-sizing:content-box;` |
|
||||
|
|
||||
this.sbHeight = (this.inP.clientHeight * 100) / this.inP.scrollHeight |
|
||||
// this.scrollElement = this.inP
|
|
||||
|
|
||||
this.updateScrollbarStyles() |
|
||||
} |
} |
||||
|
|
||||
private updateScrollbarStyles(): void { |
Object.assign(this.scrollBar.style, this.sB) |
||||
this.sB = { |
Object.assign(this.scrollBarHolder.style, this.sBH) |
||||
width: `${this.sbw}px`, |
} |
||||
height: `${this.sbHeight}%`, |
|
||||
position: "absolute", |
|
||||
right: "0", |
|
||||
top: "0", |
|
||||
backgroundColor: "#444444", |
|
||||
borderRadius: "15px", |
|
||||
} |
|
||||
|
|
||||
this.sBH = { |
public refresh(): void { |
||||
width: `${this.sbw}px`, |
this.sbHeight = (this.inP.clientHeight * 100) / this.inP.scrollHeight |
||||
height: "100%", |
this.scrollBarHolder.style.display = this.sbHeight >= 100 ? "none" : "block" |
||||
position: "absolute", |
|
||||
right: "0", |
|
||||
top: "0", |
|
||||
backgroundColor: "#ADADAD", |
|
||||
borderRadius: "15px", |
|
||||
} |
|
||||
|
|
||||
Object.assign(this.scrollBar.style, this.sB) |
if (this.inP.scrollHeight > this.inP.clientHeight) { |
||||
Object.assign(this.scrollBarHolder.style, this.sBH) |
this.scrollBar.style.height = this.customHeight ? this.sB.height : `${this.sbHeight}%` |
||||
} |
} |
||||
|
} |
||||
public refresh(): void { |
|
||||
this.sbHeight = (this.inP.clientHeight * 100) / this.inP.scrollHeight |
public destroy(): void { |
||||
this.scrollBarHolder.style.display = this.sbHeight >= 100 ? "none" : "block" |
this.orgPar.innerHTML = this.parContent |
||||
|
this.orgPar.style.overflow = "auto" |
||||
if (this.inP.scrollHeight > this.inP.clientHeight) { |
} |
||||
this.scrollBar.style.height = this.customHeight ? this.sB.height : `${this.sbHeight}%` |
|
||||
} |
private isIE(): number | false { |
||||
|
const userAgent = navigator.userAgent.toLowerCase() |
||||
|
const msie = userAgent.indexOf("msie") |
||||
|
return msie !== -1 ? parseInt(userAgent.split("msie")[1]) : false |
||||
|
} |
||||
|
|
||||
|
public onScroll(callback: () => void): void { |
||||
|
this.onScrollF = callback |
||||
|
} |
||||
|
|
||||
|
private setupEventListeners(): void { |
||||
|
this.setupScrollListener() |
||||
|
this.setupMouseEvents() |
||||
|
} |
||||
|
|
||||
|
private setupScrollListener(): void { |
||||
|
this.inP.addEventListener("scroll", () => { |
||||
|
const scrollPercentage = (this.inP.scrollTop * 100) / this.inP.scrollHeight |
||||
|
const correction = |
||||
|
((this.sbHeight - parseFloat(this.sB.height)) * this.inP.scrollTop) / (this.inP.scrollHeight - this.inP.clientHeight) |
||||
|
|
||||
|
this.scrollBar.style.top = `${scrollPercentage + correction}%` |
||||
|
|
||||
|
if (this.onScrollF) { |
||||
|
this.onScrollF() |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
private setScroll(position: number, duration: number = 500): void { |
||||
|
if (position >= this.inP.scrollHeight - this.inP.clientHeight) { |
||||
|
position = this.inP.scrollHeight - this.inP.clientHeight |
||||
} |
} |
||||
|
|
||||
public destroy(): void { |
const difference = position - this.inP.scrollTop |
||||
this.orgPar.innerHTML = this.parContent |
const perTick = (difference / duration) * 10 |
||||
this.orgPar.style.overflow = "auto" |
|
||||
|
setTimeout(() => { |
||||
|
this.inP.scrollTop += perTick |
||||
|
if (Math.abs(position - this.inP.scrollTop) < 5) return |
||||
|
this.setScroll(position, duration - 10) |
||||
|
}, 10) |
||||
|
} |
||||
|
|
||||
|
private setupMouseEvents(): void { |
||||
|
// 滚动条容器点击事件
|
||||
|
this.scrollBarHolder.onmousedown = (e: MouseEvent) => { |
||||
|
if (e.target !== this.scrollBarHolder) return |
||||
|
const relPos = ((e.pageY - this.scrollBarHolder.getBoundingClientRect().top) * 100) / this.scrollBarHolder.clientHeight |
||||
|
this.setScroll((this.inP.scrollHeight * relPos) / 100, this.scrollSpeed) |
||||
} |
} |
||||
|
|
||||
private isIE(): number | false { |
// 滚动条拖动事件
|
||||
const userAgent = navigator.userAgent.toLowerCase() |
this.scrollBar.onmousedown = (e: MouseEvent) => { |
||||
const msie = userAgent.indexOf("msie") |
this.mdown = true |
||||
return msie !== -1 ? parseInt(userAgent.split("msie")[1]) : false |
this.posCorrection = e.pageY - this.scrollBar.getBoundingClientRect().top |
||||
|
this.btmCorrection = (this.scrollBar.clientHeight * 100) / this.newPar.clientHeight |
||||
|
return false |
||||
} |
} |
||||
|
|
||||
public onScroll(callback: () => void): void { |
// 全局鼠标事件
|
||||
this.onScrollF = callback |
document.onmouseup = () => { |
||||
|
this.mdown = false |
||||
} |
} |
||||
|
|
||||
private setupEventListeners(): void { |
document.onmousemove = (e: MouseEvent) => { |
||||
this.setupScrollListener() |
if (this.mdown) { |
||||
this.setupMouseEvents() |
// 清除文本选择
|
||||
} |
window.getSelection()?.removeAllRanges() |
||||
|
|
||||
private setupScrollListener(): void { |
this.relY = e.pageY - this.newPar.getBoundingClientRect().top |
||||
this.inP.addEventListener("scroll", () => { |
this.pC = ((this.relY - this.posCorrection) * 100) / this.newPar.clientHeight |
||||
const scrollPercentage = (this.inP.scrollTop * 100) / this.inP.scrollHeight |
|
||||
const correction = |
if (this.pC >= 0 && this.pC + this.btmCorrection <= 100) { |
||||
((this.sbHeight - parseFloat(this.sB.height)) * this.inP.scrollTop) / (this.inP.scrollHeight - this.inP.clientHeight) |
this.scrollBar.style.top = `${this.pC}%` |
||||
|
this.inP.scrollTop = |
||||
this.scrollBar.style.top = `${scrollPercentage + correction}%` |
((parseFloat(this.scrollBar.style.top) - |
||||
|
((this.sbHeight - parseFloat(this.sB.height)) * this.inP.scrollTop) / (this.inP.scrollHeight - this.inP.clientHeight)) * |
||||
if (this.onScrollF) { |
this.inP.scrollHeight) / |
||||
this.onScrollF() |
100 |
||||
} |
} else if (this.pC < 0 && parseFloat(this.scrollBar.style.top) > 0) { |
||||
}) |
this.scrollBar.style.top = "0%" |
||||
} |
this.inP.scrollTop = 0 |
||||
|
|
||||
private setScroll(position: number, duration: number = 500): void { |
|
||||
if (position >= this.inP.scrollHeight - this.inP.clientHeight) { |
|
||||
position = this.inP.scrollHeight - this.inP.clientHeight |
|
||||
} |
|
||||
|
|
||||
const difference = position - this.inP.scrollTop |
|
||||
const perTick = (difference / duration) * 10 |
|
||||
|
|
||||
setTimeout(() => { |
|
||||
this.inP.scrollTop += perTick |
|
||||
if (Math.abs(position - this.inP.scrollTop) < 5) return |
|
||||
this.setScroll(position, duration - 10) |
|
||||
}, 10) |
|
||||
} |
|
||||
|
|
||||
private setupMouseEvents(): void { |
|
||||
// 滚动条容器点击事件
|
|
||||
this.scrollBarHolder.onmousedown = (e: MouseEvent) => { |
|
||||
if (e.target !== this.scrollBarHolder) return |
|
||||
const relPos = ((e.pageY - this.scrollBarHolder.getBoundingClientRect().top) * 100) / this.scrollBarHolder.clientHeight |
|
||||
this.setScroll((this.inP.scrollHeight * relPos) / 100, this.scrollSpeed) |
|
||||
} |
|
||||
|
|
||||
// 滚动条拖动事件
|
|
||||
this.scrollBar.onmousedown = (e: MouseEvent) => { |
|
||||
this.mdown = true |
|
||||
this.posCorrection = e.pageY - this.scrollBar.getBoundingClientRect().top |
|
||||
this.btmCorrection = (this.scrollBar.clientHeight * 100) / this.newPar.clientHeight |
|
||||
return false |
|
||||
} |
} |
||||
|
|
||||
// 全局鼠标事件
|
if (this.onScrollF) { |
||||
document.onmouseup = () => { |
this.onScrollF() |
||||
this.mdown = false |
|
||||
} |
|
||||
|
|
||||
document.onmousemove = (e: MouseEvent) => { |
|
||||
if (this.mdown) { |
|
||||
// 清除文本选择
|
|
||||
window.getSelection()?.removeAllRanges() |
|
||||
|
|
||||
this.relY = e.pageY - this.newPar.getBoundingClientRect().top |
|
||||
this.pC = ((this.relY - this.posCorrection) * 100) / this.newPar.clientHeight |
|
||||
|
|
||||
if (this.pC >= 0 && this.pC + this.btmCorrection <= 100) { |
|
||||
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.inP.scrollHeight) / |
|
||||
100 |
|
||||
} else if (this.pC < 0 && parseFloat(this.scrollBar.style.top) > 0) { |
|
||||
this.scrollBar.style.top = "0%" |
|
||||
this.inP.scrollTop = 0 |
|
||||
} |
|
||||
|
|
||||
if (this.onScrollF) { |
|
||||
this.onScrollF() |
|
||||
} |
|
||||
} |
|
||||
return false |
|
||||
} |
} |
||||
|
} |
||||
|
return false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public setStyle(scrollbar?: ScrollStyle, scrollbarHolder?: ScrollStyle): Scrollbot { |
||||
|
if (scrollbar) { |
||||
|
scrollbar.width = `${this.sbw}px` |
||||
|
if ("height" in scrollbar) { |
||||
|
this.customHeight = true |
||||
|
scrollbar.height = `${(parseFloat(scrollbar.height) * 100) / this.newPar.clientHeight}%` |
||||
|
} |
||||
|
Object.assign(this.sB, scrollbar) |
||||
|
Object.assign(this.scrollBar.style, scrollbar) |
||||
} |
} |
||||
|
|
||||
public setStyle(scrollbar?: ScrollStyle, scrollbarHolder?: ScrollStyle): Scrollbot { |
if (scrollbarHolder) { |
||||
if (scrollbar) { |
scrollbarHolder.width = `${this.sbw}px` |
||||
scrollbar.width = `${this.sbw}px` |
Object.assign(this.sBH, scrollbarHolder) |
||||
if ("height" in scrollbar) { |
Object.assign(this.scrollBarHolder.style, scrollbarHolder) |
||||
this.customHeight = true |
|
||||
scrollbar.height = `${(parseFloat(scrollbar.height) * 100) / this.newPar.clientHeight}%` |
|
||||
} |
|
||||
Object.assign(this.sB, scrollbar) |
|
||||
Object.assign(this.scrollBar.style, scrollbar) |
|
||||
} |
|
||||
|
|
||||
if (scrollbarHolder) { |
|
||||
scrollbarHolder.width = `${this.sbw}px` |
|
||||
Object.assign(this.sBH, scrollbarHolder) |
|
||||
Object.assign(this.scrollBarHolder.style, scrollbarHolder) |
|
||||
} |
|
||||
|
|
||||
return this |
|
||||
} |
} |
||||
|
|
||||
|
return this |
||||
|
} |
||||
} |
} |
||||
|
|
||||
export default Scrollbot |
export default Scrollbot |
||||
|
@ -1,44 +1,45 @@ |
|||||
*, |
*, |
||||
*::before, |
*::before, |
||||
*::after { |
*::after { |
||||
box-sizing: border-box; |
box-sizing: border-box; |
||||
margin: 0; |
margin: 0; |
||||
font-weight: normal; |
font-weight: normal; |
||||
} |
} |
||||
|
|
||||
html { |
html { |
||||
--text-normal: #6b6b6b; |
--text-normal: #6b6b6b; |
||||
--text-hover: #000000; |
--text-hover: #000000; |
||||
height: 100%; |
height: 100%; |
||||
|
color: var(--text-normal); |
||||
} |
} |
||||
|
|
||||
body { |
body { |
||||
--at-apply: text-normal; |
--at-apply: text-normal; |
||||
height: 100%; |
height: 100%; |
||||
} |
} |
||||
|
|
||||
#app { |
#app { |
||||
height: 100%; |
height: 100%; |
||||
} |
} |
||||
|
|
||||
* { |
* { |
||||
user-select: none; |
user-select: none; |
||||
outline: none; |
outline: none; |
||||
} |
} |
||||
|
|
||||
.simplebar-scrollbar::before { |
.simplebar-scrollbar::before { |
||||
background-color: #bdbdbd; |
background-color: #bdbdbd; |
||||
border-radius: 0; |
border-radius: 0; |
||||
left: 0; |
left: 0; |
||||
right: 0; |
right: 0; |
||||
bottom: 0; |
bottom: 0; |
||||
top: 0; |
top: 0; |
||||
} |
} |
||||
|
|
||||
.simplebar-hover .simplebar-scrollbar::before { |
.simplebar-hover .simplebar-scrollbar::before { |
||||
background-color: #909090; |
background-color: #909090; |
||||
} |
} |
||||
|
|
||||
.simplebar-wrapper:hover ~ .simplebar-track > .simplebar-scrollbar:before { |
.simplebar-wrapper:hover ~ .simplebar-track > .simplebar-scrollbar:before { |
||||
opacity: 0.5 !important; |
opacity: 0.5 !important; |
||||
} |
} |
||||
|
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue