diff --git a/.env b/.env new file mode 100644 index 0000000..e69de29 diff --git a/.npmrc b/.npmrc index 34862ff..3b4920e 100644 --- a/.npmrc +++ b/.npmrc @@ -1,2 +1,3 @@ electron_mirror=https://npmmirror.com/mirrors/electron/ electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/ +link-workspace-packages=true diff --git a/.vscode/settings.json b/.vscode/settings.json index 4c05394..20951e4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,6 @@ { "[typescript]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" + "editor.defaultFormatter": "vscode.typescript-language-features" }, "[javascript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 470c4a0..f5ce31b 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -4,6 +4,12 @@ import vue from '@vitejs/plugin-vue' export default defineConfig({ main: { + resolve: { + alias: { + main: resolve('src/main'), + "@res": resolve("resources"), + } + }, plugins: [externalizeDepsPlugin()] }, preload: { @@ -15,6 +21,14 @@ export default defineConfig({ '@renderer': resolve('src/renderer/src') } }, - plugins: [vue()] + plugins: [vue()], + build: { + rollupOptions: { + input: { + main: resolve(__dirname, "./src/renderer/index.html"), + about: resolve(__dirname, "./src/renderer/about.html"), + }, + }, + }, } }) diff --git a/package.json b/package.json index 3d9cce3..0c98799 100644 --- a/package.json +++ b/package.json @@ -25,22 +25,28 @@ "@electron-toolkit/utils": "^4.0.0", "@trpc/client": "^10.45.2", "@trpc/server": "^10.45.2", - "electron-trpc": "^0.7.1", "electron-updater": "^6.3.9", + "superjson": "^2.2.5", "zod": "^4.1.12" }, "devDependencies": { "@electron-toolkit/eslint-config-prettier": "3.0.0", "@electron-toolkit/eslint-config-ts": "^3.1.0", "@electron-toolkit/tsconfig": "^2.0.0", + "@types/lodash-es": "^4.17.12", "@types/node": "^22.18.6", "@vitejs/plugin-vue": "^6.0.1", + "config": "workspace:^", "electron": "^38.1.2", "electron-builder": "^25.1.8", + "electron-trpc": "^0.7.1", "electron-vite": "^4.0.1", "eslint": "^9.36.0", "eslint-plugin-vue": "^10.4.0", + "lodash-es": "^4.17.21", + "logger": "workspace:^", "prettier": "^3.6.2", + "trpc": "workspace:^", "typescript": "^5.9.2", "vite": "^7.1.6", "vue": "^3.5.21", diff --git a/packages/config/app_config.json b/packages/config/app_config.json new file mode 100644 index 0000000..fa54b1f --- /dev/null +++ b/packages/config/app_config.json @@ -0,0 +1,11 @@ +{ + "storagePath": "$storagePath$", + "language": "zh", + "dev:debug": 2, + "common.theme": "auto", + "update.hoturl": "https://alist.xieyaxin.top/d/%E8%B5%84%E6%BA%90/%E6%B5%8B%E8%AF%95%E6%96%87%E4%BB%B6.zip?sign=eqy35CR-J1SOQZz0iUN2P3B0BiyZPdYH0362nLXbUhE=:1749085071", + "update.repo": "wood-desktop", + "update.owner": "npmrun", + "update.allowDowngrade": false, + "update.allowPrerelease": false +} diff --git a/packages/config/exe_config.json b/packages/config/exe_config.json new file mode 100644 index 0000000..14330f3 --- /dev/null +++ b/packages/config/exe_config.json @@ -0,0 +1,16 @@ +{ + "name": "zephyr", + "appId": "com.zephyr.app", + "win": { + "executableName": "zephyr" + }, + "linux": { + "target": ["AppImage", "snap", "deb"], + "maintainer": "electronjs.org", + "category": "Utility" + }, + "publish": { + "provider": "generic", + "url": "https://example.com/auto-updates" + } +} diff --git a/packages/config/index.ts b/packages/config/index.ts new file mode 100644 index 0000000..0888edd --- /dev/null +++ b/packages/config/index.ts @@ -0,0 +1,18 @@ +import AppConfig from "./app_config.json" +import ExeConfig from "./exe_config.json" + +// 定义主题类型 +type ThemeType = "light" | "dark" | "auto" +// 定义语言类型 +type LanguageType = "zh" | "en" + +export type IConfig = typeof AppConfig & + Pick, "common.theme"> & { + language: LanguageType + "common.theme": ThemeType + } + +export default { + appConfig: AppConfig, + exeConfig: ExeConfig, +} diff --git a/packages/config/package.json b/packages/config/package.json new file mode 100644 index 0000000..2511d8a --- /dev/null +++ b/packages/config/package.json @@ -0,0 +1,12 @@ +{ + "name": "config", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC" +} diff --git a/packages/logger/index.ts b/packages/logger/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/packages/logger/package.json b/packages/logger/package.json new file mode 100644 index 0000000..04dba78 --- /dev/null +++ b/packages/logger/package.json @@ -0,0 +1,12 @@ +{ + "name": "logger", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC" +} diff --git a/packages/trpc/common/app.ts b/packages/trpc/common/app.ts new file mode 100644 index 0000000..a582e45 --- /dev/null +++ b/packages/trpc/common/app.ts @@ -0,0 +1,8 @@ +import { mergeRouters } from './trpc'; + +import { userRouter } from './routers/user'; +import { postRouter } from './routers/post'; + +export const appRouter = mergeRouters(userRouter, postRouter) + +export type AppRouter = typeof appRouter; \ No newline at end of file diff --git a/packages/trpc/common/index.ts b/packages/trpc/common/index.ts new file mode 100644 index 0000000..6f95be5 --- /dev/null +++ b/packages/trpc/common/index.ts @@ -0,0 +1,3 @@ +import type { AppRouter } from './app'; + +export { AppRouter }; \ No newline at end of file diff --git a/packages/trpc/common/routers/post.ts b/packages/trpc/common/routers/post.ts new file mode 100644 index 0000000..09ec489 --- /dev/null +++ b/packages/trpc/common/routers/post.ts @@ -0,0 +1,19 @@ +import { router, publicProcedure } from '../trpc'; +import { z } from 'zod'; + +export const postRouter = router({ + postCreate: publicProcedure + .input( + z.object({ + title: z.string(), + }), + ) + .mutation(() => { + // const { input } = opts; + // [...] + }), + postList: publicProcedure.query(() => { + // ... + return []; + }), +}); \ No newline at end of file diff --git a/packages/trpc/common/routers/user.ts b/packages/trpc/common/routers/user.ts new file mode 100644 index 0000000..4f1178e --- /dev/null +++ b/packages/trpc/common/routers/user.ts @@ -0,0 +1,38 @@ +import { router, publicProcedure } from '../trpc'; +import { observable } from '@trpc/server/observable'; +import { EventEmitter } from 'events'; +import { WindowManager } from 'main/modules/window-manager'; + +const ee = new EventEmitter(); + +export const userRouter = router({ + userList: publicProcedure.query(() => { + // [..] + ee.emit('greeting', `Greeted`) + return [ + { + id: 1, + name: 'John Doe', + windowsLength: WindowManager.getWindowsLength(), + }, + { + id: 2, + name: 'Jane Doe', + windowsLength: WindowManager.getWindowsLength(), + }, + ]; + }), + subscribeGreeting: publicProcedure.subscription(() => { + return observable<{ text: string }>((emit) => { + function onGreet(text: string) { + emit.next({ text }); + } + + ee.on('greeting', onGreet); + + return () => { + ee.off('greeting', onGreet); + }; + }); + }), +}); \ No newline at end of file diff --git a/packages/trpc/common/trpc.ts b/packages/trpc/common/trpc.ts new file mode 100644 index 0000000..c852ac8 --- /dev/null +++ b/packages/trpc/common/trpc.ts @@ -0,0 +1,8 @@ +import { initTRPC } from '@trpc/server'; +import superjson from 'superjson'; + +const t = initTRPC.create({ transformer: superjson, isServer: true }); + +export const mergeRouters = t.mergeRouters; +export const router = t.router; +export const publicProcedure = t.procedure; \ No newline at end of file diff --git a/packages/trpc/main/index.ts b/packages/trpc/main/index.ts new file mode 100644 index 0000000..5e6caa2 --- /dev/null +++ b/packages/trpc/main/index.ts @@ -0,0 +1,11 @@ +import { appRouter } from "../common/app"; +import { BrowserWindow } from "electron"; +import { createIPCHandler } from 'electron-trpc/main'; + +const handler = createIPCHandler({ router: appRouter, windows: []}); + +export function trpcBindWindows(windows: BrowserWindow[]) { + windows.forEach(window => { + handler.attachWindow(window); + }); +} \ No newline at end of file diff --git a/packages/trpc/package.json b/packages/trpc/package.json new file mode 100644 index 0000000..5501719 --- /dev/null +++ b/packages/trpc/package.json @@ -0,0 +1,12 @@ +{ + "name": "trpc", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC" +} diff --git a/packages/trpc/preload/index.ts b/packages/trpc/preload/index.ts new file mode 100644 index 0000000..fe8e82a --- /dev/null +++ b/packages/trpc/preload/index.ts @@ -0,0 +1,5 @@ +import { exposeElectronTRPC } from 'electron-trpc/main'; + +process.once('loaded', async () => { + exposeElectronTRPC(); +}); \ No newline at end of file diff --git a/src/renderer/src/trpc.ts b/packages/trpc/renderer/index.ts similarity index 64% rename from src/renderer/src/trpc.ts rename to packages/trpc/renderer/index.ts index 4e9b863..758d7d3 100644 --- a/src/renderer/src/trpc.ts +++ b/packages/trpc/renderer/index.ts @@ -1,7 +1,9 @@ import { createTRPCProxyClient } from '@trpc/client'; import { ipcLink } from 'electron-trpc/renderer'; -import type { AppRouter } from '../../main/api'; +import superjson from 'superjson'; +import type { AppRouter } from '../common'; export const client = createTRPCProxyClient({ links: [ipcLink()], + transformer: superjson, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 900d746..89e63f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,12 +20,12 @@ importers: '@trpc/server': specifier: ^10.45.2 version: 10.45.2 - electron-trpc: - specifier: ^0.7.1 - version: 0.7.1(@trpc/client@10.45.2(@trpc/server@10.45.2))(@trpc/server@10.45.2)(electron@38.4.0) electron-updater: specifier: ^6.3.9 version: 6.6.2 + superjson: + specifier: ^2.2.5 + version: 2.2.5 zod: specifier: ^4.1.12 version: 4.1.12 @@ -39,18 +39,27 @@ importers: '@electron-toolkit/tsconfig': specifier: ^2.0.0 version: 2.0.0(@types/node@22.18.12) + '@types/lodash-es': + specifier: ^4.17.12 + version: 4.17.12 '@types/node': specifier: ^22.18.6 version: 22.18.12 '@vitejs/plugin-vue': specifier: ^6.0.1 version: 6.0.1(vite@7.1.12(@types/node@22.18.12))(vue@3.5.22(typescript@5.9.3)) + config: + specifier: workspace:^ + version: link:packages/config electron: specifier: ^38.1.2 version: 38.4.0 electron-builder: specifier: ^25.1.8 version: 25.1.8(electron-builder-squirrel-windows@25.1.8) + electron-trpc: + specifier: ^0.7.1 + version: 0.7.1(@trpc/client@10.45.2(@trpc/server@10.45.2))(@trpc/server@10.45.2)(electron@38.4.0) electron-vite: specifier: ^4.0.1 version: 4.0.1(vite@7.1.12(@types/node@22.18.12)) @@ -60,9 +69,18 @@ importers: eslint-plugin-vue: specifier: ^10.4.0 version: 10.5.1(@typescript-eslint/parser@8.46.2(eslint@9.38.0)(typescript@5.9.3))(eslint@9.38.0)(vue-eslint-parser@10.2.0(eslint@9.38.0)) + lodash-es: + specifier: ^4.17.21 + version: 4.17.21 + logger: + specifier: workspace:^ + version: link:packages/logger prettier: specifier: ^3.6.2 version: 3.6.2 + trpc: + specifier: workspace:^ + version: link:packages/trpc typescript: specifier: ^5.9.2 version: 5.9.3 @@ -79,6 +97,12 @@ importers: specifier: ^3.0.7 version: 3.1.2(typescript@5.9.3) + packages/config: {} + + packages/logger: {} + + packages/trpc: {} + packages: 7zip-bin@5.2.0: @@ -654,6 +678,12 @@ packages: '@types/keyv@3.1.4': resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} + '@types/lodash-es@4.17.12': + resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} + + '@types/lodash@4.17.20': + resolution: {integrity: sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==} + '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} @@ -1068,6 +1098,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + copy-anything@4.0.5: + resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} + engines: {node: '>=18'} + core-util-is@1.0.2: resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} @@ -1661,6 +1695,10 @@ packages: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} + is-what@5.5.0: + resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} + engines: {node: '>=18'} + isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} @@ -1736,6 +1774,9 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash-es@4.17.21: + resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} + lodash.defaults@4.2.0: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} @@ -2269,6 +2310,10 @@ packages: resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} engines: {node: '>= 8.0'} + superjson@2.2.5: + resolution: {integrity: sha512-zWPTX96LVsA/eVYnqOM2+ofcdPqdS1dAF1LN4TS2/MWuUpfitd9ctTa87wt4xrYnZnkLtS69xpBdSxVBP5Rm6w==} + engines: {node: '>=16'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -3039,6 +3084,12 @@ snapshots: dependencies: '@types/node': 22.18.12 + '@types/lodash-es@4.17.12': + dependencies: + '@types/lodash': 4.17.20 + + '@types/lodash@4.17.20': {} + '@types/ms@2.1.0': {} '@types/node@22.18.12': @@ -3596,6 +3647,10 @@ snapshots: convert-source-map@2.0.0: {} + copy-anything@4.0.5: + dependencies: + is-what: 5.5.0 + core-util-is@1.0.2: optional: true @@ -4308,6 +4363,8 @@ snapshots: is-unicode-supported@0.1.0: {} + is-what@5.5.0: {} + isarray@1.0.0: {} isbinaryfile@4.0.10: {} @@ -4376,6 +4433,8 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash-es@4.17.21: {} + lodash.defaults@4.2.0: {} lodash.difference@4.5.0: {} @@ -4916,6 +4975,10 @@ snapshots: transitivePeerDependencies: - supports-color + superjson@2.2.5: + dependencies: + copy-anything: 4.0.5 + supports-color@7.2.0: dependencies: has-flag: 4.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..dee51e9 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - "packages/*" diff --git a/src/main/api.ts b/src/main/api.ts deleted file mode 100644 index b9fb465..0000000 --- a/src/main/api.ts +++ /dev/null @@ -1,34 +0,0 @@ -import z from 'zod'; -import { initTRPC } from '@trpc/server'; -import { observable } from '@trpc/server/observable'; -import { EventEmitter } from 'events'; - -const ee = new EventEmitter(); - -const t = initTRPC.create({ isServer: true }); - -export const router = t.router({ - greeting: t.procedure.input(z.object({ name: z.string() })).query((req) => { - const { input } = req; - - ee.emit('greeting', `Greeted ${input.name}`); - return { - text: `Hello ${input.name}` as const, - }; - }), - subscript: t.procedure.subscription(() => { - return observable((emit) => { - function onGreet(text: string) { - emit.next({ text }); - } - - ee.on('greeting', onGreet); - - return () => { - ee.off('greeting', onGreet); - }; - }); - }), -}); - -export type AppRouter = typeof router; \ No newline at end of file diff --git a/src/main/env.d.ts b/src/main/env.d.ts new file mode 100644 index 0000000..929f119 --- /dev/null +++ b/src/main/env.d.ts @@ -0,0 +1,9 @@ +/// + +interface ImportMetaEnv { + // readonly MAIN_VITE_DEBUG: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} \ No newline at end of file diff --git a/src/main/index.ts b/src/main/index.ts index ebbfe02..9d4fdc4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,76 +1,44 @@ -import { app, shell, BrowserWindow, ipcMain } from 'electron' -import { join } from 'path' -import { electronApp, optimizer, is } from '@electron-toolkit/utils' -import icon from '../../resources/icon.png?asset' -import { createIPCHandler } from 'electron-trpc/main'; -import { router } from './api'; +import { app, ipcMain, nativeTheme } from 'electron' +import config from 'config' +import { electronApp, } from '@electron-toolkit/utils' +import WindowManager from './modules/window-manager'; +import icon from "@res/icon.png?asset" +import { getFileUrl, getPreloadUrl } from './utils/file'; -function createWindow(): void { - // Create the browser window. - const mainWindow = new BrowserWindow({ - width: 900, - height: 670, - show: false, - autoHideMenuBar: true, - ...(process.platform === 'linux' ? { icon } : {}), - webPreferences: { - preload: join(__dirname, '../preload/index.js'), - sandbox: false - } - }) - createIPCHandler({ router, windows: [mainWindow] }); - mainWindow.on('ready-to-show', () => { - mainWindow.show() - }) - - mainWindow.webContents.setWindowOpenHandler((details) => { - shell.openExternal(details.url) - return { action: 'deny' } - }) - - // HMR for renderer base on electron-vite cli. - // Load the remote URL for development or the local html file for production. - if (is.dev && process.env['ELECTRON_RENDERER_URL']) { - mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) - } else { - mainWindow.loadFile(join(__dirname, '../renderer/index.html')) - } -} +app.commandLine.appendSwitch("wm-window-animations-disabled") +app.disableHardwareAcceleration() -// This method will be called when Electron has finished -// initialization and is ready to create browser windows. -// Some APIs can only be used after this event occurs. app.whenReady().then(() => { - // Set app user model id for windows - electronApp.setAppUserModelId('com.electron') - - // Default open or close DevTools by F12 in development - // and ignore CommandOrControl + R in production. - // see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils - app.on('browser-window-created', (_, window) => { - optimizer.watchWindowShortcuts(window) - }) - - // IPC test - ipcMain.on('ping', () => console.log('pong')) - - createWindow() - app.on('activate', function () { - // On macOS it's common to re-create a window in the app when the - // dock icon is clicked and there are no other windows open. - if (BrowserWindow.getAllWindows().length === 0) createWindow() + electronApp.setAppUserModelId(config.exeConfig.appId) + nativeTheme.themeSource = "system" + + WindowManager.showMainWindow() + + ipcMain.on('ping', () => { + 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: { + preload: getPreloadUrl("index"), + devTools: false, + sandbox: false, + nodeIntegration: false, + contextIsolation: true, + }, + }, + }) }) }) - -// Quit when all windows are closed, except on macOS. There, it's common -// for applications and their menu bar to stay active until the user quits -// explicitly with Cmd + Q. -app.on('window-all-closed', () => { - if (process.platform !== 'darwin') { - app.quit() - } -}) - -// In this file you can include the rest of your app's specific main process -// code. You can also put them in separate files and require them here. diff --git a/src/main/modules/window-manager/index.ts b/src/main/modules/window-manager/index.ts new file mode 100644 index 0000000..c9ace3d --- /dev/null +++ b/src/main/modules/window-manager/index.ts @@ -0,0 +1,359 @@ +import { BrowserWindow, app, dialog } from "electron" +import { cloneDeep, merge } from "lodash-es" +import { defaultConfig, defaultWindowConfig, getWindowsMap, IConfig, Param } from "./windowsMap" +import { optimizer } from "@electron-toolkit/utils" +import { BaseSingleton } from "../../utils/base/base-singleton" +import { trpcBindWindows } from 'trpc/main'; + +declare module "electron" { + interface BrowserWindow { + $$forceClose?: boolean + $$lastChoice?: number + $$opts?: Param + } +} + +class WindowManagerClass extends BaseSingleton { + init(): void { + this.isMainShowReady = new Promise(resolve => { + this.isMainShowResolve = resolve + }) + /** + * 当应用被激活时触发 + */ + app.on("activate", () => { + this.showMainWindow() + }) + // Default open or close DevTools by F12 in development + // and ignore CommandOrControl + R in production. + // see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils + app.on("browser-window-created", (_, window) => { + optimizer.watchWindowShortcuts(window) + }) + + /** + * 应用程序开始关闭时回调,可以通过event.preventDefault()阻止,以下两点需要注意: + * 1. 如果是autoUpdater.quitAndInstall()关闭的,那么会所有窗口关闭,并且在close事件之后执行 + * 2. 关机,重启,用户退出时不会触发 + */ + app.on("before-quit", (event: Electron.Event) => { + const mainWin = this.get(this.mainInfo.name) + if (!mainWin || (mainWin && mainWin?.$$forceClose)) { + // app.exit() + } else { + event.preventDefault() + } + }) + + app.on("window-all-closed", () => { + if (process.platform !== "darwin") { + app.quit() + } + }) + } + + globalChioce: number = -1 + #showWin(info: Param) { + if (this.#windows.length >= 6) { + dialog.showErrorBox("错误", "窗口数量超出限制") + return + } + if (!info.name) { + dialog.showErrorBox("错误", "窗口未指定唯一key") + return + } + const index = this.findIndex(info.name) + if (index === -1) { + this.#windows.push(this.#add(info)) + } else { + if (this.#windows[index].isDestroyed()) { + this.#windows[index] = this.#add(info) + } else { + if (info.url && info.loadURLInSameWin) { + this.#windows[index].loadURL(info.url) + } + this.#windows[index].show() + } + } + } + + showMainWindow() { + this.#showWin(this.mainInfo) + this.isMainShowResolve() + } + + private isMainShowResolve + private isMainShowReady + async waitMainShowReady() { + await this.isMainShowReady + } + + createWindow(name: string, opts?: Partial) { + const info = opts as Param + info.name = name + if (!info.ignoreEmptyUrl && !info.url) { + dialog.showErrorBox("错误", name + "窗口未提供url") + return + } + this.#showWin(info as Param) + } + + showWindow(name: string, opts?: Partial) { + let have = false + for (const key in this.#urlMap) { + const info = this.#urlMap[key] + if (new RegExp(key).test(name)) { + opts && merge(info, opts) + info.name = name + if (!info.ignoreEmptyUrl && !info.url) { + dialog.showErrorBox("错误", name + "窗口未提供url") + return + } + this.#showWin(info as Param) + have = true + } + } + if (!have) { + dialog.showErrorBox("错误", name + "窗口未创建成功") + return + } + } + + #urlMap = getWindowsMap() + + getWndows() { + return this.#windows + } + + length() { + return this.#windows.length + } + + public get mainInfo() { + return this.#urlMap["main"] as Param + } + + #windows: BrowserWindow[] = [] + + #defaultConfig: IConfig = defaultConfig + + #add(config: Param) { + const curConfig = cloneDeep(this.#defaultConfig ?? {}) as Omit & { name: string } + for (const key in config) { + if (Object.prototype.hasOwnProperty.call(config, key)) { + const value = config[key] + // if (Reflect.has(curConfig, key)) { + curConfig[key] = value + // } + } + } + const privateConfig = merge(curConfig.overideWindowOpts ? {} : cloneDeep(defaultWindowConfig), curConfig.windowOpts ?? {}) + let parentWindow + if (typeof privateConfig.parent === "string") { + parentWindow = this.get(privateConfig.parent) + } + if (parentWindow) { + privateConfig.parent = parentWindow + } + const browserWin = new BrowserWindow(privateConfig) + browserWin.webContents.setWindowOpenHandler(() => { + if (curConfig.denyWindowOpen) { + return { action: "deny" } + } + return { action: "allow" } + }) + // @ts-ignore 不需要解释为啥 + browserWin.webContents.$$senderName = curConfig.name + browserWin.$$forceClose = false + browserWin.$$lastChoice = -1 + browserWin.on("close", (event: any) => { + if (this.globalChioce === 1) { + this.#onClose(curConfig.name) + return + } + if (!curConfig.confrimWindowClose) { + this.#onClose(curConfig.name) + return + } + // eslint-disable-next-line @typescript-eslint/no-this-alias + const that = this + function justQuit() { + browserWin.$$lastChoice = 1 + // app.quit() + // 不要用quit();试了会弹两次 + browserWin.$$forceClose = true + if (curConfig.name === that.mainInfo.name) { + that.globalChioce = 1 + app.quit() // exit()直接关闭客户端,不会执行quit(); + } else { + that.delete(curConfig.name) + } + } + if (browserWin.$$forceClose) { + that.delete(curConfig.name) + app.quit() + } else { + let choice = -1 + if (browserWin && browserWin!.$$lastChoice !== undefined && browserWin.$$lastChoice >= 0) { + choice = browserWin.$$lastChoice + } else { + choice = dialog.showMessageBoxSync(browserWin, { + type: "info", + title: curConfig.confrimWindowCloseText?.title ?? "确认关闭", + defaultId: curConfig.confrimWindowCloseText?.defaultId ?? 0, + cancelId: curConfig.confrimWindowCloseText?.cancelId ?? 0, + message: curConfig.confrimWindowCloseText?.message ?? "", + buttons: curConfig.confrimWindowCloseText?.buttons ?? ["确定", "取消"], + }) + } + if (choice === 1) { + justQuit() + } else { + event && event.preventDefault() + } + } + }) + browserWin.$$opts = curConfig + // 在此注册窗口 + browserWin.webContents.addListener("did-finish-load", () => { + browserWin.webContents.executeJavaScript(`window._global=${JSON.stringify({ name: curConfig.name })};`) + browserWin.webContents.send("bind-window-manager", curConfig.name) + }) + // https://www.electronjs.org/zh/docs/latest/tutorial/security#12-%E5%88%9B%E5%BB%BAwebview%E5%89%8D%E7%A1%AE%E8%AE%A4%E5%85%B6%E9%80%89%E9%A1%B9 + // browserWin.webContents.on("will-attach-webview", (_event, webPreferences) => { + // if (webPreferences.preload !== path.resolve(app.getAppPath(), "webview.js")) { + // // 如果未使用,则删除预加载脚本或验证其位置是否合法 + // delete webPreferences.preload + // } + + // // 禁用 Node.js 集成 + // webPreferences.nodeIntegration = false + + // // 验证正在加载的 URL + // // if (!params.src.startsWith('https://example.com/')) { + // // event.preventDefault() + // // } + // }) + if (curConfig.type === "info") { + // 隐藏菜单 + browserWin.setMenuBarVisibility(false) + } + if (curConfig.url) { + browserWin.loadURL(curConfig.url) + // logger.debug(`当前窗口网址:${curConfig.url}`) + } + if (curConfig.windowOpts?.show === false) { + if (curConfig.url) { + browserWin.once("ready-to-show", () => { + browserWin?.show() + }) + } else { + browserWin?.show() + } + } + trpcBindWindows([browserWin]) + return browserWin + } + + getWindowsLength() { + return this.#windows.length + } + + #onClose(name: string) { + for (let i = this.#windows.length - 1; i >= 0; i--) { + const win = this.#windows[i] + if (name === win.$$opts!.name) { + win.destroy() + this.#windows.splice(i, 1) + } + } + } + + get(name: string) { + return this.#windows.find(v => { + return v.$$opts!.name === name + }) + } + + getFocusWindow() { + const mainWindow = this.getMainWindow() + if (mainWindow?.isFocused()) { + return mainWindow + } + for (let i = 0; i < this.#windows.length; i++) { + const win = this.#windows[i] + if (win.isFocused()) { + return win + } + } + return + } + + getMainWindow() { + return this.#windows.find(v => { + return v.$$opts!.name === this.mainInfo.name + }) + } + + close(name: string | RegExp) { + const indexList = this.findAllIndex(name) + for (let i = indexList.length - 1; i >= 0; i--) { + const index = indexList[i] + const win = this.#windows[index] + win.close() + } + } + + delete(name: string | RegExp) { + const indexList = this.findAllIndex(name) + for (let i = indexList.length - 1; i >= 0; i--) { + const index = indexList[i] + this.#windows.splice(index, 1) + } + } + + findIndex(name: string | RegExp) { + const index = this.#windows.findIndex(v => { + if (typeof name === "string") { + return v.$$opts!.name === name + } else { + return name.test(v.$$opts!.name) + } + }) + return index + } + + findAllIndex(name: string | RegExp) { + const result: number[] = [] + for (let i = 0; i < this.#windows.length; i++) { + const win = this.#windows[i] + if (typeof name === "string" && win.$$opts!.name === name) { + result.push(i) + } else if (typeof name !== "string" && name.test(win.$$opts!.name)) { + result.push(i) + } + } + return result + } + + // show(name: string | RegExp) { + // let indexList = this.findAllIndex(name) + // if (!!indexList.length) { + // for (let i = 0; i < indexList.length; i++) { + // const index = indexList[i]; + // const win = this.#windows[index] + // if (win.isDestroyed()) { + // this.#windows[index] = this.#add(win.$$opts) + // } else { + // win.show() + // } + // } + // } else { + // console.warn("该窗口不存在") + // } + // } +} + +const WindowManager = WindowManagerClass.getInstance() +export { WindowManager } +export default WindowManager \ No newline at end of file diff --git a/src/main/modules/window-manager/windowsMap.ts b/src/main/modules/window-manager/windowsMap.ts new file mode 100644 index 0000000..3283c61 --- /dev/null +++ b/src/main/modules/window-manager/windowsMap.ts @@ -0,0 +1,107 @@ +import Config from "config" +import { BrowserWindowConstructorOptions } from "electron" +import icon from "@res/icon.png?asset" +import { getFileUrl, getPreloadUrl } from "../../utils/file" + +export type Param = Partial & Required> + +export interface IConfig { + name?: string + url?: string + loadURLInSameWin?: boolean + type?: "info" + windowOpts?: BrowserWindowConstructorOptions + overideWindowOpts?: boolean + ignoreEmptyUrl?: boolean + denyWindowOpen?: boolean + confrimWindowClose?: boolean + confrimWindowCloseText?: { + title: string + message: string + buttons: string[] + defaultId: number + cancelId: number + } +} + +export const defaultConfig: IConfig = { + denyWindowOpen: true, +} + +export const defaultWindowConfig = { + height: 600, + useContentSize: true, + width: 800, + show: true, + resizable: true, + minWidth: 900, + minHeight: 600, + frame: true, + transparent: false, + alwaysOnTop: false, + webPreferences: {}, +} + +export function getWindowsMap(): Record { + return { + main: { + name: "main", + url: getFileUrl("index.html"), + confrimWindowClose: true, + confrimWindowCloseText: { + title: Config.exeConfig.name, + defaultId: 0, + cancelId: 0, + message: "确定要关闭吗?", + buttons: ["没事", "直接退出"], + }, + windowOpts: { + show: false, + titleBarStyle: "hidden", + titleBarOverlay: true, + icon: icon, + ...(process.platform === "linux" ? { icon } : {}), + webPreferences: { + webviewTag: false, + preload: getPreloadUrl("index"), + nodeIntegration: false, + contextIsolation: true, + }, + }, + }, + _blank: { + overideWindowOpts: false, + confrimWindowClose: true, + confrimWindowCloseText: { + title: Config.exeConfig.name, + defaultId: 0, + cancelId: 0, + message: "确定要关闭吗?", + buttons: ["没事", "直接退出"], + }, + type: "info", + windowOpts: { + height: 600, + useContentSize: true, + width: 800, + show: true, + resizable: true, + minWidth: 900, + minHeight: 600, + frame: true, + transparent: false, + alwaysOnTop: false, + icon: icon, + title: Config.exeConfig.name, + webPreferences: { + devTools: false, + sandbox: true, + nodeIntegration: false, + contextIsolation: true, + webviewTag: false, + preload: undefined, + }, + }, + }, + } +} diff --git a/src/main/utils/base/base-singleton.ts b/src/main/utils/base/base-singleton.ts new file mode 100644 index 0000000..85c4468 --- /dev/null +++ b/src/main/utils/base/base-singleton.ts @@ -0,0 +1,30 @@ +// 抽象基类,使用泛型来正确推导子类类型 +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 + } + + abstract init(): void + + public static getInstance(this: new () => T): T { + const clazz = this as any as typeof BaseSingleton + if (!clazz._instance) { + clazz._instance = new this() + clazz._instance.init() + } + return clazz._instance as T + } +} + +export { BaseSingleton } diff --git a/src/main/utils/file.ts b/src/main/utils/file.ts new file mode 100644 index 0000000..3bdc1be --- /dev/null +++ b/src/main/utils/file.ts @@ -0,0 +1,18 @@ +import { is } from "@electron-toolkit/utils" +import { join } from "path" +import { slash } from "./url" + +export function getFileUrl(app: string) { + let winURL = "" + if (is.dev && process.env["ELECTRON_RENDERER_URL"]) { + winURL = process.env["ELECTRON_RENDERER_URL"] + `/${app}#/` + } else { + winURL = join(__dirname, `../renderer/${app}#/`) + } + return slash(winURL) + } + + export function getPreloadUrl(file) { + return join(__dirname, `../preload/${file}.js`) + } + \ No newline at end of file diff --git a/src/main/utils/url.ts b/src/main/utils/url.ts new file mode 100644 index 0000000..c6eff80 --- /dev/null +++ b/src/main/utils/url.ts @@ -0,0 +1,8 @@ +export function slash(path: string) { + const isExtendedLengthPath = path.startsWith("\\\\?\\") + if (isExtendedLengthPath) { + return path + } + return path.replace(/\\/g, "/") + } + \ No newline at end of file diff --git a/src/preload/index.ts b/src/preload/index.ts index 7abcd96..76dee5a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,26 +1 @@ -import { contextBridge } from 'electron' -import { electronAPI } from '@electron-toolkit/preload' -import { exposeElectronTRPC } from 'electron-trpc/main'; - -process.once('loaded', async () => { - exposeElectronTRPC(); -}); -// Custom APIs for renderer -const api = {} - -// Use `contextBridge` APIs to expose Electron APIs to -// renderer only if context isolation is enabled, otherwise -// just add to the DOM global. -if (process.contextIsolated) { - try { - contextBridge.exposeInMainWorld('electron', electronAPI) - contextBridge.exposeInMainWorld('api', api) - } catch (error) { - console.error(error) - } -} else { - // @ts-ignore (define in dts) - window.electron = electronAPI - // @ts-ignore (define in dts) - window.api = api -} +import 'trpc/preload'; diff --git a/src/renderer/about.html b/src/renderer/about.html new file mode 100644 index 0000000..ed6607a --- /dev/null +++ b/src/renderer/about.html @@ -0,0 +1,32 @@ + + + + + + + + + + +
+

