All files / src embed.ts

89.7% Statements 61/68
90.69% Branches 39/43
100% Functions 7/7
89.7% Lines 61/68

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                                                                                                                          2x 2x                                                                                                                   2x                 2x 246x 13x 13x 13x 13x 13x 13x 74x 246x 246x                 2x                                           2x 237x 237x 237x 237x     237x 237x   237x 237x 237x 237x 237x 237x 237x 237x 237x 237x 237x                   470x 470x 25x     25x 470x 470x   2x 8x 8x     2x                 2x 47207x 217x 217x 15x 202x 47207x 47207x     2x 47038x 47038x               2x 164x 164x                                                     2x 165x 165x           165x  
/**
 * How this page was mounted, and what that costs.
 *
 * The app is a window: a header, a tab strip, five columns and a status bar
 * around one view of one file. That is the right shape for the thing a reader
 * opens deliberately, and the wrong shape for the other way this application is
 * useful — **inside somebody else's page**, as the figure in a portal, in
 * supplementary material, in a lab's own site. There the window is furniture
 * around a picture nobody asked for, the tab strip offers tabs that have nowhere
 * to go, and the restore card offers a workspace belonging to whoever last used
 * that browser.
 *
 * So an embed says so, and this module is the one place that reads it:
 * `?embed=1` for the chrome-less mount, `?config=<url>` for the session to open
 * on arrival, `?store=<url>` for the catalogue to land on (§24.2), and
 * `?panels=` for the side columns a frame opens with (§21.3). Everything
 * downstream asks {@link mounting} rather than parsing the URL a second time.
 *
 * ## Why the query string and not the hash
 *
 * Every other piece of state this app carries is in the fragment (§5.5), so a
 * flag in the search string looks inconsistent until you watch one navigation:
 * the hash is **rewritten** on every view change (`App.writeHash`), so a flag
 * living there would be erased the first time the reader clicked a genome, and
 * the second paint would be the full window inside a 600-pixel box. The
 * fragment says *what is being looked at* and is replaced as that changes; these
 * three say *how this page was mounted*, which no navigation can change. That is
 * the same distinction `Session.edgeListUrl` draws for `?edges=`, which is a
 * fact about where a file is published rather than about the picture of it.
 *
 * It also means an embedder writes the whole thing once, in an `src=`, and gets
 * a URL whose meaning survives being reloaded, deep-linked and bookmarked.
 *
 * ## What an embed turns off, and why each one
 *
 * - **The window furniture** — header, tab strip, restore slot, status bar.
 *   Nothing in `App` needs them to exist; the columns are still built and a
 *   session still parents its cards into them, so no view has to know it is
 *   embedded (`app/App.ts`).
 * - **History entries.** `pushState` from a frame writes into the *joint*
 *   session history, so a reader's Back button would rewind the embed's pan and
 *   zoom rather than leaving the host page. The address bar is still kept in
 *   step — it is what "copy this link" and a reload are read out of — but every
 *   write replaces.
 * - **The remembered window.** `sessionStorage` and the durable workspace record
 *   are both about a window with tabs in it, and an embed is one view seeded by
 *   its URL. Left on, they would make a reload show whatever the reader last
 *   navigated to instead of the figure the page named, offer six of somebody
 *   else's tabs inside an article, and — since a third-party frame's storage is
 *   partitioned per embedding site — do all of it from a store the reader has no
 *   way to reach and clear.
 * - **The service worker and the version notice** (`main.ts`). An embed is not
 *   an app to install, and a bar offering "take the newest build" across the top
 *   of somebody's figure is chrome by another name.
 *
 * What stays is the view and everything the *view* owns: the breadcrumb, the
 * map's own controls, the deck, the analysis drawer, dropping a file, and every
 * keyboard shortcut. A host that wants the whole application simply does not
 * pass `embed=1`.
 */
 
import { canonicalUrl } from './sources/rewrite'
import { EMPTY_VIEW, readStoreView, type StoreView } from './state/facets'
 
