detached 79d6b04

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 UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The 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.
1616 | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |
1717 | `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` |
1818 | `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 | |
2020 | `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 | |
2122 | `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 | |
2223
2324 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 {
4747 type assetSet struct {
4848 byName map[string]asset
4949 urls Assets
50+ sw serviceWorker
5051 }
5152
5253 // scripts are the JavaScript files of the page, in embedded static/js: the
@@ -155,6 +156,11 @@ func loadAssets() (*assetSet, error) {
155156 if err := s.loadApp(put); err != nil {
156157 return nil, err
157158 }
159+ sw, err := loadServiceWorker()
160+ if err != nil {
161+ return nil, err
162+ }
163+ s.sw = sw
158164 return s, nil
159165 }
160166
@@ -167,11 +173,16 @@ var appIcons = []struct{ file, sizes, purpose string }{
167173 {"icon-maskable-512.png", "512x512", "maskable"},
168174 }
169175
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.
172179 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"`
173183 Name string `json:"name"`
174184 ShortName string `json:"short_name"`
185+ Description string `json:"description"`
175186 StartURL string `json:"start_url"`
176187 Scope string `json:"scope"`
177188 Display string `json:"display"`
@@ -195,7 +206,8 @@ const appColor = "#050505"
195206 // hashed URLs.
196207 func (s *assetSet) loadApp(put func(dir, name string, b []byte, typ string) string) error {
197208 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.",
199211 BackgroundColor: appColor, ThemeColor: appColor,
200212 }
201213 for _, ic := range appIcons {
@@ -47,6 +47,7 @@ type asset struct {
47 type assetSet struct {47 type assetSet struct {
48 byName map[string]asset48 byName map[string]asset
49 urls Assets49 urls Assets
50+ sw serviceWorker
50 }51 }
51 52
52 // scripts are the JavaScript files of the page, in embedded static/js: the53 // 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, err157 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, nil164 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 browser176+// 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 @@
33 package web
44
55 import (
6+ "bytes"
7+ "context"
68 "encoding/json"
9+ "fmt"
10+ "image/png"
11+ "net/http"
12+ "net/http/httptest"
713 "strings"
814 "testing"
915
@@ -25,6 +31,12 @@ func TestWebAppManifest(t *testing.T) {
2531 require.NoError(t, json.Unmarshal(man.body, &m))
2632 assert.Equal(t, "standalone", m.Display)
2733 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")
2840 require.Len(t, m.Icons, len(appIcons))
2941 for _, ic := range m.Icons {
3042 a, ok := srv.assets.byName[strings.TrimPrefix(ic.Src, StaticPrefix)]
@@ -34,7 +46,67 @@ func TestWebAppManifest(t *testing.T) {
3446 _, ok = srv.assets.byName[strings.TrimPrefix(u.AppleIcon, StaticPrefix)]
3547 assert.True(t, ok)
3648
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+
3757 page := renderToString(t, Shell(Layout{Assets: u}, nil))
3858 assert.Contains(t, page, `<link rel="manifest" href="`+u.Manifest+`">`)
3959 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)
40112 }
@@ -3,7 +3,13 @@
3 package web3 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) {
128128 <span class="search-label">Go to…</span>
129129 <kbd aria-hidden="true">ctrl k</kbd>
130130 </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>
131141 if SignedIn(ctx) {
132142 <form class="owner" method="post" action="/-/logout">
133143 <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 @@
2626 .i-key { --i: url(icons/key.svg); }
2727 .i-log-in { --i: url(icons/log-in.svg); }
2828 .i-log-out { --i: url(icons/log-out.svg); }
29+ .i-download { --i: url(icons/download.svg); }
2930
3031 .pill {
3132 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 @@
1111 // 4. The file of the diff in view is marked in the table of contents.
1212 // 5. A handler that fails is shown in the hint badge.
1313 // 6. The lines a diff hides load in place (ermiteGaps; e and E).
14+// 7. The service worker and the "install app" button (ermiteInstall).
1415 (() => {
1516 // Marks the page as scripted: what does nothing without scripts (the filters,
1617 // the "go to" and help buttons) is hidden until this class is set (see the
@@ -306,6 +307,70 @@
306307 };
307308 })();
308309
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+
309374 // The hidden lines of a diff: a row .gap holds a button that loads them
310375 // (/context); the rows that come back replace the gap in the table of each
311376 // 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 the17 // 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 them374 // 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 each375 // (/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). A376 // 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 {
152152 mux := http.NewServeMux()
153153 mux.HandleFunc("GET "+StaticPrefix, s.assets.serve)
154154 mux.HandleFunc("GET /-/events", s.events)
155+ mux.HandleFunc("GET "+ServiceWorkerPath, s.assets.sw.serve)
155156 if s.cfg.Sessions != nil {
156157 mux.HandleFunc("GET "+loginPrefix+"{nonce}", s.loginConfirm)
157158 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 @@
6666 <span class="search-label">Go to…</span>
6767 <kbd aria-hidden="true">ctrl k</kbd>
6868 </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>
6973 </div>
7074 </div>
7175 <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 @@
6666 <span class="search-label">Go to…</span>
6767 <kbd aria-hidden="true">ctrl k</kbd>
6868 </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>
6973 </div>
7074 </div>
7175 <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 @@
6666 <span class="search-label">Go to…</span>
6767 <kbd aria-hidden="true">ctrl k</kbd>
6868 </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>
6973 </div>
7074 </div>
7175 <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 @@
6666 <span class="search-label">Go to…</span>
6767 <kbd aria-hidden="true">ctrl k</kbd>
6868 </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>
6973 </div>
7074 </div>
7175 <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 @@
6969 <span class="search-label">Go to…</span>
7070 <kbd aria-hidden="true">ctrl k</kbd>
7171 </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>
7276 </div>
7377 </div>
7478 <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 @@
6666 <span class="search-label">Go to…</span>
6767 <kbd aria-hidden="true">ctrl k</kbd>
6868 </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>
6973 </div>
7074 </div>
7175 <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 @@
6666 <span class="search-label">Go to…</span>
6767 <kbd aria-hidden="true">ctrl k</kbd>
6868 </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>
6973 </div>
7074 </div>
7175 <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 @@
6666 <span class="search-label">Go to…</span>
6767 <kbd aria-hidden="true">ctrl k</kbd>
6868 </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>
6973 </div>
7074 </div>
7175 <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 @@
6666 <span class="search-label">Go to…</span>
6767 <kbd aria-hidden="true">ctrl k</kbd>
6868 </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>
6973 </div>
7074 </div>
7175 <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 @@
6363 <span class="search-label">Go to…</span>
6464 <kbd aria-hidden="true">ctrl k</kbd>
6565 </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>
6670 </div>
6771 </div>
6872 <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 @@
6363 <span class="search-label">Go to…</span>
6464 <kbd aria-hidden="true">ctrl k</kbd>
6565 </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>
6670 </div>
6771 </div>
6872 <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 @@
6363 <span class="search-label">Go to…</span>
6464 <kbd aria-hidden="true">ctrl k</kbd>
6565 </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>
6670 </div>
6771 </div>
6872 <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 @@
6363 <span class="search-label">Go to…</span>
6464 <kbd aria-hidden="true">ctrl k</kbd>
6565 </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>
6670 </div>
6771 </div>
6872 <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 @@
6363 <span class="search-label">Go to…</span>
6464 <kbd aria-hidden="true">ctrl k</kbd>
6565 </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>
6670 </div>
6771 </div>
6872 <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 @@
6363 <span class="search-label">Go to…</span>
6464 <kbd aria-hidden="true">ctrl k</kbd>
6565 </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>
6670 </div>
6771 </div>
6872 <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 @@
6363 <span class="search-label">Go to…</span>
6464 <kbd aria-hidden="true">ctrl k</kbd>
6565 </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>
6670 </div>
6771 </div>
6872 <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 @@
6363 <span class="search-label">Go to…</span>
6464 <kbd aria-hidden="true">ctrl k</kbd>
6565 </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>
6670 </div>
6771 </div>
6872 <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 @@
6363 <span class="search-label">Go to…</span>
6464 <kbd aria-hidden="true">ctrl k</kbd>
6565 </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>
6670 </div>
6771 </div>
6872 <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 @@
6666 <span class="search-label">Go to…</span>
6767 <kbd aria-hidden="true">ctrl k</kbd>
6868 </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>
6973 </div>
7074 </div>
7175 <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 @@
6363 <span class="search-label">Go to…</span>
6464 <kbd aria-hidden="true">ctrl k</kbd>
6565 </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>
6670 </div>
6771 </div>
6872 <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 @@
6363 <span class="search-label">Go to…</span>
6464 <kbd aria-hidden="true">ctrl k</kbd>
6565 </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>
6670 </div>
6771 </div>
6872 <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 @@
5151 <span class="search-label">Go to…</span>
5252 <kbd aria-hidden="true">ctrl k</kbd>
5353 </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>
5458 </div>
5559 </div>
5660 </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 @@
4444 <span class="search-label">Go to…</span>
4545 <kbd aria-hidden="true">ctrl k</kbd>
4646 </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>
4751 </div>
4852 </div>
4953 </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 @@
5151 <span class="search-label">Go to…</span>
5252 <kbd aria-hidden="true">ctrl k</kbd>
5353 </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>
5458 </div>
5559 </div>
5660 </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 @@
5151 <span class="search-label">Go to…</span>
5252 <kbd aria-hidden="true">ctrl k</kbd>
5353 </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>
5458 </div>
5559 </div>
5660 </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 @@
6060 <span class="search-label">Go to…</span>
6161 <kbd aria-hidden="true">ctrl k</kbd>
6262 </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>
6367 </div>
6468 </div>
6569 <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 @@
5757 <span class="search-label">Go to…</span>
5858 <kbd aria-hidden="true">ctrl k</kbd>
5959 </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>
6064 </div>
6165 </div>
6266 <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 @@
6969 <span class="search-label">Go to…</span>
7070 <kbd aria-hidden="true">ctrl k</kbd>
7171 </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>
7276 </div>
7377 </div>
7478 <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 @@
6363 <span class="search-label">Go to…</span>
6464 <kbd aria-hidden="true">ctrl k</kbd>
6565 </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>
6670 </div>
6771 </div>
6872 <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 @@
6666 <span class="search-label">Go to…</span>
6767 <kbd aria-hidden="true">ctrl k</kbd>
6868 </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>
6973 </div>
7074 </div>
7175 <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 @@
6666 <span class="search-label">Go to…</span>
6767 <kbd aria-hidden="true">ctrl k</kbd>
6868 </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>
6973 </div>
7074 </div>
7175 <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) {
387387 require.NotNil(t, ck, name)
388388 assert.Negative(t, ck.MaxAge, name)
389389 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)
390397 }
398+ assert.Equal(t, "no-cache", b.get("/-/sw.js", nil).hdr.Get("Cache-Control"))
391399 }
392400
393401 // 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 bad401 // Failed logins count in the limiter of failed authentications, like a bad

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 44

↑↓ moveenter openesc close