Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 | 2x 2x 2x 2x 2x 2x 2x 330x 330x 2x 94x 94x 94x 72x 72x 94x 2x 182x 52x 32x 32x 32x 32x 32x 32x 182x 182x 182x 2x 6x 6x 2x 62x 62x 40x 40x 40x 40x 40x 40x 39x 39x 39x 39x 39x 39x 39x 39x 39x 39x 39x 92x 92x 92x 92x 92x 92x 92x 37x 37x 37x 37x 37x 37x 37x 1x 1x 1x 37x 23x 19x 23x 19x 23x 23x 23x 37x 28x 28x 28x 41x 4x 4x 4x 41x 2x 2x 2x 92x 12x 12x 12x 12x 22x 22x 2x 2x 174x 174x 174x 12x 12x 12x 8x 12x 13x 5x 5x 13x 13x 12x 8x 8x 7x 7x 7x 7x 7x 2x 18x 18x 67x 67x 67x 67x 2x 63x 63x 63x 63x | /**
* Permission to re-read a local file, kept across a reload.
*
* A dropped `File` is a set of bytes the page was handed once, and no browser
* will hand them over again without a fresh gesture — which is a privacy
* property and not a limitation (§11). A `FileSystemFileHandle` is the same
* privacy property with a memory: the reader grants access to a *file*, the page
* keeps the grant rather than the bytes, and a reload can ask for the same file
* again without asking the reader to find it again.
*
* ## What is stored, and what is emphatically not
*
* A handle is a **capability**, not a path and not a copy. It cannot be read as
* text, it cannot be sent anywhere, and it names nothing a server could act on.
* No byte of any file passes through this module: `resolveHandle` answers with
* the same kind of `File` object a drop produces, and `LocalFileSource` reads it
* a slice at a time exactly as before. §11 and §12 are untouched — nothing is
* uploaded and nothing is duplicated.
*
* ## Keyed by the file, not by the tab
*
* The key is `splitUri(uri).path` — the *second half* of what `fileMemoryKey`
* builds, with the session id deliberately left off. A handle is a fact about a
* file on disk rather than about a tab: two tabs on `rep1.cool` must share one
* grant, or re-granting in the first leaves the second stale for no reason the
* reader could deduce. It is also the only key three of the four callers *can*
* use — a deck companion, an annotation track and the compare file are files
* that no tab is "of".
*
* Stripping the group half (`::resolutions/1000`) comes free with `splitUri` and
* is right for the same reason it is right in `UiMemory`: one `.mcool` at five
* resolutions is one file, and one grant.
*
* ## Which means two files can collide, so the name is checked
*
* A local file's URI is its bare name, so `rep1.cool` from two directories is
* one key. That is why a row carries the `name`, `size` and `lastModified` seen
* at grant time and why {@link resolveHandle} refuses on a name mismatch rather
* than opening: showing the wrong file with every label on screen agreeing with
* it is the worst outcome available here, and strictly worse than asking the
* reader to drop it again.
*
* A changed `size` or `lastModified` is **not** refused. Re-running a pipeline
* over a file somebody is looking at is a legitimate thing to do, and the
* reader asked for *that file*.
*
* ## Why the rows are held in memory as well
*
* `requestPermission` needs transient activation — a live user gesture — and a
* gesture does not survive an IndexedDB round trip on a cold cache. So the rows
* are hydrated once at boot and the click path never awaits storage before it
* asks. It is also what lets {@link knownPaths} be synchronous, which the tab
* strip needs: `Session.tabHint` is a getter, read in a loop by `paintTabs`, and
* an async one would make the strip repaint a frame late on every state change.
*/
import { STORES, store } from './idb'
import { crossOriginFrame } from '../embed'
import { splitUri } from '../ui/labels'
/** One granted file, and what it looked like when the grant was made. */
export interface HandleRow {
/** `splitUri(uri).path` — the key. */
path: string
handle: FileSystemFileHandle
/** The file's own name at grant time; the collision guard. */
name: string
size: number
lastModified: number
/** ISO stamp, for the account of what is stored. */
seenAt: string
}
const handleStore = store(STORES.handles)
/** Hydrated once at boot; the click path and `knownPaths` read this, not IDB. */
const rows = new Map<string, HandleRow>()
/** The one hydration, memoised, so every caller awaits the same read. */
let hydration: Promise<void> | null = null
/** The key for a URI: the file half, with any `::group` dropped. */
export function handleKey(uri: string): string {
return splitUri(uri).path
}
/**
* `true` where this browser can remember a file at all.
*
* All three parts are needed and none implies the others. Safari has
* `FileSystemFileHandle` for the origin-private filesystem and no picker, so a
* check for the type alone would promise a feature that cannot be granted — and
* a **cross-origin frame** has the picker as a function that rejects when it is
* called, which is the same promise made in a way `typeof` cannot see
* (`src/embed.ts`).
*/
export function handlesSupported(): boolean {
return (
typeof window !== 'undefined' &&
typeof window.showOpenFilePicker === 'function' &&
!crossOriginFrame() &&
typeof indexedDB !== 'undefined'
)
}
/**
* Read the stored grants into memory. Called once, early.
*
* Failures are silent and total: a browser that cannot answer holds no grants,
* which is the state every browser was in before this existed.
*/
export function hydrateHandles(): Promise<void> {
hydration ??= (async () => {
if (!handlesSupported()) return
try {
for (const key of await handleStore.keys()) {
const row = await handleStore.get<HandleRow>(key)
if (row !== null && row.handle !== undefined) rows.set(key, row)
}
} catch {
/* no grants, which is a state the whole app already handles */
}
})()
return hydration
}
/**
* The files this browser holds a grant for.
*
* Synchronous, and a snapshot: what it is used for is the wording of a tab's
* tooltip and whether a picker says "drop it again" or "click to reopen", and
* both are decisions that must not wait on storage.
*/
export function knownPaths(): ReadonlySet<string> {
return new Set(rows.keys())
}
/** `true` when this URI is one {@link resolveHandle} could plausibly answer. */
export function hasHandle(uri: string): boolean {
return rows.has(handleKey(uri))
}
/**
* Remember the grant behind `file`, if there is one.
*
* Called where a file is *opened* rather than where it arrives, so a file
* dropped on the window and never looked at leaves nothing behind.
*/
export async function rememberHandle(
uri: string,
handle: FileSystemFileHandle,
file: File,
): Promise<void> {
if (!handlesSupported()) return
const row: HandleRow = {
path: handleKey(uri),
handle,
name: file.name,
size: file.size,
lastModified: file.lastModified,
seenAt: new Date().toISOString(),
}
rows.set(row.path, row)
await handleStore.set(row.path, row)
}
/**
* The file behind a remembered grant, or `null`.
*
* `null` covers every way this can fail to produce bytes — no grant, permission
* refused, the file moved or deleted since, a different file now wearing the
* name — because there is exactly one thing every caller does about all of
* them, and it is what the app already does for a local file today: show the
* picker, with its note, and wait for a gesture.
*
* **`prompt` must be `true` only from inside a user gesture.** Without transient
* activation `requestPermission` resolves `'prompt'` without ever showing a
* dialog, so a boot path that passed `true` would leave every tab detached
* forever and look like the feature simply not working. The boot path asks
* `queryPermission` and nothing else; a click may ask for more.
*/
export async function resolveHandle(
uri: string,
options: { prompt?: boolean } = {},
): Promise<File | null> {
// Awaited rather than assumed, so no caller has to know whether boot has got
// this far. In practice it is a settled promise long before any click — the
// shell starts it as it is built — so this costs a microtask, which does not
// spend the transient activation a permission prompt needs.
await hydrateHandles()
const row = rows.get(handleKey(uri))
if (row === undefined) return null
// No permission methods at all is not a failure: it is a browser whose handles
// do not carry permissions, and the `getFile` below is the honest test of
// whether this one can still be read.
const query = row.handle.queryPermission
if (typeof query === 'function') {
let state: PermissionState
try {
state = await query.call(row.handle, { mode: 'read' })
} catch {
return null
}
if (state === 'denied') {
// A refusal is durable and the reader made it deliberately. Keeping the
// row would mean asking again on every reload.
await forgetHandle(uri)
return null
}
if (state !== 'granted') {
if (options.prompt !== true) return null
const request = row.handle.requestPermission
if (typeof request !== 'function') return null
try {
if ((await request.call(row.handle, { mode: 'read' })) !== 'granted') return null
} catch {
return null
}
}
}
let file: File
try {
file = await row.handle.getFile()
} catch {
// Moved, renamed, deleted, or on a volume that is no longer mounted. The
// grant is spent; keeping it would offer a file that cannot be opened.
await forgetHandle(uri)
return null
}
// The collision guard. Two files may share a name and therefore a key, and
// opening the wrong one is worse than opening none: every label on screen
// would agree with it. See the header.
if (file.name !== row.name) {
await forgetHandle(uri)
return null
}
// A changed size or timestamp is *not* refused — a re-run pipeline overwriting
// a file somebody is reading is a legitimate thing to want — but it is worth
// keeping the row current, so the account of what is stored describes the file
// as it is now.
if (file.size !== row.size || file.lastModified !== row.lastModified) {
const updated: HandleRow = { ...row, size: file.size, lastModified: file.lastModified }
rows.set(updated.path, updated)
void handleStore.set(updated.path, updated)
}
return file
}
/** Every tab, waiting to hear that a grant was renewed (§5.6). */
const listeners = new Set<() => void>()
/** Hear about a grant the reader renewed. Returns the unsubscribe. */
export function subscribeGrants(listener: () => void): () => void {
listeners.add(listener)
return () => listeners.delete(listener)
}
/**
* Ask the reader to allow **every** remembered file again — the *allow access*
* button, and nothing else.
*
* After a restart a stored grant comes back as `'prompt'`: the browser still
* knows the file and will not hand it over until the reader says so, and saying
* so needs the live gesture {@link resolveHandle} documents. So this is called
* from inside a click, and is the one caller that passes `prompt`.
*
* The file the button was pressed for is asked first, so the prompt the reader
* sees names the file they were looking at; then every other grant this browser
* holds, in the same gesture, so one press brings back the map, its tracks,
* their indexes and every other tab's files. Chromium's restore prompt answers
* for all of them at once, and each later ask is then already granted and shows
* nothing; a browser that asks file by file asks each while the click's
* activation lasts, and any it does not reach keeps a button of its own. A
* refusal of the first stops there: a reader who said no to the file they
* pressed for has not asked to be asked about the rest.
*
* A success is announced, and each tab tries its waiting map and its detached
* tracks again without a gesture.
*/
export async function allowHandles(first: string): Promise<File | null> {
const file = await resolveHandle(first, { prompt: true })
if (file === null) return null
const pressed = handleKey(first)
for (const key of [...rows.keys()]) {
if (key === pressed) continue
try {
await resolveHandle(key, { prompt: true })
} catch {
/* one file the browser would not answer about costs that file alone */
}
}
for (const listener of listeners) listener()
return file
}
/** Drop one grant. */
export async function forgetHandle(uri: string): Promise<void> {
const key = handleKey(uri)
rows.delete(key)
await handleStore.delete(key)
}
/** Every grant held, for the account of what is stored. */
export function handleRows(): HandleRow[] {
return [...rows.values()]
}
/** Forget every grant. Part of what the "forget everything" control clears. */
export async function forgetAllHandles(): Promise<void> {
rows.clear()
await handleStore.clear()
}
/** Drop the memory copy so a test can hydrate a fresh one. **Tests only.** */
export function resetHandlesForTests(): void {
rows.clear()
listeners.clear()
hydration = null
}
|