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.
- package/LICENSE +21 -0
- package/README.md +177 -2
- package/app/LICENSE +21 -0
- package/app/Package.swift +18 -0
- package/app/Resources/AppIcon.icns +0 -0
- package/app/Resources/Info.plist +20 -0
- package/app/Scripts/build-app.sh +44 -0
- package/app/Scripts/make-icon.swift +51 -0
- package/app/Sources/TiboDog/App.swift +163 -0
- package/app/Sources/TiboDog/CodexUsageAPI.swift +125 -0
- package/app/Sources/TiboDog/Credentials.swift +241 -0
- package/app/Sources/TiboDog/DogMonitor.swift +94 -0
- package/app/Sources/TiboDog/Log.swift +17 -0
- package/app/Sources/TiboDog/Models.swift +92 -0
- package/app/Sources/TiboDog/NotchGeometry.swift +21 -0
- package/app/Sources/TiboDog/NotchPanel.swift +38 -0
- package/app/Sources/TiboDog/NotchView.swift +571 -0
- package/app/Sources/TiboDog/NowPlayingMonitor.swift +245 -0
- package/app/Sources/TiboDog/OAuth.swift +64 -0
- package/app/Sources/TiboDog/ResetTracker.swift +148 -0
- package/app/Sources/TiboDog/UsageAPI.swift +54 -0
- package/app/Sources/TiboDog/UsageStore.swift +212 -0
- package/app/Vendor/mediaremote-adapter/LICENSE +28 -0
- package/app/Vendor/mediaremote-adapter/README.md +7 -0
- package/app/Vendor/mediaremote-adapter/bin/mediaremote-adapter.pl +280 -0
- package/app/Vendor/mediaremote-adapter/build-framework.sh +33 -0
- package/app/Vendor/mediaremote-adapter/include/MediaRemoteAdapter.h +126 -0
- package/app/Vendor/mediaremote-adapter/src/adapter/env.h +25 -0
- package/app/Vendor/mediaremote-adapter/src/adapter/env.m +105 -0
- package/app/Vendor/mediaremote-adapter/src/adapter/get.h +11 -0
- package/app/Vendor/mediaremote-adapter/src/adapter/get.m +146 -0
- package/app/Vendor/mediaremote-adapter/src/adapter/globals.h +15 -0
- package/app/Vendor/mediaremote-adapter/src/adapter/globals.m +19 -0
- package/app/Vendor/mediaremote-adapter/src/adapter/keys.h +19 -0
- package/app/Vendor/mediaremote-adapter/src/adapter/keys.m +83 -0
- package/app/Vendor/mediaremote-adapter/src/adapter/now_playing.h +20 -0
- package/app/Vendor/mediaremote-adapter/src/adapter/now_playing.m +201 -0
- package/app/Vendor/mediaremote-adapter/src/adapter/repeat.m +44 -0
- package/app/Vendor/mediaremote-adapter/src/adapter/seek.m +30 -0
- package/app/Vendor/mediaremote-adapter/src/adapter/send.m +69 -0
- package/app/Vendor/mediaremote-adapter/src/adapter/shuffle.m +46 -0
- package/app/Vendor/mediaremote-adapter/src/adapter/speed.m +30 -0
- package/app/Vendor/mediaremote-adapter/src/adapter/stream.m +523 -0
- package/app/Vendor/mediaremote-adapter/src/adapter/test.m +217 -0
- package/app/Vendor/mediaremote-adapter/src/private/MediaRemote.h +163 -0
- package/app/Vendor/mediaremote-adapter/src/private/MediaRemote.m +114 -0
- package/app/Vendor/mediaremote-adapter/src/test/NowPlayingTest.h +50 -0
- package/app/Vendor/mediaremote-adapter/src/test/NowPlayingTest.m +231 -0
- package/app/Vendor/mediaremote-adapter/src/test/main.m +53 -0
- package/app/Vendor/mediaremote-adapter/src/utility/Debounce.h +24 -0
- package/app/Vendor/mediaremote-adapter/src/utility/Debounce.m +41 -0
- package/app/Vendor/mediaremote-adapter/src/utility/helpers.h +27 -0
- package/app/Vendor/mediaremote-adapter/src/utility/helpers.m +246 -0
- package/bin/tibodog.js +162 -0
- package/package.json +46 -4
- package/sounds/CREDITS.md +11 -0
- package/sounds/bark.wav +0 -0
- package/src/bark.js +125 -0
- package/src/detect.js +15 -0
- package/src/dog.js +48 -0
- package/src/i18n.js +107 -0
- package/src/sources.js +96 -0
- 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
|
-
#
|
|
1
|
+
# 🐕 tibodog
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/tibodog)
|
|
4
|
+
|
|
5
|
+
> 티보가 리셋하면 짖는 개.
|
|
6
|
+
> A dog that barks when Tibo resets the limits.
|
|
7
|
+
|
|
8
|
+

|
|
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
|
+

|
|
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
|
+
}
|