diff --git a/panel/Bootstrap.swift b/panel/Bootstrap.swift index a4a37ab..a44ca30 100644 --- a/panel/Bootstrap.swift +++ b/panel/Bootstrap.swift @@ -515,10 +515,17 @@ enum Bootstrap { // benefit, not the daemon's). private static func writeDaemonPlistIfVenvPresent() throws { guard let venvURL = bundledVenvURL() else { return } + // Invoke via python3 with stackvox as a script argument. pip + // stamps an absolute build-time shebang into the stackvox script + // — CI-built bundles end up with /Users/runner/... which doesn't + // exist on user machines, so running the script directly fails + // with "bad interpreter". python3 is a real Mach-O binary that's + // relocatable; calling it directly bypasses the shebang entirely. + let python = venvURL.appendingPathComponent("bin/python3").path let stackvox = venvURL.appendingPathComponent("bin/stackvox").path let logPath = "\(installDir)/daemon.log" try writePlist(label: daemonLabel, - programArgs: [stackvox, "serve"], + programArgs: [python, stackvox, "serve"], logPath: logPath, env: stackvoxEnv(venvURL: venvURL)) } diff --git a/panel/PanelNav.swift b/panel/PanelNav.swift index 46af888..a98a14f 100644 --- a/panel/PanelNav.swift +++ b/panel/PanelNav.swift @@ -249,11 +249,21 @@ final class PanelNav: ObservableObject { } private nonisolated static func runStackvoxVoices() -> [String] { - let stackvox = "\(NSHomeDirectory())/.stack-nudge/venv/bin/stackvox" - guard FileManager.default.isExecutableFile(atPath: stackvox) else { return [] } + // Invoke python3 with stackvox as a script argument rather than + // executing the stackvox script directly. pip stamps an absolute + // shebang at install time pointing at the build machine's python3 + // path — CI-built bundles end up with /Users/runner/... shebangs + // that don't exist on user machines, so direct execution fails + // with "bad interpreter". Calling python3 directly bypasses it. + let venvBin = "\(NSHomeDirectory())/.stack-nudge/venv/bin" + let python = "\(venvBin)/python3" + let stackvox = "\(venvBin)/stackvox" + guard FileManager.default.isExecutableFile(atPath: python), + FileManager.default.isReadableFile(atPath: stackvox) + else { return [] } let task = Process() - task.executableURL = URL(fileURLWithPath: stackvox) - task.arguments = ["voices"] + task.executableURL = URL(fileURLWithPath: python) + task.arguments = [stackvox, "voices"] let pipe = Pipe() task.standardOutput = pipe task.standardError = Pipe() diff --git a/panel/SessionUsage.swift b/panel/SessionUsage.swift index 6c048a9..c5d2c0a 100644 --- a/panel/SessionUsage.swift +++ b/panel/SessionUsage.swift @@ -175,6 +175,13 @@ struct UsageView: View { .padding(.vertical, 14) .background(ThinScrollers()) } + // Explicit max-height claim so the ScrollView reliably + // bounds itself to the panel's available area instead of + // expanding to fit its content (which clipped behind the + // PageFooter without a visible scrollbar hint). Force the + // indicator visible so users can tell there's more to see. + .frame(maxHeight: .infinity) + .scrollIndicators(.visible) } else { emptyState } diff --git a/panel/Speaker.swift b/panel/Speaker.swift index ed2bf51..aa562ac 100644 --- a/panel/Speaker.swift +++ b/panel/Speaker.swift @@ -9,23 +9,29 @@ import AppKit // binary anymore; speech goes through `stackvox say ` as a subcommand. enum Speaker { + // Never invoke the `stackvox` script directly — pip stamps an absolute + // shebang at install time, so a venv built on the CI runner has a + // shebang pointing at /Users/runner/... that doesn't exist on user + // machines. Always go through `python3 ` so the shebang + // is bypassed and python3's own binary (a real Mach-O) handles loading. static func speak(_ text: String, voice: String? = nil, speed: String? = nil) { let venvBin = "\(NSHomeDirectory())/.stack-nudge/venv/bin" - let stackvox = "\(venvBin)/stackvox" - let socketPath = "\(NSHomeDirectory())/.cache/stackvox/daemon.sock" - guard FileManager.default.isExecutableFile(atPath: stackvox) else { return } + let python = "\(venvBin)/python3" + let stackvox = "\(venvBin)/stackvox" + let socketPath = "\(NSHomeDirectory())/.cache/stackvox/daemon.sock" + guard FileManager.default.isExecutableFile(atPath: python), + FileManager.default.isReadableFile(atPath: stackvox) + else { return } + + let venvURL = URL(fileURLWithPath: "\(NSHomeDirectory())/.stack-nudge/venv") + .resolvingSymlinksInPath() + var env = ProcessInfo.processInfo.environment + env.merge(Bootstrap.stackvoxEnv(venvURL: venvURL)) { _, new in new } if !FileManager.default.fileExists(atPath: socketPath) { let serve = Process() - serve.executableURL = URL(fileURLWithPath: stackvox) - serve.arguments = ["serve"] - // Same espeak-ng data-path workaround as the launchd plist — - // the wheel's libespeak-ng.dylib has a CI-build phontab path - // baked in; ESPEAK_DATA_PATH overrides it at runtime. - let venvURL = URL(fileURLWithPath: "\(NSHomeDirectory())/.stack-nudge/venv") - .resolvingSymlinksInPath() - var env = ProcessInfo.processInfo.environment - env.merge(Bootstrap.stackvoxEnv(venvURL: venvURL)) { _, new in new } + serve.executableURL = URL(fileURLWithPath: python) + serve.arguments = [stackvox, "serve"] serve.environment = env try? serve.run() } @@ -34,8 +40,9 @@ enum Speaker { let resolvedVoice = voice ?? config["STACKNUDGE_VOICE_NAME"] ?? "af_aoede" let resolvedSpeed = speed ?? config["STACKNUDGE_VOICE_SPEED"] ?? "1.1" let say = Process() - say.executableURL = URL(fileURLWithPath: stackvox) - say.arguments = ["say", "--voice", resolvedVoice, "--speed", resolvedSpeed, text] + say.executableURL = URL(fileURLWithPath: python) + say.arguments = [stackvox, "say", "--voice", resolvedVoice, "--speed", resolvedSpeed, text] + say.environment = env say.standardOutput = FileHandle.nullDevice say.standardError = FileHandle.nullDevice say.terminationHandler = { ended in