diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..0ceca45 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,29 @@ +name: Build +on: + push: + branches: [main] + workflow_dispatch: +jobs: + macos: + runs-on: claudechecker-macos-arm64 + steps: + - uses: actions/checkout@v4 + - name: Build universal macOS app + run: bash scripts/build-macos.sh + - uses: actions/upload-artifact@v3 + with: + name: ClaudeChecker-macOS + path: ClaudeChecker.zip + windows: + runs-on: claudechecker-windows-x64 + defaults: + run: + shell: powershell + steps: + - uses: actions/checkout@v4 + - name: Build Windows installer + run: ./scripts/build-windows.ps1 + - uses: actions/upload-artifact@v3 + with: + name: ClaudeChecker-Windows + path: ClaudeChecker-Installer.exe diff --git a/.gitea/workflows/release-windows.yml b/.gitea/workflows/release-windows.yml new file mode 100644 index 0000000..07d8653 --- /dev/null +++ b/.gitea/workflows/release-windows.yml @@ -0,0 +1,32 @@ +name: Release Windows +on: + workflow_dispatch: + inputs: + version: + description: 'Version, e.g. 0.0.1-beta.64' + required: true + type: string + channel: + description: Release channel + required: true + default: windows-beta + type: choice + options: [windows-beta, windows-stable] +jobs: + release: + if: github.ref == 'refs/heads/main' + runs-on: claudechecker-windows-x64 + defaults: + run: + shell: powershell + env: + VERSION: ${{ inputs.version }} + CHANNEL: ${{ inputs.channel }} + steps: + - uses: actions/checkout@v4 + - name: Build Windows installer + run: ./scripts/build-windows.ps1 -Version $env:VERSION + - name: Publish release and update feed + env: + GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: python scripts/publish-release.py diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..2ff2767 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,29 @@ +name: Release macOS +on: + workflow_dispatch: + inputs: + version: + description: 'Version, e.g. 1.3.3 or 1.3.4-beta.1' + required: true + type: string + channel: + description: Release channel + required: true + default: stable + type: choice + options: [stable, beta] +jobs: + release: + if: github.ref == 'refs/heads/main' + runs-on: claudechecker-macos-arm64 + env: + VERSION: ${{ inputs.version }} + CHANNEL: ${{ inputs.channel }} + steps: + - uses: actions/checkout@v4 + - name: Build universal macOS app + run: bash scripts/build-macos.sh + - name: Publish release and update feed + env: + GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: python3 scripts/publish-release.py diff --git a/.github/workflows/release-beta.yml b/.github/workflows/release-beta.yml deleted file mode 100644 index c1f8850..0000000 --- a/.github/workflows/release-beta.yml +++ /dev/null @@ -1,81 +0,0 @@ -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="[![macOS Beta](https://img.shields.io/badge/macOS_Beta-${shields_version}-orange?style=flat)](https://github.com/${{ github.repository }}/releases/tag/${tag}) " - python3 -c " - import os - badge = os.environ['BADGE'] - lines = open('README.md').readlines() - out = [] - for line in lines: - out.append(badge + chr(10) if '' 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 4764cb5..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,105 +0,0 @@ -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 = ['' + chr(10) if '' 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f1919a0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/build/ +/publish/ +/ClaudeChecker.zip +/ClaudeChecker-Installer.exe +/windows/bin/ +/windows/obj/ +__pycache__/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bca53a4..a7f8b6f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ 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. +Please use the [issue templates](https://git.jamesbone.net/coder/claudechecker/issues/new/choose) — they keep reports consistent and easy to act on. ## Pull requests diff --git a/ClaudeChecker/Info.plist b/ClaudeChecker/Info.plist index 6d4400b..9a3c846 100644 --- a/ClaudeChecker/Info.plist +++ b/ClaudeChecker/Info.plist @@ -15,9 +15,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.3.3-beta.5 + 1.3.3 CFBundleVersion - 81 + 82 LSMinimumSystemVersion 13.0 LSUIElement diff --git a/ClaudeChecker/UpdateManager.swift b/ClaudeChecker/UpdateManager.swift index c2fd7c7..1255f36 100644 --- a/ClaudeChecker/UpdateManager.swift +++ b/ClaudeChecker/UpdateManager.swift @@ -30,8 +30,8 @@ enum UpdateError: LocalizedError { @MainActor class UpdateManager: ObservableObject { - static let versionURL = "https://raw.githubusercontent.com/superdooper86/claudechecker/refs/heads/main/version.json" - static let betaVersionURL = "https://raw.githubusercontent.com/superdooper86/claudechecker/refs/heads/main/version-beta.json" + static let versionURL = "https://git.jamesbone.net/coder/claudechecker/raw/branch/main/version.json" + static let betaVersionURL = "https://git.jamesbone.net/coder/claudechecker/raw/branch/main/version-beta.json" @Published var updateAvailable = false @Published var latestVersion = "" @@ -118,7 +118,7 @@ class UpdateManager: ObservableObject { } private func fetchVersion(from urlString: String) async -> VersionInfo? { - // Add timestamp to bust GitHub's 5-minute CDN cache + // Add timestamp to bust cached update metadata let cacheBusted = urlString + "?t=\(Int(Date().timeIntervalSince1970))" guard let url = URL(string: cacheBusted) else { print("[UpdateManager] Invalid URL: \(urlString)") diff --git a/README.md b/README.md index dfc4228..9990e93 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@
-ClaudeChecker icon +ClaudeChecker icon # ClaudeChecker @@ -11,9 +11,9 @@ [![Swift](https://img.shields.io/badge/Swift-5.9-F05138?style=flat&logo=swift&logoColor=white)](https://swift.org) [![License](https://img.shields.io/badge/license-MIT-blue?style=flat)](LICENSE)
-[![macOS Stable](https://img.shields.io/badge/macOS_Stable-1.3.2-orange?style=flat)](https://github.com/superdooper86/claudechecker/releases) -[![macOS Beta](https://img.shields.io/badge/macOS_Beta-1.3.3--beta.5-orange?style=flat)](https://github.com/superdooper86/claudechecker/releases/tag/v1.3.3-beta.5) -[![Win Beta](https://img.shields.io/badge/Win_Beta-0.0.1--beta.63-blue?style=flat)](https://github.com/superdooper86/claudechecker/releases/tag/win-v0.0.1-beta.63) +[![macOS Stable](https://img.shields.io/badge/macOS_Stable-1.3.2-orange?style=flat)](https://git.jamesbone.net/coder/claudechecker/releases) +[![macOS Beta](https://img.shields.io/badge/macOS_Beta-1.3.3--beta.5-orange?style=flat)](https://git.jamesbone.net/coder/claudechecker/releases/tag/v1.3.3-beta.5) +[![Win Beta](https://img.shields.io/badge/Win_Beta-0.0.1--beta.63-blue?style=flat)](https://git.jamesbone.net/coder/claudechecker/releases/tag/win-v0.0.1-beta.63)
@@ -130,7 +130,7 @@ brew install --cask claudechecker ``` ### Download -1. Go to [Releases](https://github.com/superdooper86/claudechecker/releases) +1. Go to [Releases](https://git.jamesbone.net/coder/claudechecker/releases) 2. Download `ClaudeChecker.zip` from the latest release 3. Unzip and drag `ClaudeChecker.app` to `/Applications` 4. Open from Applications — it will appear in your menubar @@ -166,7 +166,7 @@ ClaudeChecker reads from the following `claude.ai` API endpoints (authenticated ## Privacy -- No data leaves your machine except to `claude.ai` (your own session) and `raw.githubusercontent.com` (version check) +- No data leaves your machine except to `claude.ai` (your own session) and `git.jamesbone.net` (version check) - Session stored in macOS `WKWebsiteDataStore.default()` — same as Safari - No analytics, no tracking, no ads @@ -181,3 +181,28 @@ ClaudeChecker reads from the following `claude.ai` API endpoints (authenticated
ClaudeChecker is not affiliated with or endorsed by Anthropic.
+ +## Building and releasing + +The canonical repository is https://git.jamesbone.net/coder/claudechecker. +Both macOS and Windows source live on `main`; the original Windows feature +branch and all historical tags/releases are retained. + +Gitea **Build** validates every push to `main` on repository-scoped host runners: +`claudechecker-macos-arm64` (Mac mini, Xcode) and `claudechecker-windows-x64` +(Windows host, .NET 8, Inno Setup). Each run uploads its app/installer artifact. + +Use **Release macOS** or **Release Windows**, select `main`, and supply a version +and channel. Stable versions use `X.Y.Z`; beta versions use `X.Y.Z-beta.N`. +The workflow builds that main revision, creates the release and uploads the asset +before advancing the corresponding `version*.json` feed on main. Releases are +never overwritten. macOS builds retain the existing ad-hoc signing model; this +project does not currently perform Developer ID signing or notarization. + +Existing installs still check the old GitHub `version*.json` URLs. Those files +are kept as a migration bridge advertising the first Gitea-hosted update. Keep +the GitHub repository accessible for those clients; subsequent updates use Gitea. +GitHub workflows are disabled after the Gitea builds are verified. + +Local build commands are `bash scripts/build-macos.sh` and, on Windows, +`./scripts/build-windows.ps1` with `INNO_SETUP_COMPILER` pointing to `ISCC.exe`. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index c0da116..cfb8136 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,8 +1,4 @@ ## What's new in v1.3.3 -### Diagnostics improvements -- Org IDs are now redacted when copying diagnostics text (privacy) -- Removed stale Cookie Store section (always showed 0) -- All fields (Org ID, lastActiveOrg, Last error, Time) now included in copied output -- Last error now shows "(none)" instead of blank when there are no errors -- Privacy note updated to reflect what is and isn't redacted +- Updates and release downloads now come from git.jamesbone.net. +- macOS and Windows builds are managed by Gitea Actions from main. diff --git a/scripts/build-macos.sh b/scripts/build-macos.sh new file mode 100644 index 0000000..97a368b --- /dev/null +++ b/scripts/build-macos.sh @@ -0,0 +1,12 @@ +#!/bin/bash +set -euo pipefail +if [[ -n "${VERSION:-}" ]]; then + [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-beta\.[0-9]+)?$ ]] || exit 2 + /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" ClaudeChecker/Info.plist +fi +xcodebuild -project ClaudeChecker.xcodeproj -scheme ClaudeChecker \ + -configuration Release -derivedDataPath build \ + ARCHS="arm64 x86_64" ONLY_ACTIVE_ARCH=NO CODE_SIGN_IDENTITY=- clean build +codesign --verify --deep --strict build/Build/Products/Release/ClaudeChecker.app +lipo -verify_arch arm64 x86_64 build/Build/Products/Release/ClaudeChecker.app/Contents/MacOS/ClaudeChecker +ditto -c -k --keepParent build/Build/Products/Release/ClaudeChecker.app ClaudeChecker.zip diff --git a/scripts/build-windows.ps1 b/scripts/build-windows.ps1 new file mode 100644 index 0000000..4205efd --- /dev/null +++ b/scripts/build-windows.ps1 @@ -0,0 +1,10 @@ +param([string]$Version = '0.0.1-beta.64') +$ErrorActionPreference = 'Stop' +if ($Version -notmatch '^\d+\.\d+\.\d+(-beta\.\d+)?$') { throw 'Invalid version' } +dotnet publish windows/ClaudeCheckerWindows.csproj -c Release -r win-x64 --self-contained true "-p:Version=$Version" -o publish +if ($LASTEXITCODE -ne 0) { throw 'dotnet publish failed' } +Copy-Item -Recurse -Force windows/Assets publish/Assets +& $env:INNO_SETUP_COMPILER "/DAppVersion=$Version" windows/installer.iss +if ($LASTEXITCODE -ne 0) { throw 'Inno Setup failed' } +if (!(Test-Path ClaudeChecker-Installer.exe)) { throw 'Installer missing' } +Get-FileHash ClaudeChecker-Installer.exe -Algorithm SHA256 diff --git a/scripts/publish-release.py b/scripts/publish-release.py new file mode 100644 index 0000000..ba57983 --- /dev/null +++ b/scripts/publish-release.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Publish a completed main build, then advance its update channel.""" +import base64 +import json +import os +from pathlib import Path +import re +import subprocess +import urllib.error +import urllib.parse +import urllib.request + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): + return None + + +def main(): + version = os.environ['VERSION'] + channel = os.environ['CHANNEL'] + if channel not in ('stable', 'beta', 'windows-beta', 'windows-stable'): + raise ValueError('Unknown release channel') + if not re.fullmatch(r'\d+\.\d+\.\d+(-beta\.\d+)?', version): + raise ValueError('Invalid release version') + if ('beta' in channel) != ('-beta.' in version): + raise ValueError('Version must match release channel') + if os.environ.get('GITHUB_REF') != 'refs/heads/main': + raise ValueError('Releases must build from main') + revision = subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip() + windows = channel.startswith('windows-') + tag = ('win-v' if windows else 'v') + version + artifact = Path('ClaudeChecker-Installer.exe' if windows else 'ClaudeChecker.zip') + notes = Path('windows/RELEASE_NOTES.md' if windows else 'RELEASE_NOTES.md').read_text() + feed = {'stable': 'version.json', 'beta': 'version-beta.json', + 'windows-beta': 'version-windows-beta.json', 'windows-stable': 'version-windows.json'}[channel] + server = os.environ['GITHUB_SERVER_URL'].rstrip('/') + repo = os.environ['GITHUB_REPOSITORY'] + opener = urllib.request.build_opener(NoRedirect) + + def api(path, method='GET', data=None, binary=False): + body = data if binary else (json.dumps(data).encode() if data is not None else None) + request = urllib.request.Request(f'{server}/api/v1/repos/{repo}{path}', data=body, method=method, + headers={'Authorization': 'token ' + os.environ['GITEA_TOKEN'], + 'Content-Type': 'application/octet-stream' if binary else 'application/json'}) + with opener.open(request, timeout=600) as response: + return json.load(response) + + # Never replace an existing release or advertise a partially uploaded asset. + release = api('/releases', 'POST', {'tag_name': tag, 'target_commitish': revision, + 'name': tag, 'body': notes + '\n\nBuilt from main: ' + revision, + 'draft': True, 'prerelease': 'beta' in channel}) + asset = api(f"/releases/{release['id']}/assets?name={urllib.parse.quote(artifact.name)}", + 'POST', artifact.read_bytes(), binary=True) + if asset['size'] != artifact.stat().st_size: + raise ValueError('Uploaded asset size mismatch') + api(f"/releases/{release['id']}", 'PATCH', {'draft': False}) + content = json.dumps({'version': version, + 'url': f'https://git.jamesbone.net/{repo}/releases/download/{tag}/{artifact.name}', + 'notes': notes}, indent=2) + '\n' + try: + current = api(f'/contents/{feed}?ref=main') + except urllib.error.HTTPError as error: + if error.code != 404: + raise + current = None + payload = {'branch': 'main', 'message': f'Update {channel} feed to {tag}', + 'content': base64.b64encode(content.encode()).decode()} + if current: + payload['sha'] = current['sha'] + api(f'/contents/{feed}', 'PUT' if current else 'POST', payload) + print(f'Published {tag} and {feed} from {revision}') + + +if __name__ == '__main__': + main() diff --git a/version-beta.json b/version-beta.json index ff24bbf..be8d04d 100644 --- a/version-beta.json +++ b/version-beta.json @@ -1,5 +1,5 @@ { "version": "1.3.3-beta.5", - "url": "https://github.com/superdooper86/claudechecker/releases/download/v1.3.3-beta.5/ClaudeChecker.zip", + "url": "https://git.jamesbone.net/coder/claudechecker/releases/download/v1.3.3-beta.5/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." } diff --git a/version-windows-beta.json b/version-windows-beta.json index e31edd7..36d06fe 100644 --- a/version-windows-beta.json +++ b/version-windows-beta.json @@ -1,5 +1,5 @@ -{ - "version": "0.0.1-beta.63", - "url": "https://github.com/superdooper86/claudechecker/releases/download/win-v0.0.1-beta.63/ClaudeChecker-Installer.exe", - "notes": "## What's new in beta.62\r\n\r\n### Installer\r\n- App now ships as a proper Windows installer (no more zip extract)\r\n- Installs to user AppData — no admin rights required\r\n- Auto-update now downloads and silently runs the new installer, then relaunches\r\n- Desktop shortcut creation is optional during install\r\n\r\n### Bug fixes\r\n- Fixed app crash on launch (startup DllNotFoundException caused by single-file publish; all DLLs now installed alongside the exe)\r\n- Fixed clicking Copy in Diagnostics crashing the app (CLIPBRD_E_CANT_OPEN)\r\n- Fixed Sign In — Done button now always clickable; session detection no longer depends on specific cookie names that may have changed\r\n\r\n### Diagnostics\r\n- Org IDs are now redacted when copying diagnostics text\r\n- Removed stale Cookie Store section (always showed 0)\r\n- Fixed missing fields in copied diagnostics output\r\n- Privacy note added below diagnostics header" -} +{ + "version": "0.0.1-beta.63", + "url": "https://git.jamesbone.net/coder/claudechecker/releases/download/win-v0.0.1-beta.63/ClaudeChecker-Installer.exe", + "notes": "## What's new in beta.62\r\n\r\n### Installer\r\n- App now ships as a proper Windows installer (no more zip extract)\r\n- Installs to user AppData — no admin rights required\r\n- Auto-update now downloads and silently runs the new installer, then relaunches\r\n- Desktop shortcut creation is optional during install\r\n\r\n### Bug fixes\r\n- Fixed app crash on launch (startup DllNotFoundException caused by single-file publish; all DLLs now installed alongside the exe)\r\n- Fixed clicking Copy in Diagnostics crashing the app (CLIPBRD_E_CANT_OPEN)\r\n- Fixed Sign In — Done button now always clickable; session detection no longer depends on specific cookie names that may have changed\r\n\r\n### Diagnostics\r\n- Org IDs are now redacted when copying diagnostics text\r\n- Removed stale Cookie Store section (always showed 0)\r\n- Fixed missing fields in copied diagnostics output\r\n- Privacy note added below diagnostics header" +} diff --git a/version.json b/version.json index 03110f0..dc0fa85 100644 --- a/version.json +++ b/version.json @@ -1,5 +1,5 @@ { "version": "1.3.2", - "url": "https://github.com/superdooper86/claudechecker/releases/download/v1.3.2/ClaudeChecker.zip", + "url": "https://git.jamesbone.net/coder/claudechecker/releases/download/v1.3.2/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." } diff --git a/windows/App.xaml b/windows/App.xaml new file mode 100644 index 0000000..8d9107c --- /dev/null +++ b/windows/App.xaml @@ -0,0 +1,198 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/windows/App.xaml.cs b/windows/App.xaml.cs new file mode 100644 index 0000000..2fc1f22 --- /dev/null +++ b/windows/App.xaml.cs @@ -0,0 +1,151 @@ +using System; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Threading; +using Forms = System.Windows.Forms; + +namespace ClaudeCheckerWindows; + +public partial class App : Application +{ + public static UsageViewModel ViewModel { get; } = new(); + public static UpdateManager Updater { get; } = new(); + // Persistent hidden WebView2 — shares the same user data folder as LoginWindow + // so its cookies (including cf_clearance) are always live. Used on every refresh + // for endpoints that need a real browser session (overage, prepaid). + public static WebViewFetchWindow BackgroundBrowser { get; } = new(); + + private Forms.NotifyIcon? _tray; + private PopupWindow? _popup; + private DispatcherTimer? _timer; + + protected override void OnStartup(StartupEventArgs e) + { + var logPath = System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "ClaudeChecker", "startup_crash.txt"); + + AppDomain.CurrentDomain.UnhandledException += (_, ex) => + System.IO.File.WriteAllText(logPath, ex.ExceptionObject?.ToString() ?? "unknown"); + + DispatcherUnhandledException += (_, ex) => + { + System.IO.File.WriteAllText(logPath, ex.Exception?.ToString() ?? "unknown"); + ex.Handled = false; + }; + + base.OnStartup(e); + ThemeManager.Initialize(); + SetupTray(); + ShowPopup(); + ScheduleTimer(ViewModel.RefreshInterval); + + // Show background browser window on the UI thread before Task.Run + BackgroundBrowser.Show(); + + _ = Task.Run(async () => + { + await ViewModel.LoadFromCacheAsync(); + // Initialize the persistent background browser (navigates to claude.ai once) + await Application.Current.Dispatcher.InvokeAsync( + () => BackgroundBrowser.InitAsync()).Task.Unwrap(); + await ViewModel.RefreshAsync(); + await Updater.CheckForUpdatesAsync(); + }); + } + + private void SetupTray() + { + _tray = new Forms.NotifyIcon + { + Text = "ClaudeChecker", + Visible = true, + Icon = LoadIcon(), + }; + + _tray.MouseClick += (_, e) => + { + if (e.Button == Forms.MouseButtons.Left) + Dispatcher.InvokeAsync(TogglePopup); + }; + + _tray.ContextMenuStrip = BuildContextMenu(); + + ViewModel.PropertyChanged += (_, _) => UpdateTrayText(); + Updater.PropertyChanged += (_, _) => UpdateTrayText(); + } + + private static System.Drawing.Icon LoadIcon() + { + try { return new System.Drawing.Icon("Assets/icon.ico"); } + catch { return System.Drawing.SystemIcons.Application; } + } + + private Forms.ContextMenuStrip BuildContextMenu() + { + var menu = new Forms.ContextMenuStrip(); + menu.Items.Add("Show ClaudeChecker", null, (_, _) => Dispatcher.InvokeAsync(ShowPopup)); + menu.Items.Add(new Forms.ToolStripSeparator()); + menu.Items.Add("Quit", null, (_, _) => Dispatcher.InvokeAsync(Quit)); + return menu; + } + + private void TogglePopup() + { + if (_popup == null || !_popup.IsVisible) + ShowPopup(); + else + _popup.Hide(); + } + + private void ShowPopup() + { + if (_popup == null) + { + _popup = new PopupWindow(); + } + _popup.Show(); + _popup.Activate(); + } + + public void ScheduleTimer(int seconds) + { + _timer?.Stop(); + _timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(seconds) }; + _timer.Tick += async (_, _) => + { + await ViewModel.RefreshAsync(); + await Updater.CheckForUpdatesAsync(); + }; + _timer.Start(); + } + + private void UpdateTrayText() + { + if (_tray == null) return; + var limits = ViewModel.Limits; + if (ViewModel.ShowInTaskbar && limits.Count >= 2) + { + var fh = limits.Find(l => l.Window == WindowKind.FiveHour); + var sd = limits.Find(l => l.Window == WindowKind.SevenDay); + if (fh != null && sd != null && fh.IsLive) + { + _tray.Text = $"ClaudeChecker {(int)fh.UsedPercent}% {(int)sd.UsedPercent}%"; + return; + } + } + _tray.Text = "ClaudeChecker"; + } + + private void Quit() + { + _tray?.Dispose(); + Shutdown(); + } + + protected override void OnExit(ExitEventArgs e) + { + _tray?.Dispose(); + base.OnExit(e); + } +} diff --git a/windows/AppSettings.cs b/windows/AppSettings.cs new file mode 100644 index 0000000..bbe4d88 --- /dev/null +++ b/windows/AppSettings.cs @@ -0,0 +1,49 @@ +using System; +using System.IO; +using System.Text.Json; + +namespace ClaudeCheckerWindows; + +public class AppSettings +{ + // Auth signal — non-empty means the user has completed sign-in at least once. + // The WebView2 user-data folder holds the live session; this is only for startup. + public string CookieStore { get; set; } = ""; + + // Accumulated sparkline data — built up over multiple refreshes. + public string BurnHistory { get; set; } = ""; + + // User preferences + public int RefreshInterval { get; set; } = 120; + public bool ShowInTaskbar { get; set; } = true; + public bool BetaChannel { get; set; } = false; + + private static readonly string FilePath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "ClaudeChecker", "settings.json"); + + private static AppSettings? _instance; + public static AppSettings Default => _instance ??= Load(); + + private static AppSettings Load() + { + try + { + if (File.Exists(FilePath)) + return JsonSerializer.Deserialize(File.ReadAllText(FilePath)) ?? new(); + } + catch { } + return new(); + } + + public void Save() + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!); + File.WriteAllText(FilePath, JsonSerializer.Serialize(this, + new JsonSerializerOptions { WriteIndented = true })); + } + catch { } + } +} diff --git a/windows/Assets/icon.ico b/windows/Assets/icon.ico new file mode 100644 index 0000000..dab4b7c Binary files /dev/null and b/windows/Assets/icon.ico differ diff --git a/windows/Assets/icon.png b/windows/Assets/icon.png new file mode 100644 index 0000000..d950b88 Binary files /dev/null and b/windows/Assets/icon.png differ diff --git a/windows/ClaudeCheckerWindows.csproj b/windows/ClaudeCheckerWindows.csproj new file mode 100644 index 0000000..f7b4a47 --- /dev/null +++ b/windows/ClaudeCheckerWindows.csproj @@ -0,0 +1,32 @@ + + + + WinExe + net8.0-windows + true + enable + enable + ClaudeChecker + ClaudeCheckerWindows + Assets\icon.ico + 1.0.0 + x64 + + + + + + + + + + + + + + PreserveNewest + + + + + diff --git a/windows/Controls/GaugeControl.cs b/windows/Controls/GaugeControl.cs new file mode 100644 index 0000000..0b6a7af --- /dev/null +++ b/windows/Controls/GaugeControl.cs @@ -0,0 +1,84 @@ +using Brush = System.Windows.Media.Brush; +using Pen = System.Windows.Media.Pen; +using Point = System.Windows.Point; +using System; +using System.Globalization; +using System.Windows; +using System.Windows.Media; + +namespace ClaudeCheckerWindows.Controls; + +public class GaugeControl : FrameworkElement +{ + public static readonly DependencyProperty PercentProperty = + DependencyProperty.Register(nameof(Percent), typeof(double), typeof(GaugeControl), + new FrameworkPropertyMetadata(0.0, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty AccentProperty = + DependencyProperty.Register(nameof(Accent), typeof(Brush), typeof(GaugeControl), + new FrameworkPropertyMetadata(Brushes.Orange, FrameworkPropertyMetadataOptions.AffectsRender)); + + public double Percent { get => (double)GetValue(PercentProperty); set => SetValue(PercentProperty, value); } + public Brush Accent { get => (Brush)GetValue(AccentProperty); set => SetValue(AccentProperty, value); } + + public GaugeControl() + { + ThemeManager.ThemeChanged += InvalidateVisual; + } + + private const double StartAngleDeg = 135; + private const double SweepDeg = 270; + private const double StrokeWidth = 6; + + protected override void OnRender(DrawingContext dc) + { + var cx = RenderSize.Width / 2; + var cy = RenderSize.Height / 2; + var radius = Math.Min(cx, cy) - StrokeWidth / 2 - 1; + + var trackColor = ThemeManager.IsDark + ? Color.FromArgb(40, 255, 255, 255) + : Color.FromArgb(30, 0, 0, 0); + DrawArc(dc, cx, cy, radius, StartAngleDeg, SweepDeg, + new Pen(new SolidColorBrush(trackColor), StrokeWidth)); + + if (Percent > 0) + DrawArc(dc, cx, cy, radius, StartAngleDeg, SweepDeg * (Percent / 100.0), + new Pen(Accent, StrokeWidth)); + + var dpi = VisualTreeHelper.GetDpi(this).PixelsPerDip; + var textColor = ThemeManager.IsDark ? Color.FromRgb(0xF5, 0xF5, 0xF5) : Color.FromRgb(0x1C, 0x1C, 0x1C); + + var pctText = new FormattedText( + $"{(int)Math.Round(Percent)}%", + CultureInfo.CurrentCulture, FlowDirection.LeftToRight, + new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, FontWeights.Bold, FontStretches.Normal), + 18, new SolidColorBrush(textColor), dpi); + + dc.DrawText(pctText, new Point(cx - pctText.Width / 2, cy - pctText.Height / 2)); + } + + private static void DrawArc(DrawingContext dc, double cx, double cy, double r, + double startDeg, double sweepDeg, Pen pen) + { + if (sweepDeg <= 0) return; + var start = PointOnCircle(cx, cy, r, startDeg); + var end = PointOnCircle(cx, cy, r, startDeg + sweepDeg); + var isLarge = sweepDeg > 180; + + var geo = new StreamGeometry(); + using (var ctx = geo.Open()) + { + ctx.BeginFigure(start, false, false); + ctx.ArcTo(end, new Size(r, r), 0, isLarge, SweepDirection.Clockwise, true, false); + } + geo.Freeze(); + dc.DrawGeometry(null, pen, geo); + } + + private static Point PointOnCircle(double cx, double cy, double r, double deg) + { + var rad = deg * Math.PI / 180; + return new Point(cx + r * Math.Cos(rad), cy + r * Math.Sin(rad)); + } +} diff --git a/windows/Controls/SparklineControl.cs b/windows/Controls/SparklineControl.cs new file mode 100644 index 0000000..f9964d0 --- /dev/null +++ b/windows/Controls/SparklineControl.cs @@ -0,0 +1,60 @@ +using Point = System.Windows.Point; +using System.Collections.Generic; +using System.Windows; +using System.Windows.Media; + +namespace ClaudeCheckerWindows.Controls; + +public class SparklineControl : FrameworkElement +{ + public static readonly DependencyProperty DataProperty = + DependencyProperty.Register(nameof(Data), typeof(IList), typeof(SparklineControl), + new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty LineColorProperty = + DependencyProperty.Register(nameof(LineColor), typeof(Color), typeof(SparklineControl), + new FrameworkPropertyMetadata(Colors.Orange, FrameworkPropertyMetadataOptions.AffectsRender)); + + public IList? Data { get => (IList?)GetValue(DataProperty); set => SetValue(DataProperty, value); } + public Color LineColor { get => (Color)GetValue(LineColorProperty); set => SetValue(LineColorProperty, value); } + + protected override void OnRender(DrawingContext dc) + { + var data = Data; + if (data == null || data.Count < 1) return; + + var plot = data.Count == 1 ? new[] { data[0], data[0] } : [.. data]; + + var w = RenderSize.Width; + var h = RenderSize.Height; + var minV = double.MaxValue; + var maxV = double.MinValue; + foreach (var v in plot) { if (v < minV) minV = v; if (v > maxV) maxV = v; } + var range = System.Math.Max(maxV - minV, 1); + + Point Pt(int i) => new( + i / (double)(plot.Length - 1) * w, + h - (plot[i] - minV) / range * (h - 6) - 3); + + var fill = new StreamGeometry(); + using (var ctx = fill.Open()) + { + ctx.BeginFigure(new Point(0, h), true, true); + for (var i = 0; i < plot.Length; i++) ctx.LineTo(Pt(i), true, false); + ctx.LineTo(new Point(w, h), true, false); + } + fill.Freeze(); + dc.DrawGeometry(new SolidColorBrush(Color.FromArgb(26, LineColor.R, LineColor.G, LineColor.B)), null, fill); + + var line = new StreamGeometry(); + using (var ctx = line.Open()) + { + ctx.BeginFigure(Pt(0), false, false); + for (var i = 1; i < plot.Length; i++) ctx.LineTo(Pt(i), true, false); + } + line.Freeze(); + dc.DrawGeometry(null, + new System.Windows.Media.Pen(new SolidColorBrush(LineColor), 1.5) { LineJoin = PenLineJoin.Round }, + line); + } +} diff --git a/windows/GlobalUsings.cs b/windows/GlobalUsings.cs new file mode 100644 index 0000000..e181f0c --- /dev/null +++ b/windows/GlobalUsings.cs @@ -0,0 +1,9 @@ +// Global aliases to resolve ambiguities between System.Windows.* and System.Drawing.* +global using Application = System.Windows.Application; +global using Brushes = System.Windows.Media.Brushes; +global using Color = System.Windows.Media.Color; +global using FlowDirection = System.Windows.FlowDirection; +global using FontFamily = System.Windows.Media.FontFamily; +global using Orientation = System.Windows.Controls.Orientation; +global using ProgressBar = System.Windows.Controls.ProgressBar; +global using Size = System.Windows.Size; diff --git a/windows/LoginWindow.xaml b/windows/LoginWindow.xaml new file mode 100644 index 0000000..c0cb4f5 --- /dev/null +++ b/windows/LoginWindow.xaml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + +