All files / src/state directories.ts

75.75% Statements 75/99
77.77% Branches 21/27
100% Functions 11/11
75.75% Lines 75/99

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                                                                  2x 2x 2x                       2x     2x   2x                   2x 1861x 1861x 1824x     85x 85x   1861x                           2x 1846x 1846x                 2x 76x 13x                 76x 76x 76x     2x 1221x 1221x               15x 15x 2x 2x 2x 1x 1x 1x 2x 2x     15x     2x 2x 2x 2x 2x       13x 13x 13x 13x 13x 13x       13x   13x 11x 11x                       4x 4x 4x 4x 4x       4x 4x 4x 4x 4x     4x                             4x     1x 1x 1x 1x     19x 19x 19x 19x     2x 14x 14x 14x  
/**
 * Permission to read a directory, kept across a reload — in the installed app,
 * and for the visit in a browser tab ({@link directoriesKept}, §20.6).
 *
 * The same arrangement `state/handles.ts` makes for a single file, one level up,
 * and worth restating because the grant is genuinely wider. A
 * `FileSystemDirectoryHandle` is a **capability**: it cannot be read as text, it
 * names nothing a server could act on, and no byte of any file passes through
 * this module. What it allows is listing the directory and opening what is in it
 * — which is what makes a connected folder a folder rather than a bookmark — and
 * it allows that again tomorrow, without the reader being asked a second time.
 *
 * That is a real thing to have granted, so it is a thing the reader can see and
 * take back: every connected directory is a line in the stored-data screen with
 * the rest, and "forget everything" drops them (`state/stored.ts`). §11 is
 * untouched — nothing is uploaded and nothing is duplicated — and the read is
 * still `LocalFileSource` over a `File`, a slice at a time.
 *
 * ## Keyed by an id this module mints
 *
 * Not by name, and not by path — a handle has no path to key by. Two directories
 * called `run2/` on two disks are two connections, and a reader who wants both
 * should get both. The id is what a drag payload carries and what the panel's
 * "last folder" remembers, so it has to be stable across reloads and unique
 * across kinds; `folder.ts`'s prefixing rule is the other half of that.
 *
 * ## Hydrated once, like the file grants, and for the same reason
 *
 * `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 read once at boot and the click path never awaits storage before it asks.
 */
 
import { STORES, store } from './idb'
import { crossOriginFrame } from '../embed'
import { installedApp } from '../installed'
 
/** One connected directory, and what it was called when it was connected. */
export interface DirectoryRow {
  id: string
  handle: FileSystemDirectoryHandle
  /** The directory's own name, which is what the panel shows. */
  name: string
  /** ISO stamp, for the account of what is stored. */
  seenAt: string
}
 
const directoryStore = store(STORES.directories)
 
/** Hydrated once at boot; every synchronous reader reads this, not IDB. */
const rows = new Map<string, DirectoryRow>()
/** The one hydration, memoised, so every caller awaits the same read. */
let hydration: Promise<void> | null = null
 
/**
 * `true` where this browser can remember a directory at all.
 *
 * Both halves are needed and neither implies the other, exactly as for file
 * grants: Safari has `FileSystemDirectoryHandle` for the origin-private
 * filesystem and no picker, so a check for the type alone would promise a
 * feature that cannot be granted.
 */
export function directoriesSupported(): boolean {
  return (
    typeof window !== 'undefined' &&
    typeof window.showDirectoryPicker === 'function' &&
    // And not from a frame somebody else's page put this window in, where the
    // picker is present and rejects — `handlesSupported` carries the argument.
    !crossOriginFrame() &&
    typeof indexedDB !== 'undefined'
  )
}
 
/**
 * `true` where a connected directory is **kept** — across a restart, and in
 * the workspaces exported from here — which is the installed app only (§20.6).
 *
 * In a browser tab a directory is connected for the visit: held in memory and
 * listed in the Files column like any other, and gone with the page. A tab is
 * a page on whatever machine opened it, and a page that kept a grant to a
 * directory of unpublished data would be keeping it for the next person at that
 * browser (`installed.ts`). Rows a tab stored before this rule are left where
 * they are, for the installed app on the same origin to find, and are still
 * listed — and forgettable — in the stored-data screen.
 */
