mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
Improve Windows installer namespace UX
This commit is contained in:
+15
-2
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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`,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user