mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
phone app: installable, opens offline, takes the share sheet onto the scratchpad, back lets go of one thing
A manifest with a 192, a 512 and a maskable icon, and a hand-written service worker built beside the app by a small vite plugin: it keeps index.html, the logo, the entry with its imports and the latin fonts, keeps each hashed asset once fetched, asks the server for pages first and answers from the cache when it is down, and never touches /api. The CSP names worker-src and manifest-src. The share target posts to /share, which the worker catches: the text waits in a cache and becomes a scratchpad note once someone is logged in, with the drawer down on it. On a touch screen one guard entry in the history makes back let go of one thing at a time — a prompt, the line, a sheet, the drawer, the editor, a tile, then a folder up — and leave only at a space's root. The bar and the band grow by the safe-area insets. The login no longer says the server is unreachable once it answers again with the session gone.
This commit is contained in:
1 parent
9363a0024d
commit
a27d5de9c7
23 files changed
+377
-26
No files matched your search
@@ -47,6 +47,30 @@ them, in one commit:
|
||||
- `o` in a space with templates offers them on the line, `:new `; `Enter` on
|
||||
nothing is still a blank document.
|
||||
|
||||
## The phone app, done
|
||||
|
||||
Written on 2026-10-22. The workspace installs on a phone as an app:
|
||||
|
||||
- A manifest (`web/public/manifest.webmanifest`) with a 192 and a 512 icon and
|
||||
a maskable one, drawn from the favicon.
|
||||
- A service worker (`web/src/sw.ts`, built by a small plugin in
|
||||
`vite.config.ts`) keeps index.html, the logo, the entry with what it imports,
|
||||
and the latin fonts, and every hashed asset once it has been fetched. Pages
|
||||
are asked of the server first and answered from the cache when it is down.
|
||||
It never touches `/api`. The CSP names `worker-src` and `manifest-src`.
|
||||
- A share target, `POST /share`, caught by the worker and never sent to the
|
||||
server: the text waits in the cache `share` and becomes a scratchpad note
|
||||
once someone is logged in.
|
||||
- Back, on a touch screen, lets go of one thing at a time through one guard
|
||||
entry in the history (`back()` in `modes.ts`), and leaves the app at the
|
||||
root of a space.
|
||||
- The bar and the band grow by the safe-area insets, so an installed app keeps
|
||||
them clear of the status bar and the gesture bar.
|
||||
|
||||
Not done, on purpose: reading documents offline, and sharing a photo or a file
|
||||
— a file needs a document or a folder to belong to, and a note has neither.
|
||||
iOS has no share target, so the iPhone keeps the Shortcut.
|
||||
|
||||
## Fixed before, on 2026-10-21
|
||||
|
||||
After the seven slices of the feature plan (trash `8ffad63`, revisions
|
||||
@@ -83,9 +107,10 @@ tab. `.github/workflows/wiki.yml` replaces it on its first run.
|
||||
|
||||
The backlog, fitted to the model rather than given new surfaces:
|
||||
|
||||
- Install the app on a phone (a PWA: manifest plus a service worker for the
|
||||
app shell, CSP `worker-src 'self'`), with a share target that posts into
|
||||
capture as a note. The touch and desktop menus are already the same.
|
||||
- Read on a phone with no network: the documents opened last, kept by the
|
||||
service worker or in IndexedDB, and cleared on logout.
|
||||
- Share a photo or a file into the app: it asks which document or folder it
|
||||
belongs to.
|
||||
- Task lists: toggle `- [ ]` in the reader, and the open ones of a space as a
|
||||
section of the agenda rather than a `:tasks` command.
|
||||
- A daily review, also as a section of the agenda: yesterday's entry, what was
|
||||
@@ -95,4 +120,4 @@ The backlog, fitted to the model rather than given new surfaces:
|
||||
Dropped: marks (`ma` … `mz`). The info sheet's headings and the tiles cover
|
||||
what they were for, and `m` stays picking nodes up.
|
||||
|
||||
Suggested next: the PWA.
|
||||
Suggested next: task lists and the daily review, as sections of the agenda.
|
||||
@@ -56,6 +56,9 @@ document per day. A file sits in a folder or belongs to its document.
|
||||
card, or more of a document.
|
||||
- **Capture.** A token per phone or script that puts a card in an inbox, a
|
||||
note on the scratchpad or a line in today's entry, and nothing else.
|
||||
- **Install.** On a phone the workspace installs as an app: it opens with no
|
||||
network, sits in the share sheet, where whatever is shared to it lands on
|
||||
the scratchpad, and back closes one thing at a time.
|
||||
- **Share.** Any document behind an unguessable, revocable read-only link.
|
||||
- **Mount.** The whole workspace over SFTP, for an editor on another machine.
|
||||
- **Back up.** A mirror to Proton Drive, end-to-end encrypted, as a registered
|
||||
@@ -223,6 +226,18 @@ has today's entry, an upload into the folder, the trash, a search across every s
|
||||
and on the board the card moves, renames and deletes; the agenda has a done
|
||||
button per card.
|
||||
|
||||
Over HTTPS the browser offers to install the workspace (Chrome's menu: *Install
|
||||
app*, or *Add to Home screen*). Installed, it opens in a window of its own, from
|
||||
its own icon, and with no network it still opens, to say the server cannot be
|
||||
reached and try again. Back — the gesture or the button — lets go of one thing
|
||||
at a time, the way `Esc` does: a sheet, the scratchpad, the editor, the
|
||||
document, then a folder up; at the root of a space it leaves the app. On
|
||||
Android the installed app is in the share sheet: a link or some text shared to
|
||||
it becomes a note on the scratchpad, the title on its first line, and waits
|
||||
there through a login if the session has run out. A service worker keeps the
|
||||
app's own files and nothing else; it never stores a document, a card or a
|
||||
note.
|
||||
|
||||
## Tiles
|
||||
|
||||
Several documents can be open at once. `L` (or `Shift-Enter`) on a row opens
|
||||
@@ -416,7 +431,8 @@ On an iPhone, a Shortcut with *Ask for Input* followed by *Get Contents of
|
||||
URL* does it: method `POST`, a header `Authorization` of `Bearer nwc_…`, and a
|
||||
JSON body with `text` set to the input. On Android, *HTTP Shortcuts* does the
|
||||
same with a text-input variable in the body. Either one goes on the home
|
||||
screen, or behind the share sheet.
|
||||
screen, or behind the share sheet. On Android the installed app is in the share
|
||||
sheet itself, with no token, and puts what is shared on the scratchpad.
|
||||
|
||||
## Not losing things
|
||||
|
||||
@@ -951,7 +967,9 @@ sealed or not.
|
||||
## Deploying
|
||||
|
||||
Put a TLS-terminating reverse proxy in front of it, point it at `WORKSPACE_BIND`,
|
||||
and set `WORKSPACE_SECURE_COOKIE=1`. Nothing else is needed: no database server, no
|
||||
and set `WORKSPACE_SECURE_COOKIE=1`. HTTPS is also what lets a phone install the
|
||||
app: a browser runs a service worker only over HTTPS, or on `localhost`.
|
||||
Nothing else is needed: no database server, no
|
||||
runtime dependencies, no build tools on the host. The server will not start
|
||||
while `WORKSPACE_SECRET` or `WORKSPACE_PASSWORD` is still the example from
|
||||
`.env.example`: with the example secret anyone could sign themselves a session.
|
||||
@@ -972,7 +990,8 @@ everyone out once.
|
||||
The app sends its own `Content-Security-Policy`, `Referrer-Policy: no-referrer`,
|
||||
`X-Content-Type-Options`, `X-Frame-Options`, `Cross-Origin-Opener-Policy`,
|
||||
`Cross-Origin-Resource-Policy` and a `Permissions-Policy` that turns off camera,
|
||||
microphone and location; API answers are `Cache-Control: no-store` unless they
|
||||
microphone and location. The policy lets in one worker and one manifest, the
|
||||
app's own (`/sw.js`, `/manifest.webmanifest`); API answers are `Cache-Control: no-store` unless they
|
||||
say otherwise. A proxy that adds its own should not weaken them. `no-referrer` matters in particular, because a share token
|
||||
lives in the URL and would otherwise leak to any site a shared entry links to.
|
||||
Share links minted before the key existed are revoked by migration `0005`;
|
||||
|
||||
@@ -60,3 +60,8 @@ with a text-input variable, method `POST`, the same header, and a JSON body
|
||||
`{"text": "{{input}}"}`.
|
||||
|
||||
Either one can sit on the home screen, or behind the share sheet.
|
||||
|
||||
**Android, without a token**: install the workspace from Chrome (*Install app*,
|
||||
over HTTPS) and it is in the share sheet itself. What is shared to it — a link,
|
||||
some text, a title — becomes a note on the scratchpad, from the session the app
|
||||
is logged in with. See [On a phone](Guide.md#on-a-phone).
|
||||
@@ -80,6 +80,9 @@ The rail of buttons on the right does the same as the keys, for a mouse.
|
||||
On a phone the same things are buttons: the dock at the bottom has a new
|
||||
document, a new folder, the board, today's entry, an upload and a search, and
|
||||
the `⋯` on a row (or a long press on it) has what can be done to that row.
|
||||
Over HTTPS the browser offers to install it as an app, which then sits in the
|
||||
share sheet and puts what is shared on the scratchpad; see
|
||||
[On a phone](Guide.md#on-a-phone).
|
||||
|
||||

|
||||
|
||||
|
||||
+15
-1
@@ -205,7 +205,9 @@ curl -H "Authorization: Bearer nwc_…" -H 'content-type: application/json' \
|
||||
-d '{"text":"call the bank"}' https://your-host/api/capture
|
||||
```
|
||||
|
||||
The token can do this and nothing else. See [Capture](Capture.md).
|
||||
The token can do this and nothing else. On Android the installed app needs no
|
||||
token: share to it, and the text is a note on the scratchpad. See
|
||||
[Capture](Capture.md) and [On a phone](#on-a-phone).
|
||||
|
||||
## Evening: the diary
|
||||
|
||||
@@ -251,3 +253,15 @@ has a done button on each card.
|
||||
|  |  |
|
||||
|
||||
`:set vim` turns the vim keys on for a phone with a keyboard.
|
||||
|
||||
Over HTTPS the browser offers to install the workspace as an app (Chrome's
|
||||
menu: *Install app*, or *Add to Home screen*). Installed, it opens from its own
|
||||
icon, and with no network it still opens, to say the server cannot be reached.
|
||||
Back lets go of one thing at a time, as `Esc` does — a sheet, the scratchpad,
|
||||
the editor, the document, then a folder up — and only at the root of a space
|
||||
leaves the app.
|
||||
|
||||
On Android the installed app is in the share sheet. A link or some text shared
|
||||
to it becomes a note on the scratchpad, the title on its first line; the drawer
|
||||
comes down with it on top, ready to be kept, made a card or put at the end of a
|
||||
document. A share made while logged out waits for the login.
|
||||
+4
-1
@@ -406,9 +406,12 @@ fn compressible(
|
||||
|
||||
/// Content-Security-Policy for the app shell. `unsafe-inline` is granted to
|
||||
/// styles only, because CodeMirror injects its theme as a <style> element at
|
||||
/// runtime; scripts stay confined to the bundled files.
|
||||
/// runtime; scripts stay confined to the bundled files. The service worker and
|
||||
/// the manifest that make it an app on a phone are the shell's own, at `/sw.js`
|
||||
/// and `/manifest.webmanifest`.
|
||||
const CSP: &str = "default-src 'self'; img-src 'self' data:; media-src 'self'; \
|
||||
style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; \
|
||||
worker-src 'self'; manifest-src 'self'; \
|
||||
frame-src 'self' https://verify.proton.me; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'";
|
||||
|
||||
type HeaderLayer = SetResponseHeaderLayer<HeaderValue>;
|
||||
|
||||
+2
-1
@@ -2,12 +2,13 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="theme-color" content="#0c0d0b" />
|
||||
<title>~/workspace</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 9.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"id": "/",
|
||||
"name": "workspace",
|
||||
"short_name": "workspace",
|
||||
"description": "A private workspace: documents, a plan, a scratchpad and a diary.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0c0d0b",
|
||||
"theme_color": "#0c0d0b",
|
||||
"icons": [
|
||||
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" },
|
||||
{ "src": "/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
],
|
||||
"share_target": {
|
||||
"action": "/share",
|
||||
"method": "POST",
|
||||
"enctype": "multipart/form-data",
|
||||
"params": { "title": "title", "text": "text", "url": "url" }
|
||||
}
|
||||
}
|
||||
+31
-2
@@ -3,7 +3,7 @@
|
||||
import { openOverview } from './lib/actions'
|
||||
import { hooks } from './lib/commands'
|
||||
import { keysOf } from './lib/keymap'
|
||||
import { dispatch } from './lib/modes'
|
||||
import { back, dispatch } from './lib/modes'
|
||||
import { embedSnippet } from './lib/markdown'
|
||||
import Agenda from './lib/Agenda.svelte'
|
||||
import Calendar from './lib/Calendar.svelte'
|
||||
@@ -30,7 +30,7 @@
|
||||
import Sidebar from './lib/Sidebar.svelte'
|
||||
import StatusBar from './lib/StatusBar.svelte'
|
||||
import { workspace, type Sheet } from './lib/store.svelte'
|
||||
import { dwindle } from './lib/util'
|
||||
import { dwindle, isTouchDevice } from './lib/util'
|
||||
|
||||
/*
|
||||
* A share link is /s/<token>#<key>. The fragment never leaves the browser, so
|
||||
@@ -181,6 +181,35 @@
|
||||
return () => clearTimeout(timer)
|
||||
})
|
||||
|
||||
/*
|
||||
* On a phone, back lets go of one thing at a time rather than leaving the
|
||||
* app. One entry is kept in the history over the page: back takes it off,
|
||||
* `back()` lets go of something, and it goes on again. With nothing left to
|
||||
* let go of it stays off, so the next back leaves. It is put on at a touch,
|
||||
* since a browser skips an entry made without one.
|
||||
*/
|
||||
$effect(() => {
|
||||
if (!isTouchDevice || shareToken) return
|
||||
let guarded = false
|
||||
const guard = () => {
|
||||
if (guarded) return
|
||||
history.pushState({ guard: true }, '')
|
||||
guarded = true
|
||||
}
|
||||
const popstate = () => {
|
||||
guarded = false
|
||||
if (back()) guard()
|
||||
// The entry under the guard still has the address the page opened on.
|
||||
else workspace.tree.syncUrl(workspace.tree.path)
|
||||
}
|
||||
window.addEventListener('pointerdown', guard, true)
|
||||
window.addEventListener('popstate', popstate)
|
||||
return () => {
|
||||
window.removeEventListener('pointerdown', guard, true)
|
||||
window.removeEventListener('popstate', popstate)
|
||||
}
|
||||
})
|
||||
|
||||
// Guard against losing a half-written entry to a stray refresh.
|
||||
$effect(() => {
|
||||
if (!workspace.tiles.all.some((tile) => tile.dirty)) return
|
||||
|
||||
+11
-6
@@ -41,10 +41,15 @@
|
||||
--font-mono: "JetBrains Mono Variable", "JetBrains Mono", ui-monospace, monospace;
|
||||
|
||||
/* The frame: a bar on top, a rail on the right, a band at the bottom, and a
|
||||
rounded opening between them that the panes are seen through. */
|
||||
--bar-h: 40px;
|
||||
rounded opening between them that the panes are seen through. Installed
|
||||
on a phone the app reaches under the status bar and the gesture bar
|
||||
(`viewport-fit=cover`), so the bar and the band grow by what those cover
|
||||
and keep their contents clear of them; in a browser both insets are 0. */
|
||||
--inset-top: env(safe-area-inset-top, 0px);
|
||||
--inset-bottom: env(safe-area-inset-bottom, 0px);
|
||||
--bar-h: calc(40px + var(--inset-top));
|
||||
--rail-w: 48px;
|
||||
--status-h: 28px;
|
||||
--status-h: calc(28px + var(--inset-bottom));
|
||||
--frame: 8px;
|
||||
--frame-radius: 18px;
|
||||
|
||||
@@ -69,7 +74,7 @@
|
||||
@media (max-width: 767px) {
|
||||
:root {
|
||||
--rail-w: var(--frame);
|
||||
--status-h: 40px;
|
||||
--status-h: calc(40px + var(--inset-bottom));
|
||||
--frame: 6px;
|
||||
--frame-radius: 14px;
|
||||
}
|
||||
@@ -333,7 +338,7 @@ input::placeholder { color: var(--muted); }
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 calc(var(--rail-w) + 4px) 0 calc(var(--frame) + 10px);
|
||||
padding: var(--inset-top) calc(var(--rail-w) + 4px) 0 calc(var(--frame) + 10px);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
@@ -402,7 +407,7 @@ input::placeholder { color: var(--muted); }
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 0 calc(var(--rail-w) + 4px) 0 calc(var(--frame) + 4px);
|
||||
padding: 0 calc(var(--rail-w) + 4px) var(--inset-bottom) calc(var(--frame) + 4px);
|
||||
font-size: 11px;
|
||||
color: var(--subtle);
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -356,7 +356,7 @@
|
||||
.mobilebar {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 8px 12px calc(8px + env(safe-area-inset-bottom));
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--rim);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
* doors.
|
||||
*/
|
||||
@media (max-width: 767px) {
|
||||
.status { padding: 0 6px; gap: 6px; overflow: visible; }
|
||||
.status { padding: 0 6px var(--inset-bottom); gap: 6px; overflow: visible; }
|
||||
.hint, .file, .readout, .flag, .pending { display: none; }
|
||||
.mode:not(.editing) { display: none; }
|
||||
.tools { display: flex; flex: 1; height: 100%; }
|
||||
|
||||
@@ -105,7 +105,7 @@ function tileKey(direction: Direction, shift: boolean) {
|
||||
}
|
||||
|
||||
/** The drawer up, and the keys back where they were: in the editor, when a document was being written. */
|
||||
async function raise() {
|
||||
export async function raise() {
|
||||
await workspace.scratch.close()
|
||||
if (!workspace.scratch.open && workspace.editing && workspace.pane === 'document') hooks.focusEditor()
|
||||
}
|
||||
|
||||
+31
-1
@@ -16,7 +16,8 @@
|
||||
* The `[y/N]` prompt sits over all of it, in App.svelte, and the command
|
||||
* line keeps its own keys while it has them.
|
||||
*/
|
||||
import { actions, type Run } from './actions'
|
||||
import { actions, raise, type Run } from './actions'
|
||||
import { hooks, runCommand } from './commands'
|
||||
import { bindings, type Binding, type Context } from './keymap'
|
||||
import { workspace } from './store.svelte'
|
||||
|
||||
@@ -150,3 +151,32 @@ export function claim(event: KeyboardEvent): boolean {
|
||||
event.stopPropagation()
|
||||
return true
|
||||
}
|
||||
|
||||
/*
|
||||
* The phone's back gesture, or its back button: one thing let go of, the way
|
||||
* Esc and q let go of it on a keyboard — a question, the line, a sheet, the
|
||||
* drawer, the editor, a tile — and then up a folder. False when there is
|
||||
* nothing left to leave, and the app itself may go.
|
||||
*/
|
||||
export function back(): boolean {
|
||||
if (hooks.closeCommandLine()) return true
|
||||
const step = leaving()
|
||||
if (!step) return false
|
||||
void step()
|
||||
return true
|
||||
}
|
||||
|
||||
function leaving(): (() => unknown) | null {
|
||||
const { prompts, sheets, scratch, drafts, tree } = workspace
|
||||
if (prompts.prompt) return () => prompts.prompt?.resolve(false)
|
||||
if (prompts.secret) return () => prompts.secret?.resolve(null)
|
||||
if (prompts.verify) return () => prompts.verify?.resolve(null)
|
||||
if (sheets.shown !== 'none') return () => sheets.close()
|
||||
if (scratch.open) return scratch.editing ? () => drafts.leaveWriting() : () => workspace.guard(raise)
|
||||
if (drafts.writing || workspace.editing) return () => runCommand('q')
|
||||
if (workspace.fullscreen) return () => (workspace.fullscreen = false)
|
||||
if (workspace.focus !== 'list' && workspace.focus !== 'node') return () => runCommand('close')
|
||||
if (workspace.open && workspace.pane === 'document') return () => runCommand('q')
|
||||
if (tree.path.length > 1) return () => workspace.guard(() => tree.up())
|
||||
return null
|
||||
}
|
||||
@@ -163,6 +163,28 @@ export class Scratchpad {
|
||||
this.ws.say('scratched — Q shows the scratchpad')
|
||||
}
|
||||
|
||||
/*
|
||||
* What the phone's share sheet sent while the app was shut, kept by the
|
||||
* service worker in the cache `share` (see sw.ts): each made a note, and
|
||||
* the drawer down on the newest. One is let go of only once it is a note,
|
||||
* so a share made with no network or no login waits for the next start.
|
||||
*/
|
||||
async takeShared() {
|
||||
if (typeof caches === 'undefined') return
|
||||
const cache = await caches.open('share')
|
||||
const kept = [...(await cache.keys())].sort((a, b) => a.url.localeCompare(b.url, undefined, { numeric: true }))
|
||||
if (!kept.length) return
|
||||
for (const request of kept) {
|
||||
const text = await (await cache.match(request))?.text()
|
||||
if (text?.trim()) await api.createScratch(text)
|
||||
await cache.delete(request)
|
||||
}
|
||||
if (this.open) this.notes = await api.scratch()
|
||||
else await this.toggle()
|
||||
this.cursor = 0
|
||||
this.ws.say(kept.length === 1 ? 'shared — on the scratchpad' : `${kept.length} shared — on the scratchpad`)
|
||||
}
|
||||
|
||||
/**
|
||||
* `:keep [title]`: the note under the cursor made a document in the folder
|
||||
* being browsed, and taken off the scratchpad. Without a title, the first
|
||||
|
||||
@@ -109,10 +109,10 @@ export class Tree {
|
||||
* outside that and shows matches from anywhere in the current space.
|
||||
*/
|
||||
|
||||
/** Keep the address bar on the node being looked at, so it can be linked to. */
|
||||
/** Keep the address bar on the node being looked at, so it can be linked to — the history entry, back's guard among them, kept as it is. */
|
||||
syncUrl(path: Crumb[]) {
|
||||
const slugs = path.map((crumb) => crumb.slug).join('/')
|
||||
if (slugs) history.replaceState(null, '', `/n/${slugs}`)
|
||||
if (slugs) history.replaceState(history.state, '', `/n/${slugs}`)
|
||||
}
|
||||
|
||||
/** Open whatever `work/einarbeiten/01-was-ist-eebus` names. */
|
||||
|
||||
@@ -140,12 +140,16 @@ export class Workspace {
|
||||
await this.cards.loadAgenda()
|
||||
await this.vault.load()
|
||||
await this.cards.openAgendaIfDue()
|
||||
void this.guard(() => this.scratch.takeShared())
|
||||
} catch (error) {
|
||||
this.user = null
|
||||
// Down is not the same as logged out: say so, and keep asking.
|
||||
if (unreachable(error)) {
|
||||
this.notice = 'the server cannot be reached — trying again'
|
||||
setTimeout(() => void this.boot(), 5000)
|
||||
} else if (this.notice.startsWith('the server cannot')) {
|
||||
// Back, but the session did not last the wait.
|
||||
this.notice = ''
|
||||
}
|
||||
} finally {
|
||||
this.booting = false
|
||||
@@ -170,6 +174,8 @@ export class Workspace {
|
||||
await this.cards.loadAgenda()
|
||||
await this.vault.load()
|
||||
await this.cards.openAgendaIfDue()
|
||||
// A share that came in while nobody was logged in has waited for this.
|
||||
void this.guard(() => this.scratch.takeShared())
|
||||
}
|
||||
|
||||
async logout(everywhere = false) {
|
||||
|
||||
@@ -5,3 +5,7 @@ import App from './App.svelte'
|
||||
import './app.css'
|
||||
|
||||
export default mount(App, { target: document.getElementById('app')! })
|
||||
|
||||
// The worker that lets the app be installed and opened offline; see sw.ts.
|
||||
// Not in development, where it would keep Vite's modules.
|
||||
if (import.meta.env.PROD && 'serviceWorker' in navigator) void navigator.serviceWorker.register('/sw.js')
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* The service worker: what makes the workspace an app on a phone.
|
||||
*
|
||||
* - The app shell — index.html, the logo, and what they need to paint — is
|
||||
* kept, so the app opens with no network and says the server cannot be
|
||||
* reached, rather than the browser's error page.
|
||||
* - Every hashed file under /assets is kept the first time it is fetched;
|
||||
* they never change, so a lazy chunk is fetched once.
|
||||
* - The share sheet posts to /share. The text is kept here, in the cache
|
||||
* `share`, and the page takes it onto the scratchpad once someone is logged
|
||||
* in (`Scratchpad.takeShared`). The post never reaches the server.
|
||||
*
|
||||
* Nothing from /api is touched or kept: the caches hold the public shell only,
|
||||
* so logging out leaves nothing private behind.
|
||||
*
|
||||
* The worker is built by `serviceWorker` in vite.config.ts, which writes in
|
||||
* the two names below. The DOM types have no worker events, so the few used
|
||||
* here are spelled out; a module, so they stay its own.
|
||||
*/
|
||||
|
||||
export {}
|
||||
|
||||
declare const __VERSION__: string
|
||||
declare const __PRECACHE__: string[]
|
||||
|
||||
interface ExtendableEvent extends Event {
|
||||
waitUntil(promise: Promise<unknown>): void
|
||||
}
|
||||
interface FetchEvent extends ExtendableEvent {
|
||||
readonly request: Request
|
||||
respondWith(response: Response | Promise<Response>): void
|
||||
}
|
||||
interface Scope {
|
||||
skipWaiting(): Promise<void>
|
||||
clients: { claim(): Promise<void> }
|
||||
addEventListener(type: 'install' | 'activate', listener: (event: ExtendableEvent) => void): void
|
||||
addEventListener(type: 'fetch', listener: (event: FetchEvent) => void): void
|
||||
}
|
||||
|
||||
const worker = self as unknown as Scope
|
||||
const SHELL = `shell-${__VERSION__}`
|
||||
|
||||
worker.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const cache = await caches.open(SHELL)
|
||||
await cache.addAll(['/index.html', '/favicon.svg', ...__PRECACHE__])
|
||||
await worker.skipWaiting()
|
||||
})(),
|
||||
)
|
||||
})
|
||||
|
||||
worker.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
for (const name of await caches.keys()) {
|
||||
if (name.startsWith('shell-') && name !== SHELL) await caches.delete(name)
|
||||
}
|
||||
await worker.clients.claim()
|
||||
})(),
|
||||
)
|
||||
})
|
||||
|
||||
worker.addEventListener('fetch', (event) => {
|
||||
const request = event.request
|
||||
const url = new URL(request.url)
|
||||
if (url.origin !== location.origin) return
|
||||
if (request.method === 'POST' && url.pathname === '/share') return event.respondWith(share(request))
|
||||
if (request.method !== 'GET') return
|
||||
if (url.pathname.startsWith('/assets/')) return event.respondWith(asset(request))
|
||||
if (request.mode === 'navigate') return event.respondWith(fresh(request, '/index.html'))
|
||||
if (url.pathname === '/favicon.svg') return event.respondWith(fresh(request, '/favicon.svg'))
|
||||
})
|
||||
|
||||
/** A hashed file: from the cache, or fetched once and kept. */
|
||||
async function asset(request: Request): Promise<Response> {
|
||||
const cache = await caches.open(SHELL)
|
||||
const kept = await cache.match(request)
|
||||
if (kept) return kept
|
||||
const response = await fetch(request)
|
||||
if (response.ok) await cache.put(request, response.clone())
|
||||
return response
|
||||
}
|
||||
|
||||
/*
|
||||
* A page — /, /n/…, /s/… — is index.html whatever the path, and the logo is
|
||||
* the logo. The server's is newer, so it is asked first; the kept one answers
|
||||
* when it cannot be reached, or a proxy in front of it says it is down.
|
||||
*/
|
||||
async function fresh(request: Request, kept: string): Promise<Response> {
|
||||
try {
|
||||
const response = await fetch(request)
|
||||
if (response.status < 502) return response
|
||||
return (await caches.match(kept)) ?? response
|
||||
} catch (error) {
|
||||
const copy = await caches.match(kept)
|
||||
if (copy) return copy
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* The share sheet: a title, some text, a link — any of them may be missing,
|
||||
* and Android often puts the link in the text as well. They become one note,
|
||||
* the title on its first line.
|
||||
*/
|
||||
async function share(request: Request): Promise<Response> {
|
||||
const form = await request.formData()
|
||||
const field = (name: string) => String(form.get(name) ?? '').trim()
|
||||
const [title, text, link] = [field('title'), field('text'), field('url')]
|
||||
const lines = [title && title !== text ? title : '', text, link && !text.includes(link) ? link : ''].filter(Boolean)
|
||||
if (lines.length) {
|
||||
const cache = await caches.open('share')
|
||||
await cache.put(`/share/${Date.now()}`, new Response(lines.join('\n\n')))
|
||||
}
|
||||
return Response.redirect('/', 303)
|
||||
}
|
||||
+48
-2
@@ -1,6 +1,6 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
||||
import type { Plugin } from 'vite'
|
||||
import type { Plugin, Rollup } from 'vite'
|
||||
|
||||
/*
|
||||
* KaTeX lists each font as woff2, woff and ttf. Every browser the app runs in
|
||||
@@ -15,8 +15,54 @@ const katexWoff2Only: Plugin = {
|
||||
},
|
||||
}
|
||||
|
||||
/*
|
||||
* src/sw.ts, built beside the app as /sw.js — unhashed, since its address is
|
||||
* what the browser remembers. Two names are written into it:
|
||||
*
|
||||
* __PRECACHE__ what index.html needs to paint: the entry, what it imports,
|
||||
* their CSS, and the latin faces of the two fonts
|
||||
* __VERSION__ a hash of every file name in the build, so a build that
|
||||
* changes anything changes sw.js too, and the browser takes
|
||||
* the new worker and drops the old shell
|
||||
*/
|
||||
const serviceWorker: Plugin = {
|
||||
name: 'service-worker',
|
||||
apply: 'build',
|
||||
buildStart() {
|
||||
this.emitFile({ type: 'chunk', id: '/src/sw.ts', fileName: 'sw.js' })
|
||||
},
|
||||
generateBundle(_, bundle) {
|
||||
const chunks = Object.values(bundle).filter((file): file is Rollup.OutputChunk => file.type === 'chunk')
|
||||
const entry = chunks.find((chunk) => chunk.isEntry && chunk.facadeModuleId?.endsWith('index.html'))
|
||||
const sw = bundle['sw.js']
|
||||
if (!entry || sw?.type !== 'chunk') return this.error('service worker: no entry, or no sw.js')
|
||||
|
||||
const shell = new Set<string>()
|
||||
const walk = (name: string) => {
|
||||
if (shell.has(name)) return
|
||||
shell.add(name)
|
||||
const chunk = bundle[name]
|
||||
if (chunk?.type !== 'chunk') return
|
||||
chunk.viteMetadata?.importedCss.forEach((css) => shell.add(css))
|
||||
chunk.imports.forEach(walk)
|
||||
}
|
||||
walk(entry.fileName)
|
||||
for (const name of Object.keys(bundle)) {
|
||||
if (/\/(jetbrains-mono|noto-sans)-latin(-ext)?-wght-normal-[^/]+\.woff2$/.test(name)) shell.add(name)
|
||||
}
|
||||
|
||||
// FNV-1a: only a change has to show, so nothing stronger is needed.
|
||||
let hash = 0x811c9dc5
|
||||
for (const char of Object.keys(bundle).sort().join('\n')) hash = Math.imul(hash ^ char.charCodeAt(0), 0x01000193)
|
||||
const version = (hash >>> 0).toString(16)
|
||||
sw.code = sw.code
|
||||
.replaceAll('__VERSION__', JSON.stringify(version))
|
||||
.replaceAll('__PRECACHE__', JSON.stringify([...shell].map((name) => `/${name}`)))
|
||||
},
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte(), katexWoff2Only],
|
||||
plugins: [svelte(), katexWoff2Only, serviceWorker],
|
||||
build: { target: 'es2022', chunkSizeWarningLimit: 900 },
|
||||
server: {
|
||||
port: 4243,
|
||||
|
||||
Reference in new issue
Block a user