Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ taskmaster/
│ ├── detection_loop.py # camera -> detectors -> events loop
│ ├── phone_detector.py # phone-in-frame detection
│ └── gaze_detector.py # gaze/face detection (planned)
├── setup.sh # one-shot install for Python + Electron
├── setup.sh # one-shot install for Python + Electron (macOS/Linux)
├── setup.ps1 # same, for Windows PowerShell
├── PLAN.md
└── README.md
```
Expand All @@ -81,11 +82,16 @@ taskmaster/
One command installs both the Python CV worker and the Electron app:

```bash
# macOS / Linux
./setup.sh

# Windows (PowerShell)
./setup.ps1
```

It creates the Python venv at `python/.venv` (Python 3.11), installs
`requirements.txt`, and runs `npm install` in `electron/`.
`requirements.txt`, and runs `npm install` in `electron/`. The Electron app
expects the worker at that venv path, so run setup before `npm run dev`.

<details>
<summary>Manual setup (if you prefer)</summary>
Expand Down
6 changes: 6 additions & 0 deletions electron/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
stopBrowserActivityBridge,
} from './browser-activity-bridge.ts'
import { registerIpcHandlers } from './ipc-handlers.ts'
import { stopPythonWorker } from './python-bridge.ts'



Expand Down Expand Up @@ -176,10 +177,15 @@ app.whenReady().then(() => {
})
registerIpcHandlers()
registerMiniTimerIpcHandlers()
// The CV worker is started on demand (see python-bridge.ts) when the renderer
// requests detection, not here — nothing runs while no session is active.
createWindow()
createTray()
})

// Safety net: force the worker down when the app quits, even if a consumer
// never released it, so no Python process outlives the app.
app.on('before-quit', () => {
stopPythonWorker()
stopBrowserActivityBridge()
})
13 changes: 11 additions & 2 deletions electron/src/main/ipc-handlers.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// Registers all ipcMain.handle() and ipcMain.on() listeners.
// This is the entry point for every message the renderer sends.
// This is the entry point for every message the renderer sends — start session, save settings, get history, etc.
import { ipcMain } from 'electron'
import { detectCommonWindowsApps } from './appDetection/detectCommonWindowsApps.ts'
import { requestPythonWorker, releasePythonWorker } from './python-bridge.ts'
import {
getLatestBrowserActivity,
setBrowserMonitoringActive,
Expand All @@ -15,11 +16,19 @@ export function registerIpcHandlers() {
const detectedApps = detectCommonWindowsApps()

console.log('[Taskmaster] Detected common apps:')
console.log(JSON.stringify(detectedApps, null, 2))
console.log(JSON.stringify(detectedApps, null, 2))

return detectedApps
})

// On-demand CV worker control. The renderer fires these (fire-and-forget) when
// it starts/stops wanting detection; the worker is reference-counted in
// python-bridge.ts. removeAllListeners guards against double-registration.
ipcMain.removeAllListeners('taskmaster:cv-request')
ipcMain.removeAllListeners('taskmaster:cv-release')
ipcMain.on('taskmaster:cv-request', () => requestPythonWorker())
ipcMain.on('taskmaster:cv-release', () => releasePythonWorker())

/* Renderer enables tab reporting only while a focus session is active. */
ipcMain.on('taskmaster:browser-monitoring-active', (event, isActive: boolean) => {
setBrowserMonitoringActive(isActive)
Expand Down
187 changes: 185 additions & 2 deletions electron/src/main/python-bridge.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,185 @@
// Spawns the Python CV worker as a child process, manages its lifecycle (start/restart/kill),
// and connects to its WebSocket. Forwards CV events (gaze, phone) to the rest of the app.
// Manages the Python CV worker process (the WebSocket server in python/main.py).
//
// The worker runs on demand, not for the whole life of the app: the renderer
// calls requestPythonWorker() when it wants detection (e.g. an onboarding check
// or a focus session) and releasePythonWorker() when it's done. The worker is
// started on the first request and stopped shortly after the last release, so
// no Python process runs while nothing is watching the camera.
//
// The renderer connects to the worker's WebSocket directly to stream frames, so
// this module only owns the process lifecycle, not the detection data.

import { spawn, type ChildProcess } from 'node:child_process'
import { existsSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'

const currentDir = path.dirname(fileURLToPath(import.meta.url))

// The python/ project folder: this file lives in electron/src/main/, so three
// levels up is the repo root, then into python/.
const pythonProjectDir = path.join(currentDir, '../../../python')

// Port the WebSocket server listens on (must match python/main.py).
const WORKER_PORT = 8765

// Path to the Python interpreter inside the project's virtual environment.
// The folder layout differs by OS (Windows uses Scripts/python.exe).
function venvPythonPath(): string {
const isWindows = process.platform === 'win32'
const binDir = isWindows ? 'Scripts' : 'bin'
const executable = isWindows ? 'python.exe' : 'python'
return path.join(pythonProjectDir, '.venv', binDir, executable)
}

// The running worker, or null when it isn't running.
let worker: ChildProcess | null = null

// True while we're deliberately shutting the worker down (app quit / stop), so
// the exit handler knows a crash from an intentional kill and doesn't restart.
let stopping = false

// Crash-restart bookkeeping. If the worker keeps dying immediately (e.g. a
// missing dependency), restarting forever just spins, so we cap consecutive
// fast restarts and back off. A restart that survives a while resets the count.
const RESTART_DELAY_MS = 1000
const MAX_RAPID_RESTARTS = 5
// How long the worker must stay up before we treat it as "healthy" again.
const HEALTHY_UPTIME_MS = 10_000
let rapidRestarts = 0
let restartTimer: ReturnType<typeof setTimeout> | null = null

// How many consumers (renderer detection sessions) currently want the worker.
// It runs while this is > 0 and stops shortly after it returns to 0.
let activeConsumers = 0

// Grace period before stopping after the last consumer leaves, so a quick
// pause/resume, page change, or dev double-mount doesn't kill then respawn it.
const STOP_GRACE_MS = 2000
let stopTimer: ReturnType<typeof setTimeout> | null = null

// Ask for the worker. Starts it on the first request and cancels any pending
// grace-period shutdown. Call releasePythonWorker() once for each request.
export function requestPythonWorker(): void {
activeConsumers += 1
if (stopTimer) {
clearTimeout(stopTimer)
stopTimer = null
}
startPythonWorker()
}

// Drop one request. When the last consumer leaves, stop the worker after a
// short grace period (a new request during that window cancels the stop).
export function releasePythonWorker(): void {
activeConsumers = Math.max(0, activeConsumers - 1)
if (activeConsumers > 0 || stopTimer) {
return
}
stopTimer = setTimeout(() => {
stopTimer = null
stopPythonWorker()
}, STOP_GRACE_MS)
}

// Start the CV worker. No-ops if it's already running. Internal: callers use
// requestPythonWorker() so the worker is reference-counted.
function startPythonWorker(): void {
if (worker) {
return
}
stopping = false

// The worker only runs from the project venv (Python 3.11 + the CV deps). If
// it's missing — e.g. setup was never run, or a Windows clone that skipped
// setup.ps1 — spawning would fail with a cryptic ENOENT, so flag it loudly
// and bail. Detection stays off until the user runs setup; the rest of the
// app is unaffected.
const pythonPath = venvPythonPath()
if (!existsSync(pythonPath)) {
console.error(
`[python] CV worker venv not found at ${pythonPath}\n` +
` Run ./setup.sh (macOS/Linux) or setup.ps1 (Windows) to create it.`,
)
return
}

// Equivalent to running, from the python/ folder:
// .venv/bin/python -m uvicorn main:app --port 8765
worker = spawn(
pythonPath,
['-m', 'uvicorn', 'main:app', '--port', String(WORKER_PORT)],
{ cwd: pythonProjectDir },
)

// Mirror the worker's output into the app console for debugging. uvicorn logs
// to stderr, so both streams are forwarded.
const forwardOutput = (data: Buffer) => {
console.log(`[python] ${data.toString().trim()}`)
}
worker.stdout?.on('data', forwardOutput)
worker.stderr?.on('data', forwardOutput)

// When the process stops, clear the handle. If it died on its own (not an
// intentional stop), bring it back up so detection keeps working.
const startedAt = Date.now()
worker.on('exit', (code) => {
console.log(`[python] worker exited (code ${code})`)
worker = null

if (stopping) {
return
}

// Nobody is asking for detection anymore (e.g. it crashed during the stop
// grace period), so don't resurrect a worker no one wants.
if (activeConsumers === 0) {
return
}

// A worker that ran long enough before dying is treated as a fresh failure,
// not part of a crash loop, so reset the rapid-restart counter.
if (Date.now() - startedAt >= HEALTHY_UPTIME_MS) {
rapidRestarts = 0
}

if (rapidRestarts >= MAX_RAPID_RESTARTS) {
console.error(
`[python] worker crashed ${rapidRestarts} times in a row; giving up`,
)
return
}

rapidRestarts += 1
console.log(`[python] restarting worker (attempt ${rapidRestarts})`)
restartTimer = setTimeout(startPythonWorker, RESTART_DELAY_MS)
})

// Fired when the process can't be spawned at all (e.g. the venv is missing).
worker.on('error', (error) => {
console.error('[python] failed to start worker:', error)
worker = null
})

console.log(`[python] CV worker starting on port ${WORKER_PORT}`)
}

// Stop the CV worker immediately. Used by the grace-period timer and as the
// app-quit safety net so the worker never outlives the app, regardless of how
// many consumers think they're still holding it.
export function stopPythonWorker(): void {
// Mark this as intentional so the exit handler won't restart the worker, and
// cancel any restart or grace-period stop already queued.
stopping = true
activeConsumers = 0
if (restartTimer) {
clearTimeout(restartTimer)
restartTimer = null
}
if (stopTimer) {
clearTimeout(stopTimer)
stopTimer = null
}
worker?.kill() // SIGTERM; uvicorn shuts down cleanly
worker = null
}
8 changes: 8 additions & 0 deletions electron/src/preload/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ console.log('Taskmaster preload loaded')

contextBridge.exposeInMainWorld('taskmaster', {
detectCommonApps: () => ipcRenderer.invoke('taskmaster:detect-common-apps'),

// On-demand CV worker control. request() before connecting to the worker's
// WebSocket, release() when done — each request must be paired with a release.
cv: {
request: () => ipcRenderer.send('taskmaster:cv-request'),
release: () => ipcRenderer.send('taskmaster:cv-release'),
},

openMiniTimer: () => ipcRenderer.invoke('taskmaster:mini-timer-open'),
sendMiniTimerState: (state) =>
ipcRenderer.send('taskmaster:mini-timer-state', state),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ export default function BrowserActivitySelectionStep({
}
return (
<section className="onboarding-screen focus-environment-screen">
<p className="status-pill onboarding-step-pill">Step 4</p>
<p className="status-pill onboarding-step-pill">Step 6</p>

<div className="focus-environment-layout">
<header className="focus-environment-header">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export default function DistractionOptionsStep({
}: DistractionOptionsStepProps) {
return (
<section className="onboarding-screen distraction-options-screen">
<p className="status-pill onboarding-step-pill">Step 4</p>
<p className="status-pill onboarding-step-pill">Step 7</p>
<div className="distraction-options-layout">
<header className="distraction-options-header">
<div className="onboarding-header">
Expand Down
Loading
Loading