export function directoriesKept(): boolean {
  return directoriesSupported() && installedApp()
}
 
/**
 * Read the stored grants into memory. Called once, early, and only where they
 * are kept ({@link directoriesKept}).
 *
 * 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 hydrateDirectories(): Promise<void> {
  hydration ??= (async () => {
    if (!directoriesKept()) return
    try {
      for (const key of await directoryStore.keys()) {
        const row = await directoryStore.get<DirectoryRow>(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
}
 
/** Every remembered directory, in the order they were connected. */
export function directoryRows(): DirectoryRow[] {
  return [...rows.values()]
}
 
/**
 * The names of the directories **stored** on this browser, whether or not this
 * page holds them — for the stored-data screen, which owes the reader the
 * whole account: a tab keeps none of its own, and still shares its storage
 * with the installed app on the same origin, whose folders are kept there.
 */
export async function storedDirectoryNames(): Promise<string[]> {
  if (!directoriesSupported()) return []
  try {
    const names: string[] = []
    for (const key of await directoryStore.keys()) {
      const row = await directoryStore.get<DirectoryRow>(key)
      if (row !== null && typeof row.name === 'string') names.push(row.name)
    }
    return names
  } catch {
    return []
  }
}
 
/** An id no stored row is using. Mirrors `ui/bucketForm.ts`'s reasoning. */
export function mintDirectoryId(): string {
  for (let n = rows.size; ; n++) {
    const id = `dir:${n}-${Math.floor(Math.random() * 1e6).toString(36)}`
    if (!rows.has(id)) return id
  }
}
 
/** Remember a directory the reader has just granted. */
export async function rememberDirectory(
  id: string,
  handle: FileSystemDirectoryHandle,
  name: string,
): Promise<void> {
  const row: DirectoryRow = { id, handle, name, seenAt: new Date().toISOString() }
  // Held in memory whether or not storage takes it, so a browser that refuses
  // IndexedDB still has the folder connected for this session — which is the
  // snapshot backend's behaviour, arrived at from the other direction.
  rows.set(id, row)
  // Written down only where a folder is kept: in a tab it lasts the visit.
  if (!directoriesKept()) return
  await directoryStore.set(id, row)
}
 
/**
 * Whether this grant is still good, asking the reader if `prompt` allows it.
 *
 * **`prompt` must be `true` only from inside a user gesture**, for
 * `state/handles.ts`'s reason: without transient activation
 * `requestPermission` resolves `'prompt'` without ever showing a dialog, so a
 * boot path that passed `true` would leave every folder dead and look like the
 * feature simply not working. Boot asks `queryPermission` and nothing else; a
 * click may ask for more.
 */
export async function directoryUsable(
  row: DirectoryRow,
  options: { prompt?: boolean } = {},
): Promise<boolean> {
  const query = row.handle.queryPermission
  // No permission methods at all is not a failure: it is a handle whose
  // permissions the browser does not model, and the first listing is the honest
  // test of whether it can still be read.
  if (typeof query !== 'function') return true
  let state: PermissionState
  try {
    state = await query.call(row.handle, { mode: 'read' })
  } catch {
    return false
  }
  if (state === 'granted') return true
  if (state === 'denied') {
    // A refusal is durable and the reader made it deliberately. Keeping the row
    // would mean asking again on every reload.
    await forgetDirectory(row.id)
    return false
  }
  if (options.prompt !== true) return false
  const request = row.handle.requestPermission
  if (typeof request !== 'function') return false
  try {
    return (await request.call(row.handle, { mode: 'read' })) === 'granted'
  } catch {
    return false
  }
}
 
/** Drop one grant. */
export async function forgetDirectory(id: string): Promise<void> {
  rows.delete(id)
  await directoryStore.delete(id)
}
 
/** Forget every grant. Part of what the "forget everything" control clears. */
export async function forgetAllDirectories(): Promise<void> {
  rows.clear()
  await directoryStore.clear()
}
 
/** Drop the memory copy so a test can hydrate a fresh one. **Tests only.** */
export function resetDirectoriesForTests(): void {
  rows.clear()
  hydration = null
}