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
58 changes: 54 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,44 +48,94 @@
# catches type errors in the AppKit/SwiftUI layer before they reach a release
# build (MAK-63).
#
# Runs in the LEAN configuration (WHISPERKIT=0 PARAKEET=0): this is the ONE
# sanctioned use of WHISPERKIT=0 — CI only. Lean mode swaps WhisperKit /
# Runs in the LEAN configuration (WHISPERKIT=0 PARAKEET=0 PLUGINS=0): this is
# the ONE sanctioned use of WHISPERKIT=0 — CI only. Lean mode swaps WhisperKit /
# FluidAudio for stub engines, so it skips the WhisperKit fork checkout and
# the long CoreML/native builds while STILL type-checking all the app source
# (the engine protocol seam is identical). Sparkle stays ON (default) so its
# updater service compiles too — it's just a cached prebuilt download, not a
# native build. No git submodules are needed in lean mode (whisper.cpp /
# llama.cpp are only pulled in by the full-fat engine builds), so checkout
# stays shallow and fast.
name: build mac app (lean)
#
# PLUGINS=0 is pinned here on purpose. Plugins are ON by default, so without
# the flag this job would silently stop testing the lean path and the escape
# hatch would rot — the `build-app-plugins` job below covers the default
# (plugins-in) configuration, so the two together pin BOTH sides of the flag.
# The lean job also proves the pure plugin core still compiles with every
# plugin surface absent, which is the property PLUGINS=0 actually promises.
name: build mac app (lean, no plugins)
runs-on: macos-14
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Select Xcode
# Third-party action pinned by commit SHA (v1.6.0) so a moved tag can't
# run unreviewed code in CI. latest-stable (not a hard version) on
# purpose: runner images rotate Xcode versions, and the manifest's
# swift-tools-version 6.0 already enforces the real floor (Xcode >= 16).
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1.6.0
with:
xcode-version: latest-stable
- name: Show Swift version
run: swift --version
- name: Cache Sparkle framework
# fetch-sparkle.sh downloads a PINNED, checksum-verified prebuilt Sparkle
# into build/sparkle/<version>/. Cache it keyed on the workflow file so a
# version bump (which edits fetch-sparkle.sh -> not the key) still
# re-verifies via the script's own checksum gate, and the cache is reused
# across runs to avoid the network round-trip.
uses: actions/cache@v4
with:
path: build/sparkle
key: sparkle-${{ runner.os }}-${{ hashFiles('scripts/fetch-sparkle.sh') }}
- name: Build mac app (lean, WHISPERKIT=0 PARAKEET=0)
- name: Build mac app (lean, WHISPERKIT=0 PARAKEET=0 PLUGINS=0)
run: WHISPERKIT=0 PARAKEET=0 PLUGINS=0 ./build.sh

build-app-plugins:

Check warning

Code scanning / CodeQL

Workflow does not contain permissions Medium

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {contents: read}
Comment on lines 67 to +95
# Compiles the app WITH the in-repo plugins — the DEFAULT configuration, and
# therefore the one users actually get (docs/PLUGINS.md).
#
# Separate from `build-app` rather than folded into it because the two jobs
# pin opposite sides of the same flag: that job proves PLUGINS=0 still
# compiles (the lean escape hatch), this one proves the shipped default does.
# Collapsing them into one build would leave whichever side wasn't chosen
# untested, and the plugin surface is exactly the kind of code that breaks
# without anyone noticing — it lives outside build.sh's OpenWhisp/ glob, so
# dropping it is not a compile error.
#
# Stays lean on the ENGINES (WHISPERKIT=0 PARAKEET=0) for the same reason
# build-app does: the engines are irrelevant to whether the plugin layer
# type-checks, and skipping them keeps the job to minutes instead of an hour.
name: build mac app (with plugins)
runs-on: macos-14
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Select Xcode
# Third-party action pinned by commit SHA (v1.6.0) so a moved tag can't
# run unreviewed code in CI.
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1.6.0
with:
xcode-version: latest-stable
- name: Cache Sparkle framework
uses: actions/cache@v4
with:
path: build/sparkle
key: sparkle-${{ runner.os }}-${{ hashFiles('scripts/fetch-sparkle.sh') }}
- name: Build mac app with plugins (WHISPERKIT=0 PARAKEET=0, PLUGINS default on)
run: WHISPERKIT=0 PARAKEET=0 ./build.sh
- name: Verify the plugins actually linked
# The build succeeding is NOT evidence the plugins were compiled in — a
# broken source list yields a working app with an empty Plugins pane and
# no error anywhere. This is the same guard package.sh and build-dmg.sh
# run before shipping, so CI fails on the drift instead of a user finding
# it. It is proven non-vacuous: it fails on a PLUGINS=0 binary.
run: |
source scripts/verify-plugins-binary.sh
verify_plugins_binary build/OpenWhisp

ios-libraries:

Check warning

Code scanning / CodeQL

