Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c51086c119 | ||
|
|
5840b34b3b | ||
|
|
5a141095e1 | ||
|
|
4b42a631be | ||
|
|
c6d2ed8261 | ||
|
|
747e26e5eb | ||
|
|
b9fd5a27ec | ||
|
|
60336cffa7 | ||
|
|
953c46077c | ||
|
|
3796139178 | ||
|
|
554eb80c0c | ||
|
|
a22bf3a309 | ||
|
|
eaab3eb539 | ||
|
|
323aa174da | ||
|
|
3803cf37f6 | ||
|
|
1977b51b74 | ||
|
|
64a3f15ac5 | ||
|
|
8fbd1c42c3 | ||
|
|
0bf19c3dc8 | ||
|
|
d56a714bb9 | ||
|
|
88f409971a | ||
|
|
9bac2540e8 | ||
|
|
792c5a26e9 | ||
|
|
421727bfb5 | ||
|
|
21b2905bcf | ||
|
|
38d6f2a582 | ||
|
|
7ea5554506 | ||
|
|
87c5992889 | ||
|
|
034e196214 | ||
|
|
2235a627b5 | ||
|
|
d6c138f1a2 | ||
|
|
432819639e | ||
|
|
52db469c13 | ||
|
|
40c86aaaf2 | ||
|
|
46b09934d7 | ||
|
|
a79a23ac24 | ||
|
|
3785629ee4 | ||
|
|
a338f4e5fe | ||
|
|
88e7d684c0 | ||
|
|
9af0d6401c | ||
|
|
7cb2159b4c | ||
|
|
206e97780d | ||
|
|
2b79ba4ae0 | ||
|
|
60b6c60c0a | ||
|
|
8140831236 | ||
|
|
9ed8916075 | ||
|
|
2ab2d913ce | ||
|
|
30ad89e458 | ||
|
|
3e4bfca2df | ||
|
|
4378463600 | ||
|
|
2cfd7d56a1 | ||
|
|
84bdfbd786 | ||
|
|
beba7586f9 | ||
|
|
a4dff4bde9 | ||
|
|
d0b33e8f43 | ||
|
|
6a5a3c9cd1 | ||
|
|
15e225439b | ||
|
|
91829b0ed9 | ||
|
|
caffc996ff | ||
|
|
cd13a6d124 | ||
|
|
566258e9c8 | ||
|
|
dc2a8e2307 | ||
|
|
f48b53fd8e | ||
|
|
ccfee938b7 | ||
|
|
28d952bcbd | ||
|
|
9bc023d239 | ||
|
|
fd82177a7d | ||
|
|
112210a99b | ||
|
|
f4a83940b1 | ||
|
|
377888a427 | ||
|
|
e24113a78b | ||
|
|
3baab91f70 | ||
|
|
0aa8837b2f | ||
|
|
373ca1dc14 | ||
|
|
7a4c21768f | ||
|
|
7b26629dc7 | ||
|
|
e446ea97f9 | ||
|
|
36af325e2d | ||
|
|
e2ef8b6f2a | ||
|
|
7a4975fa3e | ||
|
|
c9912f8463 | ||
|
|
238614a94b | ||
|
|
b11507221f | ||
|
|
b6fef53264 | ||
|
|
ec6ae6621a | ||
|
|
105a7706fa | ||
|
|
a950392a6f | ||
|
|
8087cc029d | ||
|
|
11f9e0c9b6 | ||
|
|
cc835ee2b0 | ||
|
|
8e7328b2c5 | ||
|
|
709eb12382 | ||
|
|
e866324a48 | ||
|
|
a62584221b | ||
|
|
fc7fd19652 | ||
|
|
58b947e515 | ||
|
|
88252e4d59 | ||
|
|
ad2ff3a7b6 | ||
|
|
1a9d9cd22a | ||
|
|
b8826ace9b | ||
|
|
2a0de269ff | ||
|
|
ac7ffe81af | ||
|
|
f28f7b8a5a | ||
|
|
aaed64484c | ||
|
|
2e3ee350ca | ||
|
|
e939bdb88b | ||
|
|
6749c5f158 | ||
|
|
de71ec2794 | ||
|
|
ac17ce154f | ||
|
|
d2eac62897 | ||
|
|
8c64fc50ad | ||
|
|
b2c138f665 | ||
|
|
aa6c299e2a | ||
|
|
ecbae7d7ca | ||
|
|
37a039e1d3 | ||
|
|
8f3ead2f65 | ||
|
|
734670bb3f | ||
|
|
94430e00ba | ||
|
|
7b0368b001 | ||
|
|
4629aeffbc | ||
|
|
2cb4ddfe97 | ||
|
|
73b251a1e9 | ||
|
|
7bab6a0df0 | ||
|
|
db6380fa66 | ||
|
|
47a148fa67 | ||
|
|
85ac329d36 | ||
|
|
a0fb6eabcf | ||
|
|
b778d03323 | ||
|
|
13387888b2 | ||
|
|
44be1649a1 | ||
|
|
1160712e1b | ||
|
|
e4f55c80ee | ||
|
|
526752dc36 | ||
|
|
a2d983b555 | ||
|
|
0d807c2285 | ||
|
|
1c42433145 | ||
|
|
c0ac2e5a66 | ||
|
|
405e65f9f7 | ||
|
|
1c7433ebea | ||
|
|
a39ef800e8 | ||
|
|
76dec8b85d | ||
|
|
2f04f8581c | ||
|
|
788c066a1e | ||
|
|
31280d4ab2 | ||
|
|
7b1c5bbf8e | ||
|
|
f2ba44bbaa | ||
|
|
de1fffe0e3 | ||
|
|
e80f07d1b9 | ||
|
|
e9ca8a7efc | ||
|
|
63ef2005a4 | ||
|
|
5ad7bb26f0 | ||
|
|
132a2a8f8d | ||
|
|
debd1c1850 | ||
|
|
9bcbb2ae7d | ||
|
|
c9384f6424 | ||
|
|
e4693fc18f | ||
|
|
84a8592bae | ||
|
|
bdd2038364 | ||
|
|
fd61face77 | ||
|
|
1f58a81a26 | ||
|
|
7f6da51860 | ||
|
|
b49ac23863 | ||
|
|
01d268c3e5 | ||
|
|
871d5245ac | ||
|
|
0524f9b791 | ||
|
|
d68d564351 | ||
|
|
d55bad01db | ||
|
|
7bc72e240b | ||
|
|
d77ed23d09 | ||
|
|
6136b0ded0 | ||
|
|
aba7168448 | ||
|
|
a8c2083397 | ||
|
|
ad23446ed4 | ||
|
|
d8b75d279c | ||
|
|
2658ff7f6a | ||
|
|
e10d79e5a6 | ||
|
|
88daa27630 | ||
|
|
b7c4958a74 | ||
|
|
f46b13d967 | ||
|
|
c449882ec6 | ||
|
|
3c1a2ef1be | ||
|
|
c238db963b | ||
|
|
63c9a8582d | ||
|
|
6f256b3308 | ||
|
|
2761b31a12 | ||
|
|
f9d8abebd5 | ||
|
|
66ee233b1b | ||
|
|
a12c664bde | ||
|
|
c7251d8785 | ||
|
|
7a95b100ef | ||
|
|
fb98c69c34 | ||
|
|
5629c6b61e | ||
|
|
4709f5b0f5 | ||
|
|
5f8aeaa6e9 | ||
|
|
27f1f45109 | ||
|
|
7a5512cc28 | ||
|
|
8c89ce4636 | ||
|
|
3a964dc2f8 | ||
|
|
b553d3922a | ||
|
|
61e8455e5e | ||
|
|
c4b9d7454f | ||
|
|
75aeb03332 | ||
|
|
f5203a0299 | ||
|
|
fe27e0d4c0 | ||
|
|
fb5e1ece25 | ||
|
|
71666224f1 | ||
|
|
bd693cdf94 | ||
|
|
d24a354e1c | ||
|
|
9e47041715 | ||
|
|
22151f0af4 | ||
|
|
3508fa1a94 | ||
|
|
b2fcafd3f0 | ||
|
|
45215de350 | ||
|
|
f969da8006 | ||
|
|
7f769414cc | ||
|
|
db707340bb | ||
|
|
be6bb1495d | ||
|
|
06755de91d | ||
|
|
2855155361 | ||
|
|
ecfb1aa44d | ||
|
|
8b35e5df56 | ||
|
|
794a6cdb65 | ||
|
|
e613ace503 | ||
|
|
38d7683843 | ||
|
|
802cf14a71 | ||
|
|
ae736a7e4a | ||
|
|
a1bb186af2 | ||
|
|
e93a4e6df5 | ||
|
|
a3fbce3f0e | ||
|
|
4b4280630c | ||
|
|
90acbcb968 | ||
|
|
f2f2be1305 | ||
|
|
f17694622c | ||
|
|
aa10f77776 | ||
|
|
cb00ddc806 | ||
|
|
1f357b9123 | ||
|
|
9dee674ff0 | ||
|
|
0025690a74 | ||
|
|
7b03909c55 | ||
|
|
c3ea2be809 | ||
|
|
6f6a7361fe | ||
|
|
d9d65da6b7 | ||
|
|
c18054a810 | ||
|
|
032951882f | ||
|
|
ed186af2c6 | ||
|
|
d3dabc7843 | ||
|
|
987bd36137 | ||
|
|
cfcb74f32b | ||
|
|
273e10566e | ||
|
|
76a8f433a7 | ||
|
|
65a171f296 | ||
|
|
26312290ac | ||
|
|
63cbd70f38 | ||
|
|
235ab81efb | ||
|
|
12d7855ceb | ||
|
|
d1ab5206bd | ||
|
|
997e820be8 | ||
|
|
7fc78d5a83 | ||
|
|
818d4aa6c2 | ||
|
|
6aa74fcabc | ||
|
|
2a01d6f5b8 | ||
|
|
af920eda83 | ||
|
|
6b6802eaea | ||
|
|
e9d29db2ee | ||
|
|
9be5069aac | ||
|
|
cc9e2aaba1 | ||
|
|
19bb6459ed | ||
|
|
abf05b04fc | ||
|
|
849e94c942 | ||
|
|
d76c7496bb | ||
|
|
45a3925f65 | ||
|
|
113e91583d | ||
|
|
c60fb93e1a | ||
|
|
b16c24cb44 | ||
|
|
89a79c6f13 | ||
|
|
aa529835f7 | ||
|
|
b5c3cc6379 | ||
|
|
c8adaf1baf | ||
|
|
3e99e1417e | ||
|
|
e41d3d0c0f | ||
|
|
1078951c43 | ||
|
|
ed6a5e1b51 | ||
|
|
8701fb1326 | ||
|
|
247b5fc202 | ||
|
|
255de797f8 | ||
|
|
0046c53283 | ||
|
|
8d222d63f0 | ||
|
|
5720370649 | ||
|
|
0843730ae1 | ||
|
|
9c030fcdd5 | ||
|
|
dd68ff39d6 | ||
|
|
b76bdf3bc1 | ||
|
|
c31950fd76 | ||
|
|
4e6ae21825 | ||
|
|
c0bf2e4873 | ||
|
|
081d4804ab | ||
|
|
be3899e188 | ||
|
|
286761e51a | ||
|
|
8e0d66468c | ||
|
|
ed64bcc9e5 | ||
|
|
b2275a74d3 | ||
|
|
3b05188a88 | ||
|
|
ea77454c10 |
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: Report something that isn't working correctly
|
||||||
|
title: ''
|
||||||
|
labels: bug
|
||||||
|
assignees: ''
|
||||||
|
---
|
||||||
|
|
||||||
|
**Describe the bug**
|
||||||
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
|
**To Reproduce**
|
||||||
|
Steps to reproduce the behaviour:
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
3.
|
||||||
|
|
||||||
|
**Expected behaviour**
|
||||||
|
What you expected to happen.
|
||||||
|
|
||||||
|
**Diagnostics**
|
||||||
|
Open the app → Settings → Diagnostics, then click "Copy All" and paste the output here.
|
||||||
|
This shows the error, request state, and cookie info. Email and org IDs are automatically redacted when you click Copy All.
|
||||||
|
|
||||||
|
**Screenshots**
|
||||||
|
If applicable, add screenshots to help explain your problem.
|
||||||
|
|
||||||
|
**Environment**
|
||||||
|
- Platform: [macOS / Windows]
|
||||||
|
- macOS version: [e.g. Sequoia 15.4] *(if applicable)*
|
||||||
|
- Windows version: [e.g. Windows 11 24H2] *(if applicable)*
|
||||||
|
- ClaudeChecker version: [e.g. 1.3.2]
|
||||||
|
- Claude plan: [e.g. Pro, Max]
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Any other context about the problem here.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
name: Feature request
|
||||||
|
about: Suggest an idea or improvement
|
||||||
|
title: ''
|
||||||
|
labels: enhancement
|
||||||
|
assignees: ''
|
||||||
|
---
|
||||||
|
|
||||||
|
**What problem does this solve?**
|
||||||
|
A clear and concise description of the problem or limitation you're running into.
|
||||||
|
|
||||||
|
**Describe the solution you'd like**
|
||||||
|
What you'd like to happen.
|
||||||
|
|
||||||
|
**Alternatives considered**
|
||||||
|
Any other approaches you've thought about.
|
||||||
|
|
||||||
|
**Screenshots or mockups**
|
||||||
|
If applicable, add visuals to help explain your idea.
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Any other context here.
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
name: Release Beta
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*-beta*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release-beta:
|
||||||
|
runs-on: macos-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Get version from tag
|
||||||
|
id: version
|
||||||
|
run: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: |
|
||||||
|
xcodebuild \
|
||||||
|
-project ClaudeChecker.xcodeproj \
|
||||||
|
-scheme ClaudeChecker \
|
||||||
|
-configuration Release \
|
||||||
|
-derivedDataPath build \
|
||||||
|
clean build
|
||||||
|
|
||||||
|
- name: Zip app
|
||||||
|
run: |
|
||||||
|
ditto -c -k --keepParent \
|
||||||
|
build/Build/Products/Release/ClaudeChecker.app \
|
||||||
|
ClaudeChecker.zip
|
||||||
|
|
||||||
|
- name: Create GitHub pre-release
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
gh release create "$GITHUB_REF_NAME" ClaudeChecker.zip \
|
||||||
|
--title "$GITHUB_REF_NAME" \
|
||||||
|
--prerelease \
|
||||||
|
--notes "$(cat RELEASE_NOTES.md 2>/dev/null || echo 'Beta release ${{ steps.version.outputs.version }}')"
|
||||||
|
|
||||||
|
- name: Update version-beta.json
|
||||||
|
run: |
|
||||||
|
notes=$(cat RELEASE_NOTES.md 2>/dev/null || echo "Beta release ${{ steps.version.outputs.version }}")
|
||||||
|
jq -n \
|
||||||
|
--arg version "${{ steps.version.outputs.version }}" \
|
||||||
|
--arg url "https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/ClaudeChecker.zip" \
|
||||||
|
--arg notes "$notes" \
|
||||||
|
'{version: $version, url: $url, notes: $notes}' > version-beta.json
|
||||||
|
|
||||||
|
- name: Update beta badge in README
|
||||||
|
run: |
|
||||||
|
shields_version=$(echo "${{ steps.version.outputs.version }}" | sed 's/-/--/g')
|
||||||
|
tag="${{ github.ref_name }}"
|
||||||
|
export BADGE="[](https://github.com/${{ github.repository }}/releases/tag/${tag}) <!-- BETA_BADGE -->"
|
||||||
|
python3 -c "
|
||||||
|
import os
|
||||||
|
badge = os.environ['BADGE']
|
||||||
|
lines = open('README.md').readlines()
|
||||||
|
out = []
|
||||||
|
for line in lines:
|
||||||
|
out.append(badge + chr(10) if '<!-- BETA_BADGE -->' in line else line)
|
||||||
|
open('README.md', 'w').writelines(out)
|
||||||
|
"
|
||||||
|
|
||||||
|
- name: Commit version-beta.json and README
|
||||||
|
run: |
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add version-beta.json README.md
|
||||||
|
git commit -m "Beta release ${{ github.ref_name }}"
|
||||||
|
for i in 1 2 3 4 5; do
|
||||||
|
git pull --rebase origin main && git push origin HEAD:main && break
|
||||||
|
echo "Push attempt $i failed, retrying..."; sleep 3
|
||||||
|
done
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
if: ${{ !contains(github.ref_name, '-') }}
|
||||||
|
runs-on: macos-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Get version from tag
|
||||||
|
id: version
|
||||||
|
run: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: |
|
||||||
|
xcodebuild \
|
||||||
|
-project ClaudeChecker.xcodeproj \
|
||||||
|
-scheme ClaudeChecker \
|
||||||
|
-configuration Release \
|
||||||
|
-derivedDataPath build \
|
||||||
|
clean build
|
||||||
|
|
||||||
|
- name: Zip app
|
||||||
|
run: |
|
||||||
|
ditto -c -k --keepParent \
|
||||||
|
build/Build/Products/Release/ClaudeChecker.app \
|
||||||
|
ClaudeChecker.zip
|
||||||
|
|
||||||
|
- name: Compute SHA256
|
||||||
|
id: sha
|
||||||
|
run: echo "sha256=$(shasum -a 256 ClaudeChecker.zip | awk '{print $1}')" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Create GitHub release
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
gh release create "$GITHUB_REF_NAME" ClaudeChecker.zip \
|
||||||
|
--title "$GITHUB_REF_NAME" \
|
||||||
|
--notes "$(cat RELEASE_NOTES.md 2>/dev/null || echo 'Release ${{ steps.version.outputs.version }}')"
|
||||||
|
|
||||||
|
- name: Update version.json
|
||||||
|
run: |
|
||||||
|
notes=$(cat RELEASE_NOTES.md 2>/dev/null || echo "Release ${{ steps.version.outputs.version }}")
|
||||||
|
jq -n \
|
||||||
|
--arg version "${{ steps.version.outputs.version }}" \
|
||||||
|
--arg url "https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/ClaudeChecker.zip" \
|
||||||
|
--arg notes "$notes" \
|
||||||
|
'{version: $version, url: $url, notes: $notes}' > version.json
|
||||||
|
|
||||||
|
- name: Update Homebrew cask
|
||||||
|
run: |
|
||||||
|
sed -i '' \
|
||||||
|
-e 's/version "[^"]*"/version "${{ steps.version.outputs.version }}"/' \
|
||||||
|
-e 's/sha256 "[^"]*"/sha256 "${{ steps.sha.outputs.sha256 }}"/' \
|
||||||
|
../homebrew-tap/Casks/claudechecker.rb 2>/dev/null || true
|
||||||
|
|
||||||
|
- name: Update README badge
|
||||||
|
env:
|
||||||
|
NEW_VERSION: ${{ steps.version.outputs.version }}
|
||||||
|
run: |
|
||||||
|
python3 -c "
|
||||||
|
import os, re
|
||||||
|
v = os.environ['NEW_VERSION']
|
||||||
|
content = open('README.md').read()
|
||||||
|
content = re.sub(r'macOS_Stable-[^-]+-orange', 'macOS_Stable-' + v + '-orange', content)
|
||||||
|
lines = content.splitlines(keepends=True)
|
||||||
|
out = ['<!-- BETA_BADGE -->' + chr(10) if '<!-- BETA_BADGE -->' in line else line for line in lines]
|
||||||
|
open('README.md', 'w').writelines(out)
|
||||||
|
"
|
||||||
|
|
||||||
|
- name: Commit version.json and README
|
||||||
|
run: |
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add version.json README.md
|
||||||
|
git commit -m "Release ${{ github.ref_name }}"
|
||||||
|
git pull --rebase origin main
|
||||||
|
git push origin HEAD:main
|
||||||
|
|
||||||
|
- name: Update Homebrew tap
|
||||||
|
env:
|
||||||
|
TAP_TOKEN: ${{ secrets.TAP_TOKEN }}
|
||||||
|
run: |
|
||||||
|
git clone https://x-access-token:${{ secrets.TAP_TOKEN }}@github.com/${{ github.repository_owner }}/homebrew-tap /tmp/homebrew-tap
|
||||||
|
sed -i '' \
|
||||||
|
-e 's/version "[^"]*"/version "${{ steps.version.outputs.version }}"/' \
|
||||||
|
-e 's/sha256 "[^"]*"/sha256 "${{ steps.sha.outputs.sha256 }}"/' \
|
||||||
|
/tmp/homebrew-tap/Casks/claudechecker.rb
|
||||||
|
cd /tmp/homebrew-tap
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add Casks/claudechecker.rb
|
||||||
|
git commit -m "Update claudechecker to ${{ github.ref_name }}"
|
||||||
|
git push
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
Thanks for your interest in ClaudeChecker!
|
||||||
|
|
||||||
|
## Reporting bugs & requesting features
|
||||||
|
|
||||||
|
Please use the [issue templates](https://github.com/superdooper86/claudechecker/issues/new/choose) — they keep reports consistent and easy to act on.
|
||||||
|
|
||||||
|
## Pull requests
|
||||||
|
|
||||||
|
- Open an issue first to discuss anything non-trivial. This avoids wasted effort if the direction isn't a fit.
|
||||||
|
- Keep changes focused — one fix or feature per PR.
|
||||||
|
- Follow the existing Swift code style in the repo.
|
||||||
|
- There's no guarantee PRs will be merged; this is a personal project maintained in spare time.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- This app targets macOS 13+
|
||||||
|
- No external dependencies — keep it that way
|
||||||
@@ -10,6 +10,13 @@
|
|||||||
<string>77</string>
|
<string>77</string>
|
||||||
<key>objects</key>
|
<key>objects</key>
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>AAAAAA009</key>
|
||||||
|
<dict>
|
||||||
|
<key>isa</key>
|
||||||
|
<string>PBXBuildFile</string>
|
||||||
|
<key>fileRef</key>
|
||||||
|
<string>AAAAAA109</string>
|
||||||
|
</dict>
|
||||||
<key>AAAAAA007</key>
|
<key>AAAAAA007</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>isa</key>
|
<key>isa</key>
|
||||||
@@ -88,6 +95,17 @@
|
|||||||
<key>sourceTree</key>
|
<key>sourceTree</key>
|
||||||
<string><group></string>
|
<string><group></string>
|
||||||
</dict>
|
</dict>
|
||||||
|
<key>AAAAAA109</key>
|
||||||
|
<dict>
|
||||||
|
<key>isa</key>
|
||||||
|
<string>PBXFileReference</string>
|
||||||
|
<key>lastKnownFileType</key>
|
||||||
|
<string>sourcecode.swift</string>
|
||||||
|
<key>path</key>
|
||||||
|
<string>DiagnosticsView.swift</string>
|
||||||
|
<key>sourceTree</key>
|
||||||
|
<string><group></string>
|
||||||
|
</dict>
|
||||||
<key>AAAAAA101</key>
|
<key>AAAAAA101</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>isa</key>
|
<key>isa</key>
|
||||||
@@ -179,6 +197,7 @@
|
|||||||
<string>AAAAAA106</string>
|
<string>AAAAAA106</string>
|
||||||
<string>AAAAAA107</string>
|
<string>AAAAAA107</string>
|
||||||
<string>AAAAAA108</string>
|
<string>AAAAAA108</string>
|
||||||
|
<string>AAAAAA109</string>
|
||||||
</array>
|
</array>
|
||||||
<key>path</key>
|
<key>path</key>
|
||||||
<string>ClaudeChecker</string>
|
<string>ClaudeChecker</string>
|
||||||
@@ -292,6 +311,7 @@
|
|||||||
<string>AAAAAA005</string>
|
<string>AAAAAA005</string>
|
||||||
<string>AAAAAA007</string>
|
<string>AAAAAA007</string>
|
||||||
<string>AAAAAA008</string>
|
<string>AAAAAA008</string>
|
||||||
|
<string>AAAAAA009</string>
|
||||||
</array>
|
</array>
|
||||||
<key>runOnlyForDeploymentPostprocessing</key>
|
<key>runOnlyForDeploymentPostprocessing</key>
|
||||||
<string>0</string>
|
<string>0</string>
|
||||||
|
|||||||
@@ -18,16 +18,43 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
var updateManager = UpdateManager()
|
var updateManager = UpdateManager()
|
||||||
var refreshTimer: Timer?
|
var refreshTimer: Timer?
|
||||||
var cookiePrimerView: WKWebView?
|
var cookiePrimerView: WKWebView?
|
||||||
|
var cookiePrimerWindow: NSWindow?
|
||||||
|
var primerNavDelegate: PrimerNavDelegate?
|
||||||
var cancellables = Set<AnyCancellable>()
|
var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
NSApp.setActivationPolicy(.accessory)
|
NSApp.setActivationPolicy(.accessory)
|
||||||
|
|
||||||
// Hidden WKWebView to prime the shared cookie store
|
// Background WebView that loads claude.ai on every launch.
|
||||||
|
// Anchored in a real (off-screen, invisible) NSWindow so WebKit doesn't throttle
|
||||||
|
// JS execution. When the page finishes loading the nav delegate calls
|
||||||
|
// adoptPrimerWebView, which sets it as the API WebView and runs the first fetch.
|
||||||
let config = WKWebViewConfiguration()
|
let config = WKWebViewConfiguration()
|
||||||
config.websiteDataStore = WKWebsiteDataStore.default()
|
config.websiteDataStore = WKWebsiteDataStore.default()
|
||||||
cookiePrimerView = WKWebView(frame: .zero, configuration: config)
|
let wv = WKWebView(frame: CGRect(x: 0, y: 0, width: 1, height: 1), configuration: config)
|
||||||
cookiePrimerView?.load(URLRequest(url: URL(string: "https://claude.ai/api/organizations/\(UsageViewModel.orgId)/usage")!))
|
let win = NSWindow(
|
||||||
|
contentRect: NSRect(x: -9999, y: -9999, width: 1, height: 1),
|
||||||
|
styleMask: [],
|
||||||
|
backing: .buffered,
|
||||||
|
defer: false
|
||||||
|
)
|
||||||
|
win.contentView = wv
|
||||||
|
win.alphaValue = 0
|
||||||
|
win.orderFront(nil)
|
||||||
|
cookiePrimerWindow = win
|
||||||
|
cookiePrimerView = wv
|
||||||
|
|
||||||
|
let delegate = PrimerNavDelegate { [weak self] in
|
||||||
|
guard let self, let wv = self.cookiePrimerView else { return }
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
await self.usageViewModel.adoptPrimerWebView(wv)
|
||||||
|
await self.updateManager.checkForUpdates()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
primerNavDelegate = delegate
|
||||||
|
wv.navigationDelegate = delegate
|
||||||
|
wv.load(URLRequest(url: URL(string: "https://claude.ai")!))
|
||||||
|
|
||||||
// Status item
|
// Status item
|
||||||
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||||||
@@ -40,14 +67,17 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
|
|
||||||
// Popover
|
// Popover
|
||||||
popover = NSPopover()
|
popover = NSPopover()
|
||||||
popover?.contentSize = NSSize(width: 480, height: 640)
|
|
||||||
popover?.behavior = .transient
|
popover?.behavior = .transient
|
||||||
popover?.animates = true
|
popover?.animates = true
|
||||||
popover?.contentViewController = NSHostingController(
|
let hc = NSHostingController(
|
||||||
rootView: ContentView()
|
rootView: ContentView()
|
||||||
.environmentObject(usageViewModel)
|
.environmentObject(usageViewModel)
|
||||||
.environmentObject(updateManager)
|
.environmentObject(updateManager)
|
||||||
)
|
)
|
||||||
|
hc.sizingOptions = .preferredContentSize
|
||||||
|
hc.view.wantsLayer = true
|
||||||
|
hc.view.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor
|
||||||
|
popover?.contentViewController = hc
|
||||||
|
|
||||||
// React to limits or menubar toggle changes
|
// React to limits or menubar toggle changes
|
||||||
usageViewModel.$limits
|
usageViewModel.$limits
|
||||||
@@ -60,6 +90,11 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
.sink { [weak self] _ in self?.updateStatusItem() }
|
.sink { [weak self] _ in self?.updateStatusItem() }
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
updateManager.$updateAvailable
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in self?.updateStatusItem() }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
// Refresh timer
|
// Refresh timer
|
||||||
scheduleTimer(interval: usageViewModel.refreshInterval)
|
scheduleTimer(interval: usageViewModel.refreshInterval)
|
||||||
|
|
||||||
@@ -126,12 +161,6 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial fetch after cookie primer + update check
|
|
||||||
Task {
|
|
||||||
try? await Task.sleep(nanoseconds: 1_500_000_000)
|
|
||||||
await usageViewModel.refresh()
|
|
||||||
await updateManager.checkForUpdates()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setMenubarIcon(button: NSStatusBarButton) {
|
private func setMenubarIcon(button: NSStatusBarButton) {
|
||||||
@@ -166,7 +195,13 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
let sdStr = sd.map { "\(Int($0.usedPercent.rounded()))%" } ?? "—"
|
let sdStr = sd.map { "\(Int($0.usedPercent.rounded()))%" } ?? "—"
|
||||||
button.image = nil
|
button.image = nil
|
||||||
button.attributedTitle = NSAttributedString(string: "")
|
button.attributedTitle = NSAttributedString(string: "")
|
||||||
button.title = "◔ \(fhStr) \(sdStr)"
|
let updatePrefix = updateManager.updateAvailable ? "↑ Update " : "◔ "
|
||||||
|
button.title = "\(updatePrefix)\(fhStr) \(sdStr)"
|
||||||
|
button.font = NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .medium)
|
||||||
|
} else if updateManager.updateAvailable {
|
||||||
|
button.image = nil
|
||||||
|
button.attributedTitle = NSAttributedString(string: "")
|
||||||
|
button.title = "↑ Update"
|
||||||
button.font = NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .medium)
|
button.font = NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .medium)
|
||||||
} else {
|
} else {
|
||||||
button.title = ""
|
button.title = ""
|
||||||
@@ -199,6 +234,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
@objc func showPopover() {
|
@objc func showPopover() {
|
||||||
guard let button = statusItem?.button, let popover else { return }
|
guard let button = statusItem?.button, let popover else { return }
|
||||||
if !popover.isShown {
|
if !popover.isShown {
|
||||||
|
NotificationCenter.default.post(name: .popoverWillOpen, object: nil)
|
||||||
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
|
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
}
|
}
|
||||||
@@ -209,6 +245,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
if popover.isShown {
|
if popover.isShown {
|
||||||
popover.performClose(nil)
|
popover.performClose(nil)
|
||||||
} else {
|
} else {
|
||||||
|
NotificationCenter.default.post(name: .popoverWillOpen, object: nil)
|
||||||
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
|
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
}
|
}
|
||||||
@@ -223,3 +260,14 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One-shot WKNavigationDelegate: fires onDone on first finish or error, then goes silent.
|
||||||
|
final class PrimerNavDelegate: NSObject, WKNavigationDelegate {
|
||||||
|
private var done = false
|
||||||
|
private let onDone: () -> Void
|
||||||
|
init(_ onDone: @escaping () -> Void) { self.onDone = onDone }
|
||||||
|
private func finish() { guard !done else { return }; done = true; onDone() }
|
||||||
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { finish() }
|
||||||
|
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { finish() }
|
||||||
|
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { finish() }
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,20 +40,35 @@ struct ContentView: View {
|
|||||||
.environmentObject(updater)
|
.environmentObject(updater)
|
||||||
.transition(.move(edge: .trailing).combined(with: .opacity))
|
.transition(.move(edge: .trailing).combined(with: .opacity))
|
||||||
} else {
|
} else {
|
||||||
ScrollView(.vertical, showsIndicators: false) {
|
VStack(spacing: 0) {
|
||||||
VStack(spacing: 0) {
|
|
||||||
// Cards grid
|
// Cards grid
|
||||||
if vm.limits.isEmpty {
|
if vm.limits.isEmpty {
|
||||||
EmptyStateView()
|
EmptyStateView()
|
||||||
.padding(.vertical, 40)
|
.padding(.vertical, 40)
|
||||||
} else {
|
} else {
|
||||||
VStack(spacing: 1) {
|
VStack(spacing: 8) {
|
||||||
ForEach(vm.limits) { limit in
|
ForEach(vm.limits) { limit in
|
||||||
LimitCard(limit: limit)
|
VStack(spacing: 0) {
|
||||||
|
HStack {
|
||||||
|
Text(limit.window == .fiveHour ? "5 Hour Limit" : "7 Day Limit")
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.textCase(.uppercase)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 10)
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
|
||||||
|
LimitCard(limit: limit)
|
||||||
|
.background(Color(nsColor: .controlBackgroundColor))
|
||||||
|
.cornerRadius(8)
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.primary.opacity(0.08), lineWidth: 0.5))
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(Color.primary.opacity(0.05))
|
.padding(.bottom, 4)
|
||||||
.padding(.vertical, 1)
|
|
||||||
|
|
||||||
// Extra credits row
|
// Extra credits row
|
||||||
if let extra = vm.extraUsage, extra.isEnabled {
|
if let extra = vm.extraUsage, extra.isEnabled {
|
||||||
@@ -80,7 +95,6 @@ struct ContentView: View {
|
|||||||
.padding(.bottom, 8)
|
.padding(.bottom, 8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
|
|
||||||
@@ -94,9 +108,16 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
.frame(width: 480)
|
.frame(width: 480)
|
||||||
.animation(.easeInOut(duration: 0.2), value: showSettings)
|
.animation(.easeInOut(duration: 0.2), value: showSettings)
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .popoverWillOpen)) { _ in
|
||||||
|
showSettings = false
|
||||||
|
}
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .openUpdateSheet)) { _ in
|
||||||
|
showSettings = false
|
||||||
|
showUpdateSheet = true
|
||||||
|
}
|
||||||
.sheet(isPresented: $showLogin) {
|
.sheet(isPresented: $showLogin) {
|
||||||
LoginSheetView(isPresented: $showLogin) {
|
LoginSheetView(isPresented: $showLogin) { webView in
|
||||||
Task { await vm.refresh() }
|
Task { await vm.adoptAndRefresh(webView) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showUpdateSheet) {
|
.sheet(isPresented: $showUpdateSheet) {
|
||||||
@@ -189,7 +210,7 @@ struct LimitCard: View {
|
|||||||
AgentIconView(agent: .claude, size: 14)
|
AgentIconView(agent: .claude, size: 14)
|
||||||
Text("Claude")
|
Text("Claude")
|
||||||
.font(.system(size: 13, weight: .semibold))
|
.font(.system(size: 13, weight: .semibold))
|
||||||
Text("· \(vm.planLabel) · \(limit.window.label)")
|
Text("· \(vm.planLabel)")
|
||||||
.font(.system(size: 11))
|
.font(.system(size: 11))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -375,12 +396,11 @@ struct DiaryRow: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 5) {
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
HStack {
|
HStack {
|
||||||
AgentIconView(agent: .claude, size: 12)
|
Text("\(totalSaved) samples")
|
||||||
Text("Claude")
|
.font(.system(size: 10.5))
|
||||||
.font(.system(size: 12, weight: .medium))
|
.foregroundColor(.secondary)
|
||||||
.foregroundColor(color)
|
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("\(totalSaved) samples · Avg Burn Rate: +\(String(format: "%.1f", burnRate))%/h")
|
Text("Avg burn rate: \(String(format: "%.1f", burnRate))%/h")
|
||||||
.font(.system(size: 10.5))
|
.font(.system(size: 10.5))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
@@ -406,7 +426,9 @@ struct SparklineView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
if data.count > 1 {
|
let plotData = data.count == 1 ? [data[0], data[0]] : data
|
||||||
|
if plotData.count > 1 {
|
||||||
|
let data = plotData
|
||||||
let w = geo.size.width
|
let w = geo.size.width
|
||||||
let h = geo.size.height
|
let h = geo.size.height
|
||||||
let minV = data.min() ?? 0
|
let minV = data.min() ?? 0
|
||||||
@@ -562,6 +584,7 @@ struct SettingsView: View {
|
|||||||
@Binding var showSettings: Bool
|
@Binding var showSettings: Bool
|
||||||
@Binding var showUpdateSheet: Bool
|
@Binding var showUpdateSheet: Bool
|
||||||
@State private var checkingForUpdates = false
|
@State private var checkingForUpdates = false
|
||||||
|
@State private var showDiagnostics = false
|
||||||
|
|
||||||
let refreshOptions: [(label: String, seconds: TimeInterval)] = [
|
let refreshOptions: [(label: String, seconds: TimeInterval)] = [
|
||||||
("1 min", 60),
|
("1 min", 60),
|
||||||
@@ -748,6 +771,23 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
|
// Diagnostics
|
||||||
|
HStack {
|
||||||
|
Label("Diagnostics", systemImage: "stethoscope")
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Button("Open") { showDiagnostics = true }
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showDiagnostics) {
|
||||||
|
DiagnosticsView(isPresented: $showDiagnostics)
|
||||||
|
.environmentObject(vm)
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
// About
|
// About
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
|
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
|
||||||
@@ -766,25 +806,23 @@ struct SettingsView: View {
|
|||||||
.padding(.vertical, 1)
|
.padding(.vertical, 1)
|
||||||
.background(Color.primary.opacity(0.06))
|
.background(Color.primary.opacity(0.06))
|
||||||
.cornerRadius(4)
|
.cornerRadius(4)
|
||||||
if updater.updateAvailable {
|
}
|
||||||
Text("v\(updater.latestVersion) available")
|
if updater.updateAvailable {
|
||||||
.font(.system(size: 10, weight: .medium))
|
Text("v\(updater.latestVersion) available")
|
||||||
.foregroundColor(.white)
|
.font(.system(size: 10, weight: .medium))
|
||||||
.padding(.horizontal, 6)
|
.foregroundColor(.white)
|
||||||
.padding(.vertical, 2)
|
.padding(.horizontal, 6)
|
||||||
.background(Color.blue)
|
.padding(.vertical, 2)
|
||||||
.cornerRadius(4)
|
.background(Color.blue)
|
||||||
.fixedSize()
|
.cornerRadius(4)
|
||||||
} else if updater.betaAvailable {
|
} else if updater.betaAvailable {
|
||||||
Text("v\(updater.latestBetaVersion) available")
|
Text("v\(updater.latestBetaVersion) available")
|
||||||
.font(.system(size: 10, weight: .medium))
|
.font(.system(size: 10, weight: .medium))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.padding(.horizontal, 6)
|
.padding(.horizontal, 6)
|
||||||
.padding(.vertical, 2)
|
.padding(.vertical, 2)
|
||||||
.background(Color.orange)
|
.background(Color.orange)
|
||||||
.cornerRadius(4)
|
.cornerRadius(4)
|
||||||
.fixedSize()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Text("Built by superdooper86 & claude")
|
Text("Built by superdooper86 & claude")
|
||||||
.font(.system(size: 11))
|
.font(.system(size: 11))
|
||||||
@@ -891,12 +929,12 @@ struct ErrorBanner: View {
|
|||||||
struct EmptyStateView: View {
|
struct EmptyStateView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 10) {
|
||||||
Image(systemName: "key.slash")
|
Image(systemName: "person.crop.circle.badge.questionmark")
|
||||||
.font(.system(size: 28))
|
.font(.system(size: 28))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
Text("No API key configured")
|
Text("Not signed in")
|
||||||
.font(.system(size: 13, weight: .medium))
|
.font(.system(size: 13, weight: .medium))
|
||||||
Text("Open Settings to add your Anthropic API key.")
|
Text("Sign in to claude.ai to see your usage.")
|
||||||
.font(.system(size: 11.5))
|
.font(.system(size: 11.5))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
@@ -973,6 +1011,36 @@ struct UpdateSuccessBanner: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Markdown notes renderer
|
||||||
|
|
||||||
|
struct ReleaseNotesText: View {
|
||||||
|
let text: String
|
||||||
|
var font: Font = .system(size: 12)
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let processed = text
|
||||||
|
.split(separator: "\n", omittingEmptySubsequences: false)
|
||||||
|
.map { line -> String in
|
||||||
|
let s = String(line)
|
||||||
|
if s.hasPrefix("### ") { return "**\(s.dropFirst(4))**" }
|
||||||
|
if s.hasPrefix("## ") { return "**\(s.dropFirst(3))**" }
|
||||||
|
if s.hasPrefix("# ") { return "**\(s.dropFirst(2))**" }
|
||||||
|
if s.hasPrefix("- ") { return "• \(s.dropFirst(2))" }
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
.joined(separator: "\n")
|
||||||
|
|
||||||
|
if let attr = try? AttributedString(markdown: processed,
|
||||||
|
options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)) {
|
||||||
|
Text(attr).font(font).foregroundColor(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
} else {
|
||||||
|
Text(text).font(font).foregroundColor(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Update Banner
|
// MARK: - Update Banner
|
||||||
|
|
||||||
struct UpdateBanner: View {
|
struct UpdateBanner: View {
|
||||||
@@ -987,12 +1055,9 @@ struct UpdateBanner: View {
|
|||||||
VStack(alignment: .leading, spacing: 1) {
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
Text("Update available — v\(updater.latestVersion)")
|
Text("Update available — v\(updater.latestVersion)")
|
||||||
.font(.system(size: 12, weight: .semibold))
|
.font(.system(size: 12, weight: .semibold))
|
||||||
if !updater.releaseNotes.isEmpty {
|
Text("Tap Update to view what's new & install.")
|
||||||
Text(updater.releaseNotes)
|
.font(.system(size: 11))
|
||||||
.font(.system(size: 11))
|
.foregroundColor(.secondary)
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
Button("Update") { showSheet = true }
|
Button("Update") { showSheet = true }
|
||||||
@@ -1003,6 +1068,7 @@ struct UpdateBanner: View {
|
|||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
.background(Color.blue.opacity(0.08))
|
.background(Color.blue.opacity(0.08))
|
||||||
.overlay(Divider(), alignment: .bottom)
|
.overlay(Divider(), alignment: .bottom)
|
||||||
|
.onTapGesture { showSheet = true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1051,9 +1117,7 @@ struct UpdateSheet: View {
|
|||||||
.foregroundColor(.blue)
|
.foregroundColor(.blue)
|
||||||
}
|
}
|
||||||
if !updater.releaseNotes.isEmpty {
|
if !updater.releaseNotes.isEmpty {
|
||||||
Text(updater.releaseNotes)
|
ReleaseNotesText(text: updater.releaseNotes)
|
||||||
.font(.system(size: 12))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -1122,3 +1186,5 @@ struct UpdateSheet: View {
|
|||||||
.frame(width: 380)
|
.frame(width: 380)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import WebKit
|
||||||
|
|
||||||
|
struct DiagnosticsView: View {
|
||||||
|
@EnvironmentObject var vm: UsageViewModel
|
||||||
|
@Binding var isPresented: Bool
|
||||||
|
@State private var liveAllCookies: [HTTPCookie] = []
|
||||||
|
@State private var loadingCookies = false
|
||||||
|
@State private var copied = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Header
|
||||||
|
HStack {
|
||||||
|
Text("Diagnostics")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
Spacer()
|
||||||
|
Button("Copy All") {
|
||||||
|
NSPasteboard.general.clearContents()
|
||||||
|
NSPasteboard.general.setString(fullDiagText, forType: .string)
|
||||||
|
copied = true
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { copied = false }
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
if copied {
|
||||||
|
Text("Copied! (IDs redacted)")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundColor(.green)
|
||||||
|
}
|
||||||
|
Button("Close") { isPresented = false }
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(Color(nsColor: .windowBackgroundColor))
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
Text("Org IDs are redacted when copied.")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
|
||||||
|
// App info
|
||||||
|
DiagSection(title: "App") {
|
||||||
|
DiagRow("Version", Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?")
|
||||||
|
DiagRow("Build", Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?")
|
||||||
|
DiagRow("Signed in", vm.isSignedIn ? "Yes" : "No")
|
||||||
|
DiagRow("Org ID", UserDefaults.standard.string(forKey: "claude_org_id") ?? "(none)")
|
||||||
|
DiagRow("lastActiveOrg", vm.diagLastActiveOrg.isEmpty ? "(not read)" : vm.diagLastActiveOrg)
|
||||||
|
DiagRow("Error", vm.errorMessage ?? "(none)")
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Last request
|
||||||
|
DiagSection(title: "Last Request") {
|
||||||
|
DiagRow("Path", vm.diagLastPath.isEmpty ? "(none)" : vm.diagLastPath)
|
||||||
|
DiagRow("Status", vm.diagLastStatus == 0 ? "(none)" : "\(vm.diagLastStatus)")
|
||||||
|
DiagRow("Error", vm.diagLastError.isEmpty ? "(none)" : vm.diagLastError)
|
||||||
|
if let t = vm.diagLastFetch {
|
||||||
|
DiagRow("Time", t.formatted(date: .omitted, time: .standard))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Live cookie list
|
||||||
|
DiagSection(title: "Live Cookie Names (WKWebsiteDataStore.default)") {
|
||||||
|
if loadingCookies {
|
||||||
|
ProgressView().scaleEffect(0.7)
|
||||||
|
} else if liveAllCookies.isEmpty {
|
||||||
|
Text("No cookies found")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
} else {
|
||||||
|
let claudeCookies = liveAllCookies.filter {
|
||||||
|
$0.domain.contains("claude.ai") || $0.domain.contains("anthropic.com")
|
||||||
|
}
|
||||||
|
let otherCookies = liveAllCookies.filter {
|
||||||
|
!$0.domain.contains("claude.ai") && !$0.domain.contains("anthropic.com")
|
||||||
|
}
|
||||||
|
if !claudeCookies.isEmpty {
|
||||||
|
Text("claude.ai / anthropic.com (\(claudeCookies.count))")
|
||||||
|
.font(.system(size: 10, weight: .medium))
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
ForEach(claudeCookies, id: \.name) { c in
|
||||||
|
Text("• \(c.domain) \(c.name)")
|
||||||
|
.font(.system(size: 10, design: .monospaced))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !otherCookies.isEmpty {
|
||||||
|
Text("Other domains (\(otherCookies.count))")
|
||||||
|
.font(.system(size: 10, weight: .medium))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.padding(.top, 4)
|
||||||
|
ForEach(otherCookies.prefix(10), id: \.name) { c in
|
||||||
|
Text("• \(c.domain) \(c.name)")
|
||||||
|
.font(.system(size: 10, design: .monospaced))
|
||||||
|
.foregroundColor(Color.secondary.opacity(0.6))
|
||||||
|
}
|
||||||
|
if otherCookies.count > 10 {
|
||||||
|
Text("… and \(otherCookies.count - 10) more")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(Color.secondary.opacity(0.5))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button("Refresh cookies") { loadLiveCookies() }
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 480, height: 560)
|
||||||
|
.onAppear { loadLiveCookies() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadLiveCookies() {
|
||||||
|
loadingCookies = true
|
||||||
|
WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
liveAllCookies = cookies.sorted { $0.domain < $1.domain }
|
||||||
|
loadingCookies = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var fullDiagText: String {
|
||||||
|
var lines: [String] = []
|
||||||
|
lines.append("=== ClaudeChecker Diagnostics ===")
|
||||||
|
lines.append("Version: \(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?") (\(Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?"))")
|
||||||
|
lines.append("Signed in: \(vm.isSignedIn ? "Yes" : "No")")
|
||||||
|
lines.append("Org ID: \(UserDefaults.standard.string(forKey: \"claude_org_id\") ?? \"(none)\")")
|
||||||
|
lines.append("lastActiveOrg: \(vm.diagLastActiveOrg.isEmpty ? \"(not read)\" : vm.diagLastActiveOrg)")
|
||||||
|
lines.append("Error: \(vm.errorMessage ?? "(none)")")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Last path: \(vm.diagLastPath)")
|
||||||
|
lines.append("Last status: \(vm.diagLastStatus)")
|
||||||
|
lines.append("Last error: \(vm.diagLastError.isEmpty ? \"(none)\" : vm.diagLastError)")
|
||||||
|
if let t = vm.diagLastFetch {
|
||||||
|
lines.append("Time: \(t.formatted(date: .omitted, time: .standard))")
|
||||||
|
}
|
||||||
|
lines.append("")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Live cookies:")
|
||||||
|
for c in liveAllCookies {
|
||||||
|
lines.append(" \(c.domain) \(c.name) httpOnly=\(c.isHTTPOnly) secure=\(c.isSecure)")
|
||||||
|
}
|
||||||
|
return redactUUIDs(lines.joined(separator: "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func redactUUIDs(_ text: String) -> String {
|
||||||
|
let pattern = "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
|
||||||
|
guard let regex = try? NSRegularExpression(pattern: pattern) else { return text }
|
||||||
|
let range = NSRange(text.startIndex..., in: text)
|
||||||
|
return regex.stringByReplacingMatches(in: text, range: range, withTemplate: "****")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private struct DiagSection<Content: View>: View {
|
||||||
|
let title: String
|
||||||
|
@ViewBuilder let content: () -> Content
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.textCase(.uppercase)
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct DiagRow: View {
|
||||||
|
let label: String
|
||||||
|
let value: String
|
||||||
|
|
||||||
|
init(_ label: String, _ value: String) {
|
||||||
|
self.label = label
|
||||||
|
self.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .top, spacing: 8) {
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.frame(width: 100, alignment: .leading)
|
||||||
|
Text(value)
|
||||||
|
.font(.system(size: 11, design: .monospaced))
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,9 +15,9 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.0.0</string>
|
<string>1.3.3-beta.4</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>19</string>
|
<string>80</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>13.0</string>
|
<string>13.0</string>
|
||||||
<key>LSUIElement</key>
|
<key>LSUIElement</key>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import WebKit
|
|||||||
// MARK: - Login Web View
|
// MARK: - Login Web View
|
||||||
|
|
||||||
struct LoginWebView: NSViewRepresentable {
|
struct LoginWebView: NSViewRepresentable {
|
||||||
let onAuthenticated: () -> Void
|
let onAuthenticated: (WKWebView) -> Void
|
||||||
|
|
||||||
func makeNSView(context: Context) -> WKWebView {
|
func makeNSView(context: Context) -> WKWebView {
|
||||||
let config = WKWebViewConfiguration()
|
let config = WKWebViewConfiguration()
|
||||||
@@ -12,6 +12,13 @@ struct LoginWebView: NSViewRepresentable {
|
|||||||
|
|
||||||
let webView = WKWebView(frame: .zero, configuration: config)
|
let webView = WKWebView(frame: .zero, configuration: config)
|
||||||
webView.navigationDelegate = context.coordinator
|
webView.navigationDelegate = context.coordinator
|
||||||
|
context.coordinator.webView = webView
|
||||||
|
|
||||||
|
// KVO on url catches SPA pushState navigations that don't fire didFinish
|
||||||
|
context.coordinator.urlObservation = webView.observe(\.url, options: [.new]) { [weak coordinator = context.coordinator] wv, _ in
|
||||||
|
coordinator?.checkCurrentURL(wv.url?.absoluteString)
|
||||||
|
}
|
||||||
|
|
||||||
webView.load(URLRequest(url: URL(string: "https://claude.ai/login")!))
|
webView.load(URLRequest(url: URL(string: "https://claude.ai/login")!))
|
||||||
return webView
|
return webView
|
||||||
}
|
}
|
||||||
@@ -23,25 +30,31 @@ struct LoginWebView: NSViewRepresentable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class Coordinator: NSObject, WKNavigationDelegate {
|
class Coordinator: NSObject, WKNavigationDelegate {
|
||||||
let onAuthenticated: () -> Void
|
let onAuthenticated: (WKWebView) -> Void
|
||||||
|
weak var webView: WKWebView?
|
||||||
|
var didAuthenticate = false
|
||||||
|
var urlObservation: NSKeyValueObservation?
|
||||||
|
|
||||||
init(onAuthenticated: @escaping () -> Void) {
|
init(onAuthenticated: @escaping (WKWebView) -> Void) {
|
||||||
self.onAuthenticated = onAuthenticated
|
self.onAuthenticated = onAuthenticated
|
||||||
}
|
}
|
||||||
|
|
||||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
func checkCurrentURL(_ url: String?) {
|
||||||
WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in
|
guard !didAuthenticate, let url, let wv = webView else { return }
|
||||||
let hasSession = cookies.contains {
|
// Ignore navigations to external OAuth providers (Google, etc.) —
|
||||||
$0.domain.contains("claude.ai") &&
|
// only consider auth complete when we land back on claude.ai/anthropic.com
|
||||||
($0.name == "sessionKey" || $0.name == "__Secure-next-auth.session-token")
|
guard url.contains("claude.ai") || url.contains("anthropic.com") else { return }
|
||||||
}
|
if url.contains("/login") || url.contains("/auth") { return }
|
||||||
if hasSession {
|
didAuthenticate = true
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
self.onAuthenticated()
|
self.onAuthenticated(wv)
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Covers full cross-document navigations
|
||||||
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||||
|
checkCurrentURL(webView.url?.absoluteString)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +62,7 @@ struct LoginWebView: NSViewRepresentable {
|
|||||||
|
|
||||||
struct LoginSheetView: View {
|
struct LoginSheetView: View {
|
||||||
@Binding var isPresented: Bool
|
@Binding var isPresented: Bool
|
||||||
let onDone: () -> Void
|
let onDone: (WKWebView) -> Void
|
||||||
@State private var authenticated = false
|
@State private var authenticated = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -58,9 +71,15 @@ struct LoginSheetView: View {
|
|||||||
Text(authenticated ? "✓ Signed in — loading data…" : "Sign in to Claude")
|
Text(authenticated ? "✓ Signed in — loading data…" : "Sign in to Claude")
|
||||||
.font(.system(size: 13, weight: .semibold))
|
.font(.system(size: 13, weight: .semibold))
|
||||||
Spacer()
|
Spacer()
|
||||||
Button("Cancel") { isPresented = false }
|
if authenticated {
|
||||||
.buttonStyle(.bordered)
|
Button("Done") { isPresented = false }
|
||||||
.controlSize(.small)
|
.buttonStyle(.borderedProminent)
|
||||||
|
.controlSize(.small)
|
||||||
|
} else {
|
||||||
|
Button("Cancel") { isPresented = false }
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
@@ -68,12 +87,13 @@ struct LoginSheetView: View {
|
|||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
LoginWebView {
|
LoginWebView { webView in
|
||||||
authenticated = true
|
authenticated = true
|
||||||
// Auto-dismiss and refresh after brief delay
|
// Adopt the authenticated WebView immediately (before sheet tears it down),
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
|
// then auto-dismiss after a moment so the user sees confirmation.
|
||||||
|
onDone(webView)
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
|
||||||
isPresented = false
|
isPresented = false
|
||||||
onDone()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ class UpdateManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
print("[UpdateManager] Stable fetch returned nil")
|
print("[UpdateManager] Stable fetch returned nil")
|
||||||
|
updateAvailable = false
|
||||||
|
latestVersion = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
if let beta = await betaResult {
|
if let beta = await betaResult {
|
||||||
@@ -316,13 +318,31 @@ class UpdateManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func isNewer(_ version: String, than current: String) -> Bool {
|
private func isNewer(_ version: String, than current: String) -> Bool {
|
||||||
let toInts = { (v: String) in v.split(separator: ".").compactMap { Int($0) } }
|
func parse(_ v: String) -> (base: [Int], pre: [Int]?) {
|
||||||
let a = toInts(version), b = toInts(current)
|
guard !v.isEmpty else { return ([], nil) }
|
||||||
for i in 0..<max(a.count, b.count) {
|
let halves = v.split(separator: "-", maxSplits: 1)
|
||||||
let av = i < a.count ? a[i] : 0
|
let base = halves[0].split(separator: ".").compactMap { Int($0) }
|
||||||
let bv = i < b.count ? b[i] : 0
|
let pre = halves.count > 1 ? String(halves[1]).split(separator: ".").compactMap { Int($0) } : nil
|
||||||
|
return (base, pre)
|
||||||
|
}
|
||||||
|
let a = parse(version), b = parse(current)
|
||||||
|
// Compare base version numbers
|
||||||
|
for i in 0..<max(a.base.count, b.base.count) {
|
||||||
|
let av = i < a.base.count ? a.base[i] : 0
|
||||||
|
let bv = i < b.base.count ? b.base[i] : 0
|
||||||
if av != bv { return av > bv }
|
if av != bv { return av > bv }
|
||||||
}
|
}
|
||||||
|
// Base equal: stable beats pre-release
|
||||||
|
if a.pre == nil && b.pre != nil { return true }
|
||||||
|
if a.pre != nil && b.pre == nil { return false }
|
||||||
|
// Both pre-release: compare pre-release numbers
|
||||||
|
if let ap = a.pre, let bp = b.pre {
|
||||||
|
for i in 0..<max(ap.count, bp.count) {
|
||||||
|
let av = i < ap.count ? ap[i] : 0
|
||||||
|
let bv = i < bp.count ? bp[i] : 0
|
||||||
|
if av != bv { return av > bv }
|
||||||
|
}
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class UpdateNotificationWindowController: NSWindowController {
|
|||||||
|
|
||||||
// Called when a new update is detected while running
|
// Called when a new update is detected while running
|
||||||
static func showUpdate(version: String, notes: String, near statusItem: NSStatusItem?) {
|
static func showUpdate(version: String, notes: String, near statusItem: NSStatusItem?) {
|
||||||
showWindow(version: version, subtitle: notes.isEmpty ? "Tap to update now." : notes, near: statusItem, isUpdate: true)
|
showWindow(version: version, subtitle: "Tap to view release notes & update now!", near: statusItem, isUpdate: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func showLimitWarning(windowName: String, percent: Int, near statusItem: NSStatusItem?) {
|
static func showLimitWarning(windowName: String, percent: Int, near statusItem: NSStatusItem?) {
|
||||||
@@ -39,7 +39,12 @@ class UpdateNotificationWindowController: NSWindowController {
|
|||||||
sharedLimit?.close()
|
sharedLimit?.close()
|
||||||
let content = UpdateNotificationView(
|
let content = UpdateNotificationView(
|
||||||
version: "", subtitle: subtitle, isUpdate: false,
|
version: "", subtitle: subtitle, isUpdate: false,
|
||||||
titleText: title, badgeIcon: badgeIcon, badgeColor: badgeColor
|
titleText: title, badgeIcon: badgeIcon, badgeColor: badgeColor,
|
||||||
|
onTap: {
|
||||||
|
sharedLimit?.close()
|
||||||
|
sharedLimit = nil
|
||||||
|
NotificationCenter.default.post(name: .showPopover, object: nil)
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
sharedLimit?.close()
|
sharedLimit?.close()
|
||||||
sharedLimit = nil
|
sharedLimit = nil
|
||||||
@@ -53,7 +58,16 @@ class UpdateNotificationWindowController: NSWindowController {
|
|||||||
|
|
||||||
private static func showWindow(version: String, subtitle: String, near statusItem: NSStatusItem?, isUpdate: Bool) {
|
private static func showWindow(version: String, subtitle: String, near statusItem: NSStatusItem?, isUpdate: Bool) {
|
||||||
shared?.close()
|
shared?.close()
|
||||||
let content = UpdateNotificationView(version: version, subtitle: subtitle, isUpdate: isUpdate) {
|
let content = UpdateNotificationView(version: version, subtitle: subtitle, isUpdate: isUpdate,
|
||||||
|
onTap: {
|
||||||
|
shared?.close()
|
||||||
|
shared = nil
|
||||||
|
NotificationCenter.default.post(name: .showPopover, object: nil)
|
||||||
|
if isUpdate {
|
||||||
|
NotificationCenter.default.post(name: .openUpdateSheet, object: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
shared?.close()
|
shared?.close()
|
||||||
shared = nil
|
shared = nil
|
||||||
}
|
}
|
||||||
@@ -129,6 +143,7 @@ struct UpdateNotificationView: View {
|
|||||||
var titleText: String? = nil
|
var titleText: String? = nil
|
||||||
var badgeIcon: String? = nil
|
var badgeIcon: String? = nil
|
||||||
var badgeColor: Color? = nil
|
var badgeColor: Color? = nil
|
||||||
|
var onTap: (() -> Void)? = nil
|
||||||
let onDismiss: () -> Void
|
let onDismiss: () -> Void
|
||||||
|
|
||||||
private var effectiveTitle: String {
|
private var effectiveTitle: String {
|
||||||
@@ -142,30 +157,40 @@ struct UpdateNotificationView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 12) {
|
ZStack(alignment: .topTrailing) {
|
||||||
ZStack(alignment: .bottomTrailing) {
|
Button(action: { onTap?() }) {
|
||||||
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
|
HStack(spacing: 12) {
|
||||||
.resizable()
|
ZStack(alignment: .bottomTrailing) {
|
||||||
.frame(width: 44, height: 44)
|
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
|
||||||
.cornerRadius(10)
|
.resizable()
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
.cornerRadius(10)
|
||||||
|
|
||||||
Image(systemName: effectiveBadgeIcon)
|
Image(systemName: effectiveBadgeIcon)
|
||||||
.font(.system(size: 16, weight: .semibold))
|
.font(.system(size: 16, weight: .semibold))
|
||||||
.foregroundColor(effectiveBadgeColor)
|
.foregroundColor(effectiveBadgeColor)
|
||||||
.background(Color(nsColor: .windowBackgroundColor).clipShape(Circle()))
|
.background(Color(nsColor: .windowBackgroundColor).clipShape(Circle()))
|
||||||
.offset(x: 4, y: 4)
|
.offset(x: 4, y: 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text(effectiveTitle)
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 24)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
.frame(width: 340, alignment: .leading)
|
||||||
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
VStack(alignment: .leading, spacing: 3) {
|
|
||||||
Text(effectiveTitle)
|
|
||||||
.font(.system(size: 13, weight: .semibold))
|
|
||||||
Text(subtitle)
|
|
||||||
.font(.system(size: 11))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(minLength: 8)
|
|
||||||
|
|
||||||
Button(action: onDismiss) {
|
Button(action: onDismiss) {
|
||||||
Image(systemName: "xmark")
|
Image(systemName: "xmark")
|
||||||
@@ -173,10 +198,8 @@ struct UpdateNotificationView: View {
|
|||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.padding(14)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 14)
|
|
||||||
.padding(.vertical, 14)
|
|
||||||
.frame(width: 340, alignment: .leading)
|
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 13)
|
RoundedRectangle(cornerRadius: 13)
|
||||||
.fill(Color(nsColor: .windowBackgroundColor))
|
.fill(Color(nsColor: .windowBackgroundColor))
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import WebKit
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
class UsageViewModel: ObservableObject {
|
class UsageViewModel: ObservableObject {
|
||||||
static let orgId = "daf626a9-4924-4ff3-ba98-23b523062f8e"
|
|
||||||
@Published var limits: [AgentLimit] = []
|
@Published var limits: [AgentLimit] = []
|
||||||
@Published var isLoading = false
|
@Published var isLoading = false
|
||||||
@Published var lastUpdated: Date?
|
@Published var lastUpdated: Date?
|
||||||
@@ -28,24 +27,170 @@ class UsageViewModel: ObservableObject {
|
|||||||
|
|
||||||
private var burnHistoryStore: [String: [Double]] = [:]
|
private var burnHistoryStore: [String: [Double]] = [:]
|
||||||
private let maxHistorySamples = 24
|
private let maxHistorySamples = 24
|
||||||
private var cachedOrgId: String?
|
|
||||||
private var previousPercents: [String: Double] = [:]
|
private var previousPercents: [String: Double] = [:]
|
||||||
private var firedThresholds: [String: Set<Int>] = [:]
|
private var firedThresholds: [String: Set<Int>] = [:]
|
||||||
|
|
||||||
|
// WebView used for JS-based API calls (avoids URLSession 403 fingerprinting issues)
|
||||||
|
private var apiWebView: WKWebView?
|
||||||
|
|
||||||
|
// Diagnostics
|
||||||
|
@Published var diagCookieCount: Int = 0
|
||||||
|
@Published var diagClaudeCookieCount: Int = 0
|
||||||
|
@Published var diagCookieDomains: [String] = []
|
||||||
|
@Published var diagLastPath: String = ""
|
||||||
|
@Published var diagLastStatus: Int = 0
|
||||||
|
@Published var diagLastError: String = ""
|
||||||
|
@Published var diagLastActiveOrg: String = "" // lastActiveOrg cookie value from JS
|
||||||
|
@Published var diagLastFetch: Date? = nil
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
cachedOrgId = UserDefaults.standard.string(forKey: "claude_org_id") ?? Self.orgId
|
|
||||||
let saved = UserDefaults.standard.double(forKey: "refresh_interval")
|
let saved = UserDefaults.standard.double(forKey: "refresh_interval")
|
||||||
refreshInterval = saved > 0 ? saved : 60
|
refreshInterval = saved > 0 ? saved : 60
|
||||||
showInMenuBar = UserDefaults.standard.object(forKey: "show_in_menubar") as? Bool ?? true
|
showInMenuBar = UserDefaults.standard.object(forKey: "show_in_menubar") as? Bool ?? true
|
||||||
|
if let saved = UserDefaults.standard.object(forKey: "burn_history") as? [String: [Double]] {
|
||||||
|
burnHistoryStore = saved
|
||||||
|
}
|
||||||
loadPlaceholderData()
|
loadPlaceholderData()
|
||||||
|
Task { await checkInitialSignInState() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called by AppDelegate once the primer WebView has loaded claude.ai.
|
||||||
|
// Sets it as the API WebView and triggers the first data fetch.
|
||||||
|
func adoptPrimerWebView(_ wv: WKWebView) async {
|
||||||
|
apiWebView = wv
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called after login: store the login WebView (already on claude.ai) so jsFetch can use it.
|
||||||
|
func adoptAndRefresh(_ loginWebView: WKWebView) async {
|
||||||
|
for _ in 0..<30 {
|
||||||
|
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
||||||
|
if cookies.contains(where: { $0.domain.contains("claude.ai") }) { break }
|
||||||
|
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||||
|
}
|
||||||
|
apiWebView = loginWebView
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs a fetch() inside the live claude.ai WebView — same browser context as the frontend,
|
||||||
|
// so no CORS/fingerprinting issues that URLSession hits.
|
||||||
|
private func jsFetch(_ path: String) async throws -> (Int, String) {
|
||||||
|
guard let wv = apiWebView else { throw AppError.networkError }
|
||||||
|
let js = """
|
||||||
|
const r = await fetch('\(path)', {
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Accept': 'application/json' }
|
||||||
|
});
|
||||||
|
const body = await r.text();
|
||||||
|
return {status: r.status, body: body};
|
||||||
|
"""
|
||||||
|
let result: Any? = try await withCheckedThrowingContinuation { cont in
|
||||||
|
wv.callAsyncJavaScript(js, arguments: [:], in: nil, in: .defaultClient) { res in
|
||||||
|
switch res {
|
||||||
|
case .success(let val): cont.resume(returning: val)
|
||||||
|
case .failure(let err): cont.resume(throwing: err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard let dict = result as? [String: Any],
|
||||||
|
let status = dict["status"] as? Int,
|
||||||
|
let body = dict["body"] as? String else {
|
||||||
|
throw AppError.networkError
|
||||||
|
}
|
||||||
|
diagLastPath = path
|
||||||
|
diagLastFetch = Date()
|
||||||
|
diagLastStatus = status
|
||||||
|
diagLastError = status != 200 ? "JS HTTP \(status)" : ""
|
||||||
|
return (status, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use JS fetch when apiWebView is ready on claude.ai, otherwise fall back to URLSession.
|
||||||
|
private func apiFetch(_ path: String) async throws -> (Int, String) {
|
||||||
|
if let wv = apiWebView, wv.url?.host?.hasSuffix("claude.ai") == true {
|
||||||
|
return try await jsFetch(path)
|
||||||
|
}
|
||||||
|
return try await urlFetch(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetches an API path via URLSession with browser-like headers.
|
||||||
|
// Claude.ai's API requires sec-fetch-*, Origin, Referer, and a browser User-Agent
|
||||||
|
// to avoid 401 — matching how the Windows version (HttpClient) handles this.
|
||||||
|
private func urlFetch(_ path: String) async throws -> (Int, String) {
|
||||||
|
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
||||||
|
let claudeCookies = cookies.filter {
|
||||||
|
$0.domain.contains("claude.ai") || $0.domain.contains("anthropic.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record cookie diagnostics
|
||||||
|
diagCookieCount = cookies.count
|
||||||
|
diagClaudeCookieCount = claudeCookies.count
|
||||||
|
diagCookieDomains = Array(Set(cookies.map { $0.domain })).sorted()
|
||||||
|
diagLastPath = path
|
||||||
|
diagLastFetch = Date()
|
||||||
|
diagLastError = ""
|
||||||
|
|
||||||
|
guard !claudeCookies.isEmpty else {
|
||||||
|
let msg = "No claude.ai cookies (\(cookies.count) total in store)"
|
||||||
|
diagLastError = msg
|
||||||
|
throw AppError.detail(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
var req = URLRequest(url: URL(string: "https://claude.ai\(path)")!)
|
||||||
|
req.httpShouldHandleCookies = false
|
||||||
|
// Build cookie header manually (same format as Windows HttpClient) to avoid
|
||||||
|
// any encoding differences from HTTPCookie.requestHeaderFields(with:)
|
||||||
|
let cookieHeader = claudeCookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ")
|
||||||
|
req.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
|
||||||
|
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
req.setValue("https://claude.ai", forHTTPHeaderField: "Origin")
|
||||||
|
req.setValue("https://claude.ai/", forHTTPHeaderField: "Referer")
|
||||||
|
req.setValue("empty", forHTTPHeaderField: "sec-fetch-dest")
|
||||||
|
req.setValue("cors", forHTTPHeaderField: "sec-fetch-mode")
|
||||||
|
req.setValue("same-origin", forHTTPHeaderField: "sec-fetch-site")
|
||||||
|
req.setValue(
|
||||||
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
||||||
|
forHTTPHeaderField: "User-Agent")
|
||||||
|
req.setValue(
|
||||||
|
"\"Chromium\";v=\"124\", \"Google Chrome\";v=\"124\", \"Not-A.Brand\";v=\"99\"",
|
||||||
|
forHTTPHeaderField: "sec-ch-ua")
|
||||||
|
req.setValue("?0", forHTTPHeaderField: "sec-ch-ua-mobile")
|
||||||
|
req.setValue("\"macOS\"", forHTTPHeaderField: "sec-ch-ua-platform")
|
||||||
|
req.setValue("web", forHTTPHeaderField: "anthropic-client-platform")
|
||||||
|
|
||||||
|
let session = URLSession(configuration: .ephemeral)
|
||||||
|
do {
|
||||||
|
let (data, response) = try await session.data(for: req)
|
||||||
|
guard let http = response as? HTTPURLResponse else { throw AppError.networkError }
|
||||||
|
let body = String(data: data, encoding: .utf8) ?? ""
|
||||||
|
diagLastStatus = http.statusCode
|
||||||
|
if http.statusCode != 200 {
|
||||||
|
diagLastError = "HTTP \(http.statusCode)"
|
||||||
|
}
|
||||||
|
return (http.statusCode, body)
|
||||||
|
} catch let e as AppError { throw e }
|
||||||
|
catch {
|
||||||
|
let msg = error.localizedDescription
|
||||||
|
diagLastError = msg
|
||||||
|
throw AppError.detail(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func checkInitialSignInState() async {
|
||||||
|
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
||||||
|
guard cookies.contains(where: { $0.domain.contains("claude.ai") }) else { return }
|
||||||
|
isSignedIn = true
|
||||||
|
// AppDelegate's primer WebView will call adoptPrimerWebView once it loads,
|
||||||
|
// which triggers the first real data fetch.
|
||||||
}
|
}
|
||||||
|
|
||||||
func signOut() async {
|
func signOut() async {
|
||||||
let store = WKWebsiteDataStore.default()
|
let store = WKWebsiteDataStore.default()
|
||||||
let types = WKWebsiteDataStore.allWebsiteDataTypes()
|
let types = WKWebsiteDataStore.allWebsiteDataTypes()
|
||||||
let records = await store.dataRecords(ofTypes: types)
|
let records = await store.dataRecords(ofTypes: types)
|
||||||
let claudeRecords = records.filter { $0.displayName.contains("claude.ai") }
|
let claudeRecords = records.filter { $0.displayName.contains("claude.ai") || $0.displayName.contains("anthropic.com") }
|
||||||
await store.removeData(ofTypes: types, for: claudeRecords)
|
await store.removeData(ofTypes: types, for: claudeRecords)
|
||||||
|
UserDefaults.standard.removeObject(forKey: "claude_org_id")
|
||||||
|
apiWebView = nil
|
||||||
isSignedIn = false
|
isSignedIn = false
|
||||||
isNotAuthenticated = true
|
isNotAuthenticated = true
|
||||||
lastUpdated = nil
|
lastUpdated = nil
|
||||||
@@ -63,18 +208,50 @@ class UsageViewModel: ObservableObject {
|
|||||||
defer { isLoading = false }
|
defer { isLoading = false }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let orgId = cachedOrgId!
|
// Read lastActiveOrg cookie from JS — most reliable org source since it's
|
||||||
async let usageFetch = fetchUsage(orgId: orgId)
|
// set by the claude.ai frontend to whichever org is currently active.
|
||||||
async let prepaidFetch = fetchPrepaidCredits(orgId: orgId)
|
var cookieOrgId: String? = nil
|
||||||
async let overageFetch = fetchOverageSpendLimit(orgId: orgId)
|
if let wv = apiWebView, wv.url?.host?.hasSuffix("claude.ai") == true {
|
||||||
async let emailFetch = fetchUserEmail()
|
let js = """
|
||||||
let (usage, prepaid, overage, email) = try await (usageFetch, prepaidFetch, overageFetch, emailFetch)
|
return document.cookie.split(';')
|
||||||
if let email { userEmail = email }
|
.map(c => c.trim().split('='))
|
||||||
|
.filter(p => p[0] === 'lastActiveOrg')
|
||||||
|
.map(p => p.slice(1).join('='))[0] || null;
|
||||||
|
"""
|
||||||
|
let raw: Any? = try? await withCheckedThrowingContinuation { cont in
|
||||||
|
wv.callAsyncJavaScript(js, arguments: [:], in: nil, in: .defaultClient) { r in
|
||||||
|
cont.resume(returning: (try? r.get()) ?? nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let v = raw as? String, !v.isEmpty {
|
||||||
|
cookieOrgId = v
|
||||||
|
diagLastActiveOrg = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (fetchedOrgId, fetchedEmail, fetchedPlan) = try await fetchBootstrap(preferredOrgId: cookieOrgId)
|
||||||
|
|
||||||
|
let orgId: String
|
||||||
|
if let id = cookieOrgId ?? fetchedOrgId {
|
||||||
|
UserDefaults.standard.set(id, forKey: "claude_org_id")
|
||||||
|
orgId = id
|
||||||
|
} else if let cached = UserDefaults.standard.string(forKey: "claude_org_id") {
|
||||||
|
orgId = cached
|
||||||
|
} else {
|
||||||
|
throw AppError.notAuthenticated
|
||||||
|
}
|
||||||
|
|
||||||
|
if let email = fetchedEmail { userEmail = email }
|
||||||
|
if let plan = fetchedPlan { planLabel = plan }
|
||||||
|
|
||||||
|
async let usageFetch = fetchUsage(orgId: orgId)
|
||||||
|
async let prepaidFetch = fetchPrepaidCredits(orgId: orgId)
|
||||||
|
async let overageFetch = fetchOverageSpendLimit(orgId: orgId)
|
||||||
|
let (usage, prepaid, overage) = try await (usageFetch, prepaidFetch, overageFetch)
|
||||||
limits = buildLimits(from: usage)
|
limits = buildLimits(from: usage)
|
||||||
extraUsage = usage.extraUsage
|
extraUsage = usage.extraUsage
|
||||||
prepaidCredits = prepaid
|
prepaidCredits = prepaid
|
||||||
overageSpendLimit = overage
|
overageSpendLimit = overage
|
||||||
planLabel = "Pro"
|
|
||||||
lastUpdated = Date()
|
lastUpdated = Date()
|
||||||
isSignedIn = true
|
isSignedIn = true
|
||||||
|
|
||||||
@@ -84,11 +261,13 @@ class UsageViewModel: ObservableObject {
|
|||||||
history.append(limits[i].usedPercent)
|
history.append(limits[i].usedPercent)
|
||||||
if history.count > maxHistorySamples { history.removeFirst() }
|
if history.count > maxHistorySamples { history.removeFirst() }
|
||||||
burnHistoryStore[key] = history
|
burnHistoryStore[key] = history
|
||||||
if history.count > 1 { limits[i].burnHistory = history }
|
limits[i].burnHistory = history
|
||||||
}
|
}
|
||||||
|
UserDefaults.standard.set(burnHistoryStore, forKey: "burn_history")
|
||||||
checkLimitNotifications(for: limits)
|
checkLimitNotifications(for: limits)
|
||||||
} catch AppError.notAuthenticated {
|
} catch AppError.notAuthenticated {
|
||||||
isNotAuthenticated = true
|
isNotAuthenticated = true
|
||||||
|
isSignedIn = false
|
||||||
errorMessage = "Not signed in"
|
errorMessage = "Not signed in"
|
||||||
} catch let error as DecodingError {
|
} catch let error as DecodingError {
|
||||||
switch error {
|
switch error {
|
||||||
@@ -106,70 +285,75 @@ class UsageViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Bootstrap
|
||||||
|
|
||||||
|
private func fetchBootstrap(preferredOrgId: String? = nil) async throws -> (orgId: String?, email: String?, planLabel: String?) {
|
||||||
|
let (status, body) = try await apiFetch("/api/bootstrap")
|
||||||
|
if status == 401 || status == 403 {
|
||||||
|
throw AppError.detail("HTTP \(status) — \(body.prefix(120))")
|
||||||
|
}
|
||||||
|
guard status == 200 else { throw AppError.detail("HTTP \(status): \(body.prefix(80))") }
|
||||||
|
guard body.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{") else {
|
||||||
|
throw AppError.detail("Non-JSON response (HTML?): \(body.prefix(80))")
|
||||||
|
}
|
||||||
|
guard let data = body.data(using: .utf8),
|
||||||
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||||
|
return (nil, nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
let account = json["account"] as? [String: Any]
|
||||||
|
let memberships = (account?["memberships"] ?? json["memberships"]) as? [[String: Any]]
|
||||||
|
let allOrgs = memberships?.compactMap { $0["organization"] as? [String: Any] } ?? []
|
||||||
|
let firstOrg = allOrgs.first
|
||||||
|
|
||||||
|
var orgId: String? = firstOrg?["uuid"] as? String
|
||||||
|
if orgId == nil {
|
||||||
|
orgId = (json["organizations"] as? [[String: Any]])?.first?["uuid"] as? String
|
||||||
|
}
|
||||||
|
if orgId == nil {
|
||||||
|
if let (orgsStatus, orgsBody) = try? await apiFetch("/api/organizations"),
|
||||||
|
orgsStatus == 200,
|
||||||
|
let orgsData = orgsBody.data(using: .utf8),
|
||||||
|
let orgs = try? JSONSerialization.jsonObject(with: orgsData) as? [[String: Any]] {
|
||||||
|
orgId = orgs.first?["uuid"] as? String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let email = account?["email_address"] as? String
|
||||||
|
|
||||||
|
// Read capabilities from the preferred (active) org, falling back to first org.
|
||||||
|
let capOrg = preferredOrgId.flatMap { id in allOrgs.first(where: { $0["uuid"] as? String == id }) } ?? firstOrg
|
||||||
|
var planLabel: String? = nil
|
||||||
|
if let caps = capOrg?["capabilities"] as? [String],
|
||||||
|
let cap = caps.first(where: { $0.hasPrefix("claude_") }) {
|
||||||
|
let name = String(cap.dropFirst("claude_".count))
|
||||||
|
planLabel = name.prefix(1).uppercased() + name.dropFirst().lowercased()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (orgId, email, planLabel)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Fetch usage
|
// MARK: - Fetch usage
|
||||||
|
|
||||||
private func fetchUsage(orgId: String) async throws -> UsageResponse {
|
private func fetchUsage(orgId: String) async throws -> UsageResponse {
|
||||||
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/usage")!
|
let (status, body) = try await apiFetch("/api/organizations/\(orgId)/usage")
|
||||||
var req = URLRequest(url: url)
|
if status == 401 || status == 403 { throw AppError.detail("usage \(status): \(body.prefix(200))") }
|
||||||
req.setValue("application/json", forHTTPHeaderField: "accept")
|
guard status == 200 else { throw AppError.networkError }
|
||||||
|
guard let data = body.data(using: .utf8) else { throw AppError.networkError }
|
||||||
// Inject cookies from WKWebsiteDataStore (shared with browser/Claude app)
|
|
||||||
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
|
||||||
let claudeCookies = cookies.filter { $0.domain.contains("claude.ai") }
|
|
||||||
if let header = HTTPCookie.requestHeaderFields(with: claudeCookies)["Cookie"] {
|
|
||||||
req.setValue(header, forHTTPHeaderField: "Cookie")
|
|
||||||
}
|
|
||||||
|
|
||||||
let (data, response) = try await URLSession.shared.data(for: req)
|
|
||||||
guard let http = response as? HTTPURLResponse else { throw AppError.networkError }
|
|
||||||
if http.statusCode == 401 || http.statusCode == 403 { throw AppError.notAuthenticated }
|
|
||||||
guard http.statusCode == 200 else { throw AppError.networkError }
|
|
||||||
|
|
||||||
return try JSONDecoder().decode(UsageResponse.self, from: data)
|
return try JSONDecoder().decode(UsageResponse.self, from: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fetchUserEmail() async throws -> String? {
|
|
||||||
let url = URL(string: "https://claude.ai/api/bootstrap")!
|
|
||||||
var req = URLRequest(url: url)
|
|
||||||
req.setValue("application/json", forHTTPHeaderField: "accept")
|
|
||||||
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
|
||||||
let claudeCookies = cookies.filter { $0.domain.contains("claude.ai") }
|
|
||||||
if let header = HTTPCookie.requestHeaderFields(with: claudeCookies)["Cookie"] {
|
|
||||||
req.setValue(header, forHTTPHeaderField: "Cookie")
|
|
||||||
}
|
|
||||||
let (data, response) = try await URLSession.shared.data(for: req)
|
|
||||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
|
|
||||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
||||||
let account = json["account"] as? [String: Any],
|
|
||||||
let email = account["email_address"] as? String else { return nil }
|
|
||||||
return email
|
|
||||||
}
|
|
||||||
|
|
||||||
private func fetchPrepaidCredits(orgId: String) async throws -> PrepaidCredits? {
|
private func fetchPrepaidCredits(orgId: String) async throws -> PrepaidCredits? {
|
||||||
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/prepaid/credits")!
|
guard let (status, body) = try? await apiFetch("/api/organizations/\(orgId)/prepaid/credits"),
|
||||||
var req = URLRequest(url: url)
|
status == 200,
|
||||||
req.setValue("application/json", forHTTPHeaderField: "accept")
|
let data = body.data(using: .utf8) else { return nil }
|
||||||
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
|
||||||
let claudeCookies = cookies.filter { $0.domain.contains("claude.ai") }
|
|
||||||
if let header = HTTPCookie.requestHeaderFields(with: claudeCookies)["Cookie"] {
|
|
||||||
req.setValue(header, forHTTPHeaderField: "Cookie")
|
|
||||||
}
|
|
||||||
let (data, response) = try await URLSession.shared.data(for: req)
|
|
||||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
|
|
||||||
return try? JSONDecoder().decode(PrepaidCredits.self, from: data)
|
return try? JSONDecoder().decode(PrepaidCredits.self, from: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fetchOverageSpendLimit(orgId: String) async throws -> OverageSpendLimit? {
|
private func fetchOverageSpendLimit(orgId: String) async throws -> OverageSpendLimit? {
|
||||||
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/overage_spend_limit")!
|
guard let (status, body) = try? await apiFetch("/api/organizations/\(orgId)/overage_spend_limit"),
|
||||||
var req = URLRequest(url: url)
|
status == 200,
|
||||||
req.setValue("application/json", forHTTPHeaderField: "accept")
|
let data = body.data(using: .utf8) else { return nil }
|
||||||
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
|
||||||
let claudeCookies = cookies.filter { $0.domain.contains("claude.ai") }
|
|
||||||
if let header = HTTPCookie.requestHeaderFields(with: claudeCookies)["Cookie"] {
|
|
||||||
req.setValue(header, forHTTPHeaderField: "Cookie")
|
|
||||||
}
|
|
||||||
let (data, response) = try await URLSession.shared.data(for: req)
|
|
||||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
|
|
||||||
return try? JSONDecoder().decode(OverageSpendLimit.self, from: data)
|
return try? JSONDecoder().decode(OverageSpendLimit.self, from: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,14 +425,11 @@ class UsageViewModel: ObservableObject {
|
|||||||
|
|
||||||
private func checkLimitNotifications(for limits: [AgentLimit]) {
|
private func checkLimitNotifications(for limits: [AgentLimit]) {
|
||||||
let thresholds = [80, 95]
|
let thresholds = [80, 95]
|
||||||
|
|
||||||
for limit in limits {
|
for limit in limits {
|
||||||
let key = limit.window.rawValue
|
let key = limit.window.rawValue
|
||||||
let curr = limit.usedPercent
|
let curr = limit.usedPercent
|
||||||
let prev = previousPercents[key]
|
let prev = previousPercents[key]
|
||||||
var fired = firedThresholds[key] ?? []
|
var fired = firedThresholds[key] ?? []
|
||||||
|
|
||||||
// Threshold warnings (80% and 95%)
|
|
||||||
for t in thresholds {
|
for t in thresholds {
|
||||||
let threshold = Double(t)
|
let threshold = Double(t)
|
||||||
guard curr >= threshold, prev ?? threshold < threshold, !fired.contains(t) else { continue }
|
guard curr >= threshold, prev ?? threshold < threshold, !fired.contains(t) else { continue }
|
||||||
@@ -259,8 +440,6 @@ class UsageViewModel: ObservableObject {
|
|||||||
userInfo: ["windowName": limit.window.displayName, "percent": t]
|
userInfo: ["windowName": limit.window.displayName, "percent": t]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset detection: was high, now low
|
|
||||||
if let prev, prev > 50, curr < 20 {
|
if let prev, prev > 50, curr < 20 {
|
||||||
fired.removeAll()
|
fired.removeAll()
|
||||||
NotificationCenter.default.post(
|
NotificationCenter.default.post(
|
||||||
@@ -269,7 +448,6 @@ class UsageViewModel: ObservableObject {
|
|||||||
userInfo: ["windowName": limit.window.displayName]
|
userInfo: ["windowName": limit.window.displayName]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
firedThresholds[key] = fired
|
firedThresholds[key] = fired
|
||||||
previousPercents[key] = curr
|
previousPercents[key] = curr
|
||||||
}
|
}
|
||||||
@@ -292,13 +470,17 @@ class UsageViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
enum AppError: LocalizedError {
|
enum AppError: LocalizedError {
|
||||||
case notAuthenticated
|
case notAuthenticated
|
||||||
case networkError
|
case networkError
|
||||||
|
case detail(String)
|
||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .notAuthenticated: return "Not signed into claude.ai — open claude.ai in your browser first."
|
case .notAuthenticated: return "Not signed into claude.ai — open claude.ai in your browser first."
|
||||||
case .networkError: return "Network error fetching usage data."
|
case .networkError: return "Network error fetching usage data."
|
||||||
|
case .detail(let msg): return msg
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -307,7 +489,9 @@ extension Notification.Name {
|
|||||||
static let refreshIntervalChanged = Notification.Name("refreshIntervalChanged")
|
static let refreshIntervalChanged = Notification.Name("refreshIntervalChanged")
|
||||||
static let closePopover = Notification.Name("closePopover")
|
static let closePopover = Notification.Name("closePopover")
|
||||||
static let showPopover = Notification.Name("showPopover")
|
static let showPopover = Notification.Name("showPopover")
|
||||||
|
static let popoverWillOpen = Notification.Name("popoverWillOpen")
|
||||||
static let updateDetected = Notification.Name("updateDetected")
|
static let updateDetected = Notification.Name("updateDetected")
|
||||||
static let limitWarning = Notification.Name("limitWarning")
|
static let limitWarning = Notification.Name("limitWarning")
|
||||||
static let limitReset = Notification.Name("limitReset")
|
static let limitReset = Notification.Name("limitReset")
|
||||||
|
static let openUpdateSheet = Notification.Name("openUpdateSheet")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,13 @@
|
|||||||
**A native macOS menubar app for monitoring your Claude AI usage limits in real time.**
|
**A native macOS menubar app for monitoring your Claude AI usage limits in real time.**
|
||||||
|
|
||||||
[](https://www.apple.com/macos/)
|
[](https://www.apple.com/macos/)
|
||||||
|
[](https://www.microsoft.com/windows/)
|
||||||
[](https://swift.org)
|
[](https://swift.org)
|
||||||
[](https://github.com/superdooper86/claudechecker/releases)
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
|
<br/>
|
||||||
|
[](https://github.com/superdooper86/claudechecker/releases)
|
||||||
|
[](https://github.com/superdooper86/claudechecker/releases/tag/v1.3.3-beta.3) <!-- BETA_BADGE -->
|
||||||
|
[](https://github.com/superdooper86/claudechecker/releases/tag/win-v0.0.1-beta.54) <!-- WIN_BETA_BADGE -->
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -29,21 +33,21 @@ No API key required. It reads directly from your `claude.ai` session using a bui
|
|||||||
|
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center"><img src="screenshots/main-v2.png" width="280" alt="Main window" /><br/><sub>Main window</sub></td>
|
<td align="center"><img src="screenshots/main-v3.png" width="280" alt="Main window" /><br/><sub>Main window</sub></td>
|
||||||
<td align="center"><img src="screenshots/settings-v2.png" width="280" alt="Settings" /><br/><sub>Settings</sub></td>
|
<td align="center"><img src="screenshots/settings-v3.png" width="280" alt="Settings" /><br/><sub>Settings</sub></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center"><img src="screenshots/update-banner.png" width="280" alt="Update banner" /><br/><sub>Update available</sub></td>
|
<td align="center"><img src="screenshots/update-banner.png" width="280" alt="Update available" /><br/><sub>Update available</sub></td>
|
||||||
<td align="center"><img src="screenshots/notification.png" width="280" alt="Update notification" /><br/><sub>System notification</sub></td>
|
<td align="center"><img src="screenshots/release-notes.png" width="280" alt="Release notes" /><br/><sub>Release notes</sub></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><img src="screenshots/notification.png" width="280" alt="Update notification" /><br/><sub>Update notification</sub></td>
|
||||||
|
<td align="center"><img src="screenshots/notif-reset.png" width="280" alt="Limit reset" /><br/><sub>Limit reset</sub></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center"><img src="screenshots/notif-limit-80.png" width="280" alt="80% limit warning" /><br/><sub>80% limit warning</sub></td>
|
<td align="center"><img src="screenshots/notif-limit-80.png" width="280" alt="80% limit warning" /><br/><sub>80% limit warning</sub></td>
|
||||||
<td align="center"><img src="screenshots/notif-limit-95.png" width="280" alt="95% limit warning" /><br/><sub>95% limit warning</sub></td>
|
<td align="center"><img src="screenshots/notif-limit-95.png" width="280" alt="95% limit warning" /><br/><sub>95% limit warning</sub></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
|
||||||
<td align="center"><img src="screenshots/notif-reset.png" width="280" alt="Limit reset" /><br/><sub>Limit reset</sub></td>
|
|
||||||
<td></td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<br/>
|
<br/>
|
||||||
@@ -113,25 +117,29 @@ No API key required. It reads directly from your `claude.ai` session using a bui
|
|||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- macOS 13 Ventura or later
|
- macOS 13 Ventura or later
|
||||||
- Xcode 15+ (to build from source)
|
|
||||||
- A claude.ai account
|
- A claude.ai account
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### Download (recommended)
|
### Homebrew (easiest)
|
||||||
|
```bash
|
||||||
|
brew tap superdooper86/tap
|
||||||
|
brew install --cask claudechecker
|
||||||
|
```
|
||||||
|
|
||||||
|
### Download
|
||||||
1. Go to [Releases](https://github.com/superdooper86/claudechecker/releases)
|
1. Go to [Releases](https://github.com/superdooper86/claudechecker/releases)
|
||||||
2. Download `ClaudeChecker.zip` from the latest release
|
2. Download `ClaudeChecker.zip` from the latest release
|
||||||
3. Unzip and drag `ClaudeChecker.app` to `/Applications`
|
3. Unzip and drag `ClaudeChecker.app` to `/Applications`
|
||||||
4. Open from Applications — it will appear in your menubar
|
4. Open from Applications — it will appear in your menubar
|
||||||
|
|
||||||
### Build from source
|
> **macOS security prompt?** If macOS blocks the app on first launch, run this in Terminal:
|
||||||
```bash
|
> ```bash
|
||||||
git clone https://github.com/superdooper86/claudechecker.git
|
> xattr -cr /Applications/ClaudeChecker.app
|
||||||
open ClaudeChecker/ClaudeChecker.xcodeproj
|
> ```
|
||||||
```
|
> This is not needed when installing via Homebrew.
|
||||||
Then in Xcode: set your Team under Signing & Capabilities, then **⌘R** to run.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
## What's new in v1.3.2
|
||||||
|
|
||||||
|
### Reliability
|
||||||
|
- Data now loads automatically on every app launch without requiring manual re-authentication. The background WebView is anchored in a hidden window so macOS no longer throttles its JavaScript execution.
|
||||||
|
Before Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
@@ -0,0 +1 @@
|
|||||||
|
{"version": "1.3.3-beta.3", "url": "https://github.com/superdooper86/claudechecker/releases/download/v1.3.3-beta.3/ClaudeChecker.zip", "notes": "## What's new in v1.3.2\n\n### Reliability\n- Data now loads automatically on every app launch without requiring manual re-authentication. The background WebView is anchored in a hidden window so macOS no longer throttles its JavaScript execution."}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1-beta.54",
|
||||||
|
"url": "https://github.com/superdooper86/claudechecker/releases/download/win-v0.0.1-beta.54/ClaudeChecker-Windows.zip",
|
||||||
|
"notes": "## What's new in beta.49\r\n\r\n### Architecture\r\n- All data (usage, plan, overage, prepaid credits) now fetched via the persistent background WebView2 — the same always-live session as the macOS app, so Cloudflare cookies never expire between refreshes\r\n- HttpClient path kept only as a brief startup fallback before the browser is ready\r\n- Settings file simplified: only stores session signal, burn history, and user preferences — no more cached API responses\r\n\r\n### Bug fixes\r\n- Plan label (e.g. \"Pro\") now updates on every refresh, not just at login\r\n- Extra Usage Credits values now correct (were 100× too large)\r\n- Limit and balance now update live on every refresh\r\n- Refresh interval dropdown now shows \"1 min\", \"2 min\" etc. correctly\r\n- ComboBox no longer flashes bright blue when clicked\r\n- Buttons now show a visible pressed state\r\n- Footer \"Updated X ago\" counter now ticks every second; shows \"X min, Y sec ago\" past 60 s\r\n- Removed stray sparkline bars from the 5 h and 7 d limit cards\r\n- App window now shown on launch\r\n\r\n### UI improvements\r\n- Gauge percentage larger, \"used\" label removed\r\n- Each limit card now shows an \"after reset\" or \"today HH:MM\" badge\r\n- Session Diary card redesigned to match macOS layout (stats row + sparkline, no Claude header)\r\n- Reset date moved inline with time remaining"
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.0.0",
|
"version": "1.3.2",
|
||||||
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.0.0/ClaudeChecker.zip",
|
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.3.2/ClaudeChecker.zip",
|
||||||
"notes": "Initial release."
|
"notes": "## What's new in v1.3.2\n\n### Reliability\n- Data now loads automatically on every app launch without requiring manual re-authentication. The background WebView is anchored in a hidden window so macOS no longer throttles its JavaScript execution."
|
||||||
}
|
}
|
||||||
|
|||||||