Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
12 changes: 9 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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/.
Expand Down
78 changes: 78 additions & 0 deletions Sources/DoNotTypeCore/CoreResources.swift
Original file line number Diff line number Diff line change
@@ -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 {}
14 changes: 13 additions & 1 deletion Sources/DoNotTypeCore/SpeechActivity.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions Sources/dnt/Doctor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
73 changes: 73 additions & 0 deletions scripts/macos-bundle-selfcontained.sh
Original file line number Diff line number Diff line change
@@ -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
Loading