Electron + Vue3开源跨平台壁纸工具实战(三)主进程

342 阅读11分钟

fbw_social_preview.png

系列

Electron + Vue3开源跨平台壁纸工具实战(一)

Electron + Vue3开源跨平台壁纸工具实战(二)本地运行

Electron + Vue3开源跨平台壁纸工具实战(三)主进程

Electron + Vue3开源跨平台壁纸工具实战(四)主进程-数据管理(1)

Electron + Vue3开源跨平台壁纸工具实战(五)主进程-数据管理(2)

Electron + Vue3开源跨平台壁纸工具实战(六)子进程服务

Electron + Vue3开源跨平台壁纸工具实战(七)进程通信

Electron + Vue3开源跨平台壁纸工具实战(八)主进程-核心功能

Electron + Vue3开源跨平台壁纸工具实战(九)子进程服务(2)

Electron + Vue3开源跨平台壁纸工具实战(十)渲染进程

源码

省流点我进Github获取源码,欢迎fork、star、PR

主进程

目录结构

├── main/                   # Electron主进程
│   ├── child_server/       # 子进程服务
│   ├── jobs/               # 定时任务
│   ├── store/              # 数据存储
│   ├── utils/              # 工具函数
│   ├── ApiBase.js          # API基类
│   ├── cache.mjs           # 缓存管理
│   ├── logger.mjs          # 日志系统
│   ├── updater.mjs         # 自动更新
│   └── index.mjs           # 主进程入口

环境变量

在主进程中初始化定义了一些应用中会使用到的目录变量,此处注意resources目录在打包后路径会变化。

// main/index.mjs

const userDataPath = app.getPath('userData')
// 目录
process.env.FBW_USER_DATA_PATH = userDataPath
process.env.FBW_LOGS_PATH = getDirPathByName(userDataPath, 'logs')
process.env.FBW_DATABASE_PATH = getDirPathByName(userDataPath, 'database')
process.env.FBW_DATABASE_FILE_PATH = path.join(process.env.FBW_DATABASE_PATH, 'fbw.db')
process.env.FBW_DOWNLOAD_PATH = getDirPathByName(userDataPath, 'download')
process.env.FBW_CERTS_PATH = getDirPathByName(userDataPath, 'certs')
process.env.FBW_PLUGINS_PATH = getDirPathByName(userDataPath, 'plugins')
process.env.FBW_TEMP_PATH = getDirPathByName(userDataPath, 'temp')
// 获取资源路径,开发环境下使用项目根目录下的资源,生产环境下使用 resources 目录下的资源
process.env.FBW_RESOURCES_PATH = app.isPackaged
  ? path.join(process.resourcesPath, 'resources')
  : path.join(__dirname, '../../resources')

全局变量

挂载全局变量,主要用于自定义插件扩展时使用

  // main/index.mjs

  // 全局变量
  global.FBW = global.FBW || {}
  // 导出API开发所需的工具和基类
  global.FBW.apiHelpers = {
    axios,
    ApiBase,
    calculateImageOrientation,
    calculateImageQuality
  }

logger

日志系统使用pino,主要是看重了性能和JSON格式,便于后期处理。

  • pino-pretty 用于在DEV环境输出日志到控制台
  • pino-roll 用于在PROD环境输出日志到文件

pino Benchmarks

image.png

image.png

image.png

logger初始化

// main/logger.mjs

import { join } from 'path'
import pino from 'pino'
import { isDev } from './utils/utils.mjs'

export default (logDir, fileName = 'app.log') => {
  // 获取用户数据目录
  const logFilePath = join(logDir || process.env.FBW_LOGS_PATH, fileName)

  // 配置 pino-roll 传输
  const transport = pino.transport(
    isDev()
      ? {
          target: 'pino-pretty',
          options: {
            colorize: true,
            translateTime: 'SYS:standard',
            ignore: 'pid,hostname'
          }
        }
      : {
          target: 'pino-roll',
          options: {
            file: logFilePath, // 日志文件路径
            frequency: 'daily', // 每天生成一个新的日志文件
            size: '10M', // 每个日志文件的最大大小
            mkdir: true, // 自动创建目录
            dateFormat: 'yyyy-MM-dd' // 自定义日志文件名称,eg: app.log.2025-02-27.1
          }
        }
  )

  // 创建 Pino 实例
  const logger = pino(transport)

  // 初始化日志 挂载全局变量
  global.logger = logger
}


