1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180 | ;; ported-from: src/main.ts
;;
;; Bootstrap: PWA updates + lobby, then lazy-load the room stack (sueta.room).
;; sueta.i18n is required first among the text-bearing namespaces — its load
;; resolves the language and hydrates the static markup before anything renders
;; (the TS `import "./i18n"` contract).
;;
;; THIS NAMESPACE IS THE :main MODULE — first paint, ~111 KB gz of it, and the
;; entire reason for the :room split is that a visitor who never opens a room
;; pays for none of the p2p stack. So: NO promesa here, and no require that
;; reaches it. `sueta.room` owns that boundary and explains why it wanted it;
;; the ladders below are shallow enough that `js-await` (pure `.then` sugar,
;; zero runtime) is the right tool and the flattening is done with named
;; functions instead. test/module-split.test.mjs asserts both halves over the
;; require graph, in both directions, so this comment cannot quietly stop being
;; true.
(ns sueta.main
(:require [sueta.i18n :as i18n :refer (t)]
[sueta.app.lobby :as lobby]
[sueta.app.migrate-storage :as migrate-storage]
[sueta.config :as config]
[sueta.fx :as fx]
[ardegazu.rooms.js :as j]
[ardegazu.rooms.lib.crypto :as rc]
[shadow.cljs.modern :refer (js-await)]
[shadow.lazy :as lazy]))
(def ^:private room-loadable (lazy/loadable sueta.room/boot-room))
(def VERSION config/APP-VERSION)
(defn- fetch-live-version
"What version is live on the server right now? (bypasses the SW precache)"
[]
(-> (js/fetch (str "./version.json?cb=" (js/Date.now)) (j/ordered "cache" "no-store"))
(.then (fn [res] (.json ^js res)))
(.then (fn [body]
(let [v (when (j/truthy? body) (unchecked-get body "version"))]
(if (identical? "number" (js* "typeof ~{}" v)) v nil))))
;; offline, or a server that has no version.json: expected, and the banner
;; simply falls back to its version-less wording
(fx/drop-silently)))
(defn- reload-once
"Own the reload: the SW only signals \"waiting\", and controllerchange may
never fire on a page that had no controller — so a 4s timeout races it and
whichever wins reloads exactly once."
[]
(let [reloaded (volatile! false)
reload (fn []
(when-not @reloaded
(vreset! reloaded true)
(.reload js/location))
js/undefined)
sw (.-serviceWorker js/navigator)]
(when (j/truthy? sw)
(.addEventListener ^js sw "controllerchange" reload (j/ordered "once" true)))
(js/setTimeout reload 4000)
js/undefined))
(defn- show-update-banner! [update-sw]
(if (j/truthy? (js/document.getElementById "upd-banner"))
(js/Promise.resolve nil)
(js-await [live (fetch-live-version)]
(let [b (js/document.createElement "button")]
(set! (.-id b) "upd-banner")
(set! (.-className b) "update-banner")
(set! (.-textContent b)
(if (and (j/truthy? live) (not (identical? live VERSION)))
(t "update.ready-v" {"live" live "v" VERSION})
(t "update.ready")))
(.addEventListener
b "click"
(fn []
(set! (.-textContent b) (t "update.updating"))
(reload-once)
(update-sw)
js/undefined))
(.appendChild js/document.body b)
js/undefined))))
(defn- watch-registration!
"Offer the banner when a worker is waiting, now and on every later update:
`updatefound` → the installing worker reaching \"installed\", plus a 5-minute
poll and a check whenever the app comes back to the foreground."
[^js reg]
(let [update-sw (fn []
(let [waiting (.-waiting reg)]
(when (j/truthy? waiting)
(.postMessage ^js waiting (j/ordered "type" "SKIP_WAITING"))))
js/undefined)
offer (fn []
(when (and (j/truthy? (.-waiting reg))
(j/truthy? (.-controller (.-serviceWorker js/navigator))))
(show-update-banner! update-sw))
js/undefined)
check (fn [] (fx/drop-silently (.update reg)) js/undefined)]
;; a worker already waiting when we registered
(offer)
(.addEventListener reg "updatefound"
(fn []
(let [installing (.-installing reg)]
(when (j/truthy? installing)
(.addEventListener ^js installing "statechange"
(fn []
(when (identical? "installed"
(.-state ^js installing))
(offer))
js/undefined))))
js/undefined))
(js/setInterval check 300000)
(.addEventListener js/document "visibilitychange"
(fn []
(when (identical? "visible" (.-visibilityState js/document)) (check))
js/undefined))
js/undefined))
(defn- register-sw!
"vite-plugin-pwa's registerSW({immediate:true, registerType:'prompt'})
equivalent. Release builds only (the dev server ships no sw.js, and a
dev-origin SW squats). The generated worker (workbox generateSW with
skipWaiting:false) waits for a SKIP_WAITING message, which is what the
banner's `update-sw` sends."
[]
(when (and (not ^boolean goog.DEBUG) (j/truthy? (.-serviceWorker js/navigator)))
;; no SW is a supported way to run this app (insecure origin, a browser that
;; refuses, a user who cleared it): expected, so it stays quiet
(-> (.register (.-serviceWorker js/navigator) "./sw.js")
(.then (fn [reg] (watch-registration! reg)))
(fx/drop-silently)))
js/undefined)
(defn- ignore-orbit-decrypt-rejections!
"OrbitDB quirk: a decrypt failure in the heads-exchange pipe surfaces as an
unhandled rejection (orphaned mid-pipe promise). Log-and-continue."
[]
(.addEventListener js/window "unhandledrejection"
(fn [^js e]
(let [reason (.-reason e)
msg (js* "String(~{})" (j/nn (when (j/truthy? reason)
(unchecked-get reason "message"))
""))]
(when (.includes ^string msg "Could not decrypt") (.preventDefault e)))
js/undefined))
js/undefined)
(def ^:private SECRET-RE (js/RegExp. "^[A-Za-z0-9_-]{43}$"))
(defn- enter-room!
"The p2p stack is the heavy chunk — load it only once we're entering a room."
[root secret force-relay]
(.replaceState js/history nil "" (str "#" secret (if force-relay "&relay" "")))
;; opening a different room link while running → clean re-boot
(.addEventListener js/window "hashchange" (fn [] (.reload js/location) js/undefined))
(js-await [boot-room (lazy/load room-loadable)]
(boot-room root secret force-relay)))
(defn- boot []
(register-sw!)
;; move legacy "sueta"-namespaced storage into the derived namespace BEFORE
;; anything (lobby, room stack) opens the new stores
(js-await [_ (migrate-storage/migrate-storage-ns)]
(do
(ignore-orbit-decrypt-rejections!)
;; fragment: #<secret>[&relay] — never sent to any server
(let [frag (.slice (.-hash js/location) 1)
parts (.split ^string frag "&")
secret-part (aget parts 0)
force-relay (.includes (.slice parts 1) "relay")
root (js/document.getElementById "app")]
(js-await [secret (if (.test SECRET-RE secret-part)
(js/Promise.resolve secret-part)
;; no room link → lobby: pick one of your rooms or
;; start a new one
(lobby/lobby root rc/new-room-secret))]
(enter-room! root secret force-relay))))))
(defn init []
;; the last catch in the app: if boot rejected, nothing else will say so
(fx/tolerate "boot failed" (boot)))
|