@1agh/maude 0.53.2 → 0.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/apps/studio/annotations-layer.tsx +11 -1
  2. package/apps/studio/api.ts +13 -7
  3. package/apps/studio/bin/screenshot.sh +43 -3
  4. package/apps/studio/build.ts +37 -3
  5. package/apps/studio/canvas-build-sandbox.ts +101 -9
  6. package/apps/studio/canvas-build-worker.ts +7 -1
  7. package/apps/studio/canvas-build.ts +9 -1
  8. package/apps/studio/client/app.jsx +226 -25
  9. package/apps/studio/client/export-center.jsx +10 -2
  10. package/apps/studio/client/github.js +7 -0
  11. package/apps/studio/client/panels/CloudBar.jsx +461 -40
  12. package/apps/studio/client/panels/SettingsPanel.jsx +6 -1
  13. package/apps/studio/client/styles/3-shell-maude.css +30 -0
  14. package/apps/studio/client/styles/4-components.css +40 -0
  15. package/apps/studio/cloud/endpoints.ts +48 -6
  16. package/apps/studio/cloud-build.ts +208 -0
  17. package/apps/studio/collab/room.ts +43 -7
  18. package/apps/studio/context.ts +12 -5
  19. package/apps/studio/dist/client.bundle.js +983 -983
  20. package/apps/studio/dist/comment-mount.js +2 -2
  21. package/apps/studio/dist/styles.css +1 -1
  22. package/apps/studio/git/repo-lock.ts +305 -0
  23. package/apps/studio/git/service.ts +171 -21
  24. package/apps/studio/http.ts +159 -13
  25. package/apps/studio/inspect.ts +66 -6
  26. package/apps/studio/server.ts +114 -17
  27. package/apps/studio/session-scope.ts +101 -0
  28. package/apps/studio/sync/autocommit.ts +84 -46
  29. package/apps/studio/sync/index.ts +20 -0
  30. package/apps/studio/test/canvas-origin-gate.test.ts +61 -0
  31. package/apps/studio/test/canvas-shell-base.test.ts +107 -0
  32. package/apps/studio/test/cloud-build.test.ts +95 -0
  33. package/apps/studio/test/cloud-endpoints.test.ts +231 -4
  34. package/apps/studio/test/cloud-shell-surfaces.test.ts +31 -2
  35. package/apps/studio/test/collab-readonly-gate.test.ts +194 -0
  36. package/apps/studio/test/git-system-engine.test.ts +135 -0
  37. package/apps/studio/test/read-only-gate.test.ts +8 -0
  38. package/apps/studio/test/repo-concurrency.test.ts +146 -0
  39. package/apps/studio/test/repo-lock.test.ts +350 -0
  40. package/apps/studio/test/server-lifecycle.test.ts +11 -1
  41. package/apps/studio/test/session-runtime-state.test.ts +226 -0
  42. package/apps/studio/test/session-scope.test.ts +100 -0
  43. package/apps/studio/test/shell-importmap.test.ts +8 -1
  44. package/apps/studio/test/sync-autocommit.test.ts +76 -0
  45. package/apps/studio/test/sync-hardening.test.ts +75 -0
  46. package/apps/studio/use-collab.tsx +21 -1
  47. package/apps/studio/whats-new.json +54 -0
  48. package/apps/studio/ws.ts +44 -8
  49. package/cli/commands/kg.mjs +49 -19
  50. package/cli/lib/gitignore-block.mjs +3 -0
  51. package/package.json +11 -9
  52. package/plugins/design/dependencies.json +1 -1
  53. package/plugins/design/templates/_shell.html +66 -32
  54. package/plugins/flow/.claude-plugin/config.schema.json +1 -1
  55. package/plugins/flow/dependencies.json +1 -1
@@ -14,7 +14,7 @@
14
14
 
15
15
  import { useEffect, useRef, useState } from 'react';
16
16
 
17
- import { invoke, isNativeApp, listen } from '../github.js';
17
+ import { invoke, isNativeApp, listen, openCloudUrl } from '../github.js';
18
18
 
