From 924d24f10bb1acb8bbda362ae26f1198b6aa22dc Mon Sep 17 00:00:00 2001 From: "torlando-agent[bot]" <281092095+torlando-agent[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:04:53 +0000 Subject: [PATCH] fix: serialize map installs across browser tabs --- docs/flasher/index.html | 4 ++ docs/flasher/js/map-installer.js | 24 +++++++- .../test_map_web_installer_contract.py | 2 + tests/web/test_map_installer.mjs | 55 +++++++++++++++++++ 4 files changed, 84 insertions(+), 1 deletion(-) diff --git a/docs/flasher/index.html b/docs/flasher/index.html index cfcb8215..67e4c3ad 100644 --- a/docs/flasher/index.html +++ b/docs/flasher/index.html @@ -462,6 +462,10 @@ setMapStatus('This browser does not provide writable directory access. Use Chrome, Chromium, or Edge; Brave currently disables this API.', 'error'); return; } + if (typeof navigator.locks?.request !== 'function') { + setMapStatus('This browser does not provide the cross-tab locking required for safe map installation. Use a current Chrome, Chromium, or Edge release.', 'error'); + return; + } mapInstallBtn.disabled = true; try { const rootDirectory = await window.showDirectoryPicker({mode: 'readwrite'}); diff --git a/docs/flasher/js/map-installer.js b/docs/flasher/js/map-installer.js index dc7003a3..d4775fb2 100644 --- a/docs/flasher/js/map-installer.js +++ b/docs/flasher/js/map-installer.js @@ -19,7 +19,7 @@ const textDecoder = new TextDecoder('utf-8', {fatal: true}); const textEncoder = new TextEncoder(); const installTails = new WeakMap(); -async function withInstallLock(rootDirectory, operation) { +async function withInProcessInstallLock(rootDirectory, operation) { const previous = installTails.get(rootDirectory) || Promise.resolve(); let release; const gate = new Promise(resolve => { release = resolve; }); @@ -33,6 +33,28 @@ async function withInstallLock(rootDirectory, operation) { } } +async function withInstallLock(rootDirectory, operation) { + const lockManager = globalThis.navigator?.locks; + if (typeof lockManager?.request === 'function') { + if (typeof rootDirectory?.name !== 'string' || rootDirectory.name.length === 0) { + fail('Selected SD root has no stable directory name'); + } + // Web Locks are shared by every same-origin installer tab. Distinct + // FileSystemDirectoryHandle objects for the same root have the same name; + // equal names on unrelated roots merely serialize harmlessly. + return lockManager.request( + `pyxis-map-installer:${rootDirectory.name}`, + {mode:'exclusive'}, + operation, + ); + } + if (typeof globalThis.window?.showDirectoryPicker === 'function') { + fail('This browser lacks the cross-tab locking required for safe map installation'); + } + // Headless contract tests do not expose browser lock primitives. + return withInProcessInstallLock(rootDirectory, operation); +} + export class MapInstallerError extends Error {} function fail(message) { throw new MapInstallerError(message); } function u16(view, offset) { return view.getUint16(offset, true); } diff --git a/tests/build_scripts/test_map_web_installer_contract.py b/tests/build_scripts/test_map_web_installer_contract.py index 81e9ce89..adac9bb4 100644 --- a/tests/build_scripts/test_map_web_installer_contract.py +++ b/tests/build_scripts/test_map_web_installer_contract.py @@ -20,6 +20,8 @@ def test_map_installer_ui_is_local_file_to_sd_and_offline_only() -> None: assert "mapSetId: 'osm-bright'" in source assert 'newest pack takes priority' in source assert "showDirectoryPicker" in source + assert "navigator.locks?.request" in source + assert "cross-tab locking required for safe map installation" in source assert "./js/map-installer.js" in source assert "Coalition MUI OSM Bright user download" in source assert "Map data (c) OpenStreetMap contributors" in source diff --git a/tests/web/test_map_installer.mjs b/tests/web/test_map_installer.mjs index 476f58f0..7b7e1a1b 100644 --- a/tests/web/test_map_installer.mjs +++ b/tests/web/test_map_installer.mjs @@ -141,6 +141,32 @@ class MemoryDirectoryHandle { } } +class DirectoryHandleAlias { + constructor(target) { this.target = target; this.name = target.name; this.kind = 'directory'; } + async getDirectoryHandle(...args) { return this.target.getDirectoryHandle(...args); } + async getFileHandle(...args) { return this.target.getFileHandle(...args); } + async *entries() { yield* this.target.entries(); } + async removeEntry(...args) { return this.target.removeEntry(...args); } + async isSameEntry(other) { return this.target === (other?.target || other); } +} + +class MemoryLockManager { + constructor() { this.tails = new Map(); } + async request(name, _options, operation) { + const previous = this.tails.get(name) || Promise.resolve(); + let release; + const gate = new Promise(resolve => { release = resolve; }); + const tail = previous.then(() => gate); + this.tails.set(name, tail); + await previous; + try { return await operation(); } + finally { + release(); + if (this.tails.get(name) === tail) this.tails.delete(name); + } + } +} + async function child(root, path) { let current = root; for (const part of path.split('/')) current = current.children.get(part); @@ -322,6 +348,35 @@ test('concurrent installs on one selected root are serialized', async () => { assert.deepEqual(new Set(newest.packs.map(pack => pack.packId)), new Set(['first-pack', 'second-pack'])); }); +test('distinct handles for one selected root are serialized across tabs', async () => { + const archive = storedZip([['2/1/1.png', PNG]]); + const root = new MemoryDirectoryHandle('sd-card'); + const firstHandle = new DirectoryHandleAlias(root); + const secondHandle = new DirectoryHandleAlias(root); + const previousNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator'); + Object.defineProperty(globalThis, 'navigator', { + configurable:true, + value:{locks:new MemoryLockManager()}, + }); + try { + await Promise.all([ + installMuiZip({archive, rootDirectory:firstHandle, metadata:{...metadata, packId:'tab-one', name:'Tab One'}}), + installMuiZip({archive, rootDirectory:secondHandle, metadata:{...metadata, packId:'tab-two', name:'Tab Two'}}), + ]); + } finally { + if (previousNavigator) Object.defineProperty(globalThis, 'navigator', previousNavigator); + else delete globalThis.navigator; + } + const pyxis = root.children.get('pyxis-map'); + const slots = ['active-pack.0', 'active-pack.1'] + .map(name => pyxis.children.get(name)) + .filter(Boolean) + .map(handle => decodeActiveSelection(handle.bytes)); + const newest = slots.sort((left, right) => right.generation - left.generation)[0]; + assert.equal(newest.generation, 2); + assert.deepEqual(new Set(newest.packs.map(pack => pack.packId)), new Set(['tab-one', 'tab-two'])); +}); + test('an empty directory created during destination creation is never removed', async () => { const archive = storedZip([['2/1/1.png', PNG]]); const root = new MemoryDirectoryHandle();