在子进程服务中,通过port.postMessageport.on('message', () => {...}进行消息传递,再在主进程中onMessage监听消息并进行日志打印

输出日志

image.png

{"level":30,"time":1747106322108,"pid":60161,"hostname":"OXOYOMacBook-Pro.local","msg":"isDev: false process.env.NODE_ENV: undefined"}
{"level":30,"time":1747106322108,"pid":60161,"hostname":"OXOYOMacBook-Pro.local","msg":"getIconPath FBW_RESOURCES_PATH: /Applications/Flying Bird Wallpaper.app/Contents/Resources/resources"}
{"level":30,"time":1747106322108,"pid":60161,"hostname":"OXOYOMacBook-Pro.local","msg":"getIconPath resourcesPath: /Applications/Flying Bird Wallpaper.app/Contents/Resources"}
{"level":30,"time":1747106322657,"pid":60161,"hostname":"OXOYOMacBook-Pro.local","msg":"数据库初始化完成"}
{"level":30,"time":1747106322658,"pid":60161,"hostname":"OXOYOMacBook-Pro.local","msg":"开始加载API插件,目录: /Applications/Flying Bird Wallpaper.app/Contents/Resources/resources/api"}
{"level":30,"time":1747106322742,"pid":60161,"hostname":"OXOYOMacBook-Pro.local","msg":"成功加载API插件: bing"}
{"level":30,"time":1747106322742,"pid":60161,"hostname":"OXOYOMacBook-Pro.local","msg":"成功加载API插件: nasa"}
{"level":30,"time":1747106322743,"pid":60161,"hostname":"OXOYOMacBook-Pro.local","msg":"成功加载API插件: pexels"}
{"level":30,"time":1747106322743,"pid":60161,"hostname":"OXOYOMacBook-Pro.local","msg":"成功加载API插件: pixabay"}
{"level":30,"time":1747106322743,"pid":60161,"hostname":"OXOYOMacBook-Pro.local","msg":"成功加载API插件: unsplash"}
{"level":30,"time":1747106322744,"pid":60161,"hostname":"OXOYOMacBook-Pro.local","msg":"成功加载API插件: birdpaper"}
{"level":30,"time":1747106322744,"pid":60161,"hostname":"OXOYOMacBook-Pro.local","msg":"开始加载API插件,目录: /Users/OXOYO/Library/Application Support/Flying Bird Wallpaper/plugins/api"}
{"level":30,"time":1747106322744,"pid":60161,"hostname":"OXOYOMacBook-Pro.local","msg":"成功加载 6 个API插件"}
{"level":30,"time":1747106322746,"pid":60161,"hostname":"OXOYOMacBook-Pro.local","msg":"写入resourceMap信息成功"}
{"level":30,"time":1747106322746,"pid":60161,"hostname":"OXOYOMacBook-Pro.local","msg":"ChildServer INIT:: serverName => fileServer, serverPath => /Applications/Flying Bird Wallpaper.app/Contents/Resources/app.asar/out/main/index-BA8g1k-3.js"}
{"level":30,"time":1747106322746,"pid":60161,"hostname":"OXOYOMacBook-Pro.local","msg":"ChildServer INIT:: serverName => h5Server, serverPath => /Applications/Flying Bird Wallpaper.app/Contents/Resources/app.asar/out/main/index-Bhlql4S6.js"}
{"level":30,"time":1747106322750,"pid":60161,"hostname":"OXOYOMacBook-Pro.local","msg":"ChildServer START:: serverName => fileServer"}
{"level":30,"time":1747106322866,"pid":60161,"hostname":"OXOYOMacBook-Pro.local","msg":"Store 初始化完成"}

异常处理

在主进程中全局捕获异常

  // main/index.mjs

  // 捕获未处理的异常
  process.on('uncaughtException', (err) => {
    // 获取错误的名称、消息和堆栈信息
    const errorMessage = err.message // 错误消息
    const errorName = err.name // 错误名称
    const errorStack = err.stack // 堆栈信息
    console.log(
      `Uncaught Exception: Name => ${errorName}, Message => ${errorMessage}, Stack => ${errorStack}`
    )
    // 打印详细错误信息
    global.logger.error(
      `Uncaught Exception: Name => ${errorName}, Message => ${errorMessage}, Stack => ${errorStack}`
    )
  })

  // 捕获未处理的 Promise 拒绝
  process.on('unhandledRejection', (reason) => {
    if (reason instanceof Error) {
      // reason 是 Error 对象时,打印详细错误信息
      const errorName = reason.name
      const errorMessage = reason.message
      const errorStack = reason.stack
      console.log(
        `Uncaught Rejection: Name => ${errorName}, Message => ${errorMessage}, Stack => ${errorStack}`
      )
      global.logger.warn(
        `Uncaught Rejection: Name => ${errorName}, Message => ${errorMessage}, Stack => ${errorStack}`
      )
    } else {
      console.log(`Unhandled Rejection: ${reason}`)
      // reason 不是 Error 对象时,直接打印 reason
      global.logger.warn(`Unhandled Rejection: ${reason}`)
    }
  })

自定义协议

注册自定义协议,用于处理在渲染进程中加载图片


    // main/index.mjs

    // 在 app.whenReady() 之前
    protocol.registerSchemesAsPrivileged([
      {
        scheme: 'fbwtp',
        privileges: {
          bypassCSP: true,
          secure: true,
          standard: true,
          supportFetchAPI: true,
          allowServiceWorkers: true,
          stream: true
        }
      }
    ])

处理自定义协议,使用RESTFUL规范来统一请求,方便请求处理及后续扩展

  // main/index.mjs
      
  // 处理自定义协议
  const handleProtocol = () => {
    protocol.handle('fbwtp', async (request) => {
      const url = new URL(request.url)
      switch (url.pathname) {
        // 处理图片请求
        case '/api/images/get': {
          const filePath = url.searchParams.get('filePath')
          const w = url.searchParams.get('w')
          const h = url.searchParams.get('h')

          const res = await handleFileRsponse({ filePath, w, h })
          return new Response(res.data, {
            status: res.status,
            headers: res.headers
          })
        }
        case '/api/videos/get': {
          const filePath = url.searchParams.get('filePath')

          const res = await handleFileRsponse({ filePath })
          return new Response(res.data, {
            status: res.status,
            headers: res.headers
          })
        }
      }
    })
  }

这里提取统一的文件响应处理逻辑,便于H5\APP复用处理逻辑,共用数据缓存。

  • 使用lru-cache提高响应速度避免每次都读取文件;通过sharp剪切图片,避免每次都加载原图。
  • 使用filePath=${filePath}&width=${width}作为缓存Key,便于通过width控制相同文件是否压缩以及压缩效果
  • 响应头添加Server-Timing便于在devtools中查看请求处理耗时
// main/utils/file.mjs

import fs from 'fs'
import path from 'path'
import sharp from 'sharp'
import cache from '../cache.mjs'
import { mimeTypes } from '../../common/publicData'

export const handleFileRsponse = async (query) => {
  const ret = {
    status: 404,
    headers: {},
    data: null
  }
  try {
    let T1, T2, T3, T4
    T1 = Date.now()
    // 获取图片 URL 和尺寸
    let { filePath, w } = query
    if (!filePath) {
      return ret
    }
    // 处理 Windows 上的绝对路径(例如 'E:/xx/yy')
    if (process.platform === 'win32') {
      filePath = filePath.replace(/\//g, '\\') // 将所有斜杠替换为反斜杠
      // 修复丢失的冒号(:),假设路径是 e\xx\yy 应该是 e:\xx\yy
      filePath = filePath.replace(/^([a-zA-Z])\\/, '$1:\\') // 在盘符后面补上冒号
    } else {
      // macOS 和 Linux 确保是绝对路径
      if (!filePath.startsWith('/')) {
        filePath = '/' + filePath
      }
    }
    filePath = decodeURIComponent(filePath)
    // 定义默认图片尺寸
    const width = w ? parseInt(w, 10) : null
    // 生成缓存键
    const cacheKey = `filePath=${filePath}&width=${width}`
    // 检查缓存
    if (cache.has(cacheKey)) {
      const cacheData = cache.get(cacheKey)
      // 返回文件内容和 MIME 类型
      ret.status = 200
      ret.headers = {
        ...cacheData.headers,
        'Server-Timing': `cache-hit;dur=${Date.now() - T1}`
      }
      ret.data = cacheData.data
      return ret
    }
    T2 = Date.now()

    // 获取文件信息
    const stats = await fs.promises.stat(filePath)
    const originalFileSize = stats.size
    const extension = path.extname(filePath).toLowerCase()
    const mimeType = mimeTypes[extension] || 'application/octet-stream'
    T3 = Date.now()

    // 读取文件并处理
    let fileBuffer
    // 只对图片进行调整大小,视频文件直接读取
    // 文件大小超过指定大小才进行调整,单位bytes
    const limitSize = 5 * 1024 * 1024
    if (
      ['.png', '.jpg', '.jpeg', '.avif', '.webp', '.gif'].includes(extension) &&
      width &&
      originalFileSize > limitSize
    ) {
      fileBuffer = await sharp(filePath)
        .resize({
          width,
          fit: 'inside', // 保持宽高比
          withoutEnlargement: true, // 避免放大小图片
          kernel: 'lanczos3', // 使用最好的缩放算法
          fastShrinkOnLoad: true // 启用快速缩小
        })
        .toBuffer()
    } else {
      fileBuffer = await fs.promises.readFile(filePath)
    }
    const fileSize = fileBuffer.length.toString()
    T4 = Date.now()

    const headers = {
      'Accept-Ranges': 'bytes',
      'Content-Type': mimeType,
      'Original-Size': originalFileSize,
      'Content-Length': fileSize,
      'Compressed-Size': fileSize,
      'Cache-Control': 'max-age=3600',
      ETag: `"${stats.mtimeMs}-${originalFileSize}"`,
      'Last-Modified': stats.mtime.toUTCString(),
      'Server-Timing': `file-check;dur=${T2 - T1}, file-stat;dur=${T3 - T2}, resize;dur=${T4 - T3}, total;dur=${T4 - T1}`,
      'X-File-Check-Time': T2 - T1 + 'ms',
      'X-File-Stat-Time': T3 - T2 + 'ms',
      'X-Resize-Time': T4 - T3 + 'ms',
      'X-Total-Time': T4 - T1 + 'ms'
    }
    // 缓存文件内容
    cache.set(cacheKey, {
      data: fileBuffer,
      headers
    })
    // 返回文件内容
    ret.status = 200
    ret.headers = headers
    ret.data = fileBuffer
    return ret
  } catch (err) {
    return ret
  }
}

使用时需在index.html中将fbwtp协议头加入,以通过内容安全策略(CSP)

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title></title>
    <!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
    <meta
      http-equiv="Content-Security-Policy"
      content="default-src * 'self' 'unsafe-inline' 'unsafe-eval' data: gap: content: https://xxx.com;media-src * blob: 'self' http://* 'unsafe-inline' 'unsafe-eval';style-src * 'self' 'unsafe-inline';img-src * 'self' data: content: fbwtp:;connect-src * blob:;"
    />
  </head>

  <body>
    <div id="app"></div>
    <script type="module" src="main.js"></script>
  </body>
</html>

在渲染进程中使用时就和网络图片一样可以直接使用了

    <img src="fbwtp://fbw/api/images/get?filePath=${item.filePath}&w=100"

应用单例

  // main/index.mjs

  // 确保单实例
  const gotTheLock = app.requestSingleInstanceLock()

  if (!gotTheLock) {
    // 如果没拿到锁,直接退出当前实例
    app.quit()
  } else {
    app.on('second-instance', () => {
      // 当运行第二个实例时,这里将会被调用
      if (mainWindow) {
        if (mainWindow.isMinimized()) {
          mainWindow.restore()
        }
        if (!mainWindow.isVisible()) {
          mainWindow.show()
        }
        mainWindow.focus()
      }
    })

应用托盘

这里使用setTemplateImage可以在MAC适配适应明暗菜单栏

  // main/index.mjs

  // 创建托盘
  const createTray = () => {
    const trayIcon = nativeImage.createFromPath(iconTray).resize({ width: 20, height: 20 })
    trayIcon.setTemplateImage(true) // 设置为模板图标

    tray = new Tray(trayIcon)

    tray.setToolTip(t('appInfo.name'))

    // 监听托盘图标点击事件
    tray.on('click', () => {
      toggleMainWindow()
    })
    // 监听托盘图标点击事件右键点击事件
    tray.on('right-click', () => {
      // 托盘右键菜单
      const contextMenuList = [
        {
          label: store?.settingData?.autoSwitchWallpaper
            ? t('actions.autoSwitchWallpaper.stop')
            : t('actions.autoSwitchWallpaper.start'),
          click: () => {
            store?.toggleAutoSwitchWallpaper()
          }
        },
        {
          label: t('actions.nextWallpaper'),
          click: () => {
            store?.doManualSwitchWallpaper('next')
          }
        },
        {
          label: t('actions.prevWallpaper'),
          click: () => {
            store?.doManualSwitchWallpaper('prev')
          }
        },
        {
          label: store?.settingData?.suspensionBallVisible
            ? t('actions.closeSuspensionBall')
            : t('actions.openSuspensionBall'),
          click: () => {
            toggleSuspensionBall()
          }
        }
      ]
      contextMenuList.push({
        type: 'separator'
      })
      // 菜单子项
      const enabledMenuChildren = []
      // 功能菜单
      const trayFuncMenuList = []
      menuList.forEach((item) => {
        if (item.placement.includes('trayMenuChildren')) {
          enabledMenuChildren.push({
            // FIXME 拼接空格,防止菜单项宽度过短
            label: t(item.locale).padEnd(20, ' '),
            click: () => handleJumpToPage(item.name)
          })
        } else if (item.placement.includes('trayFuncMenu')) {
          trayFuncMenuList.push({
            label: t(item.locale),
            click: () => handleJumpToPage(item.name)
          })
        }
      })
      if (enabledMenuChildren.length) {
        contextMenuList.push({
          label: t('actions.menu'),
          submenu: enabledMenuChildren
        })
        contextMenuList.push({
          type: 'separator'
        })
      }
      if (trayFuncMenuList.length) {
        contextMenuList.push(...trayFuncMenuList, {
          type: 'separator'
        })
      }
      // 其他菜单项
      contextMenuList.push(
        {
          label: `Version: ${app.getVersion()}`,
          // 禁用该项
          enabled: false
        },
        {
          label: t('actions.checkUpdate'),
          click: () => {
            // “检查更新”功能
            updater.checkUpdate()
          }
        },
        {
          type: 'separator'
        },
        {
          label: t('actions.quit'),
          click: () => {
            flags.isQuitting = true
            app.quit()
          }
        }
      )
      const contextMenu = Menu.buildFromTemplate(contextMenuList)
      tray.popUpContextMenu(contextMenu)
    })
  }

检查更新

这里仅实现了检查更新进行系统通知,没有考虑增量更新

// main/updater.mjs

import { app, ipcMain } from 'electron'
import electronUpdater from 'electron-updater'

const { autoUpdater } = electronUpdater

export default class Updater {
  constructor() {
    this.init()
  }
  init() {
    if (!app.isPackaged) {
      // 开启开发环境调试
      autoUpdater.forceDevUpdateConfig = true
      autoUpdater.logger = global.logger
      console.log('更新配置路径:', autoUpdater.configOnDisk)
      // 开发环境忽略代码签名检查
      autoUpdater.disableWebInstaller = true
      // 支持降级
      autoUpdater.allowDowngrade = true
      // 配置自定义更新服务器
      autoUpdater.setFeedURL({
        provider: 'generic',
        url: 'http://localhost:8080'
      })
    }
    // 配置自动更新
    autoUpdater.autoDownload = false // 自动下载
    autoUpdater.autoInstallOnAppQuit = true // 应用退出后自动安装

    // 监听渲染进程的检查更新事件,触发检查更新
    ipcMain.on('main:checkUpdate', () => {
      autoUpdater.checkForUpdatesAndNotify()
    })
  }

  on(event, callback = () => {}) {
    if (typeof callback !== 'function') {
      throw new Error('callback must be a function')
    }
    autoUpdater.on(event, callback)
  }

  checkUpdate() {
    // 检测是否有更新包并通知
    autoUpdater.checkForUpdatesAndNotify()
  }
}

在主进程里实例化Updater,并绑定监听事件


  // main/index.mjs

  // 检查更新
  updater = new Updater()
  // 绑定事件
  updater.on('update-available', (info) => {
    global.logger.info('有可用更新', info)
    // 显示系统通知
    const notice = new Notification({
      title: t('actions.checkUpdate'),
      body: t('messages.updateAvailable', {
        version: `v${info.version}`
      })
    })
    notice.on('click', () => {
      // 打开更新页面
      shell.openExternal(appInfo.github + '/releases')
    })
    notice.show()
  })
  updater.on('update-not-available', (info) => {
    global.logger.info('无需更新', info)
    // 显示系统通知
    new Notification({
      title: t('actions.checkUpdate'),
      body: t('messages.updateNotAvailable')
    }).show()
  })
  updater.on('error', (err) => {
    global.logger.error(`更新失败: error => ${err}`)
    // 显示系统通知
    new Notification({
      title: t('actions.checkUpdate'),
      body: t('messages.checkUpdateFail')
    }).show()
  })

Cache

使用lru-cache进行通用的缓存管理

// main/cache.mjs

import { LRUCache } from 'lru-cache'

const options = {
  // 缓存的最大条目数。超过此数量时,最久未使用的条目会被淘汰
  max: 1000,
  // 缓存的最大大小(基于 sizeCalculation 计算的值)
  maxSize: 1000 * 1024 * 1024, // 1GB
  // 用于计算缓存项大小的函数
  sizeCalculation: (value, key) => {
    // 计算 data 的大小
    const dataSize = value.data.length
    // 计算 headers 的大小
    const headersSize = Buffer.byteLength(JSON.stringify(value.headers))
    // 返回总大小
    return dataSize + headersSize
  },
  // 当缓存项被淘汰时调用的回调函数,用于清理资源
  dispose: (value, key) => {
    // console.log(`缓存项 ${key} 被淘汰,值为 ${value}`)
  },
  // 缓存项的默认存活时间(毫秒)。超过此时间后,缓存项会自动失效。
  ttl: 1000 * 60 * 30,
  // 是否自动清理过期的缓存项
  ttlAutopurge: true,
  // 是否允许返回过期的缓存项(即使过期,仍然可以获取到值)
  allowStale: false,
  // 当调用 get 方法时,是否更新缓存项的存活时间(TTL)
  updateAgeOnGet: false,
  // 当调用 has 方法时,是否更新缓存项的存活时间(TTL)
  updateAgeOnHas: false
  // 当缓存未命中时,用于异步获取数据的函数
  // fetchMethod: async (key, staleValue, { options, signal, context }) => {}
}

const cache = new LRUCache(options)

export default cache

devtool

在窗口配置中开启devtool

// main/index.mjs

webPreferences: {
  preload: path.join(__dirname, '../preload/index.mjs'),
  sandbox: false,
  webSecurity: false,
  // 开启devTools
  devTools: true
}
// main/index.mjs

import installExtension, { VUEJS3_DEVTOOLS } from 'electron-devtools-installer'

...

// 安装vue-devtools
installExtension(VUEJS3_DEVTOOLS)
  .then((name) => console.log(`Added Extension:  ${name}`))
  .catch((err) => console.log('An error occurred: ', err))

// 使用 webContents 捕获自定义协议,用于在devtools中调试
webContents.getAllWebContents().forEach((wc) => {
  // 启用调试器
  try {
    wc.debugger.attach('1.3') // 指定 DevTools 调试协议版本
  } catch (err) {
    console.error('Debugger attach failed:', err)
  }

  // 监听调试器消息
  wc.debugger.on('message', (event, method, params) => {
    if (method === 'Network.requestWillBeSent') {
      console.log('Request URL:', params.request.url)
    }
    if (method === 'Network.responseReceived') {
      console.log('Response:', params.response)
    }
  })

  // 启用网络事件捕获
  wc.debugger.sendCommand('Network.enable')
})

窗口

当前共有5个窗口

  • loadingWindow: 显示窗口加载动画
  • mainWindow: 主界面窗口
  • viewImageWindow: 预览图片独立窗口
  • suspensionBall: 悬浮球透明窗口
  • dynamicWallpaperWindow: 动态壁纸窗口
// main/index.mjs

  let loadingWindow
  let mainWindow
  let viewImageWindow
  let suspensionBall
  let dynamicWallpaperWindow = null
  let store
  let tray
  let updater

  // 加载动画页面地址
  const loadingURL = `${path.join(process.env.FBW_RESOURCES_PATH, '/loading.html')}`
  const getWindowURL = (name) => {
    let url = isDev()
      ? `${process.env['ELECTRON_RENDERER_URL']}/windows/${name}/index.html`
      : `${path.join(__dirname, `../renderer/windows/${name}/index.html`)}`
    return url
  }
  // 窗口根地址
  const MainWindowURL = getWindowURL('MainWindow')
  const ViewImageWindowURL = getWindowURL('ViewImageWindow')
  const SuspensionBallURL = getWindowURL('SuspensionBall')
  const DynamicWallpaperURL = getWindowURL('DynamicWallpaper')

加载动画,这里其实就是先创建一个动画窗口,等待加载完成后再执行`callback`

  const showLoading = (callback) => {
    if (!isFunc(callback)) {
      return
    }

    // 确保之前的 loadingWindow 被清理
    if (loadingWindow) {
      loadingWindow.destroy()
      loadingWindow = null
    }

    loadingWindow = new BrowserWindow({
      width: 200,
      height: 200,
      minWidth: 200,
      minHeight: 200,
      show: false,
      frame: false,
      resizable: false,
      transparent: true
    })

    // 设置加载超时
    const timeout = setTimeout(() => {
      if (loadingWindow) {
        loadingWindow.destroy()
        loadingWindow = null
        callback() // 超时后仍然执行回调
      }
    }, 10000) // 10秒超时

    // 处理加载失败
    loadingWindow.webContents.on('did-fail-load', () => {
      clearTimeout(timeout)
      if (loadingWindow) {
        loadingWindow.destroy()
        loadingWindow = null
        callback() // 加载失败后仍然执行回调
      }
    })

    loadingWindow.once('show', () => {
      clearTimeout(timeout)
      callback()
    })

    loadingWindow.once('ready-to-show', () => {
      if (loadingWindow) {
        loadingWindow.show()
      }
    })

    // 加载动画页面
    loadingWindow.loadFile(loadingURL).catch((err) => {
      global.logger.error(`加载动画页面失败: ${err}`)
      clearTimeout(timeout)
      if (loadingWindow) {
        loadingWindow.destroy()
        loadingWindow = null
        callback() // 加载失败后仍然执行回调
      }
    })
  }

使用时

    ...
    showLoading(createMainWindow)
    ...

阻止窗口右键菜单事件

  // main/index.mjs

  // 阻止窗口右键菜单事件
  const preventContextMenu = (win) => {
    if (!win) {
      return
    }
    win.webContents.on('context-menu', (event) => {
      event.preventDefault() // 阻止默认右键菜单行为
    })
    if (isWin()) {
      // 监听 278 消息,关闭因为css: -webkit-app-region: drag 引起的默认右键菜单
      win.hookWindowMessage(278, () => {
        // 阻止默认的窗口关闭行为
        win.setEnabled(false)
        setTimeout(() => {
          win.setEnabled(true)
        }, 100)
        return true
      })
    }
  }

数据管理

主进程中的数据管理统一在store目录下

  • index.mjs: Store类,数据管理入口,用于实例化各个管理类,启动定时任务、子服务等
  • DatabaseManager.mjs: 数据库管理类,用于建表、初始化数据库
  • FileManager.mjs: 文件管理类,用于启动文件子服务、处理文件
  • ApiManager.mjs: 资源接口管理类,用于加载应用自带资源API、用户自定义资源API
  • ResourcesManager.mjs: 资源查询管理类,用于查询、操作资源数据
  • SettingManager.mjs: 设置数据管理类,用于获取、更新设置数据
  • TaskScheduler.mjs: 定时任务管理类,用于管理应用内各种定时任务的启停清理
  • WallpaperManager.mjs: 壁纸管理类,用于壁纸切换、下载、清理
  • WordsManager.mjs: 词库管理类,用于查询、处理分词