19
19
  const api = async (path, init) => {
20
20
  const res = await fetch(path, init);
@@ -22,6 +22,87 @@ const api = async (path, init) => {
22
22
  return { ok: res.ok, status: res.status, json };
23
23
  };
24
24
 
25
+ /**
26
+ * Reach the OS browser from whichever surface this is running on.
27
+ *
28
+ * In the desktop shell `window.open` does nothing at all — WKWebView drops it
29
+ * silently — which is what used to strand a person mid-sign-in holding a code
30
+ * and no page to type it into. The shell therefore goes through the Rust
31
+ * opener, which zone-locks the URL against the configured cloud address (the
32
+ * webview is untrusted, DDR-054: there is deliberately no general opener).
33
+ *
34
+ * Best-effort by design: an older shell has no such command, a self-hosted
35
+ * origin can drift, and the browser can block the popup — so every caller ALSO
36
+ * shows the address as something the person can click or copy by hand.
37
+ */
38
+ /**
39
+ * Is this an address we are willing to render as a link at all?
40
+ *
41
+ * The zone lock lives in Rust, but the dialog SHOWS the address and offers to
42
+ * copy it — so an off-zone `verification_url` (hostile/compromised control
43
+ * plane, MITM'd plaintext self-host) would be presented in trusted app chrome
44
+ * with a label inviting the person to open it by hand. Refusing to render it is
45
+ * the only place that can be stopped (attacker pass 2026-08-04, B4).
46
+ */
47
+ export function isDisplayableUrl(url, cloudUrl) {
48
+ let u;
49
+ let base;
50
+ try {
51
+ u = new URL(String(url));
52
+ base = new URL(String(cloudUrl ?? DEFAULT_CLOUD_URL));
53
+ } catch {
54
+ return false;
55
+ }
56
+ if (!/^https?:$/.test(u.protocol)) return false;
57
+ // Same two arms the Rust opener enforces. A scheme check alone was NOT enough:
58
+ // `https://evil.example/activate?code=…` from a hostile or MITM'd control plane
59
+ // passed it and still rendered as a live href with a Copy button — and a
60
+ // middle-click or "Open link" never reaches the click handler, let alone Rust
61
+ // (attacker re-review 2026-08-04, NEW-3).
62
+ if (u.origin === base.origin) return true;
63
+ const host = u.hostname;
64
+ return (
65
+ u.protocol === 'https:' &&
66
+ !u.port &&
67
+ (u.pathname === '' || u.pathname === '/') &&
68
+ !u.search &&
69
+ !u.hash &&
70
+ (host === DEFAULT_CLOUD_HOST || host.endsWith(`.${DEFAULT_CLOUD_HOST}`))
71
+ );
72
+ }
73
+
74
+ /**
75
+ * Reach the OS browser. Resolves to true when the open was accepted.
76
+ *
77
+ * A refusal must NOT be swallowed: the Rust side refusing is precisely the
78
+ * security event, and a silently dead click under a label reading "if your
79
+ * browser didn't open, go to:" teaches the person to copy the address into a
80
+ * browser the lock cannot see (B4). Callers surface the failure instead.
81
+ */
82
+ function openExternal(url) {
83
+ if (!url) return Promise.resolve('failed');
84
+ if (isNativeApp()) {
85
+ return openCloudUrl(url).then(
86
+ () => 'ok',
87
+ // A REFUSAL is the security event and reads differently from "no browser
88
+ // would start" — collapsing both into one boolean lost the distinction the
89
+ // dialog needs to say something true.
90
+ (err) => (/Refusing to open/i.test(String(err)) ? 'refused' : 'failed')
91
+ );
92
+ }
93
+ // `window.open(..., 'noopener')` returns null even on SUCCESS (that is the
94
+ // whole point of noopener), so its result says nothing about whether a tab
95
+ // opened. Treating null as failure made the browser build print "Maude
96
+ // couldn't open your browser" on every single sign-in — training people that
97
+ // the one warning this dialog has is noise (attacker re-review, NEW-2).
98
+ try {
99
+ window.open(url, '_blank', 'noopener');
100
+ return Promise.resolve('ok');
101
+ } catch {
102
+ return Promise.resolve('failed');
103
+ }
104
+ }
105
+
25
106
  /**
26
107
  * Parse a maude://open/<project>?code=mhc_… deep link. Untrusted input — a
27
108
  * drive-by page can navigate to maude:// — so nothing here is acted on
@@ -31,21 +112,141 @@ const api = async (path, init) => {
31
112
  export function parseDeepLink(url) {
32
113
  const m = /^maude:\/\/open\/([a-z0-9]+(?:-[a-z0-9]+)*)\/?\?(.*)$/.exec(String(url ?? ''));
33
114
  if (!m) return null;
115
+ // The platform caps a project id at 40 chars (deriveProjectId), but that bound
116
+ // lives server-side and a drive-by maude:// link never passes through it — so
117
+ // without this, arbitrary attacker text renders in the dialog's heading, prose
118
+ // and aria-label. Nothing longer can name a real project anyway.
119
+ if (m[1].length > 40) return null;
34
120
  const code = new URLSearchParams(m[2]).get('code') ?? '';
35
121
  if (!/^mhc_[0-9a-f]{16,128}$/.test(code)) return null;
36
122
  return { project: m[1], code };
37
123
  }
38
124
 
39
- /** The share view's address for a project cell URL (viewer's browser home). */
125
+ /**
126
+ * A viewer's browser home for a project — the project's OWN address.
127
+ *
128
+ * It used to derive `view-<host>`, which no longer exists: the read-only gallery
129
+ * was deleted in Cloud Phase 25 C5, and `tenantFromHostname` now hard-refuses any
130
+ * `view-` label (deliberately, so a leftover custom domain can't spin up a cell
131
+ * literally named `view-alligators`). So this returned a guaranteed 404 — which
132
+ * was invisible while `window.open` was a no-op in the desktop shell, and would
133
+ * have become a visibly dead menu item the moment the opener started working
134
+ * (attacker re-review 2026-08-04). The browser door replaced the gallery: the
135
+ * real project, for people who actually have access.
136
+ */
40
137
  export function shareViewUrl(projectUrl, projectId) {
41
138
  try {
42
139
  const u = new URL(projectUrl);
43
- return `${u.protocol}//view-${u.host}`;
140
+ return `${u.protocol}//${u.host}`;
44
141
  } catch {
45
- return `https://view-${projectId}.cloud.maude.sh`;
142
+ return `https://${projectId}.${DEFAULT_CLOUD_HOST}`;
46
143
  }
47
144
  }
48
145
 
146
+ /** Where Maude Cloud lives by default — mirrors the Rust opener's compiled host. */
147
+ const DEFAULT_CLOUD_URL = 'https://cloud.maude.sh';
148
+ const DEFAULT_CLOUD_HOST = 'cloud.maude.sh';
149
+
150
+ /**
151
+ * Fold a name the way the PLATFORM does, so a folder and a project id can be
152
+ * compared at all.
153
+ *
154
+ * This mirrors `deriveProjectId` (apps/cloud/checkout.mjs) deliberately, NFKD
155
+ * strip and 40-char cap included. A plain kebab-case fold looked equivalent and
156
+ * wasn't: `Zkušební tým` derives the id `zkusebni-tym` on the platform but folded
157
+ * to `zku-ebn-t-m` here, so every accented project name failed to match its own
158
+ * workspace and warned on a perfectly legitimate connect — and any name over 40
159
+ * characters did the same, because the platform truncates and this didn't. A
160
+ * warning that fires on correct behaviour is a warning people learn to dismiss.
161
+ */
162
+ const norm = (s) =>
163
+ String(s ?? '')
164
+ .normalize('NFKD')
165
+ .replace(/[̀-ͯ]/g, '')
166
+ .toLowerCase()
167
+ .replace(/[^a-z0-9]+/g, '-')
168
+ .replace(/^-+|-+$/g, '')
169
+ .replace(/-{2,}/g, '-')
170
+ .slice(0, 40)
171
+ .replace(/-+$/, '');
172
+
173
+ /** The host an address carries, or '' if it isn't a readable URL. */
174
+ const hostOf = (url) => {
175
+ try {
176
+ return new URL(url).hostname;
177
+ } catch {
178
+ return '';
179
+ }
180
+ };
181
+
182
+ /** The project label a cell address carries, or '' if it carries none. */
183
+ function projectFromHubUrl(hubUrl, cloudHost) {
184
+ const host = hostOf(hubUrl);
185
+ if (!cloudHost || host === cloudHost) return '';
186
+ // `<project>.cloud.maude.sh` — only meaningful INSIDE the configured zone.
187
+ if (!host.endsWith(`.${cloudHost}`)) return '';
188
+ return norm(host.slice(0, -(cloudHost.length + 1)).replace(/^view-/, ''));
189
+ }
190
+
191
+ /**
192
+ * Does this folder look like the cloud project a link is naming?
193
+ *
194
+ * A hint about a decision the person makes — never an authorization gate. It
195
+ * exists because the confusing half of "Open in Maude" is that it attaches
196
+ * whatever folder happens to be open, so somebody with one project open who
197
+ * presses it on another is one click from syncing the wrong designs into the
198
+ * wrong workspace.
199
+ *
200
+ * WHAT THIS FUNCTION LEARNED THE HARD WAY (attacker pass 2026-08-04, B1): the
201
+ * first version treated a near-match — either name containing the other — as
202
+ * agreement, to avoid crying wolf over `alligators-web` vs `alligators`. That
203
+ * reasoning assumed a NON-ADVERSARIAL name collision. It isn't one: the name
204
+ * being compared against is a cloud project id, which anyone can register. An
205
+ * attacker registers `alligators-design-sync`, it contains `alligators`, and the
206
+ * hint falls silent on exactly the case it exists for. The server's 409 does not
207
+ * stand behind it either, because the claim and the actual project agree — it
208
+ * genuinely IS the attacker's project.
209
+ *
210
+ * So a near-match no longer resolves to "fine". It gets its OWN state, and says
211
+ * out loud that two similar names are still two different workspaces. Only an
212
+ * exact match (or a credentialed link) is silent.
213
+ *
214
+ * States: 'linked' (a real, credentialed link to this project) · 'match' (the
215
+ * names are the same) · 'similar' (alike but NOT the same — warn, softly) ·
216
+ * 'mismatch' (unrelated, or linked elsewhere — warn) · 'unknown' (no signal:
217
+ * status hasn't resolved, or the addresses carry no project name, as on every
218
+ * self-host and e2e stub at `http://127.0.0.1:<port>`). 'unknown' warns too —
219
+ * quietly, because failing open here is what B3 was.
220
+ */
221
+ export function localIdentityHint({
222
+ localProject,
223
+ linkedHubUrl,
224
+ linkedHubCredentialed,
225
+ cloudUrl,
226
+ claimed,
227
+ resolved = true,
228
+ }) {
229
+ if (!resolved) return 'unknown';
230
+ const want = norm(claimed);
231
+ if (!want) return 'unknown';
232
+
233
+ // A linkedHub only reassures when a stored credential corroborates it — the
234
+ // config file it comes from is committed and travels with the repo (B2).
235
+ const linkedProject = linkedHubUrl ? projectFromHubUrl(linkedHubUrl, hostOf(cloudUrl)) : '';
236
+ if (linkedProject && linkedHubCredentialed) {
237
+ return linkedProject === want ? 'linked' : 'mismatch';
238
+ }
239
+
240
+ // Otherwise the folder's own name is the signal — the one that catches the
241
+ // reported case (one project open, "Open in Maude" pressed on another).
242
+ const local = norm(localProject);
243
+ if (!local) return 'unknown';
244
+ if (local === want) return 'match';
245
+ const shorter = local.length <= want.length ? local : want;
246
+ const related = shorter.length >= 4 && (local.includes(want) || want.includes(local));
247
+ return related ? 'similar' : 'mismatch';
248
+ }
249
+
49
250
  function Spark({ size = 15 }) {
50
251
  return (
51
252
  <svg width={size} height={size} viewBox="0 0 32 32" fill="currentColor" aria-hidden="true">
@@ -70,6 +271,14 @@ function Icon({ name, size = 15 }) {
70
271
  <path d="M3 10.5V3.2A.7.7 0 0 1 3.7 2.5H10" />
71
272
  </>
72
273
  ),
274
+ // Direction, not departure — the decision modal's two sides are joined by
275
+ // "flows into", and the `external` glyph read as "opens in the browser".
276
+ 'arrow-right': (
277
+ <>
278
+ <line x1="2.5" y1="8" x2="12.5" y2="8" />
279
+ <polyline points="9 4.5 12.5 8 9 11.5" />
280
+ </>
281
+ ),
73
282
  link: (
74
283
  <>
75
284
  <path d="M6.5 9.5l3-3" />
@@ -102,17 +311,42 @@ export default function CloudBar() {
102
311
  const [note, setNote] = useState('');
103
312
  const [error, setError] = useState('');
104
313
  const [copied, setCopied] = useState(false);
314
+ const [copiedLink, setCopiedLink] = useState(false);
315
+ const [openFailed, setOpenFailed] = useState(''); // '' | 'refused' | 'failed'
316
+ const [cloudUrl, setCloudUrl] = useState('https://cloud.maude.sh');
317
+ // `resolved` gates the deep-link dialog: rendering the decision before the
318
+ // data it turns on made the hint fail OPEN on the launch path (B3).
319
+ const [local, setLocal] = useState({ project: '', linkedHub: null, resolved: false });
105
320
  const [pending, setPending] = useState(null); // { project, code } from maude://
106
321
  const railRef = useRef(null);
107
322
  const pollRef = useRef(null);
108
323
 
109
324
  useEffect(() => {
110
- api('/_api/cloud/status').then((r) => {
111
- if (r.ok && r.json?.connected) {
112
- setEmail(r.json.email);
113
- setState('in');
114
- } else setState('out');
115
- });
325
+ api('/_api/cloud/status')
326
+ .then((r) => {
327
+ if (r.json?.url) setCloudUrl(r.json.url);
328
+ // Resolved even on an error body — what must never happen is staying
329
+ // un-resolved forever and leaving the dialog unreachable; an empty
330
+ // project name resolves to 'unknown', which warns rather than reassures.
331
+ setLocal({
332
+ project: r.json?.project ?? '',
333
+ linkedHub: r.json?.linkedHub ?? null,
334
+ resolved: true,
335
+ });
336
+ if (r.ok && r.json?.connected) {
337
+ setEmail(r.json.email);
338
+ setState('in');
339
+ } else setState('out');
340
+ })
341
+ .catch(() => {
342
+ // A rejected fetch (the sidecar restarting under a launch-path deep link)
343
+ // would otherwise leave `resolved` false forever — and since the dialog
344
+ // is gated on it, the parked link would never be shown at all, silently
345
+ // burning a one-shot two-minute code. Resolve into the 'unknown' branch,
346
+ // which warns, and let the rail fall back to signed-out.
347
+ setLocal((l) => ({ ...l, resolved: true }));
348
+ setState('out');
349
+ });
116
350
  return () => clearInterval(pollRef.current);
117
351
  }, []);
118
352
 
@@ -124,7 +358,7 @@ export default function CloudBar() {
124
358
  const consume = (url) => {
125
359
  const parsed = parseDeepLink(url);
126
360
  if (!parsed) return;
127
- // NEVER replace a link the person is already looking at. The strip is a
361
+ // NEVER replace a link the person is already looking at. The dialog is a
128
362
  // single slot, so an overwrite would swap the code out from under a
129
363
  // click that was aimed at the previous one — the person confirms the
130
364
  // name they read and connects the link that arrived last (validate
@@ -163,6 +397,18 @@ export default function CloudBar() {
163
397
  }
164
398
  }
165
399
 
400
+ // Escape answers the decision modal the same way the scrim does: Not now.
401
+ // Declining must always be the cheap move — the dashboard re-mints a code on
402
+ // the next "Open in Maude".
403
+ useEffect(() => {
404
+ if (!pending) return undefined;
405
+ const onKey = (e) => {
406
+ if (e.key === 'Escape') setPending(null);
407
+ };
408
+ document.addEventListener('keydown', onKey);
409
+ return () => document.removeEventListener('keydown', onKey);
410
+ }, [pending]);
411
+
166
412
  useEffect(() => {
167
413
  if (!menuOpen) return undefined;
168
414
  const onDoc = (e) => {
@@ -180,7 +426,8 @@ export default function CloudBar() {
180
426
  return;
181
427
  }
182
428
  setDevice(r.json);
183
- window.open(r.json.verificationUrl, '_blank', 'noopener');
429
+ setOpenFailed('');
430
+ openExternal(r.json.verificationUrl).then((s) => setOpenFailed(s === 'ok' ? '' : s));
184
431
  pollRef.current = setInterval(async () => {
185
432
  const p = await api('/_api/cloud/signin/poll', {
186
433
  method: 'POST',
@@ -245,41 +492,158 @@ export default function CloudBar() {
245
492
  setState('out');
246
493
  }
247
494
 
248
- function copyCode() {
249
- if (!device?.userCode) return;
250
- navigator.clipboard?.writeText(device.userCode).then(
495
+ // One clipboard write, one 1.5 s "Copied" flash. The code and the link are the
496
+ // same gesture, so they share the mechanism — and both swallow a refusal
497
+ // (no clipboard permission) silently: the value is on screen either way.
498
+ function copyToClipboard(text, mark) {
499
+ if (!text) return;
500
+ navigator.clipboard?.writeText(text).then(
251
501
  () => {
252
- setCopied(true);
253
- setTimeout(() => setCopied(false), 1500);
502
+ mark(true);
503
+ setTimeout(() => mark(false), 1500);
254
504
  },
255
505
  () => {}
256
506
  );
257
507
  }
258
508
 
509
+ const copyCode = () => copyToClipboard(device?.userCode, setCopied);
510
+ const copyLink = () => copyToClipboard(device?.verificationUrl, setCopiedLink);
511
+
259
512
  if (state === 'loading') return null;
260
513
 
514
+ const hint = pending
515
+ ? localIdentityHint({
516
+ localProject: local.project,
517
+ linkedHubUrl: local.linkedHub?.url,
518
+ linkedHubCredentialed: local.linkedHub?.credentialed,
519
+ cloudUrl,
520
+ claimed: pending.project,
521
+ resolved: local.resolved,
522
+ })
523
+ : 'unknown';
524
+ // Anything but a clean, corroborated agreement gets the warning treatment and
525
+ // a demoted Connect. Silence is reserved for the two cases we can vouch for.
526
+ const reassured = hint === 'linked' || hint === 'match';
527
+
261
528
  return (
262
529
  <div className="gi-rail cb-rail" ref={railRef} data-testid="cloud-bar">
263
- {pending && (
264
- <div className="gi-rail-confirm" data-testid="cloud-deeplink">
265
- <span className="gi-rail-hint">Connect this project to {pending.project}?</span>
266
- <button
267
- type="button"
268
- className="btn btn--sm"
269
- disabled={busy === 'deeplink'}
270
- onClick={connectPending}
271
- data-testid="cloud-deeplink-connect"
272
- >
273
- {busy === 'deeplink' ? 'Connecting…' : 'Connect'}
274
- </button>
275
- <button
276
- type="button"
277
- className="btn btn--ghost btn--sm"
278
- onClick={() => setPending(null)}
279
- data-testid="cloud-deeplink-dismiss"
280
- >
281
- Not now
282
- </button>
530
+ {/* Not until `local` has resolved: the launch-path deep link can beat the
531
+ status fetch, and a modal rendered without the local half showed no
532
+ warning and a primary Connect — failing OPEN in the one scenario with
533
+ the least context (attacker pass B3). */}
534
+ {pending && local.resolved && (
535
+ <div
536
+ className="gi-modal"
537
+ role="dialog"
538
+ aria-modal="true"
539
+ aria-label={`Connect this folder to ${pending.project}`}
540
+ >
541
+ <div className="gi-scrim" aria-hidden="true" onClick={() => setPending(null)} />
542
+ <div className="gi-dialog gi-dialog--code" data-testid="cloud-deeplink-dialog">
543
+ <div className="gi-dc-head">
544
+ <span className="gi-dc-marks"><Icon name="link" size={24} /></span>
545
+ <h2>Connect to {pending.project}?</h2>
546
+ </div>
547
+ {/* Both sides, named. The old strip said "Connect this project to X?"
548
+ — which never told anyone WHICH project "this" was, the one thing
549
+ the decision turns on. */}
550
+ <div className="gi-dl-pair">
551
+ <span className="gi-dl-side">
552
+ <span className="gi-dl-lbl">This folder</span>
553
+ <span className="gi-dl-val" data-testid="cloud-deeplink-local">{local.project || 'the open project'}</span>
554
+ </span>
555
+ <span className="gi-dl-arrow" aria-hidden="true"><Icon name="arrow-right" size={16} /></span>
556
+ <span className="gi-dl-side">
557
+ <span className="gi-dl-lbl">Cloud project</span>
558
+ <span className="gi-dl-val">{pending.project}</span>
559
+ </span>
560
+ </div>
561
+ <p className="gi-dl-what">
562
+ This folder’s <code>.design/</code> canvases will sync with the {pending.project}{' '}
563
+ workspace. Nothing else in this repo is uploaded.
564
+ </p>
565
+ {hint === 'linked' && (
566
+ <p className="gi-dl-note">This folder is already linked to {pending.project} — connecting refreshes the link.</p>
567
+ )}
568
+ {!reassured && (
569
+ <div className="gi-dl-warn" data-testid="cloud-deeplink-mismatch">
570
+ <strong>
571
+ {hint === 'similar' ? (
572
+ <>
573
+ {local.project} and {pending.project} are different workspaces.
574
+ </>
575
+ ) : hint === 'unknown' ? (
576
+ <>Maude can’t tell which project this folder is.</>
577
+ ) : (
578
+ <>
579
+ This folder looks like {local.project || 'something else'}, not{' '}
580
+ {pending.project}.
581
+ </>
582
+ )}
583
+ </strong>
584
+ <span>
585
+ Connecting will sync <em>this folder’s</em> designs into {pending.project}. If you
586
+ meant to work on {pending.project}, open its folder in Maude and press “Open in
587
+ Maude” again. Don’t have it on this machine? It opens in{' '}
588
+ {isDisplayableUrl(cloudUrl, cloudUrl) ? (
589
+ <a
590
+ className="gi-dc-link-a"
591
+ href={cloudUrl}
592
+ rel="noopener noreferrer"
593
+ target="_blank"
594
+ onClick={(e) => {
595
+ e.preventDefault();
596
+ openExternal(cloudUrl);
597
+ }}
598
+ >
599
+ your browser
600
+ </a>
601
+ ) : (
602
+ 'your browser'
603
+ )}
604
+ .
605
+ </span>
606
+ </div>
607
+ )}
608
+ <div className="gi-dc-foot">
609
+ {/* On a mismatch the safe action leads and connecting is the one you
610
+ have to mean. No project switcher lives here on purpose: the
611
+ handoff code is one-shot with a two-minute life, and a switch
612
+ restarts the server — the person would come back to a dead code
613
+ and a question they already answered. */}
614
+ {!reassured ? (
615
+ <>
616
+ <button type="button" className="btn" onClick={() => setPending(null)} data-testid="cloud-deeplink-dismiss">
617
+ Not now
618
+ </button>
619
+ <button
620
+ type="button"
621
+ className="btn btn--ghost"
622
+ disabled={busy === 'deeplink'}
623
+ onClick={connectPending}
624
+ data-testid="cloud-deeplink-connect"
625
+ >
626
+ {busy === 'deeplink' ? 'Connecting…' : 'Connect anyway'}
627
+ </button>
628
+ </>
629
+ ) : (
630
+ <>
631
+ <button
632
+ type="button"
633
+ className="btn"
634
+ disabled={busy === 'deeplink'}
635
+ onClick={connectPending}
636
+ data-testid="cloud-deeplink-connect"
637
+ >
638
+ {busy === 'deeplink' ? 'Connecting…' : 'Connect'}
639
+ </button>
640
+ <button type="button" className="btn btn--ghost" onClick={() => setPending(null)} data-testid="cloud-deeplink-dismiss">
641
+ Not now
642
+ </button>
643
+ </>
644
+ )}
645
+ </div>
646
+ </div>
283
647
  </div>
284
648
  )}
285
649
  {state === 'out' && (
@@ -332,7 +696,7 @@ export default function CloudBar() {
332
696
  type="button"
333
697
  className="gi-menu-item"
334
698
  role="menuitem"
335
- onClick={() => window.open(shareViewUrl(p.url, p.id), '_blank', 'noopener')}
699
+ onClick={() => openExternal(shareViewUrl(p.url, p.id))}
336
700
  title="Viewing works in the browser. Editing in the app needs the member role — ask whoever runs the project."
337
701
  data-testid={`cloud-project-${p.id}`}
338
702
  >
@@ -356,7 +720,7 @@ export default function CloudBar() {
356
720
  )
357
721
  )}
358
722
  <div className="gi-menu-sep" />
359
- <button type="button" className="gi-menu-item" role="menuitem" onClick={() => window.open('https://cloud.maude.sh', '_blank', 'noopener')}>
723
+ <button type="button" className="gi-menu-item" role="menuitem" onClick={() => openExternal(cloudUrl)}>
360
724
  <Icon name="external" size={15} /> Open the dashboard
361
725
  </button>
362
726
  <button type="button" className="gi-menu-item gi-menu-item--danger" role="menuitem" onClick={signOut}>
@@ -374,7 +738,11 @@ export default function CloudBar() {
374
738
  <div className="gi-dc-head">
375
739
  <span className="gi-dc-marks"><Spark size={26} /></span>
376
740
  <h2>Sign in to Maude Cloud</h2>
377
- <p>Maude opened your dashboard in the browser. Confirm this code there to connect.</p>
741
+ {/* Says what to DO, not what supposedly already happened. The old
742
+ copy claimed the browser had opened — a claim this surface
743
+ cannot make, since the shell's open is best-effort and the
744
+ person is the one who finds out it didn't. */}
745
+ <p>Confirm this code on your Maude Cloud dashboard to connect.</p>
378
746
  </div>
379
747
  <div className="gi-code">
380
748
  <span className="gi-code-val" data-testid="cloud-user-code">{device.userCode}</span>
@@ -382,6 +750,59 @@ export default function CloudBar() {
382
750
  <Icon name="copy" size={15} /> {copied ? 'Copied' : 'Copy'}
383
751
  </button>
384
752
  </div>
753
+ {/* The manual path, always present — never conditional on whether the
754
+ open "worked". It is the only thing standing between an older
755
+ shell / a drifted self-host origin / a blocked popup and a dead
756
+ end. The address already carries the code (`/activate?code=…`),
757
+ so following it is one click, not a re-type. */}
758
+ {device.verificationUrl && isDisplayableUrl(device.verificationUrl, cloudUrl) && (
759
+ <div className="gi-dc-link">
760
+ <span className="gi-dc-link-lbl">
761
+ {openFailed === 'failed'
762
+ ? 'Maude couldn’t open your browser. Open this address yourself:'
763
+ : 'If your browser didn’t open, go to:'}
764
+ </span>
765
+ <div className="gi-dc-link-row">
766
+ <a
767
+ className="gi-dc-url gi-dc-link-a"
768
+ href={device.verificationUrl}
769
+ rel="noopener noreferrer"
770
+ target="_blank"
771
+ data-testid="cloud-signin-link"
772
+ onClick={(e) => {
773
+ e.preventDefault();
774
+ openExternal(device.verificationUrl).then((s) =>
775
+ setOpenFailed(s === 'ok' ? '' : s)
776
+ );
777
+ }}
778
+ >
779
+ {device.verificationUrl}
780
+ </a>
781
+ <button type="button" className="btn btn--ghost btn--sm" onClick={copyLink} aria-label="Copy the link">
782
+ <Icon name="copy" size={14} /> {copiedLink ? 'Copied' : 'Copy link'}
783
+ </button>
784
+ </div>
785
+ </div>
786
+ )}
787
+ {openFailed === 'refused' && (
788
+ // A REFUSAL is not "your browser didn't open" — it is Maude saying
789
+ // that address isn't Maude Cloud. Saying so, and NOT offering to
790
+ // help open it, is the whole difference (attacker re-review NEW-2).
791
+ <div className="gi-dl-warn" data-testid="cloud-signin-refused">
792
+ <strong>Maude wouldn’t open that address.</strong>
793
+ <span>It isn’t a Maude Cloud address. Sign in on your dashboard instead.</span>
794
+ </div>
795
+ )}
796
+ {device.verificationUrl && !isDisplayableUrl(device.verificationUrl, cloudUrl) && (
797
+ // An address we won't put behind a link or a copy button. Naming it
798
+ // is the point — silently dropping it would look like a bug.
799
+ <div className="gi-dl-warn" data-testid="cloud-signin-badurl">
800
+ <strong>Maude Cloud sent an address that doesn’t look right.</strong>
801
+ <span>
802
+ Nothing was opened. Sign in on your dashboard directly instead of following it.
803
+ </span>
804
+ </div>
805
+ )}
385
806
  <div className="gi-dc-status" aria-live="polite">
386
807
  <span className="gi-pulse" aria-hidden="true" />
387
808
  <span>Waiting for you to confirm in the browser…</span>
@@ -802,7 +802,12 @@ function AppearanceTab({ theme, onSetTheme, cpMode, onSetCpMode }) {
802
802
 
803
803
  export default function SettingsPanel({
804
804
  /** `{ dashboardUrl, projectName }` when this is a cloud tab, else null. */
805
- cloud = null,
805
+ // Tri-state (see the note where this value is created): `undefined` until
806
+ // the server config answers, `null` for the desktop, an object for a cloud
807
+ // tab. Defaulting it to `null` here is what re-broke the boot 404 after the
808
+ // call sites were fixed — a default fires on `undefined`, so "not known yet"
809
+ // became "not cloud" one layer further in, invisibly.
810
+ cloud,
806
811
  onClose,
807
812
  initialTab,
808
813
  theme,