tibodog 0.0.0-stage → 0.1.1

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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +177 -2
  3. package/app/LICENSE +21 -0
  4. package/app/Package.swift +18 -0
  5. package/app/Resources/AppIcon.icns +0 -0
  6. package/app/Resources/Info.plist +20 -0
  7. package/app/Scripts/build-app.sh +44 -0
  8. package/app/Scripts/make-icon.swift +51 -0
  9. package/app/Sources/TiboDog/App.swift +163 -0
  10. package/app/Sources/TiboDog/CodexUsageAPI.swift +125 -0
  11. package/app/Sources/TiboDog/Credentials.swift +241 -0
  12. package/app/Sources/TiboDog/DogMonitor.swift +94 -0
  13. package/app/Sources/TiboDog/Log.swift +17 -0
  14. package/app/Sources/TiboDog/Models.swift +92 -0
  15. package/app/Sources/TiboDog/NotchGeometry.swift +21 -0
  16. package/app/Sources/TiboDog/NotchPanel.swift +38 -0
  17. package/app/Sources/TiboDog/NotchView.swift +571 -0
  18. package/app/Sources/TiboDog/NowPlayingMonitor.swift +245 -0
  19. package/app/Sources/TiboDog/OAuth.swift +64 -0
  20. package/app/Sources/TiboDog/ResetTracker.swift +148 -0
  21. package/app/Sources/TiboDog/UsageAPI.swift +54 -0
  22. package/app/Sources/TiboDog/UsageStore.swift +212 -0
  23. package/app/Vendor/mediaremote-adapter/LICENSE +28 -0
  24. package/app/Vendor/mediaremote-adapter/README.md +7 -0
  25. package/app/Vendor/mediaremote-adapter/bin/mediaremote-adapter.pl +280 -0
  26. package/app/Vendor/mediaremote-adapter/build-framework.sh +33 -0
  27. package/app/Vendor/mediaremote-adapter/include/MediaRemoteAdapter.h +126 -0
  28. package/app/Vendor/mediaremote-adapter/src/adapter/env.h +25 -0
  29. package/app/Vendor/mediaremote-adapter/src/adapter/env.m +105 -0
  30. package/app/Vendor/mediaremote-adapter/src/adapter/get.h +11 -0
  31. package/app/Vendor/mediaremote-adapter/src/adapter/get.m +146 -0
  32. package/app/Vendor/mediaremote-adapter/src/adapter/globals.h +15 -0
  33. package/app/Vendor/mediaremote-adapter/src/adapter/globals.m +19 -0
  34. package/app/Vendor/mediaremote-adapter/src/adapter/keys.h +19 -0
  35. package/app/Vendor/mediaremote-adapter/src/adapter/keys.m +83 -0
  36. package/app/Vendor/mediaremote-adapter/src/adapter/now_playing.h +20 -0
  37. package/app/Vendor/mediaremote-adapter/src/adapter/now_playing.m +201 -0
  38. package/app/Vendor/mediaremote-adapter/src/adapter/repeat.m +44 -0
  39. package/app/Vendor/mediaremote-adapter/src/adapter/seek.m +30 -0
  40. package/app/Vendor/mediaremote-adapter/src/adapter/send.m +69 -0
  41. package/app/Vendor/mediaremote-adapter/src/adapter/shuffle.m +46 -0
  42. package/app/Vendor/mediaremote-adapter/src/adapter/speed.m +30 -0
  43. package/app/Vendor/mediaremote-adapter/src/adapter/stream.m +523 -0
  44. package/app/Vendor/mediaremote-adapter/src/adapter/test.m +217 -0
  45. package/app/Vendor/mediaremote-adapter/src/private/MediaRemote.h +163 -0
  46. package/app/Vendor/mediaremote-adapter/src/private/MediaRemote.m +114 -0
  47. package/app/Vendor/mediaremote-adapter/src/test/NowPlayingTest.h +50 -0
  48. package/app/Vendor/mediaremote-adapter/src/test/NowPlayingTest.m +231 -0
  49. package/app/Vendor/mediaremote-adapter/src/test/main.m +53 -0
  50. package/app/Vendor/mediaremote-adapter/src/utility/Debounce.h +24 -0
  51. package/app/Vendor/mediaremote-adapter/src/utility/Debounce.m +41 -0
  52. package/app/Vendor/mediaremote-adapter/src/utility/helpers.h +27 -0
  53. package/app/Vendor/mediaremote-adapter/src/utility/helpers.m +246 -0
  54. package/bin/tibodog.js +162 -0
  55. package/package.json +46 -4
  56. package/sounds/CREDITS.md +11 -0
  57. package/sounds/bark.wav +0 -0
  58. package/src/bark.js +125 -0
  59. package/src/detect.js +15 -0
  60. package/src/dog.js +48 -0
  61. package/src/i18n.js +107 -0
  62. package/src/sources.js +96 -0
  63. package/src/widget.js +115 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 tibodog contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,178 @@
