- server/deploy.sh: KEBOOLA_ENV_FILE -> SYNC_ENV_FILE - server/ws-gateway.service, notify-bot.service: remove Keboola from descriptions - .gitignore: generic comment for data directory - CLAUDE.md, README.md, ARCHITECTURE.md: update paths from src/adapters to connectors/ - docs/DATA_SOURCES.md: update custom connector guide to connectors/ pattern - connectors/jira/README.md: keboola-analyst -> data-analyst in config paths - dev_docs/desktop-app.md: KeboolaAnalyst -> DataAnalyst branding
9.6 KiB
Data Analyst - macOS Desktop App
Native macOS menu bar application for receiving real-time notifications from the data broker server.
Architecture
notify-runner (cron, every 5min)
|
v
bot.sock (existing) ws.sock (new)
| |
v v
Telegram API WebSocket Gateway
(existing) (ws-gateway.service)
|
nginx wss:// proxy
/ws/notifications
|
macOS App (Swift)
Notifications are delivered in parallel to both Telegram and the desktop app.
The WebSocket gateway (server/ws_gateway/) runs as a separate systemd service alongside the existing Telegram bot.
Requirements
- macOS 14.0+ (Sonoma or later)
- Xcode 16+ (for building from source)
- Active team member account (@your-domain.com Google SSO)
Building
cd macos-app/DataAnalyst
xcodebuild -scheme DataAnalyst -configuration Debug build
The built app is at:
~/Library/Developer/Xcode/DerivedData/DataAnalyst-*/Build/Products/Debug/DataAnalyst.app
To run:
open ~/Library/Developer/Xcode/DerivedData/DataAnalyst-*/Build/Products/Debug/DataAnalyst.app
Authentication Flow
- User clicks Sign In in the menu bar popover
- Browser opens
https://your-instance.example.com/desktop/link - User authenticates via Google SSO (if not already logged in)
- User clicks Authorize Desktop App
- Webapp generates a JWT token (HS256, 30-day expiry) and redirects to
data-analyst://auth?token=eyJ... - macOS app catches the custom URL scheme, stores the JWT in Keychain
- App connects to WebSocket gateway, sends
{"type":"auth","token":"..."} - Gateway validates JWT and confirms with
{"type":"auth_ok","username":"..."}
Token refresh: the app can call POST /api/desktop/refresh with Authorization: Bearer <token> before expiry. Tokens expired within 7 days are still refreshable.
WebSocket Protocol
Server: wss://your-instance.example.com/ws/notifications
Client -> Server: {"type":"auth","token":"eyJ..."}
Server -> Client: {"type":"auth_ok","username":"john"}
Server -> Client: {"type":"notification","id":"uuid","title":"Revenue Drop","message":"...","image_url":"/api/notifications/images/abc.png","script":"revenue_check","timestamp":"2026-01-30T10:00:00Z"}
Server -> Client: {"type":"ping"}
Client -> Server: {"type":"pong"}
- Heartbeat: server sends ping every 30s, disconnects after 3 missed pongs
- Auto-reconnect: exponential backoff from 1s to 30s on disconnect
- Client pong resets the missed counter by restarting the heartbeat task server-side
App Features
- Menu bar icon: bell icon with badge when unread notifications exist
- Notification list: last 50 notifications, newest first
- Detail view: full message text, chart image (if available), script name
- Native notifications: macOS notification center banners
- Open in Claude Code: button to launch terminal with Claude Code for investigation
- Settings: connection status, account info, sign out
- Persistence: notifications stored in UserDefaults between launches
- Keychain: JWT token stored securely in macOS Keychain
- Run scripts: execute notification scripts on-demand via webapp API, results arrive as WS notifications
- Logging:
os.logwith subsystemcom.dataanalyst, categoryWebSocket-- view withlog show --predicate 'subsystem == "com.dataanalyst"' --last 5m --info
Server Components
WebSocket Gateway (server/ws_gateway/)
gateway.py- main asyncio+aiohttp server with two listeners:- TCP WebSocket on
127.0.0.1:8765(proxied by nginx) - Unix socket HTTP on
/run/ws-gateway/ws.sock(internal dispatch, mode0770)
- TCP WebSocket on
auth.py- JWT validation (HS256, same secret as webapp)config.py- configuration from environment variables
Systemd service: ws-gateway.service (User=deploy, Group=data-ops)
The dispatch socket is set to 0770 after creation, allowing group members (data-ops: www-data, deploy) to connect. The RuntimeDirectoryMode=0755 controls the parent directory /run/ws-gateway/.
Desktop Auth (webapp/desktop_auth.py)
GET /desktop/link- authorization page (requires Google SSO login)POST /api/desktop/authorize- generates JWT, returns redirect URL, marks user as linkedPOST /api/desktop/refresh- refreshes existing JWTPOST /api/desktop/unlink- unlinks desktop app (requires SSO login)
Link state tracking: Desktop app link status is persisted in /data/notifications/desktop_users.json (analogous to telegram_users.json). The dashboard shows Linked/Not linked badge and an Unlink button. Link state is recorded:
- Explicitly when user authorizes via
/api/desktop/authorize - Automatically when the app makes any authenticated API call (scripts, refresh) via
require_desktop_auth()
Unlinking removes the entry from desktop_users.json but does not invalidate the existing JWT token — the app continues to work until the token expires.
Notification Images (webapp/notification_images.py)
GET /api/notifications/images/<filename>- serves chart PNGs from /tmp/
Notification Dispatch
Both server/bin/notify-runner and server/telegram_bot/bot.py dispatch notifications to the WebSocket gateway alongside Telegram delivery. The webapp API (POST /api/desktop/scripts/run) also dispatches via server/telegram_bot/dispatch.py. The dispatch is fire-and-forget - if the gateway is not running, it silently skips.
Script execution (from webapp API and Telegram bot) uses the notify-scripts helper:
sudo -u <username> /usr/local/bin/notify-scripts run <script.py>
This avoids direct filesystem access to user home directories (which are 750).
Configuration
Server Environment Variables
| Variable | Required by | Description |
|---|---|---|
DESKTOP_JWT_SECRET |
ws-gateway, webapp | Shared secret for JWT signing (HS256) |
Generate with: python -c "import secrets; print(secrets.token_hex(32))"
Must be added to GitHub Actions secrets and present in both:
/opt/data-analyst/.env(webapp reads this)/opt/data-analyst/repo/.env(ws-gateway reads this)
Nginx
WebSocket proxy location in server/webapp-nginx.conf:
location /ws/notifications {
proxy_pass http://127.0.0.1:8765/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
Project Structure
macos-app/DataAnalyst/
DataAnalyst.xcodeproj/
DataAnalyst/
App/
DataAnalystApp.swift # @main, MenuBarExtra
AppDelegate.swift # URL scheme handler (data-analyst://)
Core/
Config.swift # URLs, timeouts, keychain names
KeychainService.swift # JWT storage in Keychain
WebSocketManager.swift # URLSessionWebSocketTask + reconnect
NotificationManager.swift # UNUserNotificationCenter
NotificationStore.swift # In-memory + UserDefaults persistence
Models/
AnalystNotification.swift # Codable notification model
AuthState.swift # Auth state manager with JWT decoding
Views/
MenuBarPopover.swift # Main popover: notification list
NotificationRow.swift # List row: icon, title, time
NotificationDetail.swift # Full view with chart image
SettingsView.swift # Connection status, sign out
Info.plist # URL scheme registration
DataAnalyst.entitlements # Network client permission
Troubleshooting
"No auth token"
Token was deleted (e.g. after auth failure). Sign out and sign in again via the browser.
"Reconnecting in Xs..."
WebSocket connection lost. Check:
sudo systemctl status ws-gatewayon serversudo journalctl -u ws-gateway -ffor live logs- nginx config has the
/ws/notificationslocation block
"Auth failed"
JWT signature mismatch. Verify DESKTOP_JWT_SECRET is identical in both .env files on the server. Restart both webapp and ws-gateway after changes.
App shows green dot but notifications don't arrive
The green dot reflects the app's local state, which can become stale after a server-side WS gateway restart (e.g. during deploy). Check the actual connection:
# On server:
sudo -u deploy curl -s --unix-socket /run/ws-gateway/ws.sock http://localhost/health
# Should show {"connections": 1, "users": {"username": 1}}
If connections is 0, restart the app. Check app logs:
/usr/bin/log show --predicate 'subsystem == "com.dataanalyst"' --last 5m --info
Script runs but no notification appears
Check dispatch socket permissions and webapp logs:
# Socket must be 0770 and owned by deploy:data-ops
stat -c '%A %U:%G' /run/ws-gateway/ws.sock
# Check webapp logs for dispatch errors
sudo journalctl -u webapp --since '5 min ago' | grep -i 'dispatch\|error'
Testing notifications manually
# On server, as deploy user:
sudo -u deploy curl -s --unix-socket /run/ws-gateway/ws.sock \
-X POST http://localhost/dispatch \
-H 'Content-Type: application/json' \
-d '{"user":"USERNAME","notification":{"id":"test-1","title":"Test Alert","message":"This is a test notification.","timestamp":"2026-01-30T12:00:00Z"}}'
Future Plans
- Ad-hoc code signing and notarization (Apple Developer ID)
- Sparkle framework for auto-updates
- Host
.appdownload onhttps://your-instance.example.com/download/ - Download link on webapp dashboard
- Launch at login via SMAppService