tibodog 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -70,9 +70,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
70
70
  private var panel: NotchPanel?
71
71
  private var geometry: NotchGeometry?
72
72
  private var observers: [Any] = []
73
+ private var signalSources: [DispatchSourceSignal] = []
73
74
 
74
75
  func applicationDidFinishLaunching(_ notification: Notification) {
75
76
  NSApp.setActivationPolicy(.accessory)
77
+ quitOnSignals()
78
+ // Writing to a helper that just exited (codex, perl) must be an error we handle, not a SIGPIPE
79
+ // that kills the app.
80
+ signal(SIGPIPE, SIG_IGN)
76
81
  let store = UsageStore.shared
77
82
  store.start()
78
83
  rebuildPanel()
@@ -139,14 +144,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
139
144
  return out
140
145
  }
141
146
 
147
+ /// `pkill`, Ctrl-C or a logout quit like the menu's Quit, so the Now Playing helper stops with us.
148
+ private func quitOnSignals() {
149
+ for sig in [SIGTERM, SIGINT, SIGHUP] {
150
+ signal(sig, SIG_IGN)
151
+ let source = DispatchSource.makeSignalSource(signal: sig, queue: .main)
152
+ source.setEventHandler { NSApp.terminate(nil) }
153
+ source.resume()
154
+ signalSources.append(source)
155
+ }
156
+ }
157
+
142
158
  func applicationWillTerminate(_ notification: Notification) {
143
159
  NowPlayingMonitor.shared.stop()
144
160
  }
145
161
 
