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
41 changes: 41 additions & 0 deletions .github/workflows/electron-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: Electron CI

on:
push:
paths:
- "electron/**"
- ".github/workflows/electron-ci.yml"

pull_request:
paths:
- "electron/**"
- ".github/workflows/electron-ci.yml"

jobs:
electron-checks:
name: Install, link, and build Electron app
runs-on: ubuntu-latest

defaults:
run:
working-directory: electron

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: electron/package-lock.json

- name: Install dependencies
run: npm ci

- name: Run lint
run: npm run lint

- name: Build Electron renderer
run: npm run build
55 changes: 55 additions & 0 deletions .github/workflows/python-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
name: Python CI

on:
push:
paths:
- "python/**"
- ".github/workflows/python-ci.yml"

pull_request:
paths:
- "python/**"
- ".github/workflows/python-ci.yml"


jobs:
python-checks:
name: Install and import-check Python CV worker
runs-on: ubuntu-latest

defaults:
run:
working-directory: python

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup Python 3.11
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: pip
cache-dependency-path: python/requirements.txt

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt

- name: Check Python imports
run: |
python - <<'PY'
import cv2
import numpy
import onnxruntime
import fastapi
import uvicorn
import websockets

print("Python CV dependencies imported successfully")
PY

- name: Check project modules compile
run: |
python -m compileall .
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ build/
*.pt
*.pth
*.onnx
*.task

# Database
*.sqlite
Expand Down
46 changes: 35 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,42 +57,66 @@ taskmaster/
│ └── preload/
│ └── index.ts
├── python/
│ ├── README.md # CV worker docs
│ ├── requirements.txt # Python deps (installed by setup.sh)
│ ├── main.py # FastAPI + WebSocket server
│ └── cv/
│ ├── camera.py
│ ├── gaze_detector.py
│ └── phone_detector.py
│ ├── camera.py # webcam capture (owns the camera handle)
│ ├── 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
├── PLAN.md
└── README.md
```

## Prerequisites

- Node.js >= 18
- Python >= 3.10
- **Python 3.11** (MediaPipe has no wheels for 3.13/3.14 yet)
- A webcam

## Setup

### Python backend
One command installs both the Python CV worker and the Electron app:

```bash
cd python
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
./setup.sh
```

### Electron app
It creates the Python venv at `python/.venv` (Python 3.11), installs
`requirements.txt`, and runs `npm install` in `electron/`.

<details>
<summary>Manual setup (if you prefer)</summary>

```bash
# Python CV worker
cd python
python3.11 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
cd ..

# Electron app
cd electron
npm install
```

</details>

## Development

Start the Python CV server:
Run the CV detection loop directly (current entry point while the
WebSocket server is being built):

```bash
cd python
source .venv/bin/activate
python cv/detection_loop.py # Ctrl+C to stop
```

Later, the FastAPI + WebSocket server will be the entry point instead:

```bash
cd python
Expand Down
148 changes: 148 additions & 0 deletions electron/src/main/appDetection/detectCommonWindowsApps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// This file needs to be on main as we are using node APIs to detect if common apps are installed on the user's system. We will likely need to expand this in the future to support more apps and other platforms, but for now we are just focusing on a few common Windows apps.
import fs from 'node:fs'
import path from 'node:path'
import { COMMON_APPS } from "../../shared/appDetection/commonApps.ts"

export type DetectedWindowsApp = {
id: string
displayName: string
category: 'productivity' | 'distraction' | 'browser'
executablePath: string
defaultStatus: 'allowed' | 'blocked'
}

function expandWindowsEnvironmentPath(rawPath: string) {
return rawPath.replace(/%([^%]+)%/g, (_, variableName: string) => {
return process.env[variableName] ?? ''
})
}

function pathHasWildcard(filePath: string) {
return filePath.includes('*')
}

