Electron-vue開發實戰7——命令列呼叫與系統級別右鍵選單的實現

Molunerfinn發表於2019-04-17

原文首發在我的部落格,歡迎關注!

前言

前段時間,我用electron-vue開發了一款跨平臺(目前支援主流三大桌面作業系統)的免費開源的圖床上傳應用——PicGo,在開發過程中踩了不少的坑,不僅來自應用的業務邏輯本身,也來自electron本身。在開發這個應用過程中,我學了不少的東西。因為我也是從0開始學習electron,所以很多經歷應該也能給初學、想學electron開發的同學們一些啟發和指示。故而寫一份Electron的開發實戰經歷,用最貼近實際工程專案開發的角度來闡述。希望能幫助到大家。

預計將會從幾篇系列文章或方面來展開:

  1. electron-vue入門
  2. Main程式和Renderer程式的簡單開發
  3. 引入基於Lodash的JSON database——lowdb
  4. 跨平臺的一些相容措施
  5. 通過CI釋出以及更新的方式
  6. 開發外掛系統——CLI部分
  7. 開發外掛系統——GUI部分
  8. 命令列呼叫與系統級別右鍵選單的實現
  9. 想到再寫...

說明

PicGo是採用electron-vue開發的,所以如果你會vue,那麼跟著一起來學習將會比較快。如果你的技術棧是其他的諸如reactangular,那麼純按照本教程雖然在render端(可以理解為頁面)的構建可能學習到的東西不多,不過在main端(Electron的主程式)應該還是能學習到相應的知識的。

如果之前的文章沒閱讀的朋友可以先從之前的文章跟著看。本文主要是基於PicGo v2.1.0版本更新的重要內容做的講述。

命令列呼叫

我們在使用一些Electron開發的應用程式的時候,可以發現有些程式是可以通過命令列喚起的。比如VSCode,在macOS的.bash_profile裡可以設定alias code='/Applications/Visual\ Studio\ Code.app/Contents/Resources/app/bin/code',這樣就可以在命令列裡通過code xxx.js來呼叫VSCode開啟檔案了。如果想開啟當前目錄,可以通過code .,如果想開啟某個目錄code xxx等等。

命令列呼叫裡其實還涉及到一個問題,有的時候我們的應用是個「單例應用」,也就是不能「多開」。如何在只能單開的應用裡,也實現命令列呼叫呢?比如PicGo,在軟體開啟的時候,命令列呼叫它也能上傳圖片,而不是開啟一個新的PicGo視窗。沒事,下面會詳細說明。

實現命令列呼叫

首先我們要來實現命令列呼叫。其實Electron的命令列呼叫沒有什麼特殊的地方,與在Node.js端很類似。我以PicGo舉例:

當我們在Windows下安裝好了PicGo之後,可以在安裝目錄裡找到PicGo.exe。你有沒有想過在命令列裡執行這個exe會怎麼樣呢?在安裝目錄裡開啟powershell,輸入.\PicGo.exe,你會發現PicGo已經被開啟了。如果我是加了一些引數開啟會怎麼樣呢.\PicGo.exe upload

我們可以在main程式裡的ready事件裡把命令列引數列印出來:

app.on('ready', () => {
  console.log(process.argv) // ['D:\\PicGo.exe', 'upload']
})
複製程式碼

關鍵出現了,我們可以通過process.argv這個在Node.js端獲取命令列引數的關鍵變數同樣獲得Electron被命令列開啟後的命令列引數。那麼我們就可以在main程式的ready階段通過獲取的process.argv引數來實現我們對應的功能。

對於PicGo而言,如果通過命令列開啟它,並且傳遞了upload xxx.jpg的話,我們就可以認為使用者需要呼叫PicGo來實現上傳一張圖片。那麼我們可以這麼做(以下是例項程式碼):

import path from 'path'
import fs from 'fs-extra'
const getUploadFiles = (argv = process.argv, cwd = process.cwd()) => {
   files = argv.slice(2) // 過濾['D:\\PicGo.exe', 'upload']這兩個引數,直接獲取需要上傳的圖片路徑
   let result = []
   if (files.length > 0) { // 如果圖片列表不為空
     result = files.map(item => {
       if (path.isAbsolute(item)) { // 如果是絕對路徑
         return {
           path: item
         }
       } else {
         let tempPath = path.join(cwd, item) // 如果是相對路徑,就拼接
         if (fs.existsSync(tempPath)) { // 判斷檔案是否存在
           return {
             path: tempPath
           }
         } else {
           return null
         }
       }
     }).filter(item => item !== null) // 排除為null的路徑
   }
   return result // 返回結果
}
複製程式碼

