@k2b/cloud 0.21.1 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/package.json +4 -3
  2. package/scripts/README.md +1 -1
  3. package/scripts/build-canvas-workers.ts +58 -0
  4. package/scripts/build.ts +4 -5
  5. package/src/_internal/canvas-worker.ts +24 -0
  6. package/src/_internal/define-app.ts +81 -7
  7. package/src/_internal/page-responses.ts +19 -3
  8. package/src/_internal/registry-validation.ts +6 -0
  9. package/src/_internal/runtime-context.ts +1 -0
  10. package/src/_internal/static-assets.ts +18 -3
  11. package/src/access/ResourceApiKeys.tsx +30 -30
  12. package/src/ai/capability-execution.ts +5 -0
  13. package/src/ai/client/controller.ts +17 -3
  14. package/src/ai/client/transport.ts +47 -3
  15. package/src/ai/live-routes.ts +2 -2
  16. package/src/ai/pdf-render.ts +2 -22
  17. package/src/ai/routes.ts +5 -4
  18. package/src/ai/stream.ts +89 -65
  19. package/src/api/app-approval.ts +5 -1
  20. package/src/api/index.ts +3 -0
  21. package/src/api/me.ts +19 -5
  22. package/src/api/pwa-phone.ts +161 -0
  23. package/src/api/pwa.ts +179 -0
  24. package/src/browser/app-session.ts +69 -0
  25. package/src/browser/live-connection.ts +211 -0
  26. package/src/browser/live-websocket.ts +31 -11
  27. package/src/browser/live.ts +2 -0
  28. package/src/contracts/app.ts +10 -0
  29. package/src/contracts/index.ts +1 -0
  30. package/src/contracts/pwa-paths.ts +6 -0
  31. package/src/contracts/pwa.ts +156 -0
  32. package/src/contracts/registry.ts +7 -0
  33. package/src/contracts/shared.ts +7 -1
  34. package/src/events/index.ts +2 -0
  35. package/src/events/live-engine.ts +826 -0
  36. package/src/events/live-protocol.ts +31 -0
  37. package/src/events/live.ts +298 -0
  38. package/src/server/actor.ts +7 -0
  39. package/src/server/api-client.ts +23 -2
  40. package/src/server/index.ts +3 -1
  41. package/src/server/middleware/auth.ts +80 -8
  42. package/src/server/middleware/openapi.ts +2 -1
  43. package/src/server/services/access.ts +49 -0
  44. package/src/server/services/index.ts +1 -0
  45. package/src/services/app-approval.ts +6 -25
  46. package/src/services/audit/index.ts +2 -0
  47. package/src/services/branding/app-icon-source.ts +40 -0
  48. package/src/services/branding/app-icons.ts +112 -0
  49. package/src/services/branding/icon-render-worker.ts +69 -0
  50. package/src/services/branding/icon-svg.ts +22 -0
  51. package/src/services/identity/invocation-actor.ts +8 -5
  52. package/src/services/identity/invocation-authority.ts +1 -0
  53. package/src/services/identity/invocation-token.ts +6 -0
  54. package/src/services/index.ts +1 -0
  55. package/src/services/outbox.ts +150 -30
  56. package/src/services/pairing-secret.ts +16 -0
  57. package/src/services/pwa-devices.ts +574 -0
  58. package/src/services/session/index.ts +212 -57
  59. package/src/services/session/recent.ts +42 -0
  60. package/src/services/session/user.ts +15 -5
  61. package/src/shared/index.ts +1 -0
  62. package/src/{browser → shared}/locale-preference.ts +1 -1
  63. package/src/shared/markdown/extensions/info-blocks.ts +18 -47
  64. package/src/shared/markdown/index.ts +5 -14
  65. package/src/ssr/AppLaunchpad.island.tsx +8 -1
  66. package/src/ssr/AppLaunchpadPanel.tsx +1 -1
  67. package/src/ssr/GlobalAnnouncements.island.tsx +9 -34
  68. package/src/ssr/Layout.tsx +10 -12
  69. package/src/ssr/LayoutHeader.tsx +14 -2
  70. package/src/ssr/LayoutHelp.tsx +2 -4
  71. package/src/ssr/LayoutRail.tsx +13 -4
  72. package/src/ssr/MinimalLayoutPreferences.island.tsx +1 -1
  73. package/src/ssr/MobileProfileActions.tsx +19 -13
  74. package/src/ssr/PageError.tsx +23 -1
  75. package/src/ssr/ProfilePreferences.island.tsx +9 -1
  76. package/src/ssr/PwaLayout.tsx +69 -0
  77. package/src/ssr/PwaRuntime.island.tsx +105 -0
  78. package/src/ssr/TimezoneCookie.island.tsx +7 -1
  79. package/src/ssr/app-navigation.ts +25 -10
  80. package/src/ssr/index.ts +8 -1
  81. package/src/ssr/layout-context.ts +1 -1
  82. package/src/ssr/preference-controller.ts +2 -2
  83. package/src/ssr/profile-actions.ts +3 -1
  84. package/src/ssr/profile-preferences-messages.ts +2 -0
  85. package/src/ssr/pwa-messages.ts +24 -0
  86. package/src/styles/effects.css +6 -30
  87. package/src/styles/global.css +11 -0
  88. package/src/styles/tokens.css +5 -1
  89. package/src/styles/utilities-feedback.css +15 -5
  90. package/src/styles/utilities-layout.css +4 -0
  91. package/src/styles/utilities-markdown-editor.css +20 -2
  92. package/src/styles/utilities-markdown-table.css +35 -17
  93. package/scripts/build-pdf-renderer.ts +0 -39
