diff --git a/apps/darkroom-desktop/src/main.rs b/apps/darkroom-desktop/src/main.rs index d7a5f75..3da5298 100644 --- a/apps/darkroom-desktop/src/main.rs +++ b/apps/darkroom-desktop/src/main.rs @@ -124,5 +124,21 @@ fn runtime_dirs() -> Vec { PathBuf::from("/usr/lib/darkroom"), PathBuf::from("/usr/lib"), ]); + // An app bundle keeps its libraries in `Contents/Frameworks`, beside + // the `Contents/MacOS` the executable is in; then Homebrew's + // `onnxruntime`, Apple silicon's prefix before Intel's. Homebrew's build + // may lack CoreML, which the probe finds out for itself. + #[cfg(target_os = "macos")] + { + if let Ok(exe) = std::env::current_exe() { + if let Some(bin) = exe.parent() { + dirs.push(bin.join("../Frameworks")); + } + } + dirs.extend([ + PathBuf::from("/opt/homebrew/lib"), + PathBuf::from("/usr/local/lib"), + ]); + } dirs } diff --git a/core/dr-inference-engine/Cargo.toml b/core/dr-inference-engine/Cargo.toml index 539254e..a9bd139 100644 --- a/core/dr-inference-engine/Cargo.toml +++ b/core/dr-inference-engine/Cargo.toml @@ -38,6 +38,11 @@ ort = { workspace = true, features = ["cuda", "tensorrt"] } [target.'cfg(target_os = "android")'.dependencies] ort = { workspace = true, features = ["qnn"] } +# The Apple rung: CoreML's option builder, which fills the runtime's generic +# key/value map. `ort-sys`'s `coreml` feature is empty; nothing links. +[target.'cfg(target_os = "macos")'.dependencies] +ort = { workspace = true, features = ["coreml"] } + [features] # The floor: `tract` supplies the API table when no runtime file is found, or # always, in a build without `native`. Tests want this and nothing else. diff --git a/core/dr-inference-engine/src/engines.rs b/core/dr-inference-engine/src/engines.rs index 3a8670a..d752735 100644 --- a/core/dr-inference-engine/src/engines.rs +++ b/core/dr-inference-engine/src/engines.rs @@ -44,6 +44,20 @@ pub fn context_path(cfg: &Config, bytes: &[u8]) -> PathBuf { .join(format!("{:016x}_ctx.onnx", hash(bytes))) } +/// Where CoreML compiles `bytes` to: one directory per model, because +/// CoreML's own cache key leaves out the weights of a model loaded from +/// memory (`session::coreml`), and one per runtime version, which wrote it. +pub fn coreml_dir(cfg: &Config, bytes: &[u8]) -> PathBuf { + let runtime = match crate::api::runtime() { + crate::Runtime::OnnxRuntime { version, .. } => version, + crate::Runtime::Tract => "tract".into(), + }; + cfg.cache_dir + .join("coreml") + .join(runtime) + .join(format!("{:016x}", hash(bytes))) +} + /// After the probe: compile every configured model the selected rung can /// take, smallest first, recording each as it lands. pub fn run() { diff --git a/core/dr-inference-engine/src/lib.rs b/core/dr-inference-engine/src/lib.rs index 7b96c47..80d2b4e 100644 --- a/core/dr-inference-engine/src/lib.rs +++ b/core/dr-inference-engine/src/lib.rs @@ -83,6 +83,12 @@ pub enum Rung { MiGraphX, /// Qualcomm's Hexagon NPU through QNN, int8 models only. Android only. Hexagon, + /// Apple, through CoreML: the Neural Engine, the GPU or the CPU, as + /// CoreML schedules it. macOS only. Compiles an ML Program per model on + /// first use, so it is a compiling rung with the CPU below it. The + /// embedder stays on the CPU, as on the Hexagon: the Neural Engine + /// computes in fp16 (§7). + CoreMl, } impl Rung { @@ -93,6 +99,7 @@ impl Rung { Rung::TensorRt => "TensorRT", Rung::MiGraphX => "MIGraphX", Rung::Hexagon => "Hexagon NPU", + Rung::CoreMl => "CoreML", } } @@ -101,13 +108,16 @@ impl Rung { fn fallback(self) -> Rung { match self { Rung::TensorRt => Rung::Cuda, - Rung::MiGraphX | Rung::Hexagon | Rung::Cuda | Rung::Cpu => Rung::Cpu, + Rung::MiGraphX | Rung::Hexagon | Rung::CoreMl | Rung::Cuda | Rung::Cpu => Rung::Cpu, } } /// Whether a session on this rung needs an engine built first. fn compiles(self) -> bool { - matches!(self, Rung::TensorRt | Rung::MiGraphX | Rung::Hexagon) + matches!( + self, + Rung::TensorRt | Rung::MiGraphX | Rung::Hexagon | Rung::CoreMl + ) } /// The model form this rung wants for a role. @@ -122,10 +132,13 @@ impl Rung { /// only, and the embedder is never int8 (§7) — it runs on the CPU /// beside a detector on the NPU, so its vectors compare across devices. /// Nor is the denoiser: its int8 form failed the 0.5 dB gate by 6–9 dB - /// (denoise.md §8), so it runs on the CPU there too. + /// (denoise.md §8), so it runs on the CPU there too. CoreML is kept off + /// the embedder for the same reason as the Hexagon: the Neural Engine is + /// fp16, and which unit runs a graph is CoreML's choice. fn serves(self, role: Role) -> bool { match self { Rung::Hexagon => !matches!(role, Role::Embedder | Role::Denoiser), + Rung::CoreMl => role != Role::Embedder, _ => true, } } @@ -645,6 +658,27 @@ mod tests { ); } + #[test] + fn coreml_takes_a_compiled_detector_and_never_the_embedder() { + let hash = engines::hash(b"detector"); + let mut s = State { + config: Config::default(), + cache: Cache { + rung: Some(Rung::CoreMl), + ..Cache::default() + }, + probing: false, + wanted: 0, + }; + let on = |s: &State, role| effective_rung(s, Rung::CoreMl, role, Form::F32, hash); + // Before its program is compiled the detector waits on the CPU. + assert_eq!(on(&s, Role::Detector), Rung::Cpu); + s.cache.compiled.insert(engines::key_of(Rung::CoreMl, hash)); + assert_eq!(on(&s, Role::Detector), Rung::CoreMl); + // The embedder does not move, compiled or not (§7). + assert_eq!(on(&s, Role::Embedder), Rung::Cpu); + } + #[test] fn the_status_reports_only_the_rungs_above_the_selection() { let _serial = serial(); diff --git a/core/dr-inference-engine/src/probe.rs b/core/dr-inference-engine/src/probe.rs index 0e0de85..e3a866c 100644 --- a/core/dr-inference-engine/src/probe.rs +++ b/core/dr-inference-engine/src/probe.rs @@ -16,10 +16,15 @@ use crate::{api::Runtime, state, Cache, Config, Form, Role, Rung}; fn ladder(ceiling: Option) -> Vec { #[cfg(target_os = "android")] let all = [Rung::Hexagon]; + // Unmeasured (§2 ⁵): it is on the ladder because the probe's clock and + // `attempt` make a wrong guess cost one slow or failed probe, not a + // slow or crashing app. + #[cfg(target_os = "macos")] + let all = [Rung::CoreMl]; // A desktop has one vendor's GPU; the other vendor's providers are // "not enabled in this build" or a library that fails to load, and // either answer arrives in milliseconds. - #[cfg(not(target_os = "android"))] + #[cfg(not(any(target_os = "android", target_os = "macos")))] let all = [Rung::TensorRt, Rung::Cuda, Rung::MiGraphX]; all.into_iter() .filter(|r| ceiling.is_none_or(|c| *r <= c)) @@ -372,7 +377,50 @@ fn system_property(name: &str) -> String { String::from_utf8_lossy(&buf[..n.max(0) as usize]).into_owned() } -#[cfg(not(any(target_os = "linux", target_os = "android")))] +#[cfg(target_os = "macos")] +fn device_identity() -> String { + // The chip, and the OS release: CoreML ships with the OS, so a macOS + // update is a new provider as surely as a new driver is on Linux. + format!( + "{} macOS {}", + sysctl("machdep.cpu.brand_string"), + sysctl("kern.osproductversion") + ) +} + +#[cfg(target_os = "macos")] +fn sysctl(name: &str) -> String { + extern "C" { + fn sysctlbyname( + name: *const std::ffi::c_char, + oldp: *mut std::ffi::c_void, + oldlenp: *mut usize, + newp: *mut std::ffi::c_void, + newlen: usize, + ) -> i32; + } + let name = std::ffi::CString::new(name).unwrap(); + let mut buf = [0u8; 256]; + let mut len = buf.len(); + // SAFETY: libSystem's documented call; `len` is the buffer's size in and + // the string's length, with its terminator, out. + let rc = unsafe { + sysctlbyname( + name.as_ptr(), + buf.as_mut_ptr().cast(), + &mut len, + std::ptr::null_mut(), + 0, + ) + }; + if rc != 0 { + return String::new(); + } + let s = &buf[..len.min(buf.len())]; + String::from_utf8_lossy(s.strip_suffix(&[0]).unwrap_or(s)).into_owned() +} + +#[cfg(not(any(target_os = "linux", target_os = "android", target_os = "macos")))] fn device_identity() -> String { String::new() } diff --git a/core/dr-inference-engine/src/session.rs b/core/dr-inference-engine/src/session.rs index 0314b80..fdc4ed2 100644 --- a/core/dr-inference-engine/src/session.rs +++ b/core/dr-inference-engine/src/session.rs @@ -27,13 +27,14 @@ pub fn build(rung: Rung, role: Role, bytes: &[u8], cfg: &Config) -> ort::Result< // what makes the second case rare (§6). let context = (rung == Rung::Hexagon).then(|| crate::engines::context_path(cfg, bytes)); let ready = context.as_ref().is_some_and(|p| p.is_file()); - b = providers( - b, - rung, - role, - cfg, - if ready { None } else { context.as_deref() }, - )?; + // What the rung keeps for this model: the context the Hexagon is to + // write, or the directory CoreML compiles into. + let per_model = match rung { + Rung::CoreMl => Some(crate::engines::coreml_dir(cfg, bytes)), + _ if ready => None, + _ => context.clone(), + }; + b = providers(b, rung, role, cfg, per_model.as_deref())?; match (ready, context) { (true, Some(path)) => b.commit_from_file(path), _ => b.commit_from_memory(bytes), @@ -90,11 +91,12 @@ fn providers( rung: Rung, role: Role, cfg: &Config, - _generate_context: Option<&std::path::Path>, + per_model: Option<&std::path::Path>, ) -> ort::Result { use ort::ep; match rung { Rung::Cpu => Ok(b), + Rung::CoreMl => coreml(b, per_model), Rung::Cuda => { Ok(b.with_execution_providers([ep::CUDA::default().build().error_on_failure()])?) } @@ -139,6 +141,43 @@ fn providers( } } +/// CoreML, compiling an ML Program — the format with the operators these +/// graphs use and the one that reaches the Neural Engine — into `cache`. +/// +/// The option names are those ONNX Runtime 1.29 reads from the generic +/// key/value map (`coreml_options.cc`), which is what `ort`'s builder +/// fills. The cache is per model because of how CoreML keys it: a model +/// committed from memory, as every session here is, has no path, and the +/// key falls back to a hash of the graph's input and node names — not its +/// weights. Two exports of one architecture would share a program. The +/// directory `engines::coreml_dir` names is the hash of the bytes. +/// +/// Every compute unit is allowed, so CoreML may place a graph on the +/// Neural Engine, the GPU or the CPU; the probe's clock judges the result. +#[cfg(target_os = "macos")] +fn coreml( + b: ort::session::builder::SessionBuilder, + cache: Option<&std::path::Path>, +) -> ort::Result { + use ort::ep::{self, coreml}; + let mut ep = ep::CoreML::default() + .with_model_format(coreml::ModelFormat::MLProgram) + .with_compute_units(coreml::ComputeUnits::All); + if let Some(dir) = cache { + let _ = std::fs::create_dir_all(dir); + ep = ep.with_model_cache_dir(dir.to_string_lossy()); + } + Ok(b.with_execution_providers([ep.build().error_on_failure()])?) +} + +#[cfg(not(any(target_os = "android", target_os = "macos")))] +fn coreml( + _b: ort::session::builder::SessionBuilder, + _cache: Option<&std::path::Path>, +) -> ort::Result { + unreachable!("the CoreML rung is on the macOS ladder only") +} + /// Register MIGraphX through ONNX Runtime's generic key/value entry point. /// /// `ort`'s own builder (`ep::MIGraphX`) fills the legacy @@ -211,8 +250,8 @@ fn providers( .build() .error_on_failure()])?) } - Rung::Cuda | Rung::TensorRt | Rung::MiGraphX => { - unreachable!("no desktop GPU rung on Android") + Rung::Cuda | Rung::TensorRt | Rung::MiGraphX | Rung::CoreMl => { + unreachable!("no desktop rung on Android") } } } diff --git a/docs/dev/inference.md b/docs/dev/inference.md index 8e61106..df66133 100644 --- a/docs/dev/inference.md +++ b/docs/dev/inference.md @@ -151,10 +151,14 @@ winning: | Linux / Windows, NVIDIA GPU | TensorRT, f32 model, fp16 engine | CUDA provider, f32 | ORT CPU, f32 | tract | | Linux, AMD GPU with ROCm | MIGraphX, f32 model, fp16 program | ORT CPU, f32 | — | tract | | Linux / Windows, no GPU stack | ORT CPU, f32 | — | — | tract | -| macOS ⁵ | ORT CPU, f32 | — | — | tract | +| macOS ⁵ | CoreML, f32 model, ML Program | ORT CPU, f32 | — | tract | -⁵ CoreML is the obvious rung and is unmeasured; it is listed so its absence is a gap and not an -oversight. +⁵ **Unmeasured**, and the one exception to the rule below: nobody here has a Mac. The rung is on +the ladder because the probe makes a wrong guess cheap — a CoreML that is slower than the CPU is +rejected by §4's clock, one that errors is recorded as failed, and one that takes the process +down is refused on the third launch (§4, `attempt`). The embedder stays on the CPU (§7). The first +macOS log that shows a probe line is this row's measurement; [macos.md](macos.md) says what to +ask for. Deliberately **not** on any ladder, with the measurement that excluded each: NNAPI (no driver), XNNPACK (slower than CPU, aborts on SCRFD), WebGPU (slower than CPU), the Adreno through QNN (works, diff --git a/docs/dev/macos.md b/docs/dev/macos.md new file mode 100644 index 0000000..812dc9d --- /dev/null +++ b/docs/dev/macos.md @@ -0,0 +1,78 @@ +# macOS + +macOS is out of scope for v1 ([requirements.md](requirements.md)), and nobody working on +DarkRoom has a Mac. This page records what exists anyway, and how a macOS build is set up so +that someone who does have one can send back enough to fix what they hit. + +## 1. What exists + +- **Inference** ([inference.md §2](inference.md)). The macOS ladder is CoreML, then ONNX + Runtime's CPU provider, then tract. CoreML is unmeasured. The probe decides whether it is used, + and the crash guard (§4, `attempt`) covers the case where the provider takes the process down. + The device fingerprint is the chip (`machdep.cpu.brand_string`) and the OS release, because + CoreML ships with the OS. +- **Where files go** ([`dr_plat::dirs`](../../platform/dr-plat/src/dirs.rs)). The Unix rules, + except the state directory (the log and crash records), which is `~/Library/Logs/darkroom`. +- **A diagnostic build**, described in §3. + +The rest is not built, packaged or run on macOS by anyone here. This covers the window, +Metal through wgpu, the display profile (FR-DSP-8 asks X11 and Wayland), the keyring, the +bundle, and signing. `dr-plat` sends every non-Android Unix to the X11/Wayland dependencies. + +## 2. Building + +The Rust side compile-checks from Linux: + + rustup target add aarch64-apple-darwin + cargo check --target aarch64-apple-darwin -p dr-inference-engine --features native + +Linking needs Apple's SDK, which means a Mac. On one: + + cargo build --profile diagnostic -p darkroom-desktop + ./tools/fetch-desktop-runtime.sh # ONNX Runtime 1.29.0 with CoreML, Apple silicon only + +The fetch script puts `libonnxruntime.dylib` in the user's `runtime/` directory, next to the +models. The app also looks in `Contents/Frameworks` of its own bundle, and in Homebrew's +`/opt/homebrew/lib` and `/usr/local/lib`. Homebrew's build may not include CoreML; the probe +reports that as a failed rung and uses the CPU. + +**For whoever packages it.** A notarised app runs with the hardened runtime, whose library +validation refuses to `dlopen` a library signed by another team. A bundled +`Contents/Frameworks/libonnxruntime.dylib` must be signed with the app. A runtime the user +fetched needs the `com.apple.security.cs.disable-library-validation` entitlement, or it will not +load, and the app will be the tract build without saying why beyond one log line. + +## 3. The diagnostic build + +Every macOS build is in the hands of someone who can send a log but cannot attach a debugger, +so it is set up to log like a debug build while running at release speed. + +- **The log says more.** With no `RUST_LOG`, the desktop's default filter is `debug` for every + `dr_*` crate, for `darkroom_desktop`, and for `onnxruntime`. That last one is ONNX Runtime's + own session log, which the engine forwards into `log` on every platform (`session.rs`, + `with_runtime_log`). At `debug` it includes how many nodes each provider took. At `trace` + (`RUST_LOG=onnxruntime=trace`) it lists every node's placement, which is long. The log cap is + the same as everywhere (two files of 4 MiB). +- **Backtraces have line numbers.** `--profile diagnostic` is release plus line tables. On + macOS the tables go into a `.dSYM` beside the executable, and the backtrace in a crash record + finds them only if the `.dSYM` stays next to the binary. Keep it in the bundle. + +## 4. What to ask a Mac user for + +`~/Library/Logs/darkroom/darkroom.log`, plus `darkroom.log.1` if present, after the first launch +and after the first scan with faces. Console.app lists it under *Log Reports*. The Settings +diagnostics bundle collects the same files. The lines that answer the open questions are: + +| Line | What it tells us | +|---|---| +| `inference: ONNX Runtime … from …` / `inference: runtime tract` | Whether a runtime was found, and which one | +| `inference: floor … ms on the CPU provider` | The CPU number for §2's table | +| `inference: CoreML session built in … s` | CoreML's first compile of the probe model | +| `inference: CoreML rejected: …` / `failed: …` | Why the CPU was kept | +| `onnxruntime` lines naming `CoreMLExecutionProvider::GetCapability` | How much of the graph CoreML took | +| `inference: the app died during …` | The crash guard fired, and on what | +| `inference: compiling … for CoreML` / `ready on CoreML in … s` | Each model's compile, and any that CoreML refused | + +Also ask for the settings row (*Settings › About › Inference*), which is one line and says the +same in short. When one of these logs comes back with CoreML numbers, they go into +[inference.md §1–2](inference.md), and footnote ⁵ becomes a measurement. diff --git a/docs/dev/traceability.md b/docs/dev/traceability.md index 1c302c3..5b5845b 100644 --- a/docs/dev/traceability.md +++ b/docs/dev/traceability.md @@ -146,7 +146,7 @@ _None._ | FR-PLAT-LIN-2 | [`platform/dr-plat/src/display.rs:1`](../../platform/dr-plat/src/display.rs#L1), [`platform/dr-plat/src/display/wayland.rs:1`](../../platform/dr-plat/src/display/wayland.rs#L1), [`platform/dr-plat/src/display/x11.rs:1`](../../platform/dr-plat/src/display/x11.rs#L1) | | FR-PLAT-WIN-1 | [`platform/dr-plat/src/dirs.rs:1`](../../platform/dr-plat/src/dirs.rs#L1) | | FR-PLAT-WIN-2 | [`apps/darkroom-desktop/build.rs:1`](../../apps/darkroom-desktop/build.rs#L1), [`apps/darkroom-desktop/src/main.rs:6`](../../apps/darkroom-desktop/src/main.rs#L6), [`ui/dr-ui/src/launch_ui.rs:937`](../../ui/dr-ui/src/launch_ui.rs#L937) | -| FR-PLAT-WIN-3 | [`apps/darkroom-desktop/src/main.rs:19`](../../apps/darkroom-desktop/src/main.rs#L19) | +| FR-PLAT-WIN-3 | [`apps/darkroom-desktop/src/main.rs:35`](../../apps/darkroom-desktop/src/main.rs#L35) | | FR-RAW-1 | [`core/dr-decode/src/lib.rs:266`](../../core/dr-decode/src/lib.rs#L266), [`core/dr-types/src/lib.rs:135`](../../core/dr-types/src/lib.rs#L135), [`core/dr-types/src/lib.rs:206`](../../core/dr-types/src/lib.rs#L206) | | FR-RAW-2 | [`core/dr-decode/src/decoder.rs:1`](../../core/dr-decode/src/decoder.rs#L1), [`core/dr-decode/src/decoder.rs:26`](../../core/dr-decode/src/decoder.rs#L26), [`core/dr-decode/src/decoder.rs:59`](../../core/dr-decode/src/decoder.rs#L59), [`core/dr-decode/src/decoder.rs:95`](../../core/dr-decode/src/decoder.rs#L95), [`ui/dr-ui/src/decoder_seam.rs:185`](../../ui/dr-ui/src/decoder_seam.rs#L185), [`ui/dr-ui/src/decoder_seam.rs:1`](../../ui/dr-ui/src/decoder_seam.rs#L1), [`ui/dr-ui/src/decoder_seam.rs:216`](../../ui/dr-ui/src/decoder_seam.rs#L216), [`ui/dr-ui/src/decoder_seam.rs:257`](../../ui/dr-ui/src/decoder_seam.rs#L257), [`ui/dr-ui/src/export.rs:1238`](../../ui/dr-ui/src/export.rs#L1238), [`ui/dr-ui/src/export.rs:973`](../../ui/dr-ui/src/export.rs#L973), [`ui/dr-ui/src/import.rs:96`](../../ui/dr-ui/src/import.rs#L96), [`ui/dr-ui/src/library/sweep.rs:322`](../../ui/dr-ui/src/library/sweep.rs#L322), [`ui/dr-ui/src/library/sweep.rs:654`](../../ui/dr-ui/src/library/sweep.rs#L654), [`ui/dr-ui/src/library/thumbnails_gen.rs:109`](../../ui/dr-ui/src/library/thumbnails_gen.rs#L109), [`ui/dr-ui/src/merge.rs:119`](../../ui/dr-ui/src/merge.rs#L119), [`ui/dr-ui/src/repairs.rs:243`](../../ui/dr-ui/src/repairs.rs#L243) | | FR-RAW-3 | [`core/dr-decode/src/lib.rs:162`](../../core/dr-decode/src/lib.rs#L162), [`core/dr-decode/src/lib.rs:564`](../../core/dr-decode/src/lib.rs#L564), [`core/dr-decode/src/locate.rs:1435`](../../core/dr-decode/src/locate.rs#L1435), [`core/dr-gpu/src/demosaic.rs:1063`](../../core/dr-gpu/src/demosaic.rs#L1063), [`core/dr-gpu/src/demosaic.rs:1409`](../../core/dr-gpu/src/demosaic.rs#L1409), [`core/dr-gpu/src/demosaic.rs:868`](../../core/dr-gpu/src/demosaic.rs#L868), [`core/dr-gpu/tests/hot_pixels.rs:1`](../../core/dr-gpu/tests/hot_pixels.rs#L1) | diff --git a/tools/fetch-desktop-runtime.sh b/tools/fetch-desktop-runtime.sh index 6b05e06..e92ba82 100755 --- a/tools/fetch-desktop-runtime.sh +++ b/tools/fetch-desktop-runtime.sh @@ -20,6 +20,32 @@ # directory (docs/inference.md §1.3). set -euo pipefail DEST="${1:-${XDG_DATA_HOME:-${HOME}/.local/share}/darkroom/runtime}" + +# macOS: Microsoft's release archive, which carries the CoreML provider in the +# one library. Pinned, because the CoreML options the engine sets were read +# from this version's source (docs/dev/macos.md, CLAUDE.md "Providers"). +# Apple silicon only: no Intel archive is published since 1.29; an Intel Mac +# takes Homebrew's `onnxruntime` or stays on tract. +if [[ "$(uname -s)" == Darwin ]]; then + ORT_VERSION=1.29.0 + [[ "$(uname -m)" == arm64 ]] || { + echo "error: no ONNX Runtime ${ORT_VERSION} archive for $(uname -m); try: brew install onnxruntime" >&2 + exit 1 + } + NAME="onnxruntime-osx-arm64-${ORT_VERSION}" + WORK="$(mktemp -d)" + trap 'rm -rf "${WORK}"' EXIT + echo "==> downloading ${NAME}" + curl -fsSL "https://github.com/microsoft/onnxruntime/releases/download/v${ORT_VERSION}/${NAME}.tgz" \ + | tar xz -C "${WORK}" + mkdir -p "${DEST}" + cp "${WORK}/${NAME}/lib/libonnxruntime.dylib" "${WORK}/${NAME}/LICENSE" "${DEST}/" + echo "==> runtime in ${DEST}:" + ls -1 "${DEST}" | sed 's/^/ /' + echo " (the app finds it on its next launch; Settings › About › Inference says what it chose)" + exit 0 +fi + WORK="$(mktemp -d -p /var/tmp fetch-desktop-runtime.XXXXXX)" trap 'rm -rf "${WORK}"' EXIT