Workflow does not contain permissions Medium

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {contents: read}
# Guards the iOS consumability of the library products (MAK-51): a
# visibility or platform regression in core must not break the iPhone
# companion, which depends on these two products.
Expand Down
14 changes: 8 additions & 6 deletions .github/workflows/e2e-nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,14 @@ jobs:

full-build:
# Full-fat compile of the mac app with EVERY backend on (WhisperKit +
# Parakeet + Sparkle, the release default) plus ./package.sh, which bundles
# the runtimes and CLI and runs the binary-verify guards
# (verify-whisperkit-binary.sh / verify-parakeet-binary.sh) that assert the
# real engines actually linked. ci.yml's build-app job proves the app
# type-checks in LEAN mode fast on every PR; this nightly job proves the
# full-fat link + package path the release DMG actually uses still works.
# Parakeet + Sparkle + plugins, the release default) plus ./package.sh, which
# bundles the runtimes and CLI and runs the binary-verify guards
# (verify-whisperkit-binary.sh / verify-parakeet-binary.sh /
# verify-plugins-binary.sh) that assert the real engines and the in-repo
# plugins actually linked. ci.yml's build-app job proves the app type-checks
# in LEAN mode fast on every PR, and build-app-plugins proves the
# plugins-included default compiles; this nightly job proves the full-fat
# link + package path the release DMG actually uses still works.
# Advisory (non-blocking) like the rest of this workflow; no signing secrets
# (package.sh does ad-hoc --deep codesign only — build-dmg.sh handles
# notarized release signing).
Expand Down
149 changes: 149 additions & 0 deletions OpenWhisp/AppMain.swift
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,120 @@ class OpenWhispApp: NSObject, NSApplicationDelegate {
// First-run onboarding
showOnboardingIfNeeded()
print("[OpenWhisp] Ready")

// The meme plugin's runtime-proof probes. DEVELOPER-ONLY: compiled in only
// under INSTRUMENTATION=1, so a consumer build has no env-var-driven entry
// point into the plugin at all. See `startMemeProbeIfRequested`.
#if OPENWHISP_INSTRUMENTATION
startMemeProbeIfRequested()
#endif
}

#if OPENWHISP_INSTRUMENTATION
// MARK: - Meme plugin runtime proofs (INSTRUMENTATION=1 only)
//
// These drive the real plugin flows from environment variables so a harness can
// capture what the SHIPPING binary decides instead of another reading of the
// source (`scripts/meme-runtime-proof.sh`, `scripts/meme-voice-command-proof.sh`).
//
// They are compiled out of consumer builds on purpose. An env var that opens a
// window and drives a generate is a fine debugging tool and a poor thing to leave
// reachable in a signed, notarized app holding Accessibility and microphone
// grants — the gate is the same one `LLMBenchRunner` and `LLMLabView` already use.

/// Launch-time entry for the meme plugin's runtime proof.
///
/// Deliberately routed through `PluginHost.open` rather than constructing the
/// controller directly: the probe is only worth anything if it exercises the same
/// window the menu item opens, including the enablement gate and the cached-window
/// reuse. A probe with its own construction path could pass while the real one
/// failed — which is the exact class of mistake this whole exercise is about.
private func startMemeProbeIfRequested() {
let env = ProcessInfo.processInfo.environment

// The VOICE-COMMAND probe. Drives the refine route — the same
// `PluginHost.routeVoiceCommand` AppState calls on a real dictation — so the
// proof covers the TRIGGER LAYER, not just Generate. Both flows are
// expressible: `..._REFINE_CONTENT` set = CASE 1 (a selection is the
// material), unset = CASE 2 (the spoken remainder is).
if let instruction = env["OPENWHISP_MEME_PROBE_REFINE"], !instruction.isEmpty {
startRefineRouteProbe(instruction: instruction,
content: env["OPENWHISP_MEME_PROBE_REFINE_CONTENT"],
delay: Double(env["OPENWHISP_MEME_PROBE_DELAY"] ?? "") ?? 6)
return
}

guard let prompt = env["OPENWHISP_MEME_PROBE_PROMPT"], !prompt.isEmpty else { return }
MemeTrace.log("probe requested at launch")

// The catalog and the LLM warm on window open; give them a moment before
// firing Generate, exactly as a human opening the window and speaking would.
let delay = Double(env["OPENWHISP_MEME_PROBE_DELAY"] ?? "") ?? 6
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
PluginHost.shared.open(pluginID: PluginRegistry.memeGenerator.id)
#if OPENWHISP_PLUGINS
guard let controller = PluginHost.shared.windowController(
for: PluginRegistry.memeGenerator.id) as? MemeGeneratorWindowController
else {
MemeTrace.log("probe ABORTED: no meme window controller (plugin disabled?)")
return
}
controller.runTraceProbeIfRequested()
#else
MemeTrace.log("probe ABORTED: build has no plugins (PLUGINS=0 build)")
#endif
}
}

