@k2b/cloud 0.42.0 → 0.44.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 (352) hide show
  1. package/package.json +13 -5
  2. package/scripts/app-favicon.ts +7 -6
  3. package/scripts/build.ts +3 -0
  4. package/scripts/installable-assets.ts +61 -0
  5. package/scripts/preload.ts +3 -0
  6. package/src/_internal/app-access-path.ts +12 -0
  7. package/src/_internal/app-access-request.ts +13 -0
  8. package/src/_internal/app-access-resources.ts +141 -0
  9. package/src/_internal/app-event-catalog.ts +40 -0
  10. package/src/_internal/app-event-schemas.ts +127 -0
  11. package/src/_internal/application-package.ts +40 -0
  12. package/src/_internal/built-in-app-ids.json +27 -0
  13. package/src/_internal/capabilities.ts +185 -12
  14. package/src/_internal/capability-origin.ts +4 -0
  15. package/src/_internal/capability-streams.ts +10 -5
  16. package/src/_internal/define-app.ts +159 -45
  17. package/src/_internal/installable-assets.ts +90 -0
  18. package/src/_internal/installable-head.ts +22 -0
  19. package/src/_internal/object-store-area.ts +9 -0
  20. package/src/_internal/object-store-config.ts +101 -0
  21. package/src/_internal/object-store-key.ts +14 -0
  22. package/src/_internal/page-responses.ts +34 -3
  23. package/src/_internal/process-identity.ts +7 -2
  24. package/src/_internal/registry-validation.ts +15 -1
  25. package/src/_internal/registry.ts +44 -1
  26. package/src/_internal/runtime-context.ts +9 -1
  27. package/src/_internal/runtime-watcher.ts +8 -0
  28. package/src/_internal/status-preserving-ssr.ts +12 -1
  29. package/src/access/AppAccessEditor.browser-fixture.ts +429 -0
  30. package/src/access/AppAccessEditor.browser-frame.tsx +68 -0
  31. package/src/access/AppAccessEditor.browser-harness.tsx +65 -0
  32. package/src/access/AppAccessEditor.tsx +380 -0
  33. package/src/access/PermissionEditor.browser-harness.tsx +22 -2
  34. package/src/access/PermissionEditor.tsx +386 -77
  35. package/src/access/PrincipalAvatar.tsx +63 -0
  36. package/src/access/PrincipalPicker.tsx +29 -8
  37. package/src/access/ResourceApiKeys.tsx +3 -4
  38. package/src/access/app-access-adapter.ts +51 -0
  39. package/src/access/app-access-messages.ts +172 -0
  40. package/src/access/messages.ts +14 -0
  41. package/src/access/ui.ts +4 -1
  42. package/src/account/Avatar.tsx +21 -3
  43. package/src/account/ProfilePictureDialog.tsx +699 -0
  44. package/src/account/profile-picture-messages.ts +269 -0
  45. package/src/account/ui.ts +8 -1
  46. package/src/ai/admin.ts +3 -0
  47. package/src/ai/app-frame.ts +12 -0
  48. package/src/ai/application-tasks.ts +101 -0
  49. package/src/ai/assistant-models.ts +126 -64
  50. package/src/ai/capabilities.ts +19 -4
  51. package/src/ai/capability-execution.ts +5 -2
  52. package/src/ai/chart-block.ts +63 -30
  53. package/src/ai/chat/chart-block.tsx +12 -3
  54. package/src/ai/chat/composer-adapter.ts +8 -7
  55. package/src/ai/chat/message-actions.tsx +1 -1
  56. package/src/ai/chat/message-utils.ts +2 -2
  57. package/src/ai/chat/presentation.tsx +22 -1
  58. package/src/ai/chat-quotas.ts +5 -4
  59. package/src/ai/chat-tasks.ts +2 -2
  60. package/src/ai/client/controller.ts +11 -10
  61. package/src/ai/client/projection.ts +3 -3
  62. package/src/ai/code-capability-routes.ts +1 -0
  63. package/src/ai/compaction.ts +3 -3
  64. package/src/ai/credentials.ts +13 -114
  65. package/src/ai/default-tool-contracts.ts +4 -3
  66. package/src/ai/dictation-runtime.ts +4 -4
  67. package/src/ai/enrich.ts +2 -2
  68. package/src/ai/executor.ts +38 -27
  69. package/src/ai/http.ts +17 -29
  70. package/src/ai/index.ts +11 -5
  71. package/src/ai/inference-calls.ts +6 -4
  72. package/src/ai/maintenance.ts +1 -1
  73. package/src/ai/memory-learning-runs.ts +5 -5
  74. package/src/ai/memory-learning.ts +3 -3
  75. package/src/ai/message-queue.ts +1 -2
  76. package/src/ai/migrate.ts +75 -42
  77. package/src/ai/model-access-migrate.ts +3 -17
  78. package/src/ai/model-access.ts +44 -85
  79. package/src/ai/model-card.tsx +134 -0
  80. package/src/ai/model-catalog.ts +359 -0
  81. package/src/ai/model-chooser-messages.ts +88 -0
  82. package/src/ai/model-chooser.tsx +577 -0
  83. package/src/ai/model-delete.ts +234 -0
  84. package/src/ai/model-fixture.ts +39 -0
  85. package/src/ai/model-icon.tsx +36 -0
  86. package/src/ai/model-overview.ts +53 -0
  87. package/src/ai/model-usage.ts +45 -0
  88. package/src/ai/openai-api-messages.ts +0 -4
  89. package/src/ai/openai-api-slots.ts +10 -2
  90. package/src/ai/openai-api.ts +83 -16
  91. package/src/ai/personal-agent.ts +1 -1
  92. package/src/ai/prefs.ts +12 -10
  93. package/src/ai/projects-routes.ts +2 -1
  94. package/src/ai/projects.ts +13 -12
  95. package/src/ai/protocol.ts +2 -2
  96. package/src/ai/provider-connections.ts +161 -0
  97. package/src/ai/provider-errors.ts +138 -0
  98. package/src/ai/provider-fetch.ts +4 -0
  99. package/src/ai/provider-retry.ts +1 -1
  100. package/src/ai/provider.ts +2 -1
  101. package/src/ai/quota-limits.ts +36 -0
  102. package/src/ai/quota-provider.ts +11 -2
  103. package/src/ai/quota-report.ts +7 -4
  104. package/src/ai/quotas-migrate.ts +1 -0
  105. package/src/ai/quotas.ts +19 -8
  106. package/src/ai/recent-resources.ts +6 -0
  107. package/src/ai/request-headers.ts +14 -87
  108. package/src/ai/resource-refs.ts +6 -2
  109. package/src/ai/routes.ts +17 -18
  110. package/src/ai/runtime.ts +50 -22
  111. package/src/ai/settings.ts +93 -580
  112. package/src/ai/store.ts +45 -23
  113. package/src/ai/stream.ts +1 -1
  114. package/src/ai/structured-runs.ts +2 -2
  115. package/src/ai/structured.ts +29 -46
  116. package/src/ai/task-contracts.ts +25 -5
  117. package/src/ai/task-execution.ts +15 -6
  118. package/src/ai/task-model.ts +9 -0
  119. package/src/ai/transcription.ts +37 -21
  120. package/src/ai/turn-failure.ts +24 -2
  121. package/src/ai/types.ts +50 -62
  122. package/src/ai/ui.tsx +13 -0
  123. package/src/ai/usage.ts +8 -8
  124. package/src/ai/validate.ts +8 -7
  125. package/src/ai/view-block.ts +2 -2
  126. package/src/api/accounts-entities.ts +40 -16
  127. package/src/api/admin-ai-quotas.ts +34 -56
  128. package/src/api/admin-ai-usage.ts +1 -1
  129. package/src/api/admin-ai.ts +173 -0
  130. package/src/api/admin-core-settings.ts +68 -244
  131. package/src/api/admin-directory-shares.ts +55 -0
  132. package/src/api/admin-identity.ts +81 -2
  133. package/src/api/app-access.ts +85 -0
  134. package/src/api/app-approval.ts +3 -1
  135. package/src/api/app-event-delivery.ts +51 -0
  136. package/src/api/app-events.ts +123 -0
  137. package/src/api/app-resource-access.ts +48 -0
  138. package/src/api/auth/account-mismatch.ts +24 -0
  139. package/src/api/auth.ts +26 -3
  140. package/src/api/capabilities.ts +127 -15
  141. package/src/api/capability-streams.ts +2 -5
  142. package/src/api/identity-child-invocation.ts +145 -0
  143. package/src/api/index.ts +11 -2
  144. package/src/api/mcp.ts +180 -5
  145. package/src/api/me.ts +38 -17
  146. package/src/api/pwa-phone.ts +87 -48
  147. package/src/api/pwa.ts +34 -8
  148. package/src/api/resource-links.ts +83 -6
  149. package/src/api/resource-resolve.ts +50 -0
  150. package/src/api/search/schemas.ts +3 -1
  151. package/src/api/search.ts +9 -7
  152. package/src/api/widgets.ts +1 -0
  153. package/src/app-pwa/authority.ts +75 -0
  154. package/src/app-pwa/contracts.ts +177 -0
  155. package/src/app-pwa/core-query-error.ts +20 -0
  156. package/src/app-pwa/index.ts +23 -0
  157. package/src/app-pwa/protocol.ts +27 -0
  158. package/src/app-pwa/schema.ts +59 -0
  159. package/src/app-pwa/service.ts +861 -0
  160. package/src/app-pwa/transaction.ts +338 -0
  161. package/src/app-pwa/transport-input.ts +57 -0
  162. package/src/app-pwa/transport.ts +235 -0
  163. package/src/approvals/messages.ts +2 -2
  164. package/src/browser/app-access-recovery.ts +44 -0
  165. package/src/browser/app-session.ts +14 -3
  166. package/src/browser/cloud-id.ts +86 -0
  167. package/src/browser/live-websocket.ts +5 -0
  168. package/src/browser/pwa-drafts.ts +57 -0
  169. package/src/browser/resource-clipboard.ts +4 -0
  170. package/src/browser/resource-links.ts +52 -0
  171. package/src/browser/resource-picker.tsx +3 -2
  172. package/src/capabilities/calendar-sources.ts +302 -0
  173. package/src/capabilities/executions.ts +10 -3
  174. package/src/capabilities/migrate.ts +4 -0
  175. package/src/capabilities/nested.ts +43 -0
  176. package/src/capabilities/server-invoke.ts +202 -0
  177. package/src/capabilities/server.ts +26 -187
  178. package/src/capabilities/testing.ts +5 -2
  179. package/src/cli/access.ts +6 -5
  180. package/src/cli/account.ts +57 -0
  181. package/src/cli/admin/agents.ts +66 -0
  182. package/src/cli/admin/ai-models.ts +199 -0
  183. package/src/cli/admin/ai-quotas.ts +6 -102
  184. package/src/cli/admin/ai-usage.ts +4 -4
  185. package/src/cli/admin/app-credentials.ts +5 -3
  186. package/src/cli/admin/index.ts +7 -3
  187. package/src/cli/app-access.ts +60 -0
  188. package/src/cli/apps.ts +89 -26
  189. package/src/cli/avatar.ts +64 -0
  190. package/src/cli/capabilities.ts +22 -1
  191. package/src/cli/events.ts +320 -0
  192. package/src/cli/index.ts +1 -1
  193. package/src/config/env.ts +40 -0
  194. package/src/contracts/app-access.ts +52 -5
  195. package/src/contracts/app-approval.ts +8 -1
  196. package/src/contracts/app-details.ts +89 -0
  197. package/src/contracts/app-events.ts +51 -0
  198. package/src/contracts/app.ts +24 -5
  199. package/src/contracts/calendar-source.ts +274 -0
  200. package/src/contracts/calendar-view.ts +35 -0
  201. package/src/contracts/capabilities.ts +164 -3
  202. package/src/contracts/geo-admin.ts +2 -0
  203. package/src/contracts/geo-route.ts +0 -11
  204. package/src/contracts/geo.ts +14 -2
  205. package/src/contracts/index.ts +6 -0
  206. package/src/contracts/installable.ts +40 -0
  207. package/src/contracts/notification-types.ts +36 -9
  208. package/src/contracts/profile.ts +32 -0
  209. package/src/contracts/pwa-builds.ts +14 -0
  210. package/src/contracts/pwa-location.ts +38 -0
  211. package/src/contracts/pwa.ts +24 -3
  212. package/src/contracts/registry.ts +8 -0
  213. package/src/contracts/resource-clipboard.ts +35 -0
  214. package/src/contracts/resource-pages.ts +140 -0
  215. package/src/contracts/resource-resolution.ts +13 -0
  216. package/src/contracts/shared.ts +4 -0
  217. package/src/contracts/user-notifications.ts +2 -0
  218. package/src/events/app-event-delivery.ts +100 -0
  219. package/src/events/app-events.testing.ts +65 -0
  220. package/src/events/app-events.ts +134 -0
  221. package/src/events/core-user-created.ts +24 -0
  222. package/src/events/index.ts +9 -0
  223. package/src/events/testing.ts +2 -0
  224. package/src/index.ts +2 -1
  225. package/src/server/api/respond.ts +8 -3
  226. package/src/server/index.ts +1 -0
  227. package/src/server/middleware/app-access.ts +7 -6
  228. package/src/server/middleware/auth.ts +168 -66
  229. package/src/server/middleware/index.ts +1 -0
  230. package/src/server/middleware/invocation.ts +4 -2
  231. package/src/server/middleware/workload.ts +3 -3
  232. package/src/server/oauth-scope.ts +10 -0
  233. package/src/server/public-storage.ts +106 -0
  234. package/src/server/services/access.ts +8 -0
  235. package/src/services/accounts/app.ts +35 -3
  236. package/src/services/accounts/avatar.ts +342 -35
  237. package/src/services/accounts/base-group.ts +4 -5
  238. package/src/services/accounts/directory-shares.ts +157 -0
  239. package/src/services/accounts/entities.ts +50 -6
  240. package/src/services/accounts/groups.ts +3 -1
  241. package/src/services/accounts/identities.ts +64 -12
  242. package/src/services/accounts/local-groups.ts +1 -1
  243. package/src/services/accounts/users.ts +23 -15
  244. package/src/services/app-access-management.ts +519 -0
  245. package/src/services/app-access.ts +25 -7
  246. package/src/services/app-approval.ts +15 -4
  247. package/src/services/app-details.ts +224 -0
  248. package/src/services/app-discovery.ts +2 -1
  249. package/src/services/app-event-shapes.ts +139 -0
  250. package/src/services/app-events/accept.ts +116 -0
  251. package/src/services/app-events/admin.ts +250 -0
  252. package/src/services/app-events/declarations.ts +187 -0
  253. package/src/services/app-events/deliver.ts +218 -0
  254. package/src/services/app-events.ts +92 -0
  255. package/src/services/config-conflict.ts +11 -0
  256. package/src/services/core-user-events.ts +1 -0
  257. package/src/services/freeipa-config.ts +9 -0
  258. package/src/services/geo/admin.ts +4 -1
  259. package/src/services/geo/ledger.ts +47 -14
  260. package/src/services/geo/route-chain.ts +40 -18
  261. package/src/services/geo/route-estimate.ts +19 -0
  262. package/src/services/geo/route-plan.ts +0 -2
  263. package/src/services/geo/route-service.ts +57 -39
  264. package/src/services/geo/service.ts +51 -25
  265. package/src/services/identity/core-origin.ts +12 -0
  266. package/src/services/identity/index.ts +2 -1
  267. package/src/services/identity/invocation-actor.ts +50 -2
  268. package/src/services/identity/invocation-authority.ts +38 -1
  269. package/src/services/identity/invocation-token.ts +255 -50
  270. package/src/services/identity/issue-invocation.ts +9 -1
  271. package/src/services/identity/models-only-auth.ts +13 -4
  272. package/src/services/identity/nested-invocation.ts +128 -0
  273. package/src/services/image-metadata.ts +65 -1
  274. package/src/services/index.ts +24 -1
  275. package/src/services/ipa/sync.ts +6 -2
  276. package/src/services/ipa/users.ts +4 -1
  277. package/src/services/mandates/policy.ts +19 -12
  278. package/src/services/notifications/browser.ts +4 -0
  279. package/src/services/notifications/catalog.ts +5 -2
  280. package/src/services/notifications/platform.ts +7 -2
  281. package/src/services/notifications/user.ts +18 -7
  282. package/src/services/oauth-tokens.ts +28 -2
  283. package/src/services/object-store.ts +217 -0
  284. package/src/services/providers/local/users.ts +5 -0
  285. package/src/services/pwa-devices.ts +147 -37
  286. package/src/services/service-account-credentials.ts +5 -2
  287. package/src/services/session/index.ts +14 -0
  288. package/src/services/settings/core-settings.ts +91 -47
  289. package/src/services/settings/index.ts +8 -0
  290. package/src/services/settings/versions.ts +26 -0
  291. package/src/shared/ai-models.ts +274 -0
  292. package/src/shared/ai-quotas.ts +2 -2
  293. package/src/shared/ai-usage.ts +2 -2
  294. package/src/shared/index.ts +2 -0
  295. package/src/shared-text/documents.ts +25 -8
  296. package/src/shared-text/log.ts +1 -1
  297. package/src/shared-text/text-diff.ts +75 -0
  298. package/src/ssr/AdminArea.tsx +131 -0
  299. package/src/ssr/AdminLayout.tsx +11 -3
  300. package/src/ssr/AdminSidebar.tsx +7 -7
  301. package/src/ssr/AppAccessDenied.tsx +114 -0
  302. package/src/ssr/AppBadge.tsx +14 -0
  303. package/src/ssr/AppLaunchpadPanel.tsx +1 -1
  304. package/src/ssr/AppPwaRuntime.island.tsx +82 -0
  305. package/src/ssr/CloudShell.island.tsx +25 -4
  306. package/src/ssr/Layout.tsx +15 -7
  307. package/src/ssr/PwaLayout.tsx +73 -8
  308. package/src/ssr/PwaPartCount.island.tsx +23 -0
  309. package/src/ssr/PwaRuntime.island.tsx +85 -6
  310. package/src/ssr/SettingsSaveOutcome.tsx +58 -0
  311. package/src/ssr/SettingsSaveStatus.island.tsx +16 -0
  312. package/src/ssr/ShellRuntime.island.tsx +5 -0
  313. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
  314. package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +1 -1
  315. package/src/ssr/admin-active-link.ts +16 -0
  316. package/src/ssr/admin-navigation.ts +26 -20
  317. package/src/ssr/app-badges.ts +31 -7
  318. package/src/ssr/app-pwa-document.tsx +16 -0
  319. package/src/ssr/app-pwa-messages.ts +17 -0
  320. package/src/ssr/index.ts +8 -0
  321. package/src/ssr/layout-context.ts +1 -1
  322. package/src/ssr/page-trail-runtime.ts +3 -1
  323. package/src/ssr/platform-messages.ts +9 -1
  324. package/src/ssr/pwa-messages.ts +10 -2
  325. package/src/ssr/rail-navigation.ts +5 -1
  326. package/src/ssr/settings-draft.ts +153 -0
  327. package/src/ssr/shell-data.tsx +2 -0
  328. package/src/styles/global.css +1 -0
  329. package/src/styles/profile-picture.css +270 -0
  330. package/src/styles/utilities-navigation.css +6 -0
  331. package/src/workflows/ai/policy.ts +49 -0
  332. package/src/workflows/ai/runtime.ts +131 -21
  333. package/src/workflows/ai/store.ts +283 -35
  334. package/src/workflows/ai/types.ts +8 -2
  335. package/src/workflows/ai-actions.ts +148 -120
  336. package/src/workflows/authoring.ts +31 -0
  337. package/src/workflows/contracts.ts +1 -0
  338. package/src/workflows/definition.ts +2 -0
  339. package/src/workflows/editor.ts +15 -0
  340. package/src/workflows/index.ts +2 -1
  341. package/src/workflows/language/compiler.ts +34 -3
  342. package/src/workflows/runtime/executor.ts +132 -33
  343. package/src/workflows/runtime/index.ts +2 -0
  344. package/src/workflows/runtime/ports.ts +5 -1
  345. package/src/workflows/store/actions.ts +1 -1
  346. package/src/workflows/store/definitions.ts +5 -0
  347. package/src/workflows/store/runs.ts +5 -3
  348. package/src/workflows/store/worker.ts +7 -2
  349. package/src/ai/model-pricing.ts +0 -43
  350. package/src/ai/model-request-settings.ts +0 -98
  351. package/src/ai/settings-messages.ts +0 -129
  352. package/src/shared/ai-model-request-settings.ts +0 -21
