tibodog 0.1.2 → 0.1.4
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.
- package/README.md +7 -2
- package/app/Sources/TiboDog/App.swift +5 -2
- package/app/Sources/TiboDog/CodexUsageAPI.swift +12 -2
- package/app/Sources/TiboDog/Credentials.swift +56 -10
- package/app/Sources/TiboDog/DogMonitor.swift +55 -25
- package/app/Sources/TiboDog/Models.swift +2 -2
- package/app/Sources/TiboDog/NotchGeometry.swift +5 -2
- package/app/Sources/TiboDog/NotchView.swift +22 -11
- package/app/Sources/TiboDog/NowPlayingMonitor.swift +7 -4
- package/app/Sources/TiboDog/ResetTracker.swift +23 -6
- package/app/Sources/TiboDog/UsageStore.swift +14 -7
- package/app/Tests/TiboDogTests/CodexUsageTests.swift +77 -0
- package/bin/tibodog.js +66 -29
- package/package.json +3 -2
- package/src/bark.js +18 -9
- package/src/detect.js +1 -1
- package/src/i18n.js +2 -2
- package/src/sources.js +146 -36
- package/src/widget.js +44 -15
package/README.md
CHANGED
|
@@ -54,16 +54,21 @@ Any RSS/Atom feed of the account works: a Nitter instance, self-hosted [RSSHub](
|
|
|
54
54
|
(`/twitter/user/thsottiaux`), rss.app, and so on.
|
|
55
55
|
|
|
56
56
|
```bash
|
|
57
|
-
npx tibodog --rss "https://
|
|
57
|
+
npx tibodog --rss "https://nitter.kareem.one/thsottiaux/rss"
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
+
That Nitter instance served Tibo's feed when this was written (Oct 2026); most public ones are blocked or
|
|
61
|
+
gone (nitter.net, xcancel.com, rsshub.app all refused). Mirrors come and go, so if it stops working, find
|
|
62
|
+
another or self-host RSSHub. They're run by volunteers: keep the default 5-minute interval.
|
|
63
|
+
tibodog only ever opens the post on x.com, never the mirror's own links.
|
|
64
|
+
|
|
60
65
|
## 옵션 / Options
|
|
61
66
|
|
|
62
67
|
| flag | default | |
|
|
63
68
|
|---|---|---|
|
|
64
69
|
| `--user <handle>` | `thsottiaux` | 감시할 계정 / account to watch |
|
|
65
70
|
| `--rss <url>` | | use an RSS feed instead of the X API |
|
|
66
|
-
| `--interval <sec>` |
|
|
71
|
+
| `--interval <sec>` | 300 | poll interval (min 15) |
|
|
67
72
|
| `--keyword <text>` | | extra trigger phrase, repeatable |
|
|
68
73
|
| `--barks <n>` | 1 | 짖는 소리 반복 횟수 / times to play the bark |
|
|
69
74
|
| `--sound <file.wav\|synth>` | real recording | bring your own dog, or `synth` for the synthesized bark |
|
|
@@ -75,6 +75,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
75
75
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
|
76
76
|
NSApp.setActivationPolicy(.accessory)
|
|
77
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)
|
|
78
81
|
let store = UsageStore.shared
|
|
79
82
|
store.start()
|
|
80
83
|
rebuildPanel()
|
|
@@ -158,8 +161,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
158
161
|
|
|
159
162
|
private func rebuildPanel() {
|
|
160
163
|
let store = UsageStore.shared
|
|
161
|
-
|
|
162
|
-
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 {
|
|
163
166
|
panel?.orderOut(nil); panel = nil; geometry = nil
|
|
164
167
|
return
|
|
165
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
|
-
|
|
91
|
-
guard let
|
|
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)
|
|
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
|
-
|
|
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("
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
204
|
-
guard let
|
|
205
|
-
let
|
|
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
|
|
27
|
-
/// the
|
|
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
|
|
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:
|
|
43
|
-
Task { @MainActor in self?.
|
|
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
|
-
|
|
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:
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
let
|
|
57
|
-
|
|
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
|
|
88
|
+
if let a = unread, !a.isCredit { ResetTracker.shared.check() }
|
|
62
89
|
}
|
|
63
90
|
}
|
|
64
91
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
try?
|
|
68
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
///
|
|
15
|
-
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
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:
|
|
@@ -85,7 +85,8 @@ final class NowPlayingMonitor: ObservableObject {
|
|
|
85
85
|
open STDOUT, '>', '/dev/null'; open STDIN, '<', '/dev/null';
|
|
86
86
|
while (1) {
|
|
87
87
|
sleep 1;
|
|
88
|
-
|
|
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;
|
|
89
90
|
if (!kill 0, $app) { kill 'TERM', $adapter; exit 0 }
|
|
90
91
|
}
|
|
91
92
|
}
|
|
@@ -124,8 +125,8 @@ final class NowPlayingMonitor: ObservableObject {
|
|
|
124
125
|
guard !data.isEmpty else { return }
|
|
125
126
|
Task { @MainActor in self?.ingest(data) }
|
|
126
127
|
}
|
|
127
|
-
proc.terminationHandler = { [weak self]
|
|
128
|
-
Task { @MainActor in self?.processDied() }
|
|
128
|
+
proc.terminationHandler = { [weak self] dead in
|
|
129
|
+
Task { @MainActor in self?.processDied(dead) }
|
|
129
130
|
}
|
|
130
131
|
do {
|
|
131
132
|
try proc.run()
|
|
@@ -149,7 +150,9 @@ final class NowPlayingMonitor: ObservableObject {
|
|
|
149
150
|
lastArtworkB64 = nil
|
|
150
151
|
}
|
|
151
152
|
|
|
152
|
-
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 }
|
|
153
156
|
let wasRunning = process != nil
|
|
154
157
|
tearDown()
|
|
155
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
|
-
|
|
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 {
|
|
@@ -83,12 +90,13 @@ final class ResetTracker: ObservableObject {
|
|
|
83
90
|
self.redeeming = false
|
|
84
91
|
self.lastOutcome = "reset"
|
|
85
92
|
self.credits = max(0, (self.credits ?? 1) - 1)
|
|
93
|
+
self.spentAt = Date()
|
|
86
94
|
UsageStore.shared.mockReset()
|
|
87
95
|
}
|
|
88
96
|
return
|
|
89
97
|
}
|
|
90
98
|
let key = UUID().uuidString
|
|
91
|
-
|
|
99
|
+
DispatchQueue.global(qos: .userInitiated).async {
|
|
92
100
|
var outcome = (try? CodexUsageAPI.consumeResetCredit(idempotencyKey: key)) ?? "error"
|
|
93
101
|
// A dropped connection may still have gone through: asking again with the same key is safe.
|
|
94
102
|
if outcome == "error" { outcome = (try? CodexUsageAPI.consumeResetCredit(idempotencyKey: key)) ?? "error" }
|
|
@@ -97,6 +105,7 @@ final class ResetTracker: ObservableObject {
|
|
|
97
105
|
let tracker = ResetTracker.shared
|
|
98
106
|
tracker.redeeming = false
|
|
99
107
|
tracker.lastOutcome = outcome
|
|
108
|
+
if outcome == "reset" { tracker.spentAt = Date() }
|
|
100
109
|
tracker.check()
|
|
101
110
|
UsageStore.shared.refresh(force: true)
|
|
102
111
|
}
|
|
@@ -111,7 +120,8 @@ final class ResetTracker: ObservableObject {
|
|
|
111
120
|
guard let r = w.resetsAt, let m = w.durationMinutes else { return nil }
|
|
112
121
|
return r.addingTimeInterval(-Double(m) * 60)
|
|
113
122
|
}
|
|
114
|
-
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)
|
|
115
125
|
let last = history.last
|
|
116
126
|
credits = newCredits
|
|
117
127
|
// Only keep changes: a new credit count or a weekly window that started over.
|
|
@@ -129,8 +139,15 @@ final class ResetTracker: ObservableObject {
|
|
|
129
139
|
guard let latest = history.last else { return reachable ? .pending : .unknown }
|
|
130
140
|
// A global reset starts the weekly window over (Codex's windows start at the reset, or at the
|
|
131
141
|
// first use after it). Allow a little slack for clocks and propagation.
|
|
132
|
-
if let start = latest.weeklyStart, start >= postedAt.addingTimeInterval(-30 * 60)
|
|
133
|
-
|
|
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
|
+
}
|
|
134
151
|
}
|
|
135
152
|
let before = history.last { $0.at < postedAt }?.credits
|
|
136
153
|
if let b = before, let after = history.filter({ $0.at >= postedAt }).compactMap(\.credits).max(), after > b {
|
|
@@ -151,10 +151,6 @@ final class UsageStore: ObservableObject {
|
|
|
151
151
|
} catch UsageAPI.Failure.unauthorized where !refreshed {
|
|
152
152
|
creds = try await refreshAndSave(creds, source, allowRefresh: allowRefresh)
|
|
153
153
|
result = try await UsageAPI.fetch(token: creds.accessToken)
|
|
154
|
-
} catch UsageAPI.Failure.rateLimited where !refreshed && creds.canRefresh && allowRefresh {
|
|
155
|
-
// The endpoint answers 429 (not 401) to stale tokens; one refresh usually clears it.
|
|
156
|
-
creds = try await refreshAndSave(creds, source, allowRefresh: allowRefresh)
|
|
157
|
-
result = try await UsageAPI.fetch(token: creds.accessToken)
|
|
158
154
|
}
|
|
159
155
|
Log.info("usage ok: 5h=\(result.fiveHour?.percentText ?? "-") 7d=\(result.sevenDay?.percentText ?? "-")")
|
|
160
156
|
publish(for: provider) { $0.usage = result; $0.lastUpdated = Date(); $0.backoffUntil = nil; $0.errorMessage = nil }
|
|
@@ -175,9 +171,20 @@ final class UsageStore: ObservableObject {
|
|
|
175
171
|
guard allowRefresh else { throw StoreError.tokenExpired }
|
|
176
172
|
Log.info("refreshing token…")
|
|
177
173
|
let fresh = try await OAuth.refresh(creds)
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
174
|
+
// Claude Code (or a new /login) may have changed the stored login while we waited: theirs wins.
|
|
175
|
+
if let (stored, _) = try? await CredentialStore.loadAsync(),
|
|
176
|
+
stored.refreshToken != creds.refreshToken || stored.accessToken != creds.accessToken {
|
|
177
|
+
Log.info("stored login changed during refresh; using that one")
|
|
178
|
+
return stored
|
|
179
|
+
}
|
|
180
|
+
do {
|
|
181
|
+
try await CredentialStore.saveAsync(fresh, to: source)
|
|
182
|
+
Log.info("token refreshed and saved to \(source)")
|
|
183
|
+
} catch {
|
|
184
|
+
// The old refresh token is spent now: keep the new one until it can be written.
|
|
185
|
+
CredentialStore.holdUnsaved(fresh, replacing: creds, source: source)
|
|
186
|
+
Log.error("token refreshed but not written back yet: \(error.localizedDescription)")
|
|
187
|
+
}
|
|
181
188
|
return fresh
|
|
182
189
|
}
|
|
183
190
|
|