@hosty-sdk/app 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -1
- package/dist/embedder.d.ts +9 -0
- package/dist/embedder.js +14 -0
- package/dist/react.d.ts +48 -1
- package/dist/react.js +123 -11
- package/dist/theme.d.ts +104 -0
- package/dist/theme.js +159 -0
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -13,7 +13,8 @@ npm install @hosty-sdk/app
|
|
|
13
13
|
| `@hosty-sdk/app` | anywhere | status taxonomy, recovery decision, `hosty:auth-required` and `hosty:request-delegated-token` schemas, URL/env helpers |
|
|
14
14
|
| `@hosty-sdk/app/server` | server only | Core revalidation with caching, cookie helpers, the app-code route factory, the app secrets client |
|
|
15
15
|
| `@hosty-sdk/app/react` | client | `<AppIdentityBridge />` — probe, silent recovery, fallback cards |
|
|
16
|
-
| `@hosty-sdk/app/embedder` | client | verified responders — launch-code recovery and delegated tokens — for anything that embeds Hosty apps |
|
|
16
|
+
| `@hosty-sdk/app/embedder` | client | verified responders — launch-code recovery and delegated tokens — plus the theme sender half, for anything that embeds Hosty apps |
|
|
17
|
+
| `@hosty-sdk/app/theme` | anywhere | the shell→app theme protocol: constants, `resolveTheme`, `applyTheme`, `parseShellThemeMessage`, `themeBootstrapScript` / `createThemeBootstrapScript` |
|
|
17
18
|
|
|
18
19
|
Minimal Next.js wiring:
|
|
19
20
|
|
|
@@ -32,6 +33,38 @@ export const POST = createAppCodeRouteHandler({
|
|
|
32
33
|
});
|
|
33
34
|
```
|
|
34
35
|
|
|
36
|
+
Following the shell's theme — the launch parameters decide the theme a document loads with, the
|
|
37
|
+
`hosty:shell-theme` post covers changes while the frame is up, and the bootstrap keeps the first
|
|
38
|
+
paint right:
|
|
39
|
+
|
|
40
|
+
```tsx
|
|
41
|
+
// app/layout.tsx
|
|
42
|
+
import { launchModeBootstrapScript } from "@hosty-sdk/app";
|
|
43
|
+
import { HostLaunchBridge, HostThemeBridge } from "@hosty-sdk/app/react";
|
|
44
|
+
import { themeBootstrapScript } from "@hosty-sdk/app/theme";
|
|
45
|
+
|
|
46
|
+
<html suppressHydrationWarning>
|
|
47
|
+
<head>
|
|
48
|
+
<script dangerouslySetInnerHTML={{ __html: launchModeBootstrapScript }} />
|
|
49
|
+
<script dangerouslySetInnerHTML={{ __html: themeBootstrapScript }} />
|
|
50
|
+
</head>
|
|
51
|
+
<body>
|
|
52
|
+
<HostThemeBridge /> {/* followSystem={false} onTheme={setTheme} when the app runs its own provider (next-themes) */}
|
|
53
|
+
<HostLaunchBridge />
|
|
54
|
+
</body>
|
|
55
|
+
</html>
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
An embedder declares the theme on every frame URL and posts changes to the frame's own origin:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
import { appendThemeLaunchParams } from "@hosty-sdk/app/embedder";
|
|
62
|
+
import { createShellThemeMessage } from "@hosty-sdk/app/theme";
|
|
63
|
+
|
|
64
|
+
frame.src = appendThemeLaunchParams(launchUrl, theme, preference);
|
|
65
|
+
frame.contentWindow.postMessage(createShellThemeMessage(theme, preference), frameOrigin);
|
|
66
|
+
```
|
|
67
|
+
|
|
35
68
|
App secrets — the Core-managed keychain for runtime-acquired credentials (OAuth tokens and the
|
|
36
69
|
like), kept by Core outside the app's backed-up data directory:
|
|
37
70
|
|
package/dist/embedder.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type HostyResolvedTheme, type HostyThemePreference } from "./theme.js";
|
|
1
2
|
/** The three fields of a `message` event these parsers read. */
|
|
2
3
|
export interface EmbedderMessage {
|
|
3
4
|
data: unknown;
|
|
@@ -73,3 +74,11 @@ export declare function createReissueRateLimiter(minIntervalMs: number, now?: ()
|
|
|
73
74
|
/** True when a reissue for this app is allowed now; records the attempt when allowed. */
|
|
74
75
|
tryAcquire(appId: string): boolean;
|
|
75
76
|
};
|
|
77
|
+
/**
|
|
78
|
+
* Declares the theme an embedder is rendering in on the URL it is about to load into a frame — the
|
|
79
|
+
* workspace page, a settings tab, a panel. This is the channel that decides the theme the frame
|
|
80
|
+
* paints with; the `hosty:shell-theme` post (`createShellThemeMessage`) is for changes made while
|
|
81
|
+
* the frame is already up, because a post at the frame's `load` routinely lands before the app has
|
|
82
|
+
* a listener. Re-derived on every launch and every page switch, so it is never stale.
|
|
83
|
+
*/
|
|
84
|
+
export declare function appendThemeLaunchParams(redirectUri: string, theme: HostyResolvedTheme, preference: HostyThemePreference): string;
|
package/dist/embedder.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
// to that frame's origin. Both requests are unauthenticated by design; what makes them safe is
|
|
14
14
|
// that the embedder answers from facts about its own DOM rather than from what the frame claims.
|
|
15
15
|
import { ASK_ASSISTANT_MAX_CHARS, ASK_ASSISTANT_TYPE, ATTENTION_TYPE, AUTH_REQUIRED_INTENT_TYPE, DELEGATED_TOKEN_REQUEST_TYPE, } from "./index.js";
|
|
16
|
+
import { THEME_PARAM, THEME_PREFERENCE_PARAM } from "./theme.js";
|
|
16
17
|
/**
|
|
17
18
|
* Verifies that a `message` event is a genuine auth-required intent from the active app
|
|
18
19
|
* frame. Every check is about facts only the embedder can know:
|
|
@@ -137,3 +138,16 @@ export function createReissueRateLimiter(minIntervalMs, now = Date.now) {
|
|
|
137
138
|
},
|
|
138
139
|
};
|
|
139
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* Declares the theme an embedder is rendering in on the URL it is about to load into a frame — the
|
|
143
|
+
* workspace page, a settings tab, a panel. This is the channel that decides the theme the frame
|
|
144
|
+
* paints with; the `hosty:shell-theme` post (`createShellThemeMessage`) is for changes made while
|
|
145
|
+
* the frame is already up, because a post at the frame's `load` routinely lands before the app has
|
|
146
|
+
* a listener. Re-derived on every launch and every page switch, so it is never stale.
|
|
147
|
+
*/
|
|
148
|
+
export function appendThemeLaunchParams(redirectUri, theme, preference) {
|
|
149
|
+
const url = new URL(redirectUri);
|
|
150
|
+
url.searchParams.set(THEME_PARAM, theme);
|
|
151
|
+
url.searchParams.set(THEME_PREFERENCE_PARAM, preference);
|
|
152
|
+
return url.toString();
|
|
153
|
+
}
|
package/dist/react.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
1
2
|
import { type AppLaunchMode, type AppSessionStatus } from "./index.js";
|
|
3
|
+
import { type HostyResolvedTheme, type HostyThemePreference } from "./theme.js";
|
|
2
4
|
/**
|
|
3
5
|
* Tolerant probe reader: accepts both identity-route shapes in the fleet — a top-level
|
|
4
6
|
* `status` (marketplace/telemetry) and a nested `appSession.status` (demo-app,
|
|
@@ -24,11 +26,28 @@ export declare function HostLaunchBridge(): null;
|
|
|
24
26
|
* express the decision.
|
|
25
27
|
*/
|
|
26
28
|
export declare function useLaunchMode(): AppLaunchMode | null;
|
|
29
|
+
export type AppIdentityBridgeState = {
|
|
30
|
+
kind: "recovering";
|
|
31
|
+
} | {
|
|
32
|
+
kind: "active";
|
|
33
|
+
} | {
|
|
34
|
+
kind: "signin";
|
|
35
|
+
openUrl: string | null;
|
|
36
|
+
embedded: boolean;
|
|
37
|
+
} | {
|
|
38
|
+
kind: "denied";
|
|
39
|
+
} | {
|
|
40
|
+
kind: "unavailable";
|
|
41
|
+
} | {
|
|
42
|
+
kind: "misconfigured";
|
|
43
|
+
};
|
|
27
44
|
export interface AppIdentityBridgeProps {
|
|
28
45
|
/** Probe endpoint returning a session status + recovery params. */
|
|
29
46
|
probePath?: string;
|
|
30
47
|
/** Code-exchange endpoint. */
|
|
31
48
|
appCodePath?: string;
|
|
49
|
+
/** Optional app-owned rendering, including loading and active content. Recovery remains SDK-owned. */
|
|
50
|
+
renderState?: (state: AppIdentityBridgeState) => ReactNode;
|
|
32
51
|
}
|
|
33
52
|
/**
|
|
34
53
|
* Mount once at the top of the root layout body. On first load it consumes a `?code` from
|
|
@@ -37,4 +56,32 @@ export interface AppIdentityBridgeProps {
|
|
|
37
56
|
* redirect through Core `/open` when standalone, and cards only in fallback/terminal states
|
|
38
57
|
* — never a login UI while recovery is still running.
|
|
39
58
|
*/
|
|
40
|
-
export declare function AppIdentityBridge({ probePath, appCodePath, }?: AppIdentityBridgeProps): import("react").JSX.Element | null;
|
|
59
|
+
export declare function AppIdentityBridge({ probePath, appCodePath, renderState, }?: AppIdentityBridgeProps): string | number | bigint | boolean | Iterable<ReactNode> | Promise<string | number | bigint | boolean | import("react").ReactPortal | import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | Iterable<ReactNode> | null | undefined> | import("react").JSX.Element | null | undefined;
|
|
60
|
+
export interface HostThemeBridgeProps {
|
|
61
|
+
/**
|
|
62
|
+
* Whether the operating system decides the theme while no shell has spoken — `true` unless the
|
|
63
|
+
* app runs its own theme provider for standalone use (next-themes), which owns that case and
|
|
64
|
+
* whose stored choice the host must not overwrite. Pass the same value to
|
|
65
|
+
* `createThemeBootstrapScript`.
|
|
66
|
+
*/
|
|
67
|
+
followSystem?: boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Runs after every application, with the theme just applied. An app that also runs its own
|
|
70
|
+
* theme provider hands that provider the shell's decision here; an app that only follows the
|
|
71
|
+
* host needs nothing.
|
|
72
|
+
*/
|
|
73
|
+
onTheme?: (theme: HostyResolvedTheme, preference: HostyThemePreference) => void;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Mount once in the root layout. Applies the theme from the launch parameters, the value stored
|
|
77
|
+
* for this tab, or the operating system — in that order — persists a declared one, cleans the
|
|
78
|
+
* parameters out of the URL so a copied link cannot carry a shell's presentation into a plain
|
|
79
|
+
* browser tab, and then follows `hosty:shell-theme` posts from the parent for the life of the
|
|
80
|
+
* document.
|
|
81
|
+
*
|
|
82
|
+
* Pair it with `themeBootstrapScript` in the head: this effect runs after first paint, so without
|
|
83
|
+
* the script a document paints in the default theme for a frame before it is corrected. The system
|
|
84
|
+
* preference is followed live only while no shell has spoken — a declared theme is a choice, and a
|
|
85
|
+
* choice is not overruled by the OS.
|
|
86
|
+
*/
|
|
87
|
+
export declare function HostThemeBridge({ followSystem, onTheme }?: HostThemeBridgeProps): null;
|
package/dist/react.js
CHANGED
|
@@ -3,8 +3,9 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
3
3
|
// React slice: the client identity bridge — probe, silent recovery, and the fallback
|
|
4
4
|
// cards. Prop-less by design: the app id and browser-reachable Core origin arrive in the
|
|
5
5
|
// probe response's `recovery` field (request-time values, never build-time props).
|
|
6
|
-
import { useEffect, useState } from "react";
|
|
6
|
+
import { useEffect, useRef, useState } from "react";
|
|
7
7
|
import { buildCoreOpenUrl, decideRecoveryAction, detectLaunchMode, normalizeLaunchMode, readRecoveryParams, resolveLaunchMode, LAUNCH_MODE_ATTRIBUTE, LAUNCH_MODE_PARAM, LAUNCH_MODE_STORAGE_KEY, } from "./index.js";
|
|
8
|
+
import { applyTheme, parseShellThemeMessage, resolveTheme, THEME_PARAM, THEME_PREFERENCE_PARAM, THEME_PREFERENCE_STORAGE_KEY, THEME_STORAGE_KEY, } from "./theme.js";
|
|
8
9
|
// Once-per-tab guard so a standalone app that returns from Core still unauthorized does
|
|
9
10
|
// not bounce through /open forever. Cleared on a successful code exchange.
|
|
10
11
|
const RECOVERY_GUARD_KEY = "hosty.auth.recovery-attempted";
|
|
@@ -147,8 +148,11 @@ export function useLaunchMode() {
|
|
|
147
148
|
* redirect through Core `/open` when standalone, and cards only in fallback/terminal states
|
|
148
149
|
* — never a login UI while recovery is still running.
|
|
149
150
|
*/
|
|
150
|
-
export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePath = "/api/auth/app-code", } = {}) {
|
|
151
|
-
const [ui, setUi] = useState({ kind: "
|
|
151
|
+
export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePath = "/api/auth/app-code", renderState, } = {}) {
|
|
152
|
+
const [ui, setUi] = useState({ kind: "recovering" });
|
|
153
|
+
// A launch code is single-use. Strict Mode replays the effect after the URL is cleaned;
|
|
154
|
+
// keep its exchange alive and let the replacement effect await the same response.
|
|
155
|
+
const exchangeRef = useRef(null);
|
|
152
156
|
useEffect(() => {
|
|
153
157
|
let cancelled = false;
|
|
154
158
|
const controller = new AbortController();
|
|
@@ -197,7 +201,7 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
|
|
|
197
201
|
});
|
|
198
202
|
switch (action.kind) {
|
|
199
203
|
case "none":
|
|
200
|
-
setUi({ kind: "
|
|
204
|
+
setUi({ kind: "active" });
|
|
201
205
|
return;
|
|
202
206
|
case "post-auth-required": {
|
|
203
207
|
// The payload carries no secret, so targetOrigin "*" is safe — the Shell verifies
|
|
@@ -234,12 +238,18 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
|
|
|
234
238
|
if (code) {
|
|
235
239
|
url.searchParams.delete("code");
|
|
236
240
|
window.history.replaceState(null, "", `${url.pathname}${url.search}${url.hash}`);
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
241
|
+
exchangeRef.current = {
|
|
242
|
+
path: appCodePath,
|
|
243
|
+
response: fetch(appCodePath, {
|
|
244
|
+
method: "POST",
|
|
245
|
+
headers: { "Content-Type": "application/json" },
|
|
246
|
+
body: JSON.stringify({ code }),
|
|
247
|
+
}),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
const exchange = exchangeRef.current;
|
|
251
|
+
if (exchange && exchange.path === appCodePath) {
|
|
252
|
+
void exchange.response
|
|
243
253
|
.then((response) => {
|
|
244
254
|
if (cancelled) {
|
|
245
255
|
return;
|
|
@@ -249,11 +259,13 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
|
|
|
249
259
|
window.location.reload();
|
|
250
260
|
}
|
|
251
261
|
else {
|
|
262
|
+
exchangeRef.current = null;
|
|
252
263
|
void probeAndRecover();
|
|
253
264
|
}
|
|
254
265
|
})
|
|
255
266
|
.catch(() => {
|
|
256
267
|
if (!cancelled) {
|
|
268
|
+
exchangeRef.current = null;
|
|
257
269
|
void probeAndRecover();
|
|
258
270
|
}
|
|
259
271
|
});
|
|
@@ -266,7 +278,9 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
|
|
|
266
278
|
controller.abort();
|
|
267
279
|
};
|
|
268
280
|
}, [probePath, appCodePath]);
|
|
269
|
-
if (
|
|
281
|
+
if (renderState)
|
|
282
|
+
return renderState(ui);
|
|
283
|
+
if (ui.kind === "recovering" || ui.kind === "active") {
|
|
270
284
|
return null;
|
|
271
285
|
}
|
|
272
286
|
return (_jsx("div", { role: "status", style: barStyle, children: ui.kind === "signin" ? (_jsxs(_Fragment, { children: [_jsx("span", { children: "Your Hosty session ended." }), ui.openUrl ? (_jsx("a", { href: ui.openUrl, ...(ui.embedded ? { target: "_blank", rel: "noopener noreferrer" } : {}), style: actionStyle, children: "Sign in via Hosty" })) : (_jsx("span", { children: "Open this app from the machine running Hosty, or configure a public origin." }))] })) : ui.kind === "denied" ? (_jsx("span", { children: "You are signed in to Hosty but are not allowed to use this app." })) : ui.kind === "misconfigured" ? (_jsx("span", { children: "This app is not configured correctly on the host. Contact the administrator." })) : (_jsxs(_Fragment, { children: [_jsx("span", { children: "Can\u2019t reach Hosty right now." }), _jsx("button", { type: "button", onClick: () => window.location.reload(), style: actionStyle, children: "Retry" })] })) }));
|
|
@@ -299,3 +313,101 @@ const actionStyle = {
|
|
|
299
313
|
fontWeight: 650,
|
|
300
314
|
textDecoration: "none",
|
|
301
315
|
};
|
|
316
|
+
function readStoredTheme(key) {
|
|
317
|
+
try {
|
|
318
|
+
return window.sessionStorage.getItem(key);
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
function writeStoredTheme(theme, preference) {
|
|
325
|
+
try {
|
|
326
|
+
window.sessionStorage.setItem(THEME_STORAGE_KEY, theme);
|
|
327
|
+
window.sessionStorage.setItem(THEME_PREFERENCE_STORAGE_KEY, preference);
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
// sessionStorage may be blocked. The shell re-declares the theme on every launch and on every
|
|
331
|
+
// page switch it drives, so only app-internal navigation loses it.
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
function clearStoredTheme() {
|
|
335
|
+
try {
|
|
336
|
+
window.sessionStorage.removeItem(THEME_STORAGE_KEY);
|
|
337
|
+
window.sessionStorage.removeItem(THEME_PREFERENCE_STORAGE_KEY);
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
// Same as above — nothing to keep.
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Mount once in the root layout. Applies the theme from the launch parameters, the value stored
|
|
345
|
+
* for this tab, or the operating system — in that order — persists a declared one, cleans the
|
|
346
|
+
* parameters out of the URL so a copied link cannot carry a shell's presentation into a plain
|
|
347
|
+
* browser tab, and then follows `hosty:shell-theme` posts from the parent for the life of the
|
|
348
|
+
* document.
|
|
349
|
+
*
|
|
350
|
+
* Pair it with `themeBootstrapScript` in the head: this effect runs after first paint, so without
|
|
351
|
+
* the script a document paints in the default theme for a frame before it is corrected. The system
|
|
352
|
+
* preference is followed live only while no shell has spoken — a declared theme is a choice, and a
|
|
353
|
+
* choice is not overruled by the OS.
|
|
354
|
+
*/
|
|
355
|
+
export function HostThemeBridge({ followSystem = true, onTheme } = {}) {
|
|
356
|
+
const onThemeRef = useRef(onTheme);
|
|
357
|
+
useEffect(() => {
|
|
358
|
+
onThemeRef.current = onTheme;
|
|
359
|
+
}, [onTheme]);
|
|
360
|
+
useEffect(() => {
|
|
361
|
+
const root = document.documentElement;
|
|
362
|
+
const system = window.matchMedia("(prefers-color-scheme: dark)");
|
|
363
|
+
const apply = (theme, preference, declared) => {
|
|
364
|
+
applyTheme(root, theme, preference);
|
|
365
|
+
if (declared) {
|
|
366
|
+
writeStoredTheme(theme, preference);
|
|
367
|
+
}
|
|
368
|
+
else {
|
|
369
|
+
clearStoredTheme();
|
|
370
|
+
}
|
|
371
|
+
onThemeRef.current?.(theme, preference);
|
|
372
|
+
};
|
|
373
|
+
const url = new URL(window.location.href);
|
|
374
|
+
const resolution = resolveTheme({
|
|
375
|
+
param: url.searchParams.get(THEME_PARAM),
|
|
376
|
+
preferenceParam: url.searchParams.get(THEME_PREFERENCE_PARAM),
|
|
377
|
+
stored: readStoredTheme(THEME_STORAGE_KEY),
|
|
378
|
+
storedPreference: readStoredTheme(THEME_PREFERENCE_STORAGE_KEY),
|
|
379
|
+
systemPrefersDark: system.matches,
|
|
380
|
+
});
|
|
381
|
+
let declared = resolution.source !== "system";
|
|
382
|
+
if (declared || followSystem) {
|
|
383
|
+
apply(resolution.theme, resolution.preference, declared);
|
|
384
|
+
}
|
|
385
|
+
// Cleaned whenever present, including an unrecognized value: a parameter this app ignored
|
|
386
|
+
// must not survive into a link someone copies.
|
|
387
|
+
if (url.searchParams.has(THEME_PARAM) || url.searchParams.has(THEME_PREFERENCE_PARAM)) {
|
|
388
|
+
url.searchParams.delete(THEME_PARAM);
|
|
389
|
+
url.searchParams.delete(THEME_PREFERENCE_PARAM);
|
|
390
|
+
window.history.replaceState(null, "", `${url.pathname}${url.search}${url.hash}`);
|
|
391
|
+
}
|
|
392
|
+
const handleMessage = (event) => {
|
|
393
|
+
const message = parseShellThemeMessage(event, window);
|
|
394
|
+
if (!message) {
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
declared = true;
|
|
398
|
+
apply(message.theme, message.preference, true);
|
|
399
|
+
};
|
|
400
|
+
const handleSystemChange = () => {
|
|
401
|
+
if (!declared && followSystem) {
|
|
402
|
+
apply(system.matches ? "dark" : "light", "system", false);
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
window.addEventListener("message", handleMessage);
|
|
406
|
+
system.addEventListener("change", handleSystemChange);
|
|
407
|
+
return () => {
|
|
408
|
+
window.removeEventListener("message", handleMessage);
|
|
409
|
+
system.removeEventListener("change", handleSystemChange);
|
|
410
|
+
};
|
|
411
|
+
}, []);
|
|
412
|
+
return null;
|
|
413
|
+
}
|
package/dist/theme.d.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
export type HostyResolvedTheme = "light" | "dark";
|
|
2
|
+
/** What the operator chose in the shell. `system` means the shell follows the OS, and so does the app. */
|
|
3
|
+
export type HostyThemePreference = "light" | "dark" | "system";
|
|
4
|
+
/** The message a shell posts its frame on every theme change, and again when the frame loads. */
|
|
5
|
+
export declare const SHELL_THEME_TYPE = "hosty:shell-theme";
|
|
6
|
+
/** The launch parameters a shell appends to every URL it loads into a frame. */
|
|
7
|
+
export declare const THEME_PARAM = "hosty_theme";
|
|
8
|
+
export declare const THEME_PREFERENCE_PARAM = "hosty_theme_preference";
|
|
9
|
+
/**
|
|
10
|
+
* Where a declared theme is kept for the life of the tab. App-internal navigation carries no
|
|
11
|
+
* parameter and must not lose the theme; a tab the app opens itself inherits the storage, which
|
|
12
|
+
* is the one accepted leak — no first-party app opens one.
|
|
13
|
+
*/
|
|
14
|
+
export declare const THEME_STORAGE_KEY = "hosty.theme.resolved";
|
|
15
|
+
export declare const THEME_PREFERENCE_STORAGE_KEY = "hosty.theme.preference";
|
|
16
|
+
/** The root attributes written beside the class, for CSS or scripts that key on them. */
|
|
17
|
+
export declare const THEME_ATTRIBUTE = "data-hosty-theme";
|
|
18
|
+
export declare const THEME_PREFERENCE_ATTRIBUTE = "data-hosty-theme-preference";
|
|
19
|
+
/** The root class every Hosty app's `dark:` variant keys on. */
|
|
20
|
+
export declare const DARK_THEME_CLASS = "dark";
|
|
21
|
+
export interface ShellThemeMessage {
|
|
22
|
+
type: typeof SHELL_THEME_TYPE;
|
|
23
|
+
theme: HostyResolvedTheme;
|
|
24
|
+
preference: HostyThemePreference;
|
|
25
|
+
}
|
|
26
|
+
export declare function createShellThemeMessage(theme: HostyResolvedTheme, preference: HostyThemePreference): ShellThemeMessage;
|
|
27
|
+
export declare function normalizeResolvedTheme(value: unknown): HostyResolvedTheme | null;
|
|
28
|
+
export declare function normalizeThemePreference(value: unknown): HostyThemePreference | null;
|
|
29
|
+
export type ThemeSource = "param" | "stored" | "system";
|
|
30
|
+
export interface ThemeResolution {
|
|
31
|
+
theme: HostyResolvedTheme;
|
|
32
|
+
preference: HostyThemePreference;
|
|
33
|
+
/** Which channel decided. `system` is the only one no shell spoke through. */
|
|
34
|
+
source: ThemeSource;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Resolves the theme a document paints with, in explicit precedence: the launch parameter a
|
|
38
|
+
* shell just sent, then the value persisted for this tab, then the operating system.
|
|
39
|
+
*
|
|
40
|
+
* The parameter outranks storage because a shell rewrites it on every launch and every page
|
|
41
|
+
* switch it drives, so it is the one channel that cannot go stale. An unrecognized value on
|
|
42
|
+
* either channel is ignored rather than honoured — a newer shell must degrade an older app to
|
|
43
|
+
* what it did before, never to a theme it cannot render. A preference that arrives without a
|
|
44
|
+
* recognizable value defaults to the theme itself: a shell that resolved `dark` and said nothing
|
|
45
|
+
* more chose dark.
|
|
46
|
+
*/
|
|
47
|
+
export declare function resolveTheme(input: {
|
|
48
|
+
param?: string | null;
|
|
49
|
+
preferenceParam?: string | null;
|
|
50
|
+
stored?: string | null;
|
|
51
|
+
storedPreference?: string | null;
|
|
52
|
+
systemPrefersDark: boolean;
|
|
53
|
+
}): ThemeResolution;
|
|
54
|
+
/** The slice of the document root the bridge writes to. */
|
|
55
|
+
export interface ThemeRoot {
|
|
56
|
+
classList: {
|
|
57
|
+
toggle(token: string, force?: boolean): unknown;
|
|
58
|
+
};
|
|
59
|
+
style: {
|
|
60
|
+
colorScheme: string;
|
|
61
|
+
};
|
|
62
|
+
setAttribute(name: string, value: string): void;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Writes one theme to the root: the `dark` class Tailwind keys on, `color-scheme` so native
|
|
66
|
+
* controls and scrollbars follow, and the two attributes for anything that wants the words.
|
|
67
|
+
*/
|
|
68
|
+
export declare function applyTheme(root: ThemeRoot, theme: HostyResolvedTheme, preference: HostyThemePreference): void;
|
|
69
|
+
/**
|
|
70
|
+
* Reads a `hosty:shell-theme` message, accepting it from the parent frame alone.
|
|
71
|
+
*
|
|
72
|
+
* `event.source === window.parent` is set by the browser and is the trustworthy gate. The parent's
|
|
73
|
+
* origin is deliberately not checked: an app learns which origin embeds it from messages like this
|
|
74
|
+
* one, so it cannot be used to pre-filter them, and `document.referrer` — what some copies used —
|
|
75
|
+
* goes stale the moment the app reloads itself. Theme is not sensitive; the source check is
|
|
76
|
+
* sufficient. Not embedded at all means no parent to hear from, so nothing is accepted.
|
|
77
|
+
*/
|
|
78
|
+
export declare function parseShellThemeMessage(event: {
|
|
79
|
+
data: unknown;
|
|
80
|
+
source: unknown;
|
|
81
|
+
}, win: Pick<Window, "parent" | "self">): {
|
|
82
|
+
theme: HostyResolvedTheme;
|
|
83
|
+
preference: HostyThemePreference;
|
|
84
|
+
} | null;
|
|
85
|
+
/**
|
|
86
|
+
* The pre-hydration bootstrap: applies the theme from the same precedence `resolveTheme`
|
|
87
|
+
* implements, so a document never paints in the wrong theme before React can correct it. Mount
|
|
88
|
+
* it as an inline `<script>` in the document head.
|
|
89
|
+
*
|
|
90
|
+
* `followSystem` is the same switch `HostThemeBridge` takes. An app that runs its own theme
|
|
91
|
+
* provider for standalone use (next-themes) passes `false` here and there: the host's bootstrap
|
|
92
|
+
* then applies only a theme a shell declared and otherwise leaves the root to the provider, whose
|
|
93
|
+
* own stored choice must not be overwritten by the operating system's.
|
|
94
|
+
*
|
|
95
|
+
* A parameter is persisted here as well as in the bridge, on the launch mode's precedent, so a
|
|
96
|
+
* reload between this script and hydration cannot lose it. It does not clean the parameter out of
|
|
97
|
+
* the URL — that is `HostThemeBridge`'s job: a `history.replaceState` before hydration is a
|
|
98
|
+
* router's business, not a paint-blocking script's.
|
|
99
|
+
*/
|
|
100
|
+
export declare function createThemeBootstrapScript(options?: {
|
|
101
|
+
followSystem?: boolean;
|
|
102
|
+
}): string;
|
|
103
|
+
/** The bootstrap for an app that follows the host and otherwise the operating system — the common case. */
|
|
104
|
+
export declare const themeBootstrapScript: string;
|
package/dist/theme.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Theme slice: the protocol by which a shell hands an embedded app the theme it is rendering in,
|
|
2
|
+
// and the app half that applies it. Pure — no React, and no DOM beyond the narrow shapes the
|
|
3
|
+
// functions take — so one precedence runs in the pre-hydration bootstrap, in the React bridge,
|
|
4
|
+
// and in the tests.
|
|
5
|
+
//
|
|
6
|
+
// The design answers one failure. A shell posts `hosty:shell-theme` when its iframe fires `load`,
|
|
7
|
+
// and that post routinely lands before the app's effect has attached a listener, so it is lost.
|
|
8
|
+
// The copies this replaced then read whatever an earlier post had stored for the tab: one session
|
|
9
|
+
// in dark pinned an app dark for the life of the tab, whatever the shell was set to (telemetry-ui,
|
|
10
|
+
// 2026-09-07). So the launch parameters — which travel with the document and cannot be missed —
|
|
11
|
+
// decide the theme a document loads with; the post is for changes made while the frame is up.
|
|
12
|
+
/** The message a shell posts its frame on every theme change, and again when the frame loads. */
|
|
13
|
+
export const SHELL_THEME_TYPE = "hosty:shell-theme";
|
|
14
|
+
/** The launch parameters a shell appends to every URL it loads into a frame. */
|
|
15
|
+
export const THEME_PARAM = "hosty_theme";
|
|
16
|
+
export const THEME_PREFERENCE_PARAM = "hosty_theme_preference";
|
|
17
|
+
/**
|
|
18
|
+
* Where a declared theme is kept for the life of the tab. App-internal navigation carries no
|
|
19
|
+
* parameter and must not lose the theme; a tab the app opens itself inherits the storage, which
|
|
20
|
+
* is the one accepted leak — no first-party app opens one.
|
|
21
|
+
*/
|
|
22
|
+
export const THEME_STORAGE_KEY = "hosty.theme.resolved";
|
|
23
|
+
export const THEME_PREFERENCE_STORAGE_KEY = "hosty.theme.preference";
|
|
24
|
+
/** The root attributes written beside the class, for CSS or scripts that key on them. */
|
|
25
|
+
export const THEME_ATTRIBUTE = "data-hosty-theme";
|
|
26
|
+
export const THEME_PREFERENCE_ATTRIBUTE = "data-hosty-theme-preference";
|
|
27
|
+
/** The root class every Hosty app's `dark:` variant keys on. */
|
|
28
|
+
export const DARK_THEME_CLASS = "dark";
|
|
29
|
+
export function createShellThemeMessage(theme, preference) {
|
|
30
|
+
return { type: SHELL_THEME_TYPE, theme, preference };
|
|
31
|
+
}
|
|
32
|
+
export function normalizeResolvedTheme(value) {
|
|
33
|
+
return value === "light" || value === "dark" ? value : null;
|
|
34
|
+
}
|
|
35
|
+
export function normalizeThemePreference(value) {
|
|
36
|
+
return value === "light" || value === "dark" || value === "system" ? value : null;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Resolves the theme a document paints with, in explicit precedence: the launch parameter a
|
|
40
|
+
* shell just sent, then the value persisted for this tab, then the operating system.
|
|
41
|
+
*
|
|
42
|
+
* The parameter outranks storage because a shell rewrites it on every launch and every page
|
|
43
|
+
* switch it drives, so it is the one channel that cannot go stale. An unrecognized value on
|
|
44
|
+
* either channel is ignored rather than honoured — a newer shell must degrade an older app to
|
|
45
|
+
* what it did before, never to a theme it cannot render. A preference that arrives without a
|
|
46
|
+
* recognizable value defaults to the theme itself: a shell that resolved `dark` and said nothing
|
|
47
|
+
* more chose dark.
|
|
48
|
+
*/
|
|
49
|
+
export function resolveTheme(input) {
|
|
50
|
+
const declared = normalizeResolvedTheme(input.param);
|
|
51
|
+
if (declared) {
|
|
52
|
+
return { theme: declared, preference: normalizeThemePreference(input.preferenceParam) ?? declared, source: "param" };
|
|
53
|
+
}
|
|
54
|
+
const stored = normalizeResolvedTheme(input.stored);
|
|
55
|
+
if (stored) {
|
|
56
|
+
return { theme: stored, preference: normalizeThemePreference(input.storedPreference) ?? stored, source: "stored" };
|
|
57
|
+
}
|
|
58
|
+
return { theme: input.systemPrefersDark ? "dark" : "light", preference: "system", source: "system" };
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Writes one theme to the root: the `dark` class Tailwind keys on, `color-scheme` so native
|
|
62
|
+
* controls and scrollbars follow, and the two attributes for anything that wants the words.
|
|
63
|
+
*/
|
|
64
|
+
export function applyTheme(root, theme, preference) {
|
|
65
|
+
root.classList.toggle(DARK_THEME_CLASS, theme === "dark");
|
|
66
|
+
root.style.colorScheme = theme;
|
|
67
|
+
root.setAttribute(THEME_ATTRIBUTE, theme);
|
|
68
|
+
root.setAttribute(THEME_PREFERENCE_ATTRIBUTE, preference);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Reads a `hosty:shell-theme` message, accepting it from the parent frame alone.
|
|
72
|
+
*
|
|
73
|
+
* `event.source === window.parent` is set by the browser and is the trustworthy gate. The parent's
|
|
74
|
+
* origin is deliberately not checked: an app learns which origin embeds it from messages like this
|
|
75
|
+
* one, so it cannot be used to pre-filter them, and `document.referrer` — what some copies used —
|
|
76
|
+
* goes stale the moment the app reloads itself. Theme is not sensitive; the source check is
|
|
77
|
+
* sufficient. Not embedded at all means no parent to hear from, so nothing is accepted.
|
|
78
|
+
*/
|
|
79
|
+
export function parseShellThemeMessage(event, win) {
|
|
80
|
+
if (win.parent === win.self || event.source !== win.parent) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const data = event.data;
|
|
84
|
+
if (!data || typeof data !== "object" || data.type !== SHELL_THEME_TYPE) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
const theme = normalizeResolvedTheme(data.theme);
|
|
88
|
+
if (!theme) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
return { theme, preference: normalizeThemePreference(data.preference) ?? theme };
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The pre-hydration bootstrap: applies the theme from the same precedence `resolveTheme`
|
|
95
|
+
* implements, so a document never paints in the wrong theme before React can correct it. Mount
|
|
96
|
+
* it as an inline `<script>` in the document head.
|
|
97
|
+
*
|
|
98
|
+
* `followSystem` is the same switch `HostThemeBridge` takes. An app that runs its own theme
|
|
99
|
+
* provider for standalone use (next-themes) passes `false` here and there: the host's bootstrap
|
|
100
|
+
* then applies only a theme a shell declared and otherwise leaves the root to the provider, whose
|
|
101
|
+
* own stored choice must not be overwritten by the operating system's.
|
|
102
|
+
*
|
|
103
|
+
* A parameter is persisted here as well as in the bridge, on the launch mode's precedent, so a
|
|
104
|
+
* reload between this script and hydration cannot lose it. It does not clean the parameter out of
|
|
105
|
+
* the URL — that is `HostThemeBridge`'s job: a `history.replaceState` before hydration is a
|
|
106
|
+
* router's business, not a paint-blocking script's.
|
|
107
|
+
*/
|
|
108
|
+
export function createThemeBootstrapScript(options = {}) {
|
|
109
|
+
const followSystem = options.followSystem !== false;
|
|
110
|
+
return `
|
|
111
|
+
(() => {
|
|
112
|
+
const themes = ["light", "dark"];
|
|
113
|
+
const preferences = ["light", "dark", "system"];
|
|
114
|
+
const pick = (known, value) => (known.indexOf(value) >= 0 ? value : null);
|
|
115
|
+
const read = (key) => {
|
|
116
|
+
try {
|
|
117
|
+
return window.sessionStorage.getItem(key);
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
try {
|
|
123
|
+
const params = new URLSearchParams(window.location.search);
|
|
124
|
+
const declared = pick(themes, params.get(${JSON.stringify(THEME_PARAM)}));
|
|
125
|
+
let theme = declared;
|
|
126
|
+
let preference = null;
|
|
127
|
+
if (declared) {
|
|
128
|
+
preference = pick(preferences, params.get(${JSON.stringify(THEME_PREFERENCE_PARAM)})) || declared;
|
|
129
|
+
} else {
|
|
130
|
+
const stored = pick(themes, read(${JSON.stringify(THEME_STORAGE_KEY)}));
|
|
131
|
+
if (stored) {
|
|
132
|
+
theme = stored;
|
|
133
|
+
preference = pick(preferences, read(${JSON.stringify(THEME_PREFERENCE_STORAGE_KEY)})) || stored;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (!theme) {
|
|
137
|
+
if (!${JSON.stringify(followSystem)}) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
theme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
141
|
+
preference = "system";
|
|
142
|
+
}
|
|
143
|
+
const root = document.documentElement;
|
|
144
|
+
root.classList.toggle(${JSON.stringify(DARK_THEME_CLASS)}, theme === "dark");
|
|
145
|
+
root.style.colorScheme = theme;
|
|
146
|
+
root.setAttribute(${JSON.stringify(THEME_ATTRIBUTE)}, theme);
|
|
147
|
+
root.setAttribute(${JSON.stringify(THEME_PREFERENCE_ATTRIBUTE)}, preference);
|
|
148
|
+
if (declared) {
|
|
149
|
+
try {
|
|
150
|
+
window.sessionStorage.setItem(${JSON.stringify(THEME_STORAGE_KEY)}, theme);
|
|
151
|
+
window.sessionStorage.setItem(${JSON.stringify(THEME_PREFERENCE_STORAGE_KEY)}, preference);
|
|
152
|
+
} catch {}
|
|
153
|
+
}
|
|
154
|
+
} catch {}
|
|
155
|
+
})();
|
|
156
|
+
`;
|
|
157
|
+
}
|
|
158
|
+
/** The bootstrap for an app that follows the host and otherwise the operating system — the common case. */
|
|
159
|
+
export const themeBootstrapScript = createThemeBootstrapScript();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hosty-sdk/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Hosty app auth: session state machine, recovery bridge, Core revalidation, and the embedder responder.",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"type": "module",
|
|
@@ -32,6 +32,10 @@
|
|
|
32
32
|
"./embedder": {
|
|
33
33
|
"types": "./dist/embedder.d.ts",
|
|
34
34
|
"default": "./dist/embedder.js"
|
|
35
|
+
},
|
|
36
|
+
"./theme": {
|
|
37
|
+
"types": "./dist/theme.d.ts",
|
|
38
|
+
"default": "./dist/theme.js"
|
|
35
39
|
}
|
|
36
40
|
},
|
|
37
41
|
"scripts": {
|
|
@@ -42,7 +46,7 @@
|
|
|
42
46
|
"server-only": "^0.0.1"
|
|
43
47
|
},
|
|
44
48
|
"peerDependencies": {
|
|
45
|
-
"next": "^16.
|
|
49
|
+
"next": "^16.3.4",
|
|
46
50
|
"react": "^19.2.8"
|
|
47
51
|
},
|
|
48
52
|
"peerDependenciesMeta": {
|
|
@@ -57,7 +61,7 @@
|
|
|
57
61
|
"@types/node": "^24",
|
|
58
62
|
"@types/react": "^19.2.18",
|
|
59
63
|
"typescript": "^6",
|
|
60
|
-
"vitest": "^
|
|
64
|
+
"vitest": "^5.0.0"
|
|
61
65
|
},
|
|
62
66
|
"repository": {
|
|
63
67
|
"type": "git",
|