Skip to content

Commit 4d129d0

Browse files
committed
Merge fix-bundle-module-launch-crash: the real cause of the launch crash
Bundle.module fatalErrors in any packaged .app, because its only resolvable candidate was an absolute .build path from the compiling machine. Reproduced locally by hiding .build (old build: Trace/BPT trap 5, exit 133; fixed build: runs), which matches the Mac mini crash report exactly.
2 parents 9b4dd58 + 7a1a386 commit 4d129d0

3 files changed

Lines changed: 243 additions & 10 deletions

File tree

Scripts/package-app.sh

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,21 @@ rm -rf "$APP"
1616
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
1717
cp "$BIN" "$APP/Contents/MacOS/HudsonApp"
1818

19-
# SwiftPM resource bundles (bundled fonts, etc.) go in Contents/Resources —
20-
# that's the FIRST location `Bundle.module` probes (via Bundle.main.resourceURL)
21-
# AND the only spot codesign accepts a nested bundle. Putting them in
22-
# Contents/MacOS launches fine unsigned but makes codesign reject the whole
19+
# SwiftPM resource bundles (bundled fonts, etc.) go in Contents/Resources
20+
# because that is the only spot codesign accepts a nested bundle. Putting them
21+
# in Contents/MacOS launches fine unsigned but makes codesign reject the whole
2322
# app as "bundle format unrecognized," so it must be Resources for notarization.
23+
#
24+
# This comment used to also claim Contents/Resources is "the FIRST location
25+
# Bundle.module probes (via Bundle.main.resourceURL)". That was wrong, and it
26+
# cost two shipped releases. The generated accessor probes
27+
# `Bundle.main.bundleURL` — the bundle ROOT, not its Resources directory — and
28+
# then an absolute .build path from the compiling machine, and `fatalError`s
29+
# when both miss. So placing the bundle here is correct for codesign and
30+
# invisible to `Bundle.module`, which is why HudsonUI now resolves its own
31+
# resources (see `Typography.bundledFontURLs`) instead of relying on it.
32+
# Anything that reads bundled resources must do the same or it will crash on
33+
# every Mac except this one.
2434
shopt -s nullglob
2535
for b in ".build/$CONFIG"/*.bundle; do cp -R "$b" "$APP/Contents/Resources/"; done
2636

Sources/HudsonUI/Theme/Typography.swift

Lines changed: 71 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,19 +18,84 @@ public enum Typography {
1818
// safe, no lock needed.
1919
private static var didRegister = false
2020

21-
/// Registers every `.ttf` bundled under `Resources/Fonts`. Enumerates the
22-
/// directory rather than hard-coding filenames, so a missing or renamed
23-
/// face never crashes registration — it just doesn't get picked up, and
24-
/// `serif`/`ui` fall back to a system font.
21+
/// Registers every bundled `.ttf`. Enumerates the bundle rather than
22+
/// hard-coding filenames, so a missing or renamed face never crashes
23+
/// registration — it just doesn't get picked up, and `serif`/`ui` fall back
24+
/// to a system font.
2525
public static func register() {
2626
guard !didRegister else { return }
2727
didRegister = true
28-
let fonts = Bundle.module.urls(forResourcesWithExtension: "ttf", subdirectory: "Fonts") ?? []
29-
for url in fonts {
28+
for url in bundledFontURLs() {
3029
CTFontManagerRegisterFontsForURL(url as CFURL, .process, nil)
3130
}
3231
}
3332

33+
/// The name SwiftPM gives HudsonUI's resource bundle. Derived from
34+
/// `<package>_<target>`, so it changes only if the package or target is
35+
/// renamed — at which point `bundledFontURLs()` returns empty and the app
36+
/// renders in system faces rather than failing to launch.
37+
/// `nonisolated` for the same reason as the lookups below: it is an
38+
/// immutable constant, so actor isolation buys nothing and only forces
39+
/// callers to hop.
40+
nonisolated static let resourceBundleName = "Hudson_HudsonUI.bundle"
41+
42+
/// Every bundled `.ttf`, located WITHOUT ever touching `Bundle.module`.
43+
///
44+
/// Touching it at all was the bug. SwiftPM's generated `Bundle.module`
45+
/// probes exactly two paths and calls `fatalError` when both miss: the app
46+
/// bundle ROOT (`Bundle.main.bundleURL`, NOT its `Resources` directory),
47+
/// and an absolute path into the `.build` directory of the machine that
48+
/// compiled the binary. Neither survives packaging. `codesign` requires a
49+
/// nested resource bundle to sit in `Contents/Resources`, which is not the
50+
/// root, and a user's Mac has no `.build` directory — so `Bundle.module`
51+
/// resolved on the maintainer's machine via the build path and hard-crashed
52+
/// everywhere else, inside the first `Typography.serif` call that renders
53+
/// `RootView.loadingPlaceholder`. The app died before drawing one frame.
54+
///
55+
/// An empty return is a legitimate outcome, not a failure to report:
56+
/// `resolved(_:size:weight:fallback:)` falls back to system faces, so a
57+
/// missing font costs fidelity. It must never cost a launch.
58+
/// `nonisolated` because it reads only the filesystem and its arguments —
59+
/// the main-actor isolation on this enum exists for `didRegister`, and
60+
/// borrowing it here would force every caller and test onto the main actor
61+
/// for a pure lookup.
62+
nonisolated static func bundledFontURLs(searchPaths: [URL] = resourceSearchPaths()) -> [URL] {
63+
for directory in searchPaths {
64+
guard let bundle = Bundle(url: directory.appending(path: resourceBundleName))
65+
else { continue }
66+
// SwiftPM FLATTENS `Resources/Fonts/*.ttf` into the bundle root, so
67+
// the original `subdirectory: "Fonts"` matched nothing and the faces
68+
// never registered even on the machine where the lookup succeeded —
69+
// the app has been rendering in system fonts throughout. Both
70+
// layouts are probed so that neither a change in SwiftPM's
71+
// flattening nor a deliberate move back into a folder silently
72+
// drops the fonts again.
73+
let found = (bundle.urls(forResourcesWithExtension: "ttf", subdirectory: nil) ?? [])
74+
+ (bundle.urls(forResourcesWithExtension: "ttf", subdirectory: "Fonts") ?? [])
75+
if !found.isEmpty { return found }
76+
}
77+
return []
78+
}
79+
80+
/// Where the nested resource bundle can legitimately sit, in the order the
81+
/// three shipping shapes actually occur:
82+
/// - a packaged `.app` → `Contents/Resources`
83+
/// - `swift run HudsonApp` → beside the executable in `.build/<config>`
84+
/// - the test runner → also `.build/<config>`, next to the `.xctest`
85+
///
86+
/// Probing all three is what makes the packaged app and the dev loop agree.
87+
/// The old code only ever worked in the dev loop, which is precisely why the
88+
/// break reached a user before it reached a test.
89+
nonisolated static func resourceSearchPaths() -> [URL] {
90+
var paths: [URL] = []
91+
if let resources = Bundle.main.resourceURL { paths.append(resources) }
92+
paths.append(Bundle.main.bundleURL)
93+
if let executableDirectory = Bundle.main.executableURL?.deletingLastPathComponent() {
94+
paths.append(executableDirectory)
95+
}
96+
return paths
97+
}
98+
3499
public static func serif(_ size: CGFloat, _ weight: Font.Weight = .regular) -> Font {
35100
register()
36101
return resolved(serifCandidates, size: size, weight: weight, fallback: .serif)
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import Foundation
2+
import Testing
3+
4+
@testable import HudsonUI
5+
6+
// MARK: - The regression tests for the 0.1.1 launch crash
7+
//
8+
// Hudson 0.1.0 and 0.1.1 died on launch on every Mac except the maintainer's,
9+
// inside `Bundle.module`:
10+
//
11+
// 0 libswiftCore _assertionFailure(_:_:file:line:flags:)
12+
// 1 HudsonApp closure #1 in variable initialization expression of
13+
// static NSBundle.module
14+
// 5 HudsonApp specialized static Typography.register()
15+
// 6 HudsonApp specialized static Typography.serif(_:_:)
16+
// 7 HudsonApp closure #1 in RootView.loadingPlaceholder.getter
17+
//
18+
// SwiftPM's generated accessor probes the app bundle ROOT and an absolute
19+
// `.build` path from the compiling machine, then calls `fatalError`. A packaged
20+
// .app matches neither: codesign puts the nested bundle in Contents/Resources,
21+
// and a user's Mac has no .build directory.
22+
//
23+
// These tests drive `bundledFontURLs(searchPaths:)` against the real on-disk
24+
// layouts rather than the ambient one, because the ambient layout is exactly
25+
// what hid the bug — the dev loop resolved, so nothing ever exercised the
26+
// shape a user receives.
27+
28+
/// Builds a throwaway resource bundle holding one file named like a font.
29+
///
30+
/// The content is not a real typeface: every assertion here is about whether
31+
/// the URL is *found*, and CoreText never sees these. Keeping it fake also
32+
/// keeps the tests independent of which faces the app happens to ship.
33+
private func makeResourceBundle(
34+
at directory: URL, fontSubdirectory: String? = nil
35+
) throws -> URL {
36+
let bundle = directory.appending(path: Typography.resourceBundleName)
37+
let fontDirectory = fontSubdirectory.map { bundle.appending(path: $0) } ?? bundle
38+
try FileManager.default.createDirectory(at: fontDirectory, withIntermediateDirectories: true)
39+
try Data("not a real typeface".utf8)
40+
.write(to: fontDirectory.appending(path: "Newsreader.ttf"))
41+
return bundle
42+
}
43+
44+
private func makeScratchDirectory() throws -> URL {
45+
let directory = URL(fileURLWithPath: NSTemporaryDirectory())
46+
.appending(path: "TypographyBundleTests-\(UUID().uuidString)")
47+
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
48+
return directory
49+
}
50+
51+
// MARK: - The shape a user actually receives
52+
53+
/// **The regression test for the reported crash.**
54+
///
55+
/// Reproduces a packaged `.app`: `Hudson.app/Contents/Resources/` holds the
56+
/// nested resource bundle, and `Hudson.app/` itself does not. That is the one
57+
/// arrangement `Bundle.module` cannot resolve, and the one every download has.
58+
@Test func findsFontsInAPackagedAppResourcesDirectory() throws {
59+
let scratch = try makeScratchDirectory()
60+
defer { try? FileManager.default.removeItem(at: scratch) }
61+
62+
let appBundle = scratch.appending(path: "Hudson.app")
63+
let resources = appBundle.appending(path: "Contents/Resources")
64+
try FileManager.default.createDirectory(at: resources, withIntermediateDirectories: true)
65+
_ = try makeResourceBundle(at: resources)
66+
67+
let found = Typography.bundledFontURLs(searchPaths: [resources, appBundle])
68+
#expect(found.count == 1)
69+
#expect(found.first?.lastPathComponent == "Newsreader.ttf")
70+
}
71+
72+
/// The bundle root is searched too, which is the `swift run HudsonApp` layout:
73+
/// SwiftPM drops `Hudson_HudsonUI.bundle` beside the executable rather than
74+
/// inside a `Resources` directory. Both shapes have to work from one code path,
75+
/// or the dev loop and the shipped app disagree again.
76+
@Test func findsFontsBesideTheExecutable() throws {
77+
let scratch = try makeScratchDirectory()
78+
defer { try? FileManager.default.removeItem(at: scratch) }
79+
_ = try makeResourceBundle(at: scratch)
80+
81+
#expect(Typography.bundledFontURLs(searchPaths: [scratch]).count == 1)
82+
}
83+
84+
// MARK: - The second bug: the fonts never registered at all
85+
86+
/// SwiftPM flattens `Resources/Fonts/*.ttf` to the bundle root, so the original
87+
/// `subdirectory: "Fonts"` matched nothing and no bundled face was ever
88+
/// registered — including on the maintainer's Mac, where the app quietly
89+
/// rendered in system fonts instead of Newsreader.
90+
@Test func findsFontsFlattenedIntoTheBundleRoot() throws {
91+
let scratch = try makeScratchDirectory()
92+
defer { try? FileManager.default.removeItem(at: scratch) }
93+
_ = try makeResourceBundle(at: scratch, fontSubdirectory: nil)
94+
95+
let found = Typography.bundledFontURLs(searchPaths: [scratch])
96+
#expect(found.count == 1, "a flattened bundle is the layout SwiftPM actually produces")
97+
}
98+
99+
/// The nested layout is probed as well, so a future SwiftPM that stops
100+
/// flattening — or a deliberate move back into a folder — does not silently
101+
/// strip the typography again.
102+
@Test func findsFontsInsideANestedFontsDirectory() throws {
103+
let scratch = try makeScratchDirectory()
104+
defer { try? FileManager.default.removeItem(at: scratch) }
105+
_ = try makeResourceBundle(at: scratch, fontSubdirectory: "Fonts")
106+
107+
#expect(Typography.bundledFontURLs(searchPaths: [scratch]).count == 1)
108+
}
109+
110+
// MARK: - Absence must cost fidelity, never a launch
111+
112+
/// The crash was not "the fonts are missing", it was "the app decided a missing
113+
/// bundle is fatal". A resolver that finds nothing has to return empty and let
114+
/// `resolved(_:size:weight:fallback:)` fall back to system faces.
115+
@Test func returnsEmptyRatherThanCrashingWhenNoBundleExists() throws {
116+
let scratch = try makeScratchDirectory()
117+
defer { try? FileManager.default.removeItem(at: scratch) }
118+
119+
#expect(Typography.bundledFontURLs(searchPaths: [scratch]).isEmpty)
120+
}
121+
122+
/// An empty search path list is the degenerate version of the same guarantee.
123+
@Test func returnsEmptyForNoSearchPathsAtAll() {
124+
#expect(Typography.bundledFontURLs(searchPaths: []).isEmpty)
125+
}
126+
127+
/// A bundle directory that exists but holds no `.ttf` must also be survivable —
128+
/// `Bundle(url:)` succeeds on any directory, so this is a reachable state
129+
/// whenever a build drops the fonts.
130+
@Test func returnsEmptyForABundleContainingNoFonts() throws {
131+
let scratch = try makeScratchDirectory()
132+
defer { try? FileManager.default.removeItem(at: scratch) }
133+
try FileManager.default.createDirectory(
134+
at: scratch.appending(path: Typography.resourceBundleName),
135+
withIntermediateDirectories: true)
136+
137+
#expect(Typography.bundledFontURLs(searchPaths: [scratch]).isEmpty)
138+
}
139+
140+
// MARK: - Registration itself
141+
142+
/// `register()` is the frame that crashed. Calling it must be safe regardless
143+
/// of what the ambient layout holds, and must stay idempotent.
144+
@Test @MainActor func registerIsSafeAndIdempotent() {
145+
Typography.register()
146+
Typography.register()
147+
// Reaching here at all is the assertion: the old implementation trapped
148+
// inside Bundle.module on any machine without the compiling .build path.
149+
#expect(Bool(true))
150+
}
151+
152+
/// The real ambient lookup must never trap in the environment the suite runs
153+
/// in, which is the third shipping shape (`.build/<config>` beside the
154+
/// `.xctest`).
155+
@Test func ambientSearchPathsResolveWithoutTrapping() {
156+
#expect(!Typography.resourceSearchPaths().isEmpty)
157+
_ = Typography.bundledFontURLs()
158+
}

0 commit comments

Comments
 (0)