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 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 | 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 18x 18x 18x 18x 18x 18x 18x 1x 18x 18x 18x 18x 18x 18x 18x 18x 18x 18x 18x 18x 18x 18x 18x 18x 18x 18x 72x 72x 72x 72x 18x 18x 18x 72x 72x 72x 72x 72x 72x 72x 72x 72x 72x 72x 72x 72x 18x 18x 18x 18x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 4x 1x 1x 5x 5x 13x 13x 13x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 12x 12x 12x 12x 22x 22x 6x 6x 6x 6x 6x 6x 16x 22x 22x 22x 6x 6x 6x 6x 3x 6x 6x 6x 6x 6x 22x 22x 22x 22x 22x 22x 22x 22x 10x 6x 6x 6x 6x 6x 6x 6x 22x 22x 12x 12x 16x 12x 12x 12x 4x 12x 13x 13x 13x 13x 13x 13x 13x 13x 13x 13x 13x 6x 6x 6x 18x 18x 18x 18x 18x 18x 18x 18x 18x 18x 18x 18x 18x 18x 18x 18x | /**
* Connect a place: a directory on this machine, a bucket, a Drive folder or a
* Dropbox.
*
* One dialog for all four, because to a reader they are one thing — *the
* place my data is* — and the app's own claim is that it reads them the same
* way, one level at a time, in the Files column (§20). It is drawn as
* **providers**: a row of four tiles, and the form of the one pressed under
* it. The dialog used to stack six sections down one scroll, and two of the
* six were not places at all — a Google API key and a Google account are
* settings of *one provider*, and a reader meeting them between a bucket and a
* Drive folder had to work out which of the two they belonged to. Now they sit
* under Google Drive, which is the only tile that needs them.
*
* ## What the local half offers depends on the browser, and it says so
*
* Chromium and the desktop build have `showDirectoryPicker`, so a directory is
* genuinely **connected**: the page holds a capability, the listing is live, and
* it is all still there after a reload. Firefox and Safari have no such thing;
* what they have hands over a flat list of `File` objects, from which the tree
* is rebuilt. That is a **snapshot** — it does not update and it does not
* survive a reload. §8.5's doctrine is that a control which cannot keep its
* promise is absent rather than disabled, and the reading that would hide the
* local half in Firefox is the wrong one: the gesture works there, and what
* differs is how long the answer lasts. So the button is offered in both and
* the sentence under it changes.
*
* ## Which providers are offered
*
* `ui/connectFronts.ts` decides, because the landing page's row of *connect a
* folder* buttons offers the same places and the answer must not be able to
* differ between the two doors. The standalone build has an opaque origin, so
* every remote read fails before a byte moves (§19): it offers this machine
* and nothing else. A sign-in the build or the site cannot make — the desktop
* build, which cannot register `app://viewer` with Google, or a site built
* without the client id or the app key — keeps its **tile** here, whose pane
* says why there is no sign-in, and loses its **button** on the landing page,
* because a row of things to press is not where a reader is sent to read an
* explanation (§24.2). Each provider's section decides the rest for itself.
*
* ## Every pane is headed by its own tile's name
*
* *This machine*, *A bucket*, *Google Drive*, *Dropbox* — the label the tile
* above it carries and nothing else. Two of the four used to be imperatives —
* *Connect to your Dropbox* under a tile reading *Dropbox* — and a tab that
* renames its own subject on being pressed reads as a further step rather than
* as the thing pressed. What the pane is *for* is the dialog's title.
*
* ## The two panes that sign a reader in
*
* Google Drive and Dropbox are each a title, a `?` at the far right of the
* same line, and one large sign-in button under it (`ui/connectHead.ts`):
* what the grant is and where the token goes is behind the `?`, and the only
* things on the pane itself are the ones a reader acts on. The button says
* *sign in to Google* / *sign in to Dropbox*, in the words the landing page's
* own button said one press earlier.
*/
import { Modal } from './Modal'
import { button, el } from './dom'
import { icon } from './icons'
import { bucketForm } from './bucketForm'
import { SITE_DEFAULTS } from '../config/siteDefaults'
import {
GOOGLE_SIGN_IN_AVAILABLE,
googleState,
signIn as googleSignIn,
signOut,
subscribeGoogle,
} from '../auth/google'
import { connectDirectory } from '../browse/connected'
import { directoriesKept } from '../state/directories'
import { bucketFolderId } from '../browse/bucket'
import { directoryPickerSupported } from '../browse/local'
import { localVerb, providerFronts, type ProviderFront, type ProviderId } from './connectFronts'
import { driveFolderSection } from './DriveFolderSection'
import { connectHead, signInButton, tipCode } from './connectHead'
import { dropboxSection } from './DropboxSection'
export type { ProviderId } from './connectFronts'
export interface ConnectProps {
/**
* A folder was connected. The panel opens on it, which is the only thing
* worth doing next and is why this carries the id.
*/
onConnected(id: string): void
onClose?(): void
/** The provider to open on. Default: this machine. */
provider?: ProviderId
}
export function openConnect(props: ConnectProps): Modal {
return new Modal({
// Named for what it does, in the word the landing page's heading, the
// panes' buttons and the Files column all use (§26.4).
title: 'Connect a folder',
variant: 'modal-wide',
...(props.onClose === undefined ? {} : { onClose: props.onClose }),
body: (close) => connectBody(props, close),
})
}
interface Provider extends ProviderFront {
/** The pane, built when the tile is pressed. */
pane(done: (id: string) => void): HTMLElement
}
/**
* The providers this build can offer, in the order the tiles show them.
*
* Which places exist is `ui/connectFronts.ts`'s, because the landing page's
* row of buttons asks the same question (§24.2); what a place *is to do* is
* here, because that is the dialog. `works` is not consulted: a tile whose
* sign-in this build or this site cannot make stays, because its pane is the
* sentence saying why — see that module's header on why the two doors read the
* same fact differently.
*/
export function providers(): Provider[] {
const panes: Record<ProviderId, (done: (id: string) => void) => HTMLElement> = {
local: localSection,
bucket: bucketSection,
drive: drivePane,
dropbox: (done) => dropboxSection(done) ?? el('div'),
}
return providerFronts().map((front) => ({ ...front, pane: panes[front.id] }))
}
function connectBody(props: ConnectProps, close: () => void): HTMLElement {
const done = (id: string): void => {
close()
props.onConnected(id)
}
const list = providers()
const paneSlot = el('div', { class: 'connect-pane' })
const tiles = el('div', { class: 'connect-providers', attrs: { role: 'tablist' } })
let chosen: ProviderId | null = null
const show = (id: ProviderId): void => {
const provider = list.find((one) => one.id === id) ?? list[0]
chosen = provider.id
for (const tile of tiles.children) {
const on = tile.getAttribute('data-provider') === provider.id
tile.classList.toggle('is-on', on)
tile.setAttribute('aria-selected', String(on))
}
paneSlot.replaceChildren(provider.pane(done))
}
for (const provider of list) {
const tile = el(
'button',
{
class: 'connect-provider',
type: 'button',
attrs: { role: 'tab', 'data-provider': provider.id, 'aria-selected': 'false' },
},
icon(provider.mark, { size: 22 }),
el('span', { text: provider.label }),
)
tile.addEventListener('click', () => show(provider.id))
tiles.appendChild(tile)
}
show(props.provider ?? 'local')
void chosen
return el('div', { class: 'modal-body bucket-body connect-body' }, tiles, paneSlot, privacyNote())
}
/**
* The local half.
*
* The picker where there is one, the input everywhere else, and one sentence
* about which of the two this reader is getting. Both end in the same place —
* a folder in the panel — so the button says the same thing.
*/
function localSection(done: (id: string) => void): HTMLElement {
const granted = directoryPickerSupported()
const kept = directoriesKept()
const error = el('p', { class: 'hint bucket-error' })
// `browse/connected.ts` runs both shapes of picker and registers whatever
// comes back; the landing page's own *choose a folder…* calls the same
// function, so the two screens cannot disagree about what a directory is.
// A cancelled picker answers `null`, which is not an error and needs no
// message.
const connect = async (): Promise<void> => {
error.textContent = ''
const id = await connectDirectory()
if (id !== null) done(id)
}
return el(
'div',
{ class: 'connect-section' },
connectHead('This machine', 'hard-drive', localHelp(granted, kept)),
// The one large button the two sign-in panes have, because this pane is
// the same shape as theirs: one thing to press, and everything worth
// knowing before pressing it behind the `?`. The label is the front's, and
// it is the front's because what a browser without the picker gives is a
// snapshot rather than a connection — a difference the reader acts on, and
// one the landing page's button has to state in the same words.
el(
'div',
{ class: 'connect-signin' },
signInButton(localVerb(), 'hard-drive', () => void connect(), { disabled: false }),
),
error,
)
}
/** Behind the local pane's `?`: what "read in place" means, and how long it lasts. */
function localHelp(granted: boolean, kept: boolean): string[] {
return [
'Read in place, never uploaded. The folder is listed one level at a time in the Files panel, ' +
'and no byte of it is sent anywhere.',
kept
? 'The app keeps permission to list the folder across restarts, and the workspaces you save ' +
'say which of your files are in it, so reopening one finds them there. Take it back any ' +
'time under What this browser is remembering (Ctrl+P → Help).'
: granted
? 'In a browser tab the folder is connected for this visit and kept nowhere, so nobody else ' +
'at this browser finds it. Installed as an app — or in the desktop app — the folder is kept ' +
'across restarts, and saved workspaces find their files in it again.'
: 'This browser cannot keep that permission, so what you get is a snapshot of the file list ' +
'for this visit only — it does not update and it does not survive a reload. Chrome, Edge ' +
'and the desktop app keep the connection when installed as an app.',
]
}
/** The bucket half: the shared form, and nothing of its own. */
function bucketSection(done: (id: string) => void): HTMLElement {
return el(
'div',
{ class: 'connect-section' },
connectHead('A bucket', 'cloud', bucketHelp()),
bucketForm({ onSaved: (scope) => done(bucketFolderId(scope.id)) }),
)
}
/**
* Behind the bucket pane's `?`: how to make a bucket serve this viewer.
*
* The only one of the four panes whose subject is a *configuration* somebody
* has to go and make, rather than a consent they are about to give — so it is
* the one whose tip carries policies to paste rather than sentences to read,
* and the reason `tipCode` exists. Both blocks are the whole of what has to be
* set: everything a reader gets wrong here is one of the two.
*/
function bucketHelp(): Array<string | HTMLElement> {
return [
'An https:// or s3:// prefix on S3, R2, MinIO, Google Cloud Storage or anything else that ' +
'answers ListObjectsV2. Your browser reads it directly, with no server in between, so the ' +
'bucket — not this page — decides what may be read. Two things have to be set.',
'CORS first, without which no read starts at all. Range and Content-Range are the pair that ' +
'matters: a .mcool is read in pieces and never whole, so a bucket that will not expose them ' +
'serves files this viewer cannot open. Narrow AllowedOrigins to the site once it works.',
tipCode(
`[{
"AllowedOrigins": ["*"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["Range", "Content-Type"],
"ExposeHeaders": [
"Accept-Ranges", "Content-Range", "Content-Length", "ETag"
]
}]`,
),
'Then the reading. Public data needs s3:GetObject on the objects and s3:ListBucket on the ' +
'bucket — two different grants, which is why a prefix whose files open by URL may still ' +
'refuse to be browsed.',
tipCode(
`{
"Effect": "Allow",
"Principal": "*",
"Action": ["s3:GetObject", "s3:ListBucket"],
"Resource": [
"arn:aws:s3:::BUCKET",
"arn:aws:s3:::BUCKET/PREFIX/*"
]
}`,
),
'For private data, leave that policy off and put a key under additional settings instead: an ' +
'IAM user with those same two actions on this prefix and nothing else. The region is part ' +
'of the signature, so a wrong one is a 403.',
'A key is kept in this browser and signs requests from this page, so anything able to run ' +
'script on this origin can read it. There is no server to hide it behind — the same ' +
'property that makes a dropped file never leave your machine. Never paste an administrator ' +
'key here.',
]
}
/**
* Google Drive: a title, a `?`, a sign-in, and — once signed in — a folder box.
*
* One section, not two. The account and the folder used to be sections of
* their own, each with a heading and a paragraph, and a reader pressing *sign
* in* read two consent notices on the way to it. What the pane offers is the
* sign-in; what it needs to say about the grant is behind the `?` on the same
* line as the title (`ui/connectHead.ts`); and the folder box is what appears
* once there is an account to list a folder with (`ui/DriveFolderSection.ts`).
*
* Nothing to type. The client id a sign-in goes to is the site's, built in
* (`config/siteDefaults.ts`), and so is the API key that opens a public Drive
* link; a site built without the client id says so under the button rather
* than offering a box only its maintainer could fill.
*/
function drivePane(done: (id: string) => void): HTMLElement {
const head = connectHead('Google Drive', 'googledrive', driveHelp())
if (!GOOGLE_SIGN_IN_AVAILABLE) {
return el(
'div',
{ class: 'connect-section' },
head,
el('p', {
class: 'hint',
text:
'Not on the desktop app: a sign-in is registered against a web address, and this ' +
'window has none. ' +
(SITE_DEFAULTS.googleApiKey !== null
? 'Public Drive files still open.'
: 'Public Drive files open on the website.'),
}),
)
}
const actions = el('div', { class: 'connect-signin' })
const error = el('p', { class: 'hint bucket-error' })
const folder = driveFolderSection(done)
const show = (): void => {
const state = googleState()
if (state.signedIn && state.expiresAt !== null) {
actions.replaceChildren(
el('span', { class: 'hint', text: `Signed in until ${clock(state.expiresAt)}.` }),
button('sign out', () => signOut(), { class: 'btn' }),
)
return
}
const signIn = signInButton(
state.expiresAt !== null ? 'sign in again' : 'sign in to Google',
'googledrive',
() => {
error.textContent = ''
signIn.disabled = true
void googleSignIn()
.catch((cause: unknown) => {
error.textContent = cause instanceof Error ? cause.message : String(cause)
})
.finally(() => {
show()
})
},
// Disabled rather than absent where the site has no registration: the
// sentence under it is the one thing a reader can act on — by asking the
// site's maintainer — and hiding the button would hide what the sentence
// is about.
{
disabled: !state.configured,
reason: 'This site was built without a Google client id, so there is no project to sign in to.',
},
)
actions.replaceChildren(
signIn,
...(state.configured
? []
: [
el('p', {
class: 'hint',
text:
'Signing in to Google is not set up on this site: it needs a Google client id ' +
'registered for its address, which the site’s maintainer provides as ' +
'GOOGLE_CLIENT_ID (docs/deploy.md).',
}),
]),
)
}
show()
const off = subscribeGoogle(() => {
// The dialog is built once and thrown away with the modal, and nothing hands
// a detached body a teardown. It unsubscribes itself the first time it is
// spoken to after leaving the document.
if (!actions.isConnected) {
off()
return
}
show()
})
return el('div', { class: 'connect-section' }, head, actions, error, ...(folder === null ? [] : [folder]))
}
/**
* What the `?` says about a Google sign-in.
*
* The two sentences §24.7 requires this dialog to say — what `drive.readonly`
* actually is, and where the token ends up — and it requires them because a
* consent screen says neither: Google's names the product, and nothing
* anywhere tells the reader where the token goes. Then what a folder costs in
* privacy terms (§20.6), because the folder box is on this same pane.
*/
function driveHelp(): string[] {
return [
'Sign in with your own Google account to open a Drive file, a Drive folder or a Cloud ' +
'Storage object that is not public.' +
(SITE_DEFAULTS.googleApiKey !== null ? ' A public Drive link opens without signing in.' : ''),
'drive.readonly is read access to all of your Drive, where a bucket key is a grant over ' +
'one prefix; devstorage.read_only is read access to the Cloud Storage your account can see.',
'The token lives only in this tab’s memory — never written to storage, gone when the tab ' +
'closes, and expiring about an hour after you sign in. No address is asked for, so this ' +
'page does not know which account it is.',
'A folder is listed with that account, one level at a time, in the Files panel. Paste the ' +
'address bar of a folder open in Drive — a shared drive works the same way — or its id; ' +
'an empty box is all of My Drive. Only the folder’s name and id are kept, under What this ' +
'browser is remembering (Ctrl+P → Help).',
]
}
/** A time of day, the way the pane says until when a token is good. */
function clock(at: number): string {
return new Date(at).toLocaleTimeString()
}
/**
* The claim, restated where the grant is actually being made.
*
* The picker says it on the first screen and the About dialog says it in full;
* this is the one moment a reader hands over something wider than a single
* file, so it is worth saying again here.
*
* It is drawn once for the dialog rather than per pane, so its body has to be
* true of every pane it sits under. It used to name only *a folder* and *a
* bucket* — which, under a Dropbox sign-in, was a reassurance about two other
* panes, and a privacy line that does not fit the pane it is on reads as
* boilerplate. One clause covers all four: whatever is connected is read from
* where it stands, by this page, with nothing of ours in the middle. Which
* grant each sign-in actually makes, and where the token lives, is on that
* pane's own `?` (§24.7).
*/
function privacyNote(): HTMLElement {
return el(
'div',
{ class: 'bucket-note' },
icon('info', { size: 16 }),
el(
'div',
{},
el('strong', { text: 'Nothing is sent anywhere.' }),
el('span', {
text:
' There is no server of ours in between: whatever you connect is read by your browser ' +
'directly, and is listed under What this browser is remembering (Ctrl+P → Help).',
}),
),
)
}
|