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
26 changes: 25 additions & 1 deletion ShiftlyApp/Localization/zh-Hans.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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." = "还没有转录。";
Expand Down
222 changes: 202 additions & 20 deletions ShiftlyApp/Sources/ShiftlyApp/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ final class AppModel: ObservableObject {
@Published var recordingSeconds = 0
/// Meeting folders with a Scripto run in flight.
@Published var scriptoBusy: Set<String> = []
/// 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.
Expand Down Expand Up @@ -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()
}

Expand Down Expand Up @@ -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()
Expand All @@ -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 {
Expand Down
53 changes: 52 additions & 1 deletion ShiftlyApp/Sources/ShiftlyApp/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions ShiftlyApp/Sources/ShiftlyApp/MeetingViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion ShiftlyApp/Sources/ShiftlyKit/DataStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions ShiftlyApp/Sources/ShiftlyKit/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading
Loading