Skip to content

Commit 6b65aa4

Browse files
committed
feat: auto-repair stale CLI symlink on launch
v1.8.0/1.8.1 release scripts placed CLI at Contents/MacOS/tasktick, silently colliding with the GUI binary on case-insensitive APFS. v1.8.2 moved it to Contents/cli/ but DMG users with prior 'Enable CLI' symlinks have a stale link pointing nowhere useful. This patch adds CLISymlinkRepair: AppDelegate launches it once on applicationDidFinishLaunching. If /opt/homebrew/bin/tasktick or /usr/local/bin/tasktick resolves to a non-current path, NSAlert asks to repair; on confirm, an osascript admin escalation runs ln -sfn to update the symlink (single Touch ID prompt, no Settings navigation, no Terminal copy-paste). Localized in all 11 supported languages.
1 parent a0f0c1d commit 6b65aa4

13 files changed

Lines changed: 190 additions & 0 deletions

File tree

Sources/App/AppDelegate.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
2626
func applicationDidFinishLaunching(_ notification: Notification) {
2727
NotificationManager.shared.requestPermission()
2828

29+
// One-shot launch-time CLI symlink repair: detects /usr/local/bin/tasktick
30+
// (or /opt/homebrew/bin/tasktick) symlinks left over from v1.8.0/1.8.1
31+
// pointing at the now-relocated CLI binary, and offers a single-click
32+
// admin-prompt repair via osascript. No-op when symlinks are already
33+
// correct or absent.
34+
CLISymlinkRepair.checkAndRepairIfNeeded()
35+
2936
// Apply saved appearance mode
3037
let mode = UserDefaults.standard.string(forKey: "appearanceMode") ?? "system"
3138
switch mode {
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import AppKit
2+
import Foundation
3+
import TaskTickCore
4+
5+
/// Launch-time check + repair for the user's `tasktick` symlink.
6+
///
7+
/// Background: v1.8.0/1.8.1 release scripts placed the CLI binary at
8+
/// `.app/Contents/MacOS/tasktick`, which silently collided with the GUI
9+
/// binary `TaskTick` on default case-insensitive APFS. v1.8.2 moves the
10+
/// CLI to `Contents/cli/tasktick`, but DMG users who already ran the
11+
/// "Enable CLI" flow on a prior version are left with a symlink pointing
12+
/// at the old (now nonexistent or wrong) path. This module detects that
13+
/// state on app launch and offers a one-click in-place repair via
14+
/// `osascript … with administrator privileges` (a single Touch ID /
15+
/// password prompt — no Settings navigation, no Terminal copy-paste).
16+
@MainActor
17+
enum CLISymlinkRepair {
18+
19+
/// Standard PATH locations where the user might have installed the
20+
/// `tasktick` symlink. Apple-Silicon Homebrew first.
21+
private static let candidatePaths = [
22+
"/opt/homebrew/bin/tasktick",
23+
"/usr/local/bin/tasktick"
24+
]
25+
26+
/// Run on `applicationDidFinishLaunching`. No-op for dev builds and
27+
/// when no broken symlink exists.
28+
static func checkAndRepairIfNeeded() {
29+
guard !BundleContext.isDev else { return }
30+
31+
// The CLI inside the running .app — what the symlink should resolve to.
32+
let expected = Bundle.main.bundleURL
33+
.appendingPathComponent("Contents/cli/tasktick")
34+
.path
35+
guard FileManager.default.fileExists(atPath: expected) else {
36+
// CLI not bundled (e.g. swift run directly) — nothing to repair against.
37+
return
38+
}
39+
40+
for path in candidatePaths {
41+
guard let target = try? FileManager.default.destinationOfSymbolicLink(atPath: path) else {
42+
continue
43+
}
44+
if target != expected {
45+
promptRepair(symlinkPath: path, expected: expected)
46+
return // One prompt per launch; user can repeat next launch if multiple paths broken.
47+
}
48+
}
49+
}
50+
51+
private static func promptRepair(symlinkPath: String, expected: String) {
52+
// Don't nag — user can defer per app version.
53+
let dismissKey = "cliRepair.dismissedVersion"
54+
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
55+
if UserDefaults.standard.string(forKey: dismissKey) == version {
56+
return
57+
}
58+
59+
let alert = NSAlert()
60+
alert.messageText = L10n.tr("cli.repair.title")
61+
alert.informativeText = L10n.tr("cli.repair.message", symlinkPath)
62+
alert.alertStyle = .warning
63+
alert.addButton(withTitle: L10n.tr("cli.repair.update_button"))
64+
alert.addButton(withTitle: L10n.tr("cli.repair.later_button"))
65+
66+
let response = alert.runModal()
67+
if response == .alertFirstButtonReturn {
68+
performRepair(symlinkPath: symlinkPath, expected: expected)
69+
} else {
70+
UserDefaults.standard.set(version, forKey: dismissKey)
71+
}
72+
}
73+
74+
private static func performRepair(symlinkPath: String, expected: String) {
75+
// Single auth prompt via AppleScript admin escalation. -n forces
76+
// ln to overwrite the existing symlink without de-referencing it.
77+
let script = """
78+
do shell script "ln -sfn '\(expected)' '\(symlinkPath)'" with administrator privileges
79+
"""
80+
let task = Process()
81+
task.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
82+
task.arguments = ["-e", script]
83+
do {
84+
try task.run()
85+
task.waitUntilExit()
86+
if task.terminationStatus == 0 {
87+
NSLog("✓ CLI symlink repaired: \(symlinkPath)\(expected)")
88+
showSuccess(symlinkPath: symlinkPath)
89+
} else {
90+
NSLog("⚠️ osascript exited \(task.terminationStatus) during CLI repair")
91+
// User likely cancelled the auth prompt. Don't dismiss for the
92+
// version — they may try again next launch.
93+
}
94+
} catch {
95+
NSLog("⚠️ CLI symlink repair failed: \(error)")
96+
}
97+
}
98+
99+
private static func showSuccess(symlinkPath: String) {
100+
let alert = NSAlert()
101+
alert.messageText = L10n.tr("cli.repair.success.title")
102+
alert.informativeText = L10n.tr("cli.repair.success.message", symlinkPath)
103+
alert.addButton(withTitle: "OK")
104+
alert.runModal()
105+
}
106+
}

Sources/TaskTickCore/Localization/de.lproj/Localizable.strings

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,3 +435,10 @@
435435
"toast.action.failed" = "Aktion fehlgeschlagen";
436436
"toast.action.failed.notReady" = "TaskTick startet";
437437
"toast.action.failed.taskNotFound" = "Aufgabe nicht gefunden";
438+
439+
"cli.repair.title" = "tasktick-CLI-Verknüpfung aktualisieren";
440+
"cli.repair.message" = "%@ zeigt auf einen veralteten CLI-Pfad. Jetzt aktualisieren? (Admin-Rechte erforderlich)";
441+
"cli.repair.update_button" = "Jetzt aktualisieren";
442+
"cli.repair.later_button" = "Später";
443+
"cli.repair.success.title" = "tasktick-CLI aktualisiert";
444+
"cli.repair.success.message" = "%@ verweist nun auf die gebündelte CLI-Binärdatei.";

Sources/TaskTickCore/Localization/en.lproj/Localizable.strings

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -557,3 +557,10 @@
557557
"toast.action.failed" = "Action failed";
558558
"toast.action.failed.notReady" = "TaskTick is still starting";
559559
"toast.action.failed.taskNotFound" = "Task not found";
560+
561+
"cli.repair.title" = "Update tasktick CLI link";
562+
"cli.repair.message" = "The tasktick command at %@ points to an outdated location. Update it to the new path now? (Requires admin permission.)";
563+
"cli.repair.update_button" = "Update Now";
564+
"cli.repair.later_button" = "Later";
565+
"cli.repair.success.title" = "tasktick CLI updated";
566+
"cli.repair.success.message" = "%@ now points at the bundled CLI binary.";

Sources/TaskTickCore/Localization/es.lproj/Localizable.strings

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,3 +426,10 @@
426426
"toast.action.failed" = "Acción fallida";
427427
"toast.action.failed.notReady" = "TaskTick está iniciando";
428428
"toast.action.failed.taskNotFound" = "Tarea no encontrada";
429+
430+
"cli.repair.title" = "Actualizar enlace de tasktick CLI";
431+
"cli.repair.message" = "%@ apunta a una ruta antigua de la CLI. ¿Actualizar ahora? (Requiere permiso de administrador)";
432+
"cli.repair.update_button" = "Actualizar ahora";
433+
"cli.repair.later_button" = "Más tarde";
434+
"cli.repair.success.title" = "tasktick CLI actualizado";
435+
"cli.repair.success.message" = "%@ ahora apunta al binario de CLI incluido.";

Sources/TaskTickCore/Localization/fr.lproj/Localizable.strings

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,3 +392,10 @@
392392
"toast.action.failed" = "Action échouée";
393393
"toast.action.failed.notReady" = "TaskTick démarre";
394394
"toast.action.failed.taskNotFound" = "Tâche introuvable";
395+
396+
"cli.repair.title" = "Mettre à jour le lien tasktick CLI";
397+
"cli.repair.message" = "%@ pointe vers un ancien chemin de la CLI. Mettre à jour maintenant ? (Permissions administrateur requises)";
398+
"cli.repair.update_button" = "Mettre à jour";
399+
"cli.repair.later_button" = "Plus tard";
400+
"cli.repair.success.title" = "tasktick CLI mis à jour";
401+
"cli.repair.success.message" = "%@ pointe maintenant vers le binaire CLI groupé.";

Sources/TaskTickCore/Localization/id.lproj/Localizable.strings

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,3 +435,10 @@
435435
"toast.action.failed" = "Aksi gagal";
436436
"toast.action.failed.notReady" = "TaskTick sedang memulai";
437437
"toast.action.failed.taskNotFound" = "Tugas tidak ditemukan";
438+
439+
"cli.repair.title" = "Perbarui tautan tasktick CLI";
440+
"cli.repair.message" = "%@ menunjuk ke jalur CLI lama. Perbarui sekarang? (Memerlukan izin admin)";
441+
"cli.repair.update_button" = "Perbarui Sekarang";
442+
"cli.repair.later_button" = "Nanti";
443+
"cli.repair.success.title" = "tasktick CLI diperbarui";
444+
"cli.repair.success.message" = "%@ kini menunjuk ke biner CLI yang dibundel.";

Sources/TaskTickCore/Localization/it.lproj/Localizable.strings

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,3 +435,10 @@
435435
"toast.action.failed" = "Azione fallita";
436436
"toast.action.failed.notReady" = "TaskTick è in avvio";
437437
"toast.action.failed.taskNotFound" = "Attività non trovata";
438+
439+
"cli.repair.title" = "Aggiorna il collegamento tasktick CLI";
440+
"cli.repair.message" = "%@ punta a un vecchio percorso della CLI. Aggiornare ora? (Richiede privilegi di amministratore)";
441+
"cli.repair.update_button" = "Aggiorna ora";
442+
"cli.repair.later_button" = "Più tardi";
443+
"cli.repair.success.title" = "tasktick CLI aggiornato";
444+
"cli.repair.success.message" = "%@ ora punta al binario della CLI incluso.";

Sources/TaskTickCore/Localization/ja.lproj/Localizable.strings

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,3 +435,10 @@
435435
"toast.action.failed" = "操作に失敗しました";
436436
"toast.action.failed.notReady" = "TaskTick は起動中です";
437437
"toast.action.failed.taskNotFound" = "タスクが見つかりません";
438+
439+
"cli.repair.title" = "tasktick CLI のリンクを更新";
440+
"cli.repair.message" = "%@ は古い CLI パスを指しています。今すぐ更新しますか?(管理者権限が必要)";
441+
"cli.repair.update_button" = "今すぐ更新";
442+
"cli.repair.later_button" = "後で";
443+
"cli.repair.success.title" = "tasktick CLI を更新しました";
444+
"cli.repair.success.message" = "%@ は新しい CLI バイナリを指しています。";

Sources/TaskTickCore/Localization/ko.lproj/Localizable.strings

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,3 +410,10 @@
410410
"toast.action.failed" = "작업 실패";
411411
"toast.action.failed.notReady" = "TaskTick 시작 중";
412412
"toast.action.failed.taskNotFound" = "작업을 찾을 수 없음";
413+
414+
"cli.repair.title" = "tasktick CLI 링크 업데이트";
415+
"cli.repair.message" = "%@ 가 오래된 CLI 경로를 가리키고 있습니다. 지금 업데이트할까요? (관리자 권한 필요)";
416+
"cli.repair.update_button" = "지금 업데이트";
417+
"cli.repair.later_button" = "나중에";
418+
"cli.repair.success.title" = "tasktick CLI 가 업데이트되었습니다";
419+
"cli.repair.success.message" = "%@ 가 이제 새 CLI 바이너리를 가리킵니다.";

0 commit comments

Comments
 (0)