All files / src/app resolve.ts

100% Statements 20/20
100% Branches 11/11
100% Functions 3/3
100% Lines 20/20

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                                                                                    2x 2x 2x 2x                                                   2x 294x 294x 294x                   2x 5x 5x                         43x 43x 43x 43x 43x 43x     42x 43x 43x  
/**
 * How to obtain the bytes behind a URI — asked three ways, on purpose.
 *
 * There used to be one function for this, `inferSource`, and six callers. They
 * looked like six askings of one question and they are not: two of them gate a
 * **write to the URL hash**, one runs inside a synchronous repaint, and three
 * want bytes. Collapsing them onto one async resolver would have been the
 * obvious refactor and would have broken the first two in ways nothing would
 * have reported.
 *
 * So:
 *
 * | | asks | answers |
 * |---|---|---|
 * | {@link inferSource} | "can **anybody** open this?" | sync, remote-only |
 * | {@link mayResolve} | "would it be worth asking?" | sync, from a snapshot |
 * | {@link resolveSource} | "give me the bytes" | async, remote **or** a grant |
 *
 * ## Why `inferSource` must stay exactly what it was
 *
 * It is the **shareability** test. `Session` calls it before writing `uri2` into
 * the view state and before accepting one out of it, and the hash is a public
 * artifact: it goes into links, into the address bar, and through
 * `state/shareLink.ts` into something a reader sends a colleague. A local file
 * name in there is a private filename posted to a stranger — which
 * `shareLink.shortenable` already refuses to do — and a recipient's deck would
 * try to *fetch* it, relative to the site root on the web and against
 * `app://viewer/` in the desktop build.
 *
 * A remembered grant does not change that. This browser being able to reopen
 * `rep1.cool` says nothing about anybody else's, so the hash gates keep asking
 * the question they have always asked, and the answer is still no.
 *
 * ## Why `mayResolve` is separate, and synchronous
 *
 * `Session.tabHint` is a getter, read in a loop by `App.paintTabs` on every
 * state change. Making it async would make the tab strip repaint a frame late
 * and flicker while a reader drags a colour slider. It reads a set hydrated at
 * boot instead, so a tab can say *"remembered — click to reopen"* rather than
 * *"drop the same file again"* without waiting for storage.
 */
 
import { canonicalUrl } from '../sources/rewrite'
import { hasHandle } from '../state/handles'
import { hasLocation, resolveLocal } from '../state/locations'
import { splitUri } from '../ui/labels'
import type { SourceSpec } from '../worker/protocol'
 
/**
 * Work out how to obtain the bytes for a URI that arrived **in a link**.
 *
 * A remote URL is re-openable from the hash alone; a local file is not — no
 * browser re-reads a dropped file without another gesture, which is a privacy
 * property rather than a limitation (§11). Returning `null` sends the user to
 * the picker with an explanation.
 *
 * ## Canonicalised first, and the test itself is untouched
 *
 * `sources/rewrite.ts` runs before the `https?:` test rather than after it, and
 * that ordering is the whole of what changed here (SPECS §24.5). The test still
 * asks *can anybody open this*, and still answers it about an `http(s)` URL
 * alone; what moved is that `gs://bucket/file.mcool` and `s3://bucket/file.cool`
 * now **are** such a URL by the time it is asked. Both were already admitted by
 * `state/shareLink.ts`'s `openableElsewhere` as addresses that mean the same
 * thing in anybody's browser, which is the same question from the other side, so
 * this closes a disagreement rather than widening a gate: a scheme that could
 * travel in a link could not be opened out of one.
 *
 * A local file name is unchanged by the rewrite table and still answers `null`,
 * which is the property this function exists for and the one its tests pin.
 */
export function inferSource(uri: string): SourceSpec | null {
  const path = canonicalUrl(splitUri(uri).path)
  return /^https?:\/\//i.test(path) ? { kind: 'remote', url: path } : null
}
 
/**
 * `true` when {@link resolveSource} has some chance of answering.
 *
 * A guess, deliberately: the grant may since have been revoked and the file may
 * have been deleted, neither of which is knowable without asking. It is used
 * only to choose the *wording* of a tooltip and a note, where being optimistic
 * costs a reader one click and being pessimistic costs them the feature.
 */
export function mayResolve(uri: string): boolean {
  return inferSource(uri) !== null || hasHandle(uri) || hasLocation(uri)
}
 
/**
 * The bytes, or `null`.
 *
 * Remote first, because that path touches no storage and is what every demo,
 * every published session and every shared link takes.
 *
 * `prompt` may only be `true` inside a user gesture — a click, a key. Without
 * transient activation the permission request resolves without ever showing a
 * dialog, so passing it from a boot path would silently leave every remembered
 * file unopened and look exactly like the feature not working.
 */
export async function resolveSource(
  uri: string,
  options: { prompt?: boolean } = {},
): Promise<SourceSpec | null> {
  const remote = inferSource(uri)
  if (remote !== null) return remote
  // The grant for the file itself, then its place in a folder the installed
  // app keeps (§20.6).
  const file = await resolveLocal(uri, options)
  return file === null ? null : { kind: 'local', file }
}