@runappsai/sdk 0.0.0-stage → 0.3.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 (105) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +274 -2
  3. package/dist/activity-tracker.d.ts +113 -0
  4. package/dist/activity-tracker.d.ts.map +1 -0
  5. package/dist/activity-tracker.js +176 -0
  6. package/dist/activity-tracker.js.map +1 -0
  7. package/dist/audio.d.ts +129 -0
  8. package/dist/audio.d.ts.map +1 -0
  9. package/dist/audio.js +267 -0
  10. package/dist/audio.js.map +1 -0
  11. package/dist/browser-auth.d.ts +179 -0
  12. package/dist/browser-auth.d.ts.map +1 -0
  13. package/dist/browser-auth.js +1361 -0
  14. package/dist/browser-auth.js.map +1 -0
  15. package/dist/chat.d.ts +221 -0
  16. package/dist/chat.d.ts.map +1 -0
  17. package/dist/chat.js +155 -0
  18. package/dist/chat.js.map +1 -0
  19. package/dist/client.d.ts +155 -0
  20. package/dist/client.d.ts.map +1 -0
  21. package/dist/client.js +157 -0
  22. package/dist/client.js.map +1 -0
  23. package/dist/computer.d.ts +57 -0
  24. package/dist/computer.d.ts.map +1 -0
  25. package/dist/computer.js +21 -0
  26. package/dist/computer.js.map +1 -0
  27. package/dist/embeddings.d.ts +64 -0
  28. package/dist/embeddings.d.ts.map +1 -0
  29. package/dist/embeddings.js +30 -0
  30. package/dist/embeddings.js.map +1 -0
  31. package/dist/errors.d.ts +24 -0
  32. package/dist/errors.d.ts.map +1 -0
  33. package/dist/errors.js +29 -0
  34. package/dist/errors.js.map +1 -0
  35. package/dist/event-wrap.d.ts +59 -0
  36. package/dist/event-wrap.d.ts.map +1 -0
  37. package/dist/event-wrap.js +142 -0
  38. package/dist/event-wrap.js.map +1 -0
  39. package/dist/events.d.ts +119 -0
  40. package/dist/events.d.ts.map +1 -0
  41. package/dist/events.js +87 -0
  42. package/dist/events.js.map +1 -0
  43. package/dist/files.d.ts +218 -0
  44. package/dist/files.d.ts.map +1 -0
  45. package/dist/files.js +212 -0
  46. package/dist/files.js.map +1 -0
  47. package/dist/image.d.ts +185 -0
  48. package/dist/image.d.ts.map +1 -0
  49. package/dist/image.js +148 -0
  50. package/dist/image.js.map +1 -0
  51. package/dist/index.d.ts +30 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +26 -0
  54. package/dist/index.js.map +1 -0
  55. package/dist/media.d.ts +56 -0
  56. package/dist/media.d.ts.map +1 -0
  57. package/dist/media.js +133 -0
  58. package/dist/media.js.map +1 -0
  59. package/dist/model_options.d.ts +165 -0
  60. package/dist/model_options.d.ts.map +1 -0
  61. package/dist/model_options.js +59 -0
  62. package/dist/model_options.js.map +1 -0
  63. package/dist/models.d.ts +88 -0
  64. package/dist/models.d.ts.map +1 -0
  65. package/dist/models.js +55 -0
  66. package/dist/models.js.map +1 -0
  67. package/dist/sdk.umd.js +4 -0
  68. package/dist/sdk.umd.js.map +1 -0
  69. package/dist/transport.d.ts +120 -0
  70. package/dist/transport.d.ts.map +1 -0
  71. package/dist/transport.js +214 -0
  72. package/dist/transport.js.map +1 -0
  73. package/dist/types.d.ts +31 -0
  74. package/dist/types.d.ts.map +1 -0
  75. package/dist/types.js +2 -0
  76. package/dist/types.js.map +1 -0
  77. package/dist/validate.d.ts +16 -0
  78. package/dist/validate.d.ts.map +1 -0
  79. package/dist/validate.js +329 -0
  80. package/dist/validate.js.map +1 -0
  81. package/dist/video.d.ts +134 -0
  82. package/dist/video.d.ts.map +1 -0
  83. package/dist/video.js +60 -0
  84. package/dist/video.js.map +1 -0
  85. package/package.json +54 -4
  86. package/src/activity-tracker.ts +251 -0
  87. package/src/audio.ts +440 -0
  88. package/src/browser-auth.ts +1529 -0
  89. package/src/chat.ts +368 -0
  90. package/src/client.ts +250 -0
  91. package/src/computer.ts +80 -0
  92. package/src/embeddings.ts +97 -0
  93. package/src/errors.ts +29 -0
  94. package/src/event-wrap.ts +168 -0
  95. package/src/events.ts +172 -0
  96. package/src/files.ts +347 -0
  97. package/src/image.ts +339 -0
  98. package/src/index.ts +151 -0
  99. package/src/media.ts +147 -0
  100. package/src/model_options.ts +220 -0
  101. package/src/models.ts +121 -0
  102. package/src/transport.ts +292 -0
  103. package/src/types.ts +31 -0
  104. package/src/validate.ts +364 -0
  105. package/src/video.ts +215 -0