/** What the query string asks of this mount. */
export interface Mounting {
  /** Chrome-less: one view, and none of the window around it. */
  readonly embed: boolean
  /**
   * A published workspace to open on arrival, absolute, or `null`.
   *
   * The pointer form of a session, where `#session=` is the payload form. Both open
   * the same `.chromosomix.json` through the same parser; the difference is who
   * holds it. A payload cannot be changed once an embed is published and grows
   * the URL by the size of the session; a pointer is a file on a bucket that can
   * be edited afterwards, has no length to worry about, and — because the names
   * inside it are resolved against its own URL (`state/SessionConfig.ts`) — is
   * self-contained enough to be copied to another bucket with its data.
   */
  readonly config: string | null
  /**
   * A store to open the drawer on, or `null` (§24.2).
   *
   * The third parameter beside the two above, and the one that names a *shelf*
   * rather than a picture: `?config=` says "open this figure", `?store=` says
   * "here is a catalogue, choose". Two spellings: the id of a shipped store
   * (`4dn`, `encode`) — a short name, kept as written — or the absolute URL of
   * a store file a lab published, resolved and scheme-checked exactly as
   * `config` is, because that value is about to be handed to `fetch`.
   *
   * It is read here, beside the other two, for the reason this module exists:
   * how a page was mounted is a fact no navigation can change, so it lives in
   * the query string where `App.writeHash` cannot erase it. The drawer then
   * writes the same parameter back as the reader narrows, which is what makes
   * a narrowed store a link.
   */
  readonly store: string | null
  /**
   * How the store is narrowed and drawn: the text, the ticked facets, the
   * matrix — every other parameter in the address (`state/facets.ts`). Empty
   * when no store was named.
   */
  readonly storeView: StoreView
  /**
   * The side columns a frame opens with, from `?panels=` (§21.3).
   *
   * An embed's two columns arrive folded, because a published figure is a
   * picture first (`app/App.ts`). A page that frames the app to *teach* it —
   * the documentation's live views — wants the controls in sight instead, and
   * says so: `panels=open` for both, or the columns by name,
   * `panels=annotations`, `panels=aesthetics`, or both with a comma. Read in a
   * frame only: a window's columns are the reader's own (`ui/SidePanel.ts`).
   */
  readonly panels: readonly EmbedPanel[]
}
 
/** A side column an embed mounts, by the name `?panels=` gives it. */
export type EmbedPanel = 'annotations' | 'aesthetics'
 
/** The mount of a page that was not asked for anything in particular. */
const PLAIN: Mounting = { embed: false, config: null, store: null, storeView: EMPTY_VIEW, panels: [] }
 
/**
 * The columns `?panels=` names, in the order an embed mounts them, each once.
 *
 * `open` is both, and so is `all`. A name it does not know is passed over
 * rather than refusing the rest: a page written for a later build, naming a
 * column this one does not have, still opens the ones it does.
 */
export function readPanels(value: string | null): readonly EmbedPanel[] {
  if (value === null) return []
  const asked = new Set(
    value
      .toLowerCase()
      .split(',')
      .map((part) => part.trim()),
  )
  const both = asked.has('open') || asked.has('all')
  return (['annotations', 'aesthetics'] as const).filter((name) => both || asked.has(name))
}
 
/**
 * A shipped store's id, or a remembered one's: a short name, not an address.
 *
 * Letters, digits, dashes and the `store:<n>` colon — `4dn` begins with a
 * digit — and never a dot or a slash, which is what an address has and a name
 * does not, so `store.json` beside the page is still resolved as a file.
 */
const STORE_ID = /^[a-z0-9][a-z0-9:-]*$/i
 
/**
 * Read a query string as a mount.
 *
 * Pure, and takes its base, so the whole contract is testable without a
 * `location`. Forgiving in the way every other reader of an outside string in
 * this app is forgiving: a value it does not understand leaves the default
 * standing rather than refusing the page.
 *
 * `embed` is **on for a key that is present** — `?embed`, `?embed=1`,
 * `?embed=true` — and off for the three spellings of "no", because a hand-edited
 * `embed=0` should mean what it says rather than being a truthy string.
 *
 * `config` and a `store` that is an address are resolved against `base` so a
 * self-hosted deployment can write `?config=session.json` or `?store=store.json`
 * beside its own files, and both are kept only when they come out `http:` or
 * `https:`. That check is not
 * ceremony: the value is a URL from outside that this app is about to `fetch`,
 * and the schemes that are not those two are the ones with no server on the
 * other end and a document of their own to hand back.
 */
