✨用 Electron 造一个应用管理软件,一键启动所有程序或者命令行✨

1,571 阅读5分钟

1734932548277.jpg

前言

每次电脑重启后都需要自己再打开一堆的文档、应用程序。总是觉得很麻烦。正巧再学习Electron,就有了一个自己弄一个应用程序管理器,把那些需要启动的命令管理起来。然后就可以直接用客户端直接一键启动那些软件。

技术基础要求

简单的理解electron中的模块分层和知道electron的文件目录结构
一些简单node.js和vue3的相关基础

效果图

image.png

实现原理

关键的两个方法是node中的child_process模块执行cmd命令行和使用Electron中的shell模块中的openPath(path)方法去启动应用程序,当然如果你熟悉shell脚本可以都使用child_process去实现

技术栈

技术栈官网
vue3cn.vuejs.org/
electronelectron.nodejs.cn/
node.jswww.nodeapp.cn/

代码实现

image.png

一、数据字段与页面设计
    // 主要数据格式
    type FileData = {
      id?:String, 
      url:String, // 启动文件的路径或者是命令行命令
      startMode:Boolean, // 启动模式,T:启动应用,F:命令行执行
      editStatus:Boolean, // 开启输入框编辑
    }

页面的html样式

<template>
  <div v-for=" (fileData, index) in startFileData" :key="index">
    <el-switch v-model="fileData.startMode" size="large" active-text="启动程序" inactive-text="执行命令" />
    <el-input :disabled="!fileData.editStatus" v-model="fileData.url" style="width: 300px" placeholder="请输入路径或者命令...">
      <template #suffix>
        <el-icon class="el-input__icon mouse-style" @click="fileData.editStatus = !fileData.editStatus">
          <el-tooltip :disabled="disabled" content="编辑" placement="bottom" :hide-after=0 effect="light">
            <Edit v-show="!fileData.editStatus" />
          </el-tooltip> 
          <el-tooltip :disabled="disabled" content="保存"  placement="bottom" :hide-after=0 effect="light">
            <Select v-show="fileData.editStatus" @click="saveStartFile(fileData)" /> 
          </el-tooltip> 
        </el-icon>
      </template>
    </el-input>
    <el-button type="primary" v-model="fileData.startMode" @click="checkedFile(fileData)">{{ fileData.startMode ? "选择文件"
      : "选择文件夹" }}</el-button>
    <el-button type="primary" @click="ls(fileData)">启动</el-button>
    <el-button type="danger" @click="delStartFile(fileData)" :icon="Delete" circle />
  </div>
  <el-button @click="addStartFile">添加</el-button>
</template>

image.png

二、ipc通讯模块和获取文件路径的方法配置

选择文件采用的是electron中的dialog.showOpenDialog方法。可以直接获取文件的路径。 但是因为在electron中页面是在渲染器中,而dialog模块是只能在进程中才可以使用的,所以还需要采用electron中的ipc通讯模块

配置ipc的通讯,编写统一监听的方法,可以统一的管理需要监听开启的方法

// mian/monitor/index.ts
interface monitorFunType {
    name: string;
    args?: any[];
}
/**
 * 开启模块的监听方法
 * @param monitorModule 需要开启监听的模块
 * @param monitorFun 模块中的方法
 * @return void
 * @example
 * onMonitorFunction('monitorModule',"getFilePath")
 * onMonitorFunction('monitorModule',["getFilePath","consoleTest"])
 * onMonitorFunction('monitorModule',["getFilePath",{name:'monitorModule',args:any}])
 */
