From 120f37c05dadee86620379d4e143269c5304b0cc Mon Sep 17 00:00:00 2001 From: TN019 Date: Fri, 24 Jul 2026 10:53:10 +1000 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20one-click=20Scripto=20install=20?= =?UTF-8?q?=E2=80=94=20clone=20next=20to=20the=20checkout=20(or=20App=20Su?= =?UTF-8?q?pport),=20uv=20sync,=20auto-set=20folder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../zh-Hans.lproj/Localizable.strings | 8 +- ShiftlyApp/Sources/ShiftlyApp/AppModel.swift | 111 ++++++++++++++---- .../Sources/ShiftlyApp/ContentView.swift | 16 ++- .../Sources/ShiftlyKit/ScriptoInstall.swift | 39 ++++++ .../Tests/ShiftlyKitTests/MeetingsTests.swift | 39 ++++++ docs/SETUP.md | 2 +- 6 files changed, 192 insertions(+), 23 deletions(-) create mode 100644 ShiftlyApp/Sources/ShiftlyKit/ScriptoInstall.swift diff --git a/ShiftlyApp/Localization/zh-Hans.lproj/Localizable.strings b/ShiftlyApp/Localization/zh-Hans.lproj/Localizable.strings index 667949c..8d253c8 100644 --- a/ShiftlyApp/Localization/zh-Hans.lproj/Localizable.strings +++ b/ShiftlyApp/Localization/zh-Hans.lproj/Localizable.strings @@ -473,7 +473,13 @@ "Scripto folder" = "Scripto 目录"; "Not set" = "未设置"; "Translate to" = "翻译目标"; -"Transcribe / Translate run Scripto's CLI headlessly (uv run scripto-cli); subtitles land next to each recording." = "转录/翻译 通过 Scripto 的命令行无界面运行(uv run scripto-cli);字幕文件生成在每段录音旁边。"; +"Transcribe / Translate run Scripto's CLI headlessly (uv run scripto-cli); subtitles land next to each recording. Install automatically clones Scripto from GitHub and sets the folder for you (needs git; transcription needs uv)." = "转录/翻译 通过 Scripto 的命令行无界面运行(uv run scripto-cli);字幕文件生成在每段录音旁边。「一键安装」会从 GitHub 克隆 Scripto 并自动设置目录(需要 git;转录需要 uv)。"; +"Install automatically" = "一键安装"; +"Installing Scripto…" = "正在安装 Scripto…"; +"Scripto is ready." = "Scripto 已就绪。"; +"Scripto cloned. Install uv (brew install uv) before transcribing." = "Scripto 已克隆。转录前请先安装 uv(brew install uv)。"; +"Scripto cloned, but uv sync failed: %@" = "Scripto 已克隆,但 uv sync 失败:%@"; +"Scripto install failed: %@" = "Scripto 安装失败:%@"; "Choose the folder for meeting recordings." = "选择存放会议录音的文件夹。"; "Choose your Scripto checkout (the folder with pyproject.toml)." = "选择你的 Scripto 项目目录(含 pyproject.toml)。"; "No transcript yet." = "还没有转录。"; diff --git a/ShiftlyApp/Sources/ShiftlyApp/AppModel.swift b/ShiftlyApp/Sources/ShiftlyApp/AppModel.swift index 500c621..47cfdc2 100644 --- a/ShiftlyApp/Sources/ShiftlyApp/AppModel.swift +++ b/ShiftlyApp/Sources/ShiftlyApp/AppModel.swift @@ -89,6 +89,8 @@ final class AppModel: ObservableObject { @Published var recordingSeconds = 0 /// Meeting folders with a Scripto run in flight. @Published var scriptoBusy: Set = [] + /// One-click Scripto install (clone + uv sync) in flight. + @Published var scriptoInstalling = false private var audioRecorder: AVAudioRecorder? /// SystemAudioRecorder on macOS 15+; typed AnyObject so the stored /// property compiles against the macOS 13 deployment target. @@ -631,26 +633,7 @@ final class AppModel: ObservableObject { if translate { command += " --translate --target \(target)" } - let proc = Process() - proc.executableURL = URL(fileURLWithPath: "/bin/zsh") - proc.arguments = ["-lc", command] - let errPipe = Pipe() - proc.standardError = errPipe - proc.standardOutput = Pipe() - do { - try proc.run() - proc.waitUntilExit() - if proc.terminationStatus == 0 { - return (true, "") - } - let err = String( - data: errPipe.fileHandleForReading.readDataToEndOfFile(), - encoding: .utf8 - )?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return (false, err.split(separator: "\n").last.map(String.init) ?? "exit \(proc.terminationStatus)") - } catch { - return (false, error.localizedDescription) - } + return Self.runShell(command) }.value scriptoBusy.remove(meeting.folder) refreshMeetings() @@ -665,6 +648,94 @@ final class AppModel: ObservableObject { "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" } + /// Runs a command through a login zsh (so brew-installed tools like uv + /// are on PATH). Returns success plus the last stderr line on failure. + /// stderr is drained before waiting so a chatty command can't deadlock + /// the pipe; stdout is discarded. + nonisolated static func runShell(_ command: String) -> (ok: Bool, message: String) { + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/bin/zsh") + proc.arguments = ["-lc", command] + let errPipe = Pipe() + proc.standardError = errPipe + proc.standardOutput = FileHandle.nullDevice + do { + try proc.run() + let errData = errPipe.fileHandleForReading.readDataToEndOfFile() + proc.waitUntilExit() + if proc.terminationStatus == 0 { + return (true, "") + } + let err = String(data: errData, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return (false, err.split(separator: "\n").last.map(String.init) ?? "exit \(proc.terminationStatus)") + } catch { + return (false, error.localizedDescription) + } + } + + private enum ScriptoInstallStep { + case ready + case needsUV + case syncFailed(String) + case failed(String) + } + + /// One-click Scripto install: clone the repo next to a Shiftly checkout + /// (or into Application Support), pre-warm the Python env with + /// `uv sync`, and point scripto_dir at the result. An existing checkout + /// at the destination is adopted instead of re-cloned. + func installScripto() { + guard !scriptoInstalling else { return } + let target = ScriptoInstall.targetDirectory(near: Bundle.main.bundlePath) + if ScriptoInstall.looksLikeCheckout(target) { + adoptScriptoDir(URL(fileURLWithPath: target)) + return + } + scriptoInstalling = true + statusMessage = L("Installing Scripto…") + Task { @MainActor in + let step = await Task.detached(priority: .userInitiated) { () -> ScriptoInstallStep in + let fm = FileManager.default + let parent = (target as NSString).deletingLastPathComponent + do { + try fm.createDirectory(atPath: parent, withIntermediateDirectories: true) + } catch { + return .failed(error.localizedDescription) + } + let existedBefore = fm.fileExists(atPath: target) + let clone = Self.runShell( + "git clone \(Self.shellQuote(ScriptoInstall.repoURL)) \(Self.shellQuote(target))" + ) + guard clone.ok else { + // git normally cleans up after itself; only sweep a + // directory this run created. + if !existedBefore { try? fm.removeItem(atPath: target) } + return .failed(clone.message) + } + guard Self.runShell("command -v uv").ok else { return .needsUV } + // Pre-warm the env so the first Transcribe doesn't silently + // stall for minutes downloading dependencies. + let sync = Self.runShell("cd \(Self.shellQuote(target)) && uv sync") + return sync.ok ? .ready : .syncFailed(sync.message) + }.value + scriptoInstalling = false + switch step { + case .failed(let message): + statusMessage = LF("Scripto install failed: %@", message) + case .ready: + adoptScriptoDir(URL(fileURLWithPath: target)) + statusMessage = L("Scripto is ready.") + case .needsUV: + adoptScriptoDir(URL(fileURLWithPath: target)) + statusMessage = L("Scripto cloned. Install uv (brew install uv) before transcribing.") + case .syncFailed(let message): + adoptScriptoDir(URL(fileURLWithPath: target)) + statusMessage = LF("Scripto cloned, but uv sync failed: %@", message) + } + } + } + func deleteMeeting(_ meeting: MeetingStore.Meeting) { noteOwnWrite() do { diff --git a/ShiftlyApp/Sources/ShiftlyApp/ContentView.swift b/ShiftlyApp/Sources/ShiftlyApp/ContentView.swift index 7051e17..6620c16 100644 --- a/ShiftlyApp/Sources/ShiftlyApp/ContentView.swift +++ b/ShiftlyApp/Sources/ShiftlyApp/ContentView.swift @@ -658,8 +658,22 @@ struct ContentView: View { .lineLimit(1) .truncationMode(.middle) Spacer(minLength: 0) + if model.scriptoDir.isEmpty { + Button { + model.installScripto() + } label: { + if model.scriptoInstalling { + ProgressView().controlSize(.small) + } else { + Text("Install automatically") + } + } + .buttonStyle(.borderedProminent) + .disabled(model.scriptoInstalling) + } Button("Change…") { chooseScriptoFolder() } .buttonStyle(.bordered) + .disabled(model.scriptoInstalling) } HStack(spacing: 10) { Text("Translate to").font(.caption).foregroundStyle(.secondary) @@ -675,7 +689,7 @@ struct ContentView: View { .frame(width: 110) Spacer(minLength: 0) } - Text("Transcribe / Translate run Scripto's CLI headlessly (uv run scripto-cli); subtitles land next to each recording.") + Text("Transcribe / Translate run Scripto's CLI headlessly (uv run scripto-cli); subtitles land next to each recording. Install automatically clones Scripto from GitHub and sets the folder for you (needs git; transcription needs uv).") .font(.caption2) .foregroundStyle(.tertiary) } diff --git a/ShiftlyApp/Sources/ShiftlyKit/ScriptoInstall.swift b/ShiftlyApp/Sources/ShiftlyKit/ScriptoInstall.swift new file mode 100644 index 0000000..56c3c2d --- /dev/null +++ b/ShiftlyApp/Sources/ShiftlyKit/ScriptoInstall.swift @@ -0,0 +1,39 @@ +import Foundation + +/// Destination logic for the one-click Scripto install (Settings → +/// Meetings). The clone itself is driven by the app; this is the pure, +/// testable part. +public enum ScriptoInstall { + public static let repoURL = "https://github.com/TN019/scripto.git" + + /// Picks the clone destination. When `startPath` (normally the app + /// bundle) sits inside a Shiftly git checkout — running dist/Shiftly.app + /// straight from the repo — the clone lands next to that checkout + /// (`../scripto`, the conventional dev layout). Everywhere else it goes + /// under `~/Library/Application Support/Shiftly/scripto`, which survives + /// the app being moved and never lives on an iCloud-synced folder. + public static func targetDirectory( + near startPath: String, fileManager fm: FileManager = .default + ) -> String { + var dir = URL(fileURLWithPath: (startPath as NSString).expandingTildeInPath) + .standardizedFileURL + while dir.pathComponents.count > 1 { + if fm.fileExists(atPath: dir.appendingPathComponent(".git").path), + fm.fileExists(atPath: dir.appendingPathComponent("ShiftlyApp/Package.swift").path) { + return dir.deletingLastPathComponent().appendingPathComponent("scripto").path + } + dir.deleteLastPathComponent() + } + let appSupport = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? URL(fileURLWithPath: NSHomeDirectory() + "/Library/Application Support") + return appSupport.appendingPathComponent("Shiftly/scripto").path + } + + /// True when `path` already looks like a usable Scripto checkout + /// (same test the transcribe action uses). + public static func looksLikeCheckout( + _ path: String, fileManager fm: FileManager = .default + ) -> Bool { + fm.fileExists(atPath: (path as NSString).expandingTildeInPath + "/pyproject.toml") + } +} diff --git a/ShiftlyApp/Tests/ShiftlyKitTests/MeetingsTests.swift b/ShiftlyApp/Tests/ShiftlyKitTests/MeetingsTests.swift index 45c57e7..b43bed4 100644 --- a/ShiftlyApp/Tests/ShiftlyKitTests/MeetingsTests.swift +++ b/ShiftlyApp/Tests/ShiftlyKitTests/MeetingsTests.swift @@ -86,3 +86,42 @@ import Testing #expect(SRT.timestamp("nonsense") == nil) } } + +@Suite struct ScriptoInstallTests { + @Test func clonesNextToAShiftlyCheckout() throws { + let fm = FileManager.default + let base = fm.temporaryDirectory + .appendingPathComponent("scripto_install_\(UUID().uuidString)") + defer { try? fm.removeItem(at: base) } + let repo = base.appendingPathComponent("dev/shiftly") + try fm.createDirectory( + at: repo.appendingPathComponent(".git"), withIntermediateDirectories: true + ) + try fm.createDirectory( + at: repo.appendingPathComponent("ShiftlyApp"), withIntermediateDirectories: true + ) + fm.createFile( + atPath: repo.appendingPathComponent("ShiftlyApp/Package.swift").path, + contents: Data() + ) + let bundle = repo.appendingPathComponent("dist/Shiftly.app/Contents/MacOS").path + let target = ScriptoInstall.targetDirectory(near: bundle, fileManager: fm) + #expect(target == base.appendingPathComponent("dev/scripto").standardizedFileURL.path) + } + + @Test func fallsBackToApplicationSupport() { + let target = ScriptoInstall.targetDirectory(near: "/Applications/Shiftly.app") + #expect(target.hasSuffix("/Library/Application Support/Shiftly/scripto")) + } + + @Test func detectsACheckoutByPyproject() throws { + let fm = FileManager.default + let dir = fm.temporaryDirectory + .appendingPathComponent("scripto_checkout_\(UUID().uuidString)") + defer { try? fm.removeItem(at: dir) } + try fm.createDirectory(at: dir, withIntermediateDirectories: true) + #expect(!ScriptoInstall.looksLikeCheckout(dir.path)) + fm.createFile(atPath: dir.appendingPathComponent("pyproject.toml").path, contents: Data()) + #expect(ScriptoInstall.looksLikeCheckout(dir.path)) + } +} diff --git a/docs/SETUP.md b/docs/SETUP.md index c5af6ef..7d9322b 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -66,7 +66,7 @@ The Python helper scripts are bundled into the app, so no repo checkout is neede **Desktop widgets:** `build_app.sh` also compiles a WidgetKit extension into `Contents/PlugIns/ShiftlyWidgets.appex` with plain `swiftc` — no Xcode. Three things make a hand-built appex loadable: the **`com.apple.security.app-sandbox` entitlement** (WidgetKit refuses unsandboxed extensions), `CFBundleSupportedPlatforms = [MacOSX]`, and linking with **`-e _NSExtensionMain`** (a plain Swift `@main` entry dies with "Unrecognized extension type"). The app feeds the widgets by writing a snapshot JSON into the `group.com.shiftly.app` container and calling `WidgetCenter.reloadAllTimelines()`. Add the widgets via right-click on the desktop → Edit Widgets → search "Shiftly"; the chips deep-link back through the `shiftly://` URL scheme (`start-work`, `meetings`, `new-note`, `open`). -**Meetings / Scripto:** recordings land in `/dd-mm-yy | hh-mm/dd-mm-yy.mp4` (AAC). On macOS 15+ a recording captures the **microphone and the Mac's system audio** (Core Audio process tap), so both sides of an online meeting (DingTalk, Zoom, Tencent Meeting, …) are recorded even with headphones on — the first recording triggers a *System Audio Recording* privacy prompt (separate from Microphone); declining it degrades recordings to mic-only. While recording, a hidden `.dd-mm-yy.system.m4a` side-track sits next to the mic file and is mixed in when the recording stops. Transcribe / Translate run [Scripto](https://github.com/TN019/scripto) headlessly via `uv run scripto-cli run