Skip to content

Commit

Permalink
feat(desktop): implement CLI installation feature
Browse files Browse the repository at this point in the history
  • Loading branch information
Red-Asuka committed Dec 19, 2024
1 parent fcd032a commit 46bbffb
Show file tree
Hide file tree
Showing 10 changed files with 353 additions and 3 deletions.
2 changes: 2 additions & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,14 @@
"@element-plus/icons-vue": "^2.3.1",
"@mqttx/ui": "workspace:*",
"better-sqlite3": "^11.6.0",
"compare-versions": "^6.1.1",
"drizzle-orm": "^0.36.4",
"electron-store": "^10.0.0",
"electron-updater": "^6.3.9",
"element-plus": "^2.8.7",
"markdown-it": "^14.1.0",
"pinia": "^2.2.6",
"sudo-prompt": "^9.2.1",
"vue-i18n": "^10.0.4",
"vue-router": "^4.4.5"
},
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import installExtension, { VUEJS_DEVTOOLS } from 'electron-devtools-installer'
import icon from '../../resources/icon.png?asset'
import { db, execute, runMigrate } from '../database/db.main'
import { type SelectSettings, settings } from '../database/schemas/settings'
import { useInstallCLI } from './installCLI'
import { useAppUpdater } from './update'

// const IsMacOS = process.platform === 'darwin'
Expand Down Expand Up @@ -103,6 +104,8 @@ app.whenReady().then(async () => {

useAppUpdater(existingSettings!)

useInstallCLI()

app.on('activate', () => {
// 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.
Expand Down
263 changes: 263 additions & 0 deletions apps/desktop/src/main/installCLI.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
import type { InstallCLIEvent } from '../preload/index.d'
import { exec } from 'node:child_process'
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { promisify } from 'node:util'
import { compareVersions, validate } from 'compare-versions'
import { app, BrowserWindow, dialog, ipcMain } from 'electron'
import sudo from 'sudo-prompt'

const STORE_PATH = app.getPath('userData')
const MQTTX_VERSION = app.getVersion()

/**
* Checks if the MQTTX CLI is installed and up to date.
*
* @param win - The BrowserWindow instance.
* @param isWindows - Boolean indicating if the OS is Windows.
* @returns A promise that resolves to a boolean indicating whether the MQTTX CLI is installed and up to date.
*/
async function checkInstalledMqttxCLI(win: BrowserWindow, isWindows: boolean): Promise<boolean> {
if (isWindows) {
return Promise.resolve(false)
}

return new Promise((resolve) => {
exec('mqttx --version', (error, stdout, stderr) => {
if (error) {
resolve(false)
} else if (stderr) {
const errorMessage = stderr.toString().trim()
dialog.showErrorBox('Error', `An error occurred while checking the MQTTX CLI version: ${errorMessage}`)
resolve(false)
} else {
const installedVersion = stdout.trim().split('\n')[0]
if (validate(installedVersion) && compareVersions(installedVersion, MQTTX_VERSION) >= 0) {
dialog.showMessageBox(win, {
type: 'info',
title: 'Check Existing Installation',
message: `MQTTX CLI is already installed and up to date (version: ${installedVersion}).`,
})
resolve(true)
} else {
dialog
.showMessageBox(win, {
type: 'question',
buttons: ['Yes', 'No'],
title: 'Found Older Version',
message: `Installed version: ${installedVersion}\nNew version: ${MQTTX_VERSION}\n\nDo you want to upgrade?`,
})
.then((response) => {
resolve(response.response !== 0)
})
}
}
})
})
}

/**
* Downloads the Mqttx CLI from the specified URL and saves it to the specified output path.
*
* @param downloadUrl - The URL from which to download the Mqttx CLI.
* @param defaultOutputPath - The default output path where the downloaded CLI will be saved.
* @param win - The BrowserWindow instance.
* @param isWindows - A boolean indicating whether the current platform is Windows.
* @returns A Promise that resolves to the output path of the downloaded CLI.
* @throws An error if no download folder is selected on Windows.
*/
async function downloadMqttxCLI(
downloadUrl: string,
defaultOutputPath: string,
win: BrowserWindow,
isWindows: boolean,
): Promise<string> {
let outputPath = defaultOutputPath

if (isWindows) {
const result = dialog.showOpenDialogSync(win, {
title: 'Select Download Folder',
properties: ['openDirectory', 'createDirectory'],
})

if (result && result.length > 0) {
const fileName = path.basename(downloadUrl)
outputPath = path.join(result[0], fileName)
} else {
throw new Error('No download folder selected.')
}
}

const response = await fetch(downloadUrl)

if (!response.ok || !response.body) {
throw new Error(`Failed to download MQTTX CLI: ${response.statusText}`)
}

const totalLength = Number(response.headers.get('content-length')) || 0
const writer = fs.createWriteStream(outputPath)
const reader = response.body.getReader()

return new Promise<string>((resolve, reject) => {
let downloadedLength = 0
sendInstallCLIStatus({ status: 'download-progress', data: { percent: 0 } })
win.setProgressBar(0)

function read() {
reader.read().then(({ done, value }) => {
if (done) {
writer.end()
sendInstallCLIStatus({ status: 'cli-downloaded' })
win.setProgressBar(-1)
resolve(outputPath)
return
}

downloadedLength += value.length
const percent = totalLength ? Math.round((downloadedLength / totalLength) * 100) : 0
sendInstallCLIStatus({ status: 'download-progress', data: { percent } })
win.setProgressBar(percent)
writer.write(Buffer.from(value))
read()
}).catch((err) => {
win.setProgressBar(-1)
writer.close()
fs.unlink(outputPath, () => {})
reject(err)
})
}

read()
})
}

type ExecFunctionParams = Parameters<typeof sudo.exec>

/**
* Installs MQTTX CLI by executing a sudo command.
*
* @param installPath - The path of the installation file.
* @returns A Promise that resolves when the installation is completed.
*/
async function sudoInstall(installPath: string): Promise<void> {
const installCommand = `install "${installPath}" /usr/local/bin/mqttx`
const options = { name: 'MQTTX' }
const execPromise = promisify<ExecFunctionParams['0'], ExecFunctionParams['1']>(sudo.exec)
try {
await execPromise(installCommand, options)
dialog.showMessageBox({
type: 'info',
title: 'Installation Completed',
message: 'MQTTX CLI has been successfully installed.\n\nYou can run "mqttx" commands in the terminal now.',
})
fs.unlink(installPath, () => console.log('Downloaded file deleted.'))
} catch (error) {
const err = error as Error
dialog.showErrorBox(
'Installation Error',
`An error occurred during the installation of MQTTX CLI: ${err.message}`,
)
}
}

/**
* Displays a message box to inform the user that the MQTTX CLI has been successfully downloaded.
* It also provides instructions on how to use the downloaded CLI.
*
* @param outputPath - The path where the MQTTX CLI is downloaded.
* @param fileName - The name of the MQTTX CLI file.
*/
function showDownloadedWindowsCLI(outputPath: string, fileName: string) {
dialog.showMessageBox({
type: 'info',
title: 'Download Completed',
message: `MQTTX CLI has been successfully downloaded.\n\nPlease manually run '${fileName}' located at: ${outputPath} to use it.`,
})
}

/**
* Returns the architecture suffix based on the provided architecture and operating system.
* @param arch - The architecture string.
* @param isWindows - Indicates whether the operating system is Windows.
* @returns The architecture suffix.
*/
function getArchSuffix(arch: string, isWindows: boolean): string {
let suffix: string
switch (arch) {
case 'arm':
case 'arm64':
case 'aarch64':
suffix = 'arm64'
break
case 'x64':
case 'amd64':
suffix = 'x64'
break
default:
suffix = 'x64'
break
}
if (isWindows) {
suffix += '.exe'
}
return suffix
}

/**
* Installs MQTTX CLI if it is not already installed.
*
* @returns A Promise that resolves when the installation is complete.
*/
async function installCLI() {
const win = BrowserWindow.getFocusedWindow()!
const { platform, arch } = {
platform: os.platform(),
arch: os.arch(),
}
const isWindows = platform === 'win32'
const isMacOS = platform === 'darwin'

const isInstalled = await checkInstalledMqttxCLI(win, isWindows)

if (isInstalled) return

const suffix = isWindows ? 'win' : isMacOS ? 'macos' : 'linux'
const archSuffix = getArchSuffix(arch, isWindows)
const fileName = `mqttx-cli-${suffix}-${archSuffix}`
// TODO: Remove before official release
const downloadUrl = `https://www.emqx.com/en/downloads/MQTTX/1.11.1/${fileName}`
// const downloadUrl = `https://www.emqx.com/en/downloads/MQTTX/${MQTTX_VERSION}/${fileName}`
const defaultOutputPath = path.join(STORE_PATH, fileName)

try {
const installPath = await downloadMqttxCLI(downloadUrl, defaultOutputPath, win, isWindows)
if (!isWindows) {
await sudoInstall(installPath)
} else {
showDownloadedWindowsCLI(installPath, fileName)
}
} catch (error) {
const err = error as Error
dialog.showErrorBox('Error', `Failed to install MQTTX CLI: ${err.message}`)
}
}

function sendInstallCLIStatus(installCLIEvent: InstallCLIEvent) {
const windows = BrowserWindow.getAllWindows()
windows.forEach((window) => {
if ('data' in installCLIEvent) {
window.webContents.send('install-cli-status', installCLIEvent.status, installCLIEvent.data)
} else {
window.webContents.send('install-cli-status', installCLIEvent.status)
}
})
}

function useInstallCLI() {
ipcMain.handle('install-cli', async () => {
return await installCLI()
})
}

export { useInstallCLI }
6 changes: 6 additions & 0 deletions apps/desktop/src/preload/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ declare global {
downloadUpdate: () => Promise<void>
cancelDownload: () => Promise<void>
installUpdate: () => Promise<void>
installCLI: () => Promise<void>
onInstallCLIStatus: (callback: (event: Electron.IpcRendererEvent, installCLIEvent: InstallCLIEvent) => void) => void
}
}
}
Expand All @@ -23,3 +25,7 @@ export type UpdateEvent =
| { status: 'download-progress', data: ProgressInfo }
| { status: 'update-downloaded', data: UpdateDownloadedEvent }
| { status: 'error', data: Error }

export type InstallCLIEvent =
| { status: 'download-progress', data: { percent: number } }
| { status: 'cli-downloaded' }
4 changes: 4 additions & 0 deletions apps/desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ const api: Window['api'] = {
downloadUpdate: () => ipcRenderer.invoke('download-update'),
cancelDownload: () => ipcRenderer.invoke('cancel-download'),
installUpdate: () => ipcRenderer.invoke('install-update'),
installCLI: () => ipcRenderer.invoke('install-cli'),
onInstallCLIStatus: callback => ipcRenderer.on('install-cli-status', (event, status, data) => {
callback(event, { status, data })
}),
}

// Use `contextBridge` APIs to expose Electron APIs to
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/renderer/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ declare module 'vue' {
MyDialog: typeof import('./../../../../packages/ui/src/components/MyDialog.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SettingsCliDownloadProgress: typeof import('./src/components/settings/cli/DownloadProgress.vue')['default']
SettingsView: typeof import('./../../../../packages/ui/src/components/SettingsView.vue')['default']
UpdateAvailable: typeof import('./src/components/update/Available.vue')['default']
UpdateDownloadProgress: typeof import('./src/components/update/DownloadProgress.vue')['default']
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<script setup lang="ts">
const props = defineProps<{
percent: number | null
updateDownloaded: boolean
}>()
const dialogVisible = defineModel<boolean>({ default: true })
const { percent, updateDownloaded } = toRefs(props)
</script>

<template>
<MyDialog
v-model="dialogVisible"
:title="$t('settings.downloadingCLI')"
width="460px"
:close-on-click-modal="false"
:close-on-press-escape="false"
:show-close="false"
center
>
<div>
<ElProgress :percentage="updateDownloaded ? 100 : percent || 0" />
</div>
<template #footer>
<div />
</template>
</MyDialog>
</template>
Loading

0 comments on commit 46bbffb

Please sign in to comment.