关于

+
    +
  • MIT开源
  • +
+
+ + + + \ No newline at end of file diff --git a/src/renderer/src/App.vue b/src/renderer/src/App.vue index 22aded3..51ffb02 100644 --- a/src/renderer/src/App.vue +++ b/src/renderer/src/App.vue @@ -1,18 +1,20 @@ @@ -33,6 +35,7 @@ const rpcHandle = async () => { diff --git a/src/renderer/src/about.ts b/src/renderer/src/about.ts new file mode 100644 index 0000000..afcc586 --- /dev/null +++ b/src/renderer/src/about.ts @@ -0,0 +1,8 @@ + +import { client } from 'trpc/renderer' + +client.subscribeGreeting.subscribe(undefined, { + onData: (data) => { + console.log(data.text); + } +}) \ No newline at end of file diff --git a/tsconfig.node.json b/tsconfig.node.json index db23a68..4d72980 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -1,8 +1,26 @@ { "extends": "@electron-toolkit/tsconfig/tsconfig.node.json", - "include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*"], + "include": [ + "electron.vite.config.*", + "src/main/**/*", + "src/preload/**/*", + "packages/**/common/**/*", + "packages/**/main/**/*", + "packages/**/preload/**/*" + ], "compilerOptions": { "composite": true, - "types": ["electron-vite/node"] + "types": [ + "electron-vite/node" + ], + "baseUrl": ".", + "paths": { + "main/*": [ + "src/main/*" + ], + "@res": [ + "resources/*" + ] + } } -} +} \ No newline at end of file diff --git a/tsconfig.web.json b/tsconfig.web.json index fcde99b..37aa462 100644 --- a/tsconfig.web.json +++ b/tsconfig.web.json @@ -5,7 +5,8 @@ "src/renderer/src/**/*", "src/renderer/src/**/*.vue", "src/preload/*.d.ts", - "src/main/api.ts" + "packages/**/common/**/*", + "packages/**/renderer/**/*" ], "compilerOptions": { "composite": true,