mirror of
https://github.com/torlando-tech/pyxis.git
synced 2026-08-29 07:08:33 +00:00
fix: serialize map installs across browser tabs
This commit is contained in:
@@ -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'});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user