From 49033f9ff2f4d798417333dc7efe990197ba43bd Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:55:46 +0200 Subject: [PATCH] release: publish 1.0.7 Windows shell fixes Stabilize Start menu and desktop shortcut migration with persistent versioned icon resources, stale-registration recovery, and explicit Windows shell notifications. Keep development, packaged, and shortcut identities aligned, and add regression coverage for orphaned per-user installs, reduced-motion changelog transitions, and update-popover pointer transit. --- CHANGELOG.md | 8 ++ README.md | 2 +- build/installer.nsh | 21 +++- package-lock.json | 4 +- package.json | 4 +- scripts/dev.mjs | 2 +- scripts/smoke-test-installer.js | 15 ++- scripts/smoke-test-public-release-config.js | 20 +++- scripts/smoke-test-workspace-ui.js | 100 ++++++++++++++++++++ src/index.html | 6 +- src/renderer-updates.ts | 11 ++- src/styles.css | 11 +++ src/workspace.css | 13 +++ 13 files changed, 195 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11c81b3..e4eb9fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 1.0.7 - 2026-08-12 + +- Replaced versioned Windows Start menu shortcuts with one stable application entry, migrated legacy shortcuts during upgrades and refreshed the Windows Shell registration. +- Kept the application icon consistent across the installer, desktop shortcut, Start menu entry, taskbar and relaunch metadata. +- Repaired upgrades after incomplete per-user installations so missing program files are restored before shortcuts are refreshed. +- Fixed the available-update popover so pointer movement from the Update button into its actions remains reliable across the full button width. +- Added a smooth expandable changelog section to the update dialog. + ## 1.0.6 - 2026-08-12 - Hardened queue process ownership, persisted state transitions and protected file access across downloads, imports, exports and local media tools. diff --git a/README.md b/README.md index 8fc8b30..9a456ec 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ The application works in public mode without a Twitch login. Connecting a Twitch ## Installation 1. Open the [latest GitHub release](https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/latest). -2. Download `Twitch-VOD-Manager-Setup-1.0.6.exe`. +2. Download `Twitch-VOD-Manager-Setup-1.0.7.exe`. 3. Run the installer and choose the installation directory. 4. Start Twitch VOD Manager and add a streamer. diff --git a/build/installer.nsh b/build/installer.nsh index e538b72..ef38475 100644 --- a/build/installer.nsh +++ b/build/installer.nsh @@ -3,20 +3,31 @@ nsExec::ExecToLog 'taskkill /F /IM "Twitch VOD Manager.exe"' !macroend +!macro preInit + ReadRegStr $0 HKCU "${INSTALL_REGISTRY_KEY}" InstallLocation + ${if} $0 != "" + ${ifNot} ${FileExists} "$0\${APP_EXECUTABLE_FILENAME}" + DeleteRegKey HKCU "${INSTALL_REGISTRY_KEY}" + DeleteRegKey HKCU "${UNINSTALL_REGISTRY_KEY}" + ${endIf} + ${endIf} +!macroend + !macro customInstall CreateDirectory "$LOCALAPPDATA\Twitch VOD Manager\Shortcut Icons" CopyFiles /SILENT "$INSTDIR\resources\app-icons\icon-${VERSION}.ico" "$LOCALAPPDATA\Twitch VOD Manager\Shortcut Icons" StrCpy $0 "$LOCALAPPDATA\Twitch VOD Manager\Shortcut Icons\icon-${VERSION}.ico" + Delete "$SMPROGRAMS\Twitch VOD Manager v*.lnk" + Delete "$DESKTOP\Twitch VOD Manager v*.lnk" ${if} ${FileExists} "$newDesktopLink" CreateShortCut "$newDesktopLink" "$appExe" "" "$0" 0 "" "" "${APP_DESCRIPTION}" ClearErrors WinShell::SetLnkAUMI "$newDesktopLink" "${APP_ID}" ${endIf} - ${if} ${FileExists} "$newStartMenuLink" - CreateShortCut "$newStartMenuLink" "$appExe" "" "$0" 0 "" "" "${APP_DESCRIPTION}" - ClearErrors - WinShell::SetLnkAUMI "$newStartMenuLink" "${APP_ID}" - ${endIf} + CreateShortCut "$newStartMenuLink" "$appExe" "" "$0" 0 "" "" "${APP_DESCRIPTION}" + ClearErrors + WinShell::SetLnkAUMI "$newStartMenuLink" "${APP_ID}" + System::Call 'shell32::SHChangeNotify(i 0x00001000, i 0x0005, w "$SMPROGRAMS", p 0)' System::Call 'shell32::SHChangeNotify(i 0x08000000, i 0x1000, i 0, i 0)' !macroend diff --git a/package-lock.json b/package-lock.json index bf630ff..0b3686d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "twitch-vod-manager", - "version": "1.0.6", + "version": "1.0.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "twitch-vod-manager", - "version": "1.0.6", + "version": "1.0.7", "license": "MIT", "dependencies": { "axios": "^1.16.1", diff --git a/package.json b/package.json index 28aafd8..d345164 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "twitch-vod-manager", - "version": "1.0.6", + "version": "1.0.7", "description": "Twitch VOD Manager - Download Twitch VODs easily", "main": "dist/main.js", "author": "Sucukdeluxe", @@ -90,7 +90,7 @@ "deleteAppDataOnUninstall": false, "createDesktopShortcut": true, "createStartMenuShortcut": true, - "shortcutName": "Twitch VOD Manager v${version}", + "shortcutName": "Twitch VOD Manager", "installerIcon": "build/icon.ico", "uninstallerIcon": "build/icon.ico", "include": "build/installer.nsh" diff --git a/scripts/dev.mjs b/scripts/dev.mjs index efbb4bf..f22aca3 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -93,7 +93,7 @@ if (process.platform === 'win32') { sourcePath: electronSourceExecutable, destinationPath: resolve(rootDirectory, 'node_modules', 'electron', 'dist', 'Twitch VOD Manager.exe'), iconPath: resolve(rootDirectory, 'build', 'icon.ico'), - version: '1.0.6', + version: '1.0.7', }); } diff --git a/scripts/smoke-test-installer.js b/scripts/smoke-test-installer.js index 7f4ca2c..09d2a5f 100644 --- a/scripts/smoke-test-installer.js +++ b/scripts/smoke-test-installer.js @@ -5,6 +5,7 @@ const { spawnSync } = require('child_process'); const root = path.resolve(__dirname, '..'); const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); +const appGuid = '08429788-303d-53b6-a4f9-894401712c7e'; function run(command, args, options = {}) { const result = spawnSync(command, args, { @@ -26,6 +27,17 @@ function findUninstaller(installationDirectory) { .map((name) => path.join(installationDirectory, name))[0] || ''; } +function assertCleanInstallerSmokeSurface() { + const userInstallKey = `HKCU\\Software\\${appGuid}`; + const machineInstallKey = `HKLM\\SOFTWARE\\${appGuid}`; + const query = (key) => spawnSync('reg', ['query', key], { encoding: 'utf8', windowsHide: true }); + const existingInstallations = [userInstallKey, machineInstallKey] + .filter((key) => query(key).status === 0); + if (existingInstallations.length > 0) { + throw new Error(`Installer smoke requires a clean Windows registration surface: ${existingInstallations.join(', ')}`); + } +} + async function waitForPathRemoval(targetPath, timeoutMs = 10000) { const deadline = Date.now() + timeoutMs; while (fs.existsSync(targetPath)) { @@ -43,6 +55,7 @@ async function main() { const installerPath = path.join(root, 'release', `Twitch-VOD-Manager-Setup-${packageJson.version}.exe`); if (!fs.statSync(installerPath).isFile()) throw new Error(`Installer is missing: ${installerPath}`); + assertCleanInstallerSmokeSurface(); const smokeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-installer-')); const installationDirectory = path.join(smokeRoot, 'app'); @@ -50,7 +63,7 @@ async function main() { let uninstallerPath = ''; try { - run(installerPath, ['/S', `/D=${installationDirectory}`], { cwd: smokeRoot }); + run(installerPath, ['/S', '/currentuser', `/D=${installationDirectory}`], { cwd: smokeRoot }); if (!fs.statSync(executablePath).isFile()) throw new Error(`Installed executable is missing: ${executablePath}`); uninstallerPath = findUninstaller(installationDirectory); if (!uninstallerPath) throw new Error('Installed uninstaller is missing'); diff --git a/scripts/smoke-test-public-release-config.js b/scripts/smoke-test-public-release-config.js index b54214e..0d0aebc 100644 --- a/scripts/smoke-test-public-release-config.js +++ b/scripts/smoke-test-public-release-config.js @@ -7,6 +7,7 @@ const packageLock = JSON.parse(fs.readFileSync(path.join(root, 'package-lock.jso const mainSource = fs.readFileSync(path.join(root, 'src', 'main.ts'), 'utf8'); const indexSource = fs.readFileSync(path.join(root, 'src', 'index.html'), 'utf8'); const installerSource = fs.readFileSync(path.join(root, 'build', 'installer.nsh'), 'utf8'); +const installerSmokeSource = fs.readFileSync(path.join(root, 'scripts', 'smoke-test-installer.js'), 'utf8'); const manifestPath = path.join(root, 'scripts', 'public-release-files.json'); const failures = []; @@ -14,9 +15,9 @@ function check(condition, message) { if (!condition) failures.push(message); } -check(packageJson.version === '1.0.6', `package version is ${packageJson.version}`); -check(packageLock.version === '1.0.6', `lockfile version is ${packageLock.version}`); -check(packageLock.packages?.['']?.version === '1.0.6', `lockfile root package version is ${packageLock.packages?.['']?.version}`); +check(packageJson.version === '1.0.7', `package version is ${packageJson.version}`); +check(packageLock.version === '1.0.7', `lockfile version is ${packageLock.version}`); +check(packageLock.packages?.['']?.version === '1.0.7', `lockfile root package version is ${packageLock.packages?.['']?.version}`); check(packageJson.build?.appId === 'io.github.sucukdeluxe.twitch-vod-manager', `appId is ${packageJson.build?.appId}`); check(packageJson.build?.publish?.provider === 'generic', `publish provider is ${packageJson.build?.publish?.provider}`); check(packageJson.build?.publish?.url === 'https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/latest/download/', `publish URL is ${packageJson.build?.publish?.url}`); @@ -24,12 +25,23 @@ check(JSON.stringify(packageJson.build?.files) === JSON.stringify(['dist/**/*', check(packageJson.build?.win?.icon === 'build/icon.ico', `Windows icon is ${packageJson.build?.win?.icon}`); check(packageJson.build?.nsis?.installerIcon === 'build/icon.ico', `installer icon is ${packageJson.build?.nsis?.installerIcon}`); check(packageJson.build?.nsis?.uninstallerIcon === 'build/icon.ico', `uninstaller icon is ${packageJson.build?.nsis?.uninstallerIcon}`); +check(packageJson.build?.nsis?.shortcutName === 'Twitch VOD Manager', `Windows Start Menu shortcut is not stable: ${packageJson.build?.nsis?.shortcutName}`); +check(installerSource.includes('!macro preInit'), 'installer does not recover from orphaned Windows registration before upgrade detection'); +check(installerSource.includes('ReadRegStr $0 HKCU "${INSTALL_REGISTRY_KEY}" InstallLocation'), 'installer does not read the existing per-user install location before upgrade detection'); +check(installerSource.includes('${ifNot} ${FileExists} "$0\\${APP_EXECUTABLE_FILENAME}"'), 'installer does not detect a missing executable in an existing per-user registration'); +check(installerSource.includes('DeleteRegKey HKCU "${INSTALL_REGISTRY_KEY}"') && installerSource.includes('DeleteRegKey HKCU "${UNINSTALL_REGISTRY_KEY}"'), 'installer does not clear orphaned per-user registration before upgrade detection'); const shortcutIconResource = packageJson.build?.extraResources?.find((entry) => entry?.from === 'build/icon.ico'); check(shortcutIconResource?.to === 'app-icons/icon-${version}.ico', `versioned shortcut icon resource is ${shortcutIconResource?.to}`); check(installerSource.includes('"$LOCALAPPDATA\\Twitch VOD Manager\\Shortcut Icons\\icon-${VERSION}.ico"'), 'installed shortcuts do not use the persistent versioned icon resource'); check(installerSource.includes('CopyFiles /SILENT "$INSTDIR\\resources\\app-icons\\icon-${VERSION}.ico"'), 'versioned shortcut icon is not copied to persistent storage'); check(installerSource.includes('CreateShortCut "$newDesktopLink"'), 'desktop shortcut is not refreshed with the versioned icon resource'); check(installerSource.includes('CreateShortCut "$newStartMenuLink"'), 'start menu shortcut is not refreshed with the versioned icon resource'); +check(installerSource.includes('Delete "$SMPROGRAMS\\Twitch VOD Manager v*.lnk"'), 'legacy versioned Start Menu shortcuts are not removed during upgrade'); +const stableStartShortcutBlock = installerSource.match(/Delete "\$SMPROGRAMS\\Twitch VOD Manager v\*\.lnk"([\s\S]*?)System::Call 'shell32::SHChangeNotify\(i 0x00001000/); +check(Boolean(stableStartShortcutBlock) && !stableStartShortcutBlock[1].includes('${if} ${FileExists} "$newStartMenuLink"'), 'stable Start Menu shortcut is not recreated when an older installer did not register it'); +check(installerSource.includes('SHChangeNotify(i 0x00001000, i 0x0005, w "$SMPROGRAMS"'), 'Windows Start Menu is not notified after shortcut refresh'); +check(installerSmokeSource.includes("'/currentuser'"), 'installer smoke does not force a per-user test installation'); +check(installerSmokeSource.includes('assertCleanInstallerSmokeSurface'), 'installer smoke can run against an existing workstation installation'); check(installerSource.includes('SHChangeNotify(i 0x08000000, i 0x1000'), 'Windows shell icon cache is not flushed after shortcut refresh'); check(installerSource.includes('${ifNot} ${isUpdated}') && installerSource.includes('RMDir /r "$LOCALAPPDATA\\Twitch VOD Manager\\Shortcut Icons"'), 'persistent shortcut icons are not cleaned up on a real uninstall'); check(packageJson.build?.win?.signAndEditExecutable !== false, 'Windows executable resource editing is enabled'); @@ -50,7 +62,7 @@ check(mainSource.includes('GITHUB_RELEASES_DOWNLOAD_BASE_URL'), 'GitHub releases check(mainSource.includes('https://api.github.com/repos/Sucukdeluxe/Twitch-VOD-Manager/releases/latest'), 'GitHub latest release API URL is missing'); check(mainSource.includes('https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/download'), 'GitHub release download URL is missing'); check(!/storyboards\/\d{8,12}(?:-|\/)/.test(mainSource), 'numeric Twitch VOD example remains in the public source'); -check(indexSource.includes('Version: v1.0.6'), 'initial version label is not 1.0.6'); +check(indexSource.includes('Version: v1.0.7'), 'initial version label is not 1.0.7'); check(!indexSource.includes('Version: v4.1.13'), 'legacy version label is still present'); check(fs.existsSync(manifestPath), 'public release manifest is missing'); diff --git a/scripts/smoke-test-workspace-ui.js b/scripts/smoke-test-workspace-ui.js index 7e8ac92..86d542a 100644 --- a/scripts/smoke-test-workspace-ui.js +++ b/scripts/smoke-test-workspace-ui.js @@ -351,6 +351,54 @@ async function run() { check(updateModalButtons.overflow.every((overflow) => overflow <= 0), `Update modal button text overflows by ${updateModalButtons.overflow.join(', ')}px`); check(updateModalButtons.skipLineHeight >= 14, `Update skip-version line height is too small: ${updateModalButtons.skipLineHeight}`); + await win.emulateMedia({ reducedMotion: 'reduce' }); + await win.evaluate(() => { + updateReady = false; + openUpdateModal({ + version: '9.9.9', + releaseNotes: '- Stable Windows shell registration\n- Smoother update details' + }); + }); + const captureUpdateChangelog = () => win.evaluate(() => { + const panel = document.getElementById('updateChangelogPanel'); + const toggle = document.getElementById('updateChangelogToggle'); + const style = panel ? getComputedStyle(panel) : null; + return { + hidden: panel?.hidden || false, + expanded: panel?.classList.contains('is-expanded') || false, + ariaHidden: panel?.getAttribute('aria-hidden') || '', + ariaExpanded: toggle?.getAttribute('aria-expanded') || '', + height: panel?.getBoundingClientRect().height || 0, + transitionDuration: style?.transitionDuration || '' + }; + }); + const changelogCollapsed = await captureUpdateChangelog(); + await win.locator('#updateChangelogToggle').click(); + await win.waitForTimeout(100); + const changelogOpening = await captureUpdateChangelog(); + await win.waitForTimeout(260); + const changelogExpanded = await captureUpdateChangelog(); + await win.locator('#updateChangelogToggle').click(); + await win.waitForTimeout(100); + const changelogClosing = await captureUpdateChangelog(); + await win.waitForTimeout(260); + const changelogClosed = await captureUpdateChangelog(); + await win.evaluate(() => dismissUpdateModal()); + await win.emulateMedia({ reducedMotion: 'no-preference' }); + checks.updateChangelogMotion = { + collapsed: changelogCollapsed, + opening: changelogOpening, + expanded: changelogExpanded, + closing: changelogClosing, + closed: changelogClosed + }; + check(!changelogCollapsed.hidden && !changelogCollapsed.expanded && changelogCollapsed.ariaHidden === 'true' && changelogCollapsed.ariaExpanded === 'false', `Collapsed changelog does not remain animatable: ${JSON.stringify(changelogCollapsed)}`); + check(changelogOpening.expanded && changelogOpening.height > changelogCollapsed.height && changelogOpening.height < changelogExpanded.height, `Changelog does not visibly expand through an intermediate frame: ${JSON.stringify({ changelogCollapsed, changelogOpening, changelogExpanded })}`); + check(changelogExpanded.expanded && changelogExpanded.ariaHidden === 'false' && changelogExpanded.ariaExpanded === 'true' && changelogExpanded.height > 0, `Expanded changelog state is incorrect: ${JSON.stringify(changelogExpanded)}`); + check(changelogClosing.height > changelogClosed.height && changelogClosing.height < changelogExpanded.height, `Changelog does not visibly collapse through an intermediate frame: ${JSON.stringify({ changelogExpanded, changelogClosing, changelogClosed })}`); + check(!changelogClosed.expanded && changelogClosed.ariaHidden === 'true' && changelogClosed.ariaExpanded === 'false' && changelogClosed.height === 0, `Collapsed changelog state is incorrect: ${JSON.stringify(changelogClosed)}`); + check(!/^0\.01ms(?:, 0\.01ms)*$/.test(changelogOpening.transitionDuration), `Changelog animation is disabled by reduced-motion fallback: ${changelogOpening.transitionDuration}`); + await win.evaluate(() => window.setDownloadPendingUi()); await win.locator('#workspaceUpdateButton').focus(); await win.waitForTimeout(80); @@ -377,6 +425,7 @@ async function run() { await win.evaluate(() => window.hideUpdateBanner()); await win.evaluate(() => window.setUpdateBannerAvailableUi({ version: '9.9.9' })); + await win.evaluate(() => (document.activeElement instanceof HTMLElement ? document.activeElement.blur() : undefined)); await win.locator('#workspaceUpdateButton').hover(); await win.waitForTimeout(80); const popoverActions = await win.evaluate(() => { @@ -401,6 +450,57 @@ async function run() { check(/^close$/i.test(popoverActions.dismissLabel), `Available update Close action is labelled "${popoverActions.dismissLabel}"`); check(popoverActions.expanded === 'true', 'Available update trigger does not expose aria-expanded=true while the popover is visible'); + const updatePopoverTransit = []; + const updateButtonBox = await win.locator('#workspaceUpdateButton').boundingBox(); + const updatePopoverBox = await win.locator('#workspaceUpdatePopover').boundingBox(); + if (updateButtonBox && updatePopoverBox) { + const transitXs = [ + updateButtonBox.x + 10, + updateButtonBox.x + updateButtonBox.width / 2, + updateButtonBox.x + updateButtonBox.width - 10 + ]; + const transitY = (updateButtonBox.y + updateButtonBox.height + updatePopoverBox.y) / 2; + + for (const transitX of transitXs) { + await win.mouse.move(transitX, transitY); + await win.waitForTimeout(300); + const gapState = await win.evaluate(({ transitX, transitY }) => { + const button = document.getElementById('workspaceUpdateButton'); + const popover = document.getElementById('workspaceUpdatePopover'); + const style = popover ? getComputedStyle(popover) : null; + return { + expanded: button?.getAttribute('aria-expanded') || '', + visibility: style?.visibility || '', + pointerEvents: style?.pointerEvents || '', + target: document.elementFromPoint(transitX, transitY)?.id || '' + }; + }, { transitX, transitY }); + await win.locator('#workspaceUpdateButton').hover(); + await win.waitForTimeout(160); + const panelState = await win.evaluate(() => { + const button = document.getElementById('workspaceUpdateButton'); + const popover = document.getElementById('workspaceUpdatePopover'); + const style = popover ? getComputedStyle(popover) : null; + return { + expanded: button?.getAttribute('aria-expanded') || '', + visibility: style?.visibility || '', + pointerEvents: style?.pointerEvents || '' + }; + }); + updatePopoverTransit.push({ gapState, panelState }); + } + } + checks.updatePopoverTransit = updatePopoverTransit; + check(updatePopoverTransit.length === 3, 'Available update popover geometry cannot be measured for hover transit'); + check(updatePopoverTransit.every(({ gapState, panelState }) => ( + gapState.expanded === 'true' + && gapState.visibility === 'visible' + && gapState.pointerEvents === 'auto' + && panelState.expanded === 'true' + && panelState.visibility === 'visible' + && panelState.pointerEvents === 'auto' + )), `Available update popover closes while moving into it: ${JSON.stringify(updatePopoverTransit)}`); + if (popoverActions.laterExists) { await win.locator('#workspaceUpdateLater').click(); await win.waitForTimeout(160); diff --git a/src/index.html b/src/index.html index e099b1f..ac74d5a 100644 --- a/src/index.html +++ b/src/index.html @@ -20,9 +20,9 @@
Version: v1.0.6
+Version: v1.0.7