@@ -26,7 +26,7 @@ export type AppLaunchpadLegalLink = {
26
26
  };
27
27
 
28
28
  export type AppLaunchpadContext = {
29
- profile?: { name: string; theme: CloudTheme };
29
+ profile?: { name: string; theme: CloudTheme; appHref?: string };
30
30
  apps: AppLaunchpadApp[];
31
31
  legalLinks: AppLaunchpadLegalLink[];
32
32
  };
@@ -1,4 +1,4 @@
1
- import { Button, dialogCore, MarkdownView, PanelDialog, panelDialogOptions, useLocale } from "@k2b/ui";
1
+ import { Button, dialogCore, IconButton, MarkdownView, NoticeCard, PanelDialog, panelDialogOptions, useLocale } from "@k2b/ui";
2
2
  import { createSignal, For, onMount, Show } from "solid-js";
3
3
  import {
4
4
  ANNOUNCEMENTS_COOKIE,
@@ -21,22 +21,6 @@ const writeCookieState = (state: AnnouncementCookieState) => {
21
21
  document.cookie = `${ANNOUNCEMENTS_COOKIE}=${serializeAnnouncementCookieState(state)}; Path=/; Max-Age=${ANNOUNCEMENTS_COOKIE_MAX_AGE_SECONDS}; SameSite=Lax`;
22
22
  };
23
23
 
24
- const toneClass = (tone: AnnouncementDisplayEntry["tone"]) => {
25
- if (tone === "success")
26
- return "border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900/60 dark:bg-emerald-950/40 dark:text-emerald-100";
27
- if (tone === "warning")
28
- return "border-amber-200 bg-amber-50 text-amber-950 dark:border-amber-900/60 dark:bg-amber-950/40 dark:text-amber-100";
29
- if (tone === "danger") return "border-red-200 bg-red-50 text-red-950 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-100";
30
- return "border-blue-200 bg-blue-50 text-blue-950 dark:border-blue-900/60 dark:bg-blue-950/40 dark:text-blue-100";
31
- };
32
-
33
- const toneIcon = (tone: AnnouncementDisplayEntry["tone"]) => {
34
- if (tone === "success") return "ti ti-circle-check";
35
- if (tone === "warning") return "ti ti-alert-triangle";
36
- if (tone === "danger") return "ti ti-alert-circle";
37
- return "ti ti-info-circle";
38
- };
39
-
40
24
  export default function GlobalAnnouncements(props: Props) {
41
25
  const locale = useLocale();
42
26
  const t = () => platformMessages.resolve([locale()]).t;
@@ -113,27 +97,18 @@ export default function GlobalAnnouncements(props: Props) {
113
97
  <div class="flex shrink-0 flex-col gap-1">
114
98
  <For each={banners()}>
115
99
  {(banner) => (
116
- <section
117
- class={`flex max-h-[min(40vh,14rem)] items-start gap-2 rounded-lg border px-3 py-2 text-xs shadow-sm ${toneClass(banner.tone)}`}
118
- >
119
- <i class={`${toneIcon(banner.tone)} mt-0.5 shrink-0`} />
120
- <div class="min-h-0 min-w-0 flex-1">
121
- <p class="font-semibold">{banner.title}</p>
100
+ <div class="cloud-announcement">
101
+ <NoticeCard tone={banner.tone} title={banner.title}>
122
102
  <MarkdownView
123
103
  trustedHtml={banner.bodyHtml}
124
104
  headingScale="compact"
125
- class="mt-1 max-h-36 overflow-y-auto overscroll-contain pr-1 [&_p]:my-0"
105
+ class="max-h-36 overflow-y-auto overscroll-contain pr-1 [&_p]:my-0"
126
106
  />
127
- </div>
128
- <button
129
- type="button"
130
- class="ml-auto inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md opacity-70 hover:bg-black/5 hover:opacity-100 dark:hover:bg-white/10"
131
- aria-label={t().dismissBanner}
132
- onClick={() => dismissBanner(banner.version)}
133
- >
134
- <i class="ti ti-x" />
135
- </button>
136
- </section>
107
+ </NoticeCard>
108
+ <IconButton size="sm" label={t().dismissBanner} onClick={() => dismissBanner(banner.version)}>
109
+ <i class="ti ti-x" aria-hidden="true" />
110
+ </IconButton>
111
+ </div>
137
112
  )}
138
113
  </For>
139
114
  </div>
@@ -4,6 +4,7 @@ import type { JSX } from "solid-js/jsx-runtime";
4
4
  import { readAppWorkspaceLayoutCookie, resolveAppWorkspaceLayoutForSidebar } from "../_internal/app-workspace-state";
5
5
  import { navigationSearchItems } from "../browser/navigation-search";
6
6
  import { resolveNavMatch } from "../contracts/app"; // ==========================
7
+ import { isPwaShellAvailable } from "../contracts/pwa";
7
8
  import { defaultRailPreferences } from "../contracts/rail-preferences";
8
9
  import { hasRole, type User } from "../contracts/shared";
9
10
  import { getLocale } from "../server/locale";
@@ -90,9 +91,7 @@ function ProfileWarnings({ user, locale }: { user: User; locale: string }) {
90
91
  if (missing.length === 0) return null;
91
92
  return (
92
93
  <a href="/me" class="block shrink-0 no-underline">
93
- <NoticeCard tone="warning" icon={false} bodyClass="flex items-center gap-2">
94
- <i class="ti ti-user-exclamation" /> <span>{t.profileIncomplete({ fields: missing.join(", ") })}</span>
95
- </NoticeCard>
94
+ <NoticeCard tone="warning">{t.profileIncomplete({ fields: missing.join(", ") })}</NoticeCard>
96
95
  </a>
97
96
  );
98
97
  }
@@ -100,13 +99,12 @@ function ExpiryWarnings({ user, dateConfig, locale }: { user: User; dateConfig:
100
99
  const t = platformMessages.resolve([locale]).t;
101
100
  const now = Date.now();
102
101
  const warnThreshold = now + WARN_DAYS * 24 * 60 * 60 * 1000;
103
- const warnings: { icon: string; message: string; expired: boolean }[] = [];
102
+ const warnings: { message: string; expired: boolean }[] = [];
104
103
  if (user.accountExpires) {
105
104
  const expires = new Date(user.accountExpires).getTime();
106
- if (expires < now) warnings.push({ icon: "ti-calendar-event", message: t.accountExpired, expired: true });
105
+ if (expires < now) warnings.push({ message: t.accountExpired, expired: true });
107
106
  else if (expires < warnThreshold)
108
107
  warnings.push({
109
- icon: "ti-calendar-event",
110
108
  message:
111
109
  user.profile === "guest"
112
110
  ? t.guestAccountExpires({ date: dates.formatDate(user.accountExpires, dateConfig) })
@@ -116,10 +114,9 @@ function ExpiryWarnings({ user, dateConfig, locale }: { user: User; dateConfig:
116
114
  }
117
115
  if (user.ipa?.passwordExpires) {
118
116
  const expires = new Date(user.ipa.passwordExpires).getTime();
119
- if (expires < now) warnings.push({ icon: "ti-key", message: t.passwordExpired, expired: true });
117
+ if (expires < now) warnings.push({ message: t.passwordExpired, expired: true });
120
118
  else if (expires < warnThreshold)
121
119
  warnings.push({
122
- icon: "ti-key",
123
120
  message: t.passwordExpires({ date: dates.formatDate(user.ipa.passwordExpires, dateConfig) }),
124
121
  expired: false,
125
122
  });
@@ -129,10 +126,7 @@ function ExpiryWarnings({ user, dateConfig, locale }: { user: User; dateConfig:
129
126
  <div class="flex shrink-0 flex-col gap-1">
130
127
  {" "}
131
128
  {warnings.map((w) => (
132
- <NoticeCard tone={w.expired ? "danger" : "warning"} icon={false} bodyClass="flex items-center gap-2">
133
- {" "}
134
- <i class={`ti ${w.icon}`} /> <span>{w.message}</span>{" "}
135
- </NoticeCard>
129
+ <NoticeCard tone={w.expired ? "danger" : "warning"}>{w.message}</NoticeCard>
136
130
  ))}{" "}
137
131
  </div>
138
132
  );
@@ -205,6 +199,8 @@ export default function Layout(props: LayoutProps) {
205
199
  if (!page.title) page.title = pageTitle;
206
200
  const breadcrumbs: Breadcrumb[] = !title ? [{ title: appName }] : typeof title === "string" ? [{ title }] : title;
207
201
  const showRail = !!user;
202
+ // The profile menu offers the mobile app only while the installation runs it.
203
+ const appHref = user && isPwaShellAvailable(runtime.apps) ? "/me/app" : undefined;
208
204
  const profileName = user?.displayName || user?.uid || "?";
209
205
  const profileAvatarSrc =
210
206
  user?.avatarHash && user.id
@@ -253,6 +249,7 @@ export default function Layout(props: LayoutProps) {
253
249
  {showRail && (
254
250
  <LayoutRail
255
251
  accent={currentApp?.appearance?.accent}
252
+ appHref={appHref}
256
253
  appsLabel={t.apps}
257
254
  homeLabel={t.home}
258
255
  launchpadApps={launchpadApps}
@@ -270,6 +267,7 @@ export default function Layout(props: LayoutProps) {
270
267
  )}
271
268
  <div class="layout-shell-content flex min-h-0 min-w-0 flex-1 flex-col">
272
269
  <LayoutHeader
270
+ appHref={appHref}
273
271
  appLabel={currentApp?.name ?? (pathname.startsWith("/admin") ? t.admin : undefined)}
274
272
  accent={currentApp?.appearance?.accent}
275
273
  authenticated={showRail}
@@ -12,6 +12,8 @@ import ProfilePreferences from "./ProfilePreferences.island";
12
12
 
13
13
  type LayoutHeaderProps = {
14
14
  accent?: string;
15
+ /** Set while the installation offers the mobile app. */
16
+ appHref?: string;
15
17
  appLabel?: string;
16
18
  authenticated: boolean;
17
19
  breadcrumbs: LayoutBreadcrumb[];
@@ -64,7 +66,11 @@ export default function LayoutHeader(props: LayoutHeaderProps) {
64
66
  />
65
67
  {(props.authenticated || props.appLabel) && (
66
68
  <AppLaunchpad
67
- profile={props.authenticated ? { name: props.profileName, theme: props.theme } : undefined}
69
+ profile={
70
+ props.authenticated
71
+ ? { name: props.profileName, theme: props.theme, ...(props.appHref ? { appHref: props.appHref } : {}) }
72
+ : undefined
73
+ }
68
74
  apps={props.launchpadApps}
69
75
  legalLinks={props.legalLinks}
70
76
  variant="header"
@@ -74,7 +80,13 @@ export default function LayoutHeader(props: LayoutHeaderProps) {
74
80
  </div>
75
81
  {props.authenticated ? (
76
82
  <div class="hidden lg:block">
77
- <ProfilePreferences avatarSrc={props.profileAvatarSrc} initialTheme={props.theme} name={props.profileName} placement="header" />
83
+ <ProfilePreferences
84
+ appHref={props.appHref}
85
+ avatarSrc={props.profileAvatarSrc}
86
+ initialTheme={props.theme}
87
+ name={props.profileName}
88
+ placement="header"
89
+ />
78
90
  </div>
79
91
  ) : (
80
92
  <>
@@ -753,10 +753,8 @@ const HelpShell = (props: {
753
753
  </Show>
754
754
  <Show when={loadError()}>
755
755
  {(message) => (
756
- <NoticeCard tone="danger" icon={false}>
757
- <p class="font-medium">{t().loadFailed}</p>
758
- <p class="mt-1 text-sm">{message()}</p>
759
- <Button size="sm" variant="secondary" class="mt-3" onClick={() => setLoadAttempt((value) => value + 1)}>
756
+ <NoticeCard tone="danger" title={t().loadFailed} detail={message()}>
757
+ <Button size="sm" variant="secondary" onClick={() => setLoadAttempt((value) => value + 1)}>
760
758
  {t().tryAgain}
761
759
  </Button>
762
760
  </NoticeCard>
@@ -12,6 +12,8 @@ export type LayoutAppLink = RailApp;
12
12
 
13
13
  type LayoutRailProps = {
14
14
  accent?: string;
15
+ /** Set while the installation offers the mobile app. */
16
+ appHref?: string;
15
17
  appsLabel: string;
16
18
  homeLabel: string;
17
19
  launchpadApps: AppLaunchpadApp[];
@@ -31,11 +33,12 @@ type LayoutRailProps = {
31
33
  const jsonScript = (value: unknown): string => JSON.stringify(value).replace(/</g, "\\u003c");
32
34
 
33
35
  export default function LayoutRail(props: LayoutRailProps) {
36
+ const profile = { name: props.profileName, theme: props.theme, ...(props.appHref ? { appHref: props.appHref } : {}) };
34
37
  return (
35
38
  <>
36
- <AppLaunchpad profile={{ name: props.profileName, theme: props.theme }} apps={props.launchpadApps} legalLinks={props.legalLinks} />
39
+ <AppLaunchpad profile={profile} apps={props.launchpadApps} legalLinks={props.legalLinks} />
37
40
  <script id="cloud-app-launchpad-data" type="application/json">
38
- {jsonScript({ apps: props.launchpadApps, legalLinks: props.legalLinks, profile: { name: props.profileName, theme: props.theme } })}
41
+ {jsonScript({ apps: props.launchpadApps, legalLinks: props.legalLinks, profile })}
39
42
  </script>
40
43
  <script id="cloud-rail-data" type="application/json">
41
44
  {jsonScript({ apps: props.apps, settings: props.railSettings })}
@@ -49,7 +52,7 @@ export default function LayoutRail(props: LayoutRailProps) {
49
52
  <nav class="layout-rail-navigation flex min-h-0 flex-1 flex-col items-center gap-1" aria-label={props.appsLabel}>
50
53
  <RailApps apps={props.apps} settings={props.railSettings} currentPath={props.currentPath} />
51
54
  <AppLaunchpad
52
- profile={{ name: props.profileName, theme: props.theme }}
55
+ profile={profile}
53
56
  apps={props.launchpadApps}
54
57
  legalLinks={props.legalLinks}
55
58
  variant="rail"
@@ -58,7 +61,13 @@ export default function LayoutRail(props: LayoutRailProps) {
58
61
  <div class="mt-auto shrink-0 flex flex-col items-center gap-1">
59
62
  <GlobalSearchTrigger variant="rail" searchLinks={props.searchLinks} />
60
63
  <LayoutHelpTrigger variant="rail" registerCommand searchHelpApps={props.searchHelpApps} accent={props.accent} />
61
- <ProfilePreferences avatarSrc={props.profileAvatarSrc} initialTheme={props.theme} name={props.profileName} placement="rail" />
64
+ <ProfilePreferences
65
+ appHref={props.appHref}
66
+ avatarSrc={props.profileAvatarSrc}
67
+ initialTheme={props.theme}
68
+ name={props.profileName}
69
+ placement="rail"
70
+ />
62
71
  </div>
63
72
  </nav>
64
73
  </aside>
@@ -1,6 +1,6 @@
1
1
  import { Button, Dropdown, useLocale } from "@k2b/ui";
2
- import { type ProfilePreferenceLocale, profilePreferenceLocale, setLocalePreference } from "../browser/locale-preference";
3
2
  import { canonicalLocale } from "../shared/locale";
3
+ import { type ProfilePreferenceLocale, profilePreferenceLocale, setLocalePreference } from "../shared/locale-preference";
4
4
  import type { CloudTheme } from "../shared/theme";
5
5
  import { createPreferenceController } from "./preference-controller";
6
6
 
@@ -3,24 +3,30 @@ import { Button, ButtonLink } from "@k2b/ui";
3
3
  import type { CloudTheme } from "../shared/theme";
4
4
  import { createProfileActions } from "./profile-actions";
5
5
 
6
- export function MobileProfileActions(props: { profile: { name: string; theme: CloudTheme }; beforeSelect: () => Promise<boolean> }) {
7
- const { preferences, signingOut, signOut } = createProfileActions(props.profile.theme);
6
+ export function MobileProfileActions(props: {
7
+ profile: { name: string; theme: CloudTheme; appHref?: string };
8
+ beforeSelect: () => Promise<boolean>;
9
+ }) {
10
+ const { preferences, signingOut, signOut } = createProfileActions(props.profile.theme, props.profile.appHref);
11
+ const navigate = (href: string) => (event: MouseEvent) => {
12
+ if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
13
+ event.preventDefault();
14
+ void props.beforeSelect().then((allowed) => {
15
+ if (allowed) documentNavigate(href);
16
+ });
17
+ };
8
18
  return (
9
19
  <section class="cloud-mobile-profile" aria-label={preferences.messages().menuLabel}>
10
- <ButtonLink
11
- href="/me"
12
- variant="ghost"
13
- onClick={(event) => {
14
- if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
15
- event.preventDefault();
16
- void props.beforeSelect().then((allowed) => {
17
- if (allowed) documentNavigate("/me");
18
- });
19
- }}
20
- >
20
+ <ButtonLink href="/me" variant="ghost" onClick={navigate("/me")}>
21
21
  <i class="ti ti-user-circle" aria-hidden="true" />
22
22
  {preferences.messages().profileSettings}
23
23
  </ButtonLink>
24
+ {props.profile.appHref && (
25
+ <ButtonLink href={props.profile.appHref} variant="ghost" onClick={navigate(props.profile.appHref)}>
26
+ <i class="ti ti-device-mobile" aria-hidden="true" />
27
+ {preferences.messages().app}
28
+ </ButtonLink>
29
+ )}
24
30
  <Button variant="ghost" onClick={preferences.toggleTheme}>
25
31
  <i class={`ti ${preferences.theme() === "light" ? "ti-moon" : "ti-sun-high"}`} aria-hidden="true" />
26
32
  {preferences.themeLabel()}
@@ -1,10 +1,13 @@
1
- import { NotFoundState } from "@k2b/ui";
1
+ import { ButtonLink, NotFoundState, Placeholder } from "@k2b/ui";
2
2
  import type { Context } from "hono";
3
3
  import type { PageErrorOptions, PageErrorStatus } from "../_internal/page-responses";
4
+ import { PWA_SCOPE } from "../contracts/pwa";
4
5
  import { getLocale } from "../server/locale";
5
6
  import Layout from "./Layout";
6
7
  import MinimalLayout from "./MinimalLayout";
8
+ import PwaLayout from "./PwaLayout";
7
9
  import { pageErrorMessages } from "./page-error-messages";
10
+ import { pwaMessages } from "./pwa-messages";
8
11
 
9
12
  export const renderPageError = (c: Context, status: PageErrorStatus, options: PageErrorOptions) => {
10
13
  const { t } = pageErrorMessages.resolve([getLocale(c)]);
@@ -14,6 +17,25 @@ export const renderPageError = (c: Context, status: PageErrorStatus, options: Pa
14
17
  const action = options.action ?? { label: t.home, href: "/" };
15
18
  c.get("page").title = title;
16
19
 
20
+ if (options.layout === "pwa") {
21
+ const pwa = pwaMessages.resolve([getLocale(c)]).t;
22
+ return () => (
23
+ <PwaLayout c={c} title={title}>
24
+ <Placeholder
25
+ state={status >= 500 ? "error" : "empty"}
26
+ icon={status === 403 ? "ti ti-lock" : status === 404 ? "ti ti-compass-off" : undefined}
27
+ title={title}
28
+ description={description}
29
+ action={
30
+ <ButtonLink href={options.action?.href ?? PWA_SCOPE} variant="secondary">
31
+ {options.action?.label ?? pwa.errorAction}
32
+ </ButtonLink>
33
+ }
34
+ />
35
+ </PwaLayout>
36
+ );
37
+ }
38
+
17
39
  return () =>
18
40
  options.layout === "minimal" ? (
19
41
  <MinimalLayout c={c}>
@@ -3,6 +3,8 @@ import type { CloudTheme } from "../shared/theme";
3
3
  import { createProfileActions } from "./profile-actions";
4
4
 
5
5
  type ProfilePreferencesProps = {
6
+ /** Set while the installation offers the mobile app. */
7
+ appHref?: string;
6
8
  avatarSrc?: string;
7
9
  initialTheme: CloudTheme;
8
10
  name: string;
@@ -10,7 +12,7 @@ type ProfilePreferencesProps = {
10
12
  };
11
13
 
12
14
  export default function ProfilePreferences(props: ProfilePreferencesProps) {
13
- const { preferences, items, signOut, signingOut } = createProfileActions(props.initialTheme);
15
+ const { preferences, items, signOut, signingOut } = createProfileActions(props.initialTheme, props.appHref);
14
16
 
15
17
  const avatar = () => <Avatar name={props.name} src={props.avatarSrc} size="xs" />;
16
18
 
@@ -32,6 +34,12 @@ export default function ProfilePreferences(props: ProfilePreferencesProps) {
32
34
  <i class="ti ti-user-circle" aria-hidden="true" />
33
35
  <span>{preferences.messages().profileSettings}</span>
34
36
  </a>
37
+ {props.appHref && (
38
+ <a href={props.appHref} class="menu-item">
39
+ <i class="ti ti-device-mobile" aria-hidden="true" />
40
+ <span>{preferences.messages().app}</span>
41
+ </a>
42
+ )}
35
43
  <button type="button" class="menu-item" onClick={signOut} disabled={signingOut()}>
36
44
  <i class="ti ti-logout" aria-hidden="true" />
37
45
  <span>{preferences.messages().signOut}</span>
@@ -0,0 +1,69 @@
1
+ import { LocaleProvider, MobileShell, TabBar, type TabBarItem } from "@k2b/ui";
2
+ import type { JSX } from "solid-js";
3
+ import { PWA_SCOPE } from "../contracts/pwa";
4
+ import { getLocale } from "../server/locale";
5
+ import { readThemeFromCookieHeader } from "../shared/theme";
6
+ import { visiblePwaParts } from "./app-navigation";
7
+ import type { LayoutContext } from "./layout-context";
8
+ import PwaRuntime from "./PwaRuntime.island";
9
+ import { pwaMessages } from "./pwa-messages";
10
+ import { getRuntimeContext } from "./runtime";
11
+ import TimezoneCookie from "./TimezoneCookie.island";
12
+
13
+ /** Parts with their own tab; Start lists every part and doubles as "More". */
14
+ const TAB_PARTS = 3;
15
+
16
+ export type PwaLayoutProps = {
17
+ /** The request context; the layout also sets the page's framing and referrer headers. */
18
+ c: LayoutContext & { header(name: string, value: string): void };
19
+ /** Header heading and document title. */
20
+ title: string;
21
+ /** Up-navigation inside the part, for example from a detail back to its list. */
22
+ back?: { href: string; label: string };
23
+ /** At most two icon buttons at the header end. */
24
+ actions?: JSX.Element;
25
+ children: JSX.Element;
26
+ };
27
+
28
+ /**
29
+ * The frame of every page in the installable mobile app (preview): the app's document head, a phone shell with a
30
+ * tab bar of Start and the first parts, and the browser runtime (service worker, offline notice, session keepalive).
31
+ * Pages render it below `/pwa/` with `ssr.pwaAccess`.
32
+ */
33
+ export default function PwaLayout(props: PwaLayoutProps) {
34
+ const { c } = props;
35
+ const locale = getLocale(c);
36
+ const t = pwaMessages.resolve([locale]).t;
37
+ const user = c.get("user");
38
+ const cloud = c.get("settings")?.app?.name || "Cloud";
39
+ const page = c.get("page");
40
+ page.theme = readThemeFromCookieHeader(c.req.raw.headers.get("Cookie"));
41
+ page.title = props.title;
42
+ page.pwa = { name: cloud };
43
+ // No app page may be framed, and none leaks its address to another site.
44
+ c.header("Referrer-Policy", "no-referrer");
45
+ c.header("Content-Security-Policy", "frame-ancestors 'none'");
46
+
47
+ const parts = visiblePwaParts(getRuntimeContext(c).apps, user, locale);
48
+ const path = new URL(c.req.raw.url).pathname;
49
+ const tabs = parts.slice(0, TAB_PARTS);
50
+ const currentTab = tabs.find((part) => path === part.pwa.href || path.startsWith(`${part.pwa.href}/`));
51
+ const items: TabBarItem[] = [
52
+ { id: "start", label: t.start, icon: "ti ti-home", href: PWA_SCOPE, current: !currentTab },
53
+ ...tabs.map((part) => ({ id: part.id, label: part.name, icon: part.icon, href: part.pwa.href, current: part === currentTab })),
54
+ ];
55
+
56
+ return (
57
+ <LocaleProvider locale={locale}>
58
+ {/* Pages without a person show no times. Their pairing link lives only in memory, and a reload would lose it. */}
59
+ <TimezoneCookie reload={!!user} />
60
+ <MobileShell
61
+ header={<MobileShell.Header title={props.title} back={props.back} actions={props.actions} />}
62
+ footer={user && parts.length > 0 ? <TabBar label={t.tabs} items={items} /> : undefined}
63
+ >
64
+ {props.children}
65
+ </MobileShell>
66
+ <PwaRuntime keepalive={!!user} cloud={cloud} />
67
+ </LocaleProvider>
68
+ );
69
+ }
@@ -0,0 +1,105 @@
1
+ import { observeMobileShell, syncThemeColor, type ToastHandle, toast, useLocale } from "@k2b/ui";
2
+ import { onCleanup, onMount } from "solid-js";
3
+ import { renewAppSession } from "../browser/app-session";
4
+ import { PWA_SCOPE, PWA_SERVICE_WORKER_PATH } from "../contracts/pwa";
5
+ import { pwaMessages } from "./pwa-messages";
6
+
7
+ /** Set once per browsing session, so the first page renews and later navigations do not. */
8
+ const RENEWED_MARKER = "cloud.pwa.renewed";
9
+ const KEEPALIVE_INTERVAL_MS = 60 * 60 * 1000;
10
+ /** Matches `cloud:theme-preference` in the preference controller. */
11
+ const THEME_PREFERENCE_EVENT = "cloud:theme-preference";
12
+
13
+ export type PwaRuntimeProps = {
14
+ /** A person uses the app: keep the app session alive. */
15
+ keepalive: boolean;
16
+ /** The installation name, for the account notice. */
17
+ cloud: string;
18
+ };
19
+
20
+ const firstPageOfSession = (): boolean => {
21
+ try {
22
+ if (window.sessionStorage.getItem(RENEWED_MARKER)) return false;
23
+ window.sessionStorage.setItem(RENEWED_MARKER, "1");
24
+ } catch {}
25
+ return true;
26
+ };
27
+
28
+ /**
29
+ * The browser side of every mobile app page: the service worker, the offline notice, the app session keepalive,
30
+ * the status bar colour after a theme change, and the server-rendered shell's measurements.
31
+ */
32
+ export default function PwaRuntime(props: PwaRuntimeProps) {
33
+ const locale = useLocale();
34
+ const t = () => pwaMessages.resolve([locale()]).t;
35
+
36
+ onMount(() => {
37
+ const cleanups: (() => void)[] = [];
38
+ onCleanup(() => {
39
+ for (const cleanup of cleanups) cleanup();
40
+ });
41
+
42
+ const shell = document.querySelector<HTMLElement>(".k2b-mobile-shell");
43
+ if (shell) cleanups.push(observeMobileShell(shell));
44
+
45
+ // The launch bounce marks its return; the page is here now, so the marker leaves the address.
46
+ const url = new URL(location.href);
47
+ if (url.searchParams.has("pwa_launch")) {
48
+ url.searchParams.delete("pwa_launch");
49
+ history.replaceState(history.state, "", url.pathname + url.search + url.hash);
50
+ }
51
+
52
+ // Registration fails in private windows and insecure contexts; the app then simply works online only.
53
+ void navigator.serviceWorker?.register(PWA_SERVICE_WORKER_PATH, { scope: PWA_SCOPE, updateViaCache: "none" }).catch(() => undefined);
54
+
55
+ let offlineToast: ToastHandle | undefined;
56
+ const offline = () => {
57
+ offlineToast ??= toast(t().offline, { duration: 0, iconClass: "ti ti-wifi-off" });
58
+ };
59
+ const online = () => {
60
+ offlineToast?.dismiss();
61
+ offlineToast = undefined;
62
+ };
63
+ if (!navigator.onLine) offline();
64
+ window.addEventListener("offline", offline);
65
+ window.addEventListener("online", online);
66
+ cleanups.push(() => {
67
+ window.removeEventListener("offline", offline);
68
+ window.removeEventListener("online", online);
69
+ offlineToast?.dismiss();
70
+ });
71
+
72
+ const syncTheme = () => syncThemeColor();
73
+ window.addEventListener(THEME_PREFERENCE_EVENT, syncTheme);
74
+ cleanups.push(() => window.removeEventListener(THEME_PREFERENCE_EVENT, syncTheme));
75
+
76
+ if (!props.keepalive) return;
77
+
78
+ let otherAccountShown = false;
79
+ let disposed = false;
80
+ cleanups.push(() => {
81
+ disposed = true;
82
+ });
83
+ const renew = async () => {
84
+ const { otherAccount } = await renewAppSession();
85
+ if (disposed || !otherAccount || otherAccountShown) return;
86
+ otherAccountShown = true;
87
+ toast(t().otherAccount({ cloud: props.cloud, name: otherAccount.name }), { duration: 0, iconClass: "ti ti-alert-triangle" });
88
+ };
89
+
90
+ if (firstPageOfSession()) void renew();
91
+ const visible = () => {
92
+ if (document.visibilityState === "visible") void renew();
93
+ };
94
+ document.addEventListener("visibilitychange", visible);
95
+ const timer = setInterval(() => {
96
+ if (document.visibilityState === "visible") void renew();
97
+ }, KEEPALIVE_INTERVAL_MS);
98
+ cleanups.push(() => {
99
+ document.removeEventListener("visibilitychange", visible);
100
+ clearInterval(timer);
101
+ });
102
+ });
103
+
104
+ return null;
105
+ }
@@ -2,12 +2,18 @@ import { cookies } from "@k2b/stdlib/browser";
2
2
  import { onMount } from "solid-js";
3
3
  import { TIMEZONE_COOKIE } from "../shared/time";
4
4
 
5
- export default function TimezoneCookie() {
5
+ export type TimezoneCookieProps = {
6
+ /** Reload once after a timezone change, so the page shows times in the browser's zone. Defaults to `true`. */
7
+ reload?: boolean;
8
+ };
9
+
10
+ export default function TimezoneCookie(props: TimezoneCookieProps) {
6
11
  onMount(() => {
7
12
  const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
8
13
  const current = cookies.readCookie(TIMEZONE_COOKIE);
9
14
  if (timeZone && current !== timeZone) {
10
15
  cookies.writeCookie(TIMEZONE_COOKIE, timeZone);
16
+ if (props.reload === false) return;
11
17
  try {
12
18
  if (sessionStorage.getItem("cloud.timezone.reload") !== timeZone) {
13
19
  sessionStorage.setItem("cloud.timezone.reload", timeZone);
@@ -1,5 +1,7 @@
1
1
  import type { RuntimeAppMeta } from "../contracts/app";
2
- import { hasRole, type User } from "../contracts/shared";
2
+ import { isPwaPartId } from "../contracts/pwa";
3
+ import { hasRole, type Role, type User } from "../contracts/shared";
4
+ import { resolveAppPresentation } from "../shared/app-presentation";
3
5
 
4
6
  export type VisibleNavigationApp = RuntimeAppMeta & {
5
7
  nav: NonNullable<RuntimeAppMeta["nav"]>;
@@ -25,17 +27,30 @@ export const hasDedicatedRuntimeRoute = (apps: readonly RuntimeAppMeta[], pathna
25
27
  return !!match && match.app.id !== currentAppId;
26
28
  };
27
29
 
30
+ const hasAnyRole = (user: User | undefined, roles: readonly Role[] | undefined): boolean =>
31
+ !roles || (!!user && roles.some((role) => (role === "guest" ? user.profile === "guest" : hasRole(user, role))));
32
+
28
33
  /** Apps rendered in the navigation for a given authenticated user. */
29
34
  export const visibleNavigationApps = (apps: readonly RuntimeAppMeta[], user: User | undefined): VisibleNavigationApp[] =>
30
35
  apps.filter(
31
36
  (app): app is VisibleNavigationApp =>
32
- !!app.nav &&
33
- app.nav.section !== "hidden" &&
34
- (!app.nav.requiresAuth || !!user) &&
35
- (!app.nav.requiresRoles ||
36
- (!!user &&
37
- app.nav.requiresRoles.some((role) => {
38
- if (role === "guest") return user.profile === "guest";
39
- return hasRole(user, role);
40
- }))),
37
+ !!app.nav && app.nav.section !== "hidden" && (!app.nav.requiresAuth || !!user) && hasAnyRole(user, app.nav.requiresRoles),
41
38
  );
39
+
40
+ export type VisiblePwaPart = RuntimeAppMeta & { pwa: NonNullable<RuntimeAppMeta["pwa"]> };
41
+
42
+ /**
43
+ * Parts of the mobile app the user may see, localized and sorted by name. Empty without a user. The roles are
44
+ * coarse visibility like the navigation's; part routes and services still authorize.
45
+ */
46
+ export const visiblePwaParts = (apps: readonly RuntimeAppMeta[], user: User | undefined, locale: string): VisiblePwaPart[] => {
47
+ if (!user) return [];
48
+ // Like the gateway, which routes /pwa/<id> only to a valid part id that claims it.
49
+ return apps
50
+ .filter(
51
+ (app): app is VisiblePwaPart =>
52
+ !!app.pwa && isPwaPartId(app.id) && app.routes.includes(app.pwa.href) && hasAnyRole(user, app.pwa.requiresRoles),
53
+ )
54
+ .map((app) => resolveAppPresentation(app, locale))
55
+ .sort((a, b) => a.name.localeCompare(b.name, locale) || a.id.localeCompare(b.id));
56
+ };
package/src/ssr/index.ts CHANGED
@@ -2,13 +2,20 @@
2
2
  // Do NOT import from this barrel in .island.tsx or .client.tsx files!
3
3
 
4
4
  export { default as AdminLayout } from "./AdminLayout";
5
- export { hasDedicatedRuntimeRoute, resolveRuntimeRoute, visibleNavigationApps } from "./app-navigation";
5
+ export {
6
+ hasDedicatedRuntimeRoute,
7
+ resolveRuntimeRoute,
8
+ type VisiblePwaPart,
9
+ visibleNavigationApps,
10
+ visiblePwaParts,
11
+ } from "./app-navigation";
6
12
  export { default as Layout } from "./Layout";
7
13
  export {
8
14
  default as MinimalLayout,
9
15
  type MinimalLayoutPreferencePosition,
10
16
  type MinimalLayoutProps,
11
17
  } from "./MinimalLayout";
18
+ export { default as PwaLayout, type PwaLayoutProps } from "./PwaLayout";
12
19
  export { requestPath } from "./request-path";
13
20
  export { getLocalizedRuntimeContext, getRuntimeContext, type RuntimeContext } from "./runtime";
14
21
  export type { UrlFilterField } from "./url-filter";