export function readMounting(search: string, base: string): Mounting {
  let params: URLSearchParams
  try {
    params = new URLSearchParams(search)
  } catch {
    return PLAIN
  }
  const flag = params.get('embed')
  const embed = flag !== null && flag !== '0' && flag !== 'false' && flag !== 'off'
 
  const named = params.get('store')
  const store =
    named !== null && named !== '' && STORE_ID.test(named) ? named : fetchable(named, base)
  return {
    embed,
    config: fetchable(params.get('config'), base),
    store,
    storeView: store === null ? EMPTY_VIEW : readStoreView(params),
    panels: readPanels(params.get('panels')),
  }
}
 
/**
 * A query parameter naming something this page may fetch, or `null`.
 *
 * One function for `config` and `store` rather than the check written twice: they
 * are the same kind of value — an outside URL about to be handed to `fetch` —
 * and the schemes that are not `http(s)` are the ones with no server on the
 * other end and a document of their own to hand back.
 */
function fetchable(named: string | null, base: string): string | null {
  if (named === null || named === '') return null
  try {
    // Canonicalised first (§24.5), so `?store=gs://…` and `?config=s3://…` are
    // the https URLs they stand for by the time the scheme is judged.
    const url = new URL(canonicalUrl(named), base)
    if (url.protocol === 'http:' || url.protocol === 'https:') return url.href
  } catch {
    /* not a URL this page can resolve; the picker is the honest answer */
  }
  return null
}
 
/** Memoised: the address does not change under a running page. */
let held: Mounting | null = null
 
/**
 * This page's mount.
 *
 * Read once and kept, because it is asked for on the paint path — `writeHash`
 * consults it on every navigation — and because a mount that changed halfway
 * through a session would leave half the window drawn.
 */
export function mounting(): Mounting {
  if (held !== null) return held
  held =
    typeof window === 'undefined'
      ? PLAIN
      : readMounting(window.location.search, window.location.href)
  return held
}
 
/** Sugar for the check that is made in a dozen places. */
export function embedded(): boolean {
  return mounting().embed
}
 
/**
 * Forget the memoised mount. Tests only.
 *
 * Exported rather than reached around, so the one mutable thing in this module
 * has exactly one door and it is a named one.
 */
export function resetMounting(): void {
  held = null
}
 
/**
 * `true` in a frame whose top-level document is on another origin.
 *
 * The File System Access pickers — `showOpenFilePicker`, `showDirectoryPicker` —
 * are **present and throwing** in a cross-origin frame: the function is there,
 * `typeof` says `'function'`, and calling it rejects with a `SecurityError`. Every
 * capability gate in this app tests for the function (`state/handles.ts`,
 * `state/directories.ts`, `browse/local.ts`, `ui/chooseFiles.ts`), so without
 * this an embedded viewer offers a Choose-files button that silently does
 * nothing — the rejection is indistinguishable from a cancelled dialog, which is
 * exactly what those gates decided to swallow.
 *
 * Answering `true` here routes all four to the fallbacks they already have for
 * Firefox and Safari: an `<input type="file">` for files, and for a directory
 * the snapshot path. Dropping is untouched and works in a frame like anywhere
 * else, which is what keeps "drop your own file on this figure" an embed a page
 * can offer.
 *
 * **Same-origin frames are not this case** and are deliberately not caught: the
 * pickers work there, and an app framed by its own site should keep them.
 *
 * The `try` is the answer as much as the comparison is. Reading
 * `window.top.location` across origins throws, and that throw *is* the finding —
 * a frame that cannot see its top-level document is in another origin from it.
 */
export function crossOriginFrame(): boolean {
  if (typeof window === 'undefined') return false
  if (window.top === window.self || window.top === null) return false
  try {
    return window.top.location.origin !== window.location.origin
  } catch {
    return true
  }
}