1
- # Temporary Holding Version
1
+ # 🐕 tibodog
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm](https://img.shields.io/npm/v/tibodog)](https://www.npmjs.com/package/tibodog)
4
+
5
+ > 티보가 리셋하면 짖는 개.
6
+ > A dog that barks when Tibo resets the limits.
7
+
8
+ ![tibodog in the notch: usage on both sides, Now Playing, and the dog barking](docs/mockup-collapsed.jpg)
9
+
10
+ ```
11
+ / \__ _______________
12
+ ( O\___ | WOOF! WOOF! |
13
+ / O <== | RESET! RESET! |
14
+ / (_____/ |_______________|
15
+ /_____/ U
16
+ ```
17
+
18
+ Tibo ([@thsottiaux](https://x.com/thsottiaux)) posts on X whenever the Codex usage limits get reset.
19
+ Run `npx tibodog` and:
20
+
21
+ - 🏝️ a **notch widget** comes up on macOS: your Claude Code or Codex usage on both sides of the notch,
22
+ Now Playing next to it, and the dog
23
+ - 👀 it **watches Tibo's feed**, and when a reset post shows up
24
+ - 🐶 the dog **barks in the notch until you read it**, 🔊 barks out loud (a real recording), and 🔔 pops a
25
+ notification
26
+
27
+ 명령어 하나로 노치 위젯(사용량 · 음악 · 개)이 뜨고, 티보의 X를 감시하다가 리셋 글이 오면 개가 짖어요.
28
+
29
+ No npm dependencies. Node 18+. The widget needs macOS 14+ and Xcode Command Line Tools to build on first
30
+ run; on Linux and Windows tibodog still watches and barks in the terminal.
31
+
32
+ ## 빠른 시작 / Quick start
33
+
34
+ ```bash
35
+ npx tibodog --test # 위젯 띄우고 바로 한번 짖어보기 / bring up the widget and bark once
36
+ ```
37
+
38
+ The first run builds the widget into `~/.tibodog` (under a minute). After that it starts instantly and keeps
39
+ running even when you close the terminal. Then pick a way to read X:
40
+
41
+ ### A) 공식 X API / Official X API
42
+
43
+ ```bash
44
+ export X_BEARER_TOKEN=xxxxxxxx
45
+ npx tibodog
46
+ ```
47
+
48
+ Reading a user timeline needs a paid X API tier. The default poll is every 5 minutes to stay
49
+ under rate limits. On a 429 the dog naps until the limit resets (the irony is noted).
50
+
51
+ ### B) RSS 미러 / Any RSS mirror (free)
52
+
53
+ Any RSS/Atom feed of the account works: a Nitter instance, self-hosted [RSSHub](https://docs.rsshub.app)
54
+ (`/twitter/user/thsottiaux`), rss.app, and so on.
55
+
56
+ ```bash
57
+ npx tibodog --rss "https://your-rsshub.example.com/twitter/user/thsottiaux"
58
+ ```
59
+
60
+ ## 옵션 / Options
61
+
62
+ | flag | default | |
63
+ |---|---|---|
64
+ | `--user <handle>` | `thsottiaux` | 감시할 계정 / account to watch |
65
+ | `--rss <url>` | | use an RSS feed instead of the X API |
66
+ | `--interval <sec>` | 60 (RSS) / 300 (X) | poll interval |
67
+ | `--keyword <text>` | | extra trigger phrase, repeatable |
68
+ | `--barks <n>` | 1 | 짖는 소리 반복 횟수 / times to play the bark |
69
+ | `--sound <file.wav\|synth>` | real recording | bring your own dog, or `synth` for the synthesized bark |
70
+ | `--lang <ko\|en>` | system locale | 표시 언어 / UI language |
71
+ | `--open` | off | open the post in the browser |
72
+ | `--no-widget` | | 노치 위젯 끄기 / don't start the notch widget |
73
+ | `--install-app` | | 위젯을 앱으로 설치 / install the widget as an app (below) |
74
+ | `--bark-old` | off | also bark at reset posts already in the feed on startup |
75
+ | `--once` | off | check once and exit (for cron) |
76
+ | `--test` | | just bark |
77
+
78
+ You can also set these through env vars: `TIBODOG_USER`, `TIBODOG_RSS`, `TIBODOG_INTERVAL`, `TIBODOG_SOUND`, `TIBODOG_LANG`.
79
+
80
+ ## 언어 / Language
81
+
82
+ 한국어(`ko`)와 영어(`en`)를 지원해요. 따로 지정하지 않으면 시스템 언어(`LANG`)를 따라가요.
83
+
84
+ ```bash
85
+ npx tibodog --lang ko # 멍! 멍멍! 리셋이다! 리셋!
86
+ npx tibodog --lang en # WOOF! WOOF! RESET! RESET!
87
+ ```
88
+
89
+ Order: `--lang` → `TIBODOG_LANG` → `LC_ALL` / `LC_MESSAGES` / `LANG` → system locale → `en`.
90
+ To add a language, add an entry to [`src/i18n.js`](src/i18n.js).
91
+
92
+ ## 노치 위젯 / The notch widget (macOS)
93
+
94
+ ![The widget opened: Tibo's post with a check that the reset reached the account, Codex usage, reset credits, Now Playing](docs/mockup-expanded.jpg)
95
+
96
+ - **Usage**: the 5-hour and weekly windows of Claude Code (the same numbers as `/usage`) or Codex, with
97
+ reset timers. Hover the notch for details; switch between Claude and Codex there or in the menu.
98
+ - **Now Playing**: album art and dancing bars while music plays; hover for the track and controls. Works with
99
+ anything that publishes to macOS Now Playing (Spotify, Apple Music, YouTube Music in Chrome, Safari...).
100
+ - **The dog**: on a reset, it barks beside the notch until you read it. Hover to see Tibo's post and a link;
101
+ keep it open for a second and the dog goes back to sleep. A quick brush doesn't count. It survives restarts,
102
+ so a reset you missed while away is still waiting for you.
103
+ - **Did the reset reach you?** The dog's card checks it against your Codex account: ✅ when your weekly
104
+ window started over after Tibo's post, 🎟️ when it came as a reset credit, ⏳ while it hasn't yet. Codex is
105
+ read every 10 minutes (even with Claude shown) and the changes are kept in `~/.tibodog/codex-history.json`.
106
+ A new reset credit makes the dog bark too.
107
+ - **Reset credits** (Codex tab): how many you have, and **Use a reset →** to spend one through Codex's own
108
+ `account/rateLimitResetCredit/consume`, after a confirm. It warns you when there's barely anything to reset.
109
+ - Right-click the island (or use the menu bar icon) for settings: provider, Now Playing, menu bar text,
110
+ launch at login.
111
+
112
+ 리셋이 오면 노치 옆에서 개가 계속 짖어요. 마우스를 올리면 티보의 글이 보이고, 1초쯤 읽고 떼면 개가 들어가요.
113
+ 살짝 스치기만 하면 계속 짖어요. 사용량과 음악은 늘 보여요.
114
+
115
+ ### 앱으로 설치 (선택) / Install as an app (optional)
116
+
117
+ `npx tibodog` is all you need. If you want the widget to start with your Mac even when tibodog isn't
118
+ running, install it as an app and turn on **Launch at Login** in its menu:
119
+
120
+ ```bash
121
+ npx tibodog --install-app # → /Applications/TiboDog.app
122
+ ```
123
+
124
+ The X watching still runs in `tibodog`; the app shows usage and music on its own and barks when tibodog
125
+ reports a reset (they talk through `~/.tibodog/alert.json`).
126
+
127
+ ### Usage sources
128
+
129
+ - **Claude Code**: reuses the Claude Code login already on your Mac (Keychain item `Claude Code-credentials`,
130
+ read through `/usr/bin/security`, falling back to `~/.claude/.credentials.json`) and calls
131
+ `api.anthropic.com/api/oauth/usage`. Refreshes an expired token the same way Claude Code does and writes it
132
+ back; turn that off in the menu. Usage limits are a subscription feature, so API-key logins show nothing.
133
+ - **Codex**: starts the installed `codex app-server`, reads `account/rateLimits/read`, and closes it. Sign in
134
+ to Codex with ChatGPT first. Set `TIBODOG_CODEX_PATH` if it lives somewhere unusual.
135
+ - **Now Playing** on macOS 15.4+ goes through the bundled
136
+ [mediaremote-adapter](https://github.com/ungive/mediaremote-adapter) (BSD-3, in `app/Vendor/`). If Apple
137
+ breaks it, music quietly disappears and the rest keeps working.
138
+
139
+ ## 어떻게 판단하나 / How it decides
140
+
141
+ A post counts as a reset post when it has a reset word (`reset`, `resetting`, `refreshed`, `리셋`, `초기화`)
142
+ **and** a limits word (`limits`, `usage`, `rate`, `codex`, `weekly`, `사용량`, `한도`, ...).
143
+ See [`src/detect.js`](src/detect.js). If the dog barks at the wrong thing, send a PR with a test case.
144
+
145
+ On the first run it only records the posts already in the feed, so it won't bark at old ones.
146
+ Seen IDs are stored in `~/.tibodog/<user>.json`.
147
+
148
+ ## 개발 / Development
149
+
150
+ ```bash
151
+ npm test # the watcher
152
+ cd app && swift test # the widget
153
+ cd app && make run # build app/build/TiboDog.app and open it
154
+ ```
155
+
156
+ Widget flags for development: `TIBODOG_DEBUG=1` (logs to stderr), `TIBODOG_MOCK=1` (sample data, no Keychain
157
+ or network), `TIBODOG_EXPANDED=1` (start expanded), `TIBODOG_SNAPSHOT=/path.png` (render and quit;
158
+ `make shots` in `app/`).
159
+
160
+ ## Privacy
161
+
162
+ tibodog sends no conversations or files anywhere. It talks to X (or the RSS feed you give it), Anthropic for
163
+ Claude usage and token refresh, and OpenAI through the installed Codex app server. No analytics.
164
+
165
+ ## 소리 출처 / Sound credits
166
+
167
+ The bundled bark is ["Barking of a dog"](https://commons.wikimedia.org/wiki/File:Barking_of_a_dog.ogg)
168
+ by Amada44 on Wikimedia Commons, licensed [CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/).
169
+ The audio file keeps that license. The code is MIT ([`LICENSE`](LICENSE); the widget in `app/` keeps Islet's notice in [`app/LICENSE`](app/LICENSE)). Details in [`sounds/CREDITS.md`](sounds/CREDITS.md).
170
+ The widget grew out of [Islet](https://github.com/aiasapkr-beep/islet), the same author's notch usage app.
171
+
172
+ The mockups in `docs/` use macOS Sonoma's default wallpaper, © Apple Inc.
173
+
174
+ ## Disclaimer
175
+
176
+ A fan-made joke project. Not affiliated with OpenAI or X. Be nice to Tibo. 🐶
177
+
178
+ MIT
package/app/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kim Minsu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,18 @@
1
+ // swift-tools-version:5.10
2
+ import PackageDescription
3
+
4
+ let package = Package(
5
+ name: "TiboDog",
6
+ platforms: [.macOS(.v14)],
7
+ targets: [
8
+ .executableTarget(
9
+ name: "TiboDog",
10
+ path: "Sources/TiboDog",
11
+ linkerSettings: [
12
+ .linkedFramework("Security"),
13
+ .linkedFramework("ServiceManagement"),
14
+ ]
15
+ ),
16
+ .testTarget(name: "TiboDogTests", dependencies: ["TiboDog"])
17
+ ]
18
+ )
Binary file
@@ -0,0 +1,20 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>CFBundleDevelopmentRegion</key><string>en</string>
6
+ <key>CFBundleExecutable</key><string>TiboDog</string>
7
+ <key>CFBundleIdentifier</key><string>kr.asapai.TiboDog</string>
8
+ <key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
9
+ <key>CFBundleName</key><string>TiboDog</string>
10
+ <key>CFBundleDisplayName</key><string>TiboDog</string>
11
+ <key>CFBundlePackageType</key><string>APPL</string>
12
+ <key>CFBundleShortVersionString</key><string>0.1.0</string>
13
+ <key>CFBundleVersion</key><string>1</string>
14
+ <key>CFBundleIconFile</key><string>AppIcon</string>
15
+ <key>LSMinimumSystemVersion</key><string>14.0</string>
16
+ <key>LSUIElement</key><true/>
17
+ <key>NSHighResolutionCapable</key><true/>
18
+ <key>NSHumanReadableCopyright</key><string>MIT License</string>
19
+ </dict>
20
+ </plist>
@@ -0,0 +1,44 @@
1
+ #!/bin/bash
2
+ # Builds TiboDog.app (release, signed with a local identity or ad-hoc).
3
+ # Output goes to ./build, or $TIBODOG_APP_OUT; intermediate files to ./.build, or $TIBODOG_SCRATCH
4
+ # (`npx tibodog` points both into ~/.tibodog so nothing is written inside the npm cache).
5
+ set -euo pipefail
6
+ cd "$(dirname "$0")/.."
7
+
8
+ CONFIG="${1:-release}"
9
+ OUT="${TIBODOG_APP_OUT:-build}"
10
+ SCRATCH="${TIBODOG_SCRATCH:-.build}"
11
+ APP="$OUT/TiboDog.app"
12
+
13
+ swift build -c "$CONFIG" --scratch-path "$SCRATCH" 2>&1 | tail -n 3
14
+ BIN="$SCRATCH/$CONFIG/TiboDog"
15
+
16
+ # Now Playing helper (see Vendor/mediaremote-adapter/README.md). Built once, reused.
17
+ ADAPTER=Vendor/mediaremote-adapter
18
+ ADAPTER_BUILD="${TIBODOG_SCRATCH:+$SCRATCH/mediaremote-adapter}"
19
+ ADAPTER_BUILD="${ADAPTER_BUILD:-$ADAPTER/build}"
20
+ if [ ! -d "$ADAPTER_BUILD/MediaRemoteAdapter.framework" ]; then
21
+ "$ADAPTER/build-framework.sh" "$ADAPTER_BUILD" 2>&1 | grep -vE "warning:|note:|^\s" || true
22
+ fi
23
+
24
+ rm -rf "$APP"
25
+ mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
26
+ cp "$BIN" "$APP/Contents/MacOS/TiboDog"
27
+ cp Resources/Info.plist "$APP/Contents/"
28
+ [ -f Resources/AppIcon.icns ] && cp Resources/AppIcon.icns "$APP/Contents/Resources/"
29
+ cp ../sounds/bark.wav "$APP/Contents/Resources/" # the bark (CC BY-SA 3.0, see ../sounds/CREDITS.md)
30
+ cp "$ADAPTER/bin/mediaremote-adapter.pl" "$APP/Contents/Resources/"
31
+ cp -R "$ADAPTER_BUILD/MediaRemoteAdapter.framework" "$APP/Contents/Resources/"
32
+ echo -n "APPL????" > "$APP/Contents/PkgInfo"
33
+
34
+ # Signing: use $TIBODOG_SIGN_IDENTITY, else a local "TiboDog Dev" (or older "Islet Dev") certificate if one
35
+ # exists, else ad-hoc. A stable identity keeps the Keychain "Always Allow" grant across rebuilds
36
+ # (ad-hoc signatures change every build, so macOS asks again each time).
37
+ IDENTITY="${TIBODOG_SIGN_IDENTITY:-}"
38
+ for NAME in "TiboDog Dev" "Islet Dev"; do
39
+ if [ -z "$IDENTITY" ] && security find-identity -p codesigning 2>/dev/null | grep -q "\"$NAME\""; then
40
+ IDENTITY="$NAME"
41
+ fi
42
+ done
43
+ codesign --force --deep --sign "${IDENTITY:--}" "$APP"
44
+ echo "Built $APP (signed: ${IDENTITY:-ad-hoc})"
@@ -0,0 +1,51 @@
1
+ // Renders Resources/AppIcon.icns: a dark rounded tile with a notch and two usage rings.
2
+ // Run: swift Scripts/make-icon.swift
3
+ import AppKit
4
+
5
+ func render(size: CGFloat) -> NSImage {
6
+ let img = NSImage(size: NSSize(width: size, height: size))
7
+ img.lockFocus()
8
+ let s = size
9
+ // Tile
10
+ let tile = NSBezierPath(roundedRect: NSRect(x: 0, y: 0, width: s, height: s), xRadius: s * 0.22, yRadius: s * 0.22)
11
+ NSColor(calibratedRed: 0.09, green: 0.09, blue: 0.11, alpha: 1).setFill(); tile.fill()
12
+ // Screen glow area
13
+ let screen = NSBezierPath(roundedRect: NSRect(x: s * 0.10, y: s * 0.14, width: s * 0.80, height: s * 0.66), xRadius: s * 0.07, yRadius: s * 0.07)
14
+ NSColor(calibratedRed: 0.16, green: 0.18, blue: 0.24, alpha: 1).setFill(); screen.fill()
15
+ // Notch
16
+ let notch = NSBezierPath(roundedRect: NSRect(x: s * 0.36, y: s * 0.71, width: s * 0.28, height: s * 0.12), xRadius: s * 0.04, yRadius: s * 0.04)
17
+ NSColor.black.setFill(); notch.fill()
18
+ // Rings
19
+ func ring(cx: CGFloat, cy: CGFloat, r: CGFloat, fraction: CGFloat, color: NSColor) {
20
+ let track = NSBezierPath(); track.appendArc(withCenter: NSPoint(x: cx, y: cy), radius: r, startAngle: 0, endAngle: 360)
21
+ track.lineWidth = s * 0.055; NSColor.white.withAlphaComponent(0.15).setStroke(); track.stroke()
22
+ let arc = NSBezierPath(); arc.appendArc(withCenter: NSPoint(x: cx, y: cy), radius: r, startAngle: 90, endAngle: 90 - 360 * fraction, clockwise: true)
23
+ arc.lineWidth = s * 0.055; arc.lineCapStyle = .round; color.setStroke(); arc.stroke()
24
+ }
25
+ ring(cx: s * 0.34, cy: s * 0.44, r: s * 0.13, fraction: 0.42, color: NSColor(calibratedRed: 0.20, green: 0.78, blue: 0.35, alpha: 1))
26
+ ring(cx: s * 0.66, cy: s * 0.44, r: s * 0.13, fraction: 0.71, color: NSColor(calibratedRed: 1.0, green: 0.62, blue: 0.04, alpha: 1))
27
+ img.unlockFocus()
28
+ return img
29
+ }
30
+
31
+ let fm = FileManager.default
32
+ let outDir = "Resources/AppIcon.iconset"
33
+ try? fm.removeItem(atPath: outDir)
34
+ try! fm.createDirectory(atPath: outDir, withIntermediateDirectories: true)
35
+ for base in [16, 32, 128, 256, 512] {
36
+ for scale in [1, 2] {
37
+ let px = CGFloat(base * scale)
38
+ let img = render(size: px)
39
+ let rep = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: Int(px), pixelsHigh: Int(px), bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0)!
40
+ NSGraphicsContext.saveGraphicsState()
41
+ NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep)
42
+ img.draw(in: NSRect(x: 0, y: 0, width: px, height: px))
43
+ NSGraphicsContext.restoreGraphicsState()
44
+ let name = scale == 1 ? "icon_\(base)x\(base).png" : "icon_\(base)x\(base)@2x.png"
45
+ try! rep.representation(using: .png, properties: [:])!.write(to: URL(fileURLWithPath: "\(outDir)/\(name)"))
46
+ }
47
+ }
48
+ let p = Process(); p.launchPath = "/usr/bin/iconutil"; p.arguments = ["-c", "icns", outDir, "-o", "Resources/AppIcon.icns"]
49
+ p.launch(); p.waitUntilExit()
50
+ try? fm.removeItem(atPath: outDir)
51
+ print(p.terminationStatus == 0 ? "Wrote Resources/AppIcon.icns" : "iconutil failed")
@@ -0,0 +1,163 @@
1
+ import SwiftUI
2
+ import AppKit
3
+
4
+ @main
5
+ struct TiboDogApp: App {
6
+ @NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
7
+ @ObservedObject private var store = UsageStore.shared
8
+
9
+ /// MenuBarExtra writes its isInserted binding back on every configuration pass. Bound
10
+ /// straight to a @Published property that republishes even for unchanged values, this
11
+ /// forms an infinite SwiftUI update loop that pins the main thread. Guard on equality.
12
+ private var menuBarIconInserted: Binding<Bool> {
13
+ Binding(get: { store.showMenuBarIcon },
14
+ set: { new in if new != store.showMenuBarIcon { store.showMenuBarIcon = new } })
15
+ }
16
+
17
+ var body: some Scene {
18
+ MenuBarExtra(isInserted: menuBarIconInserted) {
19
+ TiboDogMenu()
20
+ } label: {
21
+ HStack(spacing: 4) {
22
+ Image(systemName: "gauge.with.dots.needle.33percent")
23
+ if store.showMenuBarText { Text(store.menuBarTitle).monospacedDigit() }
24
+ }
25
+ }
26
+ }
27
+ }
28
+
29
+ /// Shared between the menu bar item and the island's right-click menu.
30
+ struct TiboDogMenu: View {
31
+ @ObservedObject private var store = UsageStore.shared
32
+
33
+ var body: some View {
34
+ Picker("Usage Provider", selection: $store.provider) {
35
+ ForEach(UsageProvider.allCases) { Text($0.title).tag($0) }
36
+ }
37
+ Divider()
38
+ if let u = store.usage {
39
+ Text("\(store.primaryTitle): \(u.fiveHour?.percentText ?? "–")" + resetSuffix(u.fiveHour))
40
+ Text("\(store.secondaryTitle): \(u.sevenDay?.percentText ?? "–")" + resetSuffix(u.sevenDay))
41
+ if let o = u.sevenDayOpus { Text("Weekly Opus: \(o.percentText)" + resetSuffix(o)) }
42
+ } else {
43
+ Text(store.errorMessage ?? "Loading…")
44
+ }
45
+ if store.usage != nil, let error = store.errorMessage { Text(error) }
46
+ Text(store.planText + (store.lastUpdated.map { " · updated \(clockText($0))" } ?? ""))
47
+ Divider()
48
+ Button("Refresh Now") { store.refresh(force: true) }
49
+ .keyboardShortcut("r")
50
+ Divider()
51
+ Toggle("Show in Notch", isOn: $store.showNotch)
52
+ Toggle("Now Playing Island", isOn: $store.showNowPlaying)
53
+ Toggle("Menu Bar Icon", isOn: $store.showMenuBarIcon)
54
+ Toggle("Show % in Menu Bar", isOn: $store.showMenuBarText)
55
+ if store.provider == .claude { Toggle("Auto-refresh Claude Token", isOn: $store.autoRefreshToken) }
56
+ Toggle("Launch at Login", isOn: $store.launchAtLogin)
57
+ Divider()
58
+ Button("Quit TiboDog") { NSApp.terminate(nil) }
59
+ .keyboardShortcut("q")
60
+ }
61
+
62
+ private func resetSuffix(_ w: UsageWindow?) -> String {
63
+ guard let r = w?.resetsAt else { return "" }
64
+ return " (resets in \(relativeText(until: r)))"
65
+ }
66
+ }
67
+
68
+ @MainActor
69
+ final class AppDelegate: NSObject, NSApplicationDelegate {
70
+ private var panel: NotchPanel?
71
+ private var geometry: NotchGeometry?
72
+ private var observers: [Any] = []
73
+
74
+ func applicationDidFinishLaunching(_ notification: Notification) {
75
+ NSApp.setActivationPolicy(.accessory)
76
+ let store = UsageStore.shared
77
+ store.start()
78
+ rebuildPanel()
79
+ snapshotIfRequested()
80
+
81
+ observers.append(NotificationCenter.default.addObserver(
82
+ forName: NSApplication.didChangeScreenParametersNotification, object: nil, queue: .main
83
+ ) { [weak self] _ in self?.rebuildPanel() })
84
+
85
+ // Show/hide when the setting flips.
86
+ let cancellable = store.$showNotch.receive(on: RunLoop.main).sink { [weak self] _ in self?.rebuildPanel() }
87
+ observers.append(cancellable)
88
+
89
+ if !UsageStore.mock {
90
+ let music = store.$showNowPlaying.removeDuplicates().receive(on: RunLoop.main).sink { on in
91
+ if on { NowPlayingMonitor.shared.start() } else { NowPlayingMonitor.shared.stop() }
92
+ }
93
+ observers.append(music)
94
+ }
95
+ }
96
+
97
+ /// TIBODOG_SNAPSHOT=/path/out.png renders the island to a trimmed PNG and quits (for README shots).
98
+ private func snapshotIfRequested() {
99
+ guard let path = ProcessInfo.processInfo.environment["TIBODOG_SNAPSHOT"] else { return }
100
+ let delay = Double(ProcessInfo.processInfo.environment["TIBODOG_SNAPSHOT_DELAY"] ?? "1.5") ?? 1.5
101
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
102
+ defer { NSApp.terminate(nil) }
103
+ guard let win = self?.panel, win.isVisible else { Log.error("snapshot: no visible panel"); return }
104
+ let wid = CGWindowID(win.windowNumber)
105
+ guard let cg = CGWindowListCreateImage(.null, .optionIncludingWindow, wid,
106
+ [.boundsIgnoreFraming, .bestResolution]) else {
107
+ Log.error("snapshot: CGWindowListCreateImage failed"); return
108
+ }
109
+ let rep = NSBitmapImageRep(cgImage: cg)
110
+ guard let trimmed = Self.trimTransparent(rep, padding: 24) ?? rep as NSBitmapImageRep?,
111
+ let png = trimmed.representation(using: .png, properties: [:]) else { return }
112
+ try? png.write(to: URL(fileURLWithPath: path))
113
+ Log.info("snapshot written to \(path) (\(rep.pixelsWide)x\(rep.pixelsHigh))")
114
+ }
115
+ }
116
+
117
+ private static func trimTransparent(_ rep: NSBitmapImageRep, padding: Int) -> NSBitmapImageRep? {
118
+ let w = rep.pixelsWide, h = rep.pixelsHigh
119
+ var minX = w, minY = h, maxX = -1, maxY = -1
120
+ for y in 0..<h {
121
+ for x in 0..<w where (rep.colorAt(x: x, y: y)?.alphaComponent ?? 0) > 0.02 {
122
+ minX = min(minX, x); maxX = max(maxX, x); minY = min(minY, y); maxY = max(maxY, y)
123
+ }
124
+ }
125
+ guard maxX >= minX else { return nil }
126
+ let x0 = max(0, minX - padding), y0 = max(0, minY - padding)
127
+ let x1 = min(w - 1, maxX + padding), y1 = min(h - 1, maxY + padding)
128
+ let cw = x1 - x0 + 1, ch = y1 - y0 + 1
129
+ guard let out = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: cw, pixelsHigh: ch, bitsPerSample: 8,
130
+ samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
131
+ colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0) else { return nil }
132
+ NSGraphicsContext.saveGraphicsState()
133
+ NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: out)
134
+ // Bitmap rows are top-down; NSRect drawing is bottom-up, so flip the source rect.
135
+ let src = NSRect(x: x0, y: h - y1 - 1, width: cw, height: ch)
136
+ NSImage(cgImage: rep.cgImage!, size: NSSize(width: w, height: h))
137
+ .draw(in: NSRect(x: 0, y: 0, width: cw, height: ch), from: src, operation: .copy, fraction: 1)
138
+ NSGraphicsContext.restoreGraphicsState()
139
+ return out
140
+ }
141
+
142
+ func applicationWillTerminate(_ notification: Notification) {
143
+ NowPlayingMonitor.shared.stop()
144
+ }
145
+
146
+ private func rebuildPanel() {
147
+ let store = UsageStore.shared
148
+ let geo = NotchGeometry.detect()
149
+ guard store.showNotch else {
150
+ panel?.orderOut(nil); panel = nil; geometry = nil
151
+ return
152
+ }
153
+ if let p = panel, geometry == geo {
154
+ p.orderFrontRegardless()
155
+ return
156
+ }
157
+ panel?.orderOut(nil)
158
+ let p = NotchPanel(geometry: geo, store: store)
159
+ p.orderFrontRegardless()
160
+ panel = p
161
+ geometry = geo
162
+ }
163
+ }
@@ -0,0 +1,125 @@
1
+ import Foundation
2
+ import Darwin
3
+
4
+ /// Uses Codex's supported local protocol; authentication stays owned by Codex.
5
+ enum CodexUsageAPI {
6
+ enum Failure: LocalizedError {
7
+ case notInstalled, timeout, unavailable, protocolError
8
+ var errorDescription: String? {
9
+ switch self {
10
+ case .notInstalled: return "Codex not found. Install Codex, or set TIBODOG_CODEX_PATH to its executable."
11
+ case .timeout: return "Codex usage request timed out. Try Refresh Now."
12
+ case .unavailable: return "Codex usage unavailable. Sign in to Codex with ChatGPT and try again."
13
+ case .protocolError: return "Unexpected Codex response. Update Codex and try again."
14
+ }
15
+ }
16
+ }
17
+
18
+ static func executable() throws -> URL {
19
+ let env = ProcessInfo.processInfo.environment
20
+ let home = FileManager.default.homeDirectoryForCurrentUser.path
21
+ let candidates = [env["TIBODOG_CODEX_PATH"]].compactMap { $0 } + [
22
+ "/Applications/Codex.app/Contents/Resources/codex",
23
+ "/Applications/ChatGPT.app/Contents/Resources/codex",
24
+ "\(home)/Applications/Codex.app/Contents/Resources/codex",
25
+ "\(home)/Applications/ChatGPT.app/Contents/Resources/codex",
26
+ "/opt/homebrew/bin/codex", "/usr/local/bin/codex", "\(home)/.local/bin/codex"
27
+ ] + (env["PATH"] ?? "").split(separator: ":").map { "\($0)/codex" }
28
+ guard let path = candidates.first(where: { FileManager.default.isExecutableFile(atPath: $0) }) else {
29
+ throw Failure.notInstalled
30
+ }
31
+ return URL(fileURLWithPath: path)
32
+ }
33
+
34
+ // Called off the main actor. poll() bounds every read, including partial JSON lines.
35
+ static func fetch() throws -> (usage: Usage, plan: String?, resetCredits: Int?) {
36
+ try decode(call("account/rateLimits/read"))
37
+ }
38
+
39
+ /// Spends one of the account's reset credits on the current windows. Returns Codex's outcome:
40
+ /// "reset", "nothingToReset", "noCredit" or "alreadyRedeemed". Retrying with the same key never
41
+ /// spends a second credit.
42
+ static func consumeResetCredit(idempotencyKey: String) throws -> String {
43
+ let result = try call("account/rateLimitResetCredit/consume", params: ["idempotencyKey": idempotencyKey])
44
+ guard let outcome = result["outcome"] as? String else { throw Failure.protocolError }
45
+ return outcome
46
+ }
47
+
48
+ /// One request to a short-lived `codex app-server`: initialize, send it, return its result.
49
+ static func call(_ method: String, params: [String: Any]? = nil) throws -> [String: Any] {
50
+ let process = Process()
51
+ process.executableURL = try executable()
52
+ process.arguments = ["app-server"]
53
+ process.currentDirectoryURL = FileManager.default.homeDirectoryForCurrentUser
54
+ let input = Pipe(), output = Pipe()
55
+ process.standardInput = input
56
+ process.standardOutput = output
57
+ process.standardError = FileHandle.nullDevice
58
+ try process.run()
59
+ let pid = process.processIdentifier
60
+ defer {
61
+ try? input.fileHandleForWriting.close()
62
+ if process.isRunning { process.terminate() }
63
+ // Do not let a hung child accumulate across refreshes.
64
+ let deadline = Date().addingTimeInterval(1)
65
+ while process.isRunning && Date() < deadline { Thread.sleep(forTimeInterval: 0.01) }
66
+ if process.isRunning { kill(pid, SIGKILL) }
67
+ process.waitUntilExit()
68
+ try? output.fileHandleForReading.close()
69
+ }
70
+ func send(_ message: [String: Any]) throws {
71
+ var data = try JSONSerialization.data(withJSONObject: message)
72
+ data.append(10)
73
+ try input.fileHandleForWriting.write(contentsOf: data)
74
+ }
75
+ try send(["id": 1, "method": "initialize", "params": ["clientInfo": ["name": "tibodog", "version": "0.4.0"]]])
76
+ let deadline = Date().addingTimeInterval(25)
77
+ var buffer = Data()
78
+ while Date() < deadline {
79
+ var descriptor = pollfd(fd: output.fileHandleForReading.fileDescriptor, events: Int16(POLLIN), revents: 0)
80
+ let ready = poll(&descriptor, 1, 200)
81
+ if ready < 0 { if errno == EINTR { continue }; throw Failure.protocolError }
82
+ if ready == 0 { continue }
83
+ let chunk = output.fileHandleForReading.availableData
84
+ guard !chunk.isEmpty else { throw Failure.unavailable }
85
+ buffer.append(chunk)
86
+ guard buffer.count < 4 * 1024 * 1024 else { throw Failure.protocolError }
87
+ while let newline = buffer.firstIndex(of: 10) {
88
+ let line = buffer.prefix(upTo: newline)
89
+ 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 }
92
+ guard message["error"] == nil else { throw Failure.unavailable }
93
+ if id == 1 {
94
+ try send(["method": "initialized"])
95
+ var request: [String: Any] = ["id": 2, "method": method]
96
+ if let params { request["params"] = params }
97
+ try send(request)
98
+ } else if id == 2 {
99
+ guard let result = message["result"] as? [String: Any] else { throw Failure.protocolError }
100
+ return result
101
+ }
102
+ }
103
+ }
104
+ throw Failure.timeout
105
+ }
106
+
107
+ static func decode(_ result: [String: Any]) throws -> (usage: Usage, plan: String?, resetCredits: Int?) {
108
+ let buckets = result["rateLimitsByLimitId"] as? [String: Any]
109
+ // Never substitute an unrelated model bucket for the Codex quota.
110
+ guard let limits = (buckets?["codex"] as? [String: Any]) ?? (result["rateLimits"] as? [String: Any]) else {
111
+ throw Failure.unavailable
112
+ }
113
+ func window(_ key: String) -> UsageWindow? {
114
+ guard let raw = limits[key] as? [String: Any],
115
+ let used = (raw["usedPercent"] as? NSNumber)?.doubleValue, used.isFinite else { return nil }
116
+ let reset = (raw["resetsAt"] as? NSNumber).map { Date(timeIntervalSince1970: $0.doubleValue) }
117
+ return UsageWindow(utilization: used, resetsAt: reset,
118
+ durationMinutes: (raw["windowDurationMins"] as? NSNumber)?.intValue)
119
+ }
120
+ let primary = window("primary"), secondary = window("secondary")
121
+ guard primary != nil || secondary != nil else { throw Failure.unavailable }
122
+ let credits = ((result["rateLimitResetCredits"] as? [String: Any])?["availableCount"] as? NSNumber)?.intValue
123
+ return (Usage(fiveHour: primary, sevenDay: secondary), limits["planType"] as? String, credits)
124
+ }
125
+ }