11.3 KiB, 1 run
created by r2519314175:877, 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 | // The one place permission is given. |
| 2 | // |
| 3 | // chrome.permissions.request needs a real user gesture, and a message from a web |
| 4 | // page is not one -- deliberately. So the question is put here, in the |
| 5 | // extension's own window, where a click is a click and Chrome will honour it. |
| 6 | // |
| 7 | // Three questions are asked here, never together: |
| 8 | // 'site' -- may Daimond operate this one site? |
| 9 | // 'mirror' -- may Daimond photograph the tab, so the panel can show it? |
| 10 | // 'hand' -- may Daimond run commands on this computer? |
| 11 | // |
| 12 | // The third is not a Chrome permission and cannot be: `nativeMessaging` is |
| 13 | // granted at install and there is nothing to ask Chrome for a second time. So |
| 14 | // for that one, this window IS the approval rather than the step before it, and |
| 15 | // the extension records the answer itself -- which is also what makes it |
| 16 | // revocable, since Chrome has nothing to take away. |
| 17 | // |
| 18 | // It is asked in the language the app is speaking. This is the one window in |
| 19 | // the product that asks the user to trust something, and a question nobody can |
| 20 | // read is not a question. |
| 21 | // |
| 22 | // TWO SCREENS, NOT TWO WINDOWS. What a person needs in order to decide is on |
| 23 | // the surface: what is being asked, which folder, which page asked, and the two |
| 24 | // buttons. Everything else -- the reasoning, the caveats, what the compartment |
| 25 | // does and does not stop -- is behind one disclosure, a Tab and a keypress away. |
| 26 | // Nothing was cut. A window nobody finishes reading is a window nobody |
| 27 | // understands, and an approval given without understanding is the failure this |
| 28 | // whole flow exists to avoid; but so is a short window that hides the fact that |
| 29 | // a website is being handed the ability to run programs. So the first screen |
| 30 | // still says, in its own words, that programs would run as this user with this |
| 31 | // user's files, whether the machine can hold them to a folder, and that this is |
| 32 | // the strongest thing Daimond can be allowed to do. |
| 33 | |
| 34 | 'use strict'; |
| 35 | |
| 36 | (() => { |
| 37 | |
| 38 | const $ = (id) => document.getElementById(id); |
| 39 | const I = globalThis.DaimondExtI18n; |
| 40 | const t = (...a) => I.t(...a); |
| 41 | const q = new URLSearchParams(location.search); |
| 42 | const nonce = q.get('nonce'); |
| 43 | const kind = q.get('kind') || 'site'; |
| 44 | const host = q.get('host') || ''; |
| 45 | const pat = q.get('pattern') || ''; |
| 46 | const origin = q.get('origin') || ''; |
| 47 | /// What the hand said it can enforce on this machine, space separated, from |
| 48 | /// its `hello`. Empty means it never said, which is a third answer and not |
| 49 | /// the same as saying no. |
| 50 | const caps = (q.get('caps') || '').split(/\s+/).filter(Boolean); |
| 51 | |
| 52 | /// Is a compartment actually in force on this machine? |
| 53 | /// |
| 54 | /// `fence.rs` answers `fence:none` rather than an empty list when there is |
| 55 | /// nothing, precisely so silence and "no fence" are told apart, and |
| 56 | /// `fence:waived` is what a user who turned it off gets. Anything else |
| 57 | /// beginning `fence:` is a mechanism that is really there. |
| 58 | function fenced() { |
| 59 | if (caps.indexOf('fence:none') >= 0 || caps.indexOf('fence:waived') >= 0) return false; |
| 60 | return caps.some((c) => c.indexOf('fence:') === 0); |
| 61 | } |
| 62 | |
| 63 | /// Is every run written down where the user can read it? |
| 64 | function journalled() { |
| 65 | return caps.some((c) => c === 'journal' || c.indexOf('journal:') === 0); |
| 66 | } |
| 67 | |
| 68 | /// The folder the hand says the grant covers, or '' where it named none. |
| 69 | /// |
| 70 | /// It travels as a `root:` capability because `wire.rs` has no field for it |
| 71 | /// -- see `hand/src/main.rs`. The page cannot work the path out for itself: |
| 72 | /// the File System Access API hands it a handle and never a name. So this is |
| 73 | /// the only place the folder can come from, and a window that did not show |
| 74 | /// it would be asking about a compartment without saying where it is. |
| 75 | function rootDir() { |
| 76 | const c = caps.find((s) => s.indexOf('root:') === 0); |
| 77 | return c ? c.slice(5) : ''; |
| 78 | } |
| 79 | |
| 80 | /// Puts a node behind the disclosure, keeping the order it is given in. |
| 81 | function conceal() { |
| 82 | $('moreBody').prepend.apply($('moreBody'), arguments); |
| 83 | } |
| 84 | |
| 85 | /// Sizes the window to its first screen. |
| 86 | /// |
| 87 | /// The window is created before anything is known about the language it will |
| 88 | /// be written in, and a height measured against English clips German while |
| 89 | /// leaving an inch of nothing under Chinese. The sheet scrolls rather than |
| 90 | /// clipping, so nothing is ever lost -- but a sentence a person has to scroll |
| 91 | /// to is a sentence they did not read, and the whole point of the split is |
| 92 | /// that the first screen IS read. So the height in background.js is a |
| 93 | /// starting guess and this corrects it, in either direction, once, before the |
| 94 | /// window has been shown long enough for anyone to have dragged it. |
| 95 | /// |
| 96 | /// Bounded both ways: never past the screen, and never so small that the two |
| 97 | /// buttons and the brand have nowhere to sit. |
| 98 | function fit() { |
| 99 | try { |
| 100 | const sheet = document.querySelector('.sheet'); |
| 101 | const have = sheet.clientHeight; |
| 102 | // What the sheet WANTS. `scrollHeight` alone cannot say: it is |
| 103 | // clamped to the box, so it reports the overflow and never the slack, |
| 104 | // and a window sized from it could grow but never give anything back. |
| 105 | // Letting the sheet take its content height for one measurement is |
| 106 | // the only way to see both. |
| 107 | const flex = sheet.style.flex; |
| 108 | sheet.style.flex = '0 0 auto'; |
| 109 | const need = Math.ceil(sheet.scrollHeight); |
| 110 | sheet.style.flex = flex; |
| 111 | const delta = need - have; |
| 112 | if (Math.abs(delta) < 4) return; |
| 113 | chrome.windows.getCurrent((w) => { |
| 114 | if (!w || w.height == null) return; |
| 115 | const room = Math.max(320, (screen.availHeight || 900) - 60); |
| 116 | chrome.windows.update(w.id, |
| 117 | { height: Math.max(300, Math.min(w.height + delta + 2, room)) }); |
| 118 | }); |
| 119 | } catch (e) { /* no windows API here; the sheet still scrolls */ } |
| 120 | } |
| 121 | |
| 122 | /// Writes the question. Called once the table is in, so the window is never |
| 123 | /// read half in one language and half in another. |
| 124 | function draw() { |
| 125 | I.paint(); |
| 126 | if (kind === 'mirror') { |
| 127 | $('head').textContent = t('grant_mirror_head'); |
| 128 | $('body').textContent = t('grant_mirror_body'); |
| 129 | $('fine').textContent = t('grant_mirror_fine'); |
| 130 | $('allow').textContent = t('grant_mirror_allow'); |
| 131 | } else if (kind === 'hand') { |
| 132 | $('head').textContent = t('grant_hand_head'); |
| 133 | // The short of it, and the line on the first screen that decides the |
| 134 | // question. `hand/README.md`'s first release gate is that the wording |
| 135 | // is chosen from `caps` rather than hard-coded: a sentence about |
| 136 | // folders, on a machine with no fence, is a promise the code does not |
| 137 | // keep, and a promise about safety that is not kept is worse than |
| 138 | // none. Three answers, because there are three: it fences, it does |
| 139 | // not, or it did not say. |
| 140 | $('lead').hidden = false; |
| 141 | $('lead').textContent = caps.length === 0 ? t('grant_hand_lead_unknown') |
| 142 | : fenced() ? t('grant_hand_lead') |
| 143 | : t('grant_hand_lead_nofence'); |
| 144 | // Which folder. A fact about the decision rather than a detail of |
| 145 | // it: approving this for a project folder and approving it for a |
| 146 | // home directory are different answers to different questions. |
| 147 | const dir = rootDir(); |
| 148 | // Shown only where there is a fact to put in it: an empty grid is a |
| 149 | // gap the reader has to account for. |
| 150 | $('facts').hidden = !dir && !origin; |
| 151 | if (dir) { |
| 152 | $('folderlab').hidden = false; |
| 153 | $('folderlab').textContent = t('grant_hand_folder'); |
| 154 | $('folder').hidden = false; |
| 155 | $('folder').textContent = dir; |
| 156 | } |
| 157 | // The question is about the machine, not about a site -- but it is |
| 158 | // asked BY a page, and only that page is answered by it, so the page |
| 159 | // is named. A user with the app open in two places should be able to |
| 160 | // see which one is asking. |
| 161 | if (origin) { |
| 162 | $('hostlab').hidden = false; |
| 163 | $('hostlab').textContent = t('grant_hand_asked_by'); |
| 164 | $('host').hidden = false; |
| 165 | $('host').textContent = origin; |
| 166 | // A row of the facts grid here, not the boxed headline it is on |
| 167 | // the site question -- there the name IS the question, here it |
| 168 | // is one fact among several. |
| 169 | $('host').className = 'val'; |
| 170 | $('facts').appendChild($('host')); |
| 171 | } |
| 172 | // Kept on the first screen deliberately, and lifted out of the fine |
| 173 | // print to get there. It is the one sentence that stops a click, and |
| 174 | // a sentence that stops a click cannot sit behind a control that a |
| 175 | // hurried person does not press. |
| 176 | $('strongest').hidden = false; |
| 177 | $('strongest').textContent = t('grant_hand_strongest'); |
| 178 | // The long form of the same three answers, the capability list |
| 179 | // verbatim, and the fine print. All still said, all one click away. |
| 180 | $('body').textContent = caps.length === 0 ? t('grant_hand_body_unknown') |
| 181 | : fenced() ? t('grant_hand_body') |
| 182 | : t('grant_hand_body_nofence'); |
| 183 | $('scope').hidden = false; |
| 184 | $('scope').textContent = caps.length |
| 185 | ? t('grant_hand_caps', caps.join(', ')) |
| 186 | : t('grant_hand_caps_unknown'); |
| 187 | // The journal is the other half of the same promise, and it is not |
| 188 | // this machine's to make either until the hand says it keeps one. |
| 189 | $('fine').textContent = journalled() ? t('grant_hand_fine') : t('grant_hand_fine_nojournal'); |
| 190 | $('allow').textContent = t('grant_hand_allow'); |
| 191 | conceal($('body'), $('scope')); |
| 192 | } else { |
| 193 | $('head').textContent = t('grant_site_head'); |
| 194 | $('host').hidden = false; |
| 195 | $('host').textContent = host; |
| 196 | // Say what is actually being granted. The pattern is `*://*.host/*`, so it |
| 197 | // covers subdomains -- which is what a person means by "this site", and |
| 198 | // what a click that crosses to an app subdomain needs -- but a window that |
| 199 | // showed the bare host alone would be asking for more than it said. |
| 200 | $('scope').hidden = false; |
| 201 | $('scope').textContent = t('grant_site_scope', host); |
| 202 | $('body').textContent = t('grant_site_body'); |
| 203 | // Set the expectation before it happens: clicking Allow hands off to |
| 204 | // Chrome's own permission prompt, whose wording is alarming by design. |
| 205 | // A user warned it is coming, and told it is the real approval, is not |
| 206 | // ambushed by it after already clicking Allow once. Chrome says it in |
| 207 | // its OWN language, which is why this sentence quotes it rather than |
| 208 | // promising the words the user will see. |
| 209 | $('fine').textContent = t('grant_site_fine'); |
| 210 | $('allow').textContent = t('grant_site_allow'); |
| 211 | } |
| 212 | // The cautious button holds the focus, so a keyboard alone can answer the |
| 213 | // window and a reflexive Return refuses rather than approves. Everything |
| 214 | // else -- the disclosure, then Allow -- is a Tab away. |
| 215 | $('deny').focus(); |
| 216 | fit(); |
| 217 | } |
| 218 | |
| 219 | /// Tells the broker how the user answered, then closes. |
| 220 | /// |
| 221 | /// 'allowed' or 'declined' -- both are ANSWERS. A window that goes away |
| 222 | /// without sending one is a dismissal, and the broker reads that from the |
| 223 | /// window closing, not from here. |
| 224 | function answer(how) { |
| 225 | chrome.runtime.sendMessage({ type: 'grant', nonce, answer: how }, () => window.close()); |
| 226 | } |
| 227 | |
| 228 | $('allow').addEventListener('click', async () => { |
| 229 | // The machine hand has no Chrome permission behind it, so there is no |
| 230 | // second prompt to defer to. This click is the whole approval, and the |
| 231 | // broker writes it down. |
| 232 | if (kind === 'hand') return answer('allowed'); |
| 233 | try { |
| 234 | // Chrome's own prompt is the real approval. Refusing it there is a |
| 235 | // refusal, however this window was clicked. |
| 236 | const ok = await chrome.permissions.request({ origins: [pat] }); |
| 237 | answer(ok ? 'allowed' : 'declined'); |
| 238 | } catch (e) { |
| 239 | answer('declined'); |
| 240 | } |
| 241 | }); |
| 242 | |
| 243 | $('deny').addEventListener('click', () => answer('declined')); |
| 244 | |
| 245 | I.ready().then(draw); |
| 246 | |
| 247 | })(); |