@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/project.ts ADDED
@@ -0,0 +1,365 @@
1
+ /**
2
+ * Writes Stor.xcodeproj into the tenant's `.apple-build/project/`: the iOS app's Xcode project, from the
3
+ * Swift files in this package (`swift/Shared` and `swift/iOS`, referenced by absolute path). The project
4
+ * is generated, so adding a Swift file needs no project edit. Everything that differs per site (bundle id,
5
+ * version, team, icon, Info.plist) lives in `.apple-build/project/Generated`, written by ios.ts.
6
+ *
7
+ * The Mac app does not use Xcode: mac.ts builds it with swiftc.
8
+ */
9
+
10
+ import { createHash } from "node:crypto";
11
+ import { mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
12
+ import path from "node:path";
13
+
14
+ import { PROJECT_ROOT, SWIFT_DIRECTORY } from "./site.js";
15
+
16
+ export const PROJECT_DIRECTORY = path.join(PROJECT_ROOT, "Stor.xcodeproj");
17
+ export const SCHEME = "Stor iOS";
18
+ export const PRODUCT = "Stor.app";
19
+ const DEPLOYMENT_TARGET = "17.0";
20
+
21
+ /** Xcode wants 24 hex digits; derive them from a name so the project is the same every time. */
22
+ function id(name: string): string {
23
+ return createHash("sha1").update(`stor-apple:${name}`).digest("hex").slice(0, 24).toUpperCase();
24
+ }
25
+
26
+ const quote = (value: string) => (/^[A-Za-z0-9_./]+$/.test(value) ? value : `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`);
27
+
28
+ interface Settings { [key: string]: string | string[] }
29
+
30
+ function settingsBlock(settings: Settings, indent: string): string {
31
+ return Object.entries(settings)
32
+ .sort(([a], [b]) => a.localeCompare(b))
33
+ .map(([key, value]) =>
34
+ Array.isArray(value)
35
+ ? `${indent}${key} = (\n${value.map((item) => `${indent}\t${quote(item)},`).join("\n")}\n${indent});`
36
+ : `${indent}${key} = ${quote(value)};`,
37
+ )
38
+ .join("\n");
39
+ }
40
+
41
+ function swiftFiles(directory: string): string[] {
42
+ return readdirSync(path.join(SWIFT_DIRECTORY, directory)).filter((file) => file.endsWith(".swift")).sort();
43
+ }
44
+
45
+ export function writeXcodeProject(): string {
46
+ const groups = [
47
+ { name: "Shared", files: swiftFiles("Shared") },
48
+ { name: "iOS", files: swiftFiles("iOS") },
49
+ ];
50
+ const sources = groups.flatMap((group) => group.files.map((file) => ({ group: group.name, file })));
51
+ const ref = (group: string, file: string) => id(`ref:${group}/${file}`);
52
+ const build = (group: string, file: string) => id(`build:${group}/${file}`);
53
+
54
+ const target = id("target");
55
+ const project = id("project");
56
+ const mainGroup = id("group:main");
57
+ const productsGroup = id("group:products");
58
+ const generatedGroup = id("group:generated");
59
+ const productRef = id("ref:product");
60
+ const plistRef = id("ref:Generated/Info-iOS.plist");
61
+ const xcconfigRef = id("ref:Generated/Site.xcconfig");
62
+ const assetsRef = id("ref:Generated/Assets.xcassets");
63
+ const assetsBuild = id("build:Generated/Assets.xcassets");
64
+ const sourcesPhase = id("phase:sources");
65
+ const frameworksPhase = id("phase:frameworks");
66
+ const resourcesPhase = id("phase:resources");
67
+ const projectConfigs = id("configs:project");
68
+ const targetConfigs = id("configs:target");
69
+
70
+ const shared: Settings = {
71
+ ALWAYS_SEARCH_USER_PATHS: "NO",
72
+ CLANG_ENABLE_MODULES: "YES",
73
+ CLANG_ENABLE_OBJC_ARC: "YES",
74
+ COPY_PHASE_STRIP: "NO",
75
+ ENABLE_STRICT_OBJC_MSGSEND: "YES",
76
+ ENABLE_USER_SCRIPT_SANDBOXING: "YES",
77
+ GCC_NO_COMMON_BLOCKS: "YES",
78
+ IPHONEOS_DEPLOYMENT_TARGET: DEPLOYMENT_TARGET,
79
+ SDKROOT: "iphoneos",
80
+ SWIFT_VERSION: "5.0",
81
+ };
82
+ const debug: Settings = {
83
+ ...shared,
84
+ DEBUG_INFORMATION_FORMAT: "dwarf",
85
+ ENABLE_TESTABILITY: "YES",
86
+ GCC_OPTIMIZATION_LEVEL: "0",
87
+ GCC_PREPROCESSOR_DEFINITIONS: ["DEBUG=1", "$(inherited)"],
88
+ ONLY_ACTIVE_ARCH: "YES",
89
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS: "DEBUG $(inherited)",
90
+ SWIFT_OPTIMIZATION_LEVEL: "-Onone",
91
+ };
92
+ const release: Settings = {
93
+ ...shared,
94
+ DEBUG_INFORMATION_FORMAT: "dwarf-with-dsym",
95
+ ENABLE_NS_ASSERTIONS: "NO",
96
+ SWIFT_COMPILATION_MODE: "wholemodule",
97
+ VALIDATE_PRODUCT: "YES",
98
+ };
99
+ // Per-site values (bundle id, version, team) come from Generated/Site.xcconfig, so they are not set here.
100
+ const targetSettings: Settings = {
101
+ ASSETCATALOG_COMPILER_APPICON_NAME: "AppIcon",
102
+ CODE_SIGN_STYLE: "Automatic",
103
+ GENERATE_INFOPLIST_FILE: "NO",
104
+ INFOPLIST_FILE: "Generated/Info-iOS.plist",
105
+ LD_RUNPATH_SEARCH_PATHS: ["$(inherited)", "@executable_path/Frameworks"],
106
+ PRODUCT_NAME: "Stor",
107
+ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: "NO",
108
+ SUPPORTS_MACCATALYST: "NO",
109
+ SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD: "NO",
110
+ SWIFT_EMIT_LOC_STRINGS: "YES",
111
+ TARGETED_DEVICE_FAMILY: "1,2",
112
+ };
113
+
114
+ const config = (name: string, settings: Settings, base?: string) =>
115
+ `\t\t${id(`config:${name}`)} /* ${name.split(":")[1]} */ = {\n\t\t\tisa = XCBuildConfiguration;\n${
116
+ base ? `\t\t\tbaseConfigurationReference = ${base} /* Site.xcconfig */;\n` : ""
117
+ }\t\t\tbuildSettings = {\n${settingsBlock(settings, "\t\t\t\t")}\n\t\t\t};\n\t\t\tname = ${name.split(":")[1]};\n\t\t};\n`;
118
+
119
+ const text = `// !$*UTF8*$!
120
+ {
121
+ archiveVersion = 1;
122
+ classes = {
123
+ };
124
+ objectVersion = 56;
125
+ objects = {
126
+
127
+ /* Begin PBXBuildFile section */
128
+ ${sources.map(({ group, file }) => `\t\t${build(group, file)} /* ${file} in Sources */ = {isa = PBXBuildFile; fileRef = ${ref(group, file)} /* ${file} */; };`).join("\n")}
129
+ \t\t${assetsBuild} /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = ${assetsRef} /* Assets.xcassets */; };
130
+ /* End PBXBuildFile section */
131
+
132
+ /* Begin PBXFileReference section */
133
+ ${sources.map(({ group, file }) => `\t\t${ref(group, file)} /* ${file} */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ${quote(file)}; sourceTree = "<group>"; };`).join("\n")}
134
+ \t\t${plistRef} /* Info-iOS.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "Info-iOS.plist"; sourceTree = "<group>"; };
135
+ \t\t${xcconfigRef} /* Site.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Site.xcconfig; sourceTree = "<group>"; };
136
+ \t\t${assetsRef} /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
137
+ \t\t${productRef} /* ${PRODUCT} */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ${PRODUCT}; sourceTree = BUILT_PRODUCTS_DIR; };
138
+ /* End PBXFileReference section */
139
+
140
+ /* Begin PBXFrameworksBuildPhase section */
141
+ \t\t${frameworksPhase} /* Frameworks */ = {
142
+ \t\t\tisa = PBXFrameworksBuildPhase;
143
+ \t\t\tbuildActionMask = 2147483647;
144
+ \t\t\tfiles = (
145
+ \t\t\t);
146
+ \t\t\trunOnlyForDeploymentPostprocessing = 0;
147
+ \t\t};
148
+ /* End PBXFrameworksBuildPhase section */
149
+
150
+ /* Begin PBXGroup section */
151
+ \t\t${mainGroup} = {
152
+ \t\t\tisa = PBXGroup;
153
+ \t\t\tchildren = (
154
+ ${groups.map((group) => `\t\t\t\t${id(`group:${group.name}`)} /* ${group.name} */,`).join("\n")}
155
+ \t\t\t\t${generatedGroup} /* Generated */,
156
+ \t\t\t\t${productsGroup} /* Products */,
157
+ \t\t\t);
158
+ \t\t\tsourceTree = "<group>";
159
+ \t\t};
160
+ ${groups.map((group) => `\t\t${id(`group:${group.name}`)} /* ${group.name} */ = {
161
+ \t\t\tisa = PBXGroup;
162
+ \t\t\tchildren = (
163
+ ${group.files.map((file) => `\t\t\t\t${ref(group.name, file)} /* ${file} */,`).join("\n")}
164
+ \t\t\t);
165
+ \t\t\tpath = ${quote(path.join(SWIFT_DIRECTORY, group.name))};
166
+ \t\t\tsourceTree = "<absolute>";
167
+ \t\t};`).join("\n")}
168
+ \t\t${generatedGroup} /* Generated */ = {
169
+ \t\t\tisa = PBXGroup;
170
+ \t\t\tchildren = (
171
+ \t\t\t\t${plistRef} /* Info-iOS.plist */,
172
+ \t\t\t\t${xcconfigRef} /* Site.xcconfig */,
173
+ \t\t\t\t${assetsRef} /* Assets.xcassets */,
174
+ \t\t\t);
175
+ \t\t\tpath = Generated;
176
+ \t\t\tsourceTree = "<group>";
177
+ \t\t};
178
+ \t\t${productsGroup} /* Products */ = {
179
+ \t\t\tisa = PBXGroup;
180
+ \t\t\tchildren = (
181
+ \t\t\t\t${productRef} /* ${PRODUCT} */,
182
+ \t\t\t);
183
+ \t\t\tname = Products;
184
+ \t\t\tsourceTree = "<group>";
185
+ \t\t};
186
+ /* End PBXGroup section */
187
+
188
+ /* Begin PBXNativeTarget section */
189
+ \t\t${target} /* ${SCHEME} */ = {
190
+ \t\t\tisa = PBXNativeTarget;
191
+ \t\t\tbuildConfigurationList = ${targetConfigs} /* Build configuration list for PBXNativeTarget "${SCHEME}" */;
192
+ \t\t\tbuildPhases = (
193
+ \t\t\t\t${sourcesPhase} /* Sources */,
194
+ \t\t\t\t${frameworksPhase} /* Frameworks */,
195
+ \t\t\t\t${resourcesPhase} /* Resources */,
196
+ \t\t\t);
197
+ \t\t\tbuildRules = (
198
+ \t\t\t);
199
+ \t\t\tdependencies = (
200
+ \t\t\t);
201
+ \t\t\tname = "${SCHEME}";
202
+ \t\t\tproductName = "${SCHEME}";
203
+ \t\t\tproductReference = ${productRef} /* ${PRODUCT} */;
204
+ \t\t\tproductType = "com.apple.product-type.application";
205
+ \t\t};
206
+ /* End PBXNativeTarget section */
207
+
208
+ /* Begin PBXProject section */
209
+ \t\t${project} /* Project object */ = {
210
+ \t\t\tisa = PBXProject;
211
+ \t\t\tattributes = {
212
+ \t\t\t\tBuildIndependentTargetsInParallel = 1;
213
+ \t\t\t\tLastSwiftUpdateCheck = 1600;
214
+ \t\t\t\tLastUpgradeCheck = 1600;
215
+ \t\t\t\tTargetAttributes = {
216
+ \t\t\t\t\t${target} = {
217
+ \t\t\t\t\t\tCreatedOnToolsVersion = 16.0;
218
+ \t\t\t\t\t};
219
+ \t\t\t\t};
220
+ \t\t\t};
221
+ \t\t\tbuildConfigurationList = ${projectConfigs} /* Build configuration list for PBXProject "Stor" */;
222
+ \t\t\tcompatibilityVersion = "Xcode 14.0";
223
+ \t\t\tdevelopmentRegion = en;
224
+ \t\t\thasScannedForEncodings = 0;
225
+ \t\t\tknownRegions = (
226
+ \t\t\t\ten,
227
+ \t\t\t\tBase,
228
+ \t\t\t);
229
+ \t\t\tmainGroup = ${mainGroup};
230
+ \t\t\tproductRefGroup = ${productsGroup} /* Products */;
231
+ \t\t\tprojectDirPath = "";
232
+ \t\t\tprojectRoot = "";
233
+ \t\t\ttargets = (
234
+ \t\t\t\t${target} /* ${SCHEME} */,
235
+ \t\t\t);
236
+ \t\t};
237
+ /* End PBXProject section */
238
+
239
+ /* Begin PBXResourcesBuildPhase section */
240
+ \t\t${resourcesPhase} /* Resources */ = {
241
+ \t\t\tisa = PBXResourcesBuildPhase;
242
+ \t\t\tbuildActionMask = 2147483647;
243
+ \t\t\tfiles = (
244
+ \t\t\t\t${assetsBuild} /* Assets.xcassets in Resources */,
245
+ \t\t\t);
246
+ \t\t\trunOnlyForDeploymentPostprocessing = 0;
247
+ \t\t};
248
+ /* End PBXResourcesBuildPhase section */
249
+
250
+ /* Begin PBXSourcesBuildPhase section */
251
+ \t\t${sourcesPhase} /* Sources */ = {
252
+ \t\t\tisa = PBXSourcesBuildPhase;
253
+ \t\t\tbuildActionMask = 2147483647;
254
+ \t\t\tfiles = (
255
+ ${sources.map(({ group, file }) => `\t\t\t\t${build(group, file)} /* ${file} in Sources */,`).join("\n")}
256
+ \t\t\t);
257
+ \t\t\trunOnlyForDeploymentPostprocessing = 0;
258
+ \t\t};
259
+ /* End PBXSourcesBuildPhase section */
260
+
261
+ /* Begin XCBuildConfiguration section */
262
+ ${config("project:Debug", debug)}${config("project:Release", release)}${config("target:Debug", targetSettings, xcconfigRef)}${config("target:Release", targetSettings, xcconfigRef)}/* End XCBuildConfiguration section */
263
+
264
+ /* Begin XCConfigurationList section */
265
+ \t\t${projectConfigs} /* Build configuration list for PBXProject "Stor" */ = {
266
+ \t\t\tisa = XCConfigurationList;
267
+ \t\t\tbuildConfigurations = (
268
+ \t\t\t\t${id("config:project:Debug")} /* Debug */,
269
+ \t\t\t\t${id("config:project:Release")} /* Release */,
270
+ \t\t\t);
271
+ \t\t\tdefaultConfigurationIsVisible = 0;
272
+ \t\t\tdefaultConfigurationName = Release;
273
+ \t\t};
274
+ \t\t${targetConfigs} /* Build configuration list for PBXNativeTarget "${SCHEME}" */ = {
275
+ \t\t\tisa = XCConfigurationList;
276
+ \t\t\tbuildConfigurations = (
277
+ \t\t\t\t${id("config:target:Debug")} /* Debug */,
278
+ \t\t\t\t${id("config:target:Release")} /* Release */,
279
+ \t\t\t);
280
+ \t\t\tdefaultConfigurationIsVisible = 0;
281
+ \t\t\tdefaultConfigurationName = Release;
282
+ \t\t};
283
+ /* End XCConfigurationList section */
284
+ \t};
285
+ \trootObject = ${project} /* Project object */;
286
+ }
287
+ `;
288
+
289
+ const reference = `<BuildableReference
290
+ BuildableIdentifier = "primary"
291
+ BlueprintIdentifier = "${target}"
292
+ BuildableName = "${PRODUCT}"
293
+ BlueprintName = "${SCHEME}"
294
+ ReferencedContainer = "container:Stor.xcodeproj">
295
+ </BuildableReference>`;
296
+ const scheme = `<?xml version="1.0" encoding="UTF-8"?>
297
+ <Scheme
298
+ LastUpgradeVersion = "1600"
299
+ version = "1.7">
300
+ <BuildAction
301
+ parallelizeBuildables = "YES"
302
+ buildImplicitDependencies = "YES">
303
+ <BuildActionEntries>
304
+ <BuildActionEntry
305
+ buildForTesting = "YES"
306
+ buildForRunning = "YES"
307
+ buildForProfiling = "YES"
308
+ buildForArchiving = "YES"
309
+ buildForAnalyzing = "YES">
310
+ ${reference}
311
+ </BuildActionEntry>
312
+ </BuildActionEntries>
313
+ </BuildAction>
314
+ <TestAction
315
+ buildConfiguration = "Debug"
316
+ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
317
+ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
318
+ shouldUseLaunchSchemeArgsEnv = "YES">
319
+ <Testables>
320
+ </Testables>
321
+ </TestAction>
322
+ <LaunchAction
323
+ buildConfiguration = "Release"
324
+ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
325
+ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
326
+ launchStyle = "0"
327
+ useCustomWorkingDirectory = "NO"
328
+ ignoresPersistentStateOnLaunch = "NO"
329
+ debugDocumentVersioning = "YES"
330
+ debugServiceExtension = "internal"
331
+ allowLocationSimulation = "YES">
332
+ <BuildableProductRunnable
333
+ runnableDebuggingMode = "0">
334
+ ${reference}
335
+ </BuildableProductRunnable>
336
+ </LaunchAction>
337
+ <ProfileAction
338
+ buildConfiguration = "Release"
339
+ shouldUseLaunchSchemeArgsEnv = "YES"
340
+ savedToolIdentifier = ""
341
+ useCustomWorkingDirectory = "NO"
342
+ debugDocumentVersioning = "YES">
343
+ <BuildableProductRunnable
344
+ runnableDebuggingMode = "0">
345
+ ${reference}
346
+ </BuildableProductRunnable>
347
+ </ProfileAction>
348
+ <AnalyzeAction
349
+ buildConfiguration = "Debug">
350
+ </AnalyzeAction>
351
+ <ArchiveAction
352
+ buildConfiguration = "Release"
353
+ revealArchiveInOrganizer = "YES">
354
+ </ArchiveAction>
355
+ </Scheme>
356
+ `;
357
+
358
+ mkdirSync(PROJECT_ROOT, { recursive: true });
359
+ rmSync(PROJECT_DIRECTORY, { recursive: true, force: true });
360
+ const schemes = path.join(PROJECT_DIRECTORY, "xcshareddata", "xcschemes");
361
+ mkdirSync(schemes, { recursive: true });
362
+ writeFileSync(path.join(PROJECT_DIRECTORY, "project.pbxproj"), text);
363
+ writeFileSync(path.join(schemes, `${SCHEME}.xcscheme`), scheme);
364
+ return PROJECT_DIRECTORY;
365
+ }
package/src/resign.ts ADDED
@@ -0,0 +1,280 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * Keep free-signed iPhone builds alive. Profiles from a free Apple ID (Xcode
4
+ * "Personal Team") expire after seven days; this rebuilds each site with a fresh
5
+ * profile and installs it over the old copy, which keeps the app's data.
6
+ *
7
+ * Usage:
8
+ * hearthlog apple:resign rebuild and install sites whose profile expires soon
9
+ * hearthlog apple:resign --force rebuild and install every site now
10
+ * hearthlog apple:resign --dry-run report what is due without building
11
+ * hearthlog apple:resign status show each site's installed profile expiry
12
+ * hearthlog apple:resign install run daily at 03:30 through a LaunchAgent
13
+ * hearthlog apple:resign uninstall remove the LaunchAgent
14
+ *
15
+ * Settings live in resign.local.json (git-ignored); see
16
+ * resign.example.json (in @hearthlog/native) and docs/ios-free-signing.md. Each site's app is prepared first
17
+ * (ios.ts), so a build always carries that site's bundle id, icon and team.
18
+ */
19
+
20
+ import { spawnSync } from "node:child_process";
21
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
22
+ import { homedir, userInfo } from "node:os";
23
+ import { createRequire } from "node:module";
24
+ import path from "node:path";
25
+ import { prepareIosSite } from "./ios.js";
26
+ import {
27
+ AppleError as ResignError,
28
+ BUILD_ROOT as APPLE_BUILD_ROOT,
29
+ loadAppleSettings,
30
+ loadAppleSite,
31
+ run,
32
+ type AppleSettings,
33
+ } from "./site.js";
34
+ import { REPOSITORY_ROOT } from "@hearthlog/tooling/tenants/tenant-config.js";
35
+
36
+ interface InstallState {
37
+ bundleId: string;
38
+ expiresAt: string;
39
+ installedAt: string;
40
+ }
41
+
42
+ interface ProvisioningProfile {
43
+ file: string;
44
+ name: string;
45
+ applicationIdentifier: string;
46
+ expiresAt: Date;
47
+ }
48
+
49
+ const DAY_MS = 86_400_000;
50
+ const BUILD_ROOT = path.join(APPLE_BUILD_ROOT, "ios");
51
+ /** One agent per tenant: the label follows the bundle prefix, so tenant repositories never share one. */
52
+ const labelFor = (settings: AppleSettings) => `${settings.bundlePrefix}.resign`;
53
+ const launchAgentFor = (settings: AppleSettings) => path.join(homedir(), "Library", "LaunchAgents", `${labelFor(settings)}.plist`);
54
+ const logFileFor = (settings: AppleSettings) => path.join(homedir(), "Library", "Logs", `${labelFor(settings)}.log`);
55
+ /** Xcode 16 keeps managed profiles in the first folder; older versions used the second. */
56
+ const PROFILE_DIRECTORIES = [
57
+ path.join(homedir(), "Library", "Developer", "Xcode", "UserData", "Provisioning Profiles"),
58
+ path.join(homedir(), "Library", "MobileDevice", "Provisioning Profiles"),
59
+ ];
60
+
61
+ function log(message: string) {
62
+ console.log(`[${new Date().toISOString()}] ${message}`);
63
+ }
64
+
65
+ function notify(title: string, message: string) {
66
+ const quote = (value: string) => JSON.stringify(value);
67
+ spawnSync("osascript", ["-e", `display notification ${quote(message)} with title ${quote(title)}`]);
68
+ }
69
+
70
+ function requireXcode() {
71
+ const developerDirectory = run("xcode-select", ["-p"], { quiet: true }).trim();
72
+ if (developerDirectory.includes("CommandLineTools")) {
73
+ throw new ResignError("Xcode is required. Install it, then run: sudo xcode-select -s /Applications/Xcode.app");
74
+ }
75
+ }
76
+
77
+ /** devicectl and xcodebuild both accept the hardware UDID, so resolve names to it. */
78
+ function resolveDevice(device: string): { udid: string; name: string } {
79
+ const output = path.join(BUILD_ROOT, "devices.json");
80
+ mkdirSync(BUILD_ROOT, { recursive: true });
81
+ run("xcrun", ["devicectl", "list", "devices", "--json-output", output], { quiet: true });
82
+ const listing = JSON.parse(readFileSync(output, "utf8")) as {
83
+ result?: { devices?: Array<{ hardwareProperties?: { udid?: string; platform?: string }; deviceProperties?: { name?: string } }> };
84
+ };
85
+ for (const candidate of listing.result?.devices ?? []) {
86
+ const udid = candidate.hardwareProperties?.udid;
87
+ const name = candidate.deviceProperties?.name;
88
+ if (udid && (udid === device || name === device)) return { udid, name: name ?? udid };
89
+ }
90
+ throw new ResignError(`No paired device named "${device}". Pair it once in Xcode and turn on "Connect via network".`);
91
+ }
92
+
93
+ /** Read one value; profiles hold binary data that a whole-file JSON conversion rejects. */
94
+ function plistValue(keyPath: string, file: string, input?: string): string {
95
+ return run("plutil", ["-extract", keyPath, "raw", "-o", "-", input === undefined ? file : "-"], { input, quiet: true }).trim();
96
+ }
97
+
98
+ /** Profiles are CMS-signed plists; security decodes them to XML first. */
99
+ function readProfile(file: string): ProvisioningProfile | null {
100
+ try {
101
+ const xml = run("security", ["cms", "-D", "-i", file], { quiet: true });
102
+ const expiresAt = new Date(plistValue("ExpirationDate", file, xml));
103
+ if (Number.isNaN(expiresAt.getTime())) return null;
104
+ return {
105
+ file,
106
+ name: plistValue("Name", file, xml),
107
+ applicationIdentifier: plistValue("Entitlements.application-identifier", file, xml),
108
+ expiresAt,
109
+ };
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Xcode reuses a cached profile until it expires, so a rebuild two days early would
117
+ * install a build that still dies on the old date. Remove only the Xcode-managed
118
+ * team profiles for this bundle id; Xcode downloads a fresh seven-day one.
119
+ */
120
+ function removeCachedProfiles(bundleId: string) {
121
+ for (const directory of PROFILE_DIRECTORIES) {
122
+ if (!existsSync(directory)) continue;
123
+ for (const entry of readdirSync(directory)) {
124
+ if (!entry.endsWith(".mobileprovision")) continue;
125
+ const profile = readProfile(path.join(directory, entry));
126
+ if (!profile) continue;
127
+ const managed = profile.name.startsWith("iOS Team Provisioning Profile:");
128
+ if (managed && profile.applicationIdentifier.endsWith(`.${bundleId}`)) {
129
+ rmSync(profile.file);
130
+ log(`Removed cached profile ${entry} for ${bundleId}`);
131
+ }
132
+ }
133
+ }
134
+ }
135
+
136
+ function statePath(site: string) {
137
+ return path.join(BUILD_ROOT, site, "installed.json");
138
+ }
139
+
140
+ function readState(site: string): InstallState | null {
141
+ try {
142
+ return JSON.parse(readFileSync(statePath(site), "utf8")) as InstallState;
143
+ } catch {
144
+ return null;
145
+ }
146
+ }
147
+
148
+ function daysLeft(state: InstallState | null, now: number) {
149
+ return state ? (Date.parse(state.expiresAt) - now) / DAY_MS : -Infinity;
150
+ }
151
+
152
+ function buildSite(settings: AppleSettings, site: string, udid: string): { app: string; bundleId: string; expiresAt: Date } {
153
+ const derivedData = path.join(BUILD_ROOT, site);
154
+ run("xcodebuild", [
155
+ "-project", settings.project,
156
+ "-scheme", settings.scheme,
157
+ "-configuration", "Release",
158
+ "-destination", `platform=iOS,id=${udid}`,
159
+ "-derivedDataPath", derivedData,
160
+ "-allowProvisioningUpdates",
161
+ "-allowProvisioningDeviceRegistration",
162
+ "-quiet",
163
+ "build",
164
+ ]);
165
+ const products = path.join(derivedData, "Build", "Products", "Release-iphoneos");
166
+ const app = readdirSync(products).find((entry) => entry.endsWith(".app"));
167
+ if (!app) throw new ResignError(`No .app in ${products}`);
168
+ const appPath = path.join(products, app);
169
+ const bundleId = plistValue("CFBundleIdentifier", path.join(appPath, "Info.plist"));
170
+ if (!bundleId) throw new ResignError(`${app} has no bundle identifier`);
171
+ const profile = readProfile(path.join(appPath, "embedded.mobileprovision"));
172
+ if (!profile) throw new ResignError(`${app} has no readable provisioning profile`);
173
+ return { app: appPath, bundleId, expiresAt: profile.expiresAt };
174
+ }
175
+
176
+ async function installWithRetry(udid: string, app: string) {
177
+ for (let attempt = 1; ; attempt += 1) {
178
+ try {
179
+ run("xcrun", ["devicectl", "device", "install", "app", "--device", udid, app], { quiet: true });
180
+ return;
181
+ } catch (error) {
182
+ if (attempt === 3) throw error;
183
+ log(`Install attempt ${attempt} failed; retrying in a minute (is the iPhone on this network?)`);
184
+ await new Promise((resolve) => setTimeout(resolve, 60_000));
185
+ }
186
+ }
187
+ }
188
+
189
+ async function resign({ force, dryRun }: { force: boolean; dryRun: boolean }) {
190
+ const settings = loadAppleSettings({ requireDevice: true });
191
+ // An unknown site id should fail now, not halfway through a build.
192
+ for (const site of settings.sites) await loadAppleSite(site, settings);
193
+ const now = Date.now();
194
+ const due = settings.sites.filter((site) => force || daysLeft(readState(site), now) < settings.renewWithinDays);
195
+ for (const site of settings.sites) {
196
+ const left = daysLeft(readState(site), now);
197
+ log(`${site}: ${Number.isFinite(left) ? `${left.toFixed(1)} days left` : "not installed by this script"}${due.includes(site) ? ", due" : ""}`);
198
+ }
199
+ if (due.length === 0 || dryRun) return;
200
+
201
+ requireXcode();
202
+ const device = resolveDevice(settings.device);
203
+ const renewed: string[] = [];
204
+ for (const site of due) {
205
+ const definition = await loadAppleSite(site, settings);
206
+ removeCachedProfiles(definition.bundleId);
207
+ log(`${site}: preparing ${definition.bundleId}, then building for ${device.name}`);
208
+ await prepareIosSite(definition, settings, { requireTeam: true });
209
+ const build = buildSite(settings, site, device.udid);
210
+ if (build.expiresAt.getTime() - Date.now() < 6 * DAY_MS) {
211
+ throw new ResignError(`${site}: Xcode signed with a profile that expires ${build.expiresAt.toISOString()}; remove it from Xcode's profile folder and run with --force.`);
212
+ }
213
+ await installWithRetry(device.udid, build.app);
214
+ const state: InstallState = { bundleId: build.bundleId, expiresAt: build.expiresAt.toISOString(), installedAt: new Date().toISOString() };
215
+ writeFileSync(statePath(site), `${JSON.stringify(state, null, 4)}\n`);
216
+ log(`${site}: installed ${build.bundleId}, runs until ${build.expiresAt.toLocaleString()}`);
217
+ renewed.push(site);
218
+ }
219
+ notify("stor re-signed", `${renewed.join(", ")} installed on ${device.name} for another 7 days.`);
220
+ }
221
+
222
+ function launchAgentPlist(settings: AppleSettings) {
223
+ const escape = (value: string) => value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
224
+ // The PATH entry survives `brew upgrade node`; process.execPath points into a versioned Cellar folder.
225
+ const node = spawnSync("/usr/bin/which", ["node"], { encoding: "utf8" }).stdout.trim() || process.execPath;
226
+ // The tenant's installed `hearthlog` bin runs the command (and the build before it) the way a terminal does.
227
+ const bin = path.join(path.dirname(createRequire(path.join(REPOSITORY_ROOT, "package.json")).resolve("@hearthlog/tooling/package.json")), "bin", "hearthlog.mjs");
228
+ const programArguments = [node, bin, "apple:resign"];
229
+ return `<?xml version="1.0" encoding="UTF-8"?>
230
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
231
+ <plist version="1.0">
232
+ <dict>
233
+ <key>Label</key><string>${labelFor(settings)}</string>
234
+ <key>ProgramArguments</key>
235
+ <array>
236
+ ${programArguments.map((argument) => ` <string>${escape(argument)}</string>`).join("\n")}
237
+ </array>
238
+ <key>WorkingDirectory</key><string>${escape(REPOSITORY_ROOT)}</string>
239
+ <key>EnvironmentVariables</key>
240
+ <dict><key>PATH</key><string>/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin</string></dict>
241
+ <!-- Missed runs during sleep fire on the next wake. -->
242
+ <key>StartCalendarInterval</key>
243
+ <dict><key>Hour</key><integer>3</integer><key>Minute</key><integer>30</integer></dict>
244
+ <key>StandardOutPath</key><string>${escape(logFileFor(settings))}</string>
245
+ <key>StandardErrorPath</key><string>${escape(logFileFor(settings))}</string>
246
+ </dict>
247
+ </plist>
248
+ `;
249
+ }
250
+
251
+ function installAgent() {
252
+ const settings = loadAppleSettings({ requireDevice: false });
253
+ const agent = launchAgentFor(settings);
254
+ mkdirSync(path.dirname(agent), { recursive: true });
255
+ writeFileSync(agent, launchAgentPlist(settings));
256
+ const domain = `gui/${userInfo().uid}`;
257
+ spawnSync("launchctl", ["bootout", `${domain}/${labelFor(settings)}`]);
258
+ run("launchctl", ["bootstrap", domain, agent]);
259
+ log(`Installed ${agent}; runs daily at 03:30, log at ${logFileFor(settings)}`);
260
+ }
261
+
262
+ function uninstallAgent() {
263
+ const settings = loadAppleSettings({ requireDevice: false });
264
+ spawnSync("launchctl", ["bootout", `gui/${userInfo().uid}/${labelFor(settings)}`]);
265
+ rmSync(launchAgentFor(settings), { force: true });
266
+ log(`Removed ${launchAgentFor(settings)}`);
267
+ }
268
+
269
+ const args = process.argv.slice(2);
270
+ try {
271
+ if (args[0] === "install") installAgent();
272
+ else if (args[0] === "uninstall") uninstallAgent();
273
+ else if (args[0] === "status") await resign({ force: false, dryRun: true });
274
+ else await resign({ force: args.includes("--force"), dryRun: args.includes("--dry-run") });
275
+ } catch (error) {
276
+ const message = error instanceof Error ? error.message : String(error);
277
+ log(`Failed: ${message}`);
278
+ notify("stor re-sign failed", message);
279
+ process.exitCode = 1;
280
+ }