Skip to content
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
101 changes: 101 additions & 0 deletions scripts/nsisInstallerHooks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import assert from 'node:assert/strict';
import test from 'node:test';

import { readSource } from './sourceTree.js';

// `bundle.windows.nsis.installerHooks` is a file the NSIS bundler `!include`s
// into its own installer script, and the four call sites in that script are all
// guarded by `!ifmacrodef`:
//
// !ifmacrodef NSIS_HOOK_POSTINSTALL
// !insertmacro NSIS_HOOK_POSTINSTALL
// !endif
//
// `!ifmacrodef` is a compile-time conditional, so a hook whose macro name is
// spelled any other way is skipped with no error and no warning. The installer
// still builds, still installs, and simply never runs the hook — which is how
// `hooks.nsi` sat inert from 2026-01-12 to 2026-08-05 defining
// `NSIS_HOOK_POST_INSTALL` (an underscore the bundler does not use).
//
// Nothing else in the build can catch that: the bundler does not validate the
// file, and NSIS itself is only reachable on a Windows runner. So the spelling
// is pinned here.
//
// Source: crates/tauri-bundler/src/bundle/windows/nsis/installer.nsi in
// tauri-apps/tauri, unchanged in this set since tauri-cli-v2.0.0. This repo
// pins @tauri-apps/cli 2.9.6 in package-lock.json.
const BUNDLER_HOOK_MACROS = [
'NSIS_HOOK_PREINSTALL',
'NSIS_HOOK_POSTINSTALL',
'NSIS_HOOK_PREUNINSTALL',
'NSIS_HOOK_POSTUNINSTALL'
];

const config = JSON.parse(readSource('src-tauri/tauri.conf.json')) as {
bundle: { windows?: { nsis?: { installMode?: string; installerHooks?: string } } };
};

const installerHooks = config.bundle.windows?.nsis?.installerHooks;

test('a configured installer hook file exists', () => {
if (installerHooks === undefined) return;
// The path is resolved relative to the config file, not to the repo root.
assert.doesNotThrow(
() => readSource(`src-tauri/${installerHooks}`),
`bundle.windows.nsis.installerHooks points at src-tauri/${installerHooks}, which is not in the repo`
);
});

test('installer hook macros are spelled the way the bundler checks for them', () => {
if (installerHooks === undefined) return;
const hooks = readSource(`src-tauri/${installerHooks}`);
const defined = [...hooks.matchAll(/^[ \t]*!macro[ \t]+(\S+)/gm)].map((match) => match[1]);

// Only the entry points are pinned; a hook file is free to define private
// helper macros under any name it likes.
const entryPoints = defined.filter((name) => /^NSIS_HOOK/i.test(name));

for (const name of entryPoints) {
assert.ok(
BUNDLER_HOOK_MACROS.includes(name),
`${installerHooks} defines !macro ${name}, which no !ifmacrodef in the bundler template matches, ` +
`so it is never inserted. Expected one of: ${BUNDLER_HOOK_MACROS.join(', ')}`
);
}

// A hook file that defines no entry point at all is the same dead weight by
// a different route: the config names it, the bundler includes it, nothing
// in it ever runs.
assert.notEqual(
entryPoints.length,
0,
`${installerHooks} defines no NSIS_HOOK_* macro, so including it has no effect. ` +
'Drop bundle.windows.nsis.installerHooks instead.'
);
});

test('installer hooks write through the install-mode hive, not a hardcoded one', () => {
if (installerHooks === undefined) return;
const hooks = readSource(`src-tauri/${installerHooks}`);
const installMode = config.bundle.windows?.nsis?.installMode ?? 'currentUser';
if (installMode === 'currentUser') return;

// With installMode "both" the user picks per-user or all-users at install
// time, and the bundler template writes every registry key through SHCTX so
// it follows that choice. A hook that names a hive outright disagrees with
// the rest of the installer: an all-users install would leave per-user keys
// that the all-users uninstall cannot see.
const hardcoded = [
...hooks.matchAll(
/^[ \t]*(?:Write|Delete|Read)Reg\w*[ \t]+(HKCU|HKLM|HKCR|HKEY_CURRENT_USER|HKEY_LOCAL_MACHINE|HKEY_CLASSES_ROOT)\b/gm
)
].map((match) => match[1]);

assert.deepEqual(
hardcoded,
[],
`installMode is ${JSON.stringify(installMode)}, so ${installerHooks} must address the registry ` +
'through SHCTX (or SHELL_CONTEXT) like the bundler template does, not through ' +
`${[...new Set(hardcoded)].join('/')}.`
);
});
53 changes: 21 additions & 32 deletions src-tauri/hooks.nsi
Original file line number Diff line number Diff line change
@@ -1,37 +1,26 @@
!macro MARKPAD_REGISTER_OPEN_WITH EXTENSION
WriteRegStr HKCU "Software\Classes\.${EXTENSION}\shell\Open with Markpad" "" "Open with Markpad"
WriteRegStr HKCU "Software\Classes\.${EXTENSION}\shell\Open with Markpad" "Icon" "$INSTDIR\Markpad.exe"
WriteRegStr HKCU "Software\Classes\.${EXTENSION}\shell\Open with Markpad\command" "" "$\"$INSTDIR\Markpad.exe$\" $\"%1$\""

