diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 59215da8b..9fe46d194 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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" }, diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 14da09107..12c3e09b9 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -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' @@ -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. diff --git a/apps/desktop/src/main/installCLI.ts b/apps/desktop/src/main/installCLI.ts new file mode 100644 index 000000000..59ce9f5fd --- /dev/null +++ b/apps/desktop/src/main/installCLI.ts @@ -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 { + 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 { + 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((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 + +/** + * 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 { + const installCommand = `install "${installPath}" /usr/local/bin/mqttx` + const options = { name: 'MQTTX' } + const execPromise = promisify(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 } diff --git a/apps/desktop/src/preload/index.d.ts b/apps/desktop/src/preload/index.d.ts index 181513be6..201f6ae78 100644 --- a/apps/desktop/src/preload/index.d.ts +++ b/apps/desktop/src/preload/index.d.ts @@ -12,6 +12,8 @@ declare global { downloadUpdate: () => Promise cancelDownload: () => Promise installUpdate: () => Promise + installCLI: () => Promise + onInstallCLIStatus: (callback: (event: Electron.IpcRendererEvent, installCLIEvent: InstallCLIEvent) => void) => void } } } @@ -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' } diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index b19b7d42b..25f1fcb99 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -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 diff --git a/apps/desktop/src/renderer/components.d.ts b/apps/desktop/src/renderer/components.d.ts index 60663a22a..129b734eb 100644 --- a/apps/desktop/src/renderer/components.d.ts +++ b/apps/desktop/src/renderer/components.d.ts @@ -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'] diff --git a/apps/desktop/src/renderer/src/components/settings/cli/DownloadProgress.vue b/apps/desktop/src/renderer/src/components/settings/cli/DownloadProgress.vue new file mode 100644 index 000000000..6358e5a9a --- /dev/null +++ b/apps/desktop/src/renderer/src/components/settings/cli/DownloadProgress.vue @@ -0,0 +1,29 @@ + + + diff --git a/apps/desktop/src/renderer/src/pages/settings.vue b/apps/desktop/src/renderer/src/pages/settings.vue index 128d74303..dbb2b8596 100644 --- a/apps/desktop/src/renderer/src/pages/settings.vue +++ b/apps/desktop/src/renderer/src/pages/settings.vue @@ -3,15 +3,44 @@ import useSettingsService from '@database/services/SettingsService' const { settings } = useSettingsService() -function installCli() { - // TODO: implement this function - console.log('installCli') +const intallCliBtnLoading = ref(false) + +async function installCli() { + try { + intallCliBtnLoading.value = true + await window.api.installCLI() + } catch (error) { + console.error(error) + } finally { + intallCliBtnLoading.value = false + } } + +const cliDownloadProgressVisible = ref(false) +const cliDownloadProgressPercent = ref(null) +const cliDownloaded = ref(false) + +window.api.onInstallCLIStatus((_event, cliDownloadEvent) => { + const { status } = cliDownloadEvent + if (status === 'download-progress') { + cliDownloadProgressVisible.value = true + cliDownloadProgressPercent.value = cliDownloadEvent.data.percent + } else if (status === 'cli-downloaded') { + cliDownloaded.value = true + cliDownloadProgressVisible.value = false + } +}) diff --git a/packages/ui/src/components/SettingsView.vue b/packages/ui/src/components/SettingsView.vue index 071c428c6..08eb0b4de 100644 --- a/packages/ui/src/components/SettingsView.vue +++ b/packages/ui/src/components/SettingsView.vue @@ -7,6 +7,7 @@ const emit = defineEmits<{ }>() const settings = defineModel({ required: true }) +const intallCliBtnLoading = defineModel('installCliBtnLoading', { default: false }) const platformType = inject('platformType', 'web') @@ -272,6 +273,7 @@ const AImodelsOptions = [
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a7f5b1d8..d2f1dfe46 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -132,6 +132,9 @@ importers: better-sqlite3: specifier: ^11.6.0 version: 11.6.0 + compare-versions: + specifier: ^6.1.1 + version: 6.1.1 drizzle-orm: specifier: ^0.36.4 version: 0.36.4(@types/better-sqlite3@7.6.12)(better-sqlite3@11.6.0) @@ -150,6 +153,9 @@ importers: pinia: specifier: ^2.2.6 version: 2.2.6(typescript@5.6.3)(vue@3.5.12) + sudo-prompt: + specifier: ^9.2.1 + version: 9.2.1 vue-i18n: specifier: ^10.0.4 version: 10.0.4(vue@3.5.12) @@ -10136,6 +10142,11 @@ packages: ts-interface-checker: 0.1.13 dev: true + /sudo-prompt@9.2.1: + resolution: {integrity: sha512-Mu7R0g4ig9TUuGSxJavny5Rv0egCEtpZRNMrZaYS1vxkiIxGiGUwoezU3LazIQ+KE04hTrTfNPgxU5gzi7F5Pw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + dev: false + /sumchecker@3.0.1: resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} engines: {node: '>= 8.0'}