feat(web): make the app installable with a service worker and an install button (#57)
- commit
- 79d6b041134c11747e1ed4cc9044073c6b07752f
- author
- brice · · 2026-10-18 06:35 UTC
- committer
- bnema · · 2026-10-18 06:35 UTC
- parent
- 2bf61ae
- signature
- SSH Unverified
SHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivAThe key is unknown
44 files · +516 -15
M internal/adapters/web/ASSETS.md +2 -1
| @@ -16,8 +16,9 @@ content-hashed names. Nothing is fetched at runtime. | |||
| 16 | 16 | | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | | | |
| 17 | 17 | | `static/fonts/atkinson-next.woff2`, `atkinson-next-italic.woff2` | Atkinson Hyperlegible Next, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-next.txt`) | Google Fonts | `18b2a1a39a2fa298b0ba5390aca68462669826c90925656f1c1f6796e0e1bbaf`, `4a5037bfaf6680f40147407407ec09fa42925774bde809a579283d27f9f08106` | | |
| 18 | 18 | | `static/fonts/atkinson-mono.woff2` | Atkinson Hyperlegible Mono, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-mono.txt`) | Google Fonts | `2706b1ee4f452e744ea91f7e4908cbde9c5d35521bf5ffffc71a382a2de89613` | | |
| 19 | - | | `static/icons/*.svg` | interface icons, used as CSS masks; `brand.svg` is a grey picture shown as is. Only the icons in use are copied, one file each: `branch`, `search`, `tag`, `file`, `chevron`, `user`, `key`, `log-in`, `log-out` are Lucide 1.8 (`git-branch`, `search`, `tag`, `file-text`, `chevron-down`, `user-round`, `key-round`, `log-in`, `log-out`; ISC, https://lucide.dev/license) | Lucide; `brand.svg` written here | | | |
| 19 | + | | `static/icons/*.svg` | interface icons, used as CSS masks; `brand.svg` is a grey picture shown as is. Only the icons in use are copied, one file each: `branch`, `search`, `tag`, `file`, `chevron`, `user`, `key`, `log-in`, `log-out`, `download` are Lucide 1.8 (`git-branch`, `search`, `tag`, `file-text`, `chevron-down`, `user-round`, `key-round`, `log-in`, `log-out`, `download`; ISC, https://lucide.dev/license) | Lucide; `brand.svg` written here | | | |
| 20 | 20 | | `static/app/*.png` | icons of the installed web app (manifest, `apple-touch-icon`), rendered from `brand.svg` with resvg; the maskable and Apple icons have the picture in the safe zone on `#181818` | generated here | | | |
| 21 | + | | `static/sw.js` | the service worker of the installed app: a network-only passthrough (no cache), served at `/-/sw.js` with `Service-Worker-Allowed: /` and revalidated on each check (not a hashed asset) | written here | | | |
| 21 | 22 | | `static/rules.json` | speculation rules: prefetch the light same-origin pages on hover (`Speculation-Rules` header); the heavy ones (tree, blob, commit, commits, compare, refs, raw, MR "since") are excluded, each costs several Git reads on the server | written here | | | |
| 22 | 23 | ||
| 23 | 24 | The three htmx files are the exact files the approved mockup was built with. | |
| @@ -16,8 +16,9 @@ content-hashed names. Nothing is fetched at runtime. | |||||
| 16 | | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | | | 16 | | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | | | ||
| 17 | | `static/fonts/atkinson-next.woff2`, `atkinson-next-italic.woff2` | Atkinson Hyperlegible Next, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-next.txt`) | Google Fonts | `18b2a1a39a2fa298b0ba5390aca68462669826c90925656f1c1f6796e0e1bbaf`, `4a5037bfaf6680f40147407407ec09fa42925774bde809a579283d27f9f08106` | | 17 | | `static/fonts/atkinson-next.woff2`, `atkinson-next-italic.woff2` | Atkinson Hyperlegible Next, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-next.txt`) | Google Fonts | `18b2a1a39a2fa298b0ba5390aca68462669826c90925656f1c1f6796e0e1bbaf`, `4a5037bfaf6680f40147407407ec09fa42925774bde809a579283d27f9f08106` | | ||
| 18 | | `static/fonts/atkinson-mono.woff2` | Atkinson Hyperlegible Mono, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-mono.txt`) | Google Fonts | `2706b1ee4f452e744ea91f7e4908cbde9c5d35521bf5ffffc71a382a2de89613` | | 18 | | `static/fonts/atkinson-mono.woff2` | Atkinson Hyperlegible Mono, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-mono.txt`) | Google Fonts | `2706b1ee4f452e744ea91f7e4908cbde9c5d35521bf5ffffc71a382a2de89613` | | ||
| 19 | - | | `static/icons/*.svg` | interface icons, used as CSS masks; `brand.svg` is a grey picture shown as is. Only the icons in use are copied, one file each: `branch`, `search`, `tag`, `file`, `chevron`, `user`, `key`, `log-in`, `log-out` are Lucide 1.8 (`git-branch`, `search`, `tag`, `file-text`, `chevron-down`, `user-round`, `key-round`, `log-in`, `log-out`; ISC, https://lucide.dev/license) | Lucide; `brand.svg` written here | | | 19 | + | | `static/icons/*.svg` | interface icons, used as CSS masks; `brand.svg` is a grey picture shown as is. Only the icons in use are copied, one file each: `branch`, `search`, `tag`, `file`, `chevron`, `user`, `key`, `log-in`, `log-out`, `download` are Lucide 1.8 (`git-branch`, `search`, `tag`, `file-text`, `chevron-down`, `user-round`, `key-round`, `log-in`, `log-out`, `download`; ISC, https://lucide.dev/license) | Lucide; `brand.svg` written here | | |
| 20 | | `static/app/*.png` | icons of the installed web app (manifest, `apple-touch-icon`), rendered from `brand.svg` with resvg; the maskable and Apple icons have the picture in the safe zone on `#181818` | generated here | | | 20 | | `static/app/*.png` | icons of the installed web app (manifest, `apple-touch-icon`), rendered from `brand.svg` with resvg; the maskable and Apple icons have the picture in the safe zone on `#181818` | generated here | | | ||
| 21 | + | | `static/sw.js` | the service worker of the installed app: a network-only passthrough (no cache), served at `/-/sw.js` with `Service-Worker-Allowed: /` and revalidated on each check (not a hashed asset) | written here | | | |||
| 21 | | `static/rules.json` | speculation rules: prefetch the light same-origin pages on hover (`Speculation-Rules` header); the heavy ones (tree, blob, commit, commits, compare, refs, raw, MR "since") are excluded, each costs several Git reads on the server | written here | | | 22 | | `static/rules.json` | speculation rules: prefetch the light same-origin pages on hover (`Speculation-Rules` header); the heavy ones (tree, blob, commit, commits, compare, refs, raw, MR "since") are excluded, each costs several Git reads on the server | written here | | | ||
| 22 | 23 | ||||
| 23 | The three htmx files are the exact files the approved mockup was built with. | 24 | The three htmx files are the exact files the approved mockup was built with. | ||
M internal/adapters/web/assets.go +15 -3
| @@ -47,6 +47,7 @@ type asset struct { | |||
| 47 | 47 | type assetSet struct { | |
| 48 | 48 | byName map[string]asset | |
| 49 | 49 | urls Assets | |
| 50 | + | sw serviceWorker | |
| 50 | 51 | } | |
| 51 | 52 | ||
| 52 | 53 | // scripts are the JavaScript files of the page, in embedded static/js: the | |
| @@ -155,6 +156,11 @@ func loadAssets() (*assetSet, error) { | |||
| 155 | 156 | if err := s.loadApp(put); err != nil { | |
| 156 | 157 | return nil, err | |
| 157 | 158 | } | |
| 159 | + | sw, err := loadServiceWorker() | |
| 160 | + | if err != nil { | |
| 161 | + | return nil, err | |
| 162 | + | } | |
| 163 | + | s.sw = sw | |
| 158 | 164 | return s, nil | |
| 159 | 165 | } | |
| 160 | 166 | ||
| @@ -167,11 +173,16 @@ var appIcons = []struct{ file, sizes, purpose string }{ | |||
| 167 | 173 | {"icon-maskable-512.png", "512x512", "maskable"}, | |
| 168 | 174 | } | |
| 169 | 175 | ||
| 170 | - | // webManifest is the shape of the web app manifest: the minimum a browser | |
| 171 | - | // asks for to offer "install" / "add to home screen". | |
| 176 | + | // webManifest is the shape of the web app manifest: what a browser asks for to | |
| 177 | + | // offer "install" / "add to home screen", and the id and description of the | |
| 178 | + | // richer install dialog. | |
| 172 | 179 | type webManifest struct { | |
| 180 | + | // ID is the identity of the app, independent of the (hashed) URL of the | |
| 181 | + | // manifest and of start_url. | |
| 182 | + | ID string `json:"id"` | |
| 173 | 183 | Name string `json:"name"` | |
| 174 | 184 | ShortName string `json:"short_name"` | |
| 185 | + | Description string `json:"description"` | |
| 175 | 186 | StartURL string `json:"start_url"` | |
| 176 | 187 | Scope string `json:"scope"` | |
| 177 | 188 | Display string `json:"display"` | |
| @@ -195,7 +206,8 @@ const appColor = "#050505" | |||
| 195 | 206 | // hashed URLs. | |
| 196 | 207 | func (s *assetSet) loadApp(put func(dir, name string, b []byte, typ string) string) error { | |
| 197 | 208 | m := webManifest{ | |
| 198 | - | Name: "ermite", ShortName: "ermite", StartURL: "/", Scope: "/", Display: "standalone", | |
| 209 | + | ID: "/", Name: "ermite", ShortName: "ermite", StartURL: "/", Scope: "/", Display: "standalone", | |
| 210 | + | Description: "Self-hosted Git forge: browse code, review merge requests and follow CI.", | |
| 199 | 211 | BackgroundColor: appColor, ThemeColor: appColor, | |
| 200 | 212 | } | |
| 201 | 213 | for _, ic := range appIcons { | |
| @@ -47,6 +47,7 @@ type asset struct { | |||||
| 47 | type assetSet struct { | 47 | type assetSet struct { | ||
| 48 | byName map[string]asset | 48 | byName map[string]asset | ||
| 49 | urls Assets | 49 | urls Assets | ||
| 50 | + | sw serviceWorker | |||
| 50 | } | 51 | } | ||
| 51 | 52 | ||||
| 52 | // scripts are the JavaScript files of the page, in embedded static/js: the | 53 | // scripts are the JavaScript files of the page, in embedded static/js: the | ||
| @@ -155,6 +156,11 @@ func loadAssets() (*assetSet, error) { | |||||
| 155 | if err := s.loadApp(put); err != nil { | 156 | if err := s.loadApp(put); err != nil { | ||
| 156 | return nil, err | 157 | return nil, err | ||
| 157 | } | 158 | } | ||
| 159 | + | sw, err := loadServiceWorker() | |||
| 160 | + | if err != nil { | |||
| 161 | + | return nil, err | |||
| 162 | + | } | |||
| 163 | + | s.sw = sw | |||
| 158 | return s, nil | 164 | return s, nil | ||
| 159 | } | 165 | } | ||
| 160 | 166 | ||||
| @@ -167,11 +173,16 @@ var appIcons = []struct{ file, sizes, purpose string }{ | |||||
| 167 | {"icon-maskable-512.png", "512x512", "maskable"}, | 173 | {"icon-maskable-512.png", "512x512", "maskable"}, | ||
| 168 | } | 174 | } | ||
| 169 | 175 | ||||
| 170 | - | // webManifest is the shape of the web app manifest: the minimum a browser | 176 | + | // webManifest is the shape of the web app manifest: what a browser asks for to |
| 171 | - | // asks for to offer "install" / "add to home screen". | 177 | + | // offer "install" / "add to home screen", and the id and description of the |
| 178 | + | // richer install dialog. | |||
| 172 | type webManifest struct { | 179 | type webManifest struct { | ||
| 180 | + | // ID is the identity of the app, independent of the (hashed) URL of the | |||
| 181 | + | // manifest and of start_url. | |||
| 182 | + | ID string `json:"id"` | |||
| 173 | Name string `json:"name"` | 183 | Name string `json:"name"` | ||
| 174 | ShortName string `json:"short_name"` | 184 | ShortName string `json:"short_name"` | ||
| 185 | + | Description string `json:"description"` | |||
| 175 | StartURL string `json:"start_url"` | 186 | StartURL string `json:"start_url"` | ||
| 176 | Scope string `json:"scope"` | 187 | Scope string `json:"scope"` | ||
| 177 | Display string `json:"display"` | 188 | Display string `json:"display"` | ||
| @@ -195,7 +206,8 @@ const appColor = "#050505" | |||||
| 195 | // hashed URLs. | 206 | // hashed URLs. | ||
| 196 | func (s *assetSet) loadApp(put func(dir, name string, b []byte, typ string) string) error { | 207 | func (s *assetSet) loadApp(put func(dir, name string, b []byte, typ string) string) error { | ||
| 197 | m := webManifest{ | 208 | m := webManifest{ | ||
| 198 | - | Name: "ermite", ShortName: "ermite", StartURL: "/", Scope: "/", Display: "standalone", | 209 | + | ID: "/", Name: "ermite", ShortName: "ermite", StartURL: "/", Scope: "/", Display: "standalone", |
| 210 | + | Description: "Self-hosted Git forge: browse code, review merge requests and follow CI.", | |||
| 199 | BackgroundColor: appColor, ThemeColor: appColor, | 211 | BackgroundColor: appColor, ThemeColor: appColor, | ||
| 200 | } | 212 | } | ||
| 201 | for _, ic := range appIcons { | 213 | for _, ic := range appIcons { | ||
A internal/adapters/web/jstest/install.test.mjs +105 -0
| @@ -0,0 +1,105 @@ | |||
| 1 | + | // Tests of the install block of static/js/ermite.js (ermiteInstall): the | |
| 2 | + | // service worker registration and the "install app" button. Run with | |
| 3 | + | // node --test 'internal/adapters/web/jstest/*.test.mjs' | |
| 4 | + | import assert from 'node:assert/strict'; | |
| 5 | + | import { readFileSync } from 'node:fs'; | |
| 6 | + | import { test } from 'node:test'; | |
| 7 | + | import { dom, el, installDom } from './dom.mjs'; | |
| 8 | + | ||
| 9 | + | const doc = installDom(); | |
| 10 | + | const button = el('button', { attrs: { 'data-install': '' } }); | |
| 11 | + | button.hidden = true; | |
| 12 | + | document.body.append(button); | |
| 13 | + | ||
| 14 | + | const setNavigator = (v) => Object.defineProperty(globalThis, 'navigator', { value: v, configurable: true, writable: true }); | |
| 15 | + | const listeners = {}; | |
| 16 | + | globalThis.addEventListener = (type, fn) => { listeners[type] = fn; }; | |
| 17 | + | const fire = (type, ev) => listeners[type](ev); | |
| 18 | + | let standalone = false; | |
| 19 | + | globalThis.matchMedia = () => ({ matches: standalone }); | |
| 20 | + | doc.readyState = 'loading'; | |
| 21 | + | const registered = []; | |
| 22 | + | setNavigator({ serviceWorker: { register: async (url, opts) => { registered.push([url, opts]); return {}; } } }); | |
| 23 | + | ||
| 24 | + | // Only the ermiteInstall block is evaluated: the rest needs a page. | |
| 25 | + | const src = readFileSync(new URL('../static/js/ermite.js', import.meta.url), 'utf8'); | |
| 26 | + | const start = src.indexOf(' window.ermiteInstall = '); | |
| 27 | + | const end = src.indexOf(' })();', start) + ' })();'.length; | |
| 28 | + | new Function('$$', src.slice(start, end))((sel) => document.querySelectorAll(sel)); | |
| 29 | + | const I = window.ermiteInstall; | |
| 30 | + | ||
| 31 | + | // promptEvent is a beforeinstallprompt event. | |
| 32 | + | function promptEvent(outcome = 'accepted') { | |
| 33 | + | const e = { prevented: false, prompts: 0, preventDefault() { this.prevented = true; } }; | |
| 34 | + | e.prompt = async () => { e.prompts++; }; | |
| 35 | + | e.userChoice = Promise.resolve({ outcome }); | |
| 36 | + | return e; | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | test('the service worker is registered at the root scope once the page has loaded', async () => { | |
| 40 | + | assert.equal(registered.length, 0, 'not before load'); | |
| 41 | + | await listeners.load(); | |
| 42 | + | assert.deepEqual(registered, [['/-/sw.js', { scope: '/' }]]); | |
| 43 | + | }); | |
| 44 | + | ||
| 45 | + | test('a refused registration is not an error', async () => { | |
| 46 | + | setNavigator({ serviceWorker: { register: () => Promise.reject(new Error('insecure')) } }); | |
| 47 | + | assert.equal(await I.register(), null); | |
| 48 | + | setNavigator({}); | |
| 49 | + | assert.equal(await I.register(), null); | |
| 50 | + | }); | |
| 51 | + | ||
| 52 | + | test('the button shows on beforeinstallprompt, and a click prompts once', async () => { | |
| 53 | + | const e = promptEvent(); | |
| 54 | + | assert.equal(button.hidden, true); | |
| 55 | + | fire('beforeinstallprompt', e); | |
| 56 | + | assert.equal(e.prevented, true, 'the browser banner is replaced by the button'); | |
| 57 | + | assert.equal(button.hidden, false); | |
| 58 | + | const done = listeners.click({ target: { closest: (s) => (s === '[data-install]' ? button : null) } }); | |
| 59 | + | assert.equal(done, undefined); | |
| 60 | + | assert.equal(await I.pending, 'accepted'); | |
| 61 | + | assert.equal(await I.prompt(), '', 'the event was used by the click'); | |
| 62 | + | assert.equal(e.prompts, 1); | |
| 63 | + | assert.equal(button.hidden, true); | |
| 64 | + | }); | |
| 65 | + | ||
| 66 | + | test('the button stays while the dialog is up; its focus then moves to the search', async () => { | |
| 67 | + | const search = el('button', { attrs: { 'aria-controls': 'panel-cmd' } }); | |
| 68 | + | document.body.append(search); | |
| 69 | + | const e = promptEvent('dismissed'); | |
| 70 | + | let shownDuring = null; | |
| 71 | + | e.prompt = async () => { shownDuring = !button.hidden; }; | |
| 72 | + | fire('beforeinstallprompt', e); | |
| 73 | + | button.focus(); | |
| 74 | + | dom.focused = []; | |
| 75 | + | assert.equal(await I.prompt(), 'dismissed'); | |
| 76 | + | assert.equal(shownDuring, true, 'not hidden under the dialog'); | |
| 77 | + | assert.equal(button.hidden, true); | |
| 78 | + | assert.deepEqual(dom.focused, [search]); | |
| 79 | + | dom.active = null; | |
| 80 | + | search.remove(); | |
| 81 | + | }); | |
| 82 | + | ||
| 83 | + | test('prompt without an event does nothing; a dismissal reports its outcome', async () => { | |
| 84 | + | assert.equal(await I.prompt(), ''); | |
| 85 | + | const e = promptEvent('dismissed'); | |
| 86 | + | fire('beforeinstallprompt', e); | |
| 87 | + | assert.equal(await I.prompt(), 'dismissed'); | |
| 88 | + | assert.equal(button.hidden, true); | |
| 89 | + | }); | |
| 90 | + | ||
| 91 | + | test('appinstalled hides the button', () => { | |
| 92 | + | fire('beforeinstallprompt', promptEvent()); | |
| 93 | + | assert.equal(button.hidden, false); | |
| 94 | + | fire('appinstalled', {}); | |
| 95 | + | assert.equal(button.hidden, true); | |
| 96 | + | }); | |
| 97 | + | ||
| 98 | + | test('an app that runs installed never shows the button', () => { | |
| 99 | + | standalone = true; | |
| 100 | + | const e = promptEvent(); | |
| 101 | + | fire('beforeinstallprompt', e); | |
| 102 | + | assert.equal(button.hidden, true); | |
| 103 | + | assert.equal(e.prevented, false); | |
| 104 | + | standalone = false; | |
| 105 | + | }); | |
| @@ -0,0 +1,105 @@ | |||||
| 1 | + | // Tests of the install block of static/js/ermite.js (ermiteInstall): the | |||
| 2 | + | // service worker registration and the "install app" button. Run with | |||
| 3 | + | // node --test 'internal/adapters/web/jstest/*.test.mjs' | |||
| 4 | + | import assert from 'node:assert/strict'; | |||
| 5 | + | import { readFileSync } from 'node:fs'; | |||
| 6 | + | import { test } from 'node:test'; | |||
| 7 | + | import { dom, el, installDom } from './dom.mjs'; | |||
| 8 | + | ||||
| 9 | + | const doc = installDom(); | |||
| 10 | + | const button = el('button', { attrs: { 'data-install': '' } }); | |||
| 11 | + | button.hidden = true; | |||
| 12 | + | document.body.append(button); | |||
| 13 | + | ||||
| 14 | + | const setNavigator = (v) => Object.defineProperty(globalThis, 'navigator', { value: v, configurable: true, writable: true }); | |||
| 15 | + | const listeners = {}; | |||
| 16 | + | globalThis.addEventListener = (type, fn) => { listeners[type] = fn; }; | |||
| 17 | + | const fire = (type, ev) => listeners[type](ev); | |||
| 18 | + | let standalone = false; | |||
| 19 | + | globalThis.matchMedia = () => ({ matches: standalone }); | |||
| 20 | + | doc.readyState = 'loading'; | |||
| 21 | + | const registered = []; | |||
| 22 | + | setNavigator({ serviceWorker: { register: async (url, opts) => { registered.push([url, opts]); return {}; } } }); | |||
| 23 | + | ||||
| 24 | + | // Only the ermiteInstall block is evaluated: the rest needs a page. | |||
| 25 | + | const src = readFileSync(new URL('../static/js/ermite.js', import.meta.url), 'utf8'); | |||
| 26 | + | const start = src.indexOf(' window.ermiteInstall = '); | |||
| 27 | + | const end = src.indexOf(' })();', start) + ' })();'.length; | |||
| 28 | + | new Function('$$', src.slice(start, end))((sel) => document.querySelectorAll(sel)); | |||
| 29 | + | const I = window.ermiteInstall; | |||
| 30 | + | ||||
| 31 | + | // promptEvent is a beforeinstallprompt event. | |||
| 32 | + | function promptEvent(outcome = 'accepted') { | |||
| 33 | + | const e = { prevented: false, prompts: 0, preventDefault() { this.prevented = true; } }; | |||
| 34 | + | e.prompt = async () => { e.prompts++; }; | |||
| 35 | + | e.userChoice = Promise.resolve({ outcome }); | |||
| 36 | + | return e; | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | test('the service worker is registered at the root scope once the page has loaded', async () => { | |||
| 40 | + | assert.equal(registered.length, 0, 'not before load'); | |||
| 41 | + | await listeners.load(); | |||
| 42 | + | assert.deepEqual(registered, [['/-/sw.js', { scope: '/' }]]); | |||
| 43 | + | }); | |||
| 44 | + | ||||
| 45 | + | test('a refused registration is not an error', async () => { | |||
| 46 | + | setNavigator({ serviceWorker: { register: () => Promise.reject(new Error('insecure')) } }); | |||
| 47 | + | assert.equal(await I.register(), null); | |||
| 48 | + | setNavigator({}); | |||
| 49 | + | assert.equal(await I.register(), null); | |||
| 50 | + | }); | |||
| 51 | + | ||||
| 52 | + | test('the button shows on beforeinstallprompt, and a click prompts once', async () => { | |||
| 53 | + | const e = promptEvent(); | |||
| 54 | + | assert.equal(button.hidden, true); | |||
| 55 | + | fire('beforeinstallprompt', e); | |||
| 56 | + | assert.equal(e.prevented, true, 'the browser banner is replaced by the button'); | |||
| 57 | + | assert.equal(button.hidden, false); | |||
| 58 | + | const done = listeners.click({ target: { closest: (s) => (s === '[data-install]' ? button : null) } }); | |||
| 59 | + | assert.equal(done, undefined); | |||
| 60 | + | assert.equal(await I.pending, 'accepted'); | |||
| 61 | + | assert.equal(await I.prompt(), '', 'the event was used by the click'); | |||
| 62 | + | assert.equal(e.prompts, 1); | |||
| 63 | + | assert.equal(button.hidden, true); | |||
| 64 | + | }); | |||
| 65 | + | ||||
| 66 | + | test('the button stays while the dialog is up; its focus then moves to the search', async () => { | |||
| 67 | + | const search = el('button', { attrs: { 'aria-controls': 'panel-cmd' } }); | |||
| 68 | + | document.body.append(search); | |||
| 69 | + | const e = promptEvent('dismissed'); | |||
| 70 | + | let shownDuring = null; | |||
| 71 | + | e.prompt = async () => { shownDuring = !button.hidden; }; | |||
| 72 | + | fire('beforeinstallprompt', e); | |||
| 73 | + | button.focus(); | |||
| 74 | + | dom.focused = []; | |||
| 75 | + | assert.equal(await I.prompt(), 'dismissed'); | |||
| 76 | + | assert.equal(shownDuring, true, 'not hidden under the dialog'); | |||
| 77 | + | assert.equal(button.hidden, true); | |||
| 78 | + | assert.deepEqual(dom.focused, [search]); | |||
| 79 | + | dom.active = null; | |||
| 80 | + | search.remove(); | |||
| 81 | + | }); | |||
| 82 | + | ||||
| 83 | + | test('prompt without an event does nothing; a dismissal reports its outcome', async () => { | |||
| 84 | + | assert.equal(await I.prompt(), ''); | |||
| 85 | + | const e = promptEvent('dismissed'); | |||
| 86 | + | fire('beforeinstallprompt', e); | |||
| 87 | + | assert.equal(await I.prompt(), 'dismissed'); | |||
| 88 | + | assert.equal(button.hidden, true); | |||
| 89 | + | }); | |||
| 90 | + | ||||
| 91 | + | test('appinstalled hides the button', () => { | |||
| 92 | + | fire('beforeinstallprompt', promptEvent()); | |||
| 93 | + | assert.equal(button.hidden, false); | |||
| 94 | + | fire('appinstalled', {}); | |||
| 95 | + | assert.equal(button.hidden, true); | |||
| 96 | + | }); | |||
| 97 | + | ||||
| 98 | + | test('an app that runs installed never shows the button', () => { | |||
| 99 | + | standalone = true; | |||
| 100 | + | const e = promptEvent(); | |||
| 101 | + | fire('beforeinstallprompt', e); | |||
| 102 | + | assert.equal(button.hidden, true); | |||
| 103 | + | assert.equal(e.prevented, false); | |||
| 104 | + | standalone = false; | |||
| 105 | + | }); | |||
M internal/adapters/web/manifest_test.go +72 -0
| @@ -3,7 +3,13 @@ | |||
| 3 | 3 | package web | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "context" | |
| 6 | 8 | "encoding/json" | |
| 9 | + | "fmt" | |
| 10 | + | "image/png" | |
| 11 | + | "net/http" | |
| 12 | + | "net/http/httptest" | |
| 7 | 13 | "strings" | |
| 8 | 14 | "testing" | |
| 9 | 15 | ||
| @@ -25,6 +31,12 @@ func TestWebAppManifest(t *testing.T) { | |||
| 25 | 31 | require.NoError(t, json.Unmarshal(man.body, &m)) | |
| 26 | 32 | assert.Equal(t, "standalone", m.Display) | |
| 27 | 33 | assert.Equal(t, "/", m.StartURL) | |
| 34 | + | assert.Equal(t, "/", m.Scope) | |
| 35 | + | // The id keeps the identity of the app when the hashed URL of the manifest | |
| 36 | + | // changes with a release. | |
| 37 | + | assert.Equal(t, "/", m.ID) | |
| 38 | + | assert.NotEmpty(t, m.Description) | |
| 39 | + | assert.NotContains(t, string(man.body), "prefer_related_applications") | |
| 28 | 40 | require.Len(t, m.Icons, len(appIcons)) | |
| 29 | 41 | for _, ic := range m.Icons { | |
| 30 | 42 | a, ok := srv.assets.byName[strings.TrimPrefix(ic.Src, StaticPrefix)] | |
| @@ -34,7 +46,67 @@ func TestWebAppManifest(t *testing.T) { | |||
| 34 | 46 | _, ok = srv.assets.byName[strings.TrimPrefix(u.AppleIcon, StaticPrefix)] | |
| 35 | 47 | assert.True(t, ok) | |
| 36 | 48 | ||
| 49 | + | // Chrome asks for a 192 and a 512 icon with purpose any. | |
| 50 | + | var any192, any512 bool | |
| 51 | + | for _, ic := range m.Icons { | |
| 52 | + | any192 = any192 || (ic.Sizes == "192x192" && ic.Purpose == "any") | |
| 53 | + | any512 = any512 || (ic.Sizes == "512x512" && ic.Purpose == "any") | |
| 54 | + | } | |
| 55 | + | assert.True(t, any192 && any512) | |
| 56 | + | ||
| 37 | 57 | page := renderToString(t, Shell(Layout{Assets: u}, nil)) | |
| 38 | 58 | assert.Contains(t, page, `<link rel="manifest" href="`+u.Manifest+`">`) | |
| 39 | 59 | assert.Contains(t, page, `<link rel="apple-touch-icon" href="`+u.AppleIcon+`">`) | |
| 60 | + | assert.Contains(t, page, `data-install`) | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // The icons are exactly the size the manifest declares: Chrome does not | |
| 64 | + | // install from an icon that is smaller than it says. | |
| 65 | + | func TestAppIconSizes(t *testing.T) { | |
| 66 | + | t.Parallel() | |
| 67 | + | for _, ic := range appIcons { | |
| 68 | + | b, err := staticFS.ReadFile("static/app/" + ic.file) | |
| 69 | + | require.NoError(t, err) | |
| 70 | + | cfg, err := png.DecodeConfig(bytes.NewReader(b)) | |
| 71 | + | require.NoError(t, err, ic.file) | |
| 72 | + | assert.Equal(t, ic.sizes, fmt.Sprintf("%dx%d", cfg.Width, cfg.Height), ic.file) | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // The service worker is served from the root scope, is revalidated on each | |
| 77 | + | // check, and answers a conditional request with 304. | |
| 78 | + | func TestServiceWorkerRoute(t *testing.T) { | |
| 79 | + | t.Parallel() | |
| 80 | + | srv := newBareServer(t) | |
| 81 | + | get := func(inm string) *httptest.ResponseRecorder { | |
| 82 | + | rec := httptest.NewRecorder() | |
| 83 | + | r := httptest.NewRequestWithContext(context.Background(), http.MethodGet, ServiceWorkerPath, nil) | |
| 84 | + | if inm != "" { | |
| 85 | + | r.Header.Set("If-None-Match", inm) | |
| 86 | + | } | |
| 87 | + | srv.ServeHTTP(rec, r) | |
| 88 | + | return rec | |
| 89 | + | } | |
| 90 | + | rec := get("") | |
| 91 | + | require.Equal(t, http.StatusOK, rec.Code) | |
| 92 | + | h := rec.Header() | |
| 93 | + | assert.Equal(t, "text/javascript; charset=utf-8", h.Get("Content-Type")) | |
| 94 | + | assert.Equal(t, "no-cache", h.Get("Cache-Control"), "a cached worker would never update") | |
| 95 | + | assert.Equal(t, "/", h.Get("Service-Worker-Allowed")) | |
| 96 | + | assert.NotEmpty(t, h.Get("ETag")) | |
| 97 | + | assert.Contains(t, rec.Body.String(), "addEventListener('fetch'") | |
| 98 | + | assert.NotContains(t, rec.Body.String(), "caches.", "nothing authenticated is stored") | |
| 99 | + | ||
| 100 | + | again := get(h.Get("ETag")) | |
| 101 | + | assert.Equal(t, http.StatusNotModified, again.Code) | |
| 102 | + | ||
| 103 | + | // The registered script is the one served. | |
| 104 | + | assert.Contains(t, staticJS(t, "ermite.js"), "register('"+ServiceWorkerPath+"'") | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func staticJS(t *testing.T, name string) string { | |
| 108 | + | t.Helper() | |
| 109 | + | b, err := staticFS.ReadFile("static/js/" + name) | |
| 110 | + | require.NoError(t, err) | |
| 111 | + | return string(b) | |
| 40 | 112 | } | |
| @@ -3,7 +3,13 @@ | |||||
| 3 | package web | 3 | package web | ||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | + | "bytes" | |||
| 7 | + | "context" | |||
| 6 | "encoding/json" | 8 | "encoding/json" | ||
| 9 | + | "fmt" | |||
| 10 | + | "image/png" | |||
| 11 | + | "net/http" | |||
| 12 | + | "net/http/httptest" | |||
| 7 | "strings" | 13 | "strings" | ||
| 8 | "testing" | 14 | "testing" | ||
| 9 | 15 | ||||
| @@ -25,6 +31,12 @@ func TestWebAppManifest(t *testing.T) { | |||||
| 25 | require.NoError(t, json.Unmarshal(man.body, &m)) | 31 | require.NoError(t, json.Unmarshal(man.body, &m)) | ||
| 26 | assert.Equal(t, "standalone", m.Display) | 32 | assert.Equal(t, "standalone", m.Display) | ||
| 27 | assert.Equal(t, "/", m.StartURL) | 33 | assert.Equal(t, "/", m.StartURL) | ||
| 34 | + | assert.Equal(t, "/", m.Scope) | |||
| 35 | + | // The id keeps the identity of the app when the hashed URL of the manifest | |||
| 36 | + | // changes with a release. | |||
| 37 | + | assert.Equal(t, "/", m.ID) | |||
| 38 | + | assert.NotEmpty(t, m.Description) | |||
| 39 | + | assert.NotContains(t, string(man.body), "prefer_related_applications") | |||
| 28 | require.Len(t, m.Icons, len(appIcons)) | 40 | require.Len(t, m.Icons, len(appIcons)) | ||
| 29 | for _, ic := range m.Icons { | 41 | for _, ic := range m.Icons { | ||
| 30 | a, ok := srv.assets.byName[strings.TrimPrefix(ic.Src, StaticPrefix)] | 42 | a, ok := srv.assets.byName[strings.TrimPrefix(ic.Src, StaticPrefix)] | ||
| @@ -34,7 +46,67 @@ func TestWebAppManifest(t *testing.T) { | |||||
| 34 | _, ok = srv.assets.byName[strings.TrimPrefix(u.AppleIcon, StaticPrefix)] | 46 | _, ok = srv.assets.byName[strings.TrimPrefix(u.AppleIcon, StaticPrefix)] | ||
| 35 | assert.True(t, ok) | 47 | assert.True(t, ok) | ||
| 36 | 48 | ||||
| 49 | + | // Chrome asks for a 192 and a 512 icon with purpose any. | |||
| 50 | + | var any192, any512 bool | |||
| 51 | + | for _, ic := range m.Icons { | |||
| 52 | + | any192 = any192 || (ic.Sizes == "192x192" && ic.Purpose == "any") | |||
| 53 | + | any512 = any512 || (ic.Sizes == "512x512" && ic.Purpose == "any") | |||
| 54 | + | } | |||
| 55 | + | assert.True(t, any192 && any512) | |||
| 56 | + | ||||
| 37 | page := renderToString(t, Shell(Layout{Assets: u}, nil)) | 57 | page := renderToString(t, Shell(Layout{Assets: u}, nil)) | ||
| 38 | assert.Contains(t, page, `<link rel="manifest" href="`+u.Manifest+`">`) | 58 | assert.Contains(t, page, `<link rel="manifest" href="`+u.Manifest+`">`) | ||
| 39 | assert.Contains(t, page, `<link rel="apple-touch-icon" href="`+u.AppleIcon+`">`) | 59 | assert.Contains(t, page, `<link rel="apple-touch-icon" href="`+u.AppleIcon+`">`) | ||
| 60 | + | assert.Contains(t, page, `data-install`) | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | // The icons are exactly the size the manifest declares: Chrome does not | |||
| 64 | + | // install from an icon that is smaller than it says. | |||
| 65 | + | func TestAppIconSizes(t *testing.T) { | |||
| 66 | + | t.Parallel() | |||
| 67 | + | for _, ic := range appIcons { | |||
| 68 | + | b, err := staticFS.ReadFile("static/app/" + ic.file) | |||
| 69 | + | require.NoError(t, err) | |||
| 70 | + | cfg, err := png.DecodeConfig(bytes.NewReader(b)) | |||
| 71 | + | require.NoError(t, err, ic.file) | |||
| 72 | + | assert.Equal(t, ic.sizes, fmt.Sprintf("%dx%d", cfg.Width, cfg.Height), ic.file) | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | // The service worker is served from the root scope, is revalidated on each | |||
| 77 | + | // check, and answers a conditional request with 304. | |||
| 78 | + | func TestServiceWorkerRoute(t *testing.T) { | |||
| 79 | + | t.Parallel() | |||
| 80 | + | srv := newBareServer(t) | |||
| 81 | + | get := func(inm string) *httptest.ResponseRecorder { | |||
| 82 | + | rec := httptest.NewRecorder() | |||
| 83 | + | r := httptest.NewRequestWithContext(context.Background(), http.MethodGet, ServiceWorkerPath, nil) | |||
| 84 | + | if inm != "" { | |||
| 85 | + | r.Header.Set("If-None-Match", inm) | |||
| 86 | + | } | |||
| 87 | + | srv.ServeHTTP(rec, r) | |||
| 88 | + | return rec | |||
| 89 | + | } | |||
| 90 | + | rec := get("") | |||
| 91 | + | require.Equal(t, http.StatusOK, rec.Code) | |||
| 92 | + | h := rec.Header() | |||
| 93 | + | assert.Equal(t, "text/javascript; charset=utf-8", h.Get("Content-Type")) | |||
| 94 | + | assert.Equal(t, "no-cache", h.Get("Cache-Control"), "a cached worker would never update") | |||
| 95 | + | assert.Equal(t, "/", h.Get("Service-Worker-Allowed")) | |||
| 96 | + | assert.NotEmpty(t, h.Get("ETag")) | |||
| 97 | + | assert.Contains(t, rec.Body.String(), "addEventListener('fetch'") | |||
| 98 | + | assert.NotContains(t, rec.Body.String(), "caches.", "nothing authenticated is stored") | |||
| 99 | + | ||||
| 100 | + | again := get(h.Get("ETag")) | |||
| 101 | + | assert.Equal(t, http.StatusNotModified, again.Code) | |||
| 102 | + | ||||
| 103 | + | // The registered script is the one served. | |||
| 104 | + | assert.Contains(t, staticJS(t, "ermite.js"), "register('"+ServiceWorkerPath+"'") | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func staticJS(t *testing.T, name string) string { | |||
| 108 | + | t.Helper() | |||
| 109 | + | b, err := staticFS.ReadFile("static/js/" + name) | |||
| 110 | + | require.NoError(t, err) | |||
| 111 | + | return string(b) | |||
| 40 | } | 112 | } | ||
A internal/adapters/web/serviceworker.go +47 -0
| @@ -0,0 +1,47 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "crypto/sha256" | |
| 8 | + | "encoding/hex" | |
| 9 | + | "net/http" | |
| 10 | + | "time" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // ServiceWorkerPath is where the service worker of the installed app is | |
| 14 | + | // served. Its default scope would be "/-/"; Service-Worker-Allowed widens it to | |
| 15 | + | // the whole site. It lives under /-/ (a name no repository can take) rather | |
| 16 | + | // than at /sw.js, which a repository could be named. | |
| 17 | + | const ServiceWorkerPath = "/-/sw.js" | |
| 18 | + | ||
| 19 | + | // serviceWorker is static/sw.js with its entity tag. | |
| 20 | + | type serviceWorker struct { | |
| 21 | + | body []byte | |
| 22 | + | etag string | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func loadServiceWorker() (serviceWorker, error) { | |
| 26 | + | b, err := staticFS.ReadFile("static/sw.js") | |
| 27 | + | if err != nil { | |
| 28 | + | return serviceWorker{}, err | |
| 29 | + | } | |
| 30 | + | sum := sha256.Sum256(b) | |
| 31 | + | return serviceWorker{body: b, etag: `"` + hex.EncodeToString(sum[:8]) + `"`}, nil | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // serve answers GET /-/sw.js. The script has no hash in its name (the browser | |
| 35 | + | // needs one address to look for a new version), so it is revalidated on every | |
| 36 | + | // check: no-cache plus an entity tag, which makes the check a 304. An answer | |
| 37 | + | // the session marked no-store (it clears a stale cookie) stays no-store. | |
| 38 | + | func (sw serviceWorker) serve(w http.ResponseWriter, r *http.Request) { | |
| 39 | + | h := w.Header() | |
| 40 | + | h.Set("Content-Type", "text/javascript; charset=utf-8") | |
| 41 | + | if h.Get("Cache-Control") != noStore { | |
| 42 | + | h.Set("Cache-Control", "no-cache") | |
| 43 | + | } | |
| 44 | + | h.Set("Service-Worker-Allowed", "/") | |
| 45 | + | h.Set("ETag", sw.etag) | |
| 46 | + | http.ServeContent(w, r, "", time.Time{}, bytes.NewReader(sw.body)) | |
| 47 | + | } | |
| @@ -0,0 +1,47 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "bytes" | |||
| 7 | + | "crypto/sha256" | |||
| 8 | + | "encoding/hex" | |||
| 9 | + | "net/http" | |||
| 10 | + | "time" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // ServiceWorkerPath is where the service worker of the installed app is | |||
| 14 | + | // served. Its default scope would be "/-/"; Service-Worker-Allowed widens it to | |||
| 15 | + | // the whole site. It lives under /-/ (a name no repository can take) rather | |||
| 16 | + | // than at /sw.js, which a repository could be named. | |||
| 17 | + | const ServiceWorkerPath = "/-/sw.js" | |||
| 18 | + | ||||
| 19 | + | // serviceWorker is static/sw.js with its entity tag. | |||
| 20 | + | type serviceWorker struct { | |||
| 21 | + | body []byte | |||
| 22 | + | etag string | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func loadServiceWorker() (serviceWorker, error) { | |||
| 26 | + | b, err := staticFS.ReadFile("static/sw.js") | |||
| 27 | + | if err != nil { | |||
| 28 | + | return serviceWorker{}, err | |||
| 29 | + | } | |||
| 30 | + | sum := sha256.Sum256(b) | |||
| 31 | + | return serviceWorker{body: b, etag: `"` + hex.EncodeToString(sum[:8]) + `"`}, nil | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // serve answers GET /-/sw.js. The script has no hash in its name (the browser | |||
| 35 | + | // needs one address to look for a new version), so it is revalidated on every | |||
| 36 | + | // check: no-cache plus an entity tag, which makes the check a 304. An answer | |||
| 37 | + | // the session marked no-store (it clears a stale cookie) stays no-store. | |||
| 38 | + | func (sw serviceWorker) serve(w http.ResponseWriter, r *http.Request) { | |||
| 39 | + | h := w.Header() | |||
| 40 | + | h.Set("Content-Type", "text/javascript; charset=utf-8") | |||
| 41 | + | if h.Get("Cache-Control") != noStore { | |||
| 42 | + | h.Set("Cache-Control", "no-cache") | |||
| 43 | + | } | |||
| 44 | + | h.Set("Service-Worker-Allowed", "/") | |||
| 45 | + | h.Set("ETag", sw.etag) | |||
| 46 | + | http.ServeContent(w, r, "", time.Time{}, bytes.NewReader(sw.body)) | |||
| 47 | + | } | |||
M internal/adapters/web/shell.templ +10 -0
| @@ -128,6 +128,16 @@ templ Header(l Layout) { | |||
| 128 | 128 | <span class="search-label">Go to…</span> | |
| 129 | 129 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 130 | 130 | </button> | |
| 131 | + | <button | |
| 132 | + | type="button" | |
| 133 | + | class="btn icon" | |
| 134 | + | data-install | |
| 135 | + | hidden | |
| 136 | + | aria-label="Install app" | |
| 137 | + | title="install ermite as an app" | |
| 138 | + | > | |
| 139 | + | <span class="ico i-download" aria-hidden="true"></span> | |
| 140 | + | </button> | |
| 131 | 141 | if SignedIn(ctx) { | |
| 132 | 142 | <form class="owner" method="post" action="/-/logout"> | |
| 133 | 143 | <span class="owner-badge" title="signed in as the owner"><span class="ico i-user" aria-hidden="true"></span>{ l.Owner }</span> | |
| @@ -128,6 +128,16 @@ templ Header(l Layout) { | |||||
| 128 | <span class="search-label">Go to…</span> | 128 | <span class="search-label">Go to…</span> | ||
| 129 | <kbd aria-hidden="true">ctrl k</kbd> | 129 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 130 | </button> | 130 | </button> | ||
| 131 | + | <button | |||
| 132 | + | type="button" | |||
| 133 | + | class="btn icon" | |||
| 134 | + | data-install | |||
| 135 | + | hidden | |||
| 136 | + | aria-label="Install app" | |||
| 137 | + | title="install ermite as an app" | |||
| 138 | + | > | |||
| 139 | + | <span class="ico i-download" aria-hidden="true"></span> | |||
| 140 | + | </button> | |||
| 131 | if SignedIn(ctx) { | 141 | if SignedIn(ctx) { | ||
| 132 | <form class="owner" method="post" action="/-/logout"> | 142 | <form class="owner" method="post" action="/-/logout"> | ||
| 133 | <span class="owner-badge" title="signed in as the owner"><span class="ico i-user" aria-hidden="true"></span>{ l.Owner }</span> | 143 | <span class="owner-badge" title="signed in as the owner"><span class="ico i-user" aria-hidden="true"></span>{ l.Owner }</span> | ||
M internal/adapters/web/shell_templ.go +11 -11 generated file: collapsed
M internal/adapters/web/static/css/20-shell.css +1 -0
| @@ -26,6 +26,7 @@ | |||
| 26 | 26 | .i-key { --i: url(icons/key.svg); } | |
| 27 | 27 | .i-log-in { --i: url(icons/log-in.svg); } | |
| 28 | 28 | .i-log-out { --i: url(icons/log-out.svg); } | |
| 29 | + | .i-download { --i: url(icons/download.svg); } | |
| 29 | 30 | ||
| 30 | 31 | .pill { | |
| 31 | 32 | display: inline-flex; align-items: center; height: 20px; padding: 0 var(--s2); margin-left: var(--s1); | |
| @@ -26,6 +26,7 @@ | |||||
| 26 | .i-key { --i: url(icons/key.svg); } | 26 | .i-key { --i: url(icons/key.svg); } | ||
| 27 | .i-log-in { --i: url(icons/log-in.svg); } | 27 | .i-log-in { --i: url(icons/log-in.svg); } | ||
| 28 | .i-log-out { --i: url(icons/log-out.svg); } | 28 | .i-log-out { --i: url(icons/log-out.svg); } | ||
| 29 | + | .i-download { --i: url(icons/download.svg); } | |||
| 29 | 30 | ||||
| 30 | .pill { | 31 | .pill { | ||
| 31 | display: inline-flex; align-items: center; height: 20px; padding: 0 var(--s2); margin-left: var(--s1); | 32 | display: inline-flex; align-items: center; height: 20px; padding: 0 var(--s2); margin-left: var(--s1); | ||
A internal/adapters/web/static/icons/download.svg +1 -0
| @@ -0,0 +1,1 @@ | |||
| 1 | + | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/></svg> | |
| @@ -0,0 +1,1 @@ | |||||
| 1 | + | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/></svg> | |||
M internal/adapters/web/static/js/ermite.js +65 -0
| @@ -11,6 +11,7 @@ | |||
| 11 | 11 | // 4. The file of the diff in view is marked in the table of contents. | |
| 12 | 12 | // 5. A handler that fails is shown in the hint badge. | |
| 13 | 13 | // 6. The lines a diff hides load in place (ermiteGaps; e and E). | |
| 14 | + | // 7. The service worker and the "install app" button (ermiteInstall). | |
| 14 | 15 | (() => { | |
| 15 | 16 | // Marks the page as scripted: what does nothing without scripts (the filters, | |
| 16 | 17 | // the "go to" and help buttons) is hidden until this class is set (see the | |
| @@ -306,6 +307,70 @@ | |||
| 306 | 307 | }; | |
| 307 | 308 | })(); | |
| 308 | 309 | ||
| 310 | + | // The installed app. The service worker (/-/sw.js) is registered once the page | |
| 311 | + | // has loaded. Chrome offers the install by itself only as an icon in the | |
| 312 | + | // address bar of a desktop (or a mini-bar on a phone): the page keeps the | |
| 313 | + | // beforeinstallprompt event and shows its own button ([data-install] in the | |
| 314 | + | // header), which asks the browser to prompt. The button stays hidden when the | |
| 315 | + | // app is already running installed or once it was installed. Self-contained: | |
| 316 | + | // jstest/install.test.mjs evaluates this block alone. | |
| 317 | + | window.ermiteInstall = (() => { | |
| 318 | + | let deferred = null; | |
| 319 | + | const buttons = () => $$('[data-install]'); | |
| 320 | + | const show = (on) => { for (const b of buttons()) b.hidden = !on; }; | |
| 321 | + | const installed = () => | |
| 322 | + | !!window.matchMedia?.('(display-mode: standalone), (display-mode: minimal-ui), (display-mode: window-controls-overlay)').matches || | |
| 323 | + | navigator.standalone === true; | |
| 324 | + | const api = { | |
| 325 | + | // register installs the service worker; a browser without one, or a | |
| 326 | + | // refusal, leaves the site working as before. | |
| 327 | + | register() { | |
| 328 | + | if (!navigator.serviceWorker) return Promise.resolve(null); | |
| 329 | + | return navigator.serviceWorker.register('/-/sw.js', { scope: '/' }).catch(() => null); | |
| 330 | + | }, | |
| 331 | + | // offer keeps the event the browser sent and shows the button. | |
| 332 | + | offer(e) { | |
| 333 | + | if (installed()) return; | |
| 334 | + | e.preventDefault(); | |
| 335 | + | deferred = e; | |
| 336 | + | show(true); | |
| 337 | + | }, | |
| 338 | + | // prompt asks the browser to show its install dialog. The event works | |
| 339 | + | // once: a new one comes if the visitor dismisses the dialog. The button | |
| 340 | + | // stays while the dialog is up; once it hides, the focus it held moves | |
| 341 | + | // to the search of the header rather than to the body. | |
| 342 | + | async prompt() { | |
| 343 | + | const e = deferred; | |
| 344 | + | if (!e) return ''; | |
| 345 | + | deferred = null; | |
| 346 | + | let outcome = ''; | |
| 347 | + | try { | |
| 348 | + | await e.prompt(); | |
| 349 | + | outcome = (await e.userChoice).outcome; | |
| 350 | + | } catch { | |
| 351 | + | outcome = ''; | |
| 352 | + | } | |
| 353 | + | const had = buttons().some((b) => b === document.activeElement); | |
| 354 | + | show(false); | |
| 355 | + | if (had) document.querySelector('[aria-controls="panel-cmd"]')?.focus?.(); | |
| 356 | + | return outcome; | |
| 357 | + | }, | |
| 358 | + | done() { | |
| 359 | + | deferred = null; | |
| 360 | + | show(false); | |
| 361 | + | }, | |
| 362 | + | }; | |
| 363 | + | addEventListener('beforeinstallprompt', api.offer); | |
| 364 | + | addEventListener('appinstalled', api.done); | |
| 365 | + | addEventListener('click', (e) => { | |
| 366 | + | // pending is the prompt the last click started (its outcome) | |
| 367 | + | if (e.target.closest?.('[data-install]')) api.pending = api.prompt(); | |
| 368 | + | }); | |
| 369 | + | if (document.readyState === 'complete') api.register(); | |
| 370 | + | else addEventListener('load', () => api.register(), { once: true }); | |
| 371 | + | return api; | |
| 372 | + | })(); | |
| 373 | + | ||
| 309 | 374 | // The hidden lines of a diff: a row .gap holds a button that loads them | |
| 310 | 375 | // (/context); the rows that come back replace the gap in the table of each | |
| 311 | 376 | // layout (the unified and the split tables hold one row each for it). A | |
| @@ -11,6 +11,7 @@ | |||||
| 11 | // 4. The file of the diff in view is marked in the table of contents. | 11 | // 4. The file of the diff in view is marked in the table of contents. | ||
| 12 | // 5. A handler that fails is shown in the hint badge. | 12 | // 5. A handler that fails is shown in the hint badge. | ||
| 13 | // 6. The lines a diff hides load in place (ermiteGaps; e and E). | 13 | // 6. The lines a diff hides load in place (ermiteGaps; e and E). | ||
| 14 | + | // 7. The service worker and the "install app" button (ermiteInstall). | |||
| 14 | (() => { | 15 | (() => { | ||
| 15 | // Marks the page as scripted: what does nothing without scripts (the filters, | 16 | // Marks the page as scripted: what does nothing without scripts (the filters, | ||
| 16 | // the "go to" and help buttons) is hidden until this class is set (see the | 17 | // the "go to" and help buttons) is hidden until this class is set (see the | ||
| @@ -306,6 +307,70 @@ | |||||
| 306 | }; | 307 | }; | ||
| 307 | })(); | 308 | })(); | ||
| 308 | 309 | ||||
| 310 | + | // The installed app. The service worker (/-/sw.js) is registered once the page | |||
| 311 | + | // has loaded. Chrome offers the install by itself only as an icon in the | |||
| 312 | + | // address bar of a desktop (or a mini-bar on a phone): the page keeps the | |||
| 313 | + | // beforeinstallprompt event and shows its own button ([data-install] in the | |||
| 314 | + | // header), which asks the browser to prompt. The button stays hidden when the | |||
| 315 | + | // app is already running installed or once it was installed. Self-contained: | |||
| 316 | + | // jstest/install.test.mjs evaluates this block alone. | |||
| 317 | + | window.ermiteInstall = (() => { | |||
| 318 | + | let deferred = null; | |||
| 319 | + | const buttons = () => $$('[data-install]'); | |||
| 320 | + | const show = (on) => { for (const b of buttons()) b.hidden = !on; }; | |||
| 321 | + | const installed = () => | |||
| 322 | + | !!window.matchMedia?.('(display-mode: standalone), (display-mode: minimal-ui), (display-mode: window-controls-overlay)').matches || | |||
| 323 | + | navigator.standalone === true; | |||
| 324 | + | const api = { | |||
| 325 | + | // register installs the service worker; a browser without one, or a | |||
| 326 | + | // refusal, leaves the site working as before. | |||
| 327 | + | register() { | |||
| 328 | + | if (!navigator.serviceWorker) return Promise.resolve(null); | |||
| 329 | + | return navigator.serviceWorker.register('/-/sw.js', { scope: '/' }).catch(() => null); | |||
| 330 | + | }, | |||
| 331 | + | // offer keeps the event the browser sent and shows the button. | |||
| 332 | + | offer(e) { | |||
| 333 | + | if (installed()) return; | |||
| 334 | + | e.preventDefault(); | |||
| 335 | + | deferred = e; | |||
| 336 | + | show(true); | |||
| 337 | + | }, | |||
| 338 | + | // prompt asks the browser to show its install dialog. The event works | |||
| 339 | + | // once: a new one comes if the visitor dismisses the dialog. The button | |||
| 340 | + | // stays while the dialog is up; once it hides, the focus it held moves | |||
| 341 | + | // to the search of the header rather than to the body. | |||
| 342 | + | async prompt() { | |||
| 343 | + | const e = deferred; | |||
| 344 | + | if (!e) return ''; | |||
| 345 | + | deferred = null; | |||
| 346 | + | let outcome = ''; | |||
| 347 | + | try { | |||
| 348 | + | await e.prompt(); | |||
| 349 | + | outcome = (await e.userChoice).outcome; | |||
| 350 | + | } catch { | |||
| 351 | + | outcome = ''; | |||
| 352 | + | } | |||
| 353 | + | const had = buttons().some((b) => b === document.activeElement); | |||
| 354 | + | show(false); | |||
| 355 | + | if (had) document.querySelector('[aria-controls="panel-cmd"]')?.focus?.(); | |||
| 356 | + | return outcome; | |||
| 357 | + | }, | |||
| 358 | + | done() { | |||
| 359 | + | deferred = null; | |||
| 360 | + | show(false); | |||
| 361 | + | }, | |||
| 362 | + | }; | |||
| 363 | + | addEventListener('beforeinstallprompt', api.offer); | |||
| 364 | + | addEventListener('appinstalled', api.done); | |||
| 365 | + | addEventListener('click', (e) => { | |||
| 366 | + | // pending is the prompt the last click started (its outcome) | |||
| 367 | + | if (e.target.closest?.('[data-install]')) api.pending = api.prompt(); | |||
| 368 | + | }); | |||
| 369 | + | if (document.readyState === 'complete') api.register(); | |||
| 370 | + | else addEventListener('load', () => api.register(), { once: true }); | |||
| 371 | + | return api; | |||
| 372 | + | })(); | |||
| 373 | + | ||||
| 309 | // The hidden lines of a diff: a row .gap holds a button that loads them | 374 | // The hidden lines of a diff: a row .gap holds a button that loads them | ||
| 310 | // (/context); the rows that come back replace the gap in the table of each | 375 | // (/context); the rows that come back replace the gap in the table of each | ||
| 311 | // layout (the unified and the split tables hold one row each for it). A | 376 | // layout (the unified and the split tables hold one row each for it). A | ||
A internal/adapters/web/static/sw.js +54 -0
| @@ -0,0 +1,54 @@ | |||
| 1 | + | // sw.js: the service worker of the installed app, served at /-/sw.js with | |
| 2 | + | // Service-Worker-Allowed: / (see serviceworker.go). | |
| 3 | + | // | |
| 4 | + | // Browsers want an installed app to have a service worker with a fetch handler | |
| 5 | + | // (Chrome on Android mints a WebAPK for such sites, and drops an empty handler | |
| 6 | + | // as if there were none). A forge has nothing to cache: pages are rendered per | |
| 7 | + | // caller (the owner sees private repositories), diffs and logs are live. So | |
| 8 | + | // this worker stores nothing. A page navigation goes to the network as it would | |
| 9 | + | // without the worker; only when the network is unreachable is the visitor shown | |
| 10 | + | // a short offline page instead of the browser's error. Every other request | |
| 11 | + | // (assets, the event stream, Git, POSTs) is left to the browser: the handler | |
| 12 | + | // does not answer it. | |
| 13 | + | // | |
| 14 | + | // The browser fetches this file again on each navigation (at least once a day) | |
| 15 | + | // and installs the new version when a single byte differs; edit it to ship a | |
| 16 | + | // new version. The worker takes over at once (skipWaiting, claim): it holds no | |
| 17 | + | // state. | |
| 18 | + | 'use strict'; | |
| 19 | + | ||
| 20 | + | const OFFLINE = `<!doctype html> | |
| 21 | + | <html lang="en"><head><meta charset="utf-8"> | |
| 22 | + | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 23 | + | <meta name="color-scheme" content="light dark"> | |
| 24 | + | <title>ermite is unreachable</title></head> | |
| 25 | + | <body style="font: 16px/1.5 system-ui, sans-serif; max-width: 32rem; margin: 20vh auto; padding: 0 1rem"> | |
| 26 | + | <h1 style="font-size: 1.25rem">ermite is unreachable</h1> | |
| 27 | + | <p>The server does not answer. Check the connection, then reload.</p> | |
| 28 | + | </body></html>`; | |
| 29 | + | ||
| 30 | + | self.addEventListener('install', () => self.skipWaiting()); | |
| 31 | + | ||
| 32 | + | self.addEventListener('activate', (event) => { | |
| 33 | + | // Navigation preload starts the page request while the worker boots, so the | |
| 34 | + | // worker adds no latency to a navigation. | |
| 35 | + | event.waitUntil(Promise.all([ | |
| 36 | + | self.registration.navigationPreload?.enable(), | |
| 37 | + | self.clients.claim(), | |
| 38 | + | ])); | |
| 39 | + | }); | |
| 40 | + | ||
| 41 | + | self.addEventListener('fetch', (event) => { | |
| 42 | + | const req = event.request; | |
| 43 | + | if (req.mode !== 'navigate' || req.method !== 'GET') return; | |
| 44 | + | event.respondWith((async () => { | |
| 45 | + | try { | |
| 46 | + | return (await event.preloadResponse) || (await fetch(req)); | |
| 47 | + | } catch { | |
| 48 | + | return new Response(OFFLINE, { | |
| 49 | + | status: 503, | |
| 50 | + | headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }, | |
| 51 | + | }); | |
| 52 | + | } | |
| 53 | + | })()); | |
| 54 | + | }); | |
| @@ -0,0 +1,54 @@ | |||||
| 1 | + | // sw.js: the service worker of the installed app, served at /-/sw.js with | |||
| 2 | + | // Service-Worker-Allowed: / (see serviceworker.go). | |||
| 3 | + | // | |||
| 4 | + | // Browsers want an installed app to have a service worker with a fetch handler | |||
| 5 | + | // (Chrome on Android mints a WebAPK for such sites, and drops an empty handler | |||
| 6 | + | // as if there were none). A forge has nothing to cache: pages are rendered per | |||
| 7 | + | // caller (the owner sees private repositories), diffs and logs are live. So | |||
| 8 | + | // this worker stores nothing. A page navigation goes to the network as it would | |||
| 9 | + | // without the worker; only when the network is unreachable is the visitor shown | |||
| 10 | + | // a short offline page instead of the browser's error. Every other request | |||
| 11 | + | // (assets, the event stream, Git, POSTs) is left to the browser: the handler | |||
| 12 | + | // does not answer it. | |||
| 13 | + | // | |||
| 14 | + | // The browser fetches this file again on each navigation (at least once a day) | |||
| 15 | + | // and installs the new version when a single byte differs; edit it to ship a | |||
| 16 | + | // new version. The worker takes over at once (skipWaiting, claim): it holds no | |||
| 17 | + | // state. | |||
| 18 | + | 'use strict'; | |||
| 19 | + | ||||
| 20 | + | const OFFLINE = `<!doctype html> | |||
| 21 | + | <html lang="en"><head><meta charset="utf-8"> | |||
| 22 | + | <meta name="viewport" content="width=device-width, initial-scale=1"> | |||
| 23 | + | <meta name="color-scheme" content="light dark"> | |||
| 24 | + | <title>ermite is unreachable</title></head> | |||
| 25 | + | <body style="font: 16px/1.5 system-ui, sans-serif; max-width: 32rem; margin: 20vh auto; padding: 0 1rem"> | |||
| 26 | + | <h1 style="font-size: 1.25rem">ermite is unreachable</h1> | |||
| 27 | + | <p>The server does not answer. Check the connection, then reload.</p> | |||
| 28 | + | </body></html>`; | |||
| 29 | + | ||||
| 30 | + | self.addEventListener('install', () => self.skipWaiting()); | |||
| 31 | + | ||||
| 32 | + | self.addEventListener('activate', (event) => { | |||
| 33 | + | // Navigation preload starts the page request while the worker boots, so the | |||
| 34 | + | // worker adds no latency to a navigation. | |||
| 35 | + | event.waitUntil(Promise.all([ | |||
| 36 | + | self.registration.navigationPreload?.enable(), | |||
| 37 | + | self.clients.claim(), | |||
| 38 | + | ])); | |||
| 39 | + | }); | |||
| 40 | + | ||||
| 41 | + | self.addEventListener('fetch', (event) => { | |||
| 42 | + | const req = event.request; | |||
| 43 | + | if (req.mode !== 'navigate' || req.method !== 'GET') return; | |||
| 44 | + | event.respondWith((async () => { | |||
| 45 | + | try { | |||
| 46 | + | return (await event.preloadResponse) || (await fetch(req)); | |||
| 47 | + | } catch { | |||
| 48 | + | return new Response(OFFLINE, { | |||
| 49 | + | status: 503, | |||
| 50 | + | headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }, | |||
| 51 | + | }); | |||
| 52 | + | } | |||
| 53 | + | })()); | |||
| 54 | + | }); | |||
M internal/adapters/web/web.go +1 -0
| @@ -152,6 +152,7 @@ func (s *Server) routes() *http.ServeMux { | |||
| 152 | 152 | mux := http.NewServeMux() | |
| 153 | 153 | mux.HandleFunc("GET "+StaticPrefix, s.assets.serve) | |
| 154 | 154 | mux.HandleFunc("GET /-/events", s.events) | |
| 155 | + | mux.HandleFunc("GET "+ServiceWorkerPath, s.assets.sw.serve) | |
| 155 | 156 | if s.cfg.Sessions != nil { | |
| 156 | 157 | mux.HandleFunc("GET "+loginPrefix+"{nonce}", s.loginConfirm) | |
| 157 | 158 | mux.HandleFunc("POST "+loginPrefix+"{nonce}", s.loginPost) | |
| @@ -152,6 +152,7 @@ func (s *Server) routes() *http.ServeMux { | |||||
| 152 | mux := http.NewServeMux() | 152 | mux := http.NewServeMux() | ||
| 153 | mux.HandleFunc("GET "+StaticPrefix, s.assets.serve) | 153 | mux.HandleFunc("GET "+StaticPrefix, s.assets.serve) | ||
| 154 | mux.HandleFunc("GET /-/events", s.events) | 154 | mux.HandleFunc("GET /-/events", s.events) | ||
| 155 | + | mux.HandleFunc("GET "+ServiceWorkerPath, s.assets.sw.serve) | |||
| 155 | if s.cfg.Sessions != nil { | 156 | if s.cfg.Sessions != nil { | ||
| 156 | mux.HandleFunc("GET "+loginPrefix+"{nonce}", s.loginConfirm) | 157 | mux.HandleFunc("GET "+loginPrefix+"{nonce}", s.loginConfirm) | ||
| 157 | mux.HandleFunc("POST "+loginPrefix+"{nonce}", s.loginPost) | 158 | mux.HandleFunc("POST "+loginPrefix+"{nonce}", s.loginPost) | ||
M internal/app/testdata/web/blob-binary.html +4 -0
| @@ -66,6 +66,10 @@ | |||
| 66 | 66 | <span class="search-label">Go to…</span> | |
| 67 | 67 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 68 | 68 | </button> | |
| 69 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |
| 70 | + | <span class="ico i-download" aria-hidden="true"> | |
| 71 | + | </span> | |
| 72 | + | </button> | |
| 69 | 73 | </div> | |
| 70 | 74 | </div> | |
| 71 | 75 | <nav class="tabs" aria-label="Sections"> | |
| @@ -66,6 +66,10 @@ | |||||
| 66 | <span class="search-label">Go to…</span> | 66 | <span class="search-label">Go to…</span> | ||
| 67 | <kbd aria-hidden="true">ctrl k</kbd> | 67 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 68 | </button> | 68 | </button> | ||
| 69 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |||
| 70 | + | <span class="ico i-download" aria-hidden="true"> | |||
| 71 | + | </span> | |||
| 72 | + | </button> | |||
| 69 | </div> | 73 | </div> | ||
| 70 | </div> | 74 | </div> | ||
| 71 | <nav class="tabs" aria-label="Sections"> | 75 | <nav class="tabs" aria-label="Sections"> | ||
M internal/app/testdata/web/blob-code.html +4 -0
| @@ -66,6 +66,10 @@ | |||
| 66 | 66 | <span class="search-label">Go to…</span> | |
| 67 | 67 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 68 | 68 | </button> | |
| 69 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |
| 70 | + | <span class="ico i-download" aria-hidden="true"> | |
| 71 | + | </span> | |
| 72 | + | </button> | |
| 69 | 73 | </div> | |
| 70 | 74 | </div> | |
| 71 | 75 | <nav class="tabs" aria-label="Sections"> | |
| @@ -66,6 +66,10 @@ | |||||
| 66 | <span class="search-label">Go to…</span> | 66 | <span class="search-label">Go to…</span> | ||
| 67 | <kbd aria-hidden="true">ctrl k</kbd> | 67 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 68 | </button> | 68 | </button> | ||
| 69 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |||
| 70 | + | <span class="ico i-download" aria-hidden="true"> | |||
| 71 | + | </span> | |||
| 72 | + | </button> | |||
| 69 | </div> | 73 | </div> | ||
| 70 | </div> | 74 | </div> | ||
| 71 | <nav class="tabs" aria-label="Sections"> | 75 | <nav class="tabs" aria-label="Sections"> | ||
M internal/app/testdata/web/blob-empty.html +4 -0
| @@ -66,6 +66,10 @@ | |||
| 66 | 66 | <span class="search-label">Go to…</span> | |
| 67 | 67 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 68 | 68 | </button> | |
| 69 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |
| 70 | + | <span class="ico i-download" aria-hidden="true"> | |
| 71 | + | </span> | |
| 72 | + | </button> | |
| 69 | 73 | </div> | |
| 70 | 74 | </div> | |
| 71 | 75 | <nav class="tabs" aria-label="Sections"> | |
| @@ -66,6 +66,10 @@ | |||||
| 66 | <span class="search-label">Go to…</span> | 66 | <span class="search-label">Go to…</span> | ||
| 67 | <kbd aria-hidden="true">ctrl k</kbd> | 67 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 68 | </button> | 68 | </button> | ||
| 69 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |||
| 70 | + | <span class="ico i-download" aria-hidden="true"> | |||
| 71 | + | </span> | |||
| 72 | + | </button> | |||
| 69 | </div> | 73 | </div> | ||
| 70 | </div> | 74 | </div> | ||
| 71 | <nav class="tabs" aria-label="Sections"> | 75 | <nav class="tabs" aria-label="Sections"> | ||
M internal/app/testdata/web/blob-image.html +4 -0
| @@ -66,6 +66,10 @@ | |||
| 66 | 66 | <span class="search-label">Go to…</span> | |
| 67 | 67 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 68 | 68 | </button> | |
| 69 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |
| 70 | + | <span class="ico i-download" aria-hidden="true"> | |
| 71 | + | </span> | |
| 72 | + | </button> | |
| 69 | 73 | </div> | |
| 70 | 74 | </div> | |
| 71 | 75 | <nav class="tabs" aria-label="Sections"> | |
| @@ -66,6 +66,10 @@ | |||||
| 66 | <span class="search-label">Go to…</span> | 66 | <span class="search-label">Go to…</span> | ||
| 67 | <kbd aria-hidden="true">ctrl k</kbd> | 67 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 68 | </button> | 68 | </button> | ||
| 69 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |||
| 70 | + | <span class="ico i-download" aria-hidden="true"> | |||
| 71 | + | </span> | |||
| 72 | + | </button> | |||
| 69 | </div> | 73 | </div> | ||
| 70 | </div> | 74 | </div> | ||
| 71 | <nav class="tabs" aria-label="Sections"> | 75 | <nav class="tabs" aria-label="Sections"> | ||
M internal/app/testdata/web/blob-markdown.html +4 -0
| @@ -69,6 +69,10 @@ | |||
| 69 | 69 | <span class="search-label">Go to…</span> | |
| 70 | 70 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 71 | 71 | </button> | |
| 72 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |
| 73 | + | <span class="ico i-download" aria-hidden="true"> | |
| 74 | + | </span> | |
| 75 | + | </button> | |
| 72 | 76 | </div> | |
| 73 | 77 | </div> | |
| 74 | 78 | <nav class="tabs" aria-label="Sections"> | |
| @@ -69,6 +69,10 @@ | |||||
| 69 | <span class="search-label">Go to…</span> | 69 | <span class="search-label">Go to…</span> | ||
| 70 | <kbd aria-hidden="true">ctrl k</kbd> | 70 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 71 | </button> | 71 | </button> | ||
| 72 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |||
| 73 | + | <span class="ico i-download" aria-hidden="true"> | |||
| 74 | + | </span> | |||
| 75 | + | </button> | |||
| 72 | </div> | 76 | </div> | ||
| 73 | </div> | 77 | </div> | ||
| 74 | <nav class="tabs" aria-label="Sections"> | 78 | <nav class="tabs" aria-label="Sections"> | ||
M internal/app/testdata/web/blob-svg.html +4 -0
| @@ -66,6 +66,10 @@ | |||
| 66 | 66 | <span class="search-label">Go to…</span> | |
| 67 | 67 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 68 | 68 | </button> | |
| 69 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |
| 70 | + | <span class="ico i-download" aria-hidden="true"> | |
| 71 | + | </span> | |
| 72 | + | </button> | |
| 69 | 73 | </div> | |
| 70 | 74 | </div> | |
| 71 | 75 | <nav class="tabs" aria-label="Sections"> | |
| @@ -66,6 +66,10 @@ | |||||
| 66 | <span class="search-label">Go to…</span> | 66 | <span class="search-label">Go to…</span> | ||
| 67 | <kbd aria-hidden="true">ctrl k</kbd> | 67 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 68 | </button> | 68 | </button> | ||
| 69 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |||
| 70 | + | <span class="ico i-download" aria-hidden="true"> | |||
| 71 | + | </span> | |||
| 72 | + | </button> | |||
| 69 | </div> | 73 | </div> | ||
| 70 | </div> | 74 | </div> | ||
| 71 | <nav class="tabs" aria-label="Sections"> | 75 | <nav class="tabs" aria-label="Sections"> | ||
M internal/app/testdata/web/blob-text.html +4 -0
| @@ -66,6 +66,10 @@ | |||
| 66 | 66 | <span class="search-label">Go to…</span> | |
| 67 | 67 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 68 | 68 | </button> | |
| 69 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |
| 70 | + | <span class="ico i-download" aria-hidden="true"> | |
| 71 | + | </span> | |
| 72 | + | </button> | |
| 69 | 73 | </div> | |
| 70 | 74 | </div> | |
| 71 | 75 | <nav class="tabs" aria-label="Sections"> | |
| @@ -66,6 +66,10 @@ | |||||
| 66 | <span class="search-label">Go to…</span> | 66 | <span class="search-label">Go to…</span> | ||
| 67 | <kbd aria-hidden="true">ctrl k</kbd> | 67 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 68 | </button> | 68 | </button> | ||
| 69 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |||
| 70 | + | <span class="ico i-download" aria-hidden="true"> | |||
| 71 | + | </span> | |||
| 72 | + | </button> | |||
| 69 | </div> | 73 | </div> | ||
| 70 | </div> | 74 | </div> | ||
| 71 | <nav class="tabs" aria-label="Sections"> | 75 | <nav class="tabs" aria-label="Sections"> | ||
M internal/app/testdata/web/blob-too-large.html +4 -0
| @@ -66,6 +66,10 @@ | |||
| 66 | 66 | <span class="search-label">Go to…</span> | |
| 67 | 67 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 68 | 68 | </button> | |
| 69 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |
| 70 | + | <span class="ico i-download" aria-hidden="true"> | |
| 71 | + | </span> | |
| 72 | + | </button> | |
| 69 | 73 | </div> | |
| 70 | 74 | </div> | |
| 71 | 75 | <nav class="tabs" aria-label="Sections"> | |
| @@ -66,6 +66,10 @@ | |||||
| 66 | <span class="search-label">Go to…</span> | 66 | <span class="search-label">Go to…</span> | ||
| 67 | <kbd aria-hidden="true">ctrl k</kbd> | 67 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 68 | </button> | 68 | </button> | ||
| 69 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |||
| 70 | + | <span class="ico i-download" aria-hidden="true"> | |||
| 71 | + | </span> | |||
| 72 | + | </button> | |||
| 69 | </div> | 73 | </div> | ||
| 70 | </div> | 74 | </div> | ||
| 71 | <nav class="tabs" aria-label="Sections"> | 75 | <nav class="tabs" aria-label="Sections"> | ||
M internal/app/testdata/web/blob-trojan.html +4 -0
| @@ -66,6 +66,10 @@ | |||
| 66 | 66 | <span class="search-label">Go to…</span> | |
| 67 | 67 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 68 | 68 | </button> | |
| 69 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |
| 70 | + | <span class="ico i-download" aria-hidden="true"> | |
| 71 | + | </span> | |
| 72 | + | </button> | |
| 69 | 73 | </div> | |
| 70 | 74 | </div> | |
| 71 | 75 | <nav class="tabs" aria-label="Sections"> | |
| @@ -66,6 +66,10 @@ | |||||
| 66 | <span class="search-label">Go to…</span> | 66 | <span class="search-label">Go to…</span> | ||
| 67 | <kbd aria-hidden="true">ctrl k</kbd> | 67 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 68 | </button> | 68 | </button> | ||
| 69 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |||
| 70 | + | <span class="ico i-download" aria-hidden="true"> | |||
| 71 | + | </span> | |||
| 72 | + | </button> | |||
| 69 | </div> | 73 | </div> | ||
| 70 | </div> | 74 | </div> | ||
| 71 | <nav class="tabs" aria-label="Sections"> | 75 | <nav class="tabs" aria-label="Sections"> | ||
M internal/app/testdata/web/commit-big.html +4 -0
| @@ -63,6 +63,10 @@ | |||
| 63 | 63 | <span class="search-label">Go to…</span> | |
| 64 | 64 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 65 | 65 | </button> | |
| 66 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |
| 67 | + | <span class="ico i-download" aria-hidden="true"> | |
| 68 | + | </span> | |
| 69 | + | </button> | |
| 66 | 70 | </div> | |
| 67 | 71 | </div> | |
| 68 | 72 | <nav class="tabs" aria-label="Sections"> | |
| @@ -63,6 +63,10 @@ | |||||
| 63 | <span class="search-label">Go to…</span> | 63 | <span class="search-label">Go to…</span> | ||
| 64 | <kbd aria-hidden="true">ctrl k</kbd> | 64 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 65 | </button> | 65 | </button> | ||
| 66 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |||
| 67 | + | <span class="ico i-download" aria-hidden="true"> | |||
| 68 | + | </span> | |||
| 69 | + | </button> | |||
| 66 | </div> | 70 | </div> | ||
| 67 | </div> | 71 | </div> | ||
| 68 | <nav class="tabs" aria-label="Sections"> | 72 | <nav class="tabs" aria-label="Sections"> | ||
M internal/app/testdata/web/commit-binary.html +4 -0
| @@ -63,6 +63,10 @@ | |||
| 63 | 63 | <span class="search-label">Go to…</span> | |
| 64 | 64 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 65 | 65 | </button> | |
| 66 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |
| 67 | + | <span class="ico i-download" aria-hidden="true"> | |
| 68 | + | </span> | |
| 69 | + | </button> | |
| 66 | 70 | </div> | |
| 67 | 71 | </div> | |
| 68 | 72 | <nav class="tabs" aria-label="Sections"> | |
| @@ -63,6 +63,10 @@ | |||||
| 63 | <span class="search-label">Go to…</span> | 63 | <span class="search-label">Go to…</span> | ||
| 64 | <kbd aria-hidden="true">ctrl k</kbd> | 64 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 65 | </button> | 65 | </button> | ||
| 66 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |||
| 67 | + | <span class="ico i-download" aria-hidden="true"> | |||
| 68 | + | </span> | |||
| 69 | + | </button> | |||
| 66 | </div> | 70 | </div> | ||
| 67 | </div> | 71 | </div> | ||
| 68 | <nav class="tabs" aria-label="Sections"> | 72 | <nav class="tabs" aria-label="Sections"> | ||
M internal/app/testdata/web/commit-merge.html +4 -0
| @@ -63,6 +63,10 @@ | |||
| 63 | 63 | <span class="search-label">Go to…</span> | |
| 64 | 64 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 65 | 65 | </button> | |
| 66 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |
| 67 | + | <span class="ico i-download" aria-hidden="true"> | |
| 68 | + | </span> | |
| 69 | + | </button> | |
| 66 | 70 | </div> | |
| 67 | 71 | </div> | |
| 68 | 72 | <nav class="tabs" aria-label="Sections"> | |
| @@ -63,6 +63,10 @@ | |||||
| 63 | <span class="search-label">Go to…</span> | 63 | <span class="search-label">Go to…</span> | ||
| 64 | <kbd aria-hidden="true">ctrl k</kbd> | 64 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 65 | </button> | 65 | </button> | ||
| 66 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |||
| 67 | + | <span class="ico i-download" aria-hidden="true"> | |||
| 68 | + | </span> | |||
| 69 | + | </button> | |||
| 66 | </div> | 70 | </div> | ||
| 67 | </div> | 71 | </div> | ||
| 68 | <nav class="tabs" aria-label="Sections"> | 72 | <nav class="tabs" aria-label="Sections"> | ||
M internal/app/testdata/web/commit-modify-split.html +4 -0
| @@ -63,6 +63,10 @@ | |||
| 63 | 63 | <span class="search-label">Go to…</span> | |
| 64 | 64 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 65 | 65 | </button> | |
| 66 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |
| 67 | + | <span class="ico i-download" aria-hidden="true"> | |
| 68 | + | </span> | |
| 69 | + | </button> | |
| 66 | 70 | </div> | |
| 67 | 71 | </div> | |
| 68 | 72 | <nav class="tabs" aria-label="Sections"> | |
| @@ -63,6 +63,10 @@ | |||||
| 63 | <span class="search-label">Go to…</span> | 63 | <span class="search-label">Go to…</span> | ||
| 64 | <kbd aria-hidden="true">ctrl k</kbd> | 64 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 65 | </button> | 65 | </button> | ||
| 66 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |||
| 67 | + | <span class="ico i-download" aria-hidden="true"> | |||
| 68 | + | </span> | |||
| 69 | + | </button> | |||
| 66 | </div> | 70 | </div> | ||
| 67 | </div> | 71 | </div> | ||
| 68 | <nav class="tabs" aria-label="Sections"> | 72 | <nav class="tabs" aria-label="Sections"> | ||
M internal/app/testdata/web/commit-modify.html +4 -0
| @@ -63,6 +63,10 @@ | |||
| 63 | 63 | <span class="search-label">Go to…</span> | |
| 64 | 64 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 65 | 65 | </button> | |
| 66 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |
| 67 | + | <span class="ico i-download" aria-hidden="true"> | |
| 68 | + | </span> | |
| 69 | + | </button> | |
| 66 | 70 | </div> | |
| 67 | 71 | </div> | |
| 68 | 72 | <nav class="tabs" aria-label="Sections"> | |
| @@ -63,6 +63,10 @@ | |||||
| 63 | <span class="search-label">Go to…</span> | 63 | <span class="search-label">Go to…</span> | ||
| 64 | <kbd aria-hidden="true">ctrl k</kbd> | 64 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 65 | </button> | 65 | </button> | ||
| 66 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |||
| 67 | + | <span class="ico i-download" aria-hidden="true"> | |||
| 68 | + | </span> | |||
| 69 | + | </button> | |||
| 66 | </div> | 70 | </div> | ||
| 67 | </div> | 71 | </div> | ||
| 68 | <nav class="tabs" aria-label="Sections"> | 72 | <nav class="tabs" aria-label="Sections"> | ||
M internal/app/testdata/web/commit-rename.html +4 -0
| @@ -63,6 +63,10 @@ | |||
| 63 | 63 | <span class="search-label">Go to…</span> | |
| 64 | 64 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 65 | 65 | </button> | |
| 66 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |
| 67 | + | <span class="ico i-download" aria-hidden="true"> | |
| 68 | + | </span> | |
| 69 | + | </button> | |
| 66 | 70 | </div> | |
| 67 | 71 | </div> | |
| 68 | 72 | <nav class="tabs" aria-label="Sections"> | |
| @@ -63,6 +63,10 @@ | |||||
| 63 | <span class="search-label">Go to…</span> | 63 | <span class="search-label">Go to…</span> | ||
| 64 | <kbd aria-hidden="true">ctrl k</kbd> | 64 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 65 | </button> | 65 | </button> | ||
| 66 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |||
| 67 | + | <span class="ico i-download" aria-hidden="true"> | |||
| 68 | + | </span> | |||
| 69 | + | </button> | |||
| 66 | </div> | 70 | </div> | ||
| 67 | </div> | 71 | </div> | ||
| 68 | <nav class="tabs" aria-label="Sections"> | 72 | <nav class="tabs" aria-label="Sections"> | ||
M internal/app/testdata/web/commit-root.html +4 -0
| @@ -63,6 +63,10 @@ | |||
| 63 | 63 | <span class="search-label">Go to…</span> | |
| 64 | 64 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 65 | 65 | </button> | |
| 66 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |
| 67 | + | <span class="ico i-download" aria-hidden="true"> | |
| 68 | + | </span> | |
| 69 | + | </button> | |
| 66 | 70 | </div> | |
| 67 | 71 | </div> | |
| 68 | 72 | <nav class="tabs" aria-label="Sections"> | |
| @@ -63,6 +63,10 @@ | |||||
| 63 | <span class="search-label">Go to…</span> | 63 | <span class="search-label">Go to…</span> | ||
| 64 | <kbd aria-hidden="true">ctrl k</kbd> | 64 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 65 | </button> | 65 | </button> | ||
| 66 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |||
| 67 | + | <span class="ico i-download" aria-hidden="true"> | |||
| 68 | + | </span> | |||
| 69 | + | </button> | |||
| 66 | </div> | 70 | </div> | ||
| 67 | </div> | 71 | </div> | ||
| 68 | <nav class="tabs" aria-label="Sections"> | 72 | <nav class="tabs" aria-label="Sections"> | ||
M internal/app/testdata/web/commit-trojan.html +4 -0
| @@ -63,6 +63,10 @@ | |||
| 63 | 63 | <span class="search-label">Go to…</span> | |
| 64 | 64 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 65 | 65 | </button> | |
| 66 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |
| 67 | + | <span class="ico i-download" aria-hidden="true"> | |
| 68 | + | </span> | |
| 69 | + | </button> | |
| 66 | 70 | </div> | |
| 67 | 71 | </div> | |
| 68 | 72 | <nav class="tabs" aria-label="Sections"> | |
| @@ -63,6 +63,10 @@ | |||||
| 63 | <span class="search-label">Go to…</span> | 63 | <span class="search-label">Go to…</span> | ||
| 64 | <kbd aria-hidden="true">ctrl k</kbd> | 64 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 65 | </button> | 65 | </button> | ||
| 66 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |||
| 67 | + | <span class="ico i-download" aria-hidden="true"> | |||
| 68 | + | </span> | |||
| 69 | + | </button> | |||
| 66 | </div> | 70 | </div> | ||
| 67 | </div> | 71 | </div> | ||
| 68 | <nav class="tabs" aria-label="Sections"> | 72 | <nav class="tabs" aria-label="Sections"> | ||
M internal/app/testdata/web/commits-page2.html +4 -0
| @@ -63,6 +63,10 @@ | |||
| 63 | 63 | <span class="search-label">Go to…</span> | |
| 64 | 64 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 65 | 65 | </button> | |
| 66 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |
| 67 | + | <span class="ico i-download" aria-hidden="true"> | |
| 68 | + | </span> | |
| 69 | + | </button> | |
| 66 | 70 | </div> | |
| 67 | 71 | </div> | |
| 68 | 72 | <nav class="tabs" aria-label="Sections"> | |
| @@ -63,6 +63,10 @@ | |||||
| 63 | <span class="search-label">Go to…</span> | 63 | <span class="search-label">Go to…</span> | ||
| 64 | <kbd aria-hidden="true">ctrl k</kbd> | 64 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 65 | </button> | 65 | </button> | ||
| 66 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |||
| 67 | + | <span class="ico i-download" aria-hidden="true"> | |||
| 68 | + | </span> | |||
| 69 | + | </button> | |||
| 66 | </div> | 70 | </div> | ||
| 67 | </div> | 71 | </div> | ||
| 68 | <nav class="tabs" aria-label="Sections"> | 72 | <nav class="tabs" aria-label="Sections"> | ||
M internal/app/testdata/web/commits-path.html +4 -0
| @@ -66,6 +66,10 @@ | |||
| 66 | 66 | <span class="search-label">Go to…</span> | |
| 67 | 67 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 68 | 68 | </button> | |
| 69 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |
| 70 | + | <span class="ico i-download" aria-hidden="true"> | |
| 71 | + | </span> | |
| 72 | + | </button> | |
| 69 | 73 | </div> | |
| 70 | 74 | </div> | |
| 71 | 75 | <nav class="tabs" aria-label="Sections"> | |
| @@ -66,6 +66,10 @@ | |||||
| 66 | <span class="search-label">Go to…</span> | 66 | <span class="search-label">Go to…</span> | ||
| 67 | <kbd aria-hidden="true">ctrl k</kbd> | 67 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 68 | </button> | 68 | </button> | ||
| 69 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |||
| 70 | + | <span class="ico i-download" aria-hidden="true"> | |||
| 71 | + | </span> | |||
| 72 | + | </button> | |||
| 69 | </div> | 73 | </div> | ||
| 70 | </div> | 74 | </div> | ||
| 71 | <nav class="tabs" aria-label="Sections"> | 75 | <nav class="tabs" aria-label="Sections"> | ||
M internal/app/testdata/web/commits-root.html +4 -0
| @@ -63,6 +63,10 @@ | |||
| 63 | 63 | <span class="search-label">Go to…</span> | |
| 64 | 64 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 65 | 65 | </button> | |
| 66 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |
| 67 | + | <span class="ico i-download" aria-hidden="true"> | |
| 68 | + | </span> | |
| 69 | + | </button> | |
| 66 | 70 | </div> | |
| 67 | 71 | </div> | |
| 68 | 72 | <nav class="tabs" aria-label="Sections"> | |
| @@ -63,6 +63,10 @@ | |||||
| 63 | <span class="search-label">Go to…</span> | 63 | <span class="search-label">Go to…</span> | ||
| 64 | <kbd aria-hidden="true">ctrl k</kbd> | 64 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 65 | </button> | 65 | </button> | ||
| 66 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |||
| 67 | + | <span class="ico i-download" aria-hidden="true"> | |||
| 68 | + | </span> | |||
| 69 | + | </button> | |||
| 66 | </div> | 70 | </div> | ||
| 67 | </div> | 71 | </div> | ||
| 68 | <nav class="tabs" aria-label="Sections"> | 72 | <nav class="tabs" aria-label="Sections"> | ||
M internal/app/testdata/web/commits-small.html +4 -0
| @@ -63,6 +63,10 @@ | |||
| 63 | 63 | <span class="search-label">Go to…</span> | |
| 64 | 64 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 65 | 65 | </button> | |
| 66 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |
| 67 | + | <span class="ico i-download" aria-hidden="true"> | |
| 68 | + | </span> | |
| 69 | + | </button> | |
| 66 | 70 | </div> | |
| 67 | 71 | </div> | |
| 68 | 72 | <nav class="tabs" aria-label="Sections"> | |
| @@ -63,6 +63,10 @@ | |||||
| 63 | <span class="search-label">Go to…</span> | 63 | <span class="search-label">Go to…</span> | ||
| 64 | <kbd aria-hidden="true">ctrl k</kbd> | 64 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 65 | </button> | 65 | </button> | ||
| 66 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |||
| 67 | + | <span class="ico i-download" aria-hidden="true"> | |||
| 68 | + | </span> | |||
| 69 | + | </button> | |||
| 66 | </div> | 70 | </div> | ||
| 67 | </div> | 71 | </div> | ||
| 68 | <nav class="tabs" aria-label="Sections"> | 72 | <nav class="tabs" aria-label="Sections"> | ||
M internal/app/testdata/web/empty.html +4 -0
| @@ -51,6 +51,10 @@ | |||
| 51 | 51 | <span class="search-label">Go to…</span> | |
| 52 | 52 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 53 | 53 | </button> | |
| 54 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |
| 55 | + | <span class="ico i-download" aria-hidden="true"> | |
| 56 | + | </span> | |
| 57 | + | </button> | |
| 54 | 58 | </div> | |
| 55 | 59 | </div> | |
| 56 | 60 | </header> | |
| @@ -51,6 +51,10 @@ | |||||
| 51 | <span class="search-label">Go to…</span> | 51 | <span class="search-label">Go to…</span> | ||
| 52 | <kbd aria-hidden="true">ctrl k</kbd> | 52 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 53 | </button> | 53 | </button> | ||
| 54 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |||
| 55 | + | <span class="ico i-download" aria-hidden="true"> | |||
| 56 | + | </span> | |||
| 57 | + | </button> | |||
| 54 | </div> | 58 | </div> | ||
| 55 | </div> | 59 | </div> | ||
| 56 | </header> | 60 | </header> | ||
M internal/app/testdata/web/home.html +4 -0
| @@ -44,6 +44,10 @@ | |||
| 44 | 44 | <span class="search-label">Go to…</span> | |
| 45 | 45 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 46 | 46 | </button> | |
| 47 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |
| 48 | + | <span class="ico i-download" aria-hidden="true"> | |
| 49 | + | </span> | |
| 50 | + | </button> | |
| 47 | 51 | </div> | |
| 48 | 52 | </div> | |
| 49 | 53 | </header> | |
| @@ -44,6 +44,10 @@ | |||||
| 44 | <span class="search-label">Go to…</span> | 44 | <span class="search-label">Go to…</span> | ||
| 45 | <kbd aria-hidden="true">ctrl k</kbd> | 45 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 46 | </button> | 46 | </button> | ||
| 47 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |||
| 48 | + | <span class="ico i-download" aria-hidden="true"> | |||
| 49 | + | </span> | |||
| 50 | + | </button> | |||
| 47 | </div> | 51 | </div> | ||
| 48 | </div> | 52 | </div> | ||
| 49 | </header> | 53 | </header> | ||
M internal/app/testdata/web/not-found-page.html +4 -0
| @@ -51,6 +51,10 @@ | |||
| 51 | 51 | <span class="search-label">Go to…</span> | |
| 52 | 52 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 53 | 53 | </button> | |
| 54 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |
| 55 | + | <span class="ico i-download" aria-hidden="true"> | |
| 56 | + | </span> | |
| 57 | + | </button> | |
| 54 | 58 | </div> | |
| 55 | 59 | </div> | |
| 56 | 60 | </header> | |
| @@ -51,6 +51,10 @@ | |||||
| 51 | <span class="search-label">Go to…</span> | 51 | <span class="search-label">Go to…</span> | ||
| 52 | <kbd aria-hidden="true">ctrl k</kbd> | 52 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 53 | </button> | 53 | </button> | ||
| 54 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |||
| 55 | + | <span class="ico i-download" aria-hidden="true"> | |||
| 56 | + | </span> | |||
| 57 | + | </button> | |||
| 54 | </div> | 58 | </div> | ||
| 55 | </div> | 59 | </div> | ||
| 56 | </header> | 60 | </header> | ||
M internal/app/testdata/web/not-found.html +4 -0
| @@ -51,6 +51,10 @@ | |||
| 51 | 51 | <span class="search-label">Go to…</span> | |
| 52 | 52 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 53 | 53 | </button> | |
| 54 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |
| 55 | + | <span class="ico i-download" aria-hidden="true"> | |
| 56 | + | </span> | |
| 57 | + | </button> | |
| 54 | 58 | </div> | |
| 55 | 59 | </div> | |
| 56 | 60 | </header> | |
| @@ -51,6 +51,10 @@ | |||||
| 51 | <span class="search-label">Go to…</span> | 51 | <span class="search-label">Go to…</span> | ||
| 52 | <kbd aria-hidden="true">ctrl k</kbd> | 52 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 53 | </button> | 53 | </button> | ||
| 54 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |||
| 55 | + | <span class="ico i-download" aria-hidden="true"> | |||
| 56 | + | </span> | |||
| 57 | + | </button> | |||
| 54 | </div> | 58 | </div> | ||
| 55 | </div> | 59 | </div> | ||
| 56 | </header> | 60 | </header> | ||
M internal/app/testdata/web/refs.html +4 -0
| @@ -60,6 +60,10 @@ | |||
| 60 | 60 | <span class="search-label">Go to…</span> | |
| 61 | 61 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 62 | 62 | </button> | |
| 63 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |
| 64 | + | <span class="ico i-download" aria-hidden="true"> | |
| 65 | + | </span> | |
| 66 | + | </button> | |
| 63 | 67 | </div> | |
| 64 | 68 | </div> | |
| 65 | 69 | <nav class="tabs" aria-label="Sections"> | |
| @@ -60,6 +60,10 @@ | |||||
| 60 | <span class="search-label">Go to…</span> | 60 | <span class="search-label">Go to…</span> | ||
| 61 | <kbd aria-hidden="true">ctrl k</kbd> | 61 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 62 | </button> | 62 | </button> | ||
| 63 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |||
| 64 | + | <span class="ico i-download" aria-hidden="true"> | |||
| 65 | + | </span> | |||
| 66 | + | </button> | |||
| 63 | </div> | 67 | </div> | ||
| 64 | </div> | 68 | </div> | ||
| 65 | <nav class="tabs" aria-label="Sections"> | 69 | <nav class="tabs" aria-label="Sections"> | ||
M internal/app/testdata/web/summary.html +4 -0
| @@ -57,6 +57,10 @@ | |||
| 57 | 57 | <span class="search-label">Go to…</span> | |
| 58 | 58 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 59 | 59 | </button> | |
| 60 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |
| 61 | + | <span class="ico i-download" aria-hidden="true"> | |
| 62 | + | </span> | |
| 63 | + | </button> | |
| 60 | 64 | </div> | |
| 61 | 65 | </div> | |
| 62 | 66 | <nav class="tabs" aria-label="Sections"> | |
| @@ -57,6 +57,10 @@ | |||||
| 57 | <span class="search-label">Go to…</span> | 57 | <span class="search-label">Go to…</span> | ||
| 58 | <kbd aria-hidden="true">ctrl k</kbd> | 58 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 59 | </button> | 59 | </button> | ||
| 60 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |||
| 61 | + | <span class="ico i-download" aria-hidden="true"> | |||
| 62 | + | </span> | |||
| 63 | + | </button> | |||
| 60 | </div> | 64 | </div> | ||
| 61 | </div> | 65 | </div> | ||
| 62 | <nav class="tabs" aria-label="Sections"> | 66 | <nav class="tabs" aria-label="Sections"> | ||
M internal/app/testdata/web/tree-nested.html +4 -0
| @@ -69,6 +69,10 @@ | |||
| 69 | 69 | <span class="search-label">Go to…</span> | |
| 70 | 70 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 71 | 71 | </button> | |
| 72 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |
| 73 | + | <span class="ico i-download" aria-hidden="true"> | |
| 74 | + | </span> | |
| 75 | + | </button> | |
| 72 | 76 | </div> | |
| 73 | 77 | </div> | |
| 74 | 78 | <nav class="tabs" aria-label="Sections"> | |
| @@ -69,6 +69,10 @@ | |||||
| 69 | <span class="search-label">Go to…</span> | 69 | <span class="search-label">Go to…</span> | ||
| 70 | <kbd aria-hidden="true">ctrl k</kbd> | 70 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 71 | </button> | 71 | </button> | ||
| 72 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |||
| 73 | + | <span class="ico i-download" aria-hidden="true"> | |||
| 74 | + | </span> | |||
| 75 | + | </button> | |||
| 72 | </div> | 76 | </div> | ||
| 73 | </div> | 77 | </div> | ||
| 74 | <nav class="tabs" aria-label="Sections"> | 78 | <nav class="tabs" aria-label="Sections"> | ||
M internal/app/testdata/web/tree-root.html +4 -0
| @@ -63,6 +63,10 @@ | |||
| 63 | 63 | <span class="search-label">Go to…</span> | |
| 64 | 64 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 65 | 65 | </button> | |
| 66 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |
| 67 | + | <span class="ico i-download" aria-hidden="true"> | |
| 68 | + | </span> | |
| 69 | + | </button> | |
| 66 | 70 | </div> | |
| 67 | 71 | </div> | |
| 68 | 72 | <nav class="tabs" aria-label="Sections"> | |
| @@ -63,6 +63,10 @@ | |||||
| 63 | <span class="search-label">Go to…</span> | 63 | <span class="search-label">Go to…</span> | ||
| 64 | <kbd aria-hidden="true">ctrl k</kbd> | 64 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 65 | </button> | 65 | </button> | ||
| 66 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |||
| 67 | + | <span class="ico i-download" aria-hidden="true"> | |||
| 68 | + | </span> | |||
| 69 | + | </button> | |||
| 66 | </div> | 70 | </div> | ||
| 67 | </div> | 71 | </div> | ||
| 68 | <nav class="tabs" aria-label="Sections"> | 72 | <nav class="tabs" aria-label="Sections"> | ||
M internal/app/testdata/web/tree-slash-ref.html +4 -0
| @@ -66,6 +66,10 @@ | |||
| 66 | 66 | <span class="search-label">Go to…</span> | |
| 67 | 67 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 68 | 68 | </button> | |
| 69 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |
| 70 | + | <span class="ico i-download" aria-hidden="true"> | |
| 71 | + | </span> | |
| 72 | + | </button> | |
| 69 | 73 | </div> | |
| 70 | 74 | </div> | |
| 71 | 75 | <nav class="tabs" aria-label="Sections"> | |
| @@ -66,6 +66,10 @@ | |||||
| 66 | <span class="search-label">Go to…</span> | 66 | <span class="search-label">Go to…</span> | ||
| 67 | <kbd aria-hidden="true">ctrl k</kbd> | 67 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 68 | </button> | 68 | </button> | ||
| 69 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |||
| 70 | + | <span class="ico i-download" aria-hidden="true"> | |||
| 71 | + | </span> | |||
| 72 | + | </button> | |||
| 69 | </div> | 73 | </div> | ||
| 70 | </div> | 74 | </div> | ||
| 71 | <nav class="tabs" aria-label="Sections"> | 75 | <nav class="tabs" aria-label="Sections"> | ||
M internal/app/testdata/web/tree-subdir.html +4 -0
| @@ -66,6 +66,10 @@ | |||
| 66 | 66 | <span class="search-label">Go to…</span> | |
| 67 | 67 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 68 | 68 | </button> | |
| 69 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |
| 70 | + | <span class="ico i-download" aria-hidden="true"> | |
| 71 | + | </span> | |
| 72 | + | </button> | |
| 69 | 73 | </div> | |
| 70 | 74 | </div> | |
| 71 | 75 | <nav class="tabs" aria-label="Sections"> | |
| @@ -66,6 +66,10 @@ | |||||
| 66 | <span class="search-label">Go to…</span> | 66 | <span class="search-label">Go to…</span> | ||
| 67 | <kbd aria-hidden="true">ctrl k</kbd> | 67 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 68 | </button> | 68 | </button> | ||
| 69 | + | <button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app"> | |||
| 70 | + | <span class="ico i-download" aria-hidden="true"> | |||
| 71 | + | </span> | |||
| 72 | + | </button> | |||
| 69 | </div> | 73 | </div> | ||
| 70 | </div> | 74 | </div> | ||
| 71 | <nav class="tabs" aria-label="Sections"> | 75 | <nav class="tabs" aria-label="Sections"> | ||
M internal/app/web_session_test.go +8 -0
| @@ -387,7 +387,15 @@ func TestWebRejectsForgedSessions(t *testing.T) { | |||
| 387 | 387 | require.NotNil(t, ck, name) | |
| 388 | 388 | assert.Negative(t, ck.MaxAge, name) | |
| 389 | 389 | assert.Equal(t, 403, b.do("POST", "/-/logout", post(tok), "").status, name) | |
| 390 | + | ||
| 391 | + | // The service worker revalidates on every check, but an answer that | |
| 392 | + | // clears a cookie stays no-store. | |
| 393 | + | sw := b.get("/-/sw.js", as(tok)) | |
| 394 | + | assert.Equal(t, 200, sw.status, name) | |
| 395 | + | assert.Equal(t, "no-store", sw.hdr.Get("Cache-Control"), name) | |
| 396 | + | assert.NotNil(t, sw.cookie(sessionCookie), name) | |
| 390 | 397 | } | |
| 398 | + | assert.Equal(t, "no-cache", b.get("/-/sw.js", nil).hdr.Get("Cache-Control")) | |
| 391 | 399 | } | |
| 392 | 400 | ||
| 393 | 401 | // Failed logins count in the limiter of failed authentications, like a bad | |
| @@ -387,7 +387,15 @@ func TestWebRejectsForgedSessions(t *testing.T) { | |||||
| 387 | require.NotNil(t, ck, name) | 387 | require.NotNil(t, ck, name) | ||
| 388 | assert.Negative(t, ck.MaxAge, name) | 388 | assert.Negative(t, ck.MaxAge, name) | ||
| 389 | assert.Equal(t, 403, b.do("POST", "/-/logout", post(tok), "").status, name) | 389 | assert.Equal(t, 403, b.do("POST", "/-/logout", post(tok), "").status, name) | ||
| 390 | + | ||||
| 391 | + | // The service worker revalidates on every check, but an answer that | |||
| 392 | + | // clears a cookie stays no-store. | |||
| 393 | + | sw := b.get("/-/sw.js", as(tok)) | |||
| 394 | + | assert.Equal(t, 200, sw.status, name) | |||
| 395 | + | assert.Equal(t, "no-store", sw.hdr.Get("Cache-Control"), name) | |||
| 396 | + | assert.NotNil(t, sw.cookie(sessionCookie), name) | |||
| 390 | } | 397 | } | ||
| 398 | + | assert.Equal(t, "no-cache", b.get("/-/sw.js", nil).hdr.Get("Cache-Control")) | |||
| 391 | } | 399 | } | ||
| 392 | 400 | ||||
| 393 | // Failed logins count in the limiter of failed authentications, like a bad | 401 | // Failed logins count in the limiter of failed authentications, like a bad | ||