@1agh/maude 0.54.0 → 0.56.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.
- package/apps/studio/annotations-layer.tsx +11 -1
- package/apps/studio/bin/_smart-frames.mjs +187 -20
- package/apps/studio/bin/_smart-frames.test.mjs +59 -4
- package/apps/studio/bin/_transcribe.mjs +40 -3
- package/apps/studio/bin/smoke.sh +7 -1
- package/apps/studio/canvas-build-sandbox.ts +101 -9
- package/apps/studio/canvas-build-worker.ts +7 -1
- package/apps/studio/canvas-build.ts +9 -1
- package/apps/studio/client/app.jsx +49 -3
- package/apps/studio/client/github.js +7 -0
- package/apps/studio/client/panels/CloudBar.jsx +505 -45
- package/apps/studio/client/panels/GitPanel.jsx +38 -25
- package/apps/studio/client/panels/SettingsPanel.jsx +239 -27
- package/apps/studio/client/styles/3-shell-maude.css +30 -0
- package/apps/studio/client/styles/4-components.css +72 -0
- package/apps/studio/cloud/endpoints.ts +104 -9
- package/apps/studio/collab/persistence.ts +29 -2
- package/apps/studio/config.schema.json +3 -3
- package/apps/studio/context.ts +41 -0
- package/apps/studio/dist/client.bundle.js +1545 -1545
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/generation/gemma-models.ts +312 -14
- package/apps/studio/generation/prefs.ts +7 -2
- package/apps/studio/generation/runtime-probe.ts +50 -0
- package/apps/studio/generation/whisper-models.ts +124 -0
- package/apps/studio/hmr-broadcast.ts +67 -0
- package/apps/studio/http.ts +210 -110
- package/apps/studio/input-router.tsx +55 -2
- package/apps/studio/server.ts +11 -9
- package/apps/studio/sync/autocommit.ts +61 -2
- package/apps/studio/sync/cell-pairing.ts +174 -0
- package/apps/studio/sync/codec.ts +11 -5
- package/apps/studio/sync/index.ts +239 -26
- package/apps/studio/sync/limits.ts +49 -0
- package/apps/studio/sync/loopback.ts +21 -0
- package/apps/studio/sync/projection.ts +47 -12
- package/apps/studio/sync/supervisor.ts +178 -0
- package/apps/studio/test/cloud-endpoints.test.ts +326 -4
- package/apps/studio/test/csrf-write-guard.test.ts +19 -2
- package/apps/studio/test/gemma-models.test.ts +245 -0
- package/apps/studio/test/hmr-broadcast.test.ts +57 -1
- package/apps/studio/test/input-router.test.ts +95 -0
- package/apps/studio/test/shared-doc-cell-pairing.test.ts +639 -0
- package/apps/studio/test/sync-autocommit.test.ts +47 -0
- package/apps/studio/test/sync-supervisor.test.ts +212 -0
- package/apps/studio/test/trusted-request-host.test.ts +66 -0
- package/apps/studio/test/whisper-setup.test.ts +97 -0
- package/apps/studio/whats-new.json +45 -0
- package/apps/studio/ws.ts +9 -1
- package/cli/commands/kg.mjs +9 -2
- package/package.json +8 -8
- package/plugins/design/dependencies.json +21 -3
|
@@ -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,19 +112,174 @@ 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
|
-
/**
|
|
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}
|
|
140
|
+
return `${u.protocol}//${u.host}`;
|
|
44
141
|
} catch {
|
|
45
|
-
return `https
|
|
142
|
+
return `https://${projectId}.${DEFAULT_CLOUD_HOST}`;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
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
|
+
|
|
250
|
+
/**
|
|
251
|
+
* What to say after a successful Connect.
|
|
252
|
+
*
|
|
253
|
+
* The old copy said "Linked to <X> — restart the studio server to start
|
|
254
|
+
* syncing", which was three problems in one line: it named a thing the person
|
|
255
|
+
* cannot see (there is no visible server in the desktop app), gave them a task
|
|
256
|
+
* instead of a result, and never explained what connecting had achieved. The
|
|
257
|
+
* server now cycles the sync runtime itself, so this reports an OUTCOME —
|
|
258
|
+
* and on the paths where syncing genuinely didn't start, it says which one and
|
|
259
|
+
* what to do, rather than prescribing a restart for every cause.
|
|
260
|
+
*
|
|
261
|
+
* Returns `{ text, title }` — `text` is the rail's one line, `title` the full
|
|
262
|
+
* sentence on hover (the rail truncates).
|
|
263
|
+
*/
|
|
264
|
+
export function connectOutcomeNote(project, sync) {
|
|
265
|
+
const name = project || 'the workspace';
|
|
266
|
+
if (sync?.syncing) {
|
|
267
|
+
const n = sync.canvases ?? 0;
|
|
268
|
+
return {
|
|
269
|
+
text: `Syncing with ${name} — ${n} canvas${n === 1 ? '' : 'es'}.`,
|
|
270
|
+
title: `This project’s canvases now sync with the ${name} workspace. Live status is in the “hub sync” slot in the status bar.`,
|
|
271
|
+
};
|
|
46
272
|
}
|
|
273
|
+
if (sync?.reason === 'nothing-syncable') {
|
|
274
|
+
return {
|
|
275
|
+
text: `Connected to ${name} — nothing to sync yet.`,
|
|
276
|
+
title: sync.detail || 'No canvases in this project are syncable yet.',
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
return {
|
|
280
|
+
text: `Connected to ${name} — ${sync?.detail ?? 'restart Maude to start syncing.'}`,
|
|
281
|
+
title: sync?.detail || 'Restart Maude to start syncing.',
|
|
282
|
+
};
|
|
47
283
|
}
|
|
48
284
|
|
|
49
285
|
function Spark({ size = 15 }) {
|
|
@@ -70,6 +306,14 @@ function Icon({ name, size = 15 }) {
|
|
|
70
306
|
<path d="M3 10.5V3.2A.7.7 0 0 1 3.7 2.5H10" />
|
|
71
307
|
</>
|
|
72
308
|
),
|
|
309
|
+
// Direction, not departure — the decision modal's two sides are joined by
|
|
310
|
+
// "flows into", and the `external` glyph read as "opens in the browser".
|
|
311
|
+
'arrow-right': (
|
|
312
|
+
<>
|
|
313
|
+
<line x1="2.5" y1="8" x2="12.5" y2="8" />
|
|
314
|
+
<polyline points="9 4.5 12.5 8 9 11.5" />
|
|
315
|
+
</>
|
|
316
|
+
),
|
|
73
317
|
link: (
|
|
74
318
|
<>
|
|
75
319
|
<path d="M6.5 9.5l3-3" />
|
|
@@ -99,20 +343,45 @@ export default function CloudBar() {
|
|
|
99
343
|
const [device, setDevice] = useState(null); // { userCode, verificationUrl, deviceCode }
|
|
100
344
|
const [projects, setProjects] = useState(null);
|
|
101
345
|
const [busy, setBusy] = useState('');
|
|
102
|
-
const [note, setNote] = useState(
|
|
346
|
+
const [note, setNote] = useState(null); // { text, title } — connectOutcomeNote
|
|
103
347
|
const [error, setError] = useState('');
|
|
104
348
|
const [copied, setCopied] = useState(false);
|
|
349
|
+
const [copiedLink, setCopiedLink] = useState(false);
|
|
350
|
+
const [openFailed, setOpenFailed] = useState(''); // '' | 'refused' | 'failed'
|
|
351
|
+
const [cloudUrl, setCloudUrl] = useState('https://cloud.maude.sh');
|
|
352
|
+
// `resolved` gates the deep-link dialog: rendering the decision before the
|
|
353
|
+
// data it turns on made the hint fail OPEN on the launch path (B3).
|
|
354
|
+
const [local, setLocal] = useState({ project: '', linkedHub: null, resolved: false });
|
|
105
355
|
const [pending, setPending] = useState(null); // { project, code } from maude://
|
|
106
356
|
const railRef = useRef(null);
|
|
107
357
|
const pollRef = useRef(null);
|
|
108
358
|
|
|
109
359
|
useEffect(() => {
|
|
110
|
-
api('/_api/cloud/status')
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
360
|
+
api('/_api/cloud/status')
|
|
361
|
+
.then((r) => {
|
|
362
|
+
if (r.json?.url) setCloudUrl(r.json.url);
|
|
363
|
+
// Resolved even on an error body — what must never happen is staying
|
|
364
|
+
// un-resolved forever and leaving the dialog unreachable; an empty
|
|
365
|
+
// project name resolves to 'unknown', which warns rather than reassures.
|
|
366
|
+
setLocal({
|
|
367
|
+
project: r.json?.project ?? '',
|
|
368
|
+
linkedHub: r.json?.linkedHub ?? null,
|
|
369
|
+
resolved: true,
|
|
370
|
+
});
|
|
371
|
+
if (r.ok && r.json?.connected) {
|
|
372
|
+
setEmail(r.json.email);
|
|
373
|
+
setState('in');
|
|
374
|
+
} else setState('out');
|
|
375
|
+
})
|
|
376
|
+
.catch(() => {
|
|
377
|
+
// A rejected fetch (the sidecar restarting under a launch-path deep link)
|
|
378
|
+
// would otherwise leave `resolved` false forever — and since the dialog
|
|
379
|
+
// is gated on it, the parked link would never be shown at all, silently
|
|
380
|
+
// burning a one-shot two-minute code. Resolve into the 'unknown' branch,
|
|
381
|
+
// which warns, and let the rail fall back to signed-out.
|
|
382
|
+
setLocal((l) => ({ ...l, resolved: true }));
|
|
383
|
+
setState('out');
|
|
384
|
+
});
|
|
116
385
|
return () => clearInterval(pollRef.current);
|
|
117
386
|
}, []);
|
|
118
387
|
|
|
@@ -124,7 +393,7 @@ export default function CloudBar() {
|
|
|
124
393
|
const consume = (url) => {
|
|
125
394
|
const parsed = parseDeepLink(url);
|
|
126
395
|
if (!parsed) return;
|
|
127
|
-
// NEVER replace a link the person is already looking at. The
|
|
396
|
+
// NEVER replace a link the person is already looking at. The dialog is a
|
|
128
397
|
// single slot, so an overwrite would swap the code out from under a
|
|
129
398
|
// click that was aimed at the previous one — the person confirms the
|
|
130
399
|
// name they read and connects the link that arrived last (validate
|
|
@@ -157,12 +426,24 @@ export default function CloudBar() {
|
|
|
157
426
|
setBusy('');
|
|
158
427
|
setPending(null);
|
|
159
428
|
if (r.ok && r.json?.ok) {
|
|
160
|
-
setNote(
|
|
429
|
+
setNote(connectOutcomeNote(r.json.project ?? pending.project, r.json.sync));
|
|
161
430
|
} else {
|
|
162
431
|
setError(r.json?.error || 'The workspace could not be connected.');
|
|
163
432
|
}
|
|
164
433
|
}
|
|
165
434
|
|
|
435
|
+
// Escape answers the decision modal the same way the scrim does: Not now.
|
|
436
|
+
// Declining must always be the cheap move — the dashboard re-mints a code on
|
|
437
|
+
// the next "Open in Maude".
|
|
438
|
+
useEffect(() => {
|
|
439
|
+
if (!pending) return undefined;
|
|
440
|
+
const onKey = (e) => {
|
|
441
|
+
if (e.key === 'Escape') setPending(null);
|
|
442
|
+
};
|
|
443
|
+
document.addEventListener('keydown', onKey);
|
|
444
|
+
return () => document.removeEventListener('keydown', onKey);
|
|
445
|
+
}, [pending]);
|
|
446
|
+
|
|
166
447
|
useEffect(() => {
|
|
167
448
|
if (!menuOpen) return undefined;
|
|
168
449
|
const onDoc = (e) => {
|
|
@@ -180,7 +461,8 @@ export default function CloudBar() {
|
|
|
180
461
|
return;
|
|
181
462
|
}
|
|
182
463
|
setDevice(r.json);
|
|
183
|
-
|
|
464
|
+
setOpenFailed('');
|
|
465
|
+
openExternal(r.json.verificationUrl).then((s) => setOpenFailed(s === 'ok' ? '' : s));
|
|
184
466
|
pollRef.current = setInterval(async () => {
|
|
185
467
|
const p = await api('/_api/cloud/signin/poll', {
|
|
186
468
|
method: 'POST',
|
|
@@ -221,7 +503,7 @@ export default function CloudBar() {
|
|
|
221
503
|
|
|
222
504
|
async function connect(projectId) {
|
|
223
505
|
setBusy(projectId);
|
|
224
|
-
setNote(
|
|
506
|
+
setNote(null);
|
|
225
507
|
setError('');
|
|
226
508
|
const r = await api('/_api/cloud/attach', {
|
|
227
509
|
method: 'POST',
|
|
@@ -230,7 +512,7 @@ export default function CloudBar() {
|
|
|
230
512
|
});
|
|
231
513
|
setBusy('');
|
|
232
514
|
if (r.ok && r.json?.ok) {
|
|
233
|
-
setNote(
|
|
515
|
+
setNote(connectOutcomeNote(r.json.project ?? projectId, r.json.sync));
|
|
234
516
|
} else {
|
|
235
517
|
setError(r.json?.error || 'The workspace could not be connected.');
|
|
236
518
|
}
|
|
@@ -245,41 +527,158 @@ export default function CloudBar() {
|
|
|
245
527
|
setState('out');
|
|
246
528
|
}
|
|
247
529
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
530
|
+
// One clipboard write, one 1.5 s "Copied" flash. The code and the link are the
|
|
531
|
+
// same gesture, so they share the mechanism — and both swallow a refusal
|
|
532
|
+
// (no clipboard permission) silently: the value is on screen either way.
|
|
533
|
+
function copyToClipboard(text, mark) {
|
|
534
|
+
if (!text) return;
|
|
535
|
+
navigator.clipboard?.writeText(text).then(
|
|
251
536
|
() => {
|
|
252
|
-
|
|
253
|
-
setTimeout(() =>
|
|
537
|
+
mark(true);
|
|
538
|
+
setTimeout(() => mark(false), 1500);
|
|
254
539
|
},
|
|
255
540
|
() => {}
|
|
256
541
|
);
|
|
257
542
|
}
|
|
258
543
|
|
|
544
|
+
const copyCode = () => copyToClipboard(device?.userCode, setCopied);
|
|
545
|
+
const copyLink = () => copyToClipboard(device?.verificationUrl, setCopiedLink);
|
|
546
|
+
|
|
259
547
|
if (state === 'loading') return null;
|
|
260
548
|
|
|
549
|
+
const hint = pending
|
|
550
|
+
? localIdentityHint({
|
|
551
|
+
localProject: local.project,
|
|
552
|
+
linkedHubUrl: local.linkedHub?.url,
|
|
553
|
+
linkedHubCredentialed: local.linkedHub?.credentialed,
|
|
554
|
+
cloudUrl,
|
|
555
|
+
claimed: pending.project,
|
|
556
|
+
resolved: local.resolved,
|
|
557
|
+
})
|
|
558
|
+
: 'unknown';
|
|
559
|
+
// Anything but a clean, corroborated agreement gets the warning treatment and
|
|
560
|
+
// a demoted Connect. Silence is reserved for the two cases we can vouch for.
|
|
561
|
+
const reassured = hint === 'linked' || hint === 'match';
|
|
562
|
+
|
|
261
563
|
return (
|
|
262
564
|
<div className="gi-rail cb-rail" ref={railRef} data-testid="cloud-bar">
|
|
263
|
-
{
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
<
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
565
|
+
{/* Not until `local` has resolved: the launch-path deep link can beat the
|
|
566
|
+
status fetch, and a modal rendered without the local half showed no
|
|
567
|
+
warning and a primary Connect — failing OPEN in the one scenario with
|
|
568
|
+
the least context (attacker pass B3). */}
|
|
569
|
+
{pending && local.resolved && (
|
|
570
|
+
<div
|
|
571
|
+
className="gi-modal"
|
|
572
|
+
role="dialog"
|
|
573
|
+
aria-modal="true"
|
|
574
|
+
aria-label={`Connect this folder to ${pending.project}`}
|
|
575
|
+
>
|
|
576
|
+
<div className="gi-scrim" aria-hidden="true" onClick={() => setPending(null)} />
|
|
577
|
+
<div className="gi-dialog gi-dialog--code" data-testid="cloud-deeplink-dialog">
|
|
578
|
+
<div className="gi-dc-head">
|
|
579
|
+
<span className="gi-dc-marks"><Icon name="link" size={24} /></span>
|
|
580
|
+
<h2>Connect to {pending.project}?</h2>
|
|
581
|
+
</div>
|
|
582
|
+
{/* Both sides, named. The old strip said "Connect this project to X?"
|
|
583
|
+
— which never told anyone WHICH project "this" was, the one thing
|
|
584
|
+
the decision turns on. */}
|
|
585
|
+
<div className="gi-dl-pair">
|
|
586
|
+
<span className="gi-dl-side">
|
|
587
|
+
<span className="gi-dl-lbl">This folder</span>
|
|
588
|
+
<span className="gi-dl-val" data-testid="cloud-deeplink-local">{local.project || 'the open project'}</span>
|
|
589
|
+
</span>
|
|
590
|
+
<span className="gi-dl-arrow" aria-hidden="true"><Icon name="arrow-right" size={16} /></span>
|
|
591
|
+
<span className="gi-dl-side">
|
|
592
|
+
<span className="gi-dl-lbl">Cloud project</span>
|
|
593
|
+
<span className="gi-dl-val">{pending.project}</span>
|
|
594
|
+
</span>
|
|
595
|
+
</div>
|
|
596
|
+
<p className="gi-dl-what">
|
|
597
|
+
This folder’s <code>.design/</code> canvases will sync with the {pending.project}{' '}
|
|
598
|
+
workspace. Nothing else in this repo is uploaded.
|
|
599
|
+
</p>
|
|
600
|
+
{hint === 'linked' && (
|
|
601
|
+
<p className="gi-dl-note">This folder is already linked to {pending.project} — connecting refreshes the link.</p>
|
|
602
|
+
)}
|
|
603
|
+
{!reassured && (
|
|
604
|
+
<div className="gi-dl-warn" data-testid="cloud-deeplink-mismatch">
|
|
605
|
+
<strong>
|
|
606
|
+
{hint === 'similar' ? (
|
|
607
|
+
<>
|
|
608
|
+
{local.project} and {pending.project} are different workspaces.
|
|
609
|
+
</>
|
|
610
|
+
) : hint === 'unknown' ? (
|
|
611
|
+
<>Maude can’t tell which project this folder is.</>
|
|
612
|
+
) : (
|
|
613
|
+
<>
|
|
614
|
+
This folder looks like {local.project || 'something else'}, not{' '}
|
|
615
|
+
{pending.project}.
|
|
616
|
+
</>
|
|
617
|
+
)}
|
|
618
|
+
</strong>
|
|
619
|
+
<span>
|
|
620
|
+
Connecting will sync <em>this folder’s</em> designs into {pending.project}. If you
|
|
621
|
+
meant to work on {pending.project}, open its folder in Maude and press “Open in
|
|
622
|
+
Maude” again. Don’t have it on this machine? It opens in{' '}
|
|
623
|
+
{isDisplayableUrl(cloudUrl, cloudUrl) ? (
|
|
624
|
+
<a
|
|
625
|
+
className="gi-dc-link-a"
|
|
626
|
+
href={cloudUrl}
|
|
627
|
+
rel="noopener noreferrer"
|
|
628
|
+
target="_blank"
|
|
629
|
+
onClick={(e) => {
|
|
630
|
+
e.preventDefault();
|
|
631
|
+
openExternal(cloudUrl);
|
|
632
|
+
}}
|
|
633
|
+
>
|
|
634
|
+
your browser
|
|
635
|
+
</a>
|
|
636
|
+
) : (
|
|
637
|
+
'your browser'
|
|
638
|
+
)}
|
|
639
|
+
.
|
|
640
|
+
</span>
|
|
641
|
+
</div>
|
|
642
|
+
)}
|
|
643
|
+
<div className="gi-dc-foot">
|
|
644
|
+
{/* On a mismatch the safe action leads and connecting is the one you
|
|
645
|
+
have to mean. No project switcher lives here on purpose: the
|
|
646
|
+
handoff code is one-shot with a two-minute life, and a switch
|
|
647
|
+
restarts the server — the person would come back to a dead code
|
|
648
|
+
and a question they already answered. */}
|
|
649
|
+
{!reassured ? (
|
|
650
|
+
<>
|
|
651
|
+
<button type="button" className="btn" onClick={() => setPending(null)} data-testid="cloud-deeplink-dismiss">
|
|
652
|
+
Not now
|
|
653
|
+
</button>
|
|
654
|
+
<button
|
|
655
|
+
type="button"
|
|
656
|
+
className="btn btn--ghost"
|
|
657
|
+
disabled={busy === 'deeplink'}
|
|
658
|
+
onClick={connectPending}
|
|
659
|
+
data-testid="cloud-deeplink-connect"
|
|
660
|
+
>
|
|
661
|
+
{busy === 'deeplink' ? 'Connecting…' : 'Connect anyway'}
|
|
662
|
+
</button>
|
|
663
|
+
</>
|
|
664
|
+
) : (
|
|
665
|
+
<>
|
|
666
|
+
<button
|
|
667
|
+
type="button"
|
|
668
|
+
className="btn"
|
|
669
|
+
disabled={busy === 'deeplink'}
|
|
670
|
+
onClick={connectPending}
|
|
671
|
+
data-testid="cloud-deeplink-connect"
|
|
672
|
+
>
|
|
673
|
+
{busy === 'deeplink' ? 'Connecting…' : 'Connect'}
|
|
674
|
+
</button>
|
|
675
|
+
<button type="button" className="btn btn--ghost" onClick={() => setPending(null)} data-testid="cloud-deeplink-dismiss">
|
|
676
|
+
Not now
|
|
677
|
+
</button>
|
|
678
|
+
</>
|
|
679
|
+
)}
|
|
680
|
+
</div>
|
|
681
|
+
</div>
|
|
283
682
|
</div>
|
|
284
683
|
)}
|
|
285
684
|
{state === 'out' && (
|
|
@@ -306,7 +705,11 @@ export default function CloudBar() {
|
|
|
306
705
|
<span className="gi-rail-login">{email ?? 'Maude Cloud'}</span>
|
|
307
706
|
<span className="gi-rail-caret"><Icon name="chevron-up" size={13} /></span>
|
|
308
707
|
</button>
|
|
309
|
-
{note &&
|
|
708
|
+
{note && (
|
|
709
|
+
<span className="gi-rail-hint" title={note.title} data-testid="cloud-connect-note">
|
|
710
|
+
{note.text}
|
|
711
|
+
</span>
|
|
712
|
+
)}
|
|
310
713
|
{error && <span className="gi-rail-err" title={error}>{error}</span>}
|
|
311
714
|
{menuOpen && (
|
|
312
715
|
<div className="gi-menu" role="menu" aria-label="Maude Cloud">
|
|
@@ -332,7 +735,7 @@ export default function CloudBar() {
|
|
|
332
735
|
type="button"
|
|
333
736
|
className="gi-menu-item"
|
|
334
737
|
role="menuitem"
|
|
335
|
-
onClick={() =>
|
|
738
|
+
onClick={() => openExternal(shareViewUrl(p.url, p.id))}
|
|
336
739
|
title="Viewing works in the browser. Editing in the app needs the member role — ask whoever runs the project."
|
|
337
740
|
data-testid={`cloud-project-${p.id}`}
|
|
338
741
|
>
|
|
@@ -356,7 +759,7 @@ export default function CloudBar() {
|
|
|
356
759
|
)
|
|
357
760
|
)}
|
|
358
761
|
<div className="gi-menu-sep" />
|
|
359
|
-
<button type="button" className="gi-menu-item" role="menuitem" onClick={() =>
|
|
762
|
+
<button type="button" className="gi-menu-item" role="menuitem" onClick={() => openExternal(cloudUrl)}>
|
|
360
763
|
<Icon name="external" size={15} /> Open the dashboard
|
|
361
764
|
</button>
|
|
362
765
|
<button type="button" className="gi-menu-item gi-menu-item--danger" role="menuitem" onClick={signOut}>
|
|
@@ -374,7 +777,11 @@ export default function CloudBar() {
|
|
|
374
777
|
<div className="gi-dc-head">
|
|
375
778
|
<span className="gi-dc-marks"><Spark size={26} /></span>
|
|
376
779
|
<h2>Sign in to Maude Cloud</h2>
|
|
377
|
-
|
|
780
|
+
{/* Says what to DO, not what supposedly already happened. The old
|
|
781
|
+
copy claimed the browser had opened — a claim this surface
|
|
782
|
+
cannot make, since the shell's open is best-effort and the
|
|
783
|
+
person is the one who finds out it didn't. */}
|
|
784
|
+
<p>Confirm this code on your Maude Cloud dashboard to connect.</p>
|
|
378
785
|
</div>
|
|
379
786
|
<div className="gi-code">
|
|
380
787
|
<span className="gi-code-val" data-testid="cloud-user-code">{device.userCode}</span>
|
|
@@ -382,6 +789,59 @@ export default function CloudBar() {
|
|
|
382
789
|
<Icon name="copy" size={15} /> {copied ? 'Copied' : 'Copy'}
|
|
383
790
|
</button>
|
|
384
791
|
</div>
|
|
792
|
+
{/* The manual path, always present — never conditional on whether the
|
|
793
|
+
open "worked". It is the only thing standing between an older
|
|
794
|
+
shell / a drifted self-host origin / a blocked popup and a dead
|
|
795
|
+
end. The address already carries the code (`/activate?code=…`),
|
|
796
|
+
so following it is one click, not a re-type. */}
|
|
797
|
+
{device.verificationUrl && isDisplayableUrl(device.verificationUrl, cloudUrl) && (
|
|
798
|
+
<div className="gi-dc-link">
|
|
799
|
+
<span className="gi-dc-link-lbl">
|
|
800
|
+
{openFailed === 'failed'
|
|
801
|
+
? 'Maude couldn’t open your browser. Open this address yourself:'
|
|
802
|
+
: 'If your browser didn’t open, go to:'}
|
|
803
|
+
</span>
|
|
804
|
+
<div className="gi-dc-link-row">
|
|
805
|
+
<a
|
|
806
|
+
className="gi-dc-url gi-dc-link-a"
|
|
807
|
+
href={device.verificationUrl}
|
|
808
|
+
rel="noopener noreferrer"
|
|
809
|
+
target="_blank"
|
|
810
|
+
data-testid="cloud-signin-link"
|
|
811
|
+
onClick={(e) => {
|
|
812
|
+
e.preventDefault();
|
|
813
|
+
openExternal(device.verificationUrl).then((s) =>
|
|
814
|
+
setOpenFailed(s === 'ok' ? '' : s)
|
|
815
|
+
);
|
|
816
|
+
}}
|
|
817
|
+
>
|
|
818
|
+
{device.verificationUrl}
|
|
819
|
+
</a>
|
|
820
|
+
<button type="button" className="btn btn--ghost btn--sm" onClick={copyLink} aria-label="Copy the link">
|
|
821
|
+
<Icon name="copy" size={14} /> {copiedLink ? 'Copied' : 'Copy link'}
|
|
822
|
+
</button>
|
|
823
|
+
</div>
|
|
824
|
+
</div>
|
|
825
|
+
)}
|
|
826
|
+
{openFailed === 'refused' && (
|
|
827
|
+
// A REFUSAL is not "your browser didn't open" — it is Maude saying
|
|
828
|
+
// that address isn't Maude Cloud. Saying so, and NOT offering to
|
|
829
|
+
// help open it, is the whole difference (attacker re-review NEW-2).
|
|
830
|
+
<div className="gi-dl-warn" data-testid="cloud-signin-refused">
|
|
831
|
+
<strong>Maude wouldn’t open that address.</strong>
|
|
832
|
+
<span>It isn’t a Maude Cloud address. Sign in on your dashboard instead.</span>
|
|
833
|
+
</div>
|
|
834
|
+
)}
|
|
835
|
+
{device.verificationUrl && !isDisplayableUrl(device.verificationUrl, cloudUrl) && (
|
|
836
|
+
// An address we won't put behind a link or a copy button. Naming it
|
|
837
|
+
// is the point — silently dropping it would look like a bug.
|
|
838
|
+
<div className="gi-dl-warn" data-testid="cloud-signin-badurl">
|
|
839
|
+
<strong>Maude Cloud sent an address that doesn’t look right.</strong>
|
|
840
|
+
<span>
|
|
841
|
+
Nothing was opened. Sign in on your dashboard directly instead of following it.
|
|
842
|
+
</span>
|
|
843
|
+
</div>
|
|
844
|
+
)}
|
|
385
845
|
<div className="gi-dc-status" aria-live="polite">
|
|
386
846
|
<span className="gi-pulse" aria-hidden="true" />
|
|
387
847
|
<span>Waiting for you to confirm in the browser…</span>
|