@@ -0,0 +1,114 @@
1
+ import { AppWorkspace, ButtonLink, useLocale } from "@k2b/ui";
2
+ import { For, type JSX, Show } from "solid-js";
3
+ import { appAccessMessages } from "../access/app-access-messages";
4
+ import PrincipalAvatar from "../access/PrincipalAvatar";
5
+ import { principalLabel } from "../access/principal-label";
6
+ import type { AppMeta } from "../contracts/app";
7
+ import type { AccessEntry } from "../contracts/shared";
8
+ import { appAccentStyle } from "./app-appearance";
9
+ import Layout from "./Layout";
10
+ import type { LayoutContext } from "./layout-context";
11
+
12
+ export function AppAccessDeniedContent(props: {
13
+ app: Pick<AppMeta, "id" | "name" | "icon" | "access" | "appearance">;
14
+ managers: AccessEntry[];
15
+ canRepair?: boolean;
16
+ revoked?: boolean;
17
+ }) {
18
+ const locale = useLocale(),
19
+ t = () => appAccessMessages.resolve([locale()]).t;
20
+ const grants = () => props.app.access?.kind === "grants";
21
+ // A fixed rule names itself before the contact, so the reader knows why and whom to ask.
22
+ const rule = () => {
23
+ const access = props.app.access;
24
+ if (access?.kind === "noGuests") return t().deniedNoGuests({ name: props.app.name });
25
+ if (access?.kind !== "roles") return undefined;
26
+ return access.roles.length === 1 && access.roles[0] === "admin"
27
+ ? t().deniedAdmins({ name: props.app.name })
28
+ : t().deniedRoles({ name: props.app.name });
29
+ };
30
+ return (
31
+ <div class="mx-auto mt-8 w-full max-w-[460px] text-left sm:mt-[10vh]">
32
+ <span
33
+ class="app-accent-scope app-accent-text k2b-icon-tile mb-4 flex h-12 w-12 items-center justify-center rounded-xl text-[26px]"
34
+ style={appAccentStyle(props.app.appearance?.accent)}
35
+ aria-hidden="true"
36
+ >
37
+ <i class={props.app.icon} />
38
+ </span>
39
+ <h1 class="mb-2 text-[22px] font-semibold">{(props.revoked ? t().revokedTitle : t().deniedTitle)({ name: props.app.name })}</h1>
40
+ <p class="mb-4 text-[15px] text-secondary">
41
+ {props.revoked ? (
42
+ t().revokedDescription
43
+ ) : props.app.access?.kind === "resources" ? (
44
+ t().deniedResources
45
+ ) : grants() ? (
46
+ t().deniedGrants
47
+ ) : (
48
+ <>
49
+ <Show when={rule()}>{(sentence) => <>{sentence()} </>}</Show>
50
+ {t().deniedFixed}
51
+ </>
52
+ )}
53
+ </p>
54
+ {/* A fixed rule's contact is the Cloud administration the sentence already names. */}
55
+ <Show when={props.revoked || grants()}>
56
+ <div class="mb-5 flex flex-col gap-2">
57
+ <For each={props.managers}>
58
+ {(entry) => (
59
+ <div class="flex items-center gap-2.5">
60
+ <PrincipalAvatar entry={entry} label={principalLabel(entry, locale())} />
61
+ <span>{principalLabel(entry, locale())}</span>
62
+ </div>
63
+ )}
64
+ </For>
65
+ <Show when={!props.managers.length}>
66
+ <p class="text-sm">{t().askAdministration}</p>
67
+ </Show>
68
+ </div>
69
+ </Show>
70
+ <ButtonLink href="/" variant="ghost" style={{ "min-height": "36px", "font-size": "15px" }}>
71
+ {t().home}
72
+ </ButtonLink>
73
+ <Show when={props.canRepair && grants()}>
74
+ <p class="mt-5 text-[13px] text-dimmed">
75
+ {t().adminHintBefore}
76
+ {/* An inline link in the action colour and weight, as the approved denial page shows it. */}
77
+ <a
78
+ class="focus-ui whitespace-nowrap rounded-sm font-medium text-[var(--k2b-action)] hover:underline"
79
+ href={`/admin/apps?app=${encodeURIComponent(props.app.id)}`}
80
+ >
81
+ {t().adminHintLink}
82
+ </a>
83
+ {t().adminHintAfter}
84
+ </p>
85
+ </Show>
86
+ </div>
87
+ );
88
+ }
89
+
90
+ /** The white work surface every application page has; the denial page sits on it like the app it replaces. */
91
+ export function AppAccessDeniedFrame(props: { children: JSX.Element }) {
92
+ return (
93
+ <AppWorkspace mobileSurface="flush" class="min-h-0 flex-1" resizable={false}>
94
+ <AppWorkspace.Content>
95
+ <AppWorkspace.Main class="p-4 sm:p-6">{props.children}</AppWorkspace.Main>
96
+ </AppWorkspace.Content>
97
+ </AppWorkspace>
98
+ );
99
+ }
100
+
101
+ export const renderAppAccessDenied =
102
+ (c: LayoutContext, app: Pick<AppMeta, "id" | "name" | "icon" | "access" | "appearance">, managers: AccessEntry[], canRepair: boolean) =>
103
+ () => (
104
+ <Layout c={c} title={[{ title: app.name }]} fullWidth fullPage>
105
+ <AppAccessDeniedFrame>
106
+ <AppAccessDeniedContent
107
+ app={app}
108
+ managers={managers}
109
+ canRepair={canRepair}
110
+ revoked={new URL(c.req.raw.url).searchParams.get("_cloud_access") === "revoked"}
111
+ />
112
+ </AppAccessDeniedFrame>
113
+ </Layout>
114
+ );
@@ -18,6 +18,20 @@ export const useAppBadgeDescription = (input: () => { endpoint?: string; appId?:
18
18
  * takes layout space, so appearing, growing to "99+", or clearing moves
19
19
  * nothing. Decorative: the link carries the description.
20
20
  */
21
+ /**
22
+ * The count on a phone bar item: the rail's count, laid over the tab icon's corner, and its description for screen
23
+ * readers after the item's label.
24
+ */
25
+ export function TabBarAppBadge(props: { endpoint?: string; appId?: string }) {
26
+ const description = useAppBadgeDescription(() => props);
27
+ return (
28
+ <Show when={description()}>
29
+ <AppBadge endpoint={props.endpoint} appId={props.appId} />
30
+ <span class="sr-only">, {description()}</span>
31
+ </Show>
32
+ );
33
+ }
34
+
21
35
  export function AppBadge(props: { endpoint?: string; appId?: string }) {
22
36
  const count = () => appBadgeCount(props);
23
37
  return (
@@ -42,7 +42,7 @@ const appIconPalette: readonly [AppIconPaletteEntry, ...AppIconPaletteEntry[]] =
42
42
  { from: "#7c3aed" },
43
43
  { from: "#d97706" },
44
44
  { from: "#e11d48" },
45
- { from: "#0891b2" },
45
+ { from: "#c026d3" },
46
46
  { from: "#52525b" },
47
47
  ];
48
48
 
@@ -0,0 +1,82 @@
1
+ import { Button, createInstallPrompt, InstallGuide, type InstallPrompt, NoticeCard, useLocale } from "@k2b/ui";
2
+ import { createSignal, onCleanup, onMount, Show } from "solid-js";
3
+ import type { AppInstallable } from "../contracts/installable";
4
+ import { appPwaMessages } from "./app-pwa-messages";
5
+
6
+ /** Only the App-PWA browser lifecycle. No Cloud mobile-app session, identity, or persistent state. */
7
+ export default function AppPwaRuntime(props: { app: AppInstallable }) {
8
+ const locale = useLocale();
9
+ const t = () => appPwaMessages.resolve([locale()]).t;
10
+ const [installation, setInstallation] = createSignal<InstallPrompt>();
11
+ const [installUrl, setInstallUrl] = createSignal("");
12
+ const [workerFailed, setWorkerFailed] = createSignal(false);
13
+ const [registering, setRegistering] = createSignal(false);
14
+ let disposed = false;
15
+
16
+ const register = async () => {
17
+ if (registering()) return;
18
+ setRegistering(true);
19
+ try {
20
+ if (!navigator.serviceWorker) throw new Error("Service workers unavailable");
21
+ await navigator.serviceWorker.register(`${props.app.path}sw.js`, { scope: props.app.path, updateViaCache: "none" });
22
+ if (!disposed) setWorkerFailed(false);
23
+ } catch {
24
+ if (!disposed) setWorkerFailed(true);
25
+ } finally {
26
+ if (!disposed) setRegistering(false);
27
+ }
28
+ };
29
+
30
+ onMount(() => {
31
+ setInstallation(createInstallPrompt());
32
+ setInstallUrl(new URL(props.app.path, location.origin).href);
33
+ const syncTheme = () => {
34
+ const theme = document.documentElement.classList.contains("dark") ? "dark" : "light";
35
+ document.querySelector<HTMLMetaElement>("meta[data-app-pwa-theme]")?.setAttribute("content", props.app.themeColor[theme]);
36
+ document
37
+ .querySelector<HTMLLinkElement>("link[data-app-pwa-manifest]")
38
+ ?.setAttribute("href", `${props.app.path}manifest.webmanifest?theme=${theme}`);
39
+ document
40
+ .querySelector<HTMLLinkElement>("link[data-app-pwa-apple]")
41
+ ?.setAttribute("href", `${props.app.path}apple-touch-icon-${theme}.png`);
42
+ };
43
+ syncTheme();
44
+ const observer = new MutationObserver(syncTheme);
45
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
46
+ void register();
47
+ onCleanup(() => {
48
+ disposed = true;
49
+ observer.disconnect();
50
+ });
51
+ });
52
+
53
+ return (
54
+ <div data-app-pwa-runtime aria-live="polite">
55
+ <Show when={installation()}>
56
+ {(install) => (
57
+ <Show
58
+ when={
59
+ !install().installed() &&
60
+ (install().platform.startsWith("apple-") ||
61
+ install().canPrompt() ||
62
+ install().busy() ||
63
+ install().requested() ||
64
+ install().failed())
65
+ }
66
+ >
67
+ <NoticeCard title={t().title({ name: props.app.name })}>
68
+ <InstallGuide appName={props.app.name} install={install()} url={installUrl()} />
69
+ </NoticeCard>
70
+ </Show>
71
+ )}
72
+ </Show>
73
+ <Show when={workerFailed()}>
74
+ <NoticeCard tone="warning" title={t().workerFailed}>
75
+ <Button variant="secondary" loading={registering()} onClick={() => void register()}>
76
+ {t().retry}
77
+ </Button>
78
+ </NoticeCard>
79
+ </Show>
80
+ </div>
81
+ );
82
+ }
@@ -1,6 +1,8 @@
1
1
  import { TabBar, useLocale } from "@k2b/ui";
2
2
  import { createEffect, createMemo, createSignal, onCleanup, onMount, Show } from "solid-js";
3
3
  import { isServer, Portal } from "solid-js/web";
4
+ import { TabBarAppBadge } from "./AppBadge";
5
+ import { appBadgeCount } from "./app-badges";
4
6
  import { AppLaunchpadButton, setAppLaunchpadContext } from "./app-launchpad";
5
7
  import GlobalSearchTrigger from "./GlobalSearchTrigger";
6
8
  import LayoutBreadcrumbs from "./LayoutBreadcrumbs";
@@ -35,8 +37,9 @@ export default function CloudShell(props: CloudShellProps) {
35
37
  /**
36
38
  * The phone bar's live copy, mounted with the rail's island so the shell adds no island instance. It renders nothing
37
39
  * while the server's bar is current, so mounting moves and refocuses nothing. When the saved rail preferences or the
38
- * URL (a fragment, Back and Forward, an in-page navigation) change what the bar shows, it renders the new bar into the
39
- * bar's row, and CSS hides the server's.
40
+ * URL (a fragment, Back and Forward, an in-page navigation) change what the bar shows, or an app in it has a count,
41
+ * it renders the new bar into the bar's row, and CSS hides the server's. Counts, like the rail's, exist only in the
42
+ * browser.
40
43
  */
41
44
  function BarNavigation(props: { currentPath: string }) {
42
45
  if (isServer) return null;
@@ -51,6 +54,12 @@ function BarNavigation(props: { currentPath: string }) {
51
54
  phoneBarItems(projectRailNavigation(data.apps, rail, locale()), path, { label: t().home, path: data.home ?? "/" });
52
55
  const served = JSON.stringify(itemsFor(initial, props.currentPath));
53
56
  const items = createMemo(() => itemsFor(settings(), currentPath()));
57
+ const counted = () => items().some((item) => item.counted && appBadgeCount(item.counted) > 0);
58
+ const live = createMemo(() =>
59
+ items().map((item) =>
60
+ item.counted ? { ...item, badge: <TabBarAppBadge endpoint={item.counted.endpoint} appId={item.counted.appId} /> } : item,
61
+ ),
62
+ );
54
63
  onMount(() => {
55
64
  const sync = () => setSettings(readRailContext()?.settings ?? initial);
56
65
  const syncLocation = () => setCurrentPath(`${window.location.pathname}${window.location.search}${window.location.hash}`);
@@ -76,10 +85,22 @@ function BarNavigation(props: { currentPath: string }) {
76
85
  });
77
86
  const row = document.querySelector<HTMLElement>(".cloud-tab-bar__row");
78
87
  if (!row) return null;
88
+ // Swapping bars keeps a focused link: the bar that takes over focuses the same destination, once CSS shows it.
89
+ const [shown, setShown] = createSignal(false);
90
+ const focusIn = (selector: string, tab: string) =>
91
+ [...row.querySelectorAll<HTMLElement>(`${selector} a[data-tab]`)].find((link) => link.dataset.tab === tab)?.focus();
92
+ createEffect(() => {
93
+ const next = counted() || JSON.stringify(items()) !== served;
94
+ if (next === shown()) return;
95
+ const focused = document.activeElement;
96
+ const tab = focused instanceof HTMLElement && row.contains(focused) ? focused.dataset.tab : undefined;
97
+ setShown(next);
98
+ if (tab) queueMicrotask(() => focusIn(next ? ".cloud-tab-bar__live" : ".k2b-tab-bar:not(.cloud-tab-bar__live)", tab));
99
+ });
79
100
  return (
80
- <Show when={JSON.stringify(items()) !== served}>
101
+ <Show when={shown()}>
81
102
  <Portal mount={row}>
82
- <TabBar variant="floating" class="cloud-tab-bar__live" label={t().apps} items={items()} />
103
+ <TabBar variant="floating" class="cloud-tab-bar__live" label={t().apps} items={live()} />
83
104
  </Portal>
84
105
  </Show>
85
106
  );
@@ -14,6 +14,7 @@ import { isAppSessionActor } from "../server/actor";
14
14
  import { getLocale } from "../server/locale";
15
15
  import { getDateConfig } from "../server/time";
16
16
  import { dates, resolveHomePath } from "../shared";
17
+ import { publicCloudOrigin } from "../shared/app-url";
17
18
  import { readThemePreferenceFromCookieHeader } from "../shared/theme";
18
19
  import AnnouncementBanners from "./AnnouncementBanners";
19
20
  import { appAppearanceStyle, resolveCurrentApp } from "./app-appearance";
@@ -258,7 +259,9 @@ export default function Layout(props: LayoutProps) {
258
259
  const panel: ShellData["panel"] = providers.length
259
260
  ? { providers, provider: panelState.provider ?? providers[0]?.id, context: pageContext, consent: c.get("panelConsent") }
260
261
  : undefined;
262
+ const cloudOrigin = settings?.app?.url ? publicCloudOrigin(settings.app.url) : undefined;
261
263
  const shellData: ShellData = {
264
+ ...(cloudOrigin ? { cloudOrigin } : {}),
262
265
  ...(panel ? { panel } : {}),
263
266
  platform: keyboardPlatform(c.req.raw.headers.get("User-Agent") ?? ""),
264
267
  ...(viewHelp.length ? { help: viewHelp } : {}),
@@ -294,6 +297,7 @@ export default function Layout(props: LayoutProps) {
294
297
  apps: allApps,
295
298
  legalLinks,
296
299
  searchLinks,
300
+ cloudOrigin: shellData.cloudOrigin,
297
301
  home: shellData.home,
298
302
  profile: shellData.profile,
299
303
  announcements: user ? (announcements?.latestAnnouncementVersion ?? null) : null,
@@ -314,18 +318,21 @@ export default function Layout(props: LayoutProps) {
314
318
  data-layout-full-page="true"
315
319
  data-workspace-sidebar-collapsed={workspaceLayout?.sidebarCollapsed ? "true" : undefined}
316
320
  >
317
- <ShellRuntime pageTrail help={runtimeHelp} panel={panel ? true : undefined} />
321
+ <ShellRuntime
322
+ {...(currentApp?.access ? { appAccess: { id: currentApp.id, routes: currentApp.routes } } : {})}
323
+ pageTrail
324
+ help={runtimeHelp}
325
+ panel={panel ? true : undefined}
326
+ />
318
327
  <PageTrailProvider trail={pageTrail}>
319
328
  <main class="min-h-0 min-w-0 flex-1" data-cloud-app-area="">
320
329
  <AppWorkspace.LayoutStateProvider state={workspaceLayout}>{props.children}</AppWorkspace.LayoutStateProvider>
321
330
  </main>
322
331
  </PageTrailProvider>
323
- {/* Focus pages carry shell data only for their panel providers. */}
324
- {panel && (
325
- <ShellDataProvider data={{ panel, platform: shellData.platform, theme, apps: [], legalLinks: [], searchLinks: {} }}>
326
- <PanelSlot panel={panel} state={{ ...panelState, open: false }} platform={shellData.platform} />
327
- </ShellDataProvider>
328
- )}
332
+ {/* Focus pages also expose the canonical origin to their islands. */}
333
+ <ShellDataProvider data={{ cloudOrigin, panel, platform: shellData.platform, theme, apps: [], legalLinks: [], searchLinks: {} }}>
334
+ {panel && <PanelSlot panel={panel} state={{ ...panelState, open: false }} platform={shellData.platform} />}
335
+ </ShellDataProvider>
329
336
  <div data-print-fallback="">
330
337
  <p>{t.printFallbackTitle}</p>
331
338
  <p>{props.printHint ?? t.printFallbackHint}</p>
@@ -355,6 +362,7 @@ export default function Layout(props: LayoutProps) {
355
362
  data-workspace-sidebar-collapsed={workspaceLayout?.sidebarCollapsed ? "true" : undefined}
356
363
  >
357
364
  <ShellRuntime
365
+ {...(currentApp?.access ? { appAccess: { id: currentApp.id, routes: currentApp.routes } } : {})}
358
366
  panel={panel ? true : undefined}
359
367
  workspace={{ appId: currentApp?.id }}
360
368
  pageTrail
@@ -1,9 +1,13 @@
1
1
  import { LocaleProvider, MobileShell, TabBar, type TabBarItem } from "@k2b/ui";
2
2
  import type { JSX } from "solid-js";
3
- import { PWA_SCOPE } from "../contracts/pwa";
3
+ import { buildMetadata } from "../_internal/build-metadata";
4
+ import { registeredPwaBuilds } from "../_internal/runtime-watcher";
5
+ import { PWA_COOKIES, PWA_LIMITS, PWA_SCOPE } from "../contracts/pwa";
6
+ import { pwaBuild } from "../contracts/pwa-builds";
4
7
  import { getLocale } from "../server/locale";
5
8
  import { visiblePwaParts } from "./app-navigation";
6
9
  import type { LayoutContext } from "./layout-context";
10
+ import PwaPartCount from "./PwaPartCount.island";
7
11
  import PwaRuntime from "./PwaRuntime.island";
8
12
  import { pwaMessages } from "./pwa-messages";
9
13
  import { getDisplayRuntimeContext } from "./runtime";
@@ -17,17 +21,28 @@ export type PwaLayoutProps = {
17
21
  c: LayoutContext & { header(name: string, value: string): void };
18
22
  /** Header heading and document title. */
19
23
  title: string;
20
- /** Up-navigation inside the part, for example from a detail back to its list. */
21
- back?: { href: string; label: string };
24
+ /** The heading beside the sidebar from 48rem, which already names the installation; `title` by default. */
25
+ sidebarTitle?: string;
26
+ /**
27
+ * Up-navigation inside the part, for example from a detail back to its list. `withSidebar: false` hides it beside
28
+ * the sidebar, which already leads there.
29
+ */
30
+ back?: { href: string; label: string; withSidebar?: boolean };
22
31
  /** At most two icon buttons at the header end. */
23
32
  actions?: JSX.Element;
33
+ /**
34
+ * The page brings its own heads and scroll areas, such as `AppWorkspace.Main` levels; the frame keeps only its
35
+ * navigation. `title` still names the document.
36
+ */
37
+ bare?: boolean;
24
38
  children: JSX.Element;
25
39
  };
26
40
 
27
41
  /**
28
42
  * The frame of every page in the installable mobile app (preview): the app's document head, a phone shell with a
29
- * tab bar of Start and the first parts, and the browser runtime (service worker, offline notice, session keepalive).
30
- * Pages render it below `/pwa/` with `ssr.pwaAccess`.
43
+ * tab bar of Start and the first parts, from 48rem a sidebar with Start, every part and Settings instead, and the
44
+ * browser runtime (service worker, offline notice, session keepalive). Pages render it below `/pwa/` with
45
+ * `ssr.pwaAccess`.
31
46
  */
32
47
  export default function PwaLayout(props: PwaLayoutProps) {
33
48
  const { c } = props;
@@ -44,25 +59,75 @@ export default function PwaLayout(props: PwaLayoutProps) {
44
59
 
45
60
  const parts = visiblePwaParts(getDisplayRuntimeContext(c).apps, user, locale);
46
61
  const path = new URL(c.req.raw.url).pathname;
62
+ const owner = page.pwaOwner;
63
+ const ownBuild = pwaBuild(buildMetadata);
64
+ const coreBuild = registeredPwaBuilds().core;
65
+ const builds = { ...(owner && ownBuild ? { [owner]: ownBuild } : {}), ...(coreBuild ? { core: coreBuild } : {}) };
66
+ // A part's badge calls for attention in the sidebar and on its tab; without one, its summary's count stays quiet in
67
+ // the sidebar. One watcher reads every route of the page.
68
+ const countRoutes = [...new Set(parts.flatMap((part) => [part.pwa.badge, part.pwa.summary].filter((path) => path !== undefined)))];
69
+ const badge = (part: (typeof parts)[number]) =>
70
+ part.pwa.badge ? <PwaPartCount endpoint={part.pwa.badge} endpoints={countRoutes} attention /> : undefined;
47
71
  const tabs = parts.slice(0, TAB_PARTS);
48
72
  const currentTab = tabs.find((part) => path === part.pwa.href || path.startsWith(`${part.pwa.href}/`));
49
73
  // Start is titled with the installation's name; a part's first page carries the part's name.
50
74
  const items: TabBarItem[] = [
51
75
  { id: "start", label: t.start, title: cloud, icon: "ti ti-home", href: PWA_SCOPE, current: !currentTab },
52
- ...tabs.map((part) => ({ id: part.id, label: part.name, icon: part.icon, href: part.pwa.href, current: part === currentTab })),
76
+ ...tabs.map((part) => ({
77
+ id: part.id,
78
+ label: part.name,
79
+ icon: part.icon,
80
+ href: part.pwa.href,
81
+ current: part === currentTab,
82
+ badge: badge(part),
83
+ })),
53
84
  ];
85
+ // The sidebar names the installation itself, so Start is titled Start there; Settings is a destination of its own.
86
+ const settingsHref = `${PWA_SCOPE}settings`;
87
+ const currentPart = parts.find((part) => path === part.pwa.href || path.startsWith(`${part.pwa.href}/`));
88
+ const sidebar = (
89
+ <MobileShell.Sidebar
90
+ label={t.tabs}
91
+ title={cloud}
92
+ logo="/branding/logo"
93
+ items={[{ id: "start", label: t.start, icon: "ti ti-home", href: PWA_SCOPE, current: path === PWA_SCOPE }]}
94
+ sections={[
95
+ {
96
+ title: t.apps,
97
+ items: parts.map((part) => ({
98
+ id: part.id,
99
+ label: part.name,
100
+ icon: part.icon,
101
+ href: part.pwa.href,
102
+ current: part === currentPart,
103
+ badge: badge(part),
104
+ count: part.pwa.summary ? <PwaPartCount endpoint={part.pwa.summary} endpoints={countRoutes} /> : undefined,
105
+ })),
106
+ },
107
+ ]}
108
+ footer={[{ id: "settings", label: t.settings, icon: "ti ti-settings", href: settingsHref, current: path === settingsHref }]}
109
+ />
110
+ );
54
111
 
55
112
  return (
56
113
  <LocaleProvider locale={locale}>
57
114
  {/* Pages without a person show no times. Their pairing link lives only in memory, and a reload would lose it. */}
58
115
  <TimezoneCookie reload={!!user} />
59
116
  <MobileShell
60
- header={<MobileShell.Header title={props.title} back={props.back} actions={props.actions} />}
117
+ header={<MobileShell.Header title={props.title} sidebarTitle={props.sidebarTitle} back={props.back} actions={props.actions} />}
118
+ scroll={!props.bare}
61
119
  footer={user && parts.length > 0 ? <TabBar label={t.tabs} items={items} /> : undefined}
120
+ sidebar={user ? sidebar : undefined}
62
121
  >
63
122
  {props.children}
64
123
  </MobileShell>
65
- <PwaRuntime keepalive={!!user} cloud={cloud} />
124
+ <PwaRuntime
125
+ keepalive={!!user}
126
+ accountId={user?.id}
127
+ builds={builds}
128
+ cloud={cloud}
129
+ rememberAddress={page.pwaAddress ? { cookie: PWA_COOKIES.lastAddress, maxAge: PWA_LIMITS.idleDays * 86_400 } : undefined}
130
+ />
66
131
  </LocaleProvider>
67
132
  );
68
133
  }
@@ -0,0 +1,23 @@
1
+ import { AppWorkspace } from "@k2b/ui";
2
+ import { onCleanup, onMount, Show } from "solid-js";
3
+ import { appBadgeCount, watchAppBadges } from "./app-badges";
4
+
5
+ export type PwaPartCountProps = {
6
+ /** This part's `pwa.badge` or, without one, its `pwa.summary` route. */
7
+ endpoint: string;
8
+ /** Every count route of the page; the first count on a page reads them all, once a minute like the web's. */
9
+ endpoints: string[];
10
+ /** A badge calls for attention, such as unread messages; a summary's count stays quiet beside the label. */
11
+ attention?: boolean;
12
+ };
13
+
14
+ /** The count of a part in the Cloud app's sidebar and tab bar; nothing at zero. */
15
+ export default function PwaPartCount(props: PwaPartCountProps) {
16
+ onMount(() => onCleanup(watchAppBadges(props.endpoints)));
17
+ const count = () => appBadgeCount({ endpoint: props.endpoint });
18
+ return (
19
+ <Show when={props.attention} fallback={<Show when={count() > 0}>{count() > 99 ? "99+" : count()}</Show>}>
20
+ <AppWorkspace.SidebarItemStatus unread={count()} />
21
+ </Show>
22
+ );
23
+ }
@@ -1,6 +1,9 @@
1
1
  import { observeMobileShell, syncThemeColor, type ToastHandle, toast, useLocale } from "@k2b/ui";
2
2
  import { onCleanup, onMount } from "solid-js";
3
3
  import { renewAppSession } from "../browser/app-session";
4
+ import { pwaDrafts } from "../browser/pwa-drafts";
5
+ import { reloadOnce } from "../browser/reload";
6
+ import { rememberedPwaAddress } from "../contracts/pwa-location";
4
7
  import { PWA_SCOPE, PWA_SERVICE_WORKER_PATH } from "../contracts/pwa-paths";
5
8
  import { pwaMessages } from "./pwa-messages";
6
9
 
@@ -15,6 +18,11 @@ export type PwaRuntimeProps = {
15
18
  keepalive: boolean;
16
19
  /** The installation name, for the account notice. */
17
20
  cloud: string;
21
+ accountId?: string;
22
+ /** Builds used by this document: its owner and Core shared assets. */
23
+ builds?: Record<string, string>;
24
+ /** Only successful authenticated documents remember browser-history navigation. */
25
+ rememberAddress?: { cookie: string; maxAge: number };
18
26
  };
19
27
 
20
28
  const firstPageOfSession = (): boolean => {
@@ -75,21 +83,92 @@ export default function PwaRuntime(props: PwaRuntimeProps) {
75
83
 
76
84
  if (!props.keepalive) return;
77
85
 
86
+ if (props.rememberAddress) {
87
+ const { cookie, maxAge } = props.rememberAddress;
88
+ const remember = () => {
89
+ const address = rememberedPwaAddress(location.pathname + location.search);
90
+ if (address)
91
+ document.cookie = `${cookie}=${encodeURIComponent(address)}; Path=${PWA_SCOPE}; Secure; SameSite=Lax; Max-Age=${maxAge}`;
92
+ };
93
+ // The place counts when a page arrives and when the app leaves the foreground, the moment the system may end
94
+ // it. Selections that parts keep in the address without a page load, such as a Spaces task, are covered this
95
+ // way too. A document that is being left writes nothing: the next page, or a sign-out that deleted the
96
+ // cookie, has already answered, and the outgoing page fires "pagehide" before it turns hidden.
97
+ let leaving = false;
98
+ const hidden = () => {
99
+ if (document.visibilityState === "hidden" && !leaving) remember();
100
+ };
101
+ const leave = () => {
102
+ leaving = true;
103
+ };
104
+ const back = (event: PageTransitionEvent) => {
105
+ if (!event.persisted) return;
106
+ leaving = false;
107
+ remember();
108
+ };
109
+ remember();
110
+ document.addEventListener("visibilitychange", hidden);
111
+ window.addEventListener("pagehide", leave);
112
+ window.addEventListener("pageshow", back);
113
+ cleanups.push(() => {
114
+ document.removeEventListener("visibilitychange", hidden);
115
+ window.removeEventListener("pagehide", leave);
116
+ window.removeEventListener("pageshow", back);
117
+ });
118
+ }
119
+
78
120
  let otherAccountShown = false;
79
121
  let disposed = false;
80
122
  cleanups.push(() => {
81
123
  disposed = true;
82
124
  });
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" });
125
+ let updateToast: ToastHandle | undefined;
126
+ let reloading = false;
127
+ cleanups.push(() => updateToast?.dismiss());
128
+ // Every control a person types into, numbers, dates and times included.
129
+ const textField =
130
+ 'textarea, input:not([type="checkbox"], [type="radio"], [type="button"], [type="submit"], [type="reset"], [type="hidden"], [type="file"], [type="range"], [type="color"], [type="image"]), [contenteditable]:not([contenteditable="false"])';
131
+ const edited = new WeakSet<Element>();
132
+ const input = (event: Event) => {
133
+ const field = event.target instanceof Element ? event.target.closest(textField) : null;
134
+ if (field) edited.add(field);
135
+ };
136
+ document.addEventListener("input", input, true);
137
+ cleanups.push(() => document.removeEventListener("input", input, true));
138
+ const unsafe = () =>
139
+ document.activeElement?.matches(textField) ||
140
+ !!(props.accountId && pwaDrafts.has(props.accountId)) ||
141
+ !!document.querySelector('dialog[open]:not([aria-modal="false"])') ||
142
+ [...document.querySelectorAll(textField)].some(
143
+ (field) =>
144
+ edited.has(field) &&
145
+ (!(field instanceof window.HTMLInputElement || field instanceof window.HTMLTextAreaElement) ||
146
+ field.value !== field.defaultValue),
147
+ );
148
+ const renew = async (foreground = false) => {
149
+ const { ok, otherAccount, builds } = await renewAppSession();
150
+ if (disposed) return;
151
+ if (otherAccount && !otherAccountShown) {
152
+ otherAccountShown = true;
153
+ toast(t().otherAccount({ cloud: props.cloud, name: otherAccount.name }), { duration: 0, iconClass: "ti ti-alert-triangle" });
154
+ }
155
+ const changed = ok && Object.entries(props.builds ?? {}).some(([id, build]) => builds?.[id] !== undefined && builds[id] !== build);
156
+ if (!changed || reloading || document.visibilityState !== "visible") return;
157
+ // Only the foreground transition is an automatic reload opportunity. Re-check input after the request.
158
+ if (foreground && !unsafe() && reloadOnce("pwa-update")) {
159
+ reloading = true;
160
+ return;
161
+ }
162
+ updateToast ??= toast(t().newVersion({ cloud: props.cloud }), {
163
+ duration: 0,
164
+ iconClass: "ti ti-refresh",
165
+ action: { label: t().reload, onClick: () => location.reload() },
166
+ });
88
167
  };
89
168
 
90
169
  if (firstPageOfSession()) void renew();
91
170
  const visible = () => {
92
- if (document.visibilityState === "visible") void renew();
171
+ if (document.visibilityState === "visible") void renew(true);
93
172
  };
94
173
  document.addEventListener("visibilitychange", visible);
95
174
  const timer = setInterval(() => {