拿到圖片列表後就執行自帶的上傳邏輯即可。下面說說單開應用的命令列呼叫注意事項。

實現單例應用的命令列呼叫

Electron的發展很快,本文講述的Electron版本為當前最新的v4.1.4,所以關於實現單例應用的api也是跟隨官方文件走的,如果你的Electron版本不是v4.x,那麼需要找對應版本的Electron文件。

當前版本下實現單例應用的官方例子是:

const { app } = require('electron')
let myWindow = null

const gotTheLock = app.requestSingleInstanceLock() // 拿到單例鎖

if (!gotTheLock) { // 如果一個應用二次開啟,那麼getTheLock為false
  app.quit() // 立即退出二次開啟的應用
} else {
  app.on('second-instance', (event, commandLine, workingDirectory) => { // 一個應用嘗試開啟第二個例項時觸發
    // Someone tried to run a second instance, we should focus our window.
    if (myWindow) {
      if (myWindow.isMinimized()) myWindow.restore()
      myWindow.focus()
    }
  })

  // Create myWindow, load the rest of the app, etc...
  app.on('ready', () => {
  })
}
複製程式碼

注意有個second-instance事件。當我們試圖在開啟一個單例應用之後再開啟這個應用的時候,就會觸發這個事件。並且這個事件的回撥函式裡,有commandLineworkingDeirectory,實際上它們就是process.argv和對應的cwd(執行路徑)。因此我們可以在這個事件裡書寫當應用試圖被二次開啟的時候應該做的事的邏輯。以下依然以PicGo舉例:

app.on('second-instance', (event, commandLine, workingDirectory) => {
 let files = getUploadFiles(commandLine, workingDirectory)
 if (files === null || files.length > 0) { // 如果有檔案列表作為引數,說明是命令列啟動
   if (files === null) { // 如果為null說明是讓PicGo上傳剪貼簿的圖片
     uploadClipboardFiles()
   } else { // 否則說明是讓PicGo上傳具體的圖片檔案
     // ...
     uploadChoosedFiles(win.webContents, files)
   }
 } else { // 如果files === [] 說明並不是命令列啟動或者並沒有帶額外引數
   if (settingWindow) { // 說明使用者是點選了PicGo圖示啟動,那麼這個時候把原有的視窗調出來並focus即可
     if (settingWindow.isMinimized()) {
       settingWindow.restore()
     }
     settingWindow.focus()
   }
 }
})
複製程式碼

這裡我們通過讀取commandLine引數,來判斷使用者是用命令列來呼叫PicGo上傳圖片的,還是僅僅是通過PicGo的圖示再次開啟PicGo的。關鍵的邏輯就是判斷commandLine裡有沒有關鍵的引數,從而得出是否是從命令列呼叫我們的應用的。如果使用者僅僅是通過PicGo圖示再次開啟PicGo,那麼我們應該把之前開啟過的視窗復原並啟用,告訴使用者你之前已經開啟過這個應用了。當然具體的業務邏輯不能一概而論,這裡只是我對PicGo的一點理解,只需知道核心是監聽second-instance事件即可。

以下是上述實現的截圖,注意命令列輸出都只在第一個終端程式裡,說明我們實現了單例應用的命令列呼叫:

Electron-vue開發實戰7——命令列呼叫與系統級別右鍵選單的實現

macOS的命令列呼叫

其實這個章節到上面基本結束。不過我想起我演示的是在Windows下做的,相對簡單。而macOS下的命令列呼叫Electron應用會有個坑,所以還是要說一下為好。(由於我沒有Linux機器,所以Linux部分就不說明了,有興趣的朋友可以測試一下跟我反饋!)

大家都知道macOS的應用基本是放在Application下的,所以我們會很自然想到直接命令列呼叫它們:

open /Applications/PicGo.app
複製程式碼

但是這樣做並不能傳遞引數進去,因為執行命令的是open

所以我們需要到更深層次的路徑啟動PicGo並傳遞引數進去:

/Applications/PicGo.app/Contents/MacOS/PicGo upload xxx.jpg
複製程式碼

只有這樣才能像Windows那樣類似PicGo.exe來實現呼叫。

值得注意的是,Electron的macOS應用想要在生產階段開啟debug模式檢視console的輸出也是到上述應用的對應目錄下:

/Applications/PicGo.app/Contents/MacOS/PicGo --debug
複製程式碼

Widnows相對簡單,只需要:

.\PicGo.exe --debug
複製程式碼

(Linux請自測)

系統級別右鍵選單