146
162
  private func rebuildPanel() {
147
163
  let store = UsageStore.shared
148
- let geo = NotchGeometry.detect()
149
- guard store.showNotch else {
164
+ // No notch wanted, or no screen right now: hide it; the next screen change brings it back.
165
+ guard store.showNotch, let geo = NotchGeometry.detect() else {
150
166
  panel?.orderOut(nil); panel = nil; geometry = nil
151
167
  return
152
168
  }
@@ -58,8 +58,11 @@ enum CodexUsageAPI {
58
58
  try process.run()
59
59
  let pid = process.processIdentifier
60
60
  defer {
61
+ // codex may start helpers of its own (MCP servers): they go when it does.
62
+ let helpers = children(of: pid)
61
63
  try? input.fileHandleForWriting.close()
62
64
  if process.isRunning { process.terminate() }
65
+ for child in helpers { kill(child, SIGTERM) }
63
66
  // Do not let a hung child accumulate across refreshes.
64
67
  let deadline = Date().addingTimeInterval(1)
65
68
  while process.isRunning && Date() < deadline { Thread.sleep(forTimeInterval: 0.01) }
@@ -87,8 +90,9 @@ enum CodexUsageAPI {
87
90
  while let newline = buffer.firstIndex(of: 10) {
88
91
  let line = buffer.prefix(upTo: newline)
89
92
  buffer.removeSubrange(...newline)
90
- guard let message = try JSONSerialization.jsonObject(with: line) as? [String: Any] else { continue }
91
- guard let id = message["id"] as? Int else { continue }
93
+ // Skip anything that isn't a reply to us: log lines, blank lines, codex's own requests.
94
+ guard let message = (try? JSONSerialization.jsonObject(with: line)) as? [String: Any],
95
+ message["method"] == nil, let id = message["id"] as? Int else { continue }
92
96
  guard message["error"] == nil else { throw Failure.unavailable }
93
97
  if id == 1 {
94
98
  try send(["method": "initialized"])
@@ -104,6 +108,12 @@ enum CodexUsageAPI {
104
108
  throw Failure.timeout
105
109
  }
106
110
 
111
+ private static func children(of pid: pid_t) -> [pid_t] {
112
+ var buffer = [pid_t](repeating: 0, count: 64)
113
+ let count = proc_listchildpids(pid, &buffer, Int32(MemoryLayout<pid_t>.size * buffer.count))
114
+ return count > 0 ? Array(buffer.prefix(Int(count))) : []
115
+ }
116
+
107
117
  static func decode(_ result: [String: Any]) throws -> (usage: Usage, plan: String?, resetCredits: Int?) {
108
118
  let buckets = result["rateLimitsByLimitId"] as? [String: Any]
109
119
  // Never substitute an unrelated model bucket for the Codex quota.
@@ -64,7 +64,7 @@ struct Credentials {
64
64
 
65
65
  enum CredentialSource: CustomStringConvertible {
66
66
  case keychain // direct SecItem access (may prompt for permission)
67
- case securityCLI(String) // /usr/bin/security (account name), no per-app prompt
67
+ case securityCLI(String?) // /usr/bin/security (account name, if it could be read), no per-app prompt
68
68
  case file(URL)
69
69
 
70
70
  var description: String {
@@ -80,6 +80,7 @@ enum CredentialError: LocalizedError {
80
80
  case notFound
81
81
  case signedOut
82
82
  case malformed
83
+ case unknownAccount
83
84
  case keychain(OSStatus)
84
85
 
85
86
  var errorDescription: String? {
@@ -87,6 +88,7 @@ enum CredentialError: LocalizedError {
87
88
  case .notFound: return "Claude Code credentials not found. Run `claude` and sign in first."
88
89
  case .signedOut: return "Claude Code is signed out. Run `claude` in Terminal and /login."
89
90
  case .malformed: return "Credentials found but not in the expected format."
91
+ case .unknownAccount: return "Couldn't tell which Keychain account holds the Claude login, so it wasn't updated."
90
92
  case .keychain(let s):
91
93
  let msg = SecCopyErrorMessageString(s, nil) as String? ?? "OSStatus \(s)"
92
94
  return "Keychain error: \(msg)"
@@ -122,7 +124,31 @@ enum CredentialStore {
122
124
  }
123
125
  }
124
126
 
127
+ // A refreshed login that couldn't be written back yet. The refresh token rotates on refresh, so
128
+ // dropping it would sign Claude Code out; it's used (and saved again) until the store catches up.
129
+ private static let pendingLock = NSLock()
130
+ nonisolated(unsafe) private static var pending: (replaces: String?, fresh: Credentials, source: CredentialSource)?
131
+
132
+ static func holdUnsaved(_ fresh: Credentials, replacing old: Credentials, source: CredentialSource) {
133
+ pendingLock.lock(); defer { pendingLock.unlock() }
134
+ pending = (old.refreshToken, fresh, source)
135
+ }
136
+
125
137
  static func load() throws -> (Credentials, CredentialSource) {
138
+ let (stored, source) = try loadStored()
139
+ pendingLock.lock(); defer { pendingLock.unlock() }
140
+ guard let p = pending else { return (stored, source) }
141
+ // The store still has the login we refreshed from: ours is newer, try writing it again.
142
+ if stored.refreshToken == p.replaces {
143
+ if (try? save(p.fresh, to: p.source)) != nil { pending = nil; Log.info("refreshed token written back") }
144
+ return (p.fresh, p.source)
145
+ }
146
+ // Someone else (Claude Code, a new /login) changed it since: theirs wins.
147
+ pending = nil
148
+ return (stored, source)
149
+ }
150
+
151
+ private static func loadStored() throws -> (Credentials, CredentialSource) {
126
152
  // 1) /usr/bin/security: an Apple-signed tool already trusted for this item on most
127
153
  // machines, so it reads the token without raising a per-app Keychain prompt.
128
154
  if let (data, account) = readViaSecurityCLI() {
@@ -159,12 +185,16 @@ enum CredentialStore {
159
185
  /// Runs `security` with stdout captured through a temp file. Using a file (not a Pipe)
160
186
  /// means we never have to drain a pipe while blocking a thread, so this is safe to call
161
187
  /// synchronously from the main actor without risking a pipe-buffer deadlock.
162
- private static func runSecurity(_ args: [String]) -> (Int32, Data) {
188
+ /// `input` goes to its stdin; nothing secret ever goes in `args` (other users can see argv in `ps`).
189
+ /// Gives up after 10s: a Keychain dialog nobody answers must not stall refreshes forever.
190
+ private static func runSecurity(_ args: [String], input: Data? = nil) -> (Int32, Data) {
163
191
  let proc = Process()
164
192
  proc.executableURL = URL(fileURLWithPath: "/usr/bin/security")
165
193
  proc.arguments = args
194
+ let stdin = Pipe()
195
+ proc.standardInput = input == nil ? FileHandle.nullDevice : stdin
166
196
  let tmp = URL(fileURLWithPath: NSTemporaryDirectory())
167
- .appendingPathComponent("islet-sec-\(UUID().uuidString)")
197
+ .appendingPathComponent("tibodog-sec-\(UUID().uuidString)")
168
198
  FileManager.default.createFile(atPath: tmp.path, contents: nil)
169
199
  guard let handle = try? FileHandle(forWritingTo: tmp) else { return (-1, Data()) }
170
200
  defer { try? FileManager.default.removeItem(at: tmp) }
@@ -175,14 +205,23 @@ enum CredentialStore {
175
205
  let done = DispatchSemaphore(value: 0)
176
206
  proc.terminationHandler = { _ in done.signal() }
177
207
  do { try proc.run() } catch { try? handle.close(); return (-1, Data()) }
178
- done.wait()
208
+ if let input {
209
+ try? stdin.fileHandleForWriting.write(contentsOf: input)
210
+ try? stdin.fileHandleForWriting.close()
211
+ }
212
+ if done.wait(timeout: .now() + 10) == .timedOut {
213
+ proc.terminate()
214
+ _ = done.wait(timeout: .now() + 2)
215
+ try? handle.close()
216
+ return (-1, Data())
217
+ }
179
218
  try? handle.close()
180
219
  let data = (try? Data(contentsOf: tmp)) ?? Data()
181
220
  return (proc.terminationStatus, data)
182
221
  }
183
222
 
184
223
  /// Reads the raw JSON blob and the account name it is stored under, if the tool succeeds.
185
- private static func readViaSecurityCLI() -> (Data, String)? {
224
+ private static func readViaSecurityCLI() -> (Data, String?)? {
186
225
  let (code, data) = runSecurity(["find-generic-password", "-s", service, "-w"])
187
226
  guard code == 0 else { return nil }
188
227
  var blob = data
@@ -190,7 +229,7 @@ enum CredentialStore {
190
229
  guard !blob.isEmpty else { return nil }
191
230
  // The account is needed to update the same item in place later.
192
231
  let (mcode, meta) = runSecurity(["find-generic-password", "-s", service, "-g"])
193
- var account = NSUserName()
232
+ var account: String?
194
233
  if mcode == 0, let text = String(data: meta, encoding: .utf8),
195
234
  let range = text.range(of: "\"acct\"<blob>=\"") {
196
235
  let rest = text[range.upperBound...]
@@ -199,10 +238,17 @@ enum CredentialStore {
199
238
  return (blob, account)
200
239
  }
201
240
 
202
- private static func writeViaSecurityCLI(_ data: Data, account: String) throws {
203
- // -U updates the existing item in place, preserving its access control.
204
- guard let json = String(data: data, encoding: .utf8) else { throw CredentialError.malformed }
205
- let (code, _) = runSecurity(["add-generic-password", "-U", "-s", service, "-a", account, "-w", json])
241
+ private static func writeViaSecurityCLI(_ data: Data, account: String?) throws {
242
+ // Guessing the account would add a second item beside Claude Code's instead of updating it.
243
+ guard let account else { throw CredentialError.unknownAccount }
244
+ guard let json = String(data: data, encoding: .utf8), !json.contains("\n") else { throw CredentialError.malformed }
245
+ // -U updates the existing item in place, preserving its access control. The command goes in
246
+ // through `security -i` on stdin, so the token never shows up in a process list.
247
+ func quoted(_ s: String) -> String {
248
+ "\"" + s.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + "\""
249
+ }
250
+ let command = "add-generic-password -U -s \(quoted(service)) -a \(quoted(account)) -w \(quoted(json))\n"
251
+ let (code, _) = runSecurity(["-i"], input: Data(command.utf8))
206
252
  guard code == 0 else { throw CredentialError.keychain(errSecAuthFailed) }
207
253
  }
208
254
 
@@ -22,53 +22,83 @@ func L(_ ko: String, _ en: String) -> String {
22
22
  Locale.preferredLanguages.first?.hasPrefix("ko") == true ? ko : en
23
23
  }
24
24
 
25
+ /// Only a post on x.com/twitter.com over https: the link comes from a feed, which must not be able to
26
+ /// open a file, an app or another app's URL scheme.
27
+ func isPostURL(_ url: URL) -> Bool {
28
+ guard url.scheme == "https", let host = url.host?.lowercased() else { return false }
29
+ let hosts = ["x.com", "twitter.com", "www.x.com", "www.twitter.com", "mobile.twitter.com", "mobile.x.com"]
30
+ return hosts.contains(host) && url.path.contains("/status/")
31
+ }
32
+
25
33
  /// The dog widget: from the moment tibodog sees Tibo reset the limits, a dog barks beside the notch until
26
- /// the person opens the island and reads the post. Read every two seconds from tibodog's folder; once read,
27
- /// the post's id goes to alert-seen there so the dog stays quiet after a restart.
34
+ /// the person opens the island and reads the post. Read every two seconds from tibodog's folder: the CLI
35
+ /// writes alert.json, the widget writes credit.json when a reset credit comes in. Read ids go to
36
+ /// alert-seen there (one per line) so the dog stays quiet after a restart.
28
37
  @MainActor
29
38
  final class DogMonitor: ObservableObject {
30
39
  static let shared = DogMonitor()
31
40
 
32
- /// The reset not read yet, or nil.
41
+ /// The reset not read yet, or nil (a post on X first, then a reset credit).
33
42
  @Published private(set) var alert: DogAlert?
43
+ /// Whether the dog is moving right now: all the time for the first minute, then a short bark every
44
+ /// ten seconds, so an alert left unread for days doesn't redraw the notch 30 times a second.
45
+ @Published private(set) var barking = false
34
46
 
35
47
  private var timer: Timer?
48
+ private var shownAt = Date()
36
49
  private let dir = ProcessInfo.processInfo.environment["TIBODOG_HOME"]
37
50
  ?? (NSHomeDirectory() as NSString).appendingPathComponent(".tibodog")
38
- private var alertPath: String { (dir as NSString).appendingPathComponent("alert.json") }
39
- private var seenPath: String { (dir as NSString).appendingPathComponent("alert-seen") }
51
+ private func path(_ name: String) -> String { (dir as NSString).appendingPathComponent(name) }
40
52
 
41
53
  private init() {
42
- timer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in
43
- Task { @MainActor in self?.poll() }
54
+ timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
55
+ Task { @MainActor in self?.tick() }
44
56
  }
45
57
  poll()
46
58
  }
47
59
 
48
- func poll() {
60
+ private var ticks = 0
61
+ private func tick() {
62
+ ticks += 1
63
+ if ticks % 4 == 0 { poll() }
64
+ let since = Date().timeIntervalSince(shownAt)
65
+ let now = alert != nil && (since < 60 || since.truncatingRemainder(dividingBy: 10) < 2)
66
+ if now != barking { barking = now }
67
+ }
68
+
69
+ private func read(_ name: String) -> DogAlert? {
49
70
  let decoder = JSONDecoder()
50
71
  decoder.dateDecodingStrategy = .secondsSince1970
51
- guard let data = FileManager.default.contents(atPath: alertPath),
52
- let a = try? decoder.decode(DogAlert.self, from: data) else {
53
- if alert != nil { alert = nil }
54
- return
55
- }
56
- let seen = (try? String(contentsOfFile: seenPath, encoding: .utf8))?.trimmingCharacters(in: .whitespacesAndNewlines)
57
- let unread = seen == a.id ? nil : a
72
+ guard let data = FileManager.default.contents(atPath: path(name)) else { return nil }
73
+ return try? decoder.decode(DogAlert.self, from: data)
74
+ }
75
+
76
+ private var seen: Set<String> {
77
+ let text = (try? String(contentsOfFile: path("alert-seen"), encoding: .utf8)) ?? ""
78
+ return Set(text.split(whereSeparator: \.isNewline).map(String.init))
79
+ }
80
+
81
+ func poll() {
82
+ let seen = seen
83
+ let unread = [read("alert.json"), read("credit.json")].compactMap { $0 }.first { !seen.contains($0.id) }
58
84
  if unread != alert {
85
+ if unread?.id != alert?.id { shownAt = Date() }
59
86
  alert = unread
60
87
  // A new reset: check right away whether it reached this Codex account.
61
- if unread != nil, !unread!.isCredit { ResetTracker.shared.check() }
88
+ if let a = unread, !a.isCredit { ResetTracker.shared.check() }
62
89
  }
63
90
  }
64
91
 
65
- func markRead() {
66
- guard let a = alert else { return }
67
- try? a.id.write(toFile: seenPath, atomically: true, encoding: .utf8)
68
- alert = nil
92
+ /// Marks that alert read (only the one that was actually on screen, not whatever came in since).
93
+ func markRead(_ id: String) {
94
+ let kept = (try? String(contentsOfFile: path("alert-seen"), encoding: .utf8))?
95
+ .split(whereSeparator: \.isNewline).map(String.init).suffix(50) ?? []
96
+ let lines = (Array(kept) + [id]).joined(separator: "\n") + "\n"
97
+ try? lines.write(toFile: path("alert-seen"), atomically: true, encoding: .utf8)
98
+ poll()
69
99
  }
70
100
 
71
- /// A reset credit came in: the dog barks about that too.
101
+ /// A reset credit came in: the dog barks about that too (beside, not over, an unread post).
72
102
  func creditArrived(from before: Int, to now: Int) {
73
103
  let card: [String: Any] = [
74
104
  "id": "credit-\(now)-\(Int(Date().timeIntervalSince1970))",
@@ -81,14 +111,14 @@ final class DogMonitor: ObservableObject {
81
111
  ]
82
112
  guard let data = try? JSONSerialization.data(withJSONObject: card) else { return }
83
113
  try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
84
- try? data.write(to: URL(fileURLWithPath: alertPath), options: .atomic)
114
+ try? data.write(to: URL(fileURLWithPath: path("credit.json")), options: .atomic)
85
115
  poll()
86
116
  }
87
117
 
88
118
  /// The post on X; opening it counts as reading it.
89
119
  func openPost() {
90
- guard let s = alert?.url, let url = URL(string: s) else { return }
91
- NSWorkspace.shared.open(url)
92
- markRead()
120
+ guard let a = alert else { return }
121
+ if let s = a.url, let url = URL(string: s), isPostURL(url) { NSWorkspace.shared.open(url) }
122
+ markRead(a.id)
93
123
  }
94
124
  }
@@ -20,8 +20,8 @@ struct UsageWindow: Equatable {
20
20
  return "\(m)M"
21
21
  }
22
22
 
23
- var fraction: Double { min(max(utilization / 100, 0), 1) }
24
- var percentText: String { "\(Int(utilization.rounded()))%" }
23
+ var fraction: Double { utilization.isFinite ? min(max(utilization / 100, 0), 1) : 0 }
24
+ var percentText: String { utilization.isFinite ? "\(Int(min(max(utilization, -999), 9999).rounded()))%" : "–" }
25
25
  }
26
26
 
27
27
  /// Snapshot of every window the endpoint returned.
@@ -7,9 +7,12 @@ struct NotchGeometry: Equatable {
7
7
  let notchWidth: CGFloat
8
8
  let notchHeight: CGFloat
9
9
 
10
- static func detect() -> NotchGeometry {
10
+ /// nil while there's no screen at all (lid closed with nothing attached, mid-reconfiguration).
11
+ static func detect() -> NotchGeometry? {
11
12
  let screens = NSScreen.screens
12
- let screen = screens.first { $0.safeAreaInsets.top > 0 } ?? NSScreen.main ?? screens[0]
13
+ guard let screen = screens.first(where: { $0.safeAreaInsets.top > 0 }) ?? NSScreen.main ?? screens.first else {
14
+ return nil
15
+ }
13
16
  let inset = screen.safeAreaInsets.top
14
17
  if inset > 0, let l = screen.auxiliaryTopLeftArea, let r = screen.auxiliaryTopRightArea {
15
18
  let w = screen.frame.width - l.width - r.width
@@ -11,8 +11,9 @@ struct NotchView: View {
11
11
 
12
12
  @State private var hovering = ProcessInfo.processInfo.environment["TIBODOG_EXPANDED"] == "1"
13
13
  @State private var collapseTask: Task<Void, Never>?
14
- /// When the island opened: open long enough, the dog's post counts as read when it closes.
15
- @State private var openedAt: Date?
14
+ /// The alert on screen while the island is open, and since when: shown long enough, it counts as read
15
+ /// when the island closes (one that came in at the last moment doesn't).
16
+ @State private var shown: (id: String, since: Date)?
16
17
 
17
18
  private let expandedWidth: CGFloat = 440
18
19
  // Beside the notch, each side is the gauge and what's next to it (the barking dog, the album art, the
@@ -54,22 +55,27 @@ struct NotchView: View {
54
55
  )
55
56
  .contentShape(Rectangle())
56
57
  .contextMenu { TiboDogMenu() }
58
+ .onChange(of: dog.alert?.id) { _, id in
59
+ if hovering { shown = id.map { ($0, Date()) } }
60
+ }
57
61
  .onHover { over in
58
62
  collapseTask?.cancel()
59
63
  if over {
60
- if !hovering { openedAt = Date() }
64
+ if !hovering { shown = dog.alert.map { ($0.id, Date()) } }
61
65
  withAnimation(.spring(response: 0.36, dampingFraction: 0.82)) { hovering = true }
62
66
  } else {
63
67
  collapseTask = Task {
64
68
  try? await Task.sleep(for: .milliseconds(180))
65
69
  guard !Task.isCancelled else { return }
66
70
  // A quick brush doesn't count: the dog keeps barking until the post was open for a moment.
67
- let read = openedAt.map { Date().timeIntervalSince($0) >= 1 } ?? false
71
+ let read = shown.flatMap { s in
72
+ s.id == dog.alert?.id && Date().timeIntervalSince(s.since) >= 1 ? s.id : nil
73
+ }
68
74
  withAnimation(.spring(response: 0.32, dampingFraction: 0.86)) {
69
75
  hovering = false
70
- if read { dog.markRead() }
76
+ if let id = read { dog.markRead(id) }
71
77
  }
72
- openedAt = nil
78
+ shown = nil
73
79
  }
74
80
  }
75
81
  }
@@ -322,7 +328,7 @@ struct NowPlayingRow: View {
322
328
  }
323
329
 
324
330
  private func mmss(_ s: Double) -> String {
325
- let v = max(0, Int(s))
331
+ let v = s.isFinite ? Int(min(max(s, 0), 360_000)) : 0
326
332
  return String(format: "%d:%02d", v / 60, v % 60)
327
333
  }
328
334
  }
@@ -394,10 +400,11 @@ func barkStrength(_ t: TimeInterval) -> Double {
394
400
  struct BarkingDog: View {
395
401
  static let width: CGFloat = 30
396
402
  var size: CGFloat = 18
403
+ @ObservedObject private var dog = DogMonitor.shared
397
404
 
398
405
  var body: some View {
399
- TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { ctx in
400
- let bark = barkStrength(ctx.date.timeIntervalSinceReferenceDate)
406
+ TimelineView(.animation(minimumInterval: 1.0 / 30.0, paused: !dog.barking)) { ctx in
407
+ let bark = dog.barking ? barkStrength(ctx.date.timeIntervalSinceReferenceDate) : 0
401
408
  HStack(spacing: 0) {
402
409
  Text("🐕")
403
410
  .font(.system(size: size))
@@ -425,10 +432,11 @@ struct BarkingDog: View {
425
432
  struct Woof: View {
426
433
  static let width: CGFloat = 24
427
434
  let text: String
435
+ @ObservedObject private var dog = DogMonitor.shared
428
436
 
429
437
  var body: some View {
430
- TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { ctx in
431
- let bark = barkStrength(ctx.date.timeIntervalSinceReferenceDate)
438
+ TimelineView(.animation(minimumInterval: 1.0 / 30.0, paused: !dog.barking)) { ctx in
439
+ let bark = dog.barking ? barkStrength(ctx.date.timeIntervalSinceReferenceDate) : 0
432
440
  Text(text)
433
441
  .font(.system(size: 11, weight: .heavy, design: .rounded))
434
442
  .foregroundStyle(.yellow)
@@ -488,6 +496,9 @@ struct ResetCheck: View {
488
496
  case .windowRestarted(let start):
489
497
  return (L("✅ 우리 계정에도 들어왔어요 · 주간 사용량이 \(clockText(start))에 새로 시작",
490
498
  "✅ It reached your account · weekly usage started over at \(clockText(start))"), usageColor(0))
499
+ case .windowRestartedLikely(let start):
500
+ return (L("✅ 들어온 것 같아요 · 주간 사용량이 \(clockText(start))에 새로 시작 (그 전 기록이 없어 추정)",
501
+ "✅ Looks like it reached you · weekly usage started over at \(clockText(start)) (no earlier record to compare)"), usageColor(0))
491
502
  case .creditAdded(let from, let to):
492
503
  return (L("🎟️ 리셋권으로 들어왔어요 · \(from)개 → \(to)개", "🎟️ It came as a reset credit · \(from) → \(to)"), usageColor(0))
493
504
  case .pending:
@@ -77,6 +77,22 @@ final class NowPlayingMonitor: ObservableObject {
77
77
  return nil
78
78
  }
79
79
 
80
+ /// Runs the adapter (same pid, via exec) with a tiny watcher beside it that ends the adapter once
81
+ /// TiboDog is gone, so a crash or `kill -9` never leaves a perl process streaming to nobody.
82
+ static let watchdog = #"""
83
+ my $app = getppid(); my $adapter = $$;
84
+ if (fork() == 0) {
85
+ open STDOUT, '>', '/dev/null'; open STDIN, '<', '/dev/null';
86
+ while (1) {
87
+ sleep 1;
88
+ # The adapter is our parent: once it's gone we're reparented, and its pid may be reused.
89
+ exit 0 if getppid() != $adapter;
90
+ if (!kill 0, $app) { kill 'TERM', $adapter; exit 0 }
91
+ }
92
+ }
93
+ exec '/usr/bin/perl', @ARGV or die "exec: $!";
94
+ """#
95
+
80
96
  // MARK: Lifecycle
81
97
 
82
98
  func start() {
@@ -100,7 +116,7 @@ final class NowPlayingMonitor: ObservableObject {
100
116
  }
101
117
  let proc = Process()
102
118
  proc.executableURL = URL(fileURLWithPath: "/usr/bin/perl")
103
- proc.arguments = [paths.script.path, paths.framework.path, "stream", "--debounce=150"]
119
+ proc.arguments = ["-e", Self.watchdog, paths.script.path, paths.framework.path, "stream", "--debounce=150"]
104
120
  let p = Pipe()
105
121
  proc.standardOutput = p
106
122
  proc.standardError = FileHandle.nullDevice
@@ -109,8 +125,8 @@ final class NowPlayingMonitor: ObservableObject {
109
125
  guard !data.isEmpty else { return }
110
126
  Task { @MainActor in self?.ingest(data) }
111
127
  }
112
- proc.terminationHandler = { [weak self] _ in
113
- Task { @MainActor in self?.processDied() }
128
+ proc.terminationHandler = { [weak self] dead in
129
+ Task { @MainActor in self?.processDied(dead) }
114
130
  }
115
131
  do {
116
132
  try proc.run()
@@ -134,7 +150,9 @@ final class NowPlayingMonitor: ObservableObject {
134
150
  lastArtworkB64 = nil
135
151
  }
136
152
 
137
- private func processDied() {
153
+ private func processDied(_ dead: Process) {
154
+ // A late notice about an adapter we already replaced: not the one running now.
155
+ guard dead === process else { return }
138
156
  let wasRunning = process != nil
139
157
  tearDown()
140
158
  guard enabled, wasRunning else { return }
@@ -2,8 +2,11 @@ import Foundation
2
2
 
3
3
  /// Whether a reset Tibo announced actually reached this Codex account.
4
4
  enum ResetVerdict: Equatable {
5
- /// The weekly window started over after the post.
5
+ /// The weekly window started over after the post, while the old one still had time left.
6
6
  case windowRestarted(Date)
7
+ /// It started over after the post, but there's no record from before it to rule out the old window
8
+ /// simply running out.
9
+ case windowRestartedLikely(Date)
7
10
  /// A reset credit came in after the post.
8
11
  case creditAdded(from: Int, to: Int)
9
12
  /// Nothing yet; Codex is checked every few minutes.
@@ -24,6 +27,7 @@ final class ResetTracker: ObservableObject {
24
27
  var credits: Int?
25
28
  var weeklyStart: Date?
26
29
  var weeklyUsed: Double?
30
+ var weeklyMinutes: Int?
27
31
  }
28
32
 
29
33
  @Published private(set) var credits: Int?
@@ -32,6 +36,8 @@ final class ResetTracker: ObservableObject {
32
36
  /// Spending a reset credit right now, and what Codex said last time.
33
37
  @Published private(set) var redeeming = false
34
38
  @Published private(set) var lastOutcome: String?
39
+ /// When a credit was spent from here: that restart is ours, not Tibo's.
40
+ private var spentAt: Date?
35
41
 
36
42
  private var timer: Timer?
37
43
  private var checking = false
@@ -59,7 +65,8 @@ final class ResetTracker: ObservableObject {
59
65
  // TIBODOG_MOCK=1 reads the history from TIBODOG_HOME and never asks Codex.
60
66
  guard !checking, !UsageStore.mock else { return }
61
67
  checking = true
62
- Task.detached(priority: .utility) {
68
+ // A plain GCD thread, not the Swift concurrency pool: this blocks for up to 25s.
69
+ DispatchQueue.global(qos: .utility).async {
63
70
  let result = try? CodexUsageAPI.fetch()
64
71
  DispatchQueue.main.async {
65
72
  MainActor.assumeIsolated {
@@ -77,8 +84,19 @@ final class ResetTracker: ObservableObject {
77
84
  guard !redeeming else { return }
78
85
  redeeming = true
79
86
  lastOutcome = nil
87
+ if UsageStore.mock {
88
+ // Demos and screenshots: look like Codex said "reset", never spend a real credit.
89
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.9) {
90
+ self.redeeming = false
91
+ self.lastOutcome = "reset"
92
+ self.credits = max(0, (self.credits ?? 1) - 1)
93
+ self.spentAt = Date()
94
+ UsageStore.shared.mockReset()
95
+ }
96
+ return
97
+ }
80
98
  let key = UUID().uuidString
81
- Task.detached(priority: .userInitiated) {
99
+ DispatchQueue.global(qos: .userInitiated).async {
82
100
  var outcome = (try? CodexUsageAPI.consumeResetCredit(idempotencyKey: key)) ?? "error"
83
101
  // A dropped connection may still have gone through: asking again with the same key is safe.
84
102
  if outcome == "error" { outcome = (try? CodexUsageAPI.consumeResetCredit(idempotencyKey: key)) ?? "error" }
@@ -87,6 +105,7 @@ final class ResetTracker: ObservableObject {
87
105
  let tracker = ResetTracker.shared
88
106
  tracker.redeeming = false
89
107
  tracker.lastOutcome = outcome
108
+ if outcome == "reset" { tracker.spentAt = Date() }
90
109
  tracker.check()
91
110
  UsageStore.shared.refresh(force: true)
92
111
  }
@@ -101,7 +120,8 @@ final class ResetTracker: ObservableObject {
101
120
  guard let r = w.resetsAt, let m = w.durationMinutes else { return nil }
102
121
  return r.addingTimeInterval(-Double(m) * 60)
103
122
  }
104
- let snap = Snapshot(at: Date(), credits: newCredits, weeklyStart: start, weeklyUsed: weekly?.utilization)
123
+ let snap = Snapshot(at: Date(), credits: newCredits, weeklyStart: start, weeklyUsed: weekly?.utilization,
124
+ weeklyMinutes: weekly?.durationMinutes)
105
125
  let last = history.last
106
126
  credits = newCredits
107
127
  // Only keep changes: a new credit count or a weekly window that started over.
@@ -119,8 +139,15 @@ final class ResetTracker: ObservableObject {
119
139
  guard let latest = history.last else { return reachable ? .pending : .unknown }
120
140
  // A global reset starts the weekly window over (Codex's windows start at the reset, or at the
121
141
  // first use after it). Allow a little slack for clocks and propagation.
122
- if let start = latest.weeklyStart, start >= postedAt.addingTimeInterval(-30 * 60) {
123
- return .windowRestarted(start)
142
+ if let start = latest.weeklyStart, start >= postedAt.addingTimeInterval(-30 * 60),
143
+ !(spentAt.map { abs($0.timeIntervalSince(start)) < 10 * 60 } ?? false) {
144
+ // With a record from before the post, a reset is told apart from the old window running out.
145
+ if let before = history.last(where: { $0.at < postedAt }), let oldStart = before.weeklyStart {
146
+ let oldEnd = oldStart.addingTimeInterval(Double(before.weeklyMinutes ?? 10_080) * 60)
147
+ if !sameWindow(oldStart, start), oldEnd > start.addingTimeInterval(60) { return .windowRestarted(start) }
148
+ } else {
149
+ return .windowRestartedLikely(start)
150
+ }
124
151
  }
125
152
  let before = history.last { $0.at < postedAt }?.credits
126
153
  if let b = before, let after = history.filter({ $0.at >= postedAt }).compactMap(\.credits).max(), after > b {