export function onMonitorFunction(monitorModule: Record<string, Function>, monitorFun: string | monitorFunType | Array<string | monitorFunType>):void {
    if (typeof monitorModule !== 'object' || monitorModule === null) {
        throw new Error('需要开启的对象不是一个有效的module');
    }
 
    const funcDescriptors = Array.isArray(monitorFun) ? monitorFun : [monitorFun];
 
    funcDescriptors.forEach(descriptor => {
        if (typeof descriptor === 'string') {
            const methodName = descriptor;
            const method = monitorModule[methodName];
 
            if (typeof method === 'function') {
                try {
                    method();
                } catch (error:any) {
                    console.error(`Error calling method ${methodName}: ${error.message}`);
                }
            } else {
                console.warn(`Method ${methodName} does not exist in monitorModule`);
            }
        } else if (typeof descriptor === 'object' && 'name' in descriptor) {
            const { name, args } = descriptor as monitorFunType;
            const method = monitorModule[name];
 
            if (typeof method === 'function') {
                try {
                    method(...(args || []));
                } catch (error:any) {
                    console.error(`Error calling method ${name} with args ${JSON.stringify(args || [])}: ${error.message}`);
                }
            } else {
                console.warn(`Method ${name} does not exist in monitorModule`);
            }
        } else {
            throw new Error('启动的方法描述符必须是字符串或者包含name属性的对象');
        }
    })
}

将一个类别的相关ipc监听模块放在同一个文件里,方便处理 这个文件是处理文件类ipc模块的方法

//main/monitor/fileMonitor.ts
/**
 * 接收一个参数,判断作为是否是打开文件夹,还是打开文件。返回文件的绝对路径
 * @param  {boolena} isFile
 * @return  filePaths
 * @example getFilePath();
 */
export function getFilePath() {
    ipcMain.handle("getFilePath", async (event,isFile) => {
        let filePaths:any;
        if(isFile){
            //在showOpenDialog中接收properties的参数中,若是同时存在'openFile'和'openDirectory'时
            // 会优先的执行打开文件夹得参数也就是openDirectory,所以需要进行判断
            filePaths = (await dialog.showOpenDialog({ properties: ['openFile'] })).filePaths;
        }else{
            filePaths = (await dialog.showOpenDialog({ properties: ["openDirectory"] })).filePaths;
        }
        return filePaths.length > 0 ? filePaths[0] : null;
    })
}

在主进程中得入口文件中使用监听模块管理方法开启文件管理的ipc监听

//main/index.ts
//引入相关的文件
// 监听方法启动器
import { onMonitorFunction } from './monitor/index'
// 文件监听模块
import * as FileModule from './monitor/fileMonitor'

在创建窗口方法中进行配置监听
function createWindow(): void {
    ···
      // 加载监听器
      onMonitorFunction(FileModule, ["getFilePath"])
    ···
}

然后在渲染器中使用获取文件路径的方法

//renderer/view/index.vue

//获取文件路径
const checkedFile = (fileData) => {
  fileData.editStatus = true
  ipcRenderer.invoke("getFilePath", fileData.startMode).then(res => {
    fileData.url = res
  });
}
三、启动应用程序的方法
//在main/monitor/fileMonitor.ts文件配置打开文件的方法
export function openFile(){
    ipcMain.handle("openFile",async (event,url)=>{
        // 采用shell模块的openPath方法就可以打开文件
        // ps:shell模块还有很多相关的操作文件的方法,具体的可以去官网看看
        shell.openPath(url)
    })
}

// 入口文件中添加监听方法
function createWindow(): void {
      ···
      // 加载监听器
      onMonitorFunction(FileModule, ["getFilePath"])
      ···
}
四、配置执行命令行的方法

child_process直接使用require在渲染器中引入就可以了
命令行中 /k 代表打开命令行后关闭,如果不带 /k的话命令会直接执行完成之后就关闭了命令行

// renderer/src/view/Index.vue

let exec = require('child_process').exec;

function startCommand(url){
    let multiLineCommand = `start cmd.exe /k "${url}"`
    exec(multiLineCommand, function (error: any, stdout: any, stderr: any) {
      if (error) {
        console.log(error.stack);
      }
      console.log('Child Process STDOUT: ' + stdout + stderr);
    });
}