在實現了命令列呼叫的功能之後,我就在考慮給PicGo加上原生的系統右鍵選單。這樣做的好處是使用者可以直接在一張圖片上右鍵->通過PicGo上傳。例如:

Windows下:

Electron-vue開發實戰7——命令列呼叫與系統級別右鍵選單的實現

macOS下:

Electron-vue開發實戰7——命令列呼叫與系統級別右鍵選單的實現

接下來說說二者在實現上不同的地方。(Linux沒有測試,歡迎有興趣的小夥伴測試一下跟我說說~)

Windows

Windows的右鍵選單的原理其實很簡單,在登錄檔裡寫入值就行。篇幅原因不會對Windows登錄檔的知識做過多的展開。我們只關注往哪裡寫值,寫哪些值才能實現我們要的效果。

首先我們可以看看VScode是如何實現右鍵選單「Open with Code」的。

VScode的右鍵選單

在系統裡按快捷鍵WIN+R然後輸入regedit開啟登錄檔編輯器,我們來找到VSCode的右鍵選單所在地:

HKEY_CLASSES_ROOT*shellVSCode:

Electron-vue開發實戰7——命令列呼叫與系統級別右鍵選單的實現

可以看到一個「預設」的屬性下的資料為「Open w&ith Code」,這個就是我們看到的選單名。而一個叫「Icon」的屬性下的資料為VSCodeexe安裝路徑。所以可以認為這個Icon可以獲取exeIcon並顯示到選單上。

不過這裡還沒有看到如何將檔案路徑作為引數傳入VScode的。繼續看:

HKEY_CLASSES_ROOT*shellVSCodecommand:

Electron-vue開發實戰7——命令列呼叫與系統級別右鍵選單的實現

command目錄下我們看到了如下資料:

"C:\Users\PiEgg\AppData\Local\Programs\Microsoft VS Code\Code.exe" "%1"

可以看出這個%1就是作為引數傳給Code.exe的。有了VSCode作為參考,給自己的Electron應用實現一個系統級別的右鍵選單也不難了。有人可能會說我可以在應用啟動階段通過某些npm包(比如windows-registry)來實現對登錄檔的寫入。

不過實際上,在Windows平臺,如果你是用electron-builder打包的話有一個更簡潔的解決方案,那就是編寫NSIS指令碼來實現,對此electron-builder官方給出的文件可以一看。

本文不對NSIS指令碼做過多的描述,你只需要知道它是用來生成Windows安裝介面的一門指令碼語言,你可以通過它來控制安裝(解除安裝)介面都有哪些元素。並且它可以接入安裝的生命週期,做一些操作,比如寫入登錄檔。我們利用這個特性,來給PicGo做一個安裝階段寫入登錄檔的操作,實現系統級別的右鍵選單。

electron-builderNSIS暴露的鉤子主要有customHeader, preInit, customInit, customInstall, customUnInstall,等等。

我們可以在customInstall階段通過獲取使用者安裝PicGo的路徑$INSTDIR來實現對登錄檔關鍵值的寫入。自己書寫的installer.nsh預設放在專案的build目錄下,那麼electron-builder在構建Windows應用的時候將會自動讀取這個檔案以及package.json裡的配置來生成安裝介面。

寫入登錄檔的格式大概是這樣:

WriteRegStr <reg-path> <your-reg-path> <attr-name> <value>
複製程式碼

以下是PicGo的installer.nsh,僅供參考:

!macro customInstall
   WriteRegStr HKCR "*\shell\PicGo" "" "Upload pictures w&ith PicGo"
   WriteRegStr HKCR "*\shell\PicGo" "Icon" "$INSTDIR\PicGo.exe"
   WriteRegStr HKCR "*\shell\PicGo\command" "" '"$INSTDIR\PicGo.exe" "upload" "%1"'
!macroend
!macro customUninstall
   DeleteRegKey HKCR "*\shell\PicGo"
!macroend
複製程式碼

注意HKCR即是登錄檔目錄HKEY_CLASSES_ROOT的縮寫。在寫value的時候如果要寫多個引數,可以用單引號包起來。attr-name不寫即為預設。相信有了VSCode的右鍵選單登錄檔說明,你也能看得懂上面的PicGo的指令碼了。同時注意我們應該在解除安裝階段將之前寫的登錄檔刪除,以免使用者解除安裝了應用之後選單還在,上述指令碼的後面部分是是在做這個事情。

因為上一章實現了命令列呼叫,所以我們的選單就可以通過'"$INSTDIR\PicGo.exe" "upload" "%1"'來實現選單呼叫命令了。

macOS

macOS的話可以通過實現自動化指令碼來生成右鍵選單。開啟automator

