Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(settings): implement CLI installation feature #1842

Merged
merged 3 commits into from
Dec 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 / 100)
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
Loading