fix: serialize map installs across browser tabs

This commit is contained in:
torlando-agent[bot]
2026-08-07 20:04:53 +00:00
parent 9cbee873f1
commit 924d24f10b
4 changed files with 84 additions and 1 deletions
+4
View File
@@ -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'});
+23 -1
View File
@@ -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); }
@@ -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
+55
View File
@@ -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();