commit f72cbf9e910324d970b7a67c84fd51c3a69bb42e Author: Slavomir Durej Date: Sun Dec 14 18:27:30 2025 +0000 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..61d41dc --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# Dependencies +node_modules/ +package-lock.json + +# Build output +dist/ +build/ +out/ + +# OS files +.DS_Store +Thumbs.db +desktop.ini + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Environment +.env +.env.local +.env.*.local + +# Electron +*.electron/ + +# Cache +.cache/ +.temp/ diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..ef7a829 --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,89 @@ +# Installation Instructions + +## For End Users + +### Option 1: Download Installer (Recommended) +1. Download `Claude-Usage-Widget-Setup.exe` from releases +2. Run the installer +3. Launch from Start Menu +4. Login when prompted + +### Option 2: Build from Source +```bash +# Install Node.js from https://nodejs.org (if not already installed) + +# Clone or download this project +cd claude-usage-widget + +# Install dependencies +npm install + +# Run the widget +npm start + +# Or build installer +npm run build:win +``` + +## First Time Setup + +1. **Launch the widget** - A frameless window appears +2. **Click "Login to Claude"** - Browser window opens +3. **Login to Claude.ai** - Use your normal credentials +4. **Widget activates** - Usage data appears automatically +5. **Minimize to tray** - Click the minus icon + +## System Requirements + +- **OS:** Windows 10 or later (64-bit) +- **RAM:** 200 MB +- **Disk:** 100 MB +- **Internet:** Required for Claude.ai API + +## What Gets Installed + +- Executable: `%LOCALAPPDATA%\Programs\claude-usage-widget\` +- Settings: `%APPDATA%\claude-usage-widget\` (encrypted) +- Start Menu shortcut +- Desktop shortcut (optional) + +## Uninstallation + +**Windows:** +1. Settings → Apps → Claude Usage Widget → Uninstall +2. Or run `Uninstall Claude Usage Widget.exe` from install directory + +**Manual cleanup:** +``` +%APPDATA%\claude-usage-widget\ +%LOCALAPPDATA%\Programs\claude-usage-widget\ +``` + +## Troubleshooting Install Issues + +### "Windows protected your PC" +1. Click "More info" +2. Click "Run anyway" +3. This is normal for unsigned apps + +### Installer won't run +- Ensure you have admin rights +- Disable antivirus temporarily +- Download again (file may be corrupted) + +### Can't find after install +- Check Start Menu → All Apps +- Search for "Claude Usage Widget" +- Check Desktop for shortcut + +## Security Notes + +✅ **Your data stays local** - credentials stored encrypted on your machine +✅ **Open source** - code is available for review +✅ **No telemetry** - no usage data sent anywhere +✅ **Direct API** - only communicates with claude.ai + +--- + +For development setup, see [QUICKSTART.md](QUICKSTART.md) +For full documentation, see [README.md](README.md) diff --git a/QUICKSTART.md b/QUICKSTART.md new file mode 100644 index 0000000..39a6af1 --- /dev/null +++ b/QUICKSTART.md @@ -0,0 +1,195 @@ +# Quick Start Guide + +## Installation & Development + +### 1. Install Dependencies + +```bash +cd claude-usage-widget +npm install +``` + +### 2. Run in Development Mode + +```bash +npm start +``` + +This will: +- Launch the widget with DevTools open +- Enable hot-reload for debugging +- Show console logs + +### 3. Test the Application + +**First Run:** +1. Widget appears (frameless window) +2. Click "Login to Claude" +3. Browser window opens to claude.ai +4. Login with your credentials +5. Widget automatically captures session +6. Usage data displays + +**Features to Test:** +- [ ] Drag widget around screen +- [ ] Refresh button updates data +- [ ] Minimize to system tray +- [ ] Right-click tray icon shows menu +- [ ] Progress bars animate smoothly +- [ ] Timers count down correctly +- [ ] Re-login from tray menu works + +### 4. Build for Production + +```bash +npm run build:win +``` + +Output: `dist/Claude-Usage-Widget-Setup.exe` + +## Development Tips + +### Enable DevTools +Already enabled in dev mode. To disable, edit `main.js`: +```javascript +if (process.env.NODE_ENV === 'development') { + // Comment out this line: + // mainWindow.webContents.openDevTools({ mode: 'detach' }); +} +``` + +### Test Without Building +```bash +npm start +``` + +### Debug Authentication +Check the console for: +- Cookie capture events +- Organization ID extraction +- API responses + +### Change Update Frequency +Edit `src/renderer/app.js`: +```javascript +const UPDATE_INTERVAL = 1 * 60 * 1000; // 1 minute for testing +``` + +### Mock API Response +For testing UI without API calls, add to `fetchUsageData()`: +```javascript +const mockData = { + five_hour: { utilization: 45.5, resets_at: "2025-12-13T20:00:00Z" }, + seven_day: { utilization: 78.2, resets_at: "2025-12-17T07:00:00Z" } +}; +updateUI(mockData); +return; +``` + +## File Structure + +``` +claude-usage-widget/ +├── main.js # Electron main process +├── preload.js # IPC bridge +├── package.json # Dependencies & build config +├── src/ +│ └── renderer/ +│ ├── index.html # Widget UI +│ ├── styles.css # Styling +│ └── app.js # Frontend logic +└── assets/ + ├── icon.ico # App icon + └── tray-icon.png # Tray icon +``` + +## Common Issues + +### Port Already in Use +Electron doesn't use ports, so this shouldn't happen. + +### White Screen on Launch +Check console for errors. Usually means: +- Missing file paths +- JavaScript errors in app.js +- CSS not loading + +### Login Window Not Capturing Session +Check `main.js` - the `did-finish-load` event handler should: +1. Check URL contains 'chat' or 'new' +2. Extract sessionKey cookie +3. Try to get organization ID + +### API Returns 401 +Session expired. Click "Re-login" from tray menu. + +## Adding Features + +### Custom Themes +Edit `styles.css` - change gradient colors: +```css +.widget-container { + background: linear-gradient(135deg, #your-color 0%, #another-color 100%); +} +``` + +### Notification Alerts +Add to `updateUI()` in `app.js`: +```javascript +if (weeklyUtilization >= 90) { + new Notification('Claude Usage Alert', { + body: 'You\'re at 90% of weekly limit!' + }); +} +``` + +### Keyboard Shortcuts +Add to `main.js`: +```javascript +const { globalShortcut } = require('electron'); + +app.whenReady().then(() => { + globalShortcut.register('CommandOrControl+Shift+C', () => { + if (mainWindow) { + mainWindow.isVisible() ? mainWindow.hide() : mainWindow.show(); + } + }); +}); +``` + +## Debugging + +### Console Logs +- Main process: Check terminal where you ran `npm start` +- Renderer process: Check DevTools console (F12) + +### Network Requests +DevTools → Network tab shows all API calls + +### Storage +Check stored credentials: +```javascript +// In DevTools console: +await window.electronAPI.getCredentials() +``` + +## Publishing + +1. Update version in `package.json` +2. Run `npm run build:win` +3. Test the installer in `dist/` +4. Create GitHub release +5. Upload the `.exe` file + +## Next Steps + +- [ ] Add app icon (`.ico` file) +- [ ] Add tray icon (16x16 PNG) +- [ ] Test on clean Windows machine +- [ ] Create installer screenshots +- [ ] Write changelog +- [ ] Submit to releases + +--- + +Happy coding! 🚀 diff --git a/README.md b/README.md new file mode 100644 index 0000000..cd4fbeb --- /dev/null +++ b/README.md @@ -0,0 +1,188 @@ +# Claude Usage Widget + +A beautiful, standalone Windows desktop widget that displays your Claude.ai usage statistics in real-time. + +![Claude Usage Widget](screenshot.png) + +## Features + +- 🎯 **Real-time Usage Tracking** - Monitor both session and weekly usage limits +- 📊 **Visual Progress Bars** - Clean, gradient progress indicators +- ⏱️ **Countdown Timers** - Circular timers showing time until reset +- 🔄 **Auto-refresh** - Updates every 5 minutes automatically +- 🎨 **Modern UI** - Sleek, draggable widget with dark theme +- 🔒 **Secure** - Encrypted credential storage +- 📍 **Always on Top** - Stays visible across all workspaces +- 💾 **System Tray** - Minimizes to tray for easy access + +## Installation + +### Download Pre-built Release +1. Download the latest `Claude-Usage-Widget-Setup.exe` from [Releases](releases) +2. Run the installer +3. Launch "Claude Usage Widget" from Start Menu + +### Build from Source + +**Prerequisites:** +- Node.js 18+ ([Download](https://nodejs.org)) +- npm (comes with Node.js) + +**Steps:** + +```bash +# Clone the repository +git clone https://github.com/yourusername/claude-usage-widget.git +cd claude-usage-widget + +# Install dependencies +npm install + +# Run in development mode +npm start + +# Build installer for Windows +npm run build:win +``` + +The installer will be created in the `dist/` folder. + +## Usage + +### First Launch + +1. Launch the widget +2. Click "Login to Claude" when prompted +3. A browser window will open - login to your Claude.ai account +4. The widget will automatically capture your session +5. Usage data will start displaying immediately + +### Widget Controls + +- **Drag** - Click and drag the title bar to move the widget +- **Refresh** - Click the refresh icon to update data immediately +- **Minimize** - Click the minus icon to hide to system tray +- **Close** - Click the X to minimize to tray (doesn't exit) + +### System Tray Menu + +Right-click the tray icon for: +- Show/Hide widget +- Refresh usage data +- Re-login (if session expires) +- Settings (coming soon) +- Exit application + +## Understanding the Display + +### Current Session +- **Progress Bar** - Shows usage from 0-100% +- **Timer** - Time remaining until 5-hour session resets +- **Color Coding**: + - Purple: Normal usage (0-74%) + - Orange: High usage (75-89%) + - Red: Critical usage (90-100%) + +### Weekly Limit +- **Progress Bar** - Shows weekly usage from 0-100% +- **Timer** - Time remaining until weekly reset (Wednesdays 7:00 AM) +- **Same color coding** as session usage + +## Configuration + +### Auto-start on Windows Boot + +1. Press `Win + R` +2. Type `shell:startup` and press Enter +3. Create a shortcut to the widget executable in this folder + +### Custom Refresh Interval + +Edit `src/renderer/app.js`: +```javascript +const UPDATE_INTERVAL = 5 * 60 * 1000; // Change to your preference (in milliseconds) +``` + +## Troubleshooting + +### "Login Required" keeps appearing +- Your Claude.ai session may have expired +- Click "Login to Claude" to re-authenticate +- Check that you're logging into the correct account + +### Widget not updating +- Check your internet connection +- Click the refresh button manually +- Ensure Claude.ai is accessible in your region +- Try re-logging in from the system tray menu + +### Widget position not saving +- Position is reset on each launch +- Future version will include position memory + +### Build errors +```bash +# Clear cache and reinstall +rm -rf node_modules package-lock.json +npm install +``` + +## Privacy & Security + +- Your session credentials are stored **locally only** using encrypted storage +- No data is sent to any third-party servers +- The widget only communicates with Claude.ai official API +- Session cookies are stored using Electron's secure storage + +## Technical Details + +**Built with:** +- Electron 28.0.0 +- Pure JavaScript (no framework overhead) +- Native Node.js APIs +- electron-store for secure storage + +**API Endpoint:** +``` +https://claude.ai/api/organizations/{org_id}/usage +``` + +**Storage Location:** +``` +%APPDATA%/claude-usage-widget/config.json (encrypted) +``` + +## Roadmap + +- [ ] macOS support +- [ ] Linux support +- [ ] Custom themes +- [ ] Notification alerts at usage thresholds +- [ ] Remember window position +- [ ] Settings panel +- [ ] Usage history graphs +- [ ] Multiple account support +- [ ] Keyboard shortcuts + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +## License + +MIT License - feel free to use and modify as needed. + +## Disclaimer + +This is an unofficial tool and is not affiliated with or endorsed by Anthropic. Use at your own discretion. + +## Support + +If you encounter issues: +1. Check the [Issues](issues) page +2. Create a new issue with details about your problem +3. Include your OS version and any error messages + +--- + +Made with ❤️ for the Claude.ai community diff --git a/assets/ICONS-NEEDED.md b/assets/ICONS-NEEDED.md new file mode 100644 index 0000000..529283e --- /dev/null +++ b/assets/ICONS-NEEDED.md @@ -0,0 +1,75 @@ +# Icon Assets Required + +## What You Need + +### 1. App Icon (icon.ico) +**Location:** `assets/icon.ico` +**Requirements:** +- Format: .ico file +- Sizes: 16x16, 32x32, 48x48, 256x256 (multi-resolution) +- Design: Claude-themed logo or "C" symbol +- Style: Should match the purple/blue gradient theme + +**Quick Creation:** +- Use an online tool like https://favicon.io or https://convertio.co +- Upload a PNG (256x256) of your design +- Convert to .ico with multiple sizes + +### 2. Tray Icon (tray-icon.png) +**Location:** `assets/tray-icon.png` +**Requirements:** +- Format: PNG with transparency +- Size: 16x16 or 32x32 pixels +- Design: Simple, recognizable at small size +- Style: Monochrome or minimal color (for visibility on light/dark taskbars) + +**Tips:** +- Keep it simple - just a "C" or claude symbol +- Use white/light color for dark taskbars +- Test on both dark and light Windows themes + +## Design Suggestions + +### Color Palette (from widget) +- Primary Purple: #8b5cf6 +- Light Purple: #a78bfa +- Blue: #3b82f6 +- Background: #1e1e2e + +### Icon Ideas +1. **Letter "C"** in gradient circle +2. **Chat bubble** with "C" inside +3. **Brain/AI symbol** in purple +4. **Minimalist robot** head +5. **Abstract neural network** pattern + +## Temporary Solution + +Until you create custom icons, you can use: +- Placeholder icon.ico from any Electron template +- Or remove icon references from `main.js` temporarily + +```javascript +// In main.js, comment out: +// tray = new Tray(path.join(__dirname, 'assets/tray-icon.png')); +``` + +## Online Tools + +- **Icon Generator:** https://www.icongenerator.com +- **ICO Converter:** https://convertio.co/png-ico/ +- **Free Icons:** https://icons8.com or https://www.flaticon.com + +## Example Commands (if you have ImageMagick) + +```bash +# Create .ico from PNG +convert icon-256.png -define icon:auto-resize=256,128,96,64,48,32,16 icon.ico + +# Create tray icon +convert logo.png -resize 16x16 tray-icon.png +``` + +--- + +**Note:** The app will still work without icons, but they improve the user experience significantly! diff --git a/assets/claude-logo.svg b/assets/claude-logo.svg new file mode 100644 index 0000000..651ee17 --- /dev/null +++ b/assets/claude-logo.svg @@ -0,0 +1,20 @@ + + + + + + + + \ No newline at end of file diff --git a/assets/tray-icon.png b/assets/tray-icon.png new file mode 100644 index 0000000..d1465dc Binary files /dev/null and b/assets/tray-icon.png differ diff --git a/main.js b/main.js new file mode 100644 index 0000000..30a2416 --- /dev/null +++ b/main.js @@ -0,0 +1,362 @@ +const { app, BrowserWindow, ipcMain, Tray, Menu, session, shell } = require('electron'); +const path = require('path'); +const Store = require('electron-store'); +const axios = require('axios'); + +const store = new Store({ + encryptionKey: 'claude-widget-secure-key-2024' +}); + +let mainWindow = null; +let loginWindow = null; +let tray = null; + +// Window configuration +const WIDGET_WIDTH = 480; +const WIDGET_HEIGHT = 140; + +function createMainWindow() { + mainWindow = new BrowserWindow({ + width: WIDGET_WIDTH, + height: WIDGET_HEIGHT, + frame: false, + transparent: true, + alwaysOnTop: true, + resizable: false, + skipTaskbar: false, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + preload: path.join(__dirname, 'preload.js') + } + }); + + mainWindow.loadFile('src/renderer/index.html'); + + // Make window draggable + mainWindow.setAlwaysOnTop(true, 'floating'); + mainWindow.setVisibleOnAllWorkspaces(true); + + mainWindow.on('closed', () => { + mainWindow = null; + }); + + // Development tools + if (process.env.NODE_ENV === 'development') { + mainWindow.webContents.openDevTools({ mode: 'detach' }); + } +} + +function createLoginWindow() { + loginWindow = new BrowserWindow({ + width: 800, + height: 700, + parent: mainWindow, + modal: true, + webPreferences: { + nodeIntegration: false, + contextIsolation: true + } + }); + + loginWindow.loadURL('https://claude.ai'); + + let loginCheckInterval = null; + let hasLoggedIn = false; + + // Function to check login status + async function checkLoginStatus() { + if (hasLoggedIn || !loginWindow) return; + + try { + const cookies = await session.defaultSession.cookies.get({ + url: 'https://claude.ai', + name: 'sessionKey' + }); + + if (cookies.length > 0) { + const sessionKey = cookies[0].value; + console.log('Session key found, attempting to get org ID...'); + + // Fetch org ID from API + let orgId = null; + try { + const response = await axios.get('https://claude.ai/api/organizations', { + headers: { + 'Cookie': `sessionKey=${sessionKey}`, + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' + } + }); + + if (response.data && Array.isArray(response.data) && response.data.length > 0) { + orgId = response.data[0].uuid || response.data[0].id; + console.log('Org ID fetched from API:', orgId); + } + } catch (err) { + console.log('API not ready yet:', err.message); + } + + if (sessionKey && orgId) { + hasLoggedIn = true; + if (loginCheckInterval) { + clearInterval(loginCheckInterval); + loginCheckInterval = null; + } + + console.log('Sending login-success to main window...'); + store.set('sessionKey', sessionKey); + store.set('organizationId', orgId); + + if (mainWindow) { + mainWindow.webContents.send('login-success', { sessionKey, organizationId: orgId }); + console.log('login-success sent'); + } else { + console.error('mainWindow is null, cannot send login-success'); + } + + loginWindow.close(); + } + } + } catch (error) { + console.error('Error in login check:', error); + } + } + + // Check on page load + loginWindow.webContents.on('did-finish-load', async () => { + const url = loginWindow.webContents.getURL(); + console.log('Login page loaded:', url); + + if (url.includes('claude.ai')) { + await checkLoginStatus(); + } + }); + + // Also check on navigation (URL changes) + loginWindow.webContents.on('did-navigate', async (event, url) => { + console.log('Navigated to:', url); + if (url.includes('claude.ai')) { + await checkLoginStatus(); + } + }); + + // Poll periodically in case the session becomes ready without a page navigation + loginCheckInterval = setInterval(async () => { + if (!hasLoggedIn && loginWindow) { + await checkLoginStatus(); + } else if (loginCheckInterval) { + clearInterval(loginCheckInterval); + loginCheckInterval = null; + } + }, 2000); + + loginWindow.on('closed', () => { + if (loginCheckInterval) { + clearInterval(loginCheckInterval); + loginCheckInterval = null; + } + loginWindow = null; + }); +} + +function createTray() { + try { + tray = new Tray(path.join(__dirname, 'assets/tray-icon.png')); + + const contextMenu = Menu.buildFromTemplate([ + { + label: 'Show Widget', + click: () => { + if (mainWindow) { + mainWindow.show(); + } else { + createMainWindow(); + } + } + }, + { + label: 'Refresh', + click: () => { + if (mainWindow) { + mainWindow.webContents.send('refresh-usage'); + } + } + }, + { type: 'separator' }, + { + label: 'Settings', + click: () => { + // TODO: Open settings window + } + }, + { + label: 'Re-login', + click: () => { + store.delete('sessionKey'); + store.delete('organizationId'); + createLoginWindow(); + } + }, + { type: 'separator' }, + { + label: 'Exit', + click: () => { + app.quit(); + } + } + ]); + + tray.setToolTip('Claude Usage Widget'); + tray.setContextMenu(contextMenu); + + tray.on('click', () => { + if (mainWindow) { + mainWindow.isVisible() ? mainWindow.hide() : mainWindow.show(); + } + }); + } catch (error) { + console.error('Failed to create tray:', error); + } +} + +// IPC Handlers +ipcMain.handle('get-credentials', () => { + return { + sessionKey: store.get('sessionKey'), + organizationId: store.get('organizationId') + }; +}); + +ipcMain.handle('save-credentials', (event, { sessionKey, organizationId }) => { + store.set('sessionKey', sessionKey); + if (organizationId) { + store.set('organizationId', organizationId); + } + return true; +}); + +ipcMain.handle('delete-credentials', async () => { + store.delete('sessionKey'); + store.delete('organizationId'); + + // Clear the session cookie to ensure actual logout + try { + await session.defaultSession.cookies.remove('https://claude.ai', 'sessionKey'); + // Also try checking for other auth cookies or clear storage if needed + // await session.defaultSession.clearStorageData({ storages: ['cookies'] }); + } catch (error) { + console.error('Failed to clear cookies:', error); + } + + return true; +}); + +ipcMain.on('open-login', () => { + createLoginWindow(); +}); + +ipcMain.on('minimize-window', () => { + if (mainWindow) mainWindow.hide(); +}); + +ipcMain.on('close-window', () => { + app.quit(); +}); + +ipcMain.handle('get-window-position', () => { + if (mainWindow) { + return mainWindow.getBounds(); + } + return null; +}); + +ipcMain.handle('set-window-position', (event, { x, y }) => { + if (mainWindow) { + mainWindow.setPosition(x, y); + return true; + } + return false; +}); + +ipcMain.on('open-external', (event, url) => { + shell.openExternal(url); +}); + +ipcMain.handle('fetch-usage-data', async () => { + console.log('[Main] fetch-usage-data handler called'); + const sessionKey = store.get('sessionKey'); + const organizationId = store.get('organizationId'); + + console.log('[Main] Credentials:', { + hasSessionKey: !!sessionKey, + organizationId + }); + + if (!sessionKey || !organizationId) { + throw new Error('Missing credentials'); + } + + try { + console.log('[Main] Making API request to:', `https://claude.ai/api/organizations/${organizationId}/usage`); + const response = await axios.get( + `https://claude.ai/api/organizations/${organizationId}/usage`, + { + headers: { + 'Cookie': `sessionKey=${sessionKey}`, + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' + } + } + ); + console.log('[Main] API request successful, status:', response.status); + return response.data; + } catch (error) { + console.error('[Main] API request failed:', error.message); + if (error.response) { + console.error('[Main] Response status:', error.response.status); + if (error.response.status === 401 || error.response.status === 403) { + throw new Error('Unauthorized'); + } + } + throw error; + } +}); + +// App lifecycle +app.whenReady().then(() => { + createMainWindow(); + createTray(); + + // Check if we have credentials + // const hasCredentials = store.get('sessionKey') && store.get('organizationId'); + // if (!hasCredentials) { + // setTimeout(() => { + // createLoginWindow(); + // }, 1000); + // } +}); + +app.on('window-all-closed', () => { + // Don't quit on macOS + if (process.platform !== 'darwin') { + // Keep running in tray + } +}); + +app.on('activate', () => { + if (mainWindow === null) { + createMainWindow(); + } +}); + +// Prevent multiple instances +const gotTheLock = app.requestSingleInstanceLock(); +if (!gotTheLock) { + app.quit(); +} else { + app.on('second-instance', () => { + if (mainWindow) { + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.focus(); + } + }); +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f7c2040 --- /dev/null +++ b/package.json @@ -0,0 +1,50 @@ +{ + "name": "claude-usage-widget", + "version": "1.0.0", + "description": "Desktop widget for Claude.ai usage monitoring", + "main": "main.js", + "scripts": { + "start": "electron .", + "build": "electron-builder", + "build:win": "electron-builder --win", + "dev": "cross-env NODE_ENV=development electron ." + }, + "keywords": [ + "claude", + "usage", + "widget", + "electron" + ], + "author": "Your Name", + "license": "MIT", + "devDependencies": { + "electron": "^28.0.0", + "electron-builder": "^24.9.1", + "cross-env": "^7.0.3" + }, + "dependencies": { + "electron-store": "^8.1.0", + "axios": "^1.6.2" + }, + "build": { + "appId": "com.claudeusage.widget", + "productName": "Claude Usage Widget", + "win": { + "target": [ + "nsis" + ], + "icon": "assets/icon.ico" + }, + "nsis": { + "oneClick": false, + "allowToChangeInstallationDirectory": true, + "createDesktopShortcut": true, + "createStartMenuShortcut": true + }, + "files": [ + "**/*", + "!**/*.ts", + "!*.md" + ] + } +} diff --git a/preload.js b/preload.js new file mode 100644 index 0000000..cb54db6 --- /dev/null +++ b/preload.js @@ -0,0 +1,31 @@ +const { contextBridge, ipcRenderer } = require('electron'); + +// Expose protected methods that allow the renderer process to use +// the ipcRenderer without exposing the entire object +contextBridge.exposeInMainWorld('electronAPI', { + // Credentials management + getCredentials: () => ipcRenderer.invoke('get-credentials'), + saveCredentials: (credentials) => ipcRenderer.invoke('save-credentials', credentials), + deleteCredentials: () => ipcRenderer.invoke('delete-credentials'), + + // Window controls + minimizeWindow: () => ipcRenderer.send('minimize-window'), + closeWindow: () => ipcRenderer.send('close-window'), + openLogin: () => ipcRenderer.send('open-login'), + + // Window position + getWindowPosition: () => ipcRenderer.invoke('get-window-position'), + setWindowPosition: (position) => ipcRenderer.invoke('set-window-position', position), + + // Event listeners + onLoginSuccess: (callback) => { + ipcRenderer.on('login-success', (event, data) => callback(data)); + }, + onRefreshUsage: (callback) => { + ipcRenderer.on('refresh-usage', () => callback()); + }, + + // API + fetchUsageData: () => ipcRenderer.invoke('fetch-usage-data'), + openExternal: (url) => ipcRenderer.send('open-external', url) +}); diff --git a/src/renderer/app.js b/src/renderer/app.js new file mode 100644 index 0000000..c164e91 --- /dev/null +++ b/src/renderer/app.js @@ -0,0 +1,355 @@ +// Application state +let credentials = null; +let updateInterval = null; +let countdownInterval = null; +let latestUsageData = null; +const UPDATE_INTERVAL = 5 * 60 * 1000; // 5 minutes + +// DOM elements +const elements = { + loadingContainer: document.getElementById('loadingContainer'), + loginContainer: document.getElementById('loginContainer'), + mainContent: document.getElementById('mainContent'), + loginBtn: document.getElementById('loginBtn'), + refreshBtn: document.getElementById('refreshBtn'), + minimizeBtn: document.getElementById('minimizeBtn'), + closeBtn: document.getElementById('closeBtn'), + + sessionPercentage: document.getElementById('sessionPercentage'), + sessionProgress: document.getElementById('sessionProgress'), + sessionTimer: document.getElementById('sessionTimer'), + sessionTimeText: document.getElementById('sessionTimeText'), + + weeklyPercentage: document.getElementById('weeklyPercentage'), + weeklyProgress: document.getElementById('weeklyProgress'), + weeklyTimer: document.getElementById('weeklyTimer'), + weeklyTimeText: document.getElementById('weeklyTimeText'), + + settingsBtn: document.getElementById('settingsBtn'), + settingsOverlay: document.getElementById('settingsOverlay'), + closeSettingsBtn: document.getElementById('closeSettingsBtn'), + logoutBtn: document.getElementById('logoutBtn'), + coffeeBtn: document.getElementById('coffeeBtn') +}; + +// Initialize +async function init() { + setupEventListeners(); + credentials = await window.electronAPI.getCredentials(); + + if (credentials.sessionKey && credentials.organizationId) { + showMainContent(); + await fetchUsageData(); + startAutoUpdate(); + } else { + showLoginRequired(); + } +} + +// Event Listeners +function setupEventListeners() { + elements.loginBtn.addEventListener('click', () => { + window.electronAPI.openLogin(); + }); + + elements.refreshBtn.addEventListener('click', async () => { + console.log('Refresh button clicked'); + elements.refreshBtn.classList.add('spinning'); + await fetchUsageData(); + elements.refreshBtn.classList.remove('spinning'); + }); + + elements.minimizeBtn.addEventListener('click', () => { + window.electronAPI.minimizeWindow(); + }); + + elements.closeBtn.addEventListener('click', () => { + window.electronAPI.closeWindow(); // Exit application completely + }); + + // Settings calls + elements.settingsBtn.addEventListener('click', () => { + elements.settingsOverlay.style.display = 'flex'; + }); + + elements.closeSettingsBtn.addEventListener('click', () => { + elements.settingsOverlay.style.display = 'none'; + }); + + elements.logoutBtn.addEventListener('click', async () => { + await window.electronAPI.deleteCredentials(); + elements.settingsOverlay.style.display = 'none'; + showLoginRequired(); + window.electronAPI.openLogin(); + }); + + elements.coffeeBtn.addEventListener('click', () => { + window.electronAPI.openExternal('https://paypal.me/SlavomirDurej?country.x=GB&locale.x=en_GB'); + }); + + // Listen for login success + window.electronAPI.onLoginSuccess(async (data) => { + console.log('Renderer received login-success event', data); + credentials = data; + await window.electronAPI.saveCredentials(data); + console.log('Credentials saved, showing main content'); + showMainContent(); + await fetchUsageData(); + startAutoUpdate(); + }); + + // Listen for refresh requests from tray + window.electronAPI.onRefreshUsage(async () => { + await fetchUsageData(); + }); +} + +// Fetch usage data from Claude API +async function fetchUsageData() { + console.log('fetchUsageData called', { credentials }); + + if (!credentials.sessionKey || !credentials.organizationId) { + console.log('Missing credentials, showing login'); + showLoginRequired(); + return; + } + + try { + console.log('Calling electronAPI.fetchUsageData...'); + const data = await window.electronAPI.fetchUsageData(); + console.log('Received usage data:', data); + updateUI(data); + } catch (error) { + console.error('Error fetching usage data:', error); + if (error.message.includes('Unauthorized')) { + await window.electronAPI.deleteCredentials(); + showLoginRequired(); + } else { + showError('Failed to fetch usage data'); + } + } +} + +// Update UI with usage data +// Update UI with usage data +function updateUI(data) { + latestUsageData = data; + refreshTimers(); + startCountdown(); + + // Update timestamp + // Update timestamp + // const now = new Date(); + // elements.lastUpdate.textContent = `Updated ${now.toLocaleTimeString()}`; +} + +// Track if we've already triggered a refresh for expired timers +let sessionResetTriggered = false; +let weeklyResetTriggered = false; + +function refreshTimers() { + if (!latestUsageData) return; + + // Session data + const sessionUtilization = latestUsageData.five_hour?.utilization || 0; + const sessionResetsAt = latestUsageData.five_hour?.resets_at; + + // Check if session timer has expired and we need to refresh + if (sessionResetsAt) { + const sessionDiff = new Date(sessionResetsAt) - new Date(); + if (sessionDiff <= 0 && !sessionResetTriggered) { + sessionResetTriggered = true; + console.log('Session timer expired, triggering refresh...'); + // Wait a few seconds for the server to update, then refresh + setTimeout(() => { + fetchUsageData(); + }, 3000); + } else if (sessionDiff > 0) { + sessionResetTriggered = false; // Reset flag when timer is active again + } + } + + updateProgressBar( + elements.sessionProgress, + elements.sessionPercentage, + sessionUtilization + ); + + updateTimer( + elements.sessionTimer, + elements.sessionTimeText, + sessionResetsAt, + 5 * 60 // 5 hours in minutes + ); + + // Weekly data + const weeklyUtilization = latestUsageData.seven_day?.utilization || 0; + const weeklyResetsAt = latestUsageData.seven_day?.resets_at; + + // Check if weekly timer has expired and we need to refresh + if (weeklyResetsAt) { + const weeklyDiff = new Date(weeklyResetsAt) - new Date(); + if (weeklyDiff <= 0 && !weeklyResetTriggered) { + weeklyResetTriggered = true; + console.log('Weekly timer expired, triggering refresh...'); + setTimeout(() => { + fetchUsageData(); + }, 3000); + } else if (weeklyDiff > 0) { + weeklyResetTriggered = false; + } + } + + updateProgressBar( + elements.weeklyProgress, + elements.weeklyPercentage, + weeklyUtilization, + true + ); + + updateTimer( + elements.weeklyTimer, + elements.weeklyTimeText, + weeklyResetsAt, + 7 * 24 * 60 // 7 days in minutes + ); +} + +function startCountdown() { + if (countdownInterval) clearInterval(countdownInterval); + countdownInterval = setInterval(() => { + refreshTimers(); + }, 1000); +} + +// Update progress bar +function updateProgressBar(progressElement, percentageElement, value, isWeekly = false) { + const percentage = Math.min(Math.max(value, 0), 100); + + progressElement.style.width = `${percentage}%`; + percentageElement.textContent = `${Math.round(percentage)}%`; + + // Update color based on usage level + progressElement.classList.remove('warning', 'danger'); + if (percentage >= 90) { + progressElement.classList.add('danger'); + } else if (percentage >= 75) { + progressElement.classList.add('warning'); + } +} + +// Update circular timer +function updateTimer(timerElement, textElement, resetsAt, totalMinutes) { + if (!resetsAt) { + textElement.textContent = '--:--'; + timerElement.style.strokeDashoffset = 63; + return; + } + + const resetDate = new Date(resetsAt); + const now = new Date(); + const diff = resetDate - now; + + if (diff <= 0) { + textElement.textContent = 'Resetting...'; + timerElement.style.strokeDashoffset = 0; + return; + } + + // Calculate remaining time + const hours = Math.floor(diff / (1000 * 60 * 60)); + const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); + // const seconds = Math.floor((diff % (1000 * 60)) / 1000); // Optional seconds + + // Format time display + if (hours >= 24) { + const days = Math.floor(hours / 24); + const remainingHours = hours % 24; + textElement.textContent = `${days}d ${remainingHours}h`; + } else if (hours > 0) { + textElement.textContent = `${hours}h ${minutes}m`; + } else { + textElement.textContent = `${minutes}m`; + } + + // Calculate progress (elapsed percentage) + const totalMs = totalMinutes * 60 * 1000; + const elapsedMs = totalMs - diff; + const elapsedPercentage = (elapsedMs / totalMs) * 100; + + // Update circle (63 is ~2*pi*10) + const circumference = 63; + const offset = circumference - (elapsedPercentage / 100) * circumference; + timerElement.style.strokeDashoffset = offset; + + // Update color based on remaining time + timerElement.classList.remove('warning', 'danger'); + if (elapsedPercentage >= 90) { + timerElement.classList.add('danger'); + } else if (elapsedPercentage >= 75) { + timerElement.classList.add('warning'); + } +} + +// UI State Management +function showLoading() { + elements.loadingContainer.style.display = 'block'; + elements.loginContainer.style.display = 'none'; + elements.mainContent.style.display = 'none'; +} + +function showLoginRequired() { + elements.loadingContainer.style.display = 'none'; + elements.loginContainer.style.display = 'flex'; // Use flex to preserve centering + elements.mainContent.style.display = 'none'; + stopAutoUpdate(); +} + +function showMainContent() { + elements.loadingContainer.style.display = 'none'; + elements.loginContainer.style.display = 'none'; + elements.mainContent.style.display = 'block'; +} + +function showError(message) { + // TODO: Implement error notification + console.error(message); +} + +// Auto-update management +function startAutoUpdate() { + stopAutoUpdate(); + updateInterval = setInterval(() => { + fetchUsageData(); + }, UPDATE_INTERVAL); +} + +function stopAutoUpdate() { + if (updateInterval) { + clearInterval(updateInterval); + updateInterval = null; + } +} + +// Add spinning animation for refresh button +const style = document.createElement('style'); +style.textContent = ` + @keyframes spin-refresh { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } + } + + .refresh-btn.spinning svg { + animation: spin-refresh 1s linear; + } +`; +document.head.appendChild(style); + +// Start the application +init(); + +// Cleanup on unload +window.addEventListener('beforeunload', () => { + stopAutoUpdate(); + if (countdownInterval) clearInterval(countdownInterval); +}); diff --git a/src/renderer/index.html b/src/renderer/index.html new file mode 100644 index 0000000..f720ab9 --- /dev/null +++ b/src/renderer/index.html @@ -0,0 +1,126 @@ + + + + + + + Claude Usage Widget + + + + + + + +
+
+ +
+
Claude Usage
+
+ + + + +
+
+ + +
+
+

