From b45d0f9ed207121220accdea38c3d2cd6cc481fc Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Thu, 1 Oct 2026 00:20:44 +0800 Subject: [PATCH] feat(installer): make the Windows desktop shortcut optional (#1517) --- electron-builder.json | 11 +- electron/installer.nsh | 53 ++++++++ packaging/build/msi-shortcuts.cjs | 74 +++++++++++ .../tests/electron/msi-shortcuts.test.ts | 124 ++++++++++++++++++ 4 files changed, 258 insertions(+), 4 deletions(-) create mode 100644 electron/installer.nsh create mode 100644 packaging/build/msi-shortcuts.cjs create mode 100644 src/backend/tests/electron/msi-shortcuts.test.ts diff --git a/electron-builder.json b/electron-builder.json index 27ff1d47a..b580d9ce0 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -62,10 +62,13 @@ "installerIcon": "public/icon.ico", "uninstallerIcon": "public/icon.ico", "shortcutName": "Termix", - "uninstallDisplayName": "Termix" + "uninstallDisplayName": "Termix", + "include": "electron/installer.nsh" }, "msi": { - "artifactName": "termix_windows_${arch}_msi.${ext}" + "artifactName": "termix_windows_${arch}_msi.${ext}", + "oneClick": false, + "additionalLightArgs": ["-sice:ICE57"] }, "linux": { "artifactName": "termix_linux_${arch}_portable.${ext}", @@ -102,7 +105,6 @@ "deb": { "artifactName": "termix_linux_${arch}_deb.${ext}" }, - "mac": { "target": [ { @@ -146,5 +148,6 @@ "NSAppleEventsUsageDescription": "Termix needs access to control other applications for terminal operations." } }, - "generateUpdatesFilesForAllChannels": true + "generateUpdatesFilesForAllChannels": true, + "msiProjectCreated": "packaging/build/msi-shortcuts.cjs" } diff --git a/electron/installer.nsh b/electron/installer.nsh new file mode 100644 index 000000000..19c6be1a4 --- /dev/null +++ b/electron/installer.nsh @@ -0,0 +1,53 @@ +!include MUI2.nsh +!include nsDialogs.nsh +!include LogicLib.nsh + +!ifndef BUILD_UNINSTALLER +Var TermixDesktopCheckbox +Var TermixDesktopChoice + +; Keep electron-builder's --no-desktop-shortcut flag, adding the UI choice. +!macro _TermixNoDesktopShortcut _a _b _t _f + StrCmp $TermixDesktopChoice ${BST_UNCHECKED} `${_t}` 0 + ${StdUtils.TestParameter} $R9 "no-desktop-shortcut" + StrCmp $R9 "true" `${_t}` `${_f}` +!macroend +!undef isNoDesktopShortcut +!define isNoDesktopShortcut `"" TermixNoDesktopShortcut ""` + +; Defined here, not at include time: electron-builder includes this file +; before it adds the StdUtils plugin directory that isUpdated needs. +!macro customPageAfterChangeDir + Page custom TermixShortcutPage TermixShortcutPageLeave + + Function TermixShortcutPage + ; Updates retain electron-builder's existing keep-shortcuts behavior. + ${If} ${isUpdated} + Abort + ${EndIf} + !insertmacro MUI_HEADER_TEXT "Shortcuts" "Choose whether to create a desktop shortcut." + nsDialogs::Create 1018 + Pop $0 + ${If} $0 == error + Abort + ${EndIf} + ${NSD_CreateCheckbox} 0 0 100% 16u "Create a desktop shortcut" + Pop $TermixDesktopCheckbox + ${If} $TermixDesktopChoice == "" + StrCpy $TermixDesktopChoice ${BST_CHECKED} + ${EndIf} + ${StdUtils.TestParameter} $R9 "no-desktop-shortcut" + ${If} $R9 == "true" + StrCpy $TermixDesktopChoice ${BST_UNCHECKED} + EnableWindow $TermixDesktopCheckbox 0 + ${EndIf} + ${NSD_SetState} $TermixDesktopCheckbox $TermixDesktopChoice + nsDialogs::Show + FunctionEnd + + Function TermixShortcutPageLeave + ${NSD_GetState} $TermixDesktopCheckbox $TermixDesktopChoice + FunctionEnd +!macroend + +!endif diff --git a/packaging/build/msi-shortcuts.cjs b/packaging/build/msi-shortcuts.cjs new file mode 100644 index 000000000..6f7a7cc67 --- /dev/null +++ b/packaging/build/msi-shortcuts.cjs @@ -0,0 +1,74 @@ +const fs = require("node:fs/promises"); + +/** Give the generated MSI shortcut its own optional component. */ +module.exports = async function msiShortcuts(projectFile) { + let xml = await fs.readFile(projectFile, "utf8"); + const shortcut = xml.match(/]*\/>/g); + if ( + shortcut?.length !== 1 || + !xml.includes('') + ) { + throw new Error( + "Unexpected MSI template: desktop shortcut or assisted UI missing", + ); + } + const link = shortcut[0].replace( + ' Advertise="yes"', + ' Target="[#mainExecutable]"', + ); + xml = xml.replace(shortcut[0], ""); + xml = xml.replace( + '', + '\n ', + ); + xml = xml.replace( + "", + ` + + + + TERMIX_DESKTOP_SHORTCUT = "1" + ${link} + + + + + + + + + WixAppFolder = "WixPerMachineFolder" + WixAppFolder = "WixPerUserFolder" + + + 1 + + + 1 + + + + `, + ); + xml = xml.replace( + /(1<\/Publish>/g, + 'WIXUI_DONTVALIDATEPATH OR WIXUI_INSTALLDIR_VALID="1"', + ); + xml = xml.replace( + /(') + ) { + throw new Error("Unexpected MSI template: shortcut dialog routing missing"); + } + await fs.writeFile(projectFile, xml); +}; diff --git a/src/backend/tests/electron/msi-shortcuts.test.ts b/src/backend/tests/electron/msi-shortcuts.test.ts new file mode 100644 index 000000000..c78412680 --- /dev/null +++ b/src/backend/tests/electron/msi-shortcuts.test.ts @@ -0,0 +1,124 @@ +import { createRequire } from "node:module"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +const require = createRequire(import.meta.url); +const customize = require("../../../../packaging/build/msi-shortcuts.cjs") as ( + path: string, +) => Promise; +const { DOMParser } = require("@xmldom/xmldom"); +let dir: string; +afterEach(async () => { + if (dir) await rm(dir, { recursive: true, force: true }); +}); + +const project = ` + +1 +1 +1 + + + + + +`; + +describe("MSI desktop shortcut option", () => { + it("makes only the desktop shortcut conditional and connects both installation scopes to its page", async () => { + dir = await mkdtemp(join(tmpdir(), "termix-msi-")); + const file = join(dir, "project.wxs"); + await writeFile(file, project); + await customize(file); + const xml = await readFile(file, "utf8"); + const doc = new DOMParser().parseFromString(xml, "text/xml"); + const elements = (name: string) => + Array.from(doc.getElementsByTagName(name)) as Element[]; + const desktop = elements("Shortcut").find( + (node) => node.getAttribute("Id") === "desktopShortcut", + )!; + expect(desktop.getAttribute("Target")).toBe("[#mainExecutable]"); + expect(desktop.getAttribute("Advertise")).toBeFalsy(); + expect((desktop.parentNode as Element).getAttribute("Id")).toBe( + "TermixDesktopShortcut", + ); + expect(elements("Condition")[0].textContent).toBe( + 'TERMIX_DESKTOP_SHORTCUT = "1"', + ); + expect( + elements("Shortcut") + .find((node) => node.getAttribute("Id") === "startMenuShortcut")! + .getAttribute("Advertise"), + ).toBe("yes"); + expect( + elements("Publish").filter( + (node) => node.getAttribute("Value") === "TermixShortcutDlg", + ), + ).toHaveLength(3); + const installDir = elements("Publish").find( + (node) => + node.getAttribute("Dialog") === "InstallDirDlg" && + node.getAttribute("Value") === "TermixShortcutDlg", + )!; + // Must run after WixUI_InstallDir's own VerifyReadyDlg event (Order 4). + expect(Number(installDir.getAttribute("Order"))).toBeGreaterThan(4); + expect(installDir.textContent).toBe( + 'WIXUI_DONTVALIDATEPATH OR WIXUI_INSTALLDIR_VALID="1"', + ); + expect( + elements("Control") + .find((node) => node.getAttribute("Type") === "CheckBox")! + .getAttribute("Property"), + ).toBe("TERMIX_DESKTOP_SHORTCUT"); + }); + + it("accepts the installed electron-builder MSI template", async () => { + const template = await readFile( + require.resolve("app-builder-lib/templates/msi/template.xml"), + "utf8", + ); + const xml = require("ejs").render( + template + .replace(/{{/g, "<%") + .replace(/}}/g, "%>") + .replace(/\$\{([^}]+)}/g, "<%=$1%>"), + { + productName: "Termix", + upgradeCode: "9A4E27D1-6F54-4B15-ABBD-54B194CB7200", + version: "2.9.0", + manufacturer: "Termix", + compressionLevel: "high", + installationDirectoryWixName: "Termix", + iconPath: null, + isRunAfterFinish: false, + isAssisted: true, + isPerMachine: false, + programFilesId: "ProgramFiles64Folder", + menuCategory: null, + isCreateDesktopShortcut: true, + isCreateStartMenuShortcut: true, + dirs: "", + files: + '', + }, + ); + dir = await mkdtemp(join(tmpdir(), "termix-msi-")); + const file = join(dir, "project.wxs"); + await writeFile(file, xml); + await customize(file); + const result = await readFile(file, "utf8"); + expect(result.match(/Value="TermixShortcutDlg"/g)).toHaveLength(3); + expect(result).toContain('RegistryValue Root="HKMU"'); + expect(result).toContain(''); + }); + + it("stops the build if the generated installer template changes", async () => { + dir = await mkdtemp(join(tmpdir(), "termix-msi-")); + const file = join(dir, "project.wxs"); + await writeFile(file, ""); + await expect(customize(file)).rejects.toThrow("Unexpected MSI template"); + expect(await readFile(file, "utf8")).toBe(""); + }); +});