/// Drive the voice-command route from launch and report what it decided.
///
/// Calls `PluginHost.routeVoiceCommand` — the SAME entry point
/// `AppState.deliverFinalText` uses the moment a mid-dictation refine finalizes,
/// with the same (instruction, content) pair. Nothing about the routing decision,
/// the enablement gate, the window open, or the generate is probe-specific; only
/// the source of the two strings differs (env vars instead of the mic).
///
/// That matters because the wiring is exactly what a source read keeps getting
/// wrong: a trigger that matches in `swift test` proves the ROUTER, not that the
/// refine pipeline ever reaches it. This drives the pipeline's own seam.
private func startRefineRouteProbe(instruction: String, content: String?, delay: Double) {
MemeTrace.log(
"refine-route probe requested: instruction=\"\(instruction)\" "
+ "content=\(content.map { "\"\($0)\"" } ?? "nil")")
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
guard let appState = self.appState else {
MemeTrace.log("refine-route probe ABORTED: no appState")
return
}
let effect = PluginHost.shared.routeVoiceCommand(
instruction: instruction, content: content, on: appState)
// nil = the router declined and the pipeline would run a NORMAL refine.
// That is the near-miss case's expected outcome, and it must be visible.
if let effect {
MemeTrace.log("refine-route probe: ROUTED, refine effect=\(effect)")
} else {
MemeTrace.log(
"refine-route probe: NOT ROUTED -> normal refine "
+ "(status=\"\(appState.statusMessage)\")")
}
// Report the canvas after the generate settles, like the generate probe.
let deadline = Double(
ProcessInfo.processInfo.environment["OPENWHISP_MEME_PROBE_SECONDS"] ?? "") ?? 90
#if OPENWHISP_PLUGINS
guard let controller = PluginHost.shared.windowController(
for: PluginRegistry.memeGenerator.id) as? MemeGeneratorWindowController
else {
MemeTrace.log("refine-route probe done (no meme window was opened)")
return
}
controller.reportCanvasAfter(seconds: deadline)
#else
MemeTrace.log("refine-route probe: build has no plugins (PLUGINS=0 build)")
#endif
}
}
#endif // OPENWHISP_INSTRUMENTATION — meme plugin runtime proofs

/// Re-check permissions whenever the app becomes active. This is what makes
/// the missing-permission banner AUTO-CLEAR: the user grants the permission
/// in System Settings, clicks back into OpenWhisp, and the live recheck
Expand Down Expand Up @@ -381,6 +493,36 @@ class OpenWhispApp: NSObject, NSApplicationDelegate {
// Floating Scratchpad (MAK-49): a target-free surface to dictate into.
menu.addItem(menuItem("Scratchpad", symbol: "note.text", action: #selector(openScratchpad), keyEquivalent: "s"))

// Plugins (spike/plugin-system): one row per ENABLED plugin, folded into a
// submenu so an optional feature never crowds the main menu. Absent entirely
// when nothing is enabled — which is the default.
let activePlugins = PluginHost.shared.activePlugins
if !activePlugins.isEmpty {
let pluginsItem = NSMenuItem(title: "Plugins", action: nil, keyEquivalent: "")
pluginsItem.image = NSImage(
systemSymbolName: "puzzlepiece.extension", accessibilityDescription: nil)
let submenu = NSMenu()
// Shortcuts are DECLARED by each manifest and GRANTED by the host (v5):
// the plugin can't see the app's own menu, so it asks for a key and
// `PluginKeyEquivalent` resolves it against what's already bound — the
// app's reserved set first, then earlier plugins in this same list. A
// refusal is silent; the row still opens on a click.
let shortcuts = PluginKeyEquivalent.assign(
requests: activePlugins.map { ($0.id, $0.manifest.keyEquivalent) })
for plugin in activePlugins {
let item = menuItem(
plugin.manifest.name,
symbol: plugin.manifest.symbol,
action: #selector(openPlugin(_:)),
keyEquivalent: shortcuts[plugin.id] ?? "")
item.representedObject = plugin.id
item.target = self
submenu.addItem(item)
}
pluginsItem.submenu = submenu
menu.addItem(pluginsItem)
}

menu.addItem(.separator())

// Quick mid-use toggles only. Engine, live-chunk plumbing, etc. live in
Expand Down Expand Up @@ -586,6 +728,13 @@ class OpenWhispApp: NSObject, NSApplicationDelegate {
}

@objc private func openScratchpad() { appState.openScratchpad() }

/// Open an enabled plugin's window. The id rides on `representedObject` so one
/// selector serves every plugin row (the host re-checks enabled+runnable).
@objc private func openPlugin(_ sender: NSMenuItem) {
guard let id = sender.representedObject as? String else { return }
PluginHost.shared.open(pluginID: id)
}
@objc private func startDictation() { appState.startDictation() }
@objc private func stopDictation() { appState.stopDictation() }
@objc private func cancelDictation() { appState.cancelDictation() }
Expand Down
Loading
Loading