WriteRegStr HKCU "Software\Classes\SystemFileAssociations\.${EXTENSION}\shell\Open with Markpad" "" "Open with Markpad"
WriteRegStr HKCU "Software\Classes\SystemFileAssociations\.${EXTENSION}\shell\Open with Markpad" "Icon" "$INSTDIR\Markpad.exe"
WriteRegStr HKCU "Software\Classes\SystemFileAssociations\.${EXTENSION}\shell\Open with Markpad\command" "" "$\"$INSTDIR\Markpad.exe$\" $\"%1$\""

WriteRegStr HKCU "Software\Classes\.${EXTENSION}\OpenWithList\Markpad.exe" "" ""
!macroend

!macro MARKPAD_UNREGISTER_OPEN_WITH EXTENSION
DeleteRegKey HKCU "Software\Classes\.${EXTENSION}\shell\Open with Markpad"
DeleteRegKey HKCU "Software\Classes\SystemFileAssociations\.${EXTENSION}\shell\Open with Markpad"
DeleteRegKey HKCU "Software\Classes\.${EXTENSION}\OpenWithList\Markpad.exe"
!macroend

!macro NSIS_HOOK_POST_INSTALL
CreateShortcut "$DESKTOP\Markpad.lnk" "$INSTDIR\Markpad.exe" "" "$INSTDIR\Markpad.exe" 0

WriteRegStr HKCU "Software\Classes\Applications\Markpad.exe\shell\open\command" "" "$\"$INSTDIR\Markpad.exe$\" $\"%1$\""
!insertmacro MARKPAD_REGISTER_OPEN_WITH "md"
!insertmacro MARKPAD_REGISTER_OPEN_WITH "markdown"

; NSIS installer hooks, included by the bundler template
; (crates/tauri-bundler/src/bundle/windows/nsis/installer.nsi) and inserted from
; `!ifmacrodef NSIS_HOOK_POSTINSTALL` / `!ifmacrodef NSIS_HOOK_POSTUNINSTALL`.
; There is no underscore between POST and INSTALL, and `!ifmacrodef` is a
; compile-time conditional, so any other spelling is skipped in silence.
;
; Everything a hook does here has to be something the template does not already
; do. The template creates the desktop shortcut from the finish-page checkbox
; (and automatically for silent and passive installs), registers `.md` and
; `.markdown` from `bundle.fileAssociations` via APP_ASSOCIATE, and writes every
; key through SHCTX so it follows `installMode`. Duplicating any of that from
; here overrides a choice the user was already given.

!macro NSIS_HOOK_POSTINSTALL
; The template registers the file associations through FileAssociation.nsh but
; never inserts that header's own UPDATEFILEASSOC, so Explorer can go on
; serving the previous handler and icon for `.md` until the next logon.
; Broadcasting SHCNE_ASSOCCHANGED (0x08000000) with SHCNF_IDLIST (0) and two
; null items is the documented way to tell it to re-read them.
System::Call 'shell32::SHChangeNotify(i 0x08000000, i 0, p 0, p 0)'
!macroend

!macro NSIS_HOOK_POST_UNINSTALL
Delete "$DESKTOP\Markpad.lnk"

DeleteRegKey HKCU "Software\Classes\Applications\Markpad.exe"
!insertmacro MARKPAD_UNREGISTER_OPEN_WITH "md"
!insertmacro MARKPAD_UNREGISTER_OPEN_WITH "markdown"

!macro NSIS_HOOK_POSTUNINSTALL
; And again once APP_UNASSOCIATE has handed `.md` back.
System::Call 'shell32::SHChangeNotify(i 0x08000000, i 0, p 0, p 0)'
!macroend
Loading