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