五、相关文件的整体代码
rednderer/src/view/index.vue
<template>
  <div v-for=" (fileData, index) in startFileData" :key="index">
    <el-switch v-model="fileData.startMode" size="large" active-text="启动程序" inactive-text="执行命令" />
    <el-input :disabled="!fileData.editStatus" v-model="fileData.url" style="width: 300px" placeholder="请输入路径或者命令...">
      <template #suffix>
        <el-icon class="el-input__icon mouse-style" @click="fileData.editStatus = !fileData.editStatus">
          <el-tooltip :disabled="disabled" content="编辑" placement="bottom" :hide-after=0 effect="light">
            <Edit v-show="!fileData.editStatus" />
          </el-tooltip> 
          <el-tooltip :disabled="disabled" content="保存"  placement="bottom" :hide-after=0 effect="light">
            <Select v-show="fileData.editStatus" @click="saveStartFile(fileData)" /> 
          </el-tooltip> 
        </el-icon>
      </template>
    </el-input>
    <el-button type="primary" v-model="fileData.startMode" @click="checkedFile(fileData)">{{ fileData.startMode ? "选择文件"
      : "选择文件夹" }}</el-button>
    <el-button type="primary" @click="ls(fileData)">启动</el-button>
    <el-button type="danger" @click="delStartFile(fileData)" :icon="Delete" circle />
  </div>
  <el-button @click="addStartFile">添加</el-button>
</template>

<script setup lang="ts">
import { onMounted, ref,Ref } from 'vue'
import { ipcRenderer } from 'electron'
import { Edit, Select,Delete } from "@element-plus/icons-vue";
import { saveStartFileApi,getStartFileApi, updateStartFileApi, delStartFileApi } from '@renderer/api/fileApi';

type FileData = {
  id?:String,
  url:String,
  startMode:Boolean,
  editStatus:Boolean,
  isAdd?:Boolean
}

const disabled = ref(false)
const startFileData:Ref<Array<FileData>> = ref([{
  url: '',
  startMode: true,
  editStatus: false,
  isAdd:true
}])

const saveStartFile = (fileData:FileData)=>{
  fileData.editStatus = false
  if(fileData.isAdd){
    saveStartFileApi(fileData).then(res=>{
      console.log(res)
      getStartFileList(page)
    })
  }else{
    updateStartFileApi(fileData).then(res=>{
      console.log(res)
      getStartFileList(page)
    })
  }
}

const page = {current:'1',size:'10'}
const getStartFileList = (page)=>{
  getStartFileApi(page).then(res=>{
    startFileData.value = res.data.records
  },err=>{
    console.log(err)
  })
}

const delStartFile = (fileData)=>{
  delStartFileApi(fileData).then(res=>{
    console.log(res)
  })
}

const addStartFile = ()=>{
  startFileData.value.push({
    url:'',
    startMode:true,
    editStatus:true,
    isAdd:true
  })
}

const checkedFile = (fileData) => {
  fileData.editStatus = true
  ipcRenderer.invoke("getFilePath", fileData.startMode).then(res => {
    fileData.url = res
  });
}

let exec = require('child_process').exec;

function ls(fileData) {
  const { startMode, url } = fileData
  let multiLineCommand: string;

  if (startMode) {
    ipcRenderer.invoke("openFile", url).then(res => {
      console.log(res)
    }, err => {
      console.log(err)
    })
  } else {
    multiLineCommand = `start cmd.exe /k "${url}"`
    exec(multiLineCommand, function (error: any, stdout: any, stderr: any) {
      if (error) {
        console.log(error.stack);
      }
      console.log('Child Process STDOUT: ' + stdout + stderr);
    });
  }
}

onMounted(()=>{
  getStartFileList(page)
})

</script>

<style lang="scss" scoped>
.el-input__icon {
  cursor: pointer !important;
  z-index: 1000 !important;
}
</style>
main/monitor/fileMonitor.ts
import { dialog, ipcMain, shell } from 'electron';

/**
 * 返回文件的绝对路径
 * @param  {boolena} isFile
 * @return  filePaths
 * @example getFilePath();
 */
