@hearthlog/native 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/site.ts ADDED
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Shared by the apple:* scripts: what makes one site's native app different from another's.
3
+ *
4
+ * Everything per-site comes from the tenant configuration (`tenants.json` or
5
+ * `tenants/<id>/tenant.json`, plus `deployment.json` for a site in its own account:
6
+ * title, accent, hostname, Access team) and the tenant's resign.local.json (your device,
7
+ * team and bundle prefix). Nothing here is deployed or sent anywhere; the apps open the
8
+ * already-deployed site. The Swift sources ship in this package (`swift/`); everything
9
+ * generated goes to the tenant's `.apple-build/`.
10
+ */
11
+
12
+ import { spawnSync } from "node:child_process";
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
14
+ import path from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ import { brandColor, brandTileSvg } from "@hearthlog/contracts/tenant/brand";
18
+ import { themeVariablesFor } from "@hearthlog/contracts/tenant/theme";
19
+ import { infrastructureFor, loadTenantConfig, REPOSITORY_ROOT } from "@hearthlog/tooling/tenants/tenant-config.js";
20
+
21
+ /** The Swift sources of the apps: `Shared`, `macOS`, `iOS`, and `Tests`. */
22
+ export const SWIFT_DIRECTORY = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../swift");
23
+ /** Everything generated for the tenant: icons, the Mac apps, the iOS project, DerivedData. */
24
+ export const BUILD_ROOT = path.join(REPOSITORY_ROOT, ".apple-build");
25
+ /** The generated iOS project: `Stor.xcodeproj` and `Generated/` (Info.plist, build settings, icon). */
26
+ export const PROJECT_ROOT = path.join(BUILD_ROOT, "project");
27
+ export const SETTINGS_FILE = path.join(REPOSITORY_ROOT, "resign.local.json");
28
+
29
+ const MARK_ON_TILE = "#fbf9fa";
30
+
31
+ export class AppleError extends Error {}
32
+
33
+ export interface AppleSettings {
34
+ /** iPhone name as shown in Finder or Xcode, or its UDID (only the iPhone scripts need it). */
35
+ device: string;
36
+ project: string;
37
+ scheme: string;
38
+ /** Site ids to keep installed; at most three fit a free Apple ID. */
39
+ sites: string[];
40
+ renewWithinDays: number;
41
+ /** Apple team id; found from your Apple Development certificate when left out. */
42
+ team: string;
43
+ /** Reverse-DNS prefix; each app is `<prefix>.<site id>`. Must be unique to you for a free team. */
44
+ bundlePrefix: string;
45
+ }
46
+
47
+ export interface AppleSite {
48
+ id: string;
49
+ title: string;
50
+ /** The site's origin (its workers.dev address or hostname), no trailing slash. */
51
+ origin: string;
52
+ host: string;
53
+ /** Cloudflare Access team host; its login opens in the sign-in window. */
54
+ accessHost: string;
55
+ accent: string;
56
+ /** The brand tile colour of the icons. */
57
+ tile: string;
58
+ bundleId: string;
59
+ version: string;
60
+ build: string;
61
+ }
62
+
63
+ export function run(command: string, args: string[], options: { input?: string; quiet?: boolean; cwd?: string } = {}): string {
64
+ const result = spawnSync(command, args, {
65
+ cwd: options.cwd ?? REPOSITORY_ROOT,
66
+ encoding: "utf8",
67
+ input: options.input,
68
+ maxBuffer: 64 * 1024 * 1024,
69
+ stdio: options.quiet ? ["pipe", "pipe", "pipe"] : ["pipe", "pipe", "inherit"],
70
+ });
71
+ if (result.error) throw new AppleError(`${command} could not start: ${result.error.message}`);
72
+ if (result.status !== 0) {
73
+ const detail = options.quiet ? `\n${result.stderr.trim()}` : "";
74
+ throw new AppleError(`${command} ${args[0] ?? ""} exited with ${result.status}${detail}`);
75
+ }
76
+ return result.stdout;
77
+ }
78
+
79
+ export function loadAppleSettings(options: { requireDevice: boolean }): AppleSettings {
80
+ let raw: Partial<AppleSettings> = {};
81
+ if (existsSync(SETTINGS_FILE)) {
82
+ raw = JSON.parse(readFileSync(SETTINGS_FILE, "utf8")) as Partial<AppleSettings>;
83
+ } else if (options.requireDevice) {
84
+ throw new AppleError(`Missing ${path.relative(REPOSITORY_ROOT, SETTINGS_FILE)}; copy resign.example.json from @hearthlog/native and set your device.`);
85
+ }
86
+ const settings: AppleSettings = {
87
+ device: raw.device ?? "",
88
+ project: raw.project ?? path.relative(REPOSITORY_ROOT, path.join(PROJECT_ROOT, "Stor.xcodeproj")),
89
+ scheme: raw.scheme ?? "Stor iOS",
90
+ sites: raw.sites ?? [],
91
+ renewWithinDays: raw.renewWithinDays ?? 2,
92
+ team: raw.team ?? "",
93
+ bundlePrefix: raw.bundlePrefix ?? "app.stor",
94
+ };
95
+ if (options.requireDevice) {
96
+ if (!settings.device) throw new AppleError("Set \"device\" to the iPhone's name or UDID.");
97
+ if (settings.sites.length === 0) throw new AppleError("List at least one site id in \"sites\".");
98
+ if (settings.sites.length > 3) throw new AppleError("A free Apple ID can keep at most three apps on a device.");
99
+ }
100
+ if (!/^[a-z0-9]+(\.[a-z0-9-]+)+$/i.test(settings.bundlePrefix)) {
101
+ throw new AppleError(`bundlePrefix "${settings.bundlePrefix}" must be reverse-DNS, like app.stor`);
102
+ }
103
+ return settings;
104
+ }
105
+
106
+ export async function loadAppleSite(id: string, settings: AppleSettings): Promise<AppleSite> {
107
+ const { infrastructure, sites } = await loadTenantConfig();
108
+ const site = sites.find((candidate) => candidate.id === id);
109
+ if (!site) throw new AppleError(`Unknown site id "${id}" (known: ${sites.map((candidate) => candidate.id).join(", ")})`);
110
+ const origin = site.hostname.replace(/\/+$/, "");
111
+ const accent = site.theme.accent;
112
+ return {
113
+ id: site.id,
114
+ title: site.title,
115
+ origin,
116
+ host: new URL(origin).host,
117
+ // A site in its own account trusts that account's Access team.
118
+ accessHost: new URL(infrastructureFor(site, infrastructure).accessTeamDomain).host,
119
+ accent: accent ?? "",
120
+ tile: brandColor(accent, themeVariablesFor(accent, "light")["--stor-action"]),
121
+ bundleId: `${settings.bundlePrefix}.${site.id}`,
122
+ version: (JSON.parse(readFileSync(path.join(REPOSITORY_ROOT, "package.json"), "utf8")) as { version: string }).version,
123
+ build: buildNumber(),
124
+ };
125
+ }
126
+
127
+ /** Commits on this branch: strictly rising, which is all an installed build number needs. */
128
+ function buildNumber(): string {
129
+ try {
130
+ return run("git", ["rev-list", "--count", "HEAD"], { quiet: true }).trim() || "1";
131
+ } catch {
132
+ return "1";
133
+ }
134
+ }
135
+
136
+ // MARK: Info.plist
137
+
138
+ type PlistValue = string | number | boolean | PlistValue[] | { [key: string]: PlistValue };
139
+
140
+ function plistXml(value: PlistValue, indent = ""): string {
141
+ const escape = (text: string) => text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
142
+ if (typeof value === "string") return `${indent}<string>${escape(value)}</string>`;
143
+ if (typeof value === "number") return `${indent}<integer>${value}</integer>`;
144
+ if (typeof value === "boolean") return `${indent}<${value}/>`;
145
+ if (Array.isArray(value)) {
146
+ if (value.length === 0) return `${indent}<array/>`;
147
+ return `${indent}<array>\n${value.map((item) => plistXml(item, `${indent} `)).join("\n")}\n${indent}</array>`;
148
+ }
149
+ const entries = Object.entries(value);
150
+ if (entries.length === 0) return `${indent}<dict/>`;
151
+ return `${indent}<dict>\n${entries
152
+ .map(([key, item]) => `${indent} <key>${escape(key)}</key>\n${plistXml(item, `${indent} `)}`)
153
+ .join("\n")}\n${indent}</dict>`;
154
+ }
155
+
156
+ export function plistDocument(entries: { [key: string]: PlistValue }): string {
157
+ return `<?xml version="1.0" encoding="UTF-8"?>
158
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
159
+ <plist version="1.0">
160
+ ${plistXml(entries)}
161
+ </plist>
162
+ `;
163
+ }
164
+
165
+ function storSite(site: AppleSite): PlistValue {
166
+ return { id: site.id, title: site.title, origin: site.origin, accessHost: site.accessHost };
167
+ }
168
+
169
+ const CAMERA_USAGE = (site: AppleSite) => `${site.title} uses the camera to scan food barcodes.`;
170
+
171
+ export function macInfoPlist(site: AppleSite, options: { executable: string; development: boolean; inspectable: boolean }): string {
172
+ return plistDocument({
173
+ CFBundleDevelopmentRegion: "en",
174
+ CFBundleExecutable: options.executable,
175
+ CFBundleIconFile: "AppIcon",
176
+ CFBundleIdentifier: site.bundleId,
177
+ CFBundleInfoDictionaryVersion: "6.0",
178
+ CFBundleName: site.title,
179
+ CFBundleDisplayName: site.title,
180
+ CFBundlePackageType: "APPL",
181
+ CFBundleShortVersionString: site.version,
182
+ CFBundleVersion: site.build,
183
+ LSApplicationCategoryType: "public.app-category.productivity",
184
+ LSMinimumSystemVersion: "14.0",
185
+ NSCameraUsageDescription: CAMERA_USAGE(site),
186
+ NSHighResolutionCapable: true,
187
+ NSPrincipalClass: "NSApplication",
188
+ StorSite: storSite(site),
189
+ StorInspectable: options.inspectable,
190
+ ...(options.development ? { StorDevelopmentOverrides: true } : {}),
191
+ });
192
+ }
193
+
194
+ /** Build settings come from Site.xcconfig; this file carries what is not a single setting. */
195
+ export function iosInfoPlist(site: AppleSite): string {
196
+ return plistDocument({
197
+ CFBundleDevelopmentRegion: "en",
198
+ CFBundleDisplayName: site.title,
199
+ CFBundleExecutable: "$(EXECUTABLE_NAME)",
200
+ CFBundleIdentifier: "$(PRODUCT_BUNDLE_IDENTIFIER)",
201
+ CFBundleInfoDictionaryVersion: "6.0",
202
+ CFBundleName: "$(PRODUCT_NAME)",
203
+ CFBundlePackageType: "$(PRODUCT_BUNDLE_PACKAGE_TYPE)",
204
+ CFBundleShortVersionString: "$(MARKETING_VERSION)",
205
+ CFBundleVersion: "$(CURRENT_PROJECT_VERSION)",
206
+ ITSAppUsesNonExemptEncryption: false,
207
+ LSRequiresIPhoneOS: true,
208
+ NSCameraUsageDescription: CAMERA_USAGE(site),
209
+ StorSite: storSite(site),
210
+ UIApplicationSceneManifest: {
211
+ UIApplicationSupportsMultipleScenes: false,
212
+ UISceneConfigurations: {
213
+ UIWindowSceneSessionRoleApplication: [
214
+ { UISceneConfigurationName: "Default Configuration", UISceneDelegateClassName: "StorSceneDelegate" },
215
+ ],
216
+ },
217
+ },
218
+ UILaunchScreen: {},
219
+ UIRequiredDeviceCapabilities: ["arm64"],
220
+ UISupportedInterfaceOrientations: ["UIInterfaceOrientationPortrait"],
221
+ "UISupportedInterfaceOrientations~ipad": [
222
+ "UIInterfaceOrientationPortrait",
223
+ "UIInterfaceOrientationPortraitUpsideDown",
224
+ "UIInterfaceOrientationLandscapeLeft",
225
+ "UIInterfaceOrientationLandscapeRight",
226
+ ],
227
+ // Service workers and script messages in WKWebView need the site's host listed here.
228
+ WKAppBoundDomains: [site.host],
229
+ });
230
+ }
231
+
232
+ // MARK: Icons
233
+
234
+ export interface RenderedIcons {
235
+ /** Full-bleed tile, no transparency, as iOS wants. */
236
+ ios: string;
237
+ /** Rounded tile on the macOS icon grid, with its shadow. */
238
+ mac: string;
239
+ }
240
+
241
+ /** Renders the brand tile at 1024px for both platforms, using the Chromium Playwright already installs. */
242
+ export async function renderIcons(site: AppleSite, directory: string): Promise<RenderedIcons> {
243
+ mkdirSync(directory, { recursive: true });
244
+ let chromium: typeof import("@playwright/test").chromium;
245
+ try {
246
+ ({ chromium } = await import("@playwright/test"));
247
+ } catch {
248
+ throw new AppleError("Rendering the icons needs Playwright: add @playwright/test to the devDependencies.");
249
+ }
250
+ const browser = await chromium.launch();
251
+ try {
252
+ const page = await browser.newPage({ viewport: { width: 1024, height: 1024 } });
253
+ const ios = path.join(directory, "icon-ios-1024.png");
254
+ const mac = path.join(directory, "icon-mac-1024.png");
255
+
256
+ const flat = brandTileSvg({ background: site.tile, fill: MARK_ON_TILE, scale: 0.62 }).replace("<svg ", '<svg width="1024" height="1024" ');
257
+ await page.setContent(`<body style="margin:0">${flat}</body>`);
258
+ writeFileSync(ios, await page.screenshot({ type: "png" }));
259
+
260
+ // macOS draws no mask of its own: the tile is 824px on a 1024px grid, with a soft shadow below it.
261
+ const tile = brandTileSvg({ background: site.tile, fill: MARK_ON_TILE, scale: 0.66, radius: 114 }).replace("<svg ", '<svg width="824" height="824" ');
262
+ await page.setContent(`<body style="margin:0;width:1024px;height:1024px;display:grid;place-items:center"><div style="filter:drop-shadow(0 10px 14px rgba(0,0,0,.28));line-height:0;margin-top:-6px">${tile}</div></body>`);
263
+ writeFileSync(mac, await page.screenshot({ type: "png", omitBackground: true }));
264
+ return { ios, mac };
265
+ } finally {
266
+ await browser.close();
267
+ }
268
+ }
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * Run the shell's Swift checks (swift/Tests). They cover the rules that decide access and what
4
+ * gets scheduled, need no Xcode, and compile in a few seconds with the Command Line Tools.
5
+ *
6
+ * Usage: hearthlog apple:test
7
+ */
8
+
9
+ import { mkdtempSync, rmSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import path from "node:path";
12
+ import { spawnSync } from "node:child_process";
13
+
14
+ import { SWIFT_DIRECTORY, run } from "./site.js";
15
+
16
+ const directory = mkdtempSync(path.join(tmpdir(), "stor-apple-test-"));
17
+ try {
18
+ const binary = path.join(directory, "checks");
19
+ const sdk = run("xcrun", ["--show-sdk-path"], { quiet: true }).trim();
20
+ run("swiftc", [
21
+ "-swift-version", "5",
22
+ "-target", `${process.arch === "arm64" ? "arm64" : "x86_64"}-apple-macos14.0`,
23
+ "-sdk", sdk,
24
+ "-o", binary,
25
+ ...["NavigationPolicy", "ShellMessage", "ReminderScheduler", "SiteConfig"].map((name) => path.join(SWIFT_DIRECTORY, "Shared", `${name}.swift`)),
26
+ path.join(SWIFT_DIRECTORY, "Tests", "main.swift"),
27
+ ]);
28
+ const result = spawnSync(binary, { stdio: "inherit" });
29
+ process.exitCode = result.status ?? 1;
30
+ } catch (error) {
31
+ console.error(`Failed: ${error instanceof Error ? error.message : String(error)}`);
32
+ process.exitCode = 1;
33
+ } finally {
34
+ rmSync(directory, { recursive: true, force: true });
35
+ }
@@ -0,0 +1,70 @@
1
+ import Foundation
2
+
3
+ /// Decides what each navigation may do. The app's own origin loads in the web view; the
4
+ /// Cloudflare Access login opens in the sign-in window; every other page opens in the
5
+ /// default browser. Nothing else may load in a frame.
6
+ struct NavigationPolicy: Equatable {
7
+ enum Decision: Equatable {
8
+ /// Load it here.
9
+ case allow
10
+ /// Hand it to the system (default browser, Mail, Phone).
11
+ case external
12
+ /// Open the Access login in the sign-in window.
13
+ case signIn
14
+ case cancel
15
+ }
16
+
17
+ let origin: URL
18
+ let accessHost: String?
19
+
20
+ func decide(_ url: URL?, isMainFrame: Bool) -> Decision {
21
+ guard let url, let scheme = url.scheme?.lowercased() else { return .cancel }
22
+ switch scheme {
23
+ case "about":
24
+ return url.absoluteString == "about:blank" ? .allow : .cancel
25
+ case "blob":
26
+ // blob:https://host/id belongs to the page that made it.
27
+ guard let inner = URL(string: String(url.absoluteString.dropFirst("blob:".count))) else { return .cancel }
28
+ return isOrigin(inner) ? .allow : .cancel
29
+ case "data":
30
+ return isMainFrame ? .cancel : .allow
31
+ case "http", "https":
32
+ if isOrigin(url) { return .allow }
33
+ let host = url.host?.lowercased()
34
+ // The app's own host over another scheme or port is never a link to follow.
35
+ if host == origin.host?.lowercased() { return .cancel }
36
+ if let accessHost, host == accessHost { return isMainFrame ? .signIn : .cancel }
37
+ return isMainFrame ? .external : .cancel
38
+ case "mailto", "tel", "sms":
39
+ return isMainFrame ? .external : .cancel
40
+ default:
41
+ return .cancel
42
+ }
43
+ }
44
+
45
+ /// The sign-in window is done once Access sends it back to the app (past `/cdn-cgi/`, which sets the cookie).
46
+ func finishesSignIn(_ url: URL?) -> Bool {
47
+ guard let url, isOrigin(url) else { return false }
48
+ return !url.path.hasPrefix("/cdn-cgi/")
49
+ }
50
+
51
+ /// The sign-in window may follow the login across identity providers, but only over https. The app's
52
+ /// own origin and the Access host also count over the app's scheme, which is http only in development.
53
+ func allowsSignIn(_ url: URL?) -> Bool {
54
+ guard let url, let scheme = url.scheme?.lowercased() else { return false }
55
+ if scheme == "about" { return url.absoluteString == "about:blank" }
56
+ if scheme == "https" || isOrigin(url) { return true }
57
+ return scheme == origin.scheme?.lowercased() && url.host?.lowercased() == accessHost
58
+ }
59
+
60
+ func isOrigin(_ url: URL) -> Bool {
61
+ guard let scheme = url.scheme?.lowercased(), scheme == origin.scheme?.lowercased(),
62
+ let host = url.host?.lowercased(), host == origin.host?.lowercased()
63
+ else { return false }
64
+ return (url.port ?? Self.defaultPort(scheme)) == (origin.port ?? Self.defaultPort(scheme))
65
+ }
66
+
67
+ private static func defaultPort(_ scheme: String) -> Int? {
68
+ scheme == "https" ? 443 : scheme == "http" ? 80 : nil
69
+ }
70
+ }
@@ -0,0 +1,65 @@
1
+ import Foundation
2
+ import UserNotifications
3
+
4
+ /// Keeps the device's pending notifications equal to the page's latest reminder list.
5
+ /// Only requests this scheduler made (identifier prefix `stor.reminder.`) are replaced.
6
+ actor ReminderScheduler {
7
+ static let shared = ReminderScheduler()
8
+
9
+ private static let prefix = "stor.reminder."
10
+ /// iOS keeps at most 64 pending local notifications.
11
+ private static let limit = 60
12
+ private var applied: [Reminder]?
13
+
14
+ /// Asks for permission the first time; false if the person said no.
15
+ func authorize() async -> Bool {
16
+ let center = UNUserNotificationCenter.current()
17
+ switch await center.notificationSettings().authorizationStatus {
18
+ case .authorized, .provisional:
19
+ return true
20
+ case .notDetermined:
21
+ return (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false
22
+ default:
23
+ return false
24
+ }
25
+ }
26
+
27
+ /// The reminders worth scheduling: still in the future, soonest first, no more than the device keeps.
28
+ nonisolated static func plan(_ reminders: [Reminder], now: Date) -> [Reminder] {
29
+ reminders
30
+ .compactMap { reminder in reminder.date.flatMap { $0 > now ? (reminder, $0) : nil } }
31
+ .sorted { $0.1 < $1.1 }
32
+ .prefix(limit)
33
+ .map(\.0)
34
+ }
35
+
36
+ func apply(_ reminders: [Reminder], now: Date = Date()) async {
37
+ let wanted = Self.plan(reminders, now: now)
38
+ guard wanted != applied else { return }
39
+
40
+ let center = UNUserNotificationCenter.current()
41
+ // Nothing to schedule is not a reason to ask for permission.
42
+ let allowed = wanted.isEmpty ? true : await authorize()
43
+ guard allowed else {
44
+ await removeAll(center)
45
+ applied = []
46
+ return
47
+ }
48
+ await removeAll(center)
49
+ for reminder in wanted {
50
+ let content = UNMutableNotificationContent()
51
+ content.title = reminder.title
52
+ content.body = reminder.body
53
+ content.sound = .default
54
+ content.userInfo = ["path": reminder.path]
55
+ let trigger = UNCalendarNotificationTrigger(dateMatching: reminder.at, repeats: false)
56
+ try? await center.add(UNNotificationRequest(identifier: Self.prefix + reminder.id, content: content, trigger: trigger))
57
+ }
58
+ applied = wanted
59
+ }
60
+
61
+ private func removeAll(_ center: UNUserNotificationCenter) async {
62
+ let ids = await center.pendingNotificationRequests().map(\.identifier).filter { $0.hasPrefix(Self.prefix) }
63
+ center.removePendingNotificationRequests(withIdentifiers: ids)
64
+ }
65
+ }
@@ -0,0 +1,118 @@
1
+ import Foundation
2
+
3
+ enum ShellKind: String {
4
+ case mac, ios
5
+ }
6
+
7
+ enum Appearance: String {
8
+ case light, dark, system
9
+ }
10
+
11
+ /// A notification the page wants scheduled. `at` is wall-clock time in the device's time zone.
12
+ struct Reminder: Hashable {
13
+ var id: String
14
+ var title: String
15
+ var body: String
16
+ var at: DateComponents
17
+ var path: String
18
+
19
+ var date: Date? { Calendar.current.date(from: at) }
20
+ }
21
+
22
+ struct MenuEntry: Hashable {
23
+ var id: String
24
+ var title: String
25
+ var path: String
26
+ /// A section's sub-pages; the Mac Dock menu shows them as a submenu.
27
+ var children: [MenuEntry] = []
28
+ }
29
+
30
+ /// What the page tells the shell. Decoded from `{ type, body }` posted on the `stor` handler;
31
+ /// anything malformed is dropped.
32
+ enum ShellMessage: Equatable {
33
+ case appearance(preference: Appearance, resolved: Appearance, background: String?)
34
+ case badge(Int)
35
+ case reminders([Reminder])
36
+ case menu(destinations: [MenuEntry], actions: [MenuEntry])
37
+ /// Left edge of the first window control and its vertical centre, in points from the window's top left.
38
+ case lights(x: Double, y: Double)
39
+ case outbox(pending: Int)
40
+ case haptic(success: Bool)
41
+ /// Whether the pointer is over a background of the page (see `WindowDragScript`); a click there moves the window.
42
+ case backgroundHover(on: Bool)
43
+ /// What earlier pages sent from their own drag handler. The shell drags by itself now and ignores it.
44
+ case drag(clickCount: Int)
45
+
46
+ init?(_ message: Any) {
47
+ guard let envelope = message as? [String: Any],
48
+ let type = envelope["type"] as? String,
49
+ let body = envelope["body"] as? [String: Any]
50
+ else { return nil }
51
+
52
+ switch type {
53
+ case "appearance":
54
+ guard let preference = (body["preference"] as? String).flatMap(Appearance.init),
55
+ let resolved = (body["resolved"] as? String).flatMap(Appearance.init)
56
+ else { return nil }
57
+ self = .appearance(preference: preference, resolved: resolved, background: body["background"] as? String)
58
+ case "badge":
59
+ guard let count = Self.int(body["count"]) else { return nil }
60
+ self = .badge(max(0, count))
61
+ case "reminders":
62
+ guard let items = body["items"] as? [[String: Any]] else { return nil }
63
+ self = .reminders(items.compactMap(Self.reminder))
64
+ case "menu":
65
+ guard let destinations = body["destinations"] as? [[String: Any]],
66
+ let actions = body["actions"] as? [[String: Any]]
67
+ else { return nil }
68
+ self = .menu(destinations: destinations.compactMap(Self.entry), actions: actions.compactMap(Self.entry))
69
+ case "lights":
70
+ guard let x = Self.double(body["x"]), let y = Self.double(body["y"]), x.isFinite, y.isFinite else { return nil }
71
+ self = .lights(x: x, y: y)
72
+ case "outbox":
73
+ guard let pending = Self.int(body["pending"]) else { return nil }
74
+ self = .outbox(pending: max(0, pending))
75
+ case "haptic":
76
+ self = .haptic(success: (body["kind"] as? String) == "success")
77
+ case "backgroundHover":
78
+ self = .backgroundHover(on: (body["on"] as? Bool) ?? false)
79
+ case "drag":
80
+ self = .drag(clickCount: Self.int(body["clickCount"]) ?? 1)
81
+ default:
82
+ return nil
83
+ }
84
+ }
85
+
86
+ private static func int(_ value: Any?) -> Int? { (value as? NSNumber)?.intValue }
87
+ private static func double(_ value: Any?) -> Double? { (value as? NSNumber)?.doubleValue }
88
+
89
+ private static func entry(_ raw: [String: Any]) -> MenuEntry? {
90
+ guard let id = raw["id"] as? String, let title = raw["title"] as? String, let path = raw["path"] as? String,
91
+ isAppPath(path)
92
+ else { return nil }
93
+ let children = (raw["children"] as? [[String: Any]])?.compactMap(entry) ?? []
94
+ return MenuEntry(id: id, title: title, path: path, children: children)
95
+ }
96
+
97
+ private static func reminder(_ raw: [String: Any]) -> Reminder? {
98
+ guard let id = raw["id"] as? String, let title = raw["title"] as? String,
99
+ let body = raw["body"] as? String, let path = raw["path"] as? String, isAppPath(path),
100
+ let at = (raw["at"] as? String).flatMap(components)
101
+ else { return nil }
102
+ return Reminder(id: id, title: title, body: body, at: at, path: path)
103
+ }
104
+
105
+ /// `YYYY-MM-DDTHH:mm` as calendar components, without choosing a time zone.
106
+ static func components(_ text: String) -> DateComponents? {
107
+ let parts = text.split(whereSeparator: { "-T:".contains($0) }).compactMap { Int($0) }
108
+ guard parts.count == 5, (1...12).contains(parts[1]), (1...31).contains(parts[2]),
109
+ (0...23).contains(parts[3]), (0...59).contains(parts[4])
110
+ else { return nil }
111
+ return DateComponents(calendar: .current, timeZone: .current, year: parts[0], month: parts[1], day: parts[2], hour: parts[3], minute: parts[4])
112
+ }
113
+
114
+ /// An in-app route: absolute and never `//host`.
115
+ static func isAppPath(_ path: String) -> Bool {
116
+ path.hasPrefix("/") && !path.hasPrefix("//")
117
+ }
118
+ }
@@ -0,0 +1,47 @@
1
+ import CryptoKit
2
+ import Foundation
3
+
4
+ /// The site an app build opens. `scripts/apple-site.ts` writes it into Info.plist as `StorSite`.
5
+ struct SiteConfig: Equatable {
6
+ let id: String
7
+ let title: String
8
+ let origin: URL
9
+ /// Cloudflare Access team host; navigating there opens the sign-in window.
10
+ let accessHost: String?
11
+ /// A development build pointed at another origin keeps its own data store.
12
+ let isDevelopmentOrigin: Bool
13
+
14
+ var host: String { origin.host?.lowercased() ?? "" }
15
+
16
+ /// One persistent data store per site, so apps for different sites never share storage.
17
+ var dataStoreIdentifier: UUID {
18
+ let name = "app.stor.data-store.\(id)\(isDevelopmentOrigin ? ".development" : "")"
19
+ var bytes = Array(SHA256.hash(data: Data(name.utf8)).prefix(16))
20
+ bytes[6] = (bytes[6] & 0x0F) | 0x50
21
+ bytes[8] = (bytes[8] & 0x3F) | 0x80
22
+ return bytes.withUnsafeBytes { UUID(uuid: $0.load(as: uuid_t.self)) }
23
+ }
24
+
25
+ /// Reads `StorSite` from the bundle. A build without it is a build mistake, so this stops.
26
+ /// Builds made with `--dev` may point at another origin and Access host through `STOR_ORIGIN` and `STOR_ACCESS_HOST`.
27
+ static func load(bundle: Bundle = .main, environment: [String: String] = ProcessInfo.processInfo.environment) -> SiteConfig {
28
+ guard let info = bundle.object(forInfoDictionaryKey: "StorSite") as? [String: Any],
29
+ let id = info["id"] as? String,
30
+ let title = info["title"] as? String,
31
+ let originString = info["origin"] as? String,
32
+ let configured = URL(string: originString), configured.host != nil
33
+ else { fatalError("Info.plist has no valid StorSite. Build with pnpm apple:mac or pnpm apple:ios.") }
34
+
35
+ var origin = configured
36
+ var accessHost = (info["accessHost"] as? String)?.lowercased()
37
+ var isDevelopmentOrigin = false
38
+ if bundle.object(forInfoDictionaryKey: "StorDevelopmentOverrides") as? Bool == true {
39
+ if let override = environment["STOR_ORIGIN"].flatMap(URL.init(string:)), override.host != nil {
40
+ origin = override
41
+ isDevelopmentOrigin = true
42
+ }
43
+ if let override = environment["STOR_ACCESS_HOST"], !override.isEmpty { accessHost = override.lowercased() }
44
+ }
45
+ return SiteConfig(id: id, title: title, origin: origin, accessHost: accessHost, isDevelopmentOrigin: isDevelopmentOrigin)
46
+ }
47
+ }