release: publish 1.0.7 Windows shell fixes
Windows CI / verify (push) Failing after 8s

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.
This commit is contained in:
Sucukdeluxe
2026-08-12 12:55:46 +02:00
parent 5bc58a0d85
commit 49033f9ff2
13 changed files with 195 additions and 22 deletions
+8
View File
@@ -1,5 +1,13 @@
# Changelog # 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 ## 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. - Hardened queue process ownership, persisted state transitions and protected file access across downloads, imports, exports and local media tools.
+1 -1
View File
@@ -61,7 +61,7 @@ The application works in public mode without a Twitch login. Connecting a Twitch
## Installation ## Installation
1. Open the [latest GitHub release](https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/latest). 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. 3. Run the installer and choose the installation directory.
4. Start Twitch VOD Manager and add a streamer. 4. Start Twitch VOD Manager and add a streamer.
+16 -5
View File
@@ -3,20 +3,31 @@
nsExec::ExecToLog 'taskkill /F /IM "Twitch VOD Manager.exe"' nsExec::ExecToLog 'taskkill /F /IM "Twitch VOD Manager.exe"'
!macroend !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 !macro customInstall
CreateDirectory "$LOCALAPPDATA\Twitch VOD Manager\Shortcut Icons" CreateDirectory "$LOCALAPPDATA\Twitch VOD Manager\Shortcut Icons"
CopyFiles /SILENT "$INSTDIR\resources\app-icons\icon-${VERSION}.ico" "$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" 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" ${if} ${FileExists} "$newDesktopLink"
CreateShortCut "$newDesktopLink" "$appExe" "" "$0" 0 "" "" "${APP_DESCRIPTION}" CreateShortCut "$newDesktopLink" "$appExe" "" "$0" 0 "" "" "${APP_DESCRIPTION}"
ClearErrors ClearErrors
WinShell::SetLnkAUMI "$newDesktopLink" "${APP_ID}" WinShell::SetLnkAUMI "$newDesktopLink" "${APP_ID}"
${endIf} ${endIf}
${if} ${FileExists} "$newStartMenuLink" CreateShortCut "$newStartMenuLink" "$appExe" "" "$0" 0 "" "" "${APP_DESCRIPTION}"
CreateShortCut "$newStartMenuLink" "$appExe" "" "$0" 0 "" "" "${APP_DESCRIPTION}" ClearErrors
ClearErrors WinShell::SetLnkAUMI "$newStartMenuLink" "${APP_ID}"
WinShell::SetLnkAUMI "$newStartMenuLink" "${APP_ID}" System::Call 'shell32::SHChangeNotify(i 0x00001000, i 0x0005, w "$SMPROGRAMS", p 0)'
${endIf}
System::Call 'shell32::SHChangeNotify(i 0x08000000, i 0x1000, i 0, i 0)' System::Call 'shell32::SHChangeNotify(i 0x08000000, i 0x1000, i 0, i 0)'
!macroend !macroend
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "1.0.6", "version": "1.0.7",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "1.0.6", "version": "1.0.7",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"axios": "^1.16.1", "axios": "^1.16.1",
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "1.0.6", "version": "1.0.7",
"description": "Twitch VOD Manager - Download Twitch VODs easily", "description": "Twitch VOD Manager - Download Twitch VODs easily",
"main": "dist/main.js", "main": "dist/main.js",
"author": "Sucukdeluxe", "author": "Sucukdeluxe",
@@ -90,7 +90,7 @@
"deleteAppDataOnUninstall": false, "deleteAppDataOnUninstall": false,
"createDesktopShortcut": true, "createDesktopShortcut": true,
"createStartMenuShortcut": true, "createStartMenuShortcut": true,
"shortcutName": "Twitch VOD Manager v${version}", "shortcutName": "Twitch VOD Manager",
"installerIcon": "build/icon.ico", "installerIcon": "build/icon.ico",
"uninstallerIcon": "build/icon.ico", "uninstallerIcon": "build/icon.ico",
"include": "build/installer.nsh" "include": "build/installer.nsh"
+1 -1
View File
@@ -93,7 +93,7 @@ if (process.platform === 'win32') {
sourcePath: electronSourceExecutable, sourcePath: electronSourceExecutable,
destinationPath: resolve(rootDirectory, 'node_modules', 'electron', 'dist', 'Twitch VOD Manager.exe'), destinationPath: resolve(rootDirectory, 'node_modules', 'electron', 'dist', 'Twitch VOD Manager.exe'),
iconPath: resolve(rootDirectory, 'build', 'icon.ico'), iconPath: resolve(rootDirectory, 'build', 'icon.ico'),
version: '1.0.6', version: '1.0.7',
}); });
} }
+14 -1
View File
@@ -5,6 +5,7 @@ const { spawnSync } = require('child_process');
const root = path.resolve(__dirname, '..'); const root = path.resolve(__dirname, '..');
const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
const appGuid = '08429788-303d-53b6-a4f9-894401712c7e';
function run(command, args, options = {}) { function run(command, args, options = {}) {
const result = spawnSync(command, args, { const result = spawnSync(command, args, {
@@ -26,6 +27,17 @@ function findUninstaller(installationDirectory) {
.map((name) => path.join(installationDirectory, name))[0] || ''; .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) { async function waitForPathRemoval(targetPath, timeoutMs = 10000) {
const deadline = Date.now() + timeoutMs; const deadline = Date.now() + timeoutMs;
while (fs.existsSync(targetPath)) { while (fs.existsSync(targetPath)) {
@@ -43,6 +55,7 @@ async function main() {
const installerPath = path.join(root, 'release', `Twitch-VOD-Manager-Setup-${packageJson.version}.exe`); 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}`); if (!fs.statSync(installerPath).isFile()) throw new Error(`Installer is missing: ${installerPath}`);
assertCleanInstallerSmokeSurface();
const smokeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-installer-')); const smokeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-installer-'));
const installationDirectory = path.join(smokeRoot, 'app'); const installationDirectory = path.join(smokeRoot, 'app');
@@ -50,7 +63,7 @@ async function main() {
let uninstallerPath = ''; let uninstallerPath = '';
try { 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}`); if (!fs.statSync(executablePath).isFile()) throw new Error(`Installed executable is missing: ${executablePath}`);
uninstallerPath = findUninstaller(installationDirectory); uninstallerPath = findUninstaller(installationDirectory);
if (!uninstallerPath) throw new Error('Installed uninstaller is missing'); if (!uninstallerPath) throw new Error('Installed uninstaller is missing');
+16 -4
View File
@@ -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 mainSource = fs.readFileSync(path.join(root, 'src', 'main.ts'), 'utf8');
const indexSource = fs.readFileSync(path.join(root, 'src', 'index.html'), 'utf8'); const indexSource = fs.readFileSync(path.join(root, 'src', 'index.html'), 'utf8');
const installerSource = fs.readFileSync(path.join(root, 'build', 'installer.nsh'), '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 manifestPath = path.join(root, 'scripts', 'public-release-files.json');
const failures = []; const failures = [];
@@ -14,9 +15,9 @@ function check(condition, message) {
if (!condition) failures.push(message); if (!condition) failures.push(message);
} }
check(packageJson.version === '1.0.6', `package version is ${packageJson.version}`); check(packageJson.version === '1.0.7', `package version is ${packageJson.version}`);
check(packageLock.version === '1.0.6', `lockfile version is ${packageLock.version}`); check(packageLock.version === '1.0.7', `lockfile version is ${packageLock.version}`);
check(packageLock.packages?.['']?.version === '1.0.6', `lockfile root package version is ${packageLock.packages?.['']?.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?.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?.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}`); 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?.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?.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?.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'); 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(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('"$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('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 "$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('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('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(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'); 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://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(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(!/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(!indexSource.includes('Version: v4.1.13'), 'legacy version label is still present');
check(fs.existsSync(manifestPath), 'public release manifest is missing'); check(fs.existsSync(manifestPath), 'public release manifest is missing');
+100
View File
@@ -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.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}`); 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.evaluate(() => window.setDownloadPendingUi());
await win.locator('#workspaceUpdateButton').focus(); await win.locator('#workspaceUpdateButton').focus();
await win.waitForTimeout(80); await win.waitForTimeout(80);
@@ -377,6 +425,7 @@ async function run() {
await win.evaluate(() => window.hideUpdateBanner()); await win.evaluate(() => window.hideUpdateBanner());
await win.evaluate(() => window.setUpdateBannerAvailableUi({ version: '9.9.9' })); 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.locator('#workspaceUpdateButton').hover();
await win.waitForTimeout(80); await win.waitForTimeout(80);
const popoverActions = await win.evaluate(() => { 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(/^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'); 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) { if (popoverActions.laterExists) {
await win.locator('#workspaceUpdateLater').click(); await win.locator('#workspaceUpdateLater').click();
await win.waitForTimeout(160); await win.waitForTimeout(160);
+3 -3
View File
@@ -20,9 +20,9 @@
<div class="update-changelog-card is-hidden" id="updateChangelogCard"> <div class="update-changelog-card is-hidden" id="updateChangelogCard">
<div class="update-changelog-header"> <div class="update-changelog-header">
<span class="update-changelog-label" id="updateChangelogLabel">Changelog</span> <span class="update-changelog-label" id="updateChangelogLabel">Changelog</span>
<button type="button" class="update-changelog-toggle" id="updateChangelogToggle" onclick="toggleUpdateChangelog()">Changelog anzeigen</button> <button type="button" class="update-changelog-toggle" id="updateChangelogToggle" onclick="toggleUpdateChangelog()" aria-controls="updateChangelogPanel" aria-expanded="false">Changelog anzeigen</button>
</div> </div>
<div class="update-changelog-panel" id="updateChangelogPanel" hidden> <div class="update-changelog-panel" id="updateChangelogPanel" aria-hidden="true">
<div class="update-changelog-content" id="updateChangelogContent"></div> <div class="update-changelog-content" id="updateChangelogContent"></div>
<p class="update-changelog-empty" id="updateChangelogEmpty" hidden>Kein Changelog verfügbar.</p> <p class="update-changelog-empty" id="updateChangelogEmpty" hidden>Kein Changelog verfügbar.</p>
</div> </div>
@@ -940,7 +940,7 @@
<div class="settings-card" data-settings-pane="updates" hidden> <div class="settings-card" data-settings-pane="updates" hidden>
<h3 id="updateTitle">Updates</h3> <h3 id="updateTitle">Updates</h3>
<p id="versionInfo" class="card-intro">Version: v1.0.6</p> <p id="versionInfo" class="card-intro">Version: v1.0.7</p>
<button type="button" class="btn-secondary" id="checkUpdateBtn" onclick="checkUpdate()">Nach Updates suchen</button> <button type="button" class="btn-secondary" id="checkUpdateBtn" onclick="checkUpdate()">Nach Updates suchen</button>
</div> </div>
+8 -3
View File
@@ -293,7 +293,8 @@ function renderUpdateChangelog(notes?: string): void {
if (!normalized) { if (!normalized) {
card.classList.add('is-hidden'); card.classList.add('is-hidden');
panel.hidden = true; panel.classList.remove('is-expanded');
panel.setAttribute('aria-hidden', 'true');
updateChangelogExpanded = false; updateChangelogExpanded = false;
return; return;
} }
@@ -372,7 +373,8 @@ function renderUpdateChangelog(notes?: string): void {
content.appendChild(fragment); content.appendChild(fragment);
} }
panel.hidden = !updateChangelogExpanded; panel.classList.toggle('is-expanded', updateChangelogExpanded);
panel.setAttribute('aria-hidden', String(!updateChangelogExpanded));
} }
function refreshUpdateChangelogToggleText(): void { function refreshUpdateChangelogToggleText(): void {
@@ -383,6 +385,7 @@ function refreshUpdateChangelogToggleText(): void {
} }
toggle.textContent = updateChangelogExpanded ? UI_TEXT.updates.hideChangelog : UI_TEXT.updates.showChangelog; toggle.textContent = updateChangelogExpanded ? UI_TEXT.updates.hideChangelog : UI_TEXT.updates.showChangelog;
toggle.setAttribute('aria-expanded', String(updateChangelogExpanded));
} }
function refreshUpdateModalTexts(): void { function refreshUpdateModalTexts(): void {
@@ -459,7 +462,9 @@ function toggleUpdateChangelog(): void {
} }
updateChangelogExpanded = !updateChangelogExpanded; updateChangelogExpanded = !updateChangelogExpanded;
byId<HTMLElement>('updateChangelogPanel').hidden = !updateChangelogExpanded; const panel = byId<HTMLElement>('updateChangelogPanel');
panel.classList.toggle('is-expanded', updateChangelogExpanded);
panel.setAttribute('aria-hidden', String(!updateChangelogExpanded));
refreshUpdateChangelogToggleText(); refreshUpdateChangelogToggleText();
} }
+11
View File
@@ -3000,9 +3000,20 @@ input[type="checkbox"].vod-select-checkbox {
} }
.update-changelog-panel { .update-changelog-panel {
max-height: 0;
overflow: hidden;
padding: 0 14px;
opacity: 0;
transform: translateY(-4px);
transition: max-height 260ms cubic-bezier(0.22, 0.76, 0.22, 1), padding 260ms cubic-bezier(0.22, 0.76, 0.22, 1), opacity 180ms ease, transform 260ms cubic-bezier(0.22, 0.76, 0.22, 1);
}
.update-changelog-panel.is-expanded {
max-height: 320px; max-height: 320px;
overflow: auto; overflow: auto;
padding: 14px; padding: 14px;
opacity: 1;
transform: translateY(0);
} }
.update-changelog-content { .update-changelog-content {
+13
View File
@@ -2646,6 +2646,10 @@ input[type="checkbox"].vod-select-checkbox:focus-visible {
transition-delay: 0s !important; transition-delay: 0s !important;
} }
.update-changelog-panel {
transition: max-height 260ms cubic-bezier(0.22, 0.76, 0.22, 1), padding 260ms cubic-bezier(0.22, 0.76, 0.22, 1), opacity 180ms ease, transform 260ms cubic-bezier(0.22, 0.76, 0.22, 1) !important;
}
.top-nav::before { .top-nav::before {
transition: transform 420ms cubic-bezier(0.22, 0.76, 0.22, 1), width 420ms cubic-bezier(0.22, 0.76, 0.22, 1), height 420ms cubic-bezier(0.22, 0.76, 0.22, 1) !important; transition: transform 420ms cubic-bezier(0.22, 0.76, 0.22, 1), width 420ms cubic-bezier(0.22, 0.76, 0.22, 1), height 420ms cubic-bezier(0.22, 0.76, 0.22, 1) !important;
} }
@@ -2873,6 +2877,15 @@ input[type="checkbox"].vod-select-checkbox:focus-visible {
transform: rotate(45deg); transform: rotate(45deg);
} }
.workspace-update-popover::after {
position: absolute;
top: -7px;
right: 0;
left: 0;
height: 7px;
content: "";
}
.workspace-update.show:not(.popover-dismissed):hover .workspace-update-popover, .workspace-update.show:not(.popover-dismissed):hover .workspace-update-popover,
.workspace-update.show:not(.popover-dismissed):focus-within .workspace-update-popover { .workspace-update.show:not(.popover-dismissed):focus-within .workspace-update-popover {
visibility: visible; visibility: visible;