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();