我的第一个Electron应用

news2024/11/26 12:21:54

hello,好久不见,最近笔者花了几天时间入门Electron,然后做了一个非常简单的应用,本文就来给各位分享一下过程,Electron大佬请随意~

笔者开源了一个Web思维导图,虽然借助showSaveFilePickerapi可以直接操作电脑本地文件,但终归不能离线使用,所以就萌发了做一个客户端的想法,作为一个只会前端的废物,做客户端,Electron显然是最好的选择,不过缺点也很明显,安装包体积比较大,如果你对此比较介意的话可以尝试tauri。

笔者的需求很简单,能新建、打开本地文件进行编辑,另外能查看最近编辑过的文件列表。

思维导图的编辑页面直接用原来的Web版的页面即可,所以只需要新做一个主页。

最终效果如下:

主页:

编辑页:

项目引入Electron

笔者的项目是基于Vue2.x + Vue Cli开发的一个单页应用,路由用的是hash模式,引入Electron很简单,也不需要做啥大改动,直接使用vue-cli-plugin-electron-builder插件:

vue add electron-builder

然后启动服务:

npm run electron:serve

就会在Vue项目启动完成后自动帮你启动Electron,接下来就可以愉快的开发了。

主进程

Electron应用需要一个入口文件,用来控制主进程,需要在项目的package.json文件中的main字段指定:

{
    "main": "background.js"
}

主进程中存在一些基本代码,用于控制应用退出:

// background.js
import { app } from 'electron'
const isDevelopment = process.env.NODE_ENV !== 'production'

// 关闭所有窗口后退出
app.on('window-all-closed', () => {
  // 在macOS上,应用程序及其菜单栏通常保持活动状态,直到用户使用Cmd+Q明确退出
  if (process.platform !== 'darwin') {
    app.quit()
  }
})

// 在开发模式下,应父进程的请求退出。
if (isDevelopment) {
  if (process.platform === 'win32') {
    process.on('message', data => {
      if (data === 'graceful-exit') {
        app.quit()
      }
    })
  } else {
    process.on('SIGTERM', () => {
      app.quit()
    })
  }
}

然后就是创建和打开应用的窗口:

// background.js
import { app, protocol, BrowserWindow } from 'electron'
import { createProtocol } from 'vue-cli-plugin-electron-builder/lib'

// 注册协议
protocol.registerSchemesAsPrivileged([
  { scheme: 'app', privileges: { secure: true, standard: true } }
])

// 在ready事件里创建窗口
app.on('ready', async () => {
  createMainWindow()
})

app.on('activate', () => {
  // 在macOS上,当点击dock图标且没有其他窗口打开时,通常会在应用程序中重新创建一个窗口。
  if (BrowserWindow.getAllWindows().length === 0) {
    createMainWindow()
  }
})

// 创建主页面
let mainWindow = null
async function createMainWindow() {
  mainWindow = new BrowserWindow({
    width: 1200,
    height: 800,
    frame: false,
    titleBarStyle: 'hiddenInset',
    webPreferences: {
      webSecurity: false,
      preload: path.join(__dirname, 'preload.js')
    }
  })

  if (process.env.WEBPACK_DEV_SERVER_URL) {
    await mainWindow.loadURL(
      process.env.WEBPACK_DEV_SERVER_URL + '/#/workbenche'
    )
  } else {
    createProtocol('app')
    mainWindow.loadURL('app://./index.html/#/workbenche')
  }
}

ready事件中创建新窗口,默认是打开主页面,开发环境打开本地启动的服务,生产环境直接打开本地文件。

frame设为false,创建的是一个无边框窗口,也就是没有默认的工具栏和控件,只有你的页面区域。

另外可以看到在创建窗口时指定了一个文件preload.js,这个文件是渲染进程和主进程的通信桥梁。

如果你要打开页面调试的控制台,可以调用openDevTools方法:

mainWindow.webContents.openDevTools()

渲染进程

通过BrowserWindow创建的每个窗口都是一个单独的渲染进程,为了安全,一般不允许渲染进程直接访问Node.js环境,也就是我们的页面无法直接调用Node.jsAPI,但是作为一个客户端,页面显然是需要这种能力的,比如最基本的功能,操作本地文件,这就是preload.js(预加载脚本)文件的作用。

预加载脚本会在渲染器进程加载之前加载,并有权访问:两个渲染器全局对象 ( windowdocument) 、Node.js 环境。