export function getFilePath() {
    ipcMain.handle("getFilePath", async (event,isFile) => {
        let filePaths:any;
        //在showOpenDialog中接收properties的参数中,若是同时存在'openFile'和'openDirectory'时
        // 会优先的执行打开文件夹得参数也就是openDirectory,所以需要进行判断
        if(isFile){
            filePaths = (await dialog.showOpenDialog({ properties: ['openFile'] })).filePaths;
        }else{
            filePaths = (await dialog.showOpenDialog({ properties: ["openDirectory"] })).filePaths;
        }
        return filePaths.length > 0 ? filePaths[0] : null;
    })
}

export function openFile(){
    ipcMain.handle("openFile",async (event,url)=>{
        shell.openPath(url)
    })
}
main/monitor/index.ts
interface monitorFunType {
    name: string;
    args?: any[];
}
/**
 * 开启模块的监听方法
 * @param monitorModule 需要开启监听的模块
 * @param monitorFun 模块中的方法
 * @return void
 * @example
 * onMonitorFunction('monitorModule',"getFilePath")
 * onMonitorFunction('monitorModule',["getFilePath","consoleTest"])
 * onMonitorFunction('monitorModule',["getFilePath",{name:'monitorModule',args:any}])
 */
export function onMonitorFunction(monitorModule: Record<string, Function>, monitorFun: string | monitorFunType | Array<string | monitorFunType>):void {
    if (typeof monitorModule !== 'object' || monitorModule === null) {
        throw new Error('需要开启的对象不是一个有效的module');
    }
 
    const funcDescriptors = Array.isArray(monitorFun) ? monitorFun : [monitorFun];
 
    funcDescriptors.forEach(descriptor => {
        if (typeof descriptor === 'string') {
            const methodName = descriptor;
            const method = monitorModule[methodName];
 
            if (typeof method === 'function') {
                try {
                    method();
                } catch (error:any) {
                    console.error(`Error calling method ${methodName}: ${error.message}`);
                }
            } else {
                console.warn(`Method ${methodName} does not exist in monitorModule`);
            }
        } else if (typeof descriptor === 'object' && 'name' in descriptor) {
            const { name, args } = descriptor as monitorFunType;
            const method = monitorModule[name];
 
            if (typeof method === 'function') {
                try {
                    method(...(args || []));
                } catch (error:any) {
                    console.error(`Error calling method ${name} with args ${JSON.stringify(args || [])}: ${error.message}`);
                }
            } else {
                console.warn(`Method ${name} does not exist in monitorModule`);
            }
        } else {
            throw new Error('启动的方法描述符必须是字符串或者包含name属性的对象');
        }
    })
}
main/index.ts
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 { onMonitorFunction } from './monitor/index'
// 文件监听模块
import * as FileModule from './monitor/fileMonitor'
// 窗口监听模块
import * as WindowModule from './monitor/windowMonitor'


function createWindow(): void {
  // Create the browser window.
  const mainWindow = new BrowserWindow({
    width: 900,
    height: 670,
    frame: false,//设置无边框
    center: true,
    show: false,
    autoHideMenuBar: true,
    ...(process.platform === 'linux' ? { icon } : {}),
    webPreferences: {
      preload: join(__dirname, '../preload/index.js'),
      sandbox: false,
      nodeIntegration: true,
      contextIsolation: false,//采用这种方法必须设置为false
      webSecurity:false
    }
  })


  mainWindow.loadFile('http://loaclhost:3000');

  // 加载监听器
  onMonitorFunction(FileModule, ["getFilePath", "openFile"])
  onMonitorFunction(WindowModule, ["closeWindow", { name: 'maxWindow', args: [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'))
  }
}

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

// 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.
六、代码仓库

gitee:gitee.com/liu_soon/el…

后记

文中代码有一部分是和后端交互的,主要用于对数据进行交互。但我并没有贴出后端的相关代码,主要看思路,后端只是简单的curd,你可以使用其他方式进行数据存储