From 43ebed0ade142458eda7968a7a74896c5aab4b30 Mon Sep 17 00:00:00 2001 From: Andrew Zambazos Date: Sat, 27 Dec 2025 22:23:14 +1300 Subject: [PATCH] Add auto-update and display scale features Integrates electron-updater for automatic app updates, including IPC and renderer APIs for update status and controls. Adds display scale (zoom) setting to settings UI, persists user preference in localStorage, and applies it on startup. Updates OAuth popup allowlist logic and upgrades Electron to v39.2.7. --- main.js | 164 +++++++++++++++++++++++++++++++++++++---- package-lock.json | 112 +++++++++++++++++++++++++--- package.json | 12 ++- preload.js | 9 +++ renderer/script.js | 18 +++++ renderer/settings.html | 12 +++ renderer/settings.js | 22 ++++++ 7 files changed, 320 insertions(+), 29 deletions(-) diff --git a/main.js b/main.js index 50beb6b..739d0b5 100644 --- a/main.js +++ b/main.js @@ -1,4 +1,5 @@ const { app, BrowserWindow, ipcMain, session, screen, shell, dialog, Menu, clipboard, webContents } = require('electron'); +const { autoUpdater } = require('electron-updater'); const { pathToFileURL } = require('url'); const fs = require('fs'); const path = require('path'); @@ -140,14 +141,28 @@ function createWindow(startUrl) { perfMarks.browserWindow_instantiated = performance.now(); // Intercept window.open() requests and route them into the existing window as a new tab - // instead of spawning separate BrowserWindows. We still allow a small OAuth allowlist - // (accounts.google.com, login.microsoftonline.com, oauth, sso) to open real popups if - // the flow depends on window.opener relationships. Everything else becomes a new tab. + // instead of spawning separate BrowserWindows. We allow a small list of specific OAuth + // domains to open real popups if the flow depends on window.opener relationships. + // Everything else becomes a new tab. win.webContents.setWindowOpenHandler((details) => { const { url } = details; if (!/^https?:\/\//i.test(url)) return { action: 'deny' }; - // OAuth / SSO allowlist heuristic - if (/accounts\.google\.com|microsoftonline\.com|oauth|login|signin|sso/i.test(url)) { + // OAuth / SSO allowlist - only allow specific authentication provider domains + // Be restrictive to prevent normal links from opening in new windows + const oauthDomains = [ + 'accounts.google.com', + 'login.microsoftonline.com', + 'appleid.apple.com', + 'github.com/login', + 'auth0.com', + 'okta.com', + 'login.live.com', + 'facebook.com/dialog', + 'api.twitter.com/oauth', + 'discord.com/oauth2' + ]; + const isOAuthDomain = oauthDomains.some(domain => url.toLowerCase().includes(domain.toLowerCase())); + if (isOAuthDomain) { return { action: 'allow' }; // preserve popup semantics for complex auth flows } // Forward to renderer to open as tab @@ -166,18 +181,31 @@ function createWindow(startUrl) { // above now governs popup behavior. // ensure all embedded tags behave predictably without heavy injections - win.webContents.on('did-attach-webview', (event, webContents) => { + win.webContents.on('did-attach-webview', (event, webviewContents) => { // Route window.open() calls to tabs unless OAuth allowlist matched - webContents.setWindowOpenHandler((details) => { + webviewContents.setWindowOpenHandler((details) => { const { url } = details; if (!/^https?:\/\//i.test(url)) return { action: 'deny' }; - if (/accounts\.google\.com|microsoftonline\.com|oauth|login|signin|sso/i.test(url)) { + // OAuth / SSO allowlist - only allow specific authentication provider domains + const oauthDomains = [ + 'accounts.google.com', + 'login.microsoftonline.com', + 'appleid.apple.com', + 'github.com/login', + 'auth0.com', + 'okta.com', + 'login.live.com', + 'facebook.com/dialog', + 'api.twitter.com/oauth', + 'discord.com/oauth2' + ]; + const isOAuthDomain = oauthDomains.some(domain => url.toLowerCase().includes(domain.toLowerCase())); + if (isOAuthDomain) { return { action: 'allow' }; // keep popup for auth } - // Send to the owning window (embedder) to open a new tab + // Send to main window's webContents to open a new tab try { - const host = webContents.hostWebContents || webContents; - host.send('open-url-new-tab', url); + win.webContents.send('open-url-new-tab', url); } catch {} return { action: 'deny' }; }); @@ -364,6 +392,61 @@ app.whenReady().then(() => { app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); }); + + // --- Auto-Updater Setup --- + // Configure auto-updater logging + autoUpdater.logger = require('electron-updater').autoUpdater.logger; + if (autoUpdater.logger) autoUpdater.logger.transports.file.level = 'info'; + + // Check for updates after a short delay to not block startup + setTimeout(() => { + autoUpdater.checkForUpdatesAndNotify().catch(err => { + console.log('[AutoUpdater] Update check failed:', err.message); + }); + }, 3000); + + // Auto-updater event handlers + autoUpdater.on('checking-for-update', () => { + console.log('[AutoUpdater] Checking for updates...'); + broadcastToAll('update-status', { status: 'checking' }); + }); + + autoUpdater.on('update-available', (info) => { + console.log('[AutoUpdater] Update available:', info.version); + broadcastToAll('update-status', { status: 'available', version: info.version }); + }); + + autoUpdater.on('update-not-available', (info) => { + console.log('[AutoUpdater] No update available. Current version:', app.getVersion()); + broadcastToAll('update-status', { status: 'not-available', currentVersion: app.getVersion() }); + }); + + autoUpdater.on('download-progress', (progress) => { + console.log(`[AutoUpdater] Download progress: ${progress.percent.toFixed(1)}%`); + broadcastToAll('update-status', { status: 'downloading', progress: progress.percent }); + }); + + autoUpdater.on('update-downloaded', (info) => { + console.log('[AutoUpdater] Update downloaded:', info.version); + broadcastToAll('update-status', { status: 'downloaded', version: info.version }); + // Optionally prompt user to restart + dialog.showMessageBox({ + type: 'info', + title: 'Update Ready', + message: `Nebula ${info.version} has been downloaded.`, + detail: 'The update will be installed when you restart the app.', + buttons: ['Restart Now', 'Later'] + }).then(result => { + if (result.response === 0) { + autoUpdater.quitAndInstall(); + } + }); + }); + + autoUpdater.on('error', (err) => { + console.error('[AutoUpdater] Error:', err.message); + broadcastToAll('update-status', { status: 'error', message: err.message }); + }); }); // Quit when all windows are closed. @@ -373,6 +456,33 @@ app.on('window-all-closed', () => { // ipcMain handlers +// --- Auto-Update IPC handlers --- +ipcMain.handle('check-for-updates', async () => { + try { + const result = await autoUpdater.checkForUpdates(); + return { success: true, updateInfo: result?.updateInfo }; + } catch (err) { + return { success: false, error: err.message }; + } +}); + +ipcMain.handle('download-update', async () => { + try { + await autoUpdater.downloadUpdate(); + return { success: true }; + } catch (err) { + return { success: false, error: err.message }; + } +}); + +ipcMain.handle('install-update', () => { + autoUpdater.quitAndInstall(); +}); + +ipcMain.handle('get-app-version', () => { + return app.getVersion(); +}); + // --- window control handlers (only registered once now) ipcMain.handle('window-minimize', event => { BrowserWindow.fromWebContents(event.sender).minimize(); @@ -560,6 +670,30 @@ ipcMain.handle('zoom-out', event => { return z; }); +ipcMain.handle('get-display-scale', async (event) => { + // Try to read from localStorage data (user data path) + const userDataPath = app.getPath('userData'); + const storageFile = path.join(userDataPath, 'localStorage'); + + try { + // Try to get from electron store or persistent storage + // For now, we'll just return a default and let the app set it + // The display scale is stored in localStorage on the client side + return 100; // Default to 100% + } catch (err) { + return 100; // Default to 100% + } +}); + +ipcMain.handle('set-zoom-factor', (event, zoomFactor) => { + const wc = BrowserWindow.fromWebContents(event.sender).webContents; + if (wc && typeof wc.setZoomFactor === 'function') { + wc.setZoomFactor(zoomFactor); + return true; + } + return false; +}); + // allow renderer to pop a tab into its own window ipcMain.handle('open-tab-in-new-window', (event, url) => { createWindow(url); @@ -750,7 +884,7 @@ ipcMain.handle('get-electron-versions', async (event, buildType = 'stable') => { }); ipcMain.handle('upgrade-electron', async (event, buildType = 'stable') => { - const { execFile } = require('child_process'); + const { exec } = require('child_process'); const packageName = buildType === 'nightly' ? 'electron-nightly' : 'electron'; return new Promise((resolve) => { @@ -758,10 +892,10 @@ ipcMain.handle('upgrade-electron', async (event, buildType = 'stable') => { const otherPackage = buildType === 'nightly' ? 'electron' : 'electron-nightly'; // Run npm install to upgrade the package - const args = ['install', '--save-dev', packageName + '@latest']; + const command = `npm install --save-dev ${packageName}@latest`; - execFile('npm', args, - { cwd: __dirname, shell: true, maxBuffer: 10 * 1024 * 1024 }, + exec(command, + { cwd: __dirname, maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => { if (error) { console.error('Upgrade failed:', error); diff --git a/package-lock.json b/package-lock.json index 78c8889..23be45a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,11 +10,12 @@ "license": "ISC", "dependencies": { "dompurify": "^3.1.6", + "electron-updater": "^6.6.2", "highlight.js": "^11.9.0", "marked": "^12.0.2" }, "devDependencies": { - "electron": "^37.3.1", + "electron": "^39.2.7", "electron-builder": "^23.0.0", "electron-nightly": "^39.0.0-nightly.20250811" } @@ -419,7 +420,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -567,7 +567,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/asar": { @@ -1117,7 +1116,6 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1409,9 +1407,9 @@ } }, "node_modules/electron": { - "version": "37.3.1", - "resolved": "https://registry.npmjs.org/electron/-/electron-37.3.1.tgz", - "integrity": "sha512-7DhktRLqhe6OJh/Bo75bTI0puUYEmIwSzMinocgO63mx3MVjtIn2tYMzLmAleNIlud2htkjpsMG2zT4PiTCloA==", + "version": "39.2.7", + "resolved": "https://registry.npmjs.org/electron/-/electron-39.2.7.tgz", + "integrity": "sha512-KU0uFS6LSTh4aOIC3miolcbizOFP7N1M46VTYVfqIgFiuA2ilfNaOHLDS9tCMvwwHRowAsvqBrh9NgMXcTOHCQ==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1619,6 +1617,82 @@ "node": ">= 10.0.0" } }, + "node_modules/electron-updater": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.6.2.tgz", + "integrity": "sha512-Cr4GDOkbAUqRHP5/oeOmH/L2Bn6+FQPxVLZtPbcmKZC63a1F3uu5EefYOssgZXG3u/zBlubbJ5PJdITdMVggbw==", + "license": "MIT", + "dependencies": { + "builder-util-runtime": "9.3.1", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0", + "lazy-val": "^1.0.5", + "lodash.escaperegexp": "^4.1.2", + "lodash.isequal": "^4.5.0", + "semver": "^7.6.3", + "tiny-typed-emitter": "^2.1.0" + } + }, + "node_modules/electron-updater/node_modules/builder-util-runtime": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.3.1.tgz", + "integrity": "sha512-2/egrNDDnRaxVwK3A+cJq6UOlqOdedGA7JPqCeJjN2Zjk1/QB/6QUi3b714ScIGS7HafFXTyzJEOr5b44I3kvQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/electron-updater/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-updater/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-updater/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/electron-updater/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -2072,7 +2146,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/graceful-readlink": { @@ -2358,7 +2431,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -2426,7 +2498,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", - "dev": true, "license": "MIT" }, "node_modules/lodash": { @@ -2436,6 +2507,19 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, "node_modules/lowercase-keys": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", @@ -2618,7 +2702,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/node-addon-api": { @@ -2846,7 +2929,6 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", - "dev": true, "license": "ISC" }, "node_modules/semver": { @@ -3118,6 +3200,12 @@ "node": ">= 10.0.0" } }, + "node_modules/tiny-typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", + "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", + "license": "MIT" + }, "node_modules/tmp": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", diff --git a/package.json b/package.json index 0b6523b..8c121f0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "nebula", "productName": "Nebula", - "version": "1.0.0", + "version": "1.3.2", "main": "main.js", "scripts": { "start": "electron .", @@ -14,16 +14,24 @@ "description": "", "dependencies": { "dompurify": "^3.1.6", + "electron-updater": "^6.6.2", "highlight.js": "^11.9.0", "marked": "^12.0.2" }, "devDependencies": { - "electron": "^37.3.1", + "electron": "^39.2.7", "electron-builder": "^23.0.0", "electron-nightly": "^39.0.0-nightly.20250811" }, "build": { "appId": "com.andrewzambazos.nebula", + "publish": [ + { + "provider": "github", + "owner": "Bobbybear007", + "repo": "NebulaBrowser" + } + ], "mac": { "category": "public.app-category.productivity", "icon": "assets/images/Logos/Nebula-Favicon.icns" diff --git a/preload.js b/preload.js index b27404d..d717f1e 100644 --- a/preload.js +++ b/preload.js @@ -134,6 +134,15 @@ contextBridge.exposeInMainWorld('downloadsAPI', { onScanResult: (handler) => ipcRenderer.on('downloads-scan-result', (_e, payload) => handler(payload)) }); +// Auto-Updater API exposed to renderer +contextBridge.exposeInMainWorld('updaterAPI', { + checkForUpdates: () => ipcRenderer.invoke('check-for-updates'), + downloadUpdate: () => ipcRenderer.invoke('download-update'), + installUpdate: () => ipcRenderer.invoke('install-update'), + getAppVersion: () => ipcRenderer.invoke('get-app-version'), + onUpdateStatus: (handler) => ipcRenderer.on('update-status', (_e, payload) => handler(payload)) +}); + // ---------------------------------------- // Plugin renderer preloads // ---------------------------------------- diff --git a/renderer/script.js b/renderer/script.js index 4c5dcb7..249dd54 100644 --- a/renderer/script.js +++ b/renderer/script.js @@ -1098,6 +1098,24 @@ window.addEventListener('DOMContentLoaded', () => { console.error('Error applying saved theme:', err); } } + + // Initialize display scale (zoom) from localStorage + const savedDisplayScale = localStorage.getItem('nebula-display-scale'); + if (savedDisplayScale) { + try { + const scale = Number(savedDisplayScale); + if (scale > 0 && scale <= 300) { + const zoomFactor = scale / 100; + if (ipcRenderer && typeof ipcRenderer.invoke === 'function') { + ipcRenderer.invoke('set-zoom-factor', zoomFactor).catch(err => { + console.error('Error setting zoom factor:', err); + }); + } + } + } catch (err) { + console.error('Error applying saved display scale:', err); + } + } // Initial boot createTab(); diff --git a/renderer/settings.html b/renderer/settings.html index 8b59f73..ad4e7bb 100644 --- a/renderer/settings.html +++ b/renderer/settings.html @@ -105,6 +105,18 @@ + +
+

Display Scale

+
+
+ + 100% +
+

Adjust the default display scale (zoom) when opening the browser. Requires reload to take effect.

+
+
+

Custom Colors

diff --git a/renderer/settings.js b/renderer/settings.js index 07e9444..ed9d094 100644 --- a/renderer/settings.js +++ b/renderer/settings.js @@ -11,6 +11,7 @@ const WEATHER_UNIT_KEY = 'nebula-weather-unit'; // 'auto' | 'c' | 'f' const HOME_SEARCH_Y_KEY = 'nebula-home-search-y'; // number (vh) const HOME_BOOKMARKS_Y_KEY = 'nebula-home-bookmarks-y'; // number (vh) const HOME_GLANCE_CORNER_KEY = 'nebula-home-glance-corner'; // 'br'|'bl'|'tr'|'tl' +const DISPLAY_SCALE_KEY = 'nebula-display-scale'; // number (50-300) function showStatus(message) { if (statusText && statusDiv) { @@ -167,6 +168,27 @@ window.addEventListener('DOMContentLoaded', () => { notify(); })); } catch (e) { console.warn('Home layout control setup failed', e); } + + // Display scale controls + try { + const scaleSlider = document.getElementById('display-scale-slider'); + const scaleValue = document.getElementById('display-scale-value'); + + const initScale = Number(localStorage.getItem(DISPLAY_SCALE_KEY) || 100); + if (scaleSlider) { + scaleSlider.value = String(initScale); + if (scaleValue) scaleValue.textContent = initScale + '%'; + } + + if (scaleSlider) { + scaleSlider.addEventListener('input', () => { + const val = Number(scaleSlider.value); + if (scaleValue) scaleValue.textContent = val + '%'; + localStorage.setItem(DISPLAY_SCALE_KEY, String(val)); + showStatus(`Display scale set to ${val}%`); + }); + } + } catch (e) { console.warn('Display scale setup failed', e); } }); // Tabs: simple controller