可以在预加载脚本中通过contextBridge.exposeInMainWorld方法在页面的window对象上挂载属性和方法,这样页面就能使用了,具体的使用后面会介绍。

页面控制器和拖拽区域

我们创建的是无边框页面,但是作为一个客户端页面,页面控制器(最小化、全屏、关闭)和拖拽区域是必不可少的。

拖拽区域

拖拽区域一般放在页面顶部,宽度和页面宽度一致,高度随意,一个div即可:

<div class="workbencheHomeHeader"></div>
.workbencheHomeHeader {
    position: relative;
    width: 100%;
    height: 40px;
    background-color: #ebeef1;
    display: flex;
    align-items: center;
    flex-shrink: 0;
}

要让这个普通的div能被拖动也很简单,加上如下的样式即可:

.workbencheHomeHeader {
    // ...
    -webkit-app-region: drag;
}

如果这个区域内部的有些元素你不想作为拖拽区域的话,只要在这个元素上加上如下样式:

.innerElement {
    -webkit-app-region: no-drag;
}

控制器

Windows系统在无边框模式下默认不会显示控制器,但是Mac系统的控制器(红绿灯)是无法隐藏的,默认会显示在页面的左上方,所以笔者的做法是判断当前系统,如果是Windows则显示一个我们自己做的控制器,而Mac系统只要在红绿灯区域显示一个占位元素即可。

为了在页面内方便的判断当前的系统,我们可以在预加载脚本中注入一个全局变量:

// preload.js
const { contextBridge } = require('electron')

contextBridge.exposeInMainWorld('platform', process.platform)
contextBridge.exposeInMainWorld('IS_ELECTRON', true)

这样我们就可以在页面中通过window.platform获取当前所在的系统了,另外还注入了一个全局变量window.IS_ELECTRON用来给页面判断是否处于Electron环境。

Mac系统的控制器默认在左上角,也就是我们的拖拽区域内,Windows上的控制器一般是在右上角的,但是笔者直接让WindowsMac保持一致,一起放在左上角:

<div class="workbencheHomeHeader">
      <MacControl></MacControl>
      <WinControl></WinControl>
</div>
// MacControl.vue
<template>
  <div class="macControl" v-if="IS_MAC"></div>
</template>

<style lang="less" scoped>
.macControl {
  width: 100px;
  height: 100%;
  flex-shrink: 0;
}
</style>
// WinControl.vue
<template>
  <div class="winControl noDrag" v-if="IS_WIN">
    <div class="winControlBtn iconfont iconzuixiaohua" @click="minimize"></div>
    <div
      class="winControlBtn iconfont"
      :class="[isMaximize ? 'icon3zuidahua-3' : 'iconzuidahua']"
      @click="toggleMaximize"
    ></div>
    <div class="winControlBtn iconfont iconguanbi" @click="close"></div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      isMaximize: false
    }
  },
  methods: {
    // ...
  }
}
</script>

Windows控制器显然需要调用窗口的相关方法来控制窗口的最小化、关闭等。这就涉及到进程间的通信了,具体来说是渲染进程到主进程的通信。

渲染进程到主进程通信

进程间通信需要用到预加载脚本。

我们可以在预加载脚本中给页面注入一些全局方法,然后在方法中使用进程间通信 (IPC)通知主进程,拿前面的控制器为例:

// preload.js
const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('electronAPI', {
  minimize: () => ipcRenderer.send('minimize'),
  maximize: () => ipcRenderer.send('maximize'),
  unmaximize: () => ipcRenderer.send('unmaximize'),
  close: () => ipcRenderer.send('close'),
}

给页面的window对象注入了一个值为对象的属性electronAPI,我们的所有通信方法都会挂在这个对象上,这样我们的控制器就可以调用相关方法了:

// WinControl.vue
<script>
export default {
  methods: {
    minimize() {
      window.electronAPI.minimize()
    },

    toggleMaximize() {
      if (this.isMaximize) {
        this.isMaximize = false
        window.electronAPI.unmaximize()
      } else {
        this.isMaximize = true
        window.electronAPI.maximize()
      }
    },

    close() {
      window.electronAPI.close()
    }
  }
}
</script>

接下来就是在主进程中接收消息:

// background.js
import { BrowserWindow, ipcMain } from 'electron'

const bindEvent = () => {
  ;['minimize', 'maximize', 'unmaximize', 'close'].forEach(eventName => {
    ipcMain.on(eventName, event => {
      // 获取发送消息的 webContents
      const webContents = event.sender
      // 获取给定webContents的窗口
      const win = BrowserWindow.fromWebContents(webContents)
      // 调用窗口的方法
      win[item]()
    })
  })
}

app.on('ready', async () => {
  createMainWindow()
  bindEvent()// ++ 监听事件
})

新建、打开、保存

新建

当点击新建按钮时,会创建一个新的思维导图编辑窗口:

// preload.js
contextBridge.exposeInMainWorld('electronAPI', {
    create: () => ipcRenderer.send('create')
}
// background.js
import { v4 as uuid } from 'uuid'

ipcMain.on('create', createEditWindow)

const createEditWindow = async (event, id) => {
    id = id || uuid()
    const win = new BrowserWindow({
      width: 1200,
      height: 800,
      frame: false,
      titleBarStyle: 'hiddenInset',
      webPreferences: {
        webSecurity: false,
        preload: path.join(__dirname, 'preload.js')
      }
    })
    if (process.env.WEBPACK_DEV_SERVER_URL) {
      win.loadURL(
        process.env.WEBPACK_DEV_SERVER_URL + '/#/workbenche/edit/' + id
      )
    } else {
      win.loadURL('app://./index.html/#/workbenche/edit/' + id)
    }
}

编辑页面需要一个唯一的id,然后打开编辑页面窗口即可。

打开

打开是指打开本地的文件,首先笔者自定义了一个文件扩展名.smm,作为应用支持的文件,本质就是json格式。

打开本地文件需要使用到dialog模块:

// preload.js
contextBridge.exposeInMainWorld('electronAPI', {
    selectOpenFile: () => ipcRenderer.send('selectOpenFile')
}
// background.js
import { dialog } from 'electron'

// 打开本地文件
ipcMain.on('selectOpenFile', event => {
    const res = dialog.showOpenDialogSync({
        title: '选择',// 对话框窗口的标题
        filters: [{ name: '思维导图', extensions: ['smm'] }]// 指定一个文件类型数组,用于规定用户可见或可选的特定类型范围
    })
    if (res && res[0]) {
        openFile(null, res[0])
    }
})

// 打开文件进行编辑
const idToFilePath = {}// 关联id和文件路径
const openFile = (event, file) => {
    let id = uuid()
    idToFilePath[id] = file
    createEditWindow(null, id)
}

指定只能选择.smm文件,选择完成后会返回选择文件的路径。

然后调用openFile方法打开编辑窗口,同样会生成一个唯一的id,另外我们创建了一个对象用来关联idid对应的文件路径,用于后续的保存操作。

页面打开后,页面需要获取文件的数据,作为初始数据渲染到画布,这个需要渲染进程给主进程发信息,并且能接收数据,还是渲染进程到主进程的通信,只不过是双向的。

渲染进程到主进程通信(双向)

同样是使用ipcRenderer对象,只不过不是使用sendon方法,而是使用invokehandle方法。

// 页面
const getData = async () => {
    try {
        let data = await window.electronAPI.getFileContent(this.$route.params.id)
    } catch(err) {
        console.errror(err)
    }
}
// preload.js
contextBridge.exposeInMainWorld('electronAPI', {
    getFileContent: id => ipcRenderer.invoke('getFileContent', id)
}
// background.js
// 获取文件内容
ipcMain.handle('getFileContent', (event, id) => {
    return new Promise((resolve, reject) => {
        let file = idToFilePath[id]
        if (!file) {
            resolve(null)
            return
        }
        fs.readFile(file, { encoding: 'utf-8' }, (err, data) => {
            if (err) {
                reject(err)
            } else {
                resolve({
                    name: path.parse(file).name,
                    content: JSON.parse(data)
                })
            }
        })
    })
})

拖拽文件到页面

除了打开文件选择对话框选择文件外,当然也可以直接拖拽文件到页面,这和普通的web页面实现逻辑是一样的,也就是使用拖放API

<div
    class="workbencheHomeContainer"
    @drop="onDrop"
    @dragenter="onPreventDefault"
    @dragover="onPreventDefault"
    @dragleave="onPreventDefault"
  >
</div>

<script>
export default {
    // 放置文件
    onDrop(e) {
      e.preventDefault()
      e.stopPropagation()

      let df = e.dataTransfer
      let dropFiles = []
	  // 从拖拽的文件中过滤出.smm文件
      if (df.items !== undefined) {
        for (let i = 0; i < df.items.length; i++) {
          let item = df.items[i]
          if (item.kind === 'file' && item.webkitGetAsEntry().isFile) {
            let file = item.getAsFile()
            if (/\.smm$/.test(file.name)) {
              dropFiles.push(file)
            }
          }
        }
      }
      if (dropFiles.length === 1) {
        // 如果只有一个文件,直接打开编辑
        window.electronAPI.openFile(dropFiles[0].path)
      } else if (dropFiles.length > 1) {
        // 否则添加到最近文件列表
        // ...
      }
    },

    onPreventDefault(e) {
      e.preventDefault()
      e.stopPropagation()
    }
}
</script>

如果只拖拽了一个文件,那么直接打开编辑窗口,否则添加到最近的文件列表上。

保存

保存存在两种情况,一是新建还未保存过的情况,这种需要先创建本地文件,再进行保存,第二种就是文件已经存在了,直接保存到文件即可。

// preload.js
contextBridge.exposeInMainWorld('electronAPI', {
    save: (id, data, fileName) => ipcRenderer.invoke('save', id, data, fileName)
}
// background.js
ipcMain.handle('save', async (event, id, data, fileName = '未命名') => {
    // 从idToFilePath对象中获取id对应的文件路径
    // id没有关联的文件路径,代表文件没有创建,那么先创建文件
    if (!idToFilePath[id]) {
        const res = dialog.showSaveDialogSync({
            title: '保存',
            defaultPath: fileName + '.smm',
            filters: [{ name: '思维导图', extensions: ['smm'] }]
        })
        // 创建成功后返回文件路径
        if (res) {
            idToFilePath[id] = res
            fs.writeFile(res, data)
        }
    } else {
        // 文件已经存在,那么直接保存
        fs.writeFile(idToFilePath[id], data)
    }
})

根据ididToFilePath对象中获取是否存在关联的文件路径,存在的话则代表文件已经创建了,否则先创建一个文件,并且和id关联起来。

拦截页面关闭事件

当在编辑页面进行了编辑,还未保存的情况下,如果直接点击关闭页面,通常需要进行二次确认,防止误关闭导致数据丢失。

因为Mac系统的关闭是使用默认的控制器,所以无法拦截关闭方法,只能拦截关闭事件:

// 页面
window.onbeforeunload = async e => {
    e.preventDefault()
    e.returnValue = ''
    // 没有未保存内容直接关闭
    if (!this.isUnSave) {
        window.electronAPI.destroy()
    } else {
        try {
            // 否则询问用户是否关闭
            await this.checkIsClose()
            // 用户选择关闭会走这里
            window.electronAPI.destroy()
        } catch (error) {
            // 用户选择不关闭会走这里
        }
    }
}

// 询问是否关闭页面
checkIsClose() {
    return new Promise((resolve, reject) => {
        this.$confirm('有操作尚未保存,是否确认关闭?', '提示', {
            confirmButtonText: '确定',
            cancelButtonText: '取消',
            type: 'warning'
        })
            .then(async () => {
            resolve()
        })
            .catch(() => {
            reject()
        })
    })
}

判断当前是否存在未保存的操作,是的话询问用户是否关闭,关闭窗口调用的是destroy,因为使用close方法又会被这个事件拦截,就进入死循环了。

// preload.js
contextBridge.exposeInMainWorld('electronAPI', {
    destroy: () => ipcRenderer.send('destroy')
}
// background.js
;[..., 'destroy'].forEach(item => {
    ipcMain.on(item, event => {
        const webContents = event.sender
        const win = BrowserWindow.fromWebContents(webContents)
        win[item]()
    })
})

最近文件

客户端需要存储、更新、删除最近操作的文件记录,存储使用的是electron-json-storage,APIlocalstorage的差不多。

创建文件、打开文件、拖入文件、复制文件、删除文件等操作都需要更新最近文件列表,比如前面提到的打开文件:

// background.js
// 打开文件
const openFile = (event, file) => {
    let id = uuid()
    idToFilePath[id] = file
    saveToRecent(file)// ++ 保存到最近文件
    createEditWindow(null, id)
}

// 保存到最近文件
import storage from 'electron-json-storage'
const RECENT_FILE_LIST = 'recentFileList'
const saveToRecent = file => {
  return new Promise((resolve, reject) => {
    let list = getRecent()
    // 如果文件已经存在,那么先删除
    let index = list.findIndex(item => {
      return item === file
    })
    if (index !== -1) {
      list.splice(index, 1)
    }
    // 再添加,也就是使之变成最近的一个文件
    list.push(file)
    storage.set(RECENT_FILE_LIST, list, err => {
      if (err) {
        reject(err)
      } else {
        resolve()
      }
    })
  })
}

// 获取最近文件列表
const getRecent = () => {
  let res = storage.getSync(RECENT_FILE_LIST)
  return (Array.isArray(res) ? res : []).filter(item => {
    return !!item
  })
}

当然,这个操作只是更新了客户端的存储,还需要通知页面更新才行,这就涉及到主进程到渲染进程的通信了。

主进程到渲染进程通信

还是以前面的打开文件编辑方法为例:

// background.js
// 打开文件
const openFile = (event, file) => {
    let id = uuid()
    idToFilePath[id] = file
    saveToRecent(file).then(() => {// 保存到最近文件完成后通知页面刷新
        notifyMainWindowRefreshRecentFileList()// ++
    })
    createEditWindow(null, id)
}

// 通知主页面刷新最近文件列表
const notifyMainWindowRefreshRecentFileList = () => {
    mainWindow.webContents.send('refreshRecentFileList')
}

调用指定窗口的webContents对象的send方法发送信息,同样需要在预加载脚本中中转:

// preload.js
contextBridge.exposeInMainWorld('electronAPI', {
    onRefreshRecentFileList: callback => ipcRenderer.on('refreshRecentFileList', callback)
}

然后在页面中调用onRefreshRecentFileList方法注册回调:

// 页面
window.electronAPI.onRefreshRecentFileList(() => {
    this.getRecentFileList()
})

这样预加载脚本中监听到主进程发送的信息后,就会执行传入的回调方法。

页面获取最近文件列表使用前面介绍的渲染进程和主进程的双向通信方法即可。

在文件夹里显示某个文件

这也是一个常见的功能,打开文件所在文件夹,并且定位到文件。

这个功能需要使用到shell模块。

// background.js
import { shell } from 'electron'

// 打开文件所在目录,并定位文件
ipcMain.on('openFileInDir', (event, file) => {
    shell.showItemInFolder(file)
})

使用系统默认浏览器打开页面

如果直接使用a标签打开页面,Electron默认会新开一个窗口显示,当然这个窗口就不被你控制了,所以会显示丑丑的默认控件,通常打开这种非客户端页面的url都是使用系统默认的浏览器打开,实现上,直接使用open库即可。

// background.js
import open from 'open'

// 使用默认浏览器打开指定url
ipcMain.on('openUrl', (event, url) => {
    open(url)
})

设置应用为文件的默认打开应用

这是一个很重要的功能,比如我们双击.txt文件,默认会打开txt编辑器,如果我们的应用支持打开某种文件,或者自定义了一种类型的文件,比如笔者的.smm文件,那么显然在双击这些文件时应该打开我们的应用,否则还要用户自己去设置默认应用,那体验是非常不好的。

要实现这个功能,首先需要在打包配置里设置,vue-cli-plugin-electron-builder插件使用的显然是electron-builder,具体的配置字段为fileAssociations

笔者的配置为:

// vue.config.js
module.exports = {
    pluginOptions: {
        electronBuilder: {
            fileAssociations: [
                {
                    ext: 'smm',
                    name: 'mind map file',
                    role: 'Editor',
                    icon: './build/icons/icon.ico'
                }
            ]
        }
    }
}

ext指定支持的文件扩展名,icon用于该种类型文件在文件夹里显示的图标,这样当安装了我们的应用,支持的文件默认就会显示我们配置的图标:

以上只解决了文件关联的功能,双击也能打开我们的应用,但是通常情况下,还需要直接在应用中打开该文件,比如双击html文件,要的不是打开浏览器主页,而是直接在浏览器中打开该文件。

这就是需要在应用中支持了,要获取双击打开文件的路径,可以在主进程中监听will-finish-launching事件,当应用程序完成基础的启动的时候会触发该事件,然后分平台处理,在Windows平台可以直接通过process.argv来获取文件路径,在Mac系统上通过监听open-file事件来获取:

// background.js

// 存储被双击打开的文件路径
const initOpenFileQueue = []
app.on('will-finish-launching', () => {
  if (process.platform == 'win32') {
    const argv = process.argv
    if (argv) {
      argv.forEach(filePath => {
        if (filePath.indexOf('.smm') >= 0) {
          initOpenFileQueue.push(filePath)
        }
      })
    }
  } else {
    app.on('open-file', (event, file) => {
      if (app.isReady() === false) {
        initOpenFileQueue.push(file)
      } else {
        // 应用已经启动了,直接打开文件
      }
      event.preventDefault()
    })
  }
})

// 可以在ready事件触发后处理initOpenFileQueue数据

当然,目前的实现存在一个问题,就是多次双击文件,会重复打开应用,理论上来说打开一次就够了,这个知道怎么解决的朋友欢迎评论区见~

打包

功能开发完了,最后一步当然是打包了,想要打包出Windows应用和Mac应用,你至少需要两台电脑,在Windows电脑上可以打包出Windows应用,在Mac系统上可以打包出MacLinux应用。

打包使用的是electron-builder,它有非常多的配置,支持签名和自动更新等功能,笔者并没有深入研究,更多的功能只能各位自己探索了,下面是笔者参考其他项目的打包配置。

打包配置

// vue.config.js
module.exports = {
    pluginOptions: {
        electronBuilder: {
            preload: 'src/electron/preload.js',
            builderOptions: {
                productName: '思绪思维导图',
                copyright: 'Copyright © 思绪思维导图',
                asar: true,
                // 设置为文件的默认应用
                fileAssociations: [
                    {
                        ext: 'smm',
                        name: 'mind map file',
                        role: 'Editor',
                        icon: './build/icons/icon.ico'
                    }
                ],
                directories: {
                    output: 'dist_electron'
                },
                mac: {
                    target: [
                        {
                            target: 'dmg',
                            arch: ['x64', 'arm64', 'universal']
                        }
                    ],
                    artifactName: '${productName}-${os}-${version}-${arch}.${ext}',
                    category: 'public.app-category.utilities',
                    darkModeSupport: false
                },
                win: {
                    target: [
                        {
                            target: 'portable',
                            arch: ['x64']
                        },
                        {
                            target: 'nsis',
                            arch: ['x64']
                        }
                    ],
                    publisherName: '思绪思维导图',
                    icon: 'build/icons/icon.ico'
                },
                linux: {
                    target: [
                        {
                            target: 'AppImage',
                            arch: ['x64']
                        }
                    ],
                    category: 'Utilities',
                    icon: './build/icon.icns'
                },
                dmg: {
                    icon: 'build/icons/icon.icns'
                },
                nsis: {
                    oneClick: false,// 取消一键安装
                    allowToChangeInstallationDirectory: true,// 允许用户选择安装路径
                    perMachine: true
                }
            }
        }
    }
}

然后在package.json文件中添加打包命令:

// package.json
{
    "scripts": {
        "electron:build": "vue-cli-service electron:build -p never",
        "electron:build-all": "vue-cli-service electron:build -p never -mwl",
        "electron:build-mac": "vue-cli-service electron:build -p never -m",
        "electron:build-win": "vue-cli-service electron:build -p never -w",
        "electron:build-linux": "vue-cli-service electron:build -p never -l"
    }
}

第一个命令会自动根据当前系统打包对应的应用。

打包过程中可能会在下载electron的原型包的一步卡住,这个只能多试几次,或者手动下载,具体操作可以百度一下。

应用图标

前面的打包配置中可以看到配置了几种不同格式的图标,也就是我们的应用图标,Windows系统用的是.ico格式的图片,而MacLinux系统用的是.icns的图标。

首先你需要准备一张1024*1024png图片icon.png

生成.ico的图片很简单,网上搜索一下找一个在线网站转一下就行了,比如这个:png-to-ico。

要生成.icns图片,你需要在Mac系统的命令行中执行一些命令:

// 命令行进入图片所在路径

// 创建一个临时文件夹
mkdir temp.iconset

// 在临时文件夹中生成10种大小的图片
sips -z 16 16 icon.png --out temp.iconset/icon_16x16.png
sips -z 32 32 icon.png --out temp.iconset/icon_16x16@2x.png
sips -z 32 32 icon.png --out temp.iconset/icon_32x32.png
sips -z 64 64 icon.png --out temp.iconset/icon_32x32@2x.png
sips -z 128 128 icon.png --out temp.iconset/icon_128x128.png
sips -z 256 256 icon.png --out temp.iconset/icon_128x128@2x.png
sips -z 256 256 icon.png --out temp.iconset/icon_256x256.png
sips -z 512 512 icon.png --out temp.iconset/icon_256x256@2x.png
sips -z 512 512 icon.png --out temp.iconset/icon_512x512.png
sips -z 1024 1024 icon.png --out temp.iconset/icon_512x512@2x.png

// 生成.icns图片
iconutil -c icns temp.iconset -o icon.icns

然后临时文件夹可以删除,不过最好把临时生成的10张图片也复制到icon.png所在文件,否则在打包Mac应用时可能会用到,但是不存在就会报错。

总结

本文分享了一下笔者做的一个简单的应用的细节,因为也是刚入门,所以某些方面可能会存在错误,或者有更好的实现方式,欢迎评论区见。有兴趣的朋友也可以下载体验一下~

源码地址:https://github.com/wanglin2/mind-map/tree/electron。

下载地址:https://github.com/wanglin2/mind-map/releases/tag/v0.1.0。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/525327.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

【C#】接口实现多态增强版

背景 在实际的生产中&#xff0c;会涉及到需要对接多种相似性较高的系统。具体而言就是业务接口是相同的&#xff0c;但是会出现接口的参数不同的情况。这时做一个对接隔离层就显得优势很明显了。这个隔离层的作用就有了两个基本的作用&#xff1a; 1、单一性&#xff0c;保护我…

【网络】- TCP/IP四层(五层)协议 - 物理层

目录 一、概述 二、物理层的基本概念 三、OSI 参考模型  &#x1f449;3.1 导引型传输媒体  &#x1f449;3.1 导引型传输媒体 一、概述 TCP/IP 在最初定义时&#xff0c;是一个四层的体系结构&#xff0c;包括应用层、传输层、网络层、网络接口层。不过从实质上来讲&#xf…

Makefile基础教程(变量的高级主题,变量的拓展)

文章目录 前言一、变量值的替换1.简单替换2.模式替换1.变量的模式替换2.规则中的模式替换 二、变量值的嵌套三、命令行变量四、define和override五.环境变量六.局部变量七.模式变量 总结 前言 本篇文章将给大家讲解一下变量的高级主题&#xff0c;变量的拓展&#xff0c;这些主…

详解C++类和对象(下篇)

目录 一&#xff0c;再谈构造函数 1.1 构造函数体赋值 1. 2 初始化列表 1.21 自定义类型成员 1.22 const 成员变量 1.23 引用成员变量 1. 24 初始化列表的“坑” 1. 3 explicit 关键字 二&#xff0c;static 成员 2.1 概念 2.2 特性 三&#xff0c; 友元 3.…

阿里云数据库RDS MySQL Serverless测评

文章目录 1. 背景2. 概念3. 操作步骤3.1 购买产品3.2 配置RDS账号3.3 设置网络访问权限3.4 连接实例 4. 与自建数据库相比的优势4.1 弹性设置4.2 监控比较直观4.3 报警比较灵活4.4 备份更安全、更方便 5. 总结 1. 背景 作为一枚程序员&#xff0c;在日常工作中少不了跟云产品打…

Linux C/C++并发编程实战(0)谈谈并发与并行

作为并发编程的第一讲&#xff0c;比较轻松&#xff0c;我们先来谈谈什么是并发和并行。 并发&#xff08;Concurrency&#xff09;是指一个处理器同时处理多个任务。 并行&#xff08;Parallelism&#xff09;是指多个处理器或者是多核的处理器同时处理多个不同的任务。 并发…

git rebase的理解

首先看下图 比如提价了三次&#xff0c;都是同一个文件的修改&#xff0c;有三次commit的信息 想把提交的版本信息变的好看一点&#xff0c;或者变成一次提交信息 // 这个表示要查看提交的三个版本并进行合并 git rebase -i HEAD~~~// 如何要合并多个版本 git rebase -i HEA…

媲美ChatGPT4的免费工具来了!傻瓜式教程不用魔法也能使用!

嗨呀 又是元气满满的一周啦 废话不多说直接进入正题&#xff0c;仅在注册时可能需要使用一些科学方法&#xff0c;使用完全无限制 优势 对中文的支持非常强大 无需魔法上网 不受限制 免费&#xff01;&#xff01;&#xff01; 实测优于ChatGPT3.5&#xff0c;略逊于4.0&…

vue-7:组件库(移动端vant)(PC端element)

移动端vant 插件安装&#xff08;按需导入&#xff09; 重启生效 # 通过 npm 安装 npm i unplugin-vue-components -D# 通过 yarn 安装 yarn add unplugin-vue-components -D 导入基于 vite 的项目&#xff1a; 如果是基于 vite 的项目&#xff0c;在 vite.config.js 文件中…

Git详细用法:Git概述 安装 常用命令 分支操作 团队协作 、GitHub、idea集成Git、idea集成GitHub、Gitee 码云、GitLab

0 课程介绍 说明&#xff1a; 在公司想要使用idea集成git&#xff1a; 首选需要下载安装Git&#xff08;查看第2章&#xff09;之后在中设置用户签名&#xff08;查看3.1&#xff09;然后在idea中集成Git&#xff08;查看第7章&#xff09;… 0.1 学习目标 第1章 Git 概述 …

高级语句(二)

一、VIEW&#xff08;视图&#xff09; 1、 概念 可以被当作是虚拟表或存储查询 视图跟表格的不同是&#xff0c;表格中有实际储存资料&#xff0c;而视图是建立在表格之上的一个架构&#xff0c;它本身并不实际储存资料。 临时表在用户退出或同数据库的连接断开后就自动消…

关于预处理器 sass 的超全用法

随着用户需求的增加&#xff0c;应用于页面的 css 代码越来越复杂越发臃肿难以维护&#xff0c;但是又没有 css 的替代品&#xff0c;css 预处理器作为 css 的扩展&#xff0c;出现在前端技术中。 sass 是 css 预处理器中常用的一种&#xff0c;它是一种动态样式语言&#xff0…

基于html+css图展示58

准备项目 项目开发工具 Visual Studio Code 1.44.2 版本: 1.44.2 提交: ff915844119ce9485abfe8aa9076ec76b5300ddd 日期: 2020-04-16T16:36:23.138Z Electron: 7.1.11 Chrome: 78.0.3904.130 Node.js: 12.8.1 V8: 7.8.279.23-electron.0 OS: Windows_NT x64 10.0.19044 项目…

C++系列九:预处理功能

预处理功能 1. 宏定义2. 文件包含3. 条件编译4. 代码注释5. 预处理器注意事项6. 总结 预处理器是 C 编译器提供的一个工具&#xff0c;允许程序员在编译之前对源代码文件做出修改。它主要是根据在代码中命名实体的定义&#xff08;如宏、条件编译指令&#xff09;、源文件调用等…

分布函数有什么意义?

累积分布函数&#xff08;CDF&#xff09;有什么意义&#xff1f; 参考文献&#xff1a;姜咏梅. 浅析分布函数的意义与应用[J]. 科学与财富,2014(10):207-207,208. DOI:10.3969/j.issn.1671-2226.2014.10.183. 关于PMF、PDF、CDF的介绍&#xff0c;移步至我的笔记&#xff1a…

【SPSS】因子分析详细操作教程(附案例实战)

🤵‍♂️ 个人主页:@艾派森的个人主页 ✍🏻作者简介:Python学习者 🐋 希望大家多多支持,我们一起进步!😄 如果文章对你有帮助的话, 欢迎评论 💬点赞👍🏻 收藏 📂加关注+ 目录 因子分析 因子分析案例 因子分析

Clion开发STM32之OTA升级模块(一)

什么是OTA 百度百科解释个人理解&#xff1a;就是不通过烧录的方式&#xff0c;通过串口、网口、无线对主板运行的程序进行升级。减少后期的一个维护迭代程序的一个成本。 STM32的OTA升级模块的一个设计 程序启动的一个框架流程图(大致流程) FLASH的一个划分框图 BootLoader…

Nautilus Chain 或成未来最好的链上隐私生态

Nautilus Chain 目前仍旧处于测试网阶段&#xff0c;作为目前行业内首个&#xff0c;也是最受关注的 Layer3 模块化链&#xff0c;Nautilus Chain 在测试网早期阶段&#xff0c;整体就有着十分出色的数据表现。而该链有望在 6 月上线主网&#xff0c;面向更为广泛的开发者、用户…

分布式数据库设计与实现

分布式数据库设计与实现 摘要背景二期项目包括数据库选型分布式数据库设计数据集成测试部署分布式数据库扩展阅读 摘要 : 本文论述《金蚕工程》的分布式数据库的设计和实现。该项目的设计目标是实现企业间茧、丝等的合同交易&#xff08;交易规则和期货交易一样&#xff09;、…

【springcloud 微服务】springcloud openfeign使用详解

目录 一、前言 二、openfeign介绍 2.1 openfeign介绍 2.2 openfeign优势 三、Spring Cloud Alibaba整合OpenFeign 3.1 前置准备 3.2 代码整合过程 3.2.1 添加feign依赖 3.2.2 添加feign接口类 3.2.3 调整调用的方法 3.2.4 核心配置文件 3.2.5 接口模拟测试 四…