diff --git a/ShiftlyApp/Localization/zh-Hans.lproj/Localizable.strings b/ShiftlyApp/Localization/zh-Hans.lproj/Localizable.strings index 667949c..200eab4 100644 --- a/ShiftlyApp/Localization/zh-Hans.lproj/Localizable.strings +++ b/ShiftlyApp/Localization/zh-Hans.lproj/Localizable.strings @@ -473,7 +473,31 @@ "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 安装失败:%@"; + +/* In-app reinstall (Settings → Update) */ +"Update" = "更新"; +"Shiftly checkout" = "Shiftly 检出目录"; +"Not found" = "未找到"; +"Running build" = "当前运行"; +"dist build" = "dist 构建"; +"Not built" = "未构建"; +"Reinstall & Relaunch" = "重装并重启"; +"Replaces this app with the checkout's dist/Shiftly.app and relaunches. Build dist first (scripts/build_app.sh); permissions may re-prompt after the ad-hoc re-sign." = "用检出目录里的 dist/Shiftly.app 替换本应用并重新启动。请先构建 dist(scripts/build_app.sh);ad-hoc 重签后系统授权可能需要重新允许。"; +"Replace this app with the dist build and relaunch?" = "用 dist 构建替换本应用并重新启动?"; +"Reinstall now" = "立即重装"; +"That folder is not a Shiftly checkout." = "该文件夹不是 Shiftly 检出目录。"; +"Shiftly checkout saved." = "Shiftly 检出目录已保存。"; +"Reinstall works only from the packaged app." = "重装只能在打包后的应用里使用。"; +"No built app in dist — run scripts/build_app.sh first." = "dist 里没有已构建的应用——请先运行 scripts/build_app.sh。"; +"Already running the dist build." = "当前运行的就是 dist 构建。"; +"Could not start the reinstall helper: %@" = "无法启动重装辅助进程:%@"; "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..4ce96a0 100644 --- a/ShiftlyApp/Sources/ShiftlyApp/AppModel.swift +++ b/ShiftlyApp/Sources/ShiftlyApp/AppModel.swift @@ -89,6 +89,10 @@ 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 + /// Shiftly repo checkout for in-app reinstall ("" = auto-detect). + @Published var repoDir: String = "" private var audioRecorder: AVAudioRecorder? /// SystemAudioRecorder on macOS 15+; typed AnyObject so the stored /// property compiles against the macOS 13 deployment target. @@ -485,6 +489,7 @@ final class AppModel: ObservableObject { meetingsDir = config?.meetingsRoot ?? (MeetingStore.defaultDir as NSString).expandingTildeInPath scriptoDir = config?.scripto_dir ?? "" translateTarget = config?.translate_target ?? "zh" + repoDir = config?.repo_dir ?? "" meetings = meetingStore.meetings() } @@ -631,26 +636,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 +651,202 @@ 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) + } + } + } + + // MARK: In-app reinstall (Settings → Update) + + func adoptRepoDir(_ url: URL) { + guard ScriptoInstall.isShiftlyCheckout(url.path) else { + statusMessage = L("That folder is not a Shiftly checkout.") + return + } + noteOwnWrite() + do { + try store.saveMeetingSetting(key: "repo_dir", value: url.path) + repoDir = url.path + statusMessage = L("Shiftly checkout saved.") + } catch { + statusMessage = LF("Save failed: %@", error.localizedDescription) + } + } + + /// The checkout the reinstall uses: config value, else auto-detected + /// (running from a checkout's dist/, or a sibling of the Scripto dir). + /// Pure — Settings calls this during rendering. + func resolvedRepoDir() -> String? { + let configured = (repoDir as NSString).expandingTildeInPath + if !configured.isEmpty { + return ScriptoInstall.isShiftlyCheckout(configured) ? configured : nil + } + if let found = ScriptoInstall.shiftlyCheckout(near: Bundle.main.bundlePath) { + return found + } + guard !scriptoDir.isEmpty else { return nil } + let parent = URL(fileURLWithPath: (scriptoDir as NSString).expandingTildeInPath) + .deletingLastPathComponent() + let siblings = (try? FileManager.default.contentsOfDirectory(atPath: parent.path)) ?? [] + return siblings.lazy + .map { parent.appendingPathComponent($0).path } + .first { ScriptoInstall.isShiftlyCheckout($0) } + } + + /// dist/Shiftly.app inside the resolved checkout, when built. + var distAppPath: String? { + guard let repo = resolvedRepoDir() else { return nil } + let dist = repo + "/dist/Shiftly.app" + guard FileManager.default.fileExists(atPath: dist + "/Contents/MacOS/Shiftly") else { + return nil + } + return dist + } + + /// Short build-time text for an app bundle ("-" when absent). + nonisolated static func buildDateText(appBundle: String) -> String { + let binary = appBundle + "/Contents/MacOS/Shiftly" + guard let date = (try? FileManager.default + .attributesOfItem(atPath: binary))?[.modificationDate] as? Date else { return "-" } + let df = DateFormatter() + df.dateFormat = "MM-dd HH:mm" + return df.string(from: date) + } + + /// Replaces this app bundle with the checkout's dist/Shiftly.app and + /// relaunches: a detached helper waits for this process to exit, swaps + /// the bundle (keeping the old copy until the copy succeeds), then + /// reopens it. The app quits itself right after spawning the helper. + func reinstallFromDist() { + let target = URL(fileURLWithPath: Bundle.main.bundlePath).standardizedFileURL.path + guard target.hasSuffix(".app") else { + statusMessage = L("Reinstall works only from the packaged app.") + return + } + guard let source = distAppPath.map({ + URL(fileURLWithPath: $0).standardizedFileURL.path + }) else { + statusMessage = L("No built app in dist — run scripts/build_app.sh first.") + return + } + guard source != target else { + statusMessage = L("Already running the dist build.") + return + } + if isRecording { stopRecording() } + let pid = ProcessInfo.processInfo.processIdentifier + let q = Self.shellQuote + // Detached on purpose: children are not killed when the app exits. + let script = """ + while /bin/kill -0 \(pid) 2>/dev/null; do /bin/sleep 0.2; done + STALE=\(q(target + ".stale-\(pid)")) + /bin/rm -rf "$STALE" + /bin/mv \(q(target)) "$STALE" || exit 1 + if /usr/bin/ditto \(q(source)) \(q(target)); then + /bin/rm -rf "$STALE" + else + /bin/rm -rf \(q(target)) + /bin/mv "$STALE" \(q(target)) + fi + /usr/bin/open \(q(target)) + """ + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/bin/zsh") + proc.arguments = ["-c", script] + proc.standardOutput = FileHandle.nullDevice + proc.standardError = FileHandle.nullDevice + do { + try proc.run() + } catch { + statusMessage = LF("Could not start the reinstall helper: %@", error.localizedDescription) + return + } + NSApp.terminate(nil) + } + func deleteMeeting(_ meeting: MeetingStore.Meeting) { noteOwnWrite() do { diff --git a/ShiftlyApp/Sources/ShiftlyApp/ContentView.swift b/ShiftlyApp/Sources/ShiftlyApp/ContentView.swift index 7051e17..90f2c94 100644 --- a/ShiftlyApp/Sources/ShiftlyApp/ContentView.swift +++ b/ShiftlyApp/Sources/ShiftlyApp/ContentView.swift @@ -65,6 +65,7 @@ struct ContentView: View { @State var importCalendarID = "" @State var todaySwapTarget = Calendar.current.date(byAdding: .day, value: 1, to: Date()) ?? Date() @State private var showResetConfirm = false + @State var showReinstallConfirm = false @State var holidayImportCalendarID = "" @State var holidaysListExpanded = false @AppStorage("shiftly.section") private var storedSection = AppSection.today.rawValue @@ -658,8 +659,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,11 +690,47 @@ 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) } + card("Update") { + let repo = model.resolvedRepoDir() + let dist = model.distAppPath + HStack(spacing: 10) { + Text("Shiftly checkout").font(.caption).foregroundStyle(.secondary) + Text(repo ?? L("Not found")) + .font(.system(.caption, design: .monospaced)) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 0) + Button("Change…") { chooseRepoFolder() } + .buttonStyle(.bordered) + } + HStack(spacing: 10) { + Text("Running build").font(.caption).foregroundStyle(.secondary) + Text(AppModel.buildDateText(appBundle: Bundle.main.bundlePath)) + .font(.system(.caption, design: .monospaced)) + Text("dist build").font(.caption).foregroundStyle(.secondary) + Text(dist.map { AppModel.buildDateText(appBundle: $0) } ?? L("Not built")) + .font(.system(.caption, design: .monospaced)) + Spacer(minLength: 0) + Button("Reinstall & Relaunch") { showReinstallConfirm = true } + .buttonStyle(.borderedProminent) + .disabled(dist == nil) + } + Text("Replaces this app with the checkout's dist/Shiftly.app and relaunches. Build dist first (scripts/build_app.sh); permissions may re-prompt after the ad-hoc re-sign.") + .font(.caption2) + .foregroundStyle(.tertiary) + } + .confirmationDialog( + L("Replace this app with the dist build and relaunch?"), + isPresented: $showReinstallConfirm + ) { + Button(L("Reinstall now")) { model.reinstallFromDist() } + } + card("Reset") { Text("Erase everything Shiftly stores — schedule, overrides, imported history, pay, routine, sync state, daily logs, quick notes and meeting recordings — and start over from the welcome screen. Apple Calendar events and files Shiftly does not recognize are kept.") .font(.caption) diff --git a/ShiftlyApp/Sources/ShiftlyApp/MeetingViews.swift b/ShiftlyApp/Sources/ShiftlyApp/MeetingViews.swift index 1758a0b..fa9eaf9 100644 --- a/ShiftlyApp/Sources/ShiftlyApp/MeetingViews.swift +++ b/ShiftlyApp/Sources/ShiftlyApp/MeetingViews.swift @@ -60,6 +60,17 @@ extension ContentView { } } + func chooseRepoFolder() { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.prompt = "Use This Folder" + panel.message = "Choose your Shiftly checkout (the repo with scripts/build_app.sh)." + if panel.runModal() == .OK, let url = panel.url { + model.adoptRepoDir(url) + } + } + // MARK: List private var meetingListCard: some View { diff --git a/ShiftlyApp/Sources/ShiftlyKit/DataStore.swift b/ShiftlyApp/Sources/ShiftlyKit/DataStore.swift index d475be3..29d2f4a 100644 --- a/ShiftlyApp/Sources/ShiftlyKit/DataStore.swift +++ b/ShiftlyApp/Sources/ShiftlyKit/DataStore.swift @@ -119,7 +119,7 @@ public struct DataStore { /// Merge one meetings-related setting into config.json. public func saveMeetingSetting(key: String, value: String) throws { - precondition(["meetings_dir", "scripto_dir", "translate_target"].contains(key)) + precondition(["meetings_dir", "scripto_dir", "translate_target", "repo_dir"].contains(key)) var raw = try ConfigLogic.readRawConfig(atPath: paths.configPath) raw[key] = value try ConfigLogic.writeRawConfig(raw, toPath: paths.configPath) diff --git a/ShiftlyApp/Sources/ShiftlyKit/Models.swift b/ShiftlyApp/Sources/ShiftlyKit/Models.swift index 3f06e0e..f27c7f0 100644 --- a/ShiftlyApp/Sources/ShiftlyKit/Models.swift +++ b/ShiftlyApp/Sources/ShiftlyKit/Models.swift @@ -50,6 +50,9 @@ public struct Config: Codable { public var scripto_dir: String? /// Scripto translation target ("zh"/"en"); nil = "zh". public var translate_target: String? + /// Shiftly repo checkout used by in-app reinstall (Settings → Update); + /// nil = auto-detect. + public var repo_dir: String? /// Times for a shift-type id: matching type, else config defaults. public func times(forShiftType id: String?) -> (start: String, end: String) { diff --git a/ShiftlyApp/Sources/ShiftlyKit/ScriptoInstall.swift b/ShiftlyApp/Sources/ShiftlyKit/ScriptoInstall.swift new file mode 100644 index 0000000..6b77d8c --- /dev/null +++ b/ShiftlyApp/Sources/ShiftlyKit/ScriptoInstall.swift @@ -0,0 +1,62 @@ +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 { + if let repo = shiftlyCheckout(near: startPath, fileManager: fm) { + return URL(fileURLWithPath: repo) + .deletingLastPathComponent().appendingPathComponent("scripto").path + } + let appSupport = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? URL(fileURLWithPath: NSHomeDirectory() + "/Library/Application Support") + return appSupport.appendingPathComponent("Shiftly/scripto").path + } + + /// Walks up from `startPath` to the enclosing Shiftly git checkout + /// (the folder with `.git` and `ShiftlyApp/Package.swift`); nil when + /// `startPath` is not inside one (e.g. the app lives in /Applications). + public static func shiftlyCheckout( + 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.path + } + dir.deleteLastPathComponent() + } + return nil + } + + /// True when `path` looks like a Shiftly checkout usable for in-app + /// reinstall (has the package and the build script; .git not required). + public static func isShiftlyCheckout( + _ path: String, fileManager fm: FileManager = .default + ) -> Bool { + let root = (path as NSString).expandingTildeInPath + return fm.fileExists(atPath: root + "/ShiftlyApp/Package.swift") + && fm.fileExists(atPath: root + "/scripts/build_app.sh") + } + + /// 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..23f62bd 100644 --- a/ShiftlyApp/Tests/ShiftlyKitTests/MeetingsTests.swift +++ b/ShiftlyApp/Tests/ShiftlyKitTests/MeetingsTests.swift @@ -86,3 +86,75 @@ 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 findsTheEnclosingShiftlyCheckout() throws { + let fm = FileManager.default + let base = fm.temporaryDirectory + .appendingPathComponent("repo_locate_\(UUID().uuidString)") + defer { try? fm.removeItem(at: base) } + let repo = base.appendingPathComponent("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 inside = repo.appendingPathComponent("dist/Shiftly.app/Contents/MacOS").path + #expect( + ScriptoInstall.shiftlyCheckout(near: inside, fileManager: fm) + == repo.standardizedFileURL.path + ) + #expect(ScriptoInstall.shiftlyCheckout(near: base.path, fileManager: fm) == nil) + + // isShiftlyCheckout additionally wants the build script. + #expect(!ScriptoInstall.isShiftlyCheckout(repo.path, fileManager: fm)) + try fm.createDirectory( + at: repo.appendingPathComponent("scripts"), withIntermediateDirectories: true + ) + fm.createFile( + atPath: repo.appendingPathComponent("scripts/build_app.sh").path, contents: Data() + ) + #expect(ScriptoInstall.isShiftlyCheckout(repo.path, fileManager: fm)) + } + + @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/DATA_AND_API.md b/docs/DATA_AND_API.md index 1cb1a7a..a4a96de 100644 --- a/docs/DATA_AND_API.md +++ b/docs/DATA_AND_API.md @@ -287,6 +287,9 @@ tags: [] run <音频> --format srt [--translate --target ]`; 字幕 `<名>..srt` 由 Scripto 写在录音旁,App 只读——列表、播放与按时间戳 高亮均在 App 内完成。 +- `config.repo_dir`(可选):Shiftly 仓库检出路径,供 设置 → 更新 的 + 「重装并重启」使用(用检出内 `dist/Shiftly.app` 原地替换当前 App 并重启); + 缺省自动探测(App 位于检出内,或 `scripto_dir` 的同级目录)。 ## 3. `shiftly` CLI 参考 diff --git a/docs/SETUP.md b/docs/SETUP.md index c5af6ef..1f46be6 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -60,13 +60,15 @@ scripts/build_app.sh # → dist/Shiftly.app Move `dist/Shiftly.app` to `/Applications` (or anywhere) and double-click. +**Updating an installed copy:** after rebuilding `dist/Shiftly.app`, use **Settings → Update → Reinstall & Relaunch** — the app locates the checkout (auto-detected, or set via `repo_dir`), swaps its own bundle for the dist build through a detached helper (the old copy is kept until the swap succeeds) and relaunches. Permissions may re-prompt because each build is ad-hoc signed anew. + **First run:** the app asks for a **storage folder** and provisions the standard layout under it — `app/data` (the data root), `app/meetings`, `logs`, `notes` — writing the absolute paths into `config.json`; a folder that already is a data root (its `data/config.json` exists) is adopted as is. Each location can be relocated later from **Settings → Storage**: a change *moves* the existing content and leaves nothing behind. Set the weekly schedule, press **Sync Now** — macOS asks for Calendar access on the first sync. The chosen folder is remembered; the `SHIFTLY_ROOT` environment variable still wins when set. The Python helper scripts are bundled into the app, so no repo checkout is needed at the data folder (a `scripts/` directory at the data root takes precedence when present). **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