@@ -0,0 +1,1361 @@
1
+ // browser-auth.ts — opt-in runapps.ai auth flow for browser bundles.
2
+ //
3
+ // Activated by `new RunApps({ authProvider: "runjobs", baseURL: "..." })`.
4
+ // In Node and other non-window environments this module's runtime code
5
+ // short-circuits to no-ops; it ships with the IIFE bundle for resource
6
+ // bundles loaded via <script> tag.
7
+ //
8
+ // Responsibilities:
9
+ // - Detect & consume `#runjobs_token=…` URL fragment planted by the
10
+ // grant page after a successful sign-in.
11
+ // - Trigger a redirect-based grant flow when the bundle calls a
12
+ // gateway endpoint without a fresh token.
13
+ // - In iframe contexts (the runjobs dashboard embedding), handshake
14
+ // with the parent via postMessage to silently obtain a token.
15
+ // - Render an unobtrusive identity badge in the bottom-right corner
16
+ // so the end user can see WHICH runjobs account is paying for the
17
+ // calls being made on their behalf.
18
+ //
19
+ // Why redirect (not popup): under COOP=same-origin (required so the
20
+ // bundle can use OPFS / SharedArrayBuffer), the browser severs the
21
+ // window.opener relationship for cross-origin popups, so popup-based
22
+ // grant flows silently lose their postMessage replies.
23
+ import { ActivityTracker } from "./activity-tracker.js";
24
+ const TOKEN_REFRESH_MARGIN_S = 60;
25
+ // Per-project localStorage namespacing. When two BrowserAuth bundles
26
+ // live on the same origin (most commonly `localhost:5173` during
27
+ // development of multiple SDK-using apps, or a deployed origin that
28
+ // hosts more than one bundle), a single shared key would let them
29
+ // clobber each other's tokens — bundle B's signIn() would overwrite
30
+ // the token bundle A had cached, and the next time A booted it'd
31
+ // either reuse B's token (wrong project scope, every gateway call
32
+ // returns 404 on `files/...`) or skip the cache and redirect through
33
+ // the grant flow on every reload. Append the constructor-supplied
34
+ // `project` to the key so bundles scoped to different projects keep
35
+ // independent slots in localStorage. Bundles without an explicit
36
+ // project (iframe / parent-handshake mode) fall back to the legacy
37
+ // unsuffixed key, which preserves back-compat for already-persisted
38
+ // tokens after upgrade.
39
+ const STORAGE_KEY_BASE = "__runjobs_auth_v1__";
40
+ // Set when the user clicks "Sign out". While present, getToken() must
41
+ // not silently redirect to the grant page even though the user still
42
+ // has a runapps.ai cookie — otherwise the backend's auto-grant turns
43
+ // every page reload into a re-authentication. Cleared on explicit
44
+ // signIn() so the user can come back. Same per-project namespacing
45
+ // rationale as STORAGE_KEY_BASE — signing out of bundle A shouldn't
46
+ // also sign out bundle B that happens to share the origin.
47
+ const SIGNED_OUT_KEY_BASE = "__runjobs_signed_out_v1__";
48
+ /** Compose the localStorage key for an auth slot. `null` project
49
+ * yields the base key (iframe-mode and pre-v1.x bundles), which
50
+ * preserves back-compat for tokens persisted before this change. */
51
+ function storageKey(project) {
52
+ return project ? `${STORAGE_KEY_BASE}:${project}` : STORAGE_KEY_BASE;
53
+ }
54
+ /** Same as storageKey, for the sticky sign-out flag. */
55
+ function signedOutKey(project) {
56
+ return project ? `${SIGNED_OUT_KEY_BASE}:${project}` : SIGNED_OUT_KEY_BASE;
57
+ }
58
+ /**
59
+ * BrowserAuth encapsulates the browser-side auth state for a RunApps
60
+ * client. Exposes:
61
+ *
62
+ * - getToken() fresh bearer (auto-refreshes / signs in)
63
+ * - signIn() force a redirect to the grant page
64
+ * - user { id, name } — null until signed in
65
+ * - onTokenChange(fn) subscribe to token (re)acquisition
66
+ *
67
+ * Construct once per page; the SDK passes its `getToken` as the
68
+ * transport's `apiKeyResolver`.
69
+ */
70
+ export class BrowserAuth {
71
+ origin;
72
+ hideBadge;
73
+ project;
74
+ events;
75
+ /** Lazily-constructed when events are present — drives the badge's
76
+ * activity ring / LED / popover. nullable for tests / non-browser. */
77
+ tracker;
78
+ badgePosition;
79
+ token = null;
80
+ expiresAt = 0;
81
+ userInfo = null;
82
+ listeners = [];
83
+ parentHandshake = null;
84
+ signingIn = false;
85
+ constructor(opts = {}) {
86
+ this.origin = (opts.origin ?? "https://www.runapps.ai").replace(/\/$/, "");
87
+ this.hideBadge = !!opts.hideBadge;
88
+ this.project = opts.project ?? null;
89
+ this.events = opts.events ?? null;
90
+ this.badgePosition = opts.badgePosition ?? "bottom-right";
91
+ if (this.events && !this.hideBadge) {
92
+ this.tracker = new ActivityTracker();
93
+ this.tracker.attach(this.events);
94
+ }
95
+ else {
96
+ this.tracker = null;
97
+ }
98
+ if (typeof window === "undefined")
99
+ return;
100
+ // Restore from localStorage (survives page reloads). We pin the
101
+ // saved token to the gateway origin: a bundle that switches
102
+ // between dev / prod gateways shouldn't accidentally reuse the
103
+ // previous one's token.
104
+ this.loadPersisted();
105
+ // Eager: install token from the URL fragment if the grant page
106
+ // just redirected us back here. This always WINS over a
107
+ // localStorage-cached token because a fresh redirect implies the
108
+ // user just (re)authenticated and the old cache may be stale.
109
+ // A successful fragment install also clears the "signed out"
110
+ // sticky flag — the user explicitly signed in again.
111
+ if (this.consumeFragment()) {
112
+ this.clearSignedOut();
113
+ }
114
+ if (this.isSignedOut()) {
115
+ // Honour the user's explicit sign-out across reloads. Don't
116
+ // attempt parent handshake or render the badge.
117
+ }
118
+ else if (this.inIframe() && !this.tokenIsFresh()) {
119
+ // Best-effort parent handshake — silent if it succeeds, falls
120
+ // through to the standard fragment / redirect path otherwise.
121
+ this.parentHandshake = this.requestTokenFromParent().catch(() => {
122
+ /* dashboard parent unavailable; will redirect on next call */
123
+ });
124
+ }
125
+ else if (this.tokenIsFresh() && this.userInfo && !this.hideBadge) {
126
+ // Restored from cache — render the badge so the user can see
127
+ // they're already signed in (and find the sign-out affordance
128
+ // their bundle author exposed).
129
+ this.renderBadge();
130
+ }
131
+ }
132
+ /** Public token-fetcher; pass to RunApps as `apiKeyResolver`. */
133
+ getToken = async () => {
134
+ if (this.tokenIsFresh())
135
+ return this.token;
136
+ // User explicitly signed out — don't silently re-auth. Throw so
137
+ // the caller surfaces an unauthenticated state and can prompt the
138
+ // user to click signIn. Without this guard, the backend's
139
+ // auto-grant flow would re-issue a token on every page reload
140
+ // (the cookie on the gateway domain is still valid).
141
+ if (this.isSignedOut()) {
142
+ throw new Error("RunApps: signed out — call client.signIn() to authenticate");
143
+ }
144
+ // ── Embedded (cross-site iframe) path ──
145
+ //
146
+ // Inside a cross-site iframe (the dashboard's bundle embed),
147
+ // signIn() → /api/sdk/grant is a DEAD END: navigating the iframe to
148
+ // the gateway origin is a *sub-frame* navigation, so the session
149
+ // cookie is NOT sent (SameSite) and grant bounces to a login form
150
+ // the user can't complete in place. The dashboard parent — same
151
+ // origin as the gateway, and it holds the cookie — is the ONLY
152
+ // party that can mint a token here. So we RE-handshake with the
153
+ // parent on EVERY stale token (not just once at construction).
154
+ // This is what lets a bundle survive token expiry / revocation
155
+ // without ever dropping to the login screen.
156
+ if (this.inIframe()) {
157
+ try {
158
+ // Reuse the eager construction-time handshake on the first call
159
+ // (avoids a duplicate post), then consume it so later staleness
160
+ // (expiry / 401-invalidate) starts a fresh handshake instead.
161
+ if (this.parentHandshake) {
162
+ await this.parentHandshake;
163
+ this.parentHandshake = null;
164
+ }
165
+ if (!this.tokenIsFresh()) {
166
+ await this.requestTokenFromParent();
167
+ }
168
+ }
169
+ catch {
170
+ /* parent slow / unreachable — fall through to the throw */
171
+ }
172
+ if (this.tokenIsFresh())
173
+ return this.token;
174
+ // Never signIn() inside an iframe (cookie-less grant dead end).
175
+ // Surface as unauthenticated so the bundle can retry / show state.
176
+ throw new Error("RunApps: could not obtain a token from the dashboard");
177
+ }
178
+ // ── Top-level (own tab / window) path ──
179
+ //
180
+ // signIn() → grant works here because the cookie IS sent on a
181
+ // top-level navigation.
182
+ if (this.parentHandshake) {
183
+ await this.parentHandshake;
184
+ if (this.tokenIsFresh())
185
+ return this.token;
186
+ }
187
+ // No fresh token + can't get one silently → redirect to grant.
188
+ // The page is about to unload; return a never-resolving Promise so
189
+ // the caller doesn't proceed with a half-issued request.
190
+ this.signIn();
191
+ return new Promise(() => { });
192
+ };
193
+ /** Subscribe to (re)acquisition events. Returns an unsubscribe fn. */
194
+ onTokenChange(handler) {
195
+ this.listeners.push(handler);
196
+ return () => {
197
+ const i = this.listeners.indexOf(handler);
198
+ if (i >= 0)
199
+ this.listeners.splice(i, 1);
200
+ };
201
+ }
202
+ /** Currently-authenticated user, or null. */
203
+ get user() {
204
+ return this.userInfo;
205
+ }
206
+ /**
207
+ * True iff a cached bearer token is present AND not within the
208
+ * 60-second pre-expiry refresh margin. Use this — not `user !==
209
+ * null` — to decide whether to show "please sign in" UI: the user
210
+ * metadata is only populated when the SDK was fed fresh user info
211
+ * (post-redirect fragment carries it; iframe handshake carries it;
212
+ * an older localStorage save format may have persisted only the
213
+ * token). A token without metadata is still a fully-usable
214
+ * session — every gateway call will succeed — and prompting the
215
+ * user to sign in there causes a redirect that loses session
216
+ * continuity for no reason.
217
+ */
218
+ hasFreshToken() {
219
+ return this.tokenIsFresh();
220
+ }
221
+ /**
222
+ * Clear the cached token / user / badge and the persisted copy in
223
+ * localStorage. Next gateway call will trigger a redirect to the
224
+ * grant page — i.e. the standard sign-in flow. Wire this to a
225
+ * "Sign out" button in the bundle's settings UI.
226
+ *
227
+ * Does NOT redirect on its own; the bundle decides whether to
228
+ * also navigate the user (e.g. to a logged-out splash page) or
229
+ * just refresh state and let the next API call re-prompt.
230
+ */
231
+ signOut() {
232
+ this.token = null;
233
+ this.expiresAt = 0;
234
+ this.userInfo = null;
235
+ this.parentHandshake = null;
236
+ this.signingIn = false;
237
+ this.clearPersisted();
238
+ this.markSignedOut();
239
+ this.removeBadge();
240
+ }
241
+ /**
242
+ * Drop the in-memory + persisted token without setting the sticky
243
+ * "signed out" flag. Used by the transport layer when the gateway
244
+ * returns 401 (token revoked server-side, e.g. user unsubscribed in
245
+ * another tab). The next `getToken()` will trigger the standard
246
+ * `signIn()` redirect-grant flow and the user re-auths transparently.
247
+ */
248
+ invalidate() {
249
+ this.token = null;
250
+ this.expiresAt = 0;
251
+ this.userInfo = null;
252
+ this.parentHandshake = null;
253
+ this.signingIn = false;
254
+ this.clearPersisted();
255
+ }
256
+ /** Force a redirect to the grant page. */
257
+ signIn() {
258
+ if (typeof window === "undefined" || this.signingIn)
259
+ return;
260
+ // Explicit signIn clears the "signed out" flag so getToken() can
261
+ // proceed with the redirect-grant flow.
262
+ this.clearSignedOut();
263
+ this.signingIn = true;
264
+ const ret = location.href.split("#")[0] ?? location.href;
265
+ const scheme = window.matchMedia?.("(prefers-color-scheme: dark)")
266
+ .matches
267
+ ? "dark"
268
+ : "light";
269
+ location.href = this.buildGrantUrl({
270
+ pageOrigin: location.origin,
271
+ app: location.host,
272
+ redirectTo: ret,
273
+ scheme,
274
+ });
275
+ }
276
+ /**
277
+ * Build the `/api/sdk/grant?…` URL the user is redirected to. Split
278
+ * out so tests can assert URL formation without a `window` context;
279
+ * `signIn` is the only production caller.
280
+ *
281
+ * Public surface, but namespaced under `_buildGrantUrlForTest` so it
282
+ * doesn't show up in IntelliSense as a normal API.
283
+ */
284
+ _buildGrantUrlForTest(args) {
285
+ return this.buildGrantUrl(args);
286
+ }
287
+ /**
288
+ * Return the localStorage keys this instance reads / writes for its
289
+ * persisted token + sign-out flag. Exposed for tests so we can
290
+ * assert per-project namespacing without a real DOM; not part of
291
+ * the public API surface (the leading underscore + `ForTest` suffix
292
+ * is the SDK's convention for this).
293
+ */
294
+ _storageKeysForTest() {
295
+ return {
296
+ auth: storageKey(this.project),
297
+ signedOut: signedOutKey(this.project),
298
+ };
299
+ }
300
+ buildGrantUrl(args) {
301
+ const params = new URLSearchParams();
302
+ params.set("origin", args.pageOrigin);
303
+ params.set("app", args.app);
304
+ params.set("redirect_to", args.redirectTo);
305
+ params.set("scheme", args.scheme);
306
+ if (this.project)
307
+ params.set("project_id", this.project);
308
+ return this.origin + "/api/sdk/grant?" + params.toString();
309
+ }
310
+ // ── Internals ────────────────────────────────────────────────────
311
+ nowSec() {
312
+ return Math.floor(Date.now() / 1000);
313
+ }
314
+ tokenIsFresh() {
315
+ return !!this.token && this.nowSec() < this.expiresAt - TOKEN_REFRESH_MARGIN_S;
316
+ }
317
+ inIframe() {
318
+ try {
319
+ return window.self !== window.top;
320
+ }
321
+ catch {
322
+ return true;
323
+ }
324
+ }
325
+ setToken(token, expiresAt, user) {
326
+ this.token = token;
327
+ this.expiresAt = expiresAt || this.nowSec() + 3600;
328
+ if (user) {
329
+ this.userInfo = user;
330
+ if (!this.hideBadge)
331
+ this.renderBadge();
332
+ }
333
+ this.savePersisted();
334
+ for (const fn of this.listeners) {
335
+ try {
336
+ fn(token);
337
+ }
338
+ catch (err) {
339
+ console.warn("[runapps] onTokenChange handler threw", err);
340
+ }
341
+ }
342
+ }
343
+ savePersisted() {
344
+ if (typeof localStorage === "undefined" || !this.token)
345
+ return;
346
+ try {
347
+ const data = {
348
+ token: this.token,
349
+ expiresAt: this.expiresAt,
350
+ origin: this.origin,
351
+ ...(this.userInfo ? { user: this.userInfo } : {}),
352
+ };
353
+ localStorage.setItem(storageKey(this.project), JSON.stringify(data));
354
+ }
355
+ catch {
356
+ /* quota / private mode — degrade to in-memory only */
357
+ }
358
+ }
359
+ loadPersisted() {
360
+ if (typeof localStorage === "undefined")
361
+ return;
362
+ try {
363
+ const raw = localStorage.getItem(storageKey(this.project));
364
+ if (!raw)
365
+ return;
366
+ const data = JSON.parse(raw);
367
+ if (!data.token || !data.origin)
368
+ return;
369
+ // Pin to the gateway origin we were constructed with — a bundle
370
+ // talking to a different gateway shouldn't reuse this token.
371
+ if (data.origin !== this.origin)
372
+ return;
373
+ // Already expired? Drop it; next call will redirect to grant.
374
+ if (typeof data.expiresAt === "number" && data.expiresAt > 0 &&
375
+ this.nowSec() >= data.expiresAt - TOKEN_REFRESH_MARGIN_S) {
376
+ this.clearPersisted();
377
+ return;
378
+ }
379
+ this.token = data.token;
380
+ this.expiresAt = data.expiresAt ?? 0;
381
+ if (data.user)
382
+ this.userInfo = data.user;
383
+ }
384
+ catch {
385
+ /* corrupted blob — drop it */
386
+ this.clearPersisted();
387
+ }
388
+ }
389
+ clearPersisted() {
390
+ if (typeof localStorage === "undefined")
391
+ return;
392
+ try {
393
+ localStorage.removeItem(storageKey(this.project));
394
+ }
395
+ catch { /* ignore */ }
396
+ }
397
+ isSignedOut() {
398
+ if (typeof localStorage === "undefined")
399
+ return false;
400
+ try {
401
+ return localStorage.getItem(signedOutKey(this.project)) === "1";
402
+ }
403
+ catch {
404
+ return false;
405
+ }
406
+ }
407
+ markSignedOut() {
408
+ if (typeof localStorage === "undefined")
409
+ return;
410
+ try {
411
+ localStorage.setItem(signedOutKey(this.project), "1");
412
+ }
413
+ catch { /* ignore */ }
414
+ }
415
+ clearSignedOut() {
416
+ if (typeof localStorage === "undefined")
417
+ return;
418
+ try {
419
+ localStorage.removeItem(signedOutKey(this.project));
420
+ }
421
+ catch { /* ignore */ }
422
+ }
423
+ removeBadge() {
424
+ if (typeof document === "undefined")
425
+ return;
426
+ document.getElementById("__runjobs_identity__")?.remove();
427
+ }
428
+ /** Parse the #runjobs_token=… fragment, install, then strip it. */
429
+ consumeFragment() {
430
+ if (!location.hash || location.hash.indexOf("runjobs_token=") < 0)
431
+ return false;
432
+ const params = {};
433
+ location.hash
434
+ .replace(/^#/, "")
435
+ .split("&")
436
+ .forEach((kv) => {
437
+ const i = kv.indexOf("=");
438
+ if (i >= 0)
439
+ params[decodeURIComponent(kv.slice(0, i))] = decodeURIComponent(kv.slice(i + 1));
440
+ });
441
+ if (!params.runjobs_token)
442
+ return false;
443
+ let user;
444
+ if (params.user) {
445
+ try {
446
+ user = JSON.parse(params.user);
447
+ }
448
+ catch {
449
+ /* malformed; ignore — token still works */
450
+ }
451
+ }
452
+ this.setToken(params.runjobs_token, parseInt(params.expires_at ?? "", 10) || 0, user);
453
+ // Clean fragment so a refresh doesn't re-consume.
454
+ try {
455
+ history.replaceState(null, "", location.pathname + location.search);
456
+ }
457
+ catch {
458
+ /* ignore — non-fatal */
459
+ }
460
+ return true;
461
+ }
462
+ /** postMessage handshake with the dashboard parent (iframe path). */
463
+ requestTokenFromParent() {
464
+ return new Promise((resolve, reject) => {
465
+ let done = false;
466
+ const timers = [];
467
+ const cleanup = () => {
468
+ done = true;
469
+ window.removeEventListener("message", onMessage);
470
+ for (const t of timers)
471
+ clearTimeout(t);
472
+ };
473
+ const onMessage = (e) => {
474
+ if (e.origin !== this.origin)
475
+ return;
476
+ const data = e.data;
477
+ if (data && data.type === "runjobs:token" && data.token) {
478
+ this.setToken(data.token, data.expires_at ?? 0, data.user);
479
+ cleanup();
480
+ resolve();
481
+ }
482
+ };
483
+ window.addEventListener("message", onMessage);
484
+ // postMessage is NOT queued: if the parent's React listener isn't
485
+ // mounted at the instant of our first post, that single message is
486
+ // lost forever and we'd time out → dead-end grant redirect. So
487
+ // re-post a few times across the wait window; a late-mounting
488
+ // parent still receives a request to answer. The parent also
489
+ // pushes the token proactively on iframe load — belt-and-braces.
490
+ const post = () => {
491
+ if (done)
492
+ return;
493
+ try {
494
+ window.parent.postMessage({ type: "runjobs:request-token" }, this.origin);
495
+ }
496
+ catch (err) {
497
+ cleanup();
498
+ reject(err);
499
+ }
500
+ };
501
+ post();
502
+ for (const d of [250, 750, 1500, 3000])
503
+ timers.push(setTimeout(post, d));
504
+ // Longer overall window (5s vs the old 3s) gives a heavy bundle +
505
+ // a cold parent room to complete the round trip before we give up.
506
+ timers.push(setTimeout(() => {
507
+ if (done)
508
+ return;
509
+ cleanup();
510
+ reject(new Error("runapps: parent handshake timeout"));
511
+ }, 5000));
512
+ });
513
+ }
514
+ /** Floating identity badge — bottom-right pill showing the user.
515
+ * Click opens the runapps.ai dashboard in a new tab so the user
516
+ * can manage their account / billing without losing the bundle's
517
+ * in-flight state. Uses `noopener,noreferrer` so the new tab
518
+ * can't reach back into the bundle window via `window.opener`. */
519
+ renderBadge() {
520
+ if (typeof document === "undefined" || !this.userInfo)
521
+ return;
522
+ const ID = "__runjobs_identity__";
523
+ const dashboardUrl = `${this.origin}/dashboard`;
524
+ const ready = (cb) => document.body ? cb() : document.addEventListener("DOMContentLoaded", cb);
525
+ ready(() => {
526
+ // Idempotent: remove an older instance so successive renders
527
+ // (e.g. after sign-in finishes) don't stack badges.
528
+ document.getElementById(ID)?.remove();
529
+ const root = mountActivityBadge({
530
+ id: ID,
531
+ user: this.userInfo,
532
+ dashboardUrl,
533
+ tracker: this.tracker,
534
+ position: this.badgePosition,
535
+ // Bind through `this` so the popover's Sign-out button runs
536
+ // BrowserAuth.signOut (clears token cache + flips signed-out
537
+ // flag + dispatches the auth event); a plain method reference
538
+ // would lose `this`.
539
+ //
540
+ // Then hard-reload the page. signOut() deliberately doesn't
541
+ // navigate (its contract leaves that to the host bundle), but
542
+ // the badge IS the SDK's own UI, and a user clicking its
543
+ // "Sign out" expects to land back on the app's signed-out /
544
+ // login view — not keep staring at a now-stale authed page.
545
+ // The reload re-runs the bundle's bootstrap against a cleared
546
+ // token + the sticky signed-out flag, so the app naturally
547
+ // shows its login state. Guard window for non-browser hosts.
548
+ onSignOut: () => {
549
+ this.signOut();
550
+ if (typeof window !== "undefined")
551
+ window.location.reload();
552
+ },
553
+ });
554
+ document.body.appendChild(root);
555
+ });
556
+ }
557
+ }
558
+ /**
559
+ * Per-corner CSS overrides for the floating badge + its popover.
560
+ * `top` vs `bottom` flips the badge's vertical anchor AND the
561
+ * popover's open direction (top-anchored badge → popover opens
562
+ * downward); `right` vs `left` flips horizontal anchor on both.
563
+ */
564
+ /**
565
+ * Inject the `@keyframes __runjobs_led_pulse__` rule into the document
566
+ * head ONCE (idempotent — re-mounts and multiple SDK instances on the
567
+ * same page share the same rule). Must run at badge mount time so
568
+ * the LED's `animation` CSS resolves the keyframe reference the
569
+ * moment status flips from idle to active — previously this lived
570
+ * inside createPopover() and only fired on first hover, leaving the
571
+ * LED frozen until the user interacted.
572
+ */
573
+ function ensurePulseKeyframes() {
574
+ if (typeof document === "undefined")
575
+ return;
576
+ if (document.getElementById("__runjobs_kf__"))
577
+ return;
578
+ const style = document.createElement("style");
579
+ style.id = "__runjobs_kf__";
580
+ style.textContent =
581
+ "@keyframes __runjobs_led_pulse__ {" +
582
+ " 0% { box-shadow: 0 0 0 0 rgba(59,130,246,0.7); }" +
583
+ " 70% { box-shadow: 0 0 0 6px rgba(59,130,246,0); }" +
584
+ " 100% { box-shadow: 0 0 0 0 rgba(59,130,246,0); }" +
585
+ "}";
586
+ (document.head ?? document.documentElement).appendChild(style);
587
+ }
588
+ function badgeCornerStyles(position) {
589
+ const v = position.startsWith("top") ? "top" : "bottom";
590
+ const h = position.endsWith("right") ? "right" : "left";
591
+ // Popover sits on the OPPOSITE vertical side of the badge so it
592
+ // grows AWAY from the edge of the viewport, never offscreen.
593
+ const popoverVerticalAnchor = v === "bottom" ? "bottom:calc(100% + 8px);top:auto" : "top:calc(100% + 8px);bottom:auto";
594
+ const popoverHorizontalAnchor = h === "right" ? "right:0;left:auto" : "left:0;right:auto";
595
+ return {
596
+ badge: `${v}:16px;${h}:16px`,
597
+ popover: `${popoverVerticalAnchor};${popoverHorizontalAnchor}`,
598
+ };
599
+ }
600
+ // ─── Drag-to-move + position persistence ────────────────────────────
601
+ //
602
+ // Users sometimes want to move the floating badge — it overlaps a
603
+ // site's own corner UI (chat widget, footer button, etc.). Drag to
604
+ // reposition; the saved coords land in a cookie so reloads keep the
605
+ // chosen spot. Cookie because the user asked for cookie; localStorage
606
+ // would be the more typical choice for ~10 bytes of UI state, but
607
+ // cookie is fine — small payload, same-site, no SDK dependency on a
608
+ // storage permission.
609
+ /** Cookie key the saved badge position lives under. URI-safe, prefixed
610
+ * so other libraries / consumers don't accidentally collide. */
611
+ const BADGE_POS_COOKIE = "__runjobs_badge_pos__";
612
+ /** Pixel threshold a pointer must travel before we treat the gesture
613
+ * as a drag instead of a click. Below this, it's a normal badge tap
614
+ * that opens the popover. 5 px matches OS-level click slop tolerances. */
615
+ const BADGE_DRAG_THRESHOLD_PX = 5;
616
+ /** Edge inset when clamping a dragged badge into the viewport — keeps
617
+ * it visually inside the safe area on resize. Match the default
618
+ * corner offset (`16px` in badgeCornerStyles). */
619
+ const BADGE_EDGE_INSET_PX = 8;
620
+ function readBadgePosCookie() {
621
+ if (typeof document === "undefined")
622
+ return null;
623
+ const raw = document.cookie
624
+ .split(";")
625
+ .map((s) => s.trim())
626
+ .find((s) => s.startsWith(BADGE_POS_COOKIE + "="));
627
+ if (!raw)
628
+ return null;
629
+ const value = decodeURIComponent(raw.slice(BADGE_POS_COOKIE.length + 1));
630
+ const parts = value.split(",");
631
+ const rawX = parts[0];
632
+ const rawY = parts[1];
633
+ if (parts.length !== 2 || rawX === undefined || rawY === undefined)
634
+ return null;
635
+ const x = Number.parseInt(rawX, 10);
636
+ const y = Number.parseInt(rawY, 10);
637
+ if (!Number.isFinite(x) || !Number.isFinite(y))
638
+ return null;
639
+ return { x, y };
640
+ }
641
+ function writeBadgePosCookie(x, y) {
642
+ if (typeof document === "undefined")
643
+ return;
644
+ // 1-year expiry, root path, lax cross-site so the cookie survives
645
+ // navigation between subdomains under the same site. Secure when
646
+ // we're on HTTPS so iframes can read it back without warnings.
647
+ const maxAge = 60 * 60 * 24 * 365;
648
+ const secure = typeof window !== "undefined" && window.location?.protocol === "https:" ? ";Secure" : "";
649
+ document.cookie =
650
+ `${BADGE_POS_COOKIE}=${encodeURIComponent(`${Math.round(x)},${Math.round(y)}`)};` +
651
+ `path=/;max-age=${maxAge};SameSite=Lax${secure}`;
652
+ }
653
+ /** Snap (x, y) into the viewport so a saved position from a wider
654
+ * screen doesn't render off-canvas after a window resize.
655
+ * Coordinates are top-left of the badge element; w/h are its bounds. */
656
+ function clampToViewport(x, y, w, h) {
657
+ const vw = typeof window !== "undefined" ? window.innerWidth : 1024;
658
+ const vh = typeof window !== "undefined" ? window.innerHeight : 768;
659
+ const maxX = Math.max(BADGE_EDGE_INSET_PX, vw - w - BADGE_EDGE_INSET_PX);
660
+ const maxY = Math.max(BADGE_EDGE_INSET_PX, vh - h - BADGE_EDGE_INSET_PX);
661
+ return {
662
+ x: Math.min(Math.max(BADGE_EDGE_INSET_PX, x), maxX),
663
+ y: Math.min(Math.max(BADGE_EDGE_INSET_PX, y), maxY),
664
+ };
665
+ }
666
+ /** Recompute the popover anchor CSS based on which quadrant of the
667
+ * viewport the badge currently sits in. Mirrors badgeCornerStyles'
668
+ * rule: popover grows AWAY from the nearer edge so it never escapes
669
+ * the viewport. Used after a drag so the popover keeps opening
670
+ * inward even when the badge is no longer in its starting corner. */
671
+ function popoverCssForRect(rect) {
672
+ const vw = typeof window !== "undefined" ? window.innerWidth : 1024;
673
+ const vh = typeof window !== "undefined" ? window.innerHeight : 768;
674
+ const cx = rect.left + rect.width / 2;
675
+ const cy = rect.top + rect.height / 2;
676
+ const verticalAnchor = cy < vh / 2
677
+ ? "top:calc(100% + 8px);bottom:auto"
678
+ : "bottom:calc(100% + 8px);top:auto";
679
+ const horizontalAnchor = cx < vw / 2 ? "left:0;right:auto" : "right:0;left:auto";
680
+ return `${verticalAnchor};${horizontalAnchor}`;
681
+ }
682
+ function mountActivityBadge(opts) {
683
+ const { id, user, dashboardUrl, tracker, position, onSignOut } = opts;
684
+ const corners = badgeCornerStyles(position);
685
+ // `currentPopoverCss` starts at the corner-derived anchor and gets
686
+ // overwritten when the user drags the badge to a new quadrant.
687
+ // createPopover() reads this at popover instantiation time; if the
688
+ // popover was already built before a drag, the drag handler also
689
+ // patches popover.style directly so the next open uses the new anchor.
690
+ let currentPopoverCss = corners.popover;
691
+ // Inject the LED pulse keyframes at mount time. Previously this
692
+ // lived inside createPopover() — which only runs on first hover —
693
+ // so the LED's `animation` CSS referenced a keyframe rule that
694
+ // didn't exist until the user hovered, and the browser silently
695
+ // skipped the animation. Result: LED stayed solid until hover,
696
+ // exactly matching the bug report.
697
+ ensurePulseKeyframes();
698
+ const el = document.createElement("button");
699
+ el.id = id;
700
+ el.type = "button";
701
+ el.title = "RunApps activity";
702
+ el.setAttribute("aria-label", "RunApps activity status");
703
+ el.style.cssText = [
704
+ "position:fixed",
705
+ corners.badge,
706
+ "z-index:2147483647",
707
+ "display:flex",
708
+ "align-items:center",
709
+ "gap:8px",
710
+ "padding:6px 12px 6px 6px",
711
+ "border-radius:999px",
712
+ "font:500 12px/1 -apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif",
713
+ "color:#fff",
714
+ "background:rgba(15,15,20,0.55)",
715
+ "backdrop-filter:blur(10px) saturate(1.2)",
716
+ "-webkit-backdrop-filter:blur(10px) saturate(1.2)",
717
+ "border:1px solid rgba(255,255,255,0.12)",
718
+ "box-shadow:0 4px 16px rgba(0,0,0,0.15)",
719
+ "user-select:none",
720
+ "max-width:220px",
721
+ "overflow:visible", // popover spills out the top
722
+ "cursor:pointer",
723
+ "appearance:none",
724
+ "-webkit-appearance:none",
725
+ "transition:background-color 120ms ease-out, box-shadow 120ms ease-out",
726
+ ].join(";");
727
+ el.addEventListener("mouseenter", () => {
728
+ el.style.backgroundColor = "rgba(30,30,40,0.7)";
729
+ el.style.boxShadow = "0 6px 20px rgba(0,0,0,0.22)";
730
+ });
731
+ el.addEventListener("mouseleave", () => {
732
+ el.style.backgroundColor = "rgba(15,15,20,0.55)";
733
+ el.style.boxShadow = "0 4px 16px rgba(0,0,0,0.15)";
734
+ });
735
+ // ─── Avatar with ring + LED ────────────────────────────────
736
+ const avatarWrap = document.createElement("span");
737
+ avatarWrap.style.cssText = [
738
+ "position:relative",
739
+ "width:26px;height:26px",
740
+ "flex-shrink:0",
741
+ "display:inline-flex;align-items:center;justify-content:center",
742
+ ].join(";");
743
+ // Progress ring (SVG). Hidden at idle so we don't paint when
744
+ // nothing's happening. Stroke offset / dasharray animate via
745
+ // direct style writes from the redraw loop.
746
+ const ringNS = "http://www.w3.org/2000/svg";
747
+ const ring = document.createElementNS(ringNS, "svg");
748
+ ring.setAttribute("viewBox", "0 0 26 26");
749
+ ring.setAttribute("width", "26");
750
+ ring.setAttribute("height", "26");
751
+ ring.style.cssText = [
752
+ "position:absolute;inset:0",
753
+ "pointer-events:none",
754
+ "opacity:0",
755
+ "transition:opacity 200ms ease-out",
756
+ ].join(";");
757
+ const ringTrack = document.createElementNS(ringNS, "circle");
758
+ ringTrack.setAttribute("cx", "13");
759
+ ringTrack.setAttribute("cy", "13");
760
+ ringTrack.setAttribute("r", "12");
761
+ ringTrack.setAttribute("fill", "none");
762
+ ringTrack.setAttribute("stroke", "rgba(255,255,255,0.08)");
763
+ ringTrack.setAttribute("stroke-width", "1.5");
764
+ const ringArc = document.createElementNS(ringNS, "circle");
765
+ ringArc.setAttribute("cx", "13");
766
+ ringArc.setAttribute("cy", "13");
767
+ ringArc.setAttribute("r", "12");
768
+ ringArc.setAttribute("fill", "none");
769
+ ringArc.setAttribute("stroke", "#3b82f6");
770
+ ringArc.setAttribute("stroke-width", "2");
771
+ ringArc.setAttribute("stroke-linecap", "round");
772
+ // Rotate origin = center, start arc from 12 o'clock so progress
773
+ // sweeps clockwise like every progress UI on the planet.
774
+ ringArc.setAttribute("transform", "rotate(-90 13 13)");
775
+ // Circumference = 2πr ≈ 75.4. dasharray=circumference + dashoffset
776
+ // drives the arc length.
777
+ const RING_CIRC = 2 * Math.PI * 12;
778
+ ringArc.setAttribute("stroke-dasharray", String(RING_CIRC));
779
+ ringArc.setAttribute("stroke-dashoffset", String(RING_CIRC));
780
+ ring.appendChild(ringTrack);
781
+ ring.appendChild(ringArc);
782
+ const avatar = document.createElement("div");
783
+ avatar.textContent = (user.name || "?").charAt(0).toUpperCase();
784
+ avatar.style.cssText = [
785
+ "width:22px;height:22px;border-radius:50%;flex-shrink:0",
786
+ "display:flex;align-items:center;justify-content:center",
787
+ "background:linear-gradient(135deg,#3b82f6,#8b5cf6)",
788
+ "font-size:11px;font-weight:600;color:#fff",
789
+ "position:relative",
790
+ ].join(";");
791
+ // LED status dot — absolute-positioned over the avatar's
792
+ // bottom-right corner. Same trick Discord uses for online status.
793
+ const led = document.createElement("span");
794
+ led.style.cssText = [
795
+ "position:absolute",
796
+ "bottom:-2px;right:-2px",
797
+ "width:8px;height:8px;border-radius:50%",
798
+ "background:rgba(255,255,255,0.2)",
799
+ "border:2px solid rgba(15,15,20,1)",
800
+ "box-shadow:0 0 0 0 transparent",
801
+ "transition:background-color 150ms ease-out, box-shadow 200ms ease-out",
802
+ "pointer-events:none",
803
+ ].join(";");
804
+ avatarWrap.appendChild(ring);
805
+ avatarWrap.appendChild(avatar);
806
+ avatarWrap.appendChild(led);
807
+ const name = document.createElement("span");
808
+ name.textContent = user.name || "Signed in";
809
+ name.style.cssText = "white-space:nowrap;overflow:hidden;text-overflow:ellipsis;pointer-events:none";
810
+ el.appendChild(avatarWrap);
811
+ el.appendChild(name);
812
+ // ─── Popover (lazy, append on first open) ──────────────────
813
+ //
814
+ // Open / close has two modes:
815
+ // - hover: 400ms-grace open on badge enter; 300ms-grace close
816
+ // when neither badge NOR popover is hovered. The grace lets
817
+ // the user cross the 8px gap between badge and popover without
818
+ // losing the popover. Standard tooltip pattern.
819
+ // - click: sticky — stays open until the user clicks outside.
820
+ // Useful for reading + copying values out of the panel.
821
+ let popover = null;
822
+ let popoverOpen = false;
823
+ let popoverOpenedBy = null;
824
+ let openTimer = null;
825
+ let closeTimer = null;
826
+ const cancelOpen = () => {
827
+ if (openTimer !== null) {
828
+ window.clearTimeout(openTimer);
829
+ openTimer = null;
830
+ }
831
+ };
832
+ const cancelClose = () => {
833
+ if (closeTimer !== null) {
834
+ window.clearTimeout(closeTimer);
835
+ closeTimer = null;
836
+ }
837
+ };
838
+ const scheduleClose = () => {
839
+ cancelClose();
840
+ closeTimer = window.setTimeout(() => {
841
+ // Re-check the mode — a click between schedule and fire turns
842
+ // a hover-pending close into a sticky open.
843
+ if (popoverOpenedBy === "hover")
844
+ closePopover();
845
+ }, 300);
846
+ };
847
+ const openPopover = (by) => {
848
+ cancelOpen();
849
+ cancelClose();
850
+ if (popoverOpen) {
851
+ // Upgrade a hover-open to click-stickiness if the user
852
+ // clicked while the popover was already hover-shown.
853
+ if (by === "click")
854
+ popoverOpenedBy = "click";
855
+ return;
856
+ }
857
+ if (!popover) {
858
+ popover = createPopover(dashboardUrl, currentPopoverCss, () => {
859
+ // Tear down our DOM before the auth flip runs so the popover
860
+ // doesn't briefly show "signed out" state to a user who's
861
+ // about to navigate away. closePopover() also removes the
862
+ // hover/click handlers' state.
863
+ closePopover();
864
+ onSignOut();
865
+ });
866
+ // Attach hover handlers ONCE — the popover element is reused
867
+ // across open/close cycles, so .remove() / .appendChild()
868
+ // doesn't shake the listeners off.
869
+ popover.addEventListener("mouseenter", cancelClose);
870
+ popover.addEventListener("mouseleave", () => {
871
+ if (popoverOpenedBy === "hover")
872
+ scheduleClose();
873
+ });
874
+ }
875
+ el.appendChild(popover);
876
+ popoverOpen = true;
877
+ popoverOpenedBy = by;
878
+ // Schedule one immediate redraw so the popover shows current
879
+ // state, not a stale snapshot from the last close.
880
+ requestRedraw();
881
+ };
882
+ const closePopover = () => {
883
+ cancelClose();
884
+ if (!popoverOpen)
885
+ return;
886
+ popover?.remove();
887
+ popoverOpen = false;
888
+ popoverOpenedBy = null;
889
+ };
890
+ el.addEventListener("click", (e) => {
891
+ e.stopPropagation();
892
+ popoverOpen ? closePopover() : openPopover("click");
893
+ });
894
+ // Hover-open with 400ms delay — matches macOS tray tooltips.
895
+ el.addEventListener("mouseenter", () => {
896
+ cancelClose(); // moving back onto badge cancels a pending close
897
+ cancelOpen();
898
+ openTimer = window.setTimeout(() => {
899
+ if (!popoverOpen)
900
+ openPopover("hover");
901
+ }, 400);
902
+ });
903
+ el.addEventListener("mouseleave", () => {
904
+ cancelOpen();
905
+ // Only the hover branch auto-closes; click-opened stays sticky
906
+ // and dismisses via the click-outside handler below.
907
+ if (popoverOpenedBy === "hover")
908
+ scheduleClose();
909
+ });
910
+ // Click anywhere outside the badge closes the popover.
911
+ document.addEventListener("click", (e) => {
912
+ if (popoverOpen && !el.contains(e.target))
913
+ closePopover();
914
+ });
915
+ // ─── Drag-to-move + cookie persistence ─────────────────────
916
+ //
917
+ // Two intents share the pointer-down on the badge:
918
+ // 1. Quick tap → open popover (existing click handler).
919
+ // 2. Hold-and-drag → reposition the badge.
920
+ //
921
+ // Disambiguate by movement distance: until the pointer travels
922
+ // BADGE_DRAG_THRESHOLD_PX from its starting point we treat the
923
+ // gesture as a pending click and let the existing click handler
924
+ // run on pointerup. Past that threshold we flip to drag mode —
925
+ // start moving the badge, claim the pointer via setPointerCapture
926
+ // so we keep getting moves even when the pointer leaves the badge
927
+ // bounds, and on pointerup persist the final coords + suppress
928
+ // the click event the browser would otherwise synthesize.
929
+ let dragStartClientX = 0;
930
+ let dragStartClientY = 0;
931
+ let dragStartBadgeLeft = 0;
932
+ let dragStartBadgeTop = 0;
933
+ let pointerDown = false;
934
+ let isDragging = false;
935
+ let suppressNextClick = false;
936
+ /** Apply absolute pixel position to the badge, clearing the corner
937
+ * anchors set at mount. Used both by the drag handler (live update)
938
+ * and the cookie-restore path (mount-time hydrate). */
939
+ const applyAbsolutePos = (x, y) => {
940
+ el.style.left = `${x}px`;
941
+ el.style.top = `${y}px`;
942
+ el.style.right = "auto";
943
+ el.style.bottom = "auto";
944
+ };
945
+ /** Recompute + apply the popover anchor based on the badge's current
946
+ * position in the viewport. Called after a drag so the popover
947
+ * always grows away from the nearest viewport edge. If the popover
948
+ * was already instantiated, patches its inline style in place. */
949
+ const recomputePopoverAnchor = () => {
950
+ const r = el.getBoundingClientRect();
951
+ currentPopoverCss = popoverCssForRect({
952
+ left: r.left,
953
+ top: r.top,
954
+ width: r.width,
955
+ height: r.height,
956
+ });
957
+ if (popover) {
958
+ // Reset the four anchor properties before re-applying so we
959
+ // don't leave stale `right` / `bottom` declarations stuck on
960
+ // the element from the previous anchor.
961
+ popover.style.top = "";
962
+ popover.style.bottom = "";
963
+ popover.style.left = "";
964
+ popover.style.right = "";
965
+ for (const decl of currentPopoverCss.split(";")) {
966
+ const [prop, val] = decl.split(":");
967
+ if (prop && val)
968
+ popover.style.setProperty(prop.trim(), val.trim());
969
+ }
970
+ }
971
+ };
972
+ el.addEventListener("pointerdown", (e) => {
973
+ // Only the primary button drags — secondary clicks should fall
974
+ // through (browsers may show a context menu, etc.).
975
+ if (e.button !== 0)
976
+ return;
977
+ pointerDown = true;
978
+ isDragging = false;
979
+ dragStartClientX = e.clientX;
980
+ dragStartClientY = e.clientY;
981
+ const r = el.getBoundingClientRect();
982
+ dragStartBadgeLeft = r.left;
983
+ dragStartBadgeTop = r.top;
984
+ });
985
+ el.addEventListener("pointermove", (e) => {
986
+ if (!pointerDown)
987
+ return;
988
+ const dx = e.clientX - dragStartClientX;
989
+ const dy = e.clientY - dragStartClientY;
990
+ if (!isDragging) {
991
+ if (Math.abs(dx) < BADGE_DRAG_THRESHOLD_PX && Math.abs(dy) < BADGE_DRAG_THRESHOLD_PX) {
992
+ return; // still within click slop — keep treating as a tap
993
+ }
994
+ // Cross the threshold → enter drag mode. Capture the pointer
995
+ // so further moves arrive even when the pointer escapes the
996
+ // badge bounds (fast drags leave the small pill quickly).
997
+ isDragging = true;
998
+ el.setPointerCapture(e.pointerId);
999
+ el.style.cursor = "grabbing";
1000
+ // Pre-emptively close any open popover — keeping it pinned to a
1001
+ // moving badge looks janky and a drag is clearly a different
1002
+ // intent than "look at the popover".
1003
+ if (popoverOpen)
1004
+ closePopover();
1005
+ }
1006
+ const rect = el.getBoundingClientRect();
1007
+ const clamped = clampToViewport(dragStartBadgeLeft + dx, dragStartBadgeTop + dy, rect.width, rect.height);
1008
+ applyAbsolutePos(clamped.x, clamped.y);
1009
+ });
1010
+ const endDrag = (e) => {
1011
+ if (!pointerDown)
1012
+ return;
1013
+ pointerDown = false;
1014
+ if (!isDragging)
1015
+ return;
1016
+ isDragging = false;
1017
+ el.style.cursor = "pointer";
1018
+ if (el.hasPointerCapture(e.pointerId))
1019
+ el.releasePointerCapture(e.pointerId);
1020
+ // Persist final position. Re-read the rect so what we store is
1021
+ // exactly what's on screen — clampToViewport may have shaved
1022
+ // the trailing few pixels.
1023
+ const r = el.getBoundingClientRect();
1024
+ writeBadgePosCookie(r.left, r.top);
1025
+ recomputePopoverAnchor();
1026
+ // The browser will synthesize a click on the badge from this
1027
+ // pointer sequence (pointer went down + up on the same element).
1028
+ // Swallow it so a drag doesn't immediately open the popover.
1029
+ suppressNextClick = true;
1030
+ };
1031
+ el.addEventListener("pointerup", endDrag);
1032
+ el.addEventListener("pointercancel", endDrag);
1033
+ // Capture-phase click swallower — runs BEFORE the existing click
1034
+ // handler that toggles the popover, so we can stop a drag-tail
1035
+ // click without modifying the open/close logic itself.
1036
+ el.addEventListener("click", (e) => {
1037
+ if (suppressNextClick) {
1038
+ suppressNextClick = false;
1039
+ e.stopPropagation();
1040
+ e.preventDefault();
1041
+ }
1042
+ }, true);
1043
+ // Hydrate from cookie. After all the listeners and the corner-based
1044
+ // default style are in place, fast-forward to the user's last saved
1045
+ // position if any. Clamp to the current viewport so a saved spot
1046
+ // from a wider monitor doesn't put the badge off-canvas.
1047
+ const savedPos = readBadgePosCookie();
1048
+ if (savedPos) {
1049
+ // We need el's measured size to clamp; but el isn't in the DOM
1050
+ // yet, so we lean on a defer to next frame after first append.
1051
+ // Until then, apply the raw saved coords (the corner-default
1052
+ // styles still apply if these are absurd; clamp will fix it).
1053
+ applyAbsolutePos(savedPos.x, savedPos.y);
1054
+ // Queue a one-shot post-mount clamp + popover-anchor recompute.
1055
+ // requestAnimationFrame fires after the parent appendChild
1056
+ // brings the badge into layout, so getBoundingClientRect works.
1057
+ requestAnimationFrame(() => {
1058
+ const r = el.getBoundingClientRect();
1059
+ const clamped = clampToViewport(savedPos.x, savedPos.y, r.width, r.height);
1060
+ applyAbsolutePos(clamped.x, clamped.y);
1061
+ recomputePopoverAnchor();
1062
+ });
1063
+ }
1064
+ // ─── Redraw loop ───────────────────────────────────────────
1065
+ //
1066
+ // Idle: zero work. Active OR popover open: redraw at ~6 Hz so
1067
+ // the ring sweep and per-call elapsed counters stay live without
1068
+ // burning CPU. Token-arrival events also push a redraw out of
1069
+ // band so the ring jumps as soon as a chunk lands.
1070
+ let rafScheduled = false;
1071
+ let intervalHandle = null;
1072
+ const reconcile = () => {
1073
+ rafScheduled = false;
1074
+ if (!tracker) {
1075
+ // No event bus → static badge. Hide ring + LED.
1076
+ ring.style.opacity = "0";
1077
+ led.style.background = "rgba(255,255,255,0.2)";
1078
+ return;
1079
+ }
1080
+ const snap = tracker.snapshot();
1081
+ updateRing(ring, ringArc, RING_CIRC, snap);
1082
+ updateLED(led, snap);
1083
+ if (popoverOpen && popover)
1084
+ updatePopover(popover, snap);
1085
+ // Schedule the next paint only if there's reason. When the
1086
+ // popover is open we want the elapsed seconds to tick; when
1087
+ // active we want the ring to keep sweeping.
1088
+ const needsLoop = snap.status !== "idle" || popoverOpen;
1089
+ if (needsLoop && intervalHandle === null) {
1090
+ intervalHandle = window.setInterval(requestRedraw, 160);
1091
+ }
1092
+ else if (!needsLoop && intervalHandle !== null) {
1093
+ window.clearInterval(intervalHandle);
1094
+ intervalHandle = null;
1095
+ }
1096
+ };
1097
+ function requestRedraw() {
1098
+ if (rafScheduled)
1099
+ return;
1100
+ rafScheduled = true;
1101
+ if (typeof requestAnimationFrame === "function") {
1102
+ requestAnimationFrame(reconcile);
1103
+ }
1104
+ else {
1105
+ setTimeout(reconcile, 0);
1106
+ }
1107
+ }
1108
+ // Drive redraws off the tracker's onChange — fires synchronously
1109
+ // after every start / delta / end / error so the LED + ring update
1110
+ // within an animation frame of the event, not when the user
1111
+ // happens to hover the badge. requestRedraw() coalesces multiple
1112
+ // notifications into one rAF paint, so a chunky stream (30+ deltas
1113
+ // per second) still paints at most once per frame.
1114
+ if (tracker) {
1115
+ tracker.onChange(requestRedraw);
1116
+ }
1117
+ // Initial paint so the user sees their LED/ring state at mount
1118
+ // even before any event fires.
1119
+ requestRedraw();
1120
+ return el;
1121
+ }
1122
+ function updateRing(ring, arc, circ, snap) {
1123
+ if (snap.active.length === 0) {
1124
+ ring.style.opacity = "0";
1125
+ return;
1126
+ }
1127
+ ring.style.opacity = "1";
1128
+ // Pick the most informative active call for the ring (highest
1129
+ // tokens/sec, falling back to oldest). The popover lists every
1130
+ // call individually; this is just for the at-a-glance glow.
1131
+ const primary = snap.active.reduce((best, c) => (c.tokensPerSec > best.tokensPerSec ? c : best), snap.active[0]);
1132
+ if (primary.streaming && primary.tokensPerSec > 0) {
1133
+ // Active streaming → arc length scales with tokens/sec, capped
1134
+ // at the full ring at ~60 tok/s (typical chat ceiling).
1135
+ const fraction = Math.min(primary.tokensPerSec / 60, 1);
1136
+ arc.setAttribute("stroke-dashoffset", String(circ * (1 - fraction)));
1137
+ arc.setAttribute("stroke", "#3b82f6");
1138
+ }
1139
+ else if (snap.status === "error") {
1140
+ arc.setAttribute("stroke-dashoffset", "0");
1141
+ arc.setAttribute("stroke", "#ef4444");
1142
+ }
1143
+ else {
1144
+ // Non-streaming or pre-first-token: indeterminate sweep — just
1145
+ // show a quarter-arc that rotates via CSS keyframes (added
1146
+ // below as a one-shot class).
1147
+ arc.setAttribute("stroke-dashoffset", String(circ * 0.75));
1148
+ arc.setAttribute("stroke", "#a78bfa");
1149
+ }
1150
+ }
1151
+ function updateLED(led, snap) {
1152
+ switch (snap.status) {
1153
+ case "idle":
1154
+ led.style.background = "rgba(255,255,255,0.2)";
1155
+ led.style.boxShadow = "0 0 0 0 transparent";
1156
+ break;
1157
+ case "active":
1158
+ led.style.background = "#3b82f6";
1159
+ // CSS-driven pulse via box-shadow ring expanding outward —
1160
+ // single keyframe loop, cheaper than animating opacity.
1161
+ led.style.boxShadow = "0 0 0 0 rgba(59,130,246,0.7)";
1162
+ led.style.animation = "__runjobs_led_pulse__ 1.2s ease-out infinite";
1163
+ break;
1164
+ case "error":
1165
+ led.style.background = "#ef4444";
1166
+ led.style.boxShadow = "0 0 0 2px rgba(239,68,68,0.3)";
1167
+ led.style.animation = "";
1168
+ break;
1169
+ }
1170
+ }
1171
+ function createPopover(dashboardUrl, anchorCss, onSignOut) {
1172
+ const pop = document.createElement("div");
1173
+ pop.style.cssText = [
1174
+ "position:absolute",
1175
+ // Anchor varies by badge corner — see badgeCornerStyles. Splits
1176
+ // top/bottom + left/right so the popover always grows TOWARDS
1177
+ // the viewport interior.
1178
+ anchorCss,
1179
+ "min-width:280px;max-width:340px",
1180
+ "padding:10px 12px",
1181
+ "border-radius:10px",
1182
+ "background:rgba(15,15,20,0.92)",
1183
+ "backdrop-filter:blur(20px) saturate(1.3)",
1184
+ "-webkit-backdrop-filter:blur(20px) saturate(1.3)",
1185
+ "border:1px solid rgba(255,255,255,0.12)",
1186
+ "box-shadow:0 10px 30px rgba(0,0,0,0.4)",
1187
+ "color:#fff",
1188
+ "font:400 11px/1.4 -apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif",
1189
+ "cursor:default",
1190
+ "text-align:left",
1191
+ ].join(";");
1192
+ pop.setAttribute("data-runjobs-popover", "1");
1193
+ pop.addEventListener("click", (e) => e.stopPropagation());
1194
+ // The popover's content is rebuilt on every redraw to keep DOM
1195
+ // diffing simple. ~3 sections × ~6 children = ~20 nodes, no
1196
+ // measurable cost at the 6 Hz redraw rate.
1197
+ const inner = document.createElement("div");
1198
+ inner.setAttribute("data-runjobs-popover-content", "1");
1199
+ pop.appendChild(inner);
1200
+ const footer = document.createElement("div");
1201
+ footer.style.cssText = [
1202
+ "margin-top:10px;padding-top:8px",
1203
+ "border-top:1px solid rgba(255,255,255,0.08)",
1204
+ "display:flex;justify-content:space-between;align-items:center",
1205
+ "font-size:10px;color:rgba(255,255,255,0.6)",
1206
+ ].join(";");
1207
+ const dash = document.createElement("a");
1208
+ dash.textContent = "Open dashboard →";
1209
+ dash.href = dashboardUrl;
1210
+ dash.target = "_blank";
1211
+ dash.rel = "noopener noreferrer";
1212
+ dash.style.cssText = "color:#a78bfa;text-decoration:none";
1213
+ footer.appendChild(dash);
1214
+ // Sign-out lives opposite the dashboard link. Styled subtle on
1215
+ // purpose — it's a destructive-ish action (drops the token cache,
1216
+ // forces the next API call into the signIn redirect) but not
1217
+ // dangerous, and we don't want it to compete visually with the
1218
+ // dashboard CTA. Hover highlights it red as a "this is the action
1219
+ // that ends your session" cue.
1220
+ const signOutBtn = document.createElement("button");
1221
+ signOutBtn.type = "button";
1222
+ signOutBtn.textContent = "Sign out";
1223
+ signOutBtn.style.cssText = [
1224
+ "background:none;border:0;padding:0;margin:0",
1225
+ "font:inherit;cursor:pointer",
1226
+ "color:rgba(255,255,255,0.55)",
1227
+ "transition:color 120ms ease",
1228
+ ].join(";");
1229
+ signOutBtn.addEventListener("mouseenter", () => {
1230
+ signOutBtn.style.color = "#f87171";
1231
+ });
1232
+ signOutBtn.addEventListener("mouseleave", () => {
1233
+ signOutBtn.style.color = "rgba(255,255,255,0.55)";
1234
+ });
1235
+ signOutBtn.addEventListener("click", (e) => {
1236
+ e.preventDefault();
1237
+ e.stopPropagation();
1238
+ onSignOut();
1239
+ });
1240
+ footer.appendChild(signOutBtn);
1241
+ pop.appendChild(footer);
1242
+ return pop;
1243
+ }
1244
+ function updatePopover(pop, snap) {
1245
+ const inner = pop.querySelector('[data-runjobs-popover-content]');
1246
+ if (!inner)
1247
+ return;
1248
+ inner.innerHTML = "";
1249
+ const now = Date.now();
1250
+ inner.appendChild(renderActiveSection(snap, now));
1251
+ inner.appendChild(renderRecentSection(snap, now));
1252
+ inner.appendChild(renderSessionSection(snap.session, now));
1253
+ }
1254
+ function renderActiveSection(snap, now) {
1255
+ const sec = document.createElement("div");
1256
+ sec.style.marginBottom = "10px";
1257
+ sec.appendChild(sectionLabel("Active", snap.active.length > 0 ? `${snap.active.length} in flight` : "—"));
1258
+ if (snap.active.length === 0) {
1259
+ const empty = document.createElement("div");
1260
+ empty.style.cssText = "color:rgba(255,255,255,0.4);font-size:10px;padding:2px 0";
1261
+ empty.textContent = "No calls in flight.";
1262
+ sec.appendChild(empty);
1263
+ return sec;
1264
+ }
1265
+ for (const c of snap.active) {
1266
+ const row = document.createElement("div");
1267
+ row.style.cssText = "display:flex;justify-content:space-between;gap:8px;padding:3px 0";
1268
+ const left = document.createElement("span");
1269
+ left.style.cssText = "color:#fff;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:180px";
1270
+ left.textContent = c.model;
1271
+ const right = document.createElement("span");
1272
+ right.style.cssText = "color:rgba(255,255,255,0.7);font-variant-numeric:tabular-nums;font-size:10px";
1273
+ const elapsed = ((now - c.startedAt) / 1000).toFixed(1);
1274
+ const rate = c.tokensPerSec > 0 ? ` · ${Math.round(c.tokensPerSec)} tok/s` : "";
1275
+ right.textContent = `${elapsed}s${rate}`;
1276
+ row.appendChild(left);
1277
+ row.appendChild(right);
1278
+ sec.appendChild(row);
1279
+ }
1280
+ return sec;
1281
+ }
1282
+ function renderRecentSection(snap, now) {
1283
+ const sec = document.createElement("div");
1284
+ sec.style.marginBottom = "10px";
1285
+ sec.appendChild(sectionLabel("Recent", snap.recent.length > 0 ? `last ${Math.min(snap.recent.length, 5)}` : "—"));
1286
+ if (snap.recent.length === 0) {
1287
+ const empty = document.createElement("div");
1288
+ empty.style.cssText = "color:rgba(255,255,255,0.4);font-size:10px;padding:2px 0";
1289
+ empty.textContent = "Nothing yet this session.";
1290
+ sec.appendChild(empty);
1291
+ return sec;
1292
+ }
1293
+ for (const c of snap.recent.slice(0, 5)) {
1294
+ const row = document.createElement("div");
1295
+ row.style.cssText = "display:flex;justify-content:space-between;gap:8px;padding:3px 0";
1296
+ const left = document.createElement("span");
1297
+ left.style.cssText = "color:#fff;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:180px";
1298
+ // Prefix bullet by status — cheap visual scan.
1299
+ const bullet = document.createElement("span");
1300
+ bullet.textContent = c.ok ? "✓ " : "✗ ";
1301
+ bullet.style.color = c.ok ? "#10b981" : "#ef4444";
1302
+ left.appendChild(bullet);
1303
+ left.appendChild(document.createTextNode(c.model));
1304
+ const right = document.createElement("span");
1305
+ right.style.cssText = "color:rgba(255,255,255,0.7);font-variant-numeric:tabular-nums;font-size:10px";
1306
+ const latency = `${(c.latencyMs / 1000).toFixed(1)}s`;
1307
+ const cost = c.costUSD !== undefined ? ` · $${c.costUSD.toFixed(4)}` : "";
1308
+ const ago = ` · ${relativeTime(now - c.endedAt)}`;
1309
+ right.textContent = `${latency}${cost}${ago}`;
1310
+ row.appendChild(left);
1311
+ row.appendChild(right);
1312
+ sec.appendChild(row);
1313
+ }
1314
+ return sec;
1315
+ }
1316
+ function renderSessionSection(s, now) {
1317
+ const sec = document.createElement("div");
1318
+ sec.appendChild(sectionLabel("Session", relativeTime(now - s.startedAt)));
1319
+ const row = document.createElement("div");
1320
+ row.style.cssText = "display:flex;justify-content:space-between;gap:12px;color:rgba(255,255,255,0.85);font-size:10px";
1321
+ const calls = document.createElement("span");
1322
+ calls.textContent = `${s.totalCalls} call${s.totalCalls === 1 ? "" : "s"}`;
1323
+ const cost = document.createElement("span");
1324
+ cost.textContent = `$${s.totalCostUSD.toFixed(4)}`;
1325
+ const errors = document.createElement("span");
1326
+ errors.textContent = s.errorCount > 0 ? `${s.errorCount} error${s.errorCount === 1 ? "" : "s"}` : "0 errors";
1327
+ if (s.errorCount > 0)
1328
+ errors.style.color = "#ef4444";
1329
+ row.appendChild(calls);
1330
+ row.appendChild(cost);
1331
+ row.appendChild(errors);
1332
+ sec.appendChild(row);
1333
+ return sec;
1334
+ }
1335
+ function sectionLabel(title, hint) {
1336
+ const lbl = document.createElement("div");
1337
+ lbl.style.cssText = [
1338
+ "display:flex;justify-content:space-between;align-items:baseline",
1339
+ "margin-bottom:4px",
1340
+ "color:rgba(255,255,255,0.55)",
1341
+ "font-size:10px;text-transform:uppercase;letter-spacing:0.05em",
1342
+ ].join(";");
1343
+ const t = document.createElement("span");
1344
+ t.textContent = title;
1345
+ const h = document.createElement("span");
1346
+ h.textContent = hint;
1347
+ h.style.cssText = "font-size:9px;letter-spacing:0";
1348
+ lbl.appendChild(t);
1349
+ lbl.appendChild(h);
1350
+ return lbl;
1351
+ }
1352
+ function relativeTime(ms) {
1353
+ if (ms < 1500)
1354
+ return "just now";
1355
+ if (ms < 60_000)
1356
+ return `${Math.round(ms / 1000)}s ago`;
1357
+ if (ms < 3_600_000)
1358
+ return `${Math.round(ms / 60_000)}m ago`;
1359
+ return `${Math.round(ms / 3_600_000)}h ago`;
1360
+ }
1361
+ //# sourceMappingURL=browser-auth.js.map