Improve Windows installer namespace UX

This commit is contained in:
PerishCode
2026-05-06 21:36:36 +08:00
parent 744a1f5032
commit ac2a7611fe
4 changed files with 181 additions and 24 deletions
+15 -2
View File
@@ -2,7 +2,7 @@
import { execFile } from 'node:child_process';
import { readFile, stat } from 'node:fs/promises';
import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
@@ -31,6 +31,8 @@ type DesktopStatus = {
};
type WinInstallResult = {
desktopShortcutExists: boolean;
desktopShortcutPath: string;
installDir: string;
installPayload: {
fileCount: number;
@@ -44,6 +46,8 @@ type WinInstallResult = {
installerPath: string;
namespace: string;
registryEntries: unknown[];
startMenuShortcutExists: boolean;
startMenuShortcutPath: string;
timingPath: string;
uninstallerPath: string;
};
@@ -68,6 +72,7 @@ type WinCleanupResult = {
residueObservation?: {
installedExeExists?: boolean;
managedProcessPids?: number[];
productNamespaceRootExists?: boolean;
registryResidues?: string[];
startMenuShortcutExists?: boolean;
uninstallerExists?: boolean;
@@ -133,7 +138,14 @@ winDescribe('packaged windows runtime smoke', () => {
expectPathInside(install.installerPath, join(outputNamespaceRoot, 'builder'));
expectPathInside(install.installDir, join(runtimeNamespaceRoot, 'install'));
expectPathInside(install.uninstallerPath, install.installDir);
expect(basename(install.uninstallerPath)).toBe('Uninstall Open Design Beta.exe');
expect(install.desktopShortcutExists).toBe(true);
expect(install.startMenuShortcutExists).toBe(true);
expect(basename(install.desktopShortcutPath)).toBe('Open Design Beta.lnk');
expect(basename(install.startMenuShortcutPath)).toBe('Open Design Beta.lnk');
expect(install.registryEntries.length).toBeGreaterThan(0);
expect(JSON.stringify(install.registryEntries)).toContain('Open Design Beta');
expect(JSON.stringify(install.registryEntries)).toContain('Open Design-release-beta-win');
expect(install.installPayload.fileCount).toBeGreaterThan(0);
expect(install.installPayload.totalBytes).toBeGreaterThan(0);
expect(install.installPayload.topLevel.length).toBeGreaterThan(0);
@@ -181,10 +193,11 @@ winDescribe('packaged windows runtime smoke', () => {
expect(stop.status).not.toBe('partial');
expect(stop.remainingPids).toEqual([]);
const uninstall = await runToolsPackJson<WinUninstallResult>('uninstall');
const uninstall = await runToolsPackJson<WinUninstallResult>('uninstall', ['--remove-product-user-data']);
installed = false;
expect(uninstall.namespace).toBe(namespace);
expect(uninstall.residueObservation?.managedProcessPids ?? []).toEqual([]);
expect(uninstall.residueObservation?.productNamespaceRootExists).toBe(false);
expect(uninstall.residueObservation?.registryResidues ?? []).toEqual([]);
expect(uninstall.residueObservation?.installedExeExists).toBe(false);
expect(uninstall.residueObservation?.uninstallerExists).toBe(false);
+116 -16
View File
@@ -7,6 +7,7 @@ import type { ToolPackConfig } from "../config.js";
import { winResources } from "../resources.js";
import { PRODUCT_NAME } from "./constants.js";
import { pathExists } from "./fs.js";
import { resolveWinInstallIdentity } from "./identity.js";
import { readPackagedVersion } from "./manifest.js";
import { sanitizeNamespace } from "./paths.js";
import type { WinBuiltAppManifest, WinPaths } from "./types.js";
@@ -62,18 +63,18 @@ async function resolveMakensisCommand(config: ToolPackConfig): Promise<string> {
}
async function writeInstallerScript(config: ToolPackConfig, paths: WinPaths): Promise<void> {
const productName = escapeNsisString(PRODUCT_NAME);
const exeName = escapeNsisString(`${PRODUCT_NAME}.exe`);
const uninstallerName = escapeNsisString(`Uninstall ${PRODUCT_NAME}.exe`);
const shortcutName = escapeNsisString(`${PRODUCT_NAME}.lnk`);
const registryKey = escapeNsisString(`Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${PRODUCT_NAME}-${sanitizeNamespace(config.namespace)}`);
const appPathsKey = escapeNsisString(`Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\${PRODUCT_NAME}.exe`);
const identity = resolveWinInstallIdentity(config);
const productName = escapeNsisString(identity.displayName);
const exeName = escapeNsisString(identity.exeName);
const uninstallerName = escapeNsisString(identity.uninstallerName);
const shortcutName = escapeNsisString(identity.shortcutName);
const registryKey = escapeNsisString(identity.registryKey);
const appPathsKey = escapeNsisString(identity.appPathsKey);
const localDataRoot = escapeNsisString(`$APPDATA\\${PRODUCT_NAME}\\namespaces\\${sanitizeNamespace(config.namespace)}`);
const nsisLogPath = escapeNsisString(paths.nsisLogPath);
await mkdir(dirname(paths.installerScriptPath), { recursive: true });
await writeFile(
paths.installerScriptPath,
`Unicode true
const script = `Unicode true
ManifestDPIAware true
RequestExecutionLevel user
@@ -98,6 +99,7 @@ RequestExecutionLevel user
!include "MUI2.nsh"
!include "LogicLib.nsh"
!include "nsDialogs.nsh"
Name "${productName}"
OutFile "\${OUTPUT_EXE}"
@@ -106,17 +108,42 @@ InstallDirRegKey HKCU "${registryKey}" "InstallLocation"
Icon "\${APP_ICON}"
UninstallIcon "\${APP_ICON}"
ShowInstDetails show
ShowUninstDetails show
ShowUninstDetails hide
!define MUI_ABORTWARNING
!define MUI_ICON "\${APP_ICON}"
!define MUI_UNICON "\${APP_ICON}"
!insertmacro MUI_PAGE_INSTFILES
!define MUI_FINISHPAGE_RUN "$INSTDIR\\${exeName}"
!define MUI_FINISHPAGE_RUN_TEXT "$(LaunchApp)"
!define MUI_FINISHPAGE_SHOWREADME
!define MUI_FINISHPAGE_SHOWREADME_TEXT "$(CreateDesktopShortcut)"
!define MUI_FINISHPAGE_SHOWREADME_FUNCTION CreateDesktopShortcut
!insertmacro MUI_PAGE_FINISH
!insertmacro MUI_UNPAGE_CONFIRM
UninstPage custom un.UninstallOptionsPage un.UninstallOptionsPageLeave
!insertmacro MUI_UNPAGE_INSTFILES
!insertmacro MUI_LANGUAGE "English"
!insertmacro MUI_LANGUAGE "SimpChinese"
LangString CreateDesktopShortcut \${LANG_ENGLISH} "Create desktop shortcut"
LangString CreateDesktopShortcut \${LANG_SIMPCHINESE} "创建桌面快捷方式"
LangString LaunchApp \${LANG_ENGLISH} "Launch ${productName}"
LangString LaunchApp \${LANG_SIMPCHINESE} "启动 ${productName}"
LangString RemoveDesktopShortcut \${LANG_ENGLISH} "Remove desktop shortcut"
LangString RemoveDesktopShortcut \${LANG_SIMPCHINESE} "删除桌面快捷方式"
LangString RemoveLocalData \${LANG_ENGLISH} "Delete local data for this installation"
LangString RemoveLocalData \${LANG_SIMPCHINESE} "删除此安装的本地数据"
LangString UninstallOptionsTitle \${LANG_ENGLISH} "Uninstall options"
LangString UninstallOptionsTitle \${LANG_SIMPCHINESE} "卸载选项"
LangString UninstallOptionsSubtitle \${LANG_ENGLISH} "Choose which local items to remove."
LangString UninstallOptionsSubtitle \${LANG_SIMPCHINESE} "选择要删除的本地项目。"
Var RemoveDesktopShortcutCheckbox
Var RemoveLocalDataCheckbox
Var RemoveDesktopShortcutState
Var RemoveLocalDataState
Function LogInstallerEvent
Exch $0
Push $1
@@ -143,13 +170,79 @@ done:
Pop $0
FunctionEnd
Function un.onInit
StrCpy $RemoveDesktopShortcutState "\${BST_CHECKED}"
StrCpy $RemoveLocalDataState 0
FunctionEnd
Function CreateDesktopShortcut
SetShellVarContext current
CreateShortCut "$DESKTOP\\${shortcutName}" "$INSTDIR\\${exeName}" "" "$INSTDIR\\${exeName}" 0
FunctionEnd
Function RemoveInstallDir
Push $0
nsExec::ExecToLog 'powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "if (Test-Path -LiteralPath ''$INSTDIR'') { Remove-Item -LiteralPath ''$INSTDIR'' -Recurse -Force -ErrorAction SilentlyContinue }"'
Pop $0
Push "install dir remove exit=$0"
Call LogInstallerEvent
Pop $0
FunctionEnd
Function un.UninstallOptionsPage
IfSilent done
!insertmacro MUI_HEADER_TEXT "$(UninstallOptionsTitle)" "$(UninstallOptionsSubtitle)"
nsDialogs::Create 1018
Pop $0
\${If} $0 == error
Abort
\${EndIf}
\${NSD_CreateCheckbox} 0 0 100% 12u "$(RemoveDesktopShortcut)"
Pop $RemoveDesktopShortcutCheckbox
\${NSD_Check} $RemoveDesktopShortcutCheckbox
\${NSD_CreateCheckbox} 0 18u 100% 12u "$(RemoveLocalData)"
Pop $RemoveLocalDataCheckbox
nsDialogs::Show
done:
FunctionEnd
Function un.UninstallOptionsPageLeave
StrCpy $RemoveDesktopShortcutState "\${BST_CHECKED}"
StrCpy $RemoveLocalDataState 0
IfSilent done
\${NSD_GetState} $RemoveDesktopShortcutCheckbox $RemoveDesktopShortcutState
\${NSD_GetState} $RemoveLocalDataCheckbox $RemoveLocalDataState
done:
FunctionEnd
Function un.RemoveInstallDirContents
Push $0
nsExec::ExecToLog 'powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "if (Test-Path -LiteralPath ''$INSTDIR'') { Get-ChildItem -LiteralPath ''$INSTDIR'' -Force | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue }"'
Pop $0
Push "install dir fast remove exit=$0"
Call un.LogInstallerEvent
Pop $0
FunctionEnd
Function un.RemoveLocalDataRoot
Push $0
nsExec::ExecToLog 'powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "if (Test-Path -LiteralPath ''${localDataRoot}'') { Remove-Item -LiteralPath ''${localDataRoot}'' -Recurse -Force -ErrorAction SilentlyContinue }"'
Pop $0
Push "local data remove exit=$0"
Call un.LogInstallerEvent
Pop $0
FunctionEnd
Section "Install"
SetShellVarContext current
Push "install section start"
Call LogInstallerEvent
IfFileExists "$INSTDIR\\${exeName}" 0 prepare_install_dir
RMDir /r "$INSTDIR"
Call RemoveInstallDir
prepare_install_dir:
InitPluginsDir
@@ -171,7 +264,9 @@ prepare_install_dir:
\${EndIf}
WriteUninstaller "$INSTDIR\\${uninstallerName}"
IfSilent 0 skip_silent_desktop_shortcut
CreateShortCut "$DESKTOP\\${shortcutName}" "$INSTDIR\\${exeName}" "" "$INSTDIR\\${exeName}" 0
skip_silent_desktop_shortcut:
CreateShortCut "$SMPROGRAMS\\${shortcutName}" "$INSTDIR\\${exeName}" "" "$INSTDIR\\${exeName}" 0
WriteRegStr HKCU "${registryKey}" "DisplayName" "${productName} \${APP_VERSION}"
WriteRegStr HKCU "${registryKey}" "DisplayVersion" "\${APP_VERSION}"
@@ -188,18 +283,23 @@ Section "Uninstall"
SetShellVarContext current
Push "uninstall section start"
Call un.LogInstallerEvent
Delete "$DESKTOP\\${shortcutName}"
\${If} $RemoveDesktopShortcutState == \${BST_CHECKED}
Delete "$DESKTOP\\${shortcutName}"
\${EndIf}
Delete "$SMPROGRAMS\\${shortcutName}"
DeleteRegKey HKCU "${registryKey}"
DeleteRegKey HKCU "${appPathsKey}"
\${If} $RemoveLocalDataState == \${BST_CHECKED}
Call un.RemoveLocalDataRoot
\${EndIf}
Call un.RemoveInstallDirContents
Delete "$INSTDIR\\${uninstallerName}"
RMDir /r "$INSTDIR"
RMDir "$INSTDIR"
Push "uninstall section done"
Call un.LogInstallerEvent
SectionEnd
`,
"utf8",
);
`;
await writeFile(paths.installerScriptPath, `\uFEFF${script}`, "utf8");
}
export async function buildCustomWinNsisInstaller(
+39
View File
@@ -0,0 +1,39 @@
import { SIDECAR_DEFAULTS } from "@open-design/sidecar-proto";
import type { ToolPackConfig } from "../config.js";
import { PRODUCT_NAME } from "./constants.js";
export type WinInstallIdentity = {
appPathsKey: string;
displayName: string;
exeName: string;
registryKey: string;
shortcutName: string;
uninstallerName: string;
};
function isBetaNamespace(namespace: string): boolean {
return /(^|[-_.])beta($|[-_.])/i.test(namespace);
}
function sanitizeNamespace(value: string): string {
return value.replace(/[^A-Za-z0-9._-]+/g, "-");
}
export function resolveWinInstallIdentity(config: Pick<ToolPackConfig, "namespace">): WinInstallIdentity {
const namespaceToken = sanitizeNamespace(config.namespace);
const displayName = isBetaNamespace(config.namespace)
? `${PRODUCT_NAME} Beta`
: config.namespace === SIDECAR_DEFAULTS.namespace
? PRODUCT_NAME
: `${PRODUCT_NAME} ${namespaceToken}`;
return {
appPathsKey: `Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\${displayName}.exe`,
displayName,
exeName: `${PRODUCT_NAME}.exe`,
registryKey: `Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${PRODUCT_NAME}-${namespaceToken}`,
shortcutName: `${displayName}.lnk`,
uninstallerName: `Uninstall ${displayName}.exe`,
};
}
+11 -6
View File
@@ -4,6 +4,7 @@ import { dirname, join } from "node:path";
import type { ToolPackConfig } from "../config.js";
import { PRODUCT_NAME } from "./constants.js";
import { pathExists } from "./fs.js";
import { resolveWinInstallIdentity } from "./identity.js";
import type { WinPaths, WinRemovalTarget } from "./types.js";
export function sanitizeNamespace(value: string): string {
@@ -14,7 +15,7 @@ export function resolveWinPaths(config: ToolPackConfig): WinPaths {
const namespaceToken = sanitizeNamespace(config.namespace);
const namespaceRoot = config.roots.output.namespaceRoot;
const installDir = join(config.roots.runtime.namespaceRoot, "install", PRODUCT_NAME);
const shortcutName = `${PRODUCT_NAME}.lnk`;
const identity = resolveWinInstallIdentity(config);
return {
appBuilderConfigPath: join(namespaceRoot, "builder-config.json"),
appBuilderOutputRoot: join(namespaceRoot, "builder"),
@@ -28,7 +29,7 @@ export function resolveWinPaths(config: ToolPackConfig): WinPaths {
installedExePath: join(installDir, `${PRODUCT_NAME}.exe`),
installerPayloadPath: join(namespaceRoot, "installer", "payload.7z"),
installerScriptPath: join(namespaceRoot, "installer", "installer.nsi"),
publicDesktopShortcutPath: join(process.env.PUBLIC ?? join(dirname(homedir()), "Public"), "Desktop", shortcutName),
publicDesktopShortcutPath: join(process.env.PUBLIC ?? join(dirname(homedir()), "Public"), "Desktop", identity.shortcutName),
installMarkerPath: join(namespaceRoot, "logs", "install.marker.json"),
installTimingPath: join(namespaceRoot, "logs", "install.timing.json"),
latestYmlPath: join(namespaceRoot, "builder", "latest.yml"),
@@ -37,12 +38,12 @@ export function resolveWinPaths(config: ToolPackConfig): WinPaths {
packagedConfigPath: join(namespaceRoot, "open-design-config.json"),
resourceRoot: join(namespaceRoot, "resources", "open-design"),
setupPath: join(namespaceRoot, "builder", `${PRODUCT_NAME}-${namespaceToken}-setup.exe`),
startMenuShortcutPath: join(process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"), "Microsoft", "Windows", "Start Menu", "Programs", shortcutName),
startMenuShortcutPath: join(process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"), "Microsoft", "Windows", "Start Menu", "Programs", identity.shortcutName),
tarballsRoot: join(namespaceRoot, "tarballs"),
userDesktopShortcutPath: join(homedir(), "Desktop", shortcutName),
userDesktopShortcutPath: join(homedir(), "Desktop", identity.shortcutName),
uninstallMarkerPath: join(namespaceRoot, "logs", "uninstall.marker.json"),
uninstallTimingPath: join(namespaceRoot, "logs", "uninstall.timing.json"),
uninstallerPath: join(installDir, `Uninstall ${PRODUCT_NAME}.exe`),
uninstallerPath: join(installDir, identity.uninstallerName),
webStandaloneHookAuditPath: join(namespaceRoot, "web-standalone-after-pack-audit.json"),
webStandaloneHookConfigPath: join(namespaceRoot, "web-standalone-after-pack-config.json"),
winIconPath: join(namespaceRoot, "resources", "win", "icon.ico"),
@@ -63,6 +64,10 @@ export function resolveWinProductNamespaceRoot(config: ToolPackConfig): string {
return join(resolveWinProductUserDataRoot(), "namespaces", config.namespace);
}
export function resolveWinLocalDataRoot(config: ToolPackConfig): string {
return resolveWinProductNamespaceRoot(config);
}
export async function createWinRemovalPlan(config: ToolPackConfig): Promise<WinRemovalTarget[]> {
const runtimeRoot = config.roots.runtime.namespaceRoot;
const targets: Array<Omit<WinRemovalTarget, "exists">> = [
@@ -70,7 +75,7 @@ export async function createWinRemovalPlan(config: ToolPackConfig): Promise<WinR
{ path: join(runtimeRoot, "logs"), scope: "logs", willRemove: config.removeLogs },
{ path: join(runtimeRoot, "runtime"), scope: "sidecars", willRemove: config.removeSidecars },
{
path: resolveWinProductUserDataRoot(),
path: resolveWinLocalDataRoot(config),
scope: "product-user-data",
willRemove: config.removeProductUserData,
},