diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 351304a..623becf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -201,6 +201,13 @@ jobs: - name: Launch the app and check it stays up run: ./scripts/macos-launch-check.sh + # And starting proves nothing about whether it can work. The app launched fine for every + # release while being unable to find its own Silero model anywhere but the build directory + # that produced it — invisible here, because the build machine is the test machine. This + # takes the build directory away and makes the bundle answer from its own contents. + - name: Check the bundle carries its own resources + run: ./scripts/macos-bundle-selfcontained.sh + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: always() with: diff --git a/Makefile b/Makefile index 897e424..eaebfc8 100644 --- a/Makefile +++ b/Makefile @@ -60,9 +60,15 @@ app: build @# instead of a commit it may no longer match. @/usr/libexec/PlistBuddy -c "Add :DNTBuildCommit string $$(git rev-parse --short HEAD 2>/dev/null || echo dev)" "$(CONTENTS)/Info.plist" @/usr/libexec/PlistBuddy -c "Add :DNTBuildTimestamp string $$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$(CONTENTS)/Info.plist" - @# SwiftPM keeps target resources in a sibling bundle. Bundle.module looks for that bundle in - @# Contents/Resources once the executable is wrapped as an app, so it must cross that boundary - @# with the binary. This carries the local Silero model and its licence notice. + @# SwiftPM keeps target resources in a sibling bundle, which has to cross into the app with the + @# binary. This carries the local Silero model and its licence notice. + @# + @# Contents/Resources is where a signed bundle must keep them, and it is NOT where SwiftPM's + @# generated Bundle.module looks — that checks the .app root and then an absolute path into the + @# build tree, so it resolved here only on the machine that compiled the binary. Every release + @# was therefore one recording away from a fatalError on anybody else's computer. The lookup now + @# lives in CoreResources.swift, and scripts/macos-bundle-selfcontained.sh hides the build + @# directory and asks the bundle to prove it does not need it. @cp -R "$(BUILD_DIR)/DoNotType_DoNotTypeCore.bundle" "$(CONTENTS)/Resources/" @# The contract ships inside the bundle so the app does not depend on the source tree. The @# directory layout is preserved, because a part is found by its path under prompt/. diff --git a/Sources/DoNotTypeCore/CoreResources.swift b/Sources/DoNotTypeCore/CoreResources.swift new file mode 100644 index 0000000..4424273 --- /dev/null +++ b/Sources/DoNotTypeCore/CoreResources.swift @@ -0,0 +1,78 @@ +import Foundation + +/// Finds this target's own resource bundle, without going through `Bundle.module`. +/// +/// SwiftPM generates `Bundle.module` as: +/// +/// Bundle.main.bundleURL.appendingPathComponent("DoNotType_DoNotTypeCore.bundle") +/// +/// falling back to an **absolute path into the machine that compiled the binary**. Neither is right +/// once the executable is wrapped in a `.app`: `Bundle.main.bundleURL` is the `.app` itself, and a +/// resource cannot live at the `.app` root — everything signed has to sit under `Contents/`, which +/// is why `make app` puts the bundle in `Contents/Resources/`. So the app layout can never satisfy +/// the generated accessor, and what actually resolved it was the compile-time fallback. +/// +/// That is why this survived so long. On the machine that built it the fallback directory exists, +/// so the app works everywhere it is tested; on a machine that merely *downloaded* it, the fallback +/// points at somebody else's `/Users/runner/work/...` and `Bundle.module` reaches its `fatalError`. +/// Every CI-built release was one recording away from dying that way, and no local run could show +/// it. Found by installing a release through Homebrew and transcribing one file with the `dnt` that +/// ships inside the bundle. +/// +/// Candidates are tried against the *resource*, not merely against whether a bundle loads, because +/// several of these paths resolve to a real bundle that does not carry it — the `.xctest` bundle +/// under `swift test` being the one that caught this list out first. +enum CoreResources { + static let bundleName = "DoNotType_DoNotTypeCore" + + /// Resolves one of this target's resources, or nil when no layout carries it. + /// + /// Deliberately nil rather than a trap. `Bundle.module` turns a missing resource into a + /// `fatalError`, which escalates a recoverable "this build has no local VAD" into a crash of + /// whatever process happened to touch it; the caller here already has an error for that case. + static func url(forResource name: String, withExtension ext: String) -> URL? { + for bundle in candidates { + if let url = bundle.url(forResource: name, withExtension: ext) { return url } + } + return nil + } + + /// Every bundle that might hold this target's resources, cheapest and most likely first. + private static let candidates: [Bundle] = { + // Bundle(for:) needs a class, and this target is otherwise structs and enums all the way + // down. Statically linked, it answers with whatever binary the core ended up inside. + let anchor = Bundle(for: ResourceAnchor.self) + + var directories: [URL] = [] + func consider(_ url: URL?) { + guard let url else { return } + if !directories.contains(url) { directories.append(url) } + } + + // The app layout (`Contents/Resources`) and the plain SwiftPM executable layout, where the + // sibling bundle sits in the same directory as the binary. One URL covers both. + consider(Bundle.main.resourceURL) + // What the generated accessor checks, kept so any layout satisfying it still works. + consider(Bundle.main.bundleURL) + // A framework or test host that embedded the core instead of statically linking it. + consider(anchor.resourceURL) + consider(anchor.bundleURL) + // `swift test`: the sibling bundle is beside the `.xctest`, not inside it. + consider(anchor.bundleURL.deletingLastPathComponent()) + consider(Bundle.main.bundleURL.deletingLastPathComponent()) + + var bundles: [Bundle] = [] + for directory in directories { + let url = directory.appendingPathComponent("\(bundleName).bundle") + if let bundle = Bundle(url: url) { bundles.append(bundle) } + } + // Some layouts (iOS among them) flatten a target's resources straight into the host bundle + // rather than keeping a sibling, so ask those two directly as well. + bundles.append(anchor) + bundles.append(Bundle.main) + return bundles + }() +} + +/// Only exists to give `Bundle(for:)` a class inside this module. +private final class ResourceAnchor {} diff --git a/Sources/DoNotTypeCore/SpeechActivity.swift b/Sources/DoNotTypeCore/SpeechActivity.swift index 3b6d392..1043fbf 100644 --- a/Sources/DoNotTypeCore/SpeechActivity.swift +++ b/Sources/DoNotTypeCore/SpeechActivity.swift @@ -52,6 +52,16 @@ public enum SpeechActivity { } } + /// Whether the bundled Silero model can actually be found in this build's layout. + /// + /// Exposed because "is the model there" is a property of how the binary was *packaged*, not of + /// the code, and the two disagreed for every release this project has cut: the model resolved + /// on any machine holding the build directory and on no other. A packaging mistake that only + /// appears on somebody else's computer needs something on this side of the network to ask. + public static var isModelAvailable: Bool { + CoreResources.url(forResource: "silero_vad", withExtension: "onnx") != nil + } + public static let sampleRate = 16_000 public static let windowSamples = 512 public static let threshold: Float = 0.5 @@ -285,7 +295,9 @@ private final class SileroModel: @unchecked Sendable { private let session: ORTSession init() throws { - guard let modelURL = Bundle.module.url(forResource: "silero_vad", withExtension: "onnx") + // Not `Bundle.module`: its generated lookup cannot succeed from inside a `.app`, and it + // traps rather than returning nil when it fails. See CoreResources. + guard let modelURL = CoreResources.url(forResource: "silero_vad", withExtension: "onnx") else { throw SpeechActivity.DetectorError.unavailable("silero_vad.onnx is missing") } let environment = try ORTEnv(loggingLevel: .warning) diff --git a/Sources/dnt/Doctor.swift b/Sources/dnt/Doctor.swift index 3f98807..1373b6d 100644 --- a/Sources/dnt/Doctor.swift +++ b/Sources/dnt/Doctor.swift @@ -111,6 +111,14 @@ struct Doctor: AsyncParsableCommand { section("Environment") row("macOS", ProcessInfo.processInfo.operatingSystemVersionString) row("opus encoder", OpusEncoder.isAvailable ? "available" : "UNAVAILABLE — uploads as WAV") + // Reported because it is a packaging property rather than a code one: the model is found + // relative to the binary, so a bundle can be built correctly and shipped wrong. Silence + // trimming and long-recording splitting both stop without it. + if SpeechActivity.isModelAvailable { + row("silero VAD", "available") + } else { + bad("silero VAD", "MISSING — this build cannot trim silence or split long recordings") + } row( "app settings", AppPreferences.isAvailable diff --git a/scripts/macos-bundle-selfcontained.sh b/scripts/macos-bundle-selfcontained.sh new file mode 100755 index 0000000..ccff67b --- /dev/null +++ b/scripts/macos-bundle-selfcontained.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# +# Checks that the built .app carries its own resources, rather than reaching back into the build +# directory that produced it. +# +# This exists because the opposite shipped, in every release this project has cut. SwiftPM's +# generated `Bundle.module` looks beside `Bundle.main.bundleURL` — the `.app` itself — and then +# falls back to an absolute path inside the build tree. `make app` puts the resource bundle under +# `Contents/Resources`, where a signed bundle must keep it, so the first location never matched and +# the fallback always did. On the machine that built it, that fallback exists. On a machine that +# downloaded it, it is somebody else's `/Users/runner/work/...` and the process dies on a +# `fatalError` the first time it needs the Silero model, which is every recording. +# +# Nothing in CI could see it: the build machine is also the test machine. So the check has to +# actively take the build directory away and then ask the bundle a question it can only answer from +# its own contents. `dnt doctor` reports the model, needs no API key and no network, which is what +# makes this runnable on every push. +set -euo pipefail + +APP="${1:-.build/DoNotType.app}" +# Absolute, because the check runs the binary from `/` to make sure nothing resolves by relative +# luck — and a relative APP would stop resolving the moment we left the checkout. +APP="$(cd "$(dirname "$APP")" && pwd)/$(basename "$APP")" +DNT="$APP/Contents/MacOS/dnt" + +[[ -x "$DNT" ]] || { + echo "✗ no dnt inside $APP — the CLI is supposed to ship in the bundle" >&2 + exit 1 +} + +# Every sibling resource bundle SwiftPM produced, whatever the configuration or arch triple. +# A while-read loop rather than `mapfile`, which is a bash 4 builtin: macOS ships bash 3.2, so +# `mapfile` works on a developer machine with Homebrew bash on PATH and fails on the CI runner. +bundles=() +while IFS= read -r line; do + [[ -n "$line" ]] && bundles+=("$line") +done < <(find .build -maxdepth 3 -name "DoNotType_*.bundle" -type d 2>/dev/null || true) + +moved=() +restore() { + for pair in "${moved[@]}"; do + mv "${pair#*|}" "${pair%|*}" + done +} +trap restore EXIT + +for bundle in "${bundles[@]}"; do + hidden="$bundle.hidden-by-selfcontained-check" + mv "$bundle" "$hidden" + moved+=("$bundle|$hidden") +done + +echo "hid ${#moved[@]} build-directory resource bundle(s); asking the app about itself" + +# Run from a directory that is not the checkout, so nothing resolves by relative luck either. +output=$(cd / && "$DNT" doctor 2>&1) || { + echo "$output" >&2 + echo "✗ dnt doctor failed inside the bundle with the build directory hidden" >&2 + exit 1 +} + +printf '%s\n' "$output" | grep -E "silero VAD|opus encoder" || true + +if printf '%s\n' "$output" | grep -q "silero VAD *available"; then + echo "✓ the bundle carries its own resources" +else + echo "$output" >&2 + echo >&2 + echo "✗ the app cannot find its Silero model without the build directory." >&2 + echo " That means a downloaded copy dies on the first recording. The resource bundle has to" >&2 + echo " be somewhere the binary looks from inside the .app — see CoreResources.swift." >&2 + exit 1 +fi