oxedyne/daimond/www/js/handmode.js
33.3 KiB, 16 runs
created by r2519314175:1377, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | /* handmode.js — which permission mode Daimond is in, shown where the work is |
| 2 | * watched and changed from there. |
| 3 | * |
| 4 | * One setting, three rungs, and the axis is what Daimond does WITHOUT ASKING: |
| 5 | * |
| 6 | * ask every command is put to you before it runs, and reaching the web |
| 7 | * is put to you once in each conversation |
| 8 | * guarded commands run; a turn that has read outside content loses the |
| 9 | * network and Daimond asks before reaching a page it chose |
| 10 | * bypass nothing is asked |
| 11 | * |
| 12 | * No rung moves the fence, the system-call filter, a Diamond's folders or the |
| 13 | * journal — those are the compartment and the record, and a mode that could |
| 14 | * switch one off would not be a permission mode. The wording that says so to |
| 15 | * the user lives in i18n/en.js under `permmode.`, and the wording that says it |
| 16 | * to the model lives in Rust (src/prompts.rs), so neither can drift alone. |
| 17 | * |
| 18 | * A SECOND AXIS lives in the same popover and is not a fourth rung: the rungs |
| 19 | * are one setting for the whole app, and whether ONE CHAT's commands may reach |
| 20 | * the network is that chat's own state. It is here rather than in a chip of its |
| 21 | * own because the button already claims this ground -- its hover text promised |
| 22 | * "what Daimond does without asking", which is precisely this question -- and a |
| 23 | * second permissions control beside one making that promise is two answers to |
| 24 | * one thing. See `netState`/`setNet`. |
| 25 | * |
| 26 | * Two rules about the surface, and they pull opposite ways on purpose: |
| 27 | * |
| 28 | * VISIBLE the mode is a word in the chat header, beside the model. A mode |
| 29 | * you have to remember is one you will be wrong about, and being |
| 30 | * wrong about this one matters. |
| 31 | * QUIET bypass explains itself ONCE, the first time it is chosen, and |
| 32 | * never again. A bypass that keeps interrupting is not a bypass. |
| 33 | */ |
| 34 | (function () { |
| 35 | 'use strict'; |
| 36 | |
| 37 | var LS_MODE = 'daimond-permission-mode'; |
| 38 | var LS_ACK = 'daimond-permission-bypass-ack'; |
| 39 | // The STANDING answer to the network question: '' asks once in each chat, |
| 40 | // 'allow' and 'refuse' answer it in advance and for good. |
| 41 | // |
| 42 | // It is persisted, and that is the whole point of it. The answer itself lives on |
| 43 | // a chat's engine object, which is built per chat and does not survive a reload |
| 44 | // -- so "you can grant it and not be interrupted again" was true only of a chat |
| 45 | // you had already gone into the menu to grant it in. Reported, in those words: |
| 46 | // "I thought we got rid of this bullshit!" It was not got rid of; it was made |
| 47 | // answerable. This is what gets rid of it. |
| 48 | var LS_NET = 'daimond-net-standing'; |
| 49 | |
| 50 | // THIS COMPUTER's autonomous posture: whether it may finish work dispatched to |
| 51 | // it while nobody is here, reaching the web, acting on pages and running |
| 52 | // commands on its own. Device-local by design and never synced -- arming one |
| 53 | // machine must not arm another -- so it is a plain localStorage key like the |
| 54 | // rung above it, not per-chat and not per-Diamond. The reader on the other side |
| 55 | // is `autonomousPosture()` in daimond.js, which reaches this SAME key by this |
| 56 | // SAME name; the two must not drift. |
| 57 | var LS_AUTO = 'daimond-autonomous-posture'; |
| 58 | |
| 59 | // THIS COMPUTER's step-away posture: whether a turn it is running is handed to |
| 60 | // another awake device when this one is stepped away from — the lid closes, the |
| 61 | // tab is closed. Device-local and never synced, the same rule as the autonomous |
| 62 | // posture above. The reader on the other side is `handoffWhenAway()` in |
| 63 | // daimond.js, which reaches this SAME key by this SAME name; the two must not |
| 64 | // drift. |
| 65 | var LS_HANDOFF = 'daimond-handoff-when-away'; |
| 66 | |
| 67 | // ── The account-level permission POLICY that TRAVELS ───────── |
| 68 | // |
| 69 | // The rung (LS_MODE) and the scope grants below are a fact about the ACCOUNT, |
| 70 | // not about this browser: a gated tool's egress happens at the gateway on the |
| 71 | // account's own credit, so consenting to it is the account's decision, and a |
| 72 | // runner device that inherits the policy must NOT put the question again. This |
| 73 | // is the half of the permission state that rides in the sync parcel -- see |
| 74 | // `snapshotPolicy`/`adoptPolicy` here and the `perms` field in daimond.js's |
| 75 | // collectSync/applySync. |
| 76 | // |
| 77 | // WHAT STAYS MACHINE-LOCAL AND NEVER TRAVELS is read a few lines up and must |
| 78 | // stay there: LS_ACK (a per-device safety acknowledgement of bypass), LS_NET |
| 79 | // (whether a COMMAND on THIS machine keeps its network after reading a |
| 80 | // stranger's words), LS_AUTO and LS_HANDOFF (this one machine's postures). |
| 81 | // Those are machine trust, and staying on the machine is the whole of their |
| 82 | // contract -- arming one computer must not arm another. |
| 83 | var LS_MODE_AT = 'daimond-permission-mode-at'; // when the rung was last chosen here (ms). |
| 84 | var LS_SCOPES = 'daimond-permission-scopes'; // account scope grants: { id: { at, on } }. |
| 85 | |
| 86 | // The scopes whose action happens at the gateway on the account. Reading the |
| 87 | // web (web_fetch/web_search) is the one gated act carried today. Named here so |
| 88 | // the snapshot is a fixed, sorted set and a scope a build does not know is |
| 89 | // dropped on the way in rather than carried as a member with no reader. |
| 90 | var ACCOUNT_SCOPES = ['reading']; |
| 91 | |
| 92 | function ms(v) { |
| 93 | return (typeof v === 'number' && isFinite(v) && v > 0) ? Math.floor(v) : 0; |
| 94 | } |
| 95 | |
| 96 | /// Tell the sync engine the policy moved, so the change travels without waiting |
| 97 | /// for the next turn to end. Best-effort: a device with no sync up loses |
| 98 | /// nothing, the next ordinary round carries it. |
| 99 | function nudge() { |
| 100 | try { if (window.DaimondSync && DaimondSync.nudge) DaimondSync.nudge(); } |
| 101 | catch (e) { /* sync is not up on this device */ } |
| 102 | } |
| 103 | |
| 104 | /// This device's stamp for the rung, or 0 when it has never chosen one -- so a |
| 105 | /// device on the factory default never overrides another device's real choice. |
| 106 | function modeAt() { |
| 107 | var raw = 0; |
| 108 | try { raw = Number(localStorage.getItem(LS_MODE_AT) || 0); } catch (e) { raw = 0; } |
| 109 | return ms(raw); |
| 110 | } |
| 111 | |
| 112 | /// The account scope grants this device holds, as { id: { at, on } }, cleaned |
| 113 | /// to the scopes this build knows and keyed for a sorted read. Absent is none. |
| 114 | function scopes() { |
| 115 | var raw = {}; |
| 116 | try { raw = JSON.parse(localStorage.getItem(LS_SCOPES) || '{}') || {}; } |
| 117 | catch (e) { raw = {}; } |
| 118 | var out = {}; |
| 119 | ACCOUNT_SCOPES.forEach(function (id) { |
| 120 | var r = raw[id]; |
| 121 | if (r && typeof r === 'object' && ms(r.at) > 0) { |
| 122 | out[id] = { at: ms(r.at), on: r.on ? 1 : 0 }; |
| 123 | } |
| 124 | }); |
| 125 | return out; |
| 126 | } |
| 127 | |
| 128 | function writeScopes(map) { |
| 129 | try { localStorage.setItem(LS_SCOPES, JSON.stringify(map)); } |
| 130 | catch (e) { /* private mode: nothing to persist */ } |
| 131 | } |
| 132 | |
| 133 | /// Does the account's standing policy grant this scope? Read at the consent |
| 134 | /// site (egressAllowed in daimond.js): a yes here is what lets a runner skip a |
| 135 | /// prompt for a scope the user has already granted on the account. |
| 136 | function scopeGranted(id) { |
| 137 | var s = scopes()[id]; |
| 138 | return !!(s && s.on); |
| 139 | } |
| 140 | |
| 141 | /// Set (or clear) an account-level scope grant, stamped now, and nudge it out. |
| 142 | /// The stamp is what lets a later change on either device win the merge. |
| 143 | function grantScope(id, on) { |
| 144 | if (ACCOUNT_SCOPES.indexOf(id) < 0) return false; |
| 145 | var map = scopes(); |
| 146 | // Strictly forward, even inside one millisecond: the stamp is the whole of |
| 147 | // what the freshest-wins merge compares, so two grants a tick apart must not |
| 148 | // read as equal and let the wrong one stand. |
| 149 | var at = Math.max(Date.now(), ms(map[id] && map[id].at) + 1); |
| 150 | map[id] = { at: at, on: on === false ? 0 : 1 }; |
| 151 | writeScopes(map); |
| 152 | nudge(); |
| 153 | return true; |
| 154 | } |
| 155 | |
| 156 | /// The account-level permission policy, for the sync parcel. |
| 157 | /// |
| 158 | /// DETERMINISTIC by construction -- a fixed field order and the scopes sorted |
| 159 | /// by key -- so an unchanged policy serialises to the same bytes and the |
| 160 | /// push-skip in sync.js still holds. NOTHING HERE STAMPS: the stamps are |
| 161 | /// written when a choice is made (`set`, `grantScope`), never when the parcel |
| 162 | /// is packed, or a quiet device would re-push the same policy for ever. |
| 163 | function snapshotPolicy() { |
| 164 | var out = { v: 1, mode: current, mode_at: modeAt(), scopes: {} }; |
| 165 | var src = scopes(); |
| 166 | Object.keys(src).sort().forEach(function (id) { out.scopes[id] = src[id]; }); |
| 167 | return out; |
| 168 | } |
| 169 | |
| 170 | /// Merge an account policy from another device. |
| 171 | /// |
| 172 | /// Freshest-wins PER FACT: the rung moves only when the arriving `mode_at` is |
| 173 | /// strictly later than ours, and each scope moves only when its own `at` is. |
| 174 | /// Nothing here stamps -- a policy this device already agrees with moves |
| 175 | /// nothing, so the next parcel it packs is unchanged (the `touchSelfDevice` |
| 176 | /// trap that had two devices pushing at each other). A parcel with no `perms` |
| 177 | /// -- a device that predates this -- is a no-op. |
| 178 | function adoptPolicy(remote) { |
| 179 | if (!remote || typeof remote !== 'object') return; |
| 180 | var rmAt = ms(remote.mode_at); |
| 181 | if (rmAt > modeAt()) { |
| 182 | // A rung this build knows, later than ours, and actually different: push |
| 183 | // it into the engine (the only copy that decides anything) and, only if |
| 184 | // that took, record it. A stamp taken forward without the rung going in |
| 185 | // would strand the page saying one thing while the engine ran another. |
| 186 | if (MODES.indexOf(remote.mode) >= 0 && remote.mode !== current) { |
| 187 | if (push(remote.mode)) { |
| 188 | current = remote.mode; |
| 189 | save(remote.mode); |
| 190 | try { localStorage.setItem(LS_MODE_AT, String(rmAt)); } catch (e) { /* private */ } |
| 191 | draw(); |
| 192 | if (typeof cfg.onChange === 'function') cfg.onChange(remote.mode); |
| 193 | } |
| 194 | } else { |
| 195 | // Their stamp is later but the rung is the one we already hold (or a |
| 196 | // name we do not know): take the stamp so the same record is not |
| 197 | // re-adopted on every pull, but leave the engine alone. |
| 198 | try { localStorage.setItem(LS_MODE_AT, String(rmAt)); } catch (e) { /* private */ } |
| 199 | } |
| 200 | } |
| 201 | var mine = scopes(); |
| 202 | var rem = (remote.scopes && typeof remote.scopes === 'object') ? remote.scopes : {}; |
| 203 | var moved = false; |
| 204 | ACCOUNT_SCOPES.forEach(function (id) { |
| 205 | var r = rem[id]; |
| 206 | if (!r || typeof r !== 'object') return; |
| 207 | var rAt = ms(r.at); |
| 208 | if (rAt > 0 && rAt > ms(mine[id] && mine[id].at)) { |
| 209 | mine[id] = { at: rAt, on: r.on ? 1 : 0 }; |
| 210 | moved = true; |
| 211 | } |
| 212 | }); |
| 213 | if (moved) writeScopes(mine); |
| 214 | } |
| 215 | |
| 216 | /// The rungs, in the order they are offered: strictest first, so the list |
| 217 | /// reads as a ladder and the last row is the one that gives most away. |
| 218 | var MODES = ['ask', 'guarded', 'bypass']; |
| 219 | var FALLBACK = 'guarded'; |
| 220 | |
| 221 | var cfg = {}; // { apply, confirm, notice, onChange } |
| 222 | var current = FALLBACK; |
| 223 | var pop, chip, chipTxt; |
| 224 | |
| 225 | function t(k, v) { |
| 226 | return (window.DaimondI18n ? DaimondI18n.t(k, v) : k); |
| 227 | } |
| 228 | |
| 229 | /// `t`, but with an English fallback for a key the tables do not carry yet. |
| 230 | /// |
| 231 | /// The autonomous-posture strings below are authored here rather than in |
| 232 | /// `i18n/en.js`, so `t` would return the key and warn. This gives the plain |
| 233 | /// English while leaving the keys ready for a translator, exactly as the |
| 234 | /// worker-consent dialogs in `daimond.js` do with their own `tOr`. |
| 235 | function tOr(k, fallback, v) { |
| 236 | var s = t(k, v); |
| 237 | if (s !== k) return s; |
| 238 | if (!v) return fallback; |
| 239 | return String(fallback).replace(/\{(\w+)\}/g, function (whole, name) { |
| 240 | return v[name] != null ? String(v[name]) : whole; |
| 241 | }); |
| 242 | } |
| 243 | |
| 244 | /// Does THIS computer hold the autonomous posture? Off by silence, false on any |
| 245 | /// read error, for the reason `load()` falls back to the careful rung. |
| 246 | function autoOn() { |
| 247 | try { return localStorage.getItem(LS_AUTO) === '1'; } |
| 248 | catch (e) { return false; } |
| 249 | } |
| 250 | |
| 251 | /// Set it, or clear it. Off is stored as removal, so a machine that was never |
| 252 | /// armed and one that was disarmed read the same absent key. |
| 253 | function setAuto(on) { |
| 254 | try { |
| 255 | if (on) localStorage.setItem(LS_AUTO, '1'); |
| 256 | else localStorage.removeItem(LS_AUTO); |
| 257 | } catch (e) { /* private mode: nothing to persist, and off is the safe read */ } |
| 258 | } |
| 259 | |
| 260 | /// Does THIS computer hand its turns off when stepped away from? Off by silence, |
| 261 | /// false on any read error, the same careful default as `autoOn`. |
| 262 | function handoffOn() { |
| 263 | try { return localStorage.getItem(LS_HANDOFF) === '1'; } |
| 264 | catch (e) { return false; } |
| 265 | } |
| 266 | |
| 267 | /// Set it, or clear it — removal for off, so armed-never and disarmed read alike. |
| 268 | function setHandoff(on) { |
| 269 | try { |
| 270 | if (on) localStorage.setItem(LS_HANDOFF, '1'); |
| 271 | else localStorage.removeItem(LS_HANDOFF); |
| 272 | } catch (e) { /* private mode: nothing to persist, and off is the safe read */ } |
| 273 | } |
| 274 | |
| 275 | function label(name) { return t('permmode.' + name); } |
| 276 | function blurb(name) { return t('permmode.' + name + '_blurb'); } |
| 277 | |
| 278 | /// What this chat's network state says, as a sentence. |
| 279 | /// |
| 280 | /// A switch of LITERAL keys and not `t('permmode.net_' + state)`, because |
| 281 | /// `i18ncheck` cannot follow a key a call site builds: a composed one would |
| 282 | /// have to be declared indirect, and five sentences that no sweep can see are |
| 283 | /// five sentences that quietly stop being translated. |
| 284 | function netSays(state) { |
| 285 | switch (state) { |
| 286 | case 'open': return t('permmode.net_open'); |
| 287 | case 'cut': return t('permmode.net_cut'); |
| 288 | case 'allowed': return t('permmode.net_allowed'); |
| 289 | case 'refused': return t('permmode.net_refused'); |
| 290 | default: return ''; |
| 291 | } |
| 292 | } |
| 293 | |
| 294 | /// This chat's network state, or '' where no chat can be asked -- before the |
| 295 | /// engine exists, or on a surface that holds no conversation. |
| 296 | /// |
| 297 | /// Bypass is answered here rather than in the wasm, which correctly reports |
| 298 | /// `open`: the rung withholds nothing, so there is nothing to grant, and a |
| 299 | /// button offering to grant it would do nothing and say it had. |
| 300 | function netState() { |
| 301 | if (current === 'bypass') return 'bypass'; |
| 302 | if (typeof cfg.netGet !== 'function') return ''; |
| 303 | var s = ''; |
| 304 | try { s = String(cfg.netGet() || ''); } catch (e) { s = ''; } |
| 305 | // A chat with no engine yet has read nothing, which is what a fresh one with |
| 306 | // an engine also reports. Returning '' would hide the section on exactly the |
| 307 | // new chat where somebody wants to see what they have standing. |
| 308 | return s || 'open'; |
| 309 | } |
| 310 | |
| 311 | /// What was saved, or the guarded rung. A stored value this build does not |
| 312 | /// know is NOT rounded to the nearest thing: it falls back, because the safe |
| 313 | /// reading of "I do not recognise that" is "give them the careful one". |
| 314 | function load() { |
| 315 | var raw = ''; |
| 316 | try { raw = localStorage.getItem(LS_MODE) || ''; } catch (e) { raw = ''; } |
| 317 | return MODES.indexOf(raw) >= 0 ? raw : FALLBACK; |
| 318 | } |
| 319 | |
| 320 | function save(name) { |
| 321 | try { localStorage.setItem(LS_MODE, name); } catch (e) { /* private mode */ } |
| 322 | } |
| 323 | |
| 324 | /// The standing answer, or '' where the user has not given one. |
| 325 | /// |
| 326 | /// A value this build does not know falls back to asking, for the same reason a |
| 327 | /// stored rung does: the safe reading of "I do not recognise that" is the careful |
| 328 | /// one, and here the careful one is to put the question. |
| 329 | function standing() { |
| 330 | var raw = ''; |
| 331 | try { raw = localStorage.getItem(LS_NET) || ''; } catch (e) { raw = ''; } |
| 332 | return (raw === 'allow' || raw === 'refuse') ? raw : ''; |
| 333 | } |
| 334 | |
| 335 | /// Record it, and push it into every engine that already exists. |
| 336 | /// |
| 337 | /// BOTH HALVES. Storing it alone would leave every chat already open answering |
| 338 | /// the old way until it was reloaded, and setting the engines alone would lose it |
| 339 | /// on the next reload -- which is the defect this whole thing exists to fix. |
| 340 | function setStanding(v) { |
| 341 | try { localStorage.setItem(LS_NET, v); } catch (e) { /* private mode */ } |
| 342 | if (typeof cfg.netApplyAll === 'function') { |
| 343 | try { cfg.netApplyAll(v); } catch (e) { /* one engine gone is not a failure */ } |
| 344 | } |
| 345 | draw(); |
| 346 | if (pop && !pop.hidden) render(); |
| 347 | } |
| 348 | |
| 349 | function acked() { |
| 350 | try { return localStorage.getItem(LS_ACK) === '1'; } catch (e) { return false; } |
| 351 | } |
| 352 | |
| 353 | function ack() { |
| 354 | try { localStorage.setItem(LS_ACK, '1'); } catch (e) { /* private mode */ } |
| 355 | } |
| 356 | |
| 357 | /// Push the rung into the wasm, which is the only copy that decides anything. |
| 358 | /// |
| 359 | /// If it will not take — an older wasm without the setter, a name it refuses |
| 360 | /// — the JavaScript copy is put BACK to whatever the wasm still holds. A page |
| 361 | /// showing "Bypass" over an engine running guarded is worse than either. |
| 362 | function push(name) { |
| 363 | if (typeof cfg.apply !== 'function') return false; |
| 364 | try { cfg.apply(name); return true; } |
| 365 | catch (e) { return false; } |
| 366 | } |
| 367 | |
| 368 | function draw() { |
| 369 | if (chipTxt) chipTxt.textContent = label(current); |
| 370 | if (chip) { |
| 371 | chip.dataset.mode = current; |
| 372 | // Accent, not alarm. Bypass is the rung many people will live in, so |
| 373 | // it is marked as "not the default" rather than scolded — and the word |
| 374 | // carries the state regardless, so nothing rests on the colour. |
| 375 | chip.classList.toggle('accent', current === 'bypass'); |
| 376 | // The hover names what THIS rung does. It used to name the category -- |
| 377 | // "Permission mode: what Daimond does without asking" -- which is the one |
| 378 | // thing somebody hovering a button marked Guarded can already see, while |
| 379 | // the sentence that answers them sat a click away in the popover. |
| 380 | chip.title = label(current) + ' — ' + blurb(current); |
| 381 | // A chat whose commands have lost the network says so on the button, |
| 382 | // because it is a state that changes what a command can do and nothing |
| 383 | // on screen showed it. The dot was already in the markup doing nothing. |
| 384 | var ns = netState(); |
| 385 | var cut = (ns === 'cut' || ns === 'refused'); |
| 386 | chip.classList.toggle('net-cut', cut); |
| 387 | chip.setAttribute('aria-label', t('permmode.chip_aria', { mode: label(current) }) |
| 388 | + (cut ? ' ' + t('permmode.net_cut_mark') : '')); |
| 389 | } |
| 390 | var row = document.getElementById('astat-hand'); |
| 391 | if (row) { |
| 392 | row.innerHTML = ''; |
| 393 | var dot = document.createElement('span'); |
| 394 | dot.className = 'astat-dot ' + (current === 'bypass' ? 'warn' : 'ok'); |
| 395 | var val = document.createElement('span'); |
| 396 | val.className = 'astat-val'; |
| 397 | val.textContent = t('permmode.astat', { mode: label(current) }); |
| 398 | row.appendChild(dot); |
| 399 | row.appendChild(val); |
| 400 | row.title = t('permmode.chip_help'); |
| 401 | row.onclick = function () { open(row); }; |
| 402 | } |
| 403 | if (pop && !pop.hidden) render(); |
| 404 | } |
| 405 | |
| 406 | /// Move to a rung. Bypass explains itself the first time and never again. |
| 407 | async function set(name) { |
| 408 | if (MODES.indexOf(name) < 0) return false; |
| 409 | if (name === current) { close(); return true; } |
| 410 | if (name === 'bypass' && !acked()) { |
| 411 | var ok = await cfg.confirm( |
| 412 | t('permmode.bypass_body'), |
| 413 | t('permmode.bypass_ok'), |
| 414 | { title: t('permmode.bypass_title'), danger: false }); |
| 415 | if (!ok) { draw(); return false; } |
| 416 | ack(); |
| 417 | } |
| 418 | if (!push(name)) { |
| 419 | // The engine would not take it, so nothing changed. Say so rather than |
| 420 | // drawing a mode that is not in force. |
| 421 | if (typeof cfg.notice === 'function') cfg.notice(t('permmode.failed')); |
| 422 | draw(); |
| 423 | return false; |
| 424 | } |
| 425 | current = name; |
| 426 | save(name); |
| 427 | // Stamp WHEN this rung was chosen, so the account policy can travel and a |
| 428 | // later choice on either device wins the merge. Written here, where a real |
| 429 | // choice is made, and never in `snapshotPolicy` -- see the note there. |
| 430 | try { localStorage.setItem(LS_MODE_AT, String(Date.now())); } catch (e) { /* private mode */ } |
| 431 | draw(); |
| 432 | close(); |
| 433 | nudge(); |
| 434 | if (typeof cfg.onChange === 'function') cfg.onChange(name); |
| 435 | return true; |
| 436 | } |
| 437 | |
| 438 | // ── The picker ────────────────────────────────────────────── |
| 439 | // A `.pop`, like the appearance menu and the panel gallery, so it dismisses |
| 440 | // on Escape and on a click outside exactly as they do — and a radio group |
| 441 | // inside it, so the arrow keys work without a line of code. |
| 442 | |
| 443 | function render() { |
| 444 | pop.innerHTML = ''; |
| 445 | // The way out, in the corner every other closer in the app holds. This |
| 446 | // popover is 359px wide on a 390px phone and left 8px of screen to its |
| 447 | // right to tap: Escape and a click outside are not enough on their own. |
| 448 | if (window.DaimondCloser) { |
| 449 | pop.appendChild(DaimondCloser.head(t('permmode.title'), { onClose: close })); |
| 450 | } |
| 451 | var h = document.createElement('div'); |
| 452 | h.className = 'pop-head'; |
| 453 | h.textContent = t('permmode.lead'); |
| 454 | pop.appendChild(h); |
| 455 | MODES.forEach(function (name) { |
| 456 | var row = document.createElement('label'); |
| 457 | row.className = 'mode-row' + (name === current ? ' on' : ''); |
| 458 | var r = document.createElement('input'); |
| 459 | r.type = 'radio'; |
| 460 | r.name = 'daimond-permission-mode'; |
| 461 | r.value = name; |
| 462 | r.checked = name === current; |
| 463 | r.addEventListener('change', function () { set(name); }); |
| 464 | var txt = document.createElement('span'); |
| 465 | txt.className = 'mode-row-txt'; |
| 466 | var nm = document.createElement('span'); |
| 467 | nm.className = 'mode-row-name'; |
| 468 | nm.textContent = label(name); |
| 469 | var bl = document.createElement('span'); |
| 470 | bl.className = 'mode-row-blurb'; |
| 471 | bl.textContent = blurb(name); |
| 472 | txt.appendChild(nm); |
| 473 | txt.appendChild(bl); |
| 474 | row.appendChild(r); |
| 475 | row.appendChild(txt); |
| 476 | pop.appendChild(row); |
| 477 | }); |
| 478 | var foot = document.createElement('p'); |
| 479 | foot.className = 'pop-note'; |
| 480 | foot.textContent = t('permmode.never'); |
| 481 | pop.appendChild(foot); |
| 482 | renderAutonomous(); |
| 483 | renderStepAway(); |
| 484 | renderNet(); |
| 485 | } |
| 486 | |
| 487 | /// This computer's autonomous posture, under a head of its own. |
| 488 | /// |
| 489 | /// It is app-wide like the rungs and device-local like them, so it sits above |
| 490 | /// the per-chat network section and below the ladder. Not a fourth rung and not |
| 491 | /// a per-chat control: it is one machine's standing decision to get on with work |
| 492 | /// dispatched to it while nobody is here. Off until it is turned on, and the |
| 493 | /// body says in plain words what a yes lets the machine do and what bounds it -- |
| 494 | /// the account's provider and Daimond credit, not a dialog. |
| 495 | function renderAutonomous() { |
| 496 | var head = document.createElement('div'); |
| 497 | head.className = 'pop-head'; |
| 498 | head.textContent = tOr('autonomous.head', 'Work on its own when you’re away'); |
| 499 | pop.appendChild(head); |
| 500 | var body = document.createElement('p'); |
| 501 | body.className = 'pop-note'; |
| 502 | body.textContent = tOr('autonomous.body', |
| 503 | 'Let this computer finish tasks you’ve dispatched to it without asking — it ' |
| 504 | + 'will reach the web, act on pages and run commands on its own. The limit ' |
| 505 | + 'is your provider and Daimond credit. This is set for THIS computer only ' |
| 506 | + 'and is off until you turn it on.'); |
| 507 | pop.appendChild(body); |
| 508 | var row = document.createElement('label'); |
| 509 | row.className = 'dlg-tick auto-row'; |
| 510 | var box = document.createElement('input'); |
| 511 | box.type = 'checkbox'; |
| 512 | box.className = 'dlg-tick-box'; |
| 513 | box.checked = autoOn(); |
| 514 | box.setAttribute('aria-label', tOr('autonomous.switch', 'Work unattended on this computer')); |
| 515 | box.addEventListener('change', function () { setAuto(box.checked); }); |
| 516 | var say = document.createElement('span'); |
| 517 | say.textContent = tOr('autonomous.switch', 'Work unattended on this computer'); |
| 518 | row.appendChild(box); |
| 519 | row.appendChild(say); |
| 520 | pop.appendChild(row); |
| 521 | } |
| 522 | |
| 523 | /// This computer's step-away hand-off, under a head of its own. |
| 524 | /// |
| 525 | /// App-wide and device-local like the autonomous posture it sits beside, and a |
| 526 | /// different promise: not "work while I am gone" but "if I close this while a |
| 527 | /// turn is running, move it to a device that is awake so it finishes there and |
| 528 | /// syncs back", rather than leaving it interrupted until this one returns. Off |
| 529 | /// until it is turned on, and for THIS computer only. |
| 530 | function renderStepAway() { |
| 531 | var head = document.createElement('div'); |
| 532 | head.className = 'pop-head'; |
| 533 | head.textContent = tOr('handoff.head', 'Hand off when you step away'); |
| 534 | pop.appendChild(head); |
| 535 | var body = document.createElement('p'); |
| 536 | body.className = 'pop-note'; |
| 537 | body.textContent = tOr('handoff.body', |
| 538 | 'If you close this computer while a turn is running, hand it to another ' |
| 539 | + 'device that is awake so it finishes there and syncs back, instead of ' |
| 540 | + 'waiting for you to return. This is set for THIS computer only and is ' |
| 541 | + 'off until you turn it on.'); |
| 542 | pop.appendChild(body); |
| 543 | var row = document.createElement('label'); |
| 544 | row.className = 'dlg-tick auto-row'; |
| 545 | var box = document.createElement('input'); |
| 546 | box.type = 'checkbox'; |
| 547 | box.className = 'dlg-tick-box'; |
| 548 | box.checked = handoffOn(); |
| 549 | box.setAttribute('aria-label', tOr('handoff.switch', 'Hand my turns to another device when I step away')); |
| 550 | box.addEventListener('change', function () { setHandoff(box.checked); }); |
| 551 | var say = document.createElement('span'); |
| 552 | say.textContent = tOr('handoff.switch', 'Hand my turns to another device when I step away'); |
| 553 | row.appendChild(box); |
| 554 | row.appendChild(say); |
| 555 | pop.appendChild(row); |
| 556 | } |
| 557 | |
| 558 | /// This chat's own network, under the rungs and under a head of its own. |
| 559 | /// |
| 560 | /// The head is what carries the scope: everything above it is the whole app |
| 561 | /// and everything below it is this conversation, and without that line the |
| 562 | /// section reads as a fourth rung -- which it is not, and which would make a |
| 563 | /// per-chat state look like a setting that outlives the chat. |
| 564 | /// |
| 565 | /// Nothing is drawn where there is no chat to answer for. |
| 566 | function renderNet() { |
| 567 | var state = netState(); |
| 568 | if (!state) return; |
| 569 | var head = document.createElement('div'); |
| 570 | head.className = 'pop-head'; |
| 571 | // It said "This chat", and that became false the moment the control below it |
| 572 | // became a STANDING answer: the sentence describes this conversation, the |
| 573 | // three choices govern every one of them. A head naming one scope over a |
| 574 | // section holding both is the kind of label that teaches somebody the wrong |
| 575 | // thing and is never corrected. It names the subject instead, and the choices |
| 576 | // say their own scope -- "Ask once per chat" is a rule about chats, not a |
| 577 | // state of one. |
| 578 | // |
| 579 | // AND THEN IT SAID "The network", WHICH IS WIDER THAN WHAT IT GOVERNS. This |
| 580 | // section answers ONE question: whether a COMMAND run on the user's machine |
| 581 | // keeps its network after the turn has read something written by somebody |
| 582 | // else. `daimond.js`'s `run_net` branch is the only reader of the standing |
| 583 | // answer, `net_step` in src/tools.rs is the only thing that consults it, and |
| 584 | // neither is on the path a page fetch takes. Every sentence under this head |
| 585 | // already said "Commands"; the head did not, and a reader who set it to |
| 586 | // "Always allow" was reasonably surprised to be asked about the next |
| 587 | // `web_fetch` anyway. Reported by the owner in those words. |
| 588 | // |
| 589 | // The label was one bug and the behaviour was another. The head is right and |
| 590 | // stays: this section really is about commands, and naming it for the whole |
| 591 | // network was how a reader came to expect it to answer for page fetches too. |
| 592 | // The rest of that report was answered on 2026-08-27, at the other door: a |
| 593 | // page fetch is now asked once per conversation instead of once per site. |
| 594 | // See the note beside `permmode.net_not_fetch` below. |
| 595 | head.textContent = t('permmode.net_head'); |
| 596 | pop.appendChild(head); |
| 597 | var line = document.createElement('p'); |
| 598 | line.className = 'pop-note net-now'; |
| 599 | line.textContent = (state === 'bypass') ? t('permmode.net_bypass') : netSays(state); |
| 600 | pop.appendChild(line); |
| 601 | // Bypass withholds nothing, so there is nothing to grant and no control. |
| 602 | if (state === 'bypass') return; |
| 603 | // THREE CHOICES AND NOT A TOGGLE, because a toggle has no way back to the |
| 604 | // default. A two-state button would let a user leave "ask me" and never |
| 605 | // return to it -- the same no-way-back this section was built to end, rebuilt |
| 606 | // one level up. They are chips on one row rather than a second ladder: the |
| 607 | // rungs above are a policy with reasons, this is one answer with three values. |
| 608 | var now = standing(); |
| 609 | var row = document.createElement('div'); |
| 610 | row.className = 'net-row'; |
| 611 | [['', 'permmode.net_each'], |
| 612 | ['allow', 'permmode.net_always'], |
| 613 | ['refuse', 'permmode.net_never']].forEach(function (pair) { |
| 614 | var b = document.createElement('button'); |
| 615 | b.type = 'button'; |
| 616 | b.className = 'chip-btn net-opt' + (pair[0] === now ? ' on' : ''); |
| 617 | b.setAttribute('aria-pressed', pair[0] === now ? 'true' : 'false'); |
| 618 | // Four literal keys, for the reason `netSays` is written the way it is. |
| 619 | b.textContent = pair[1] === 'permmode.net_each' ? t('permmode.net_each') |
| 620 | : pair[1] === 'permmode.net_always' ? t('permmode.net_always') |
| 621 | : t('permmode.net_never'); |
| 622 | b.addEventListener('click', function () { setStanding(pair[0]); }); |
| 623 | row.appendChild(b); |
| 624 | }); |
| 625 | pop.appendChild(row); |
| 626 | // AND WHAT THIS IS NOT, said here rather than left to be discovered by |
| 627 | // being asked. A page Daimond fetches for itself is a different act with a |
| 628 | // different door -- `egressAllowed`, and `web_step` in src/tools.rs, which |
| 629 | // ask ONCE IN EACH CONVERSATION and then cover every site until that |
| 630 | // conversation ends. |
| 631 | // |
| 632 | // THE SENTENCE THAT STOOD HERE SAID "asks per SITE and remembers a yes for |
| 633 | // that site only", and it was true when it was written and false a day |
| 634 | // later. The owner ruled on 2026-08-27 that asking about every new site was |
| 635 | // the defect rather than the label: "I should only be asked once in a |
| 636 | // session ... for permission to access ANY (not a specific website)." The |
| 637 | // note is kept because a line here that describes the other door is exactly |
| 638 | // the kind of line that goes quietly out of date, and this one already has |
| 639 | // once. |
| 640 | // |
| 641 | // What has NOT changed is that this control does not govern that door. It |
| 642 | // answers one question -- whether a COMMAND on the user's machine keeps its |
| 643 | // network after the turn has read a stranger's words -- and a yes here is |
| 644 | // still not a yes there. `net_step` reads `net_consent` and nothing else; |
| 645 | // `web_step` reads `web_consent` and nothing else. |
| 646 | var also = document.createElement('p'); |
| 647 | also.className = 'pop-note'; |
| 648 | also.textContent = t('permmode.net_not_fetch'); |
| 649 | pop.appendChild(also); |
| 650 | } |
| 651 | |
| 652 | function open(anchor) { |
| 653 | if (!pop) return; |
| 654 | if (!pop.hidden) { close(); return; } |
| 655 | // The chip too, and not only the popover about to be drawn over it. Opening |
| 656 | // the menu is the one moment the state is certainly being read, and a button |
| 657 | // left saying the opposite of the panel hanging off it is worse than either. |
| 658 | draw(); |
| 659 | render(); |
| 660 | pop.hidden = false; |
| 661 | if (chip) chip.setAttribute('aria-expanded', 'true'); |
| 662 | var r = (anchor || chip).getBoundingClientRect(); |
| 663 | pop.style.top = (r.bottom + 6) + 'px'; |
| 664 | var left = Math.min(r.left, window.innerWidth - pop.offsetWidth - 8); |
| 665 | pop.style.left = Math.max(8, left) + 'px'; |
| 666 | var first = pop.querySelector('input[type=radio]:checked') || pop.querySelector('input'); |
| 667 | if (first) first.focus(); |
| 668 | } |
| 669 | |
| 670 | function close() { |
| 671 | if (!pop || pop.hidden) return; |
| 672 | pop.hidden = true; |
| 673 | if (chip) { |
| 674 | chip.setAttribute('aria-expanded', 'false'); |
| 675 | try { chip.focus(); } catch (e) { /* gone from the page */ } |
| 676 | } |
| 677 | } |
| 678 | |
| 679 | /// Wire the surfaces and put the saved rung into the engine. |
| 680 | /// |
| 681 | /// `apply` is the wasm setter, which only `daimond.js` can reach: this file |
| 682 | /// is a classic script and the wasm is a module. `confirm` and `notice` are |
| 683 | /// the app's own dialog and toast, for the same reason. |
| 684 | /// |
| 685 | /// `netGet` and `netSet` reach the CURRENT chat's engine, which only the app |
| 686 | /// knows -- this file has no notion of which conversation is on screen, and |
| 687 | /// must not acquire one: a permission surface that picked its own subject |
| 688 | /// could answer for a chat the user is not looking at. |
| 689 | function init(opts) { |
| 690 | cfg = opts || {}; |
| 691 | chip = document.getElementById('hand-mode-chip'); |
| 692 | chipTxt = document.getElementById('hand-mode-chip-txt'); |
| 693 | pop = document.getElementById('hand-mode-pop'); |
| 694 | current = load(); |
| 695 | // The engine is the authority. If the saved rung will not go in, the page |
| 696 | // shows the guarded one the wasm is actually still in. |
| 697 | if (!push(current)) current = FALLBACK; |
| 698 | if (chip) chip.addEventListener('click', function (e) { e.stopPropagation(); open(chip); }); |
| 699 | document.addEventListener('keydown', function (e) { |
| 700 | if (e.key === 'Escape') close(); |
| 701 | }); |
| 702 | document.addEventListener('click', function (e) { |
| 703 | if (!pop || pop.hidden) return; |
| 704 | var path = e.composedPath ? e.composedPath() : null; |
| 705 | var inside = path ? (path.indexOf(pop) >= 0 || path.indexOf(chip) >= 0) |
| 706 | : (pop.contains(e.target) || (chip && chip.contains(e.target))); |
| 707 | if (!inside) close(); |
| 708 | }); |
| 709 | if (window.DaimondI18n) DaimondI18n.onChange(draw); |
| 710 | draw(); |
| 711 | } |
| 712 | |
| 713 | window.DaimondHandMode = { |
| 714 | init: init, |
| 715 | /// The standing answer to the network question, for `ensureApp` to put into |
| 716 | /// each new engine. '' means the engine is left to ask. |
| 717 | standingNet: standing, |
| 718 | /// Record a standing answer from somewhere other than these chips. |
| 719 | /// |
| 720 | /// The dialog is the caller. A person who answers YES has consented, in the |
| 721 | /// clearest way the app has, and asking them the same question in the next |
| 722 | /// chat treats that answer as though it had not been given -- which is what |
| 723 | /// happened, and what was reported three times. One yes is enough. |
| 724 | setStandingNet: setStanding, |
| 725 | /// Redraw the chip from what is in force NOW. |
| 726 | /// |
| 727 | /// The rung only moves when this file moves it, so the chip could always |
| 728 | /// draw itself. This chat's network cannot: a turn that reads a page marks |
| 729 | /// the chat while the popover is shut, and the mark on the button is the |
| 730 | /// only thing on screen that says so. The app calls this where the |
| 731 | /// conversation on screen changes and where a turn ends. |
| 732 | refresh: draw, |
| 733 | get: function () { return current; }, |
| 734 | set: set, |
| 735 | list: function () { |
| 736 | return MODES.map(function (n) { return { name: n, label: label(n) }; }); |
| 737 | }, |
| 738 | // ── The account-level policy that rides the sync parcel ────── |
| 739 | /// The policy this device holds, deterministic, for collectSync's `perms`. |
| 740 | snapshotPolicy: snapshotPolicy, |
| 741 | /// Merge a policy pulled from another device. Freshest-wins per fact. |
| 742 | adoptPolicy: adoptPolicy, |
| 743 | /// Does the account's standing policy grant this scope? Read at the consent |
| 744 | /// site, so a runner does not re-ask for what the account already allows. |
| 745 | scopeGranted: scopeGranted, |
| 746 | /// Grant (or clear) an account-level scope, stamped now and nudged out. |
| 747 | grantScope: grantScope, |
| 748 | }; |
| 749 | })(); |