Electron-vue開發實戰7——命令列呼叫與系統級別右鍵選單的實現

然後新建一個快速操作:

Electron-vue開發實戰7——命令列呼叫與系統級別右鍵選單的實現

將快速操作的工作流程限制到影象檔案,並且只作用於訪達.app裡,同時在左側選單裡找到shell元件,將其拖拽到右側編輯區:

Electron-vue開發實戰7——命令列呼叫與系統級別右鍵選單的實現

shell選擇成/bin/bash,傳遞輸入選成作為自變數

Electron-vue開發實戰7——命令列呼叫與系統級別右鍵選單的實現

然後將預設的內容改成如下(實際上就差不多是之前說的macOS下如何命令列呼叫Electron應用的寫法):

/Applications/PicGo.app/Contents/MacOS/PicGo upload "$@" > /dev/null 2>&1 &
複製程式碼

其中macOS的快捷操作裡,是通過"$@"來作為引數傳遞的。

如何作為右鍵選單?只要把你生成的這個workflow檔案(夾),放到~/Library/Services這個目錄下就行了。

這樣你就在你右鍵選單裡看到它:

Electron-vue開發實戰7——命令列呼叫與系統級別右鍵選單的實現

如果你的服務項過多的話,會在服務的二級選單裡看到它:

Electron-vue開發實戰7——命令列呼叫與系統級別右鍵選單的實現

其中,選單名就是你生成的這個workflow的檔案(夾)名。

那麼生成了這個workflow之後,我們如何實現不讓使用者手動建立,而是自動幫他們放到~/Library/Services目錄下呢?macOS沒有Windows那麼方便的安裝工具指令碼語言,那麼我們可以在main程式裡手動來實現這個功能。下面是PicGo的beforeOpen.js,其中我們將我們生成的workflow檔案(夾)放到專案的static目錄下。

import fs from 'fs-extra'
import path from 'path'
import os from 'os'
if (process.env.NODE_ENV !== 'development') {
  global.__static = path.join(__dirname, '/static').replace(/\\/g, '\\\\')
}
if (process.env.DEBUG_ENV === 'debug') {
  global.__static = path.join(__dirname, '../../../static').replace(/\\/g, '\\\\')
}
function beforeOpen () {
  const dest = `${os.homedir}/Library/Services/Upload pictures with PicGo.workflow`
  if (fs.existsSync(dest)) { // 判斷是否存在
    return true
  } else { // 如果不存在就複製過去
    try {
      fs.copySync(path.join(__static, 'Upload pictures with PicGo.workflow'), dest)
    } catch (e) {
      console.log(e)
    }
  }
}

export default beforeOpen
複製程式碼

然後在主程式里加入這個方法,並判斷是否在macOS下執行:

// main/index.js
import beforeOpen from './utils/beforeOpen'
// ...
if (process.platform === 'darwin') {
  beforeOpen()
}
// ...
複製程式碼

這樣使用者在安裝PicGo之後,開啟軟體之後,他的右鍵選單就多了一個「Upload pictures with PicGo」項了。

小結

至此,一個Electron應用的命令列呼叫以及系統級別右鍵選單的實現就講述完了。當然可能還有其他實現的方式,以及更細緻的實現(比如還能支援資料夾右鍵等等)。我在這裡也只是一個拋磚引玉,其他的實現或者更好的實現方式需要自己摸索啦。當然本文沒有Linux的相關內容,主要是我時間有限並且沒有Linux機器,所以也希望有興趣的朋友自己在Linux下實現了本文的功能後也能跟我說說~

本文很多都是我在開發PicGo的時候碰到的問題、踩的坑。也許文中簡單的幾句話背後就是我無數次的查閱和除錯。希望這篇文章能夠給你的electron-vue開發帶來一些啟發。文中相關的程式碼,你都可以在PicGoPicGo-Core的專案倉庫裡找到,歡迎star~如果本文能夠給你帶來幫助,那麼將是我最開心的地方。如果喜歡,歡迎關注我的部落格以及本系列文章的後續進展。(PS: 下一篇文章應該會講述一下如何構建一個Electron應用 可擴充套件的快捷鍵系統 。)

注:文中的圖片除未特地說明之外均屬於我個人作品,需要轉載請私信

參考文獻

感謝這些高質量的文章、問題等:

  1. 一個還不錯的圖床工具-PicUploader
  2. Passing command line arguments to electron executable (after installing an already packaged app)
  3. Command Line Arguments in Dev Mode
  4. Electron app Docs
  5. 以及沒來得及記錄的那些好文章,感謝你們!

相關文章