@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/LICENSE +235 -0
- package/package.json +35 -3
- package/resign.example.json +6 -0
- package/src/ios.ts +112 -0
- package/src/mac.ts +138 -0
- package/src/project.ts +365 -0
- package/src/resign.ts +280 -0
- package/src/site.ts +268 -0
- package/src/swift-checks.ts +35 -0
- package/swift/Shared/NavigationPolicy.swift +70 -0
- package/swift/Shared/ReminderScheduler.swift +65 -0
- package/swift/Shared/ShellMessage.swift +118 -0
- package/swift/Shared/SiteConfig.swift +47 -0
- package/swift/Shared/StorWebHost.swift +318 -0
- package/swift/Shared/WindowDragScript.swift +55 -0
- package/swift/Tests/main.swift +155 -0
- package/swift/iOS/AppDelegate.swift +20 -0
- package/swift/iOS/BackgroundFlush.swift +25 -0
- package/swift/iOS/SceneDelegate.swift +37 -0
- package/swift/iOS/SignInViewController.swift +62 -0
- package/swift/iOS/WebViewController.swift +196 -0
- package/swift/macOS/AppDelegate.swift +190 -0
- package/swift/macOS/AppMenu.swift +159 -0
- package/swift/macOS/DevTools.swift +85 -0
- package/swift/macOS/MainWindowController.swift +206 -0
- package/swift/macOS/SignInWindowController.swift +60 -0
- package/swift/macOS/StorWindow.swift +48 -0
- package/swift/macOS/TrafficLights.swift +115 -0
- package/swift/macOS/main.swift +9 -0
- package/README.md +0 -3
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("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
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
|
+
}
|