function findWildcardPath(filePath: string) {
const normalizedPath = path.normalize(filePath)
const wildcardIndex = normalizedPath.indexOf('*')

if (wildcardIndex === -1) {
return fs.existsSync(normalizedPath) ? normalizedPath : null
}

const beforeWildcard = normalizedPath.slice(0, wildcardIndex)
const afterWildcard = normalizedPath.slice(wildcardIndex + 1)

const baseDirectory = path.dirname(beforeWildcard)
const prefix = path.basename(beforeWildcard)

try {
if (!fs.existsSync(baseDirectory)) {
return null
}

const entries = fs.readdirSync(baseDirectory, {
withFileTypes: true,
})

const matchedDirectory = entries.find((entry) => {
return entry.isDirectory() && entry.name.startsWith(prefix)
})

if (!matchedDirectory) {
return null
}

const possiblePath = path.join(
baseDirectory,
matchedDirectory.name,
afterWildcard
)

return fs.existsSync(possiblePath) ? possiblePath : null
} catch (error) {
console.warn('[Taskmaster] Could not scan wildcard path:', {
filePath,
baseDirectory,
error,
})

return null
}
}

function findExistingAppPath(commonWindowsPaths: string[]) {
for (const rawPath of commonWindowsPaths) {
const expandedPath = expandWindowsEnvironmentPath(rawPath)

console.log('[Taskmaster] Checking path:', {
rawPath,
expandedPath,
})

if (!expandedPath) {
continue
}

if (pathHasWildcard(expandedPath)) {
const matchedPath = findWildcardPath(expandedPath)

// --- debug log ---
console.log('[Taskmaster] Wildcard path result:', {
expandedPath,
matchedPath,
})
// --- remove later ---

if (matchedPath) {
return matchedPath
}

continue
}

const normalizedPath = path.normalize(expandedPath)
try {
const exists = fs.existsSync(normalizedPath)

console.log('[Taskmaster] Path exists result:', {
normalizedPath,
exists,
})

if (exists) {
return normalizedPath
}
} catch (error) {
console.warn('[Taskmaster] Could not check path:', {
normalizedPath,
error,
})
}
}

return null
}

export function detectCommonWindowsApps(): DetectedWindowsApp[] {
if (process.platform !== 'win32') {
return []
}

return COMMON_APPS.flatMap((app) => {
const executablePath = findExistingAppPath(app.commonWindowsPaths)

if (!executablePath) {
return []
}

return [
{
id: app.id,
displayName: app.displayName,
category: app.category,
executablePath,
defaultStatus: app.defaultStatus,
},
]
})
}
6 changes: 6 additions & 0 deletions electron/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
import { app, BrowserWindow, Tray, Menu, nativeImage } from 'electron'
import path from 'path'
import { fileURLToPath } from 'url'
import { registerIpcHandlers } from './ipc-handlers.ts'



const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
Expand Down Expand Up @@ -32,6 +35,7 @@ function createWindow() {
minWidth: 1000,
minHeight: 700,
webPreferences: {
preload: path.join(__dirname, '../preload/index.js'),
nodeIntegration: false,
contextIsolation: true,
},
Expand All @@ -40,7 +44,9 @@ function createWindow() {
win.loadURL('http://localhost:5173')
}


app.whenReady().then(() => {
registerIpcHandlers()
createWindow()
createTray()
})
17 changes: 16 additions & 1 deletion electron/src/main/ipc-handlers.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,17 @@
// Registers all ipcMain.handle() and ipcMain.on() listeners.
// This is the entry point for every message the renderer sends — start session, save settings, get history, etc.
// This is the entry point for every message the renderer sends — start session, save settings, get history, etc.import { ipcMain } from 'electron'
import { ipcMain } from 'electron'
import { detectCommonWindowsApps } from './appDetection/detectCommonWindowsApps.ts'

export function registerIpcHandlers() {
ipcMain.removeHandler('taskmaster:detect-common-apps')

ipcMain.handle('taskmaster:detect-common-apps', () => {
const detectedApps = detectCommonWindowsApps()

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

return detectedApps
})
}
11 changes: 11 additions & 0 deletions electron/src/preload/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Uses contextBridge.exposeInMainWorld() to give the renderer a safe, limited API.
// Example: window.taskmaster.startSession().
// The renderer can never call Node directly - everything goes through here.

const { contextBridge, ipcRenderer } = require('electron')

console.log('Taskmaster preload loaded')

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