Loading usage data...

+
+ + + + + + + + + +
+
+ + + + + \ No newline at end of file diff --git a/src/renderer/styles.css b/src/renderer/styles.css new file mode 100644 index 0000000..123c594 --- /dev/null +++ b/src/renderer/styles.css @@ -0,0 +1,480 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; + outline: none; + /* Remove default focus outline */ +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + overflow: hidden; + background: transparent; + -webkit-app-region: no-drag; +} + +#app { + width: 100%; + height: 100vh; + display: flex; + align-items: center; + justify-content: center; +} + +.widget-container { + width: 100vw; + height: 100vh; + background: linear-gradient(135deg, #1e1e2e 0%, #2a2a3e 100%); + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.1); + display: flex; + flex-direction: column; +} + +/* Title Bar */ +.title-bar { + -webkit-app-region: drag; + background: rgba(0, 0, 0, 0.3); + padding: 8px 12px; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + height: 36px; +} + +.title { + color: #e0e0e0; + font-family: 'Libre Baskerville', serif; + font-size: 14px; + font-weight: 400; + letter-spacing: 0.5px; +} + +.controls { + -webkit-app-region: no-drag; + display: flex; + gap: 8px; + margin-right: -10px; +} + +.control-btn { + width: 28px; + height: 28px; + border: none; + background: rgba(255, 255, 255, 0.05); + color: #a0a0a0; + border-radius: 6px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + transition: all 0.2s ease; +} + +.control-btn:hover { + background: rgba(255, 255, 255, 0.1); + color: #ffffff; +} + +.refresh-btn svg { + display: block; +} + +.close-btn:hover { + background: #e74c3c; + color: white; +} + +/* Loading State */ +.loading-container { + padding: 60px 20px; + text-align: center; + color: #a0a0a0; +} + +.spinner { + width: 40px; + height: 40px; + border: 3px solid rgba(255, 255, 255, 0.1); + border-top-color: #8b5cf6; + border-radius: 50%; + animation: spin 1s linear infinite; + margin: 0 auto 16px; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Login State */ +.login-container { + padding: 0 16px; + flex: 1; + /* Fill remaining vertical space */ + display: flex; + align-items: center; + justify-content: center; +} + +.login-content { + display: flex; + align-items: center; + gap: 16px; + text-align: left; + width: 100%; + justify-content: space-between; +} + +.login-content svg { + color: #8b5cf6; + margin-bottom: 0; + width: 32px; + height: 32px; + flex-shrink: 0; +} + +.login-text-group { + display: flex; + flex-direction: column; + justify-content: center; + margin-right: auto; + /* Push button to right if space permits */ + padding-left: 12px; +} + +.login-content h3 { + color: #e0e0e0; + font-size: 14px; + margin-bottom: 2px; +} + +.login-content p { + color: #a0a0a0; + font-size: 11px; + margin-bottom: 0; +} + +.login-btn { + background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); + color: white; + border: none; + padding: 8px 16px; + border-radius: 6px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; + flex-shrink: 0; +} + +.login-btn:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(139, 92, 246, 0.3); +} + +/* Main Content */ +.content { + padding: 20px; +} + +.usage-section { + display: grid; + grid-template-columns: 125px 1fr 45px 100px; + /* Fixed width labels for alignment */ + align-items: center; + gap: 12px; + margin-bottom: 2px; + padding: 0 16px; + height: 32px; +} + +.usage-section:last-of-type { + margin-bottom: 0; +} + +/* usage-header was removed */ + + +/* We need to re-target the children of usage-header because display:contents strips the wrapper box */ +/* But the HTML structure is: + .usage-section > .usage-header > span, span + .usage-section > .progress-bar + .usage-section > .timer-container +*/ +/* Actually, let's just make usage-header NOT display contents, but hide it or remove it in HTML? + Better: make usage-section a Flex Row? + Or keeps Grid: + Column 1: Label + Column 2: Progress (flexible) + Column 3: Percentage + Column 4: Timer +*/ + +.usage-label { + color: #a0a0a0; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + white-space: nowrap; +} + +.usage-percentage { + color: #e0e0e0; + font-size: 13px; + font-weight: 700; + text-align: right; +} + +/* Progress Bar */ +.progress-bar { + height: 6px; + background: rgba(255, 255, 255, 0.1); + border-radius: 3px; + overflow: hidden; + margin: 0; + /* Remove margin as it is in grid */ + min-width: 80px; +} + +.progress-fill { + height: 100%; + background: linear-gradient(90deg, #8b5cf6 0%, #a78bfa 100%); + border-radius: 3px; + transition: width 0.6s ease; + position: relative; + box-shadow: 0 0 10px rgba(139, 92, 246, 0.3); +} + +/* Shimmer removed */ + +.progress-fill.weekly { + background: linear-gradient(90deg, #3b82f6 0%, #60a5fa 100%); + box-shadow: 0 0 10px rgba(59, 130, 246, 0.3); +} + + +/* Timer Container */ +.timer-container { + display: flex; + align-items: center; + justify-content: space-between; + /* Text on left, chart on right */ + gap: 8px; + /* space between text and pie chart */ + padding-left: 8px; + /* Alignment adjustment */ +} + +.mini-timer { + transform: rotate(-90deg); +} + +.timer-bg { + fill: none; + stroke: rgba(255, 255, 255, 0.1); + stroke-width: 4; +} + +.timer-progress { + fill: none; + stroke: #8b5cf6; + stroke-width: 4; + transition: stroke-dashoffset 0.6s ease; +} + +.timer-progress.weekly { + stroke: #3b82f6; +} + +.timer-text { + color: #e0e0e0; + font-size: 13px; + font-weight: 500; + font-variant-numeric: tabular-nums; + font-family: inherit; +} + +/* Settings Overlay */ +.settings-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: linear-gradient(135deg, #1e1e2e 0%, #2a2a3e 100%); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; +} + +.settings-content { + padding: 0 24px; + width: 100%; + text-align: center; + position: relative; + display: flex; + flex-direction: column; + gap: 12px; + max-width: 400px; +} + +.icon-close-settings-btn { + position: fixed; + top: 8px; + right: 8px; + background: rgba(255, 255, 255, 0.05); + border: none; + color: #a0a0a0; + font-size: 24px; + line-height: 1; + cursor: pointer; + padding: 4px; + border-radius: 6px; + transition: all 0.2s; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; +} + +.icon-close-settings-btn:hover { + color: #fff; + background: rgba(255, 255, 255, 0.1); +} + +.disclaimer { + color: #a0a0a0; + font-size: 9px; + line-height: 1.2; + margin-bottom: 12px; + white-space: nowrap; +} + +.coffee-btn { + background: linear-gradient(135deg, #FFD700 0%, #FFA500 100%); + color: #1a1a1a; + border: none; + padding: 8px 12px; + border-radius: 6px; + font-size: 11px; + font-weight: 700; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + text-decoration: none; + transition: all 0.2s; + width: 100%; +} + +.coffee-btn svg { + display: block; + flex-shrink: 0; +} + +.coffee-btn:hover { + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(255, 165, 0, 0.4); +} + +.settings-actions { + display: flex; + justify-content: center; + gap: 12px; + padding-top: 4px; +} + +.logout-btn { + background: transparent; + color: #ef4444; + border: 1px solid rgba(239, 68, 68, 0.5); + padding: 6px 12px; + border-radius: 6px; + cursor: pointer; + font-size: 11px; + font-weight: 600; + transition: all 0.2s; + width: 100%; +} + +.logout-btn:hover { + background: rgba(239, 68, 68, 0.15); + border-color: rgba(239, 68, 68, 0.7); +} + +.close-settings-btn { + background: rgba(255, 255, 255, 0.1); + color: #fff; + border: 1px solid rgba(255, 255, 255, 0.1); + padding: 6px 12px; + border-radius: 6px; + cursor: pointer; + font-size: 12px; + font-weight: 600; + transition: all 0.2s; +} + +.close-settings-btn:hover { + background: rgba(255, 255, 255, 0.2); +} + +.settings-btn { + font-size: 14px; +} + +.footer { + padding-top: 12px; + border-top: 1px solid rgba(255, 255, 255, 0.05); +} + +.last-update { + color: #666; + font-size: 11px; + display: block; + text-align: center; +} + +/* Scrollbar (if needed) */ +::-webkit-scrollbar { + width: 8px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.1); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.2); +} + +/* Warning states */ +.progress-fill.warning { + background: linear-gradient(90deg, #f59e0b 0%, #fbbf24 100%); +} + +.progress-fill.danger { + background: linear-gradient(90deg, #ef4444 0%, #f87171 100%); +} + +.timer-progress.warning { + stroke: #f59e0b; +} + +.timer-progress.danger { + stroke: #ef4444; +} \ No newline at end of file