diff --git a/.github/workflows/electron-ci.yml b/.github/workflows/electron-ci.yml new file mode 100644 index 0000000..c3f8761 --- /dev/null +++ b/.github/workflows/electron-ci.yml @@ -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 \ No newline at end of file diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000..49ced8f --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -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 . \ No newline at end of file diff --git a/.gitignore b/.gitignore index 98343bf..c77af2b 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ build/ *.pt *.pth *.onnx +*.task # Database *.sqlite diff --git a/README.md b/README.md index 58d46f5..90f3bce 100644 --- a/README.md +++ b/README.md @@ -57,11 +57,15 @@ 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 ``` @@ -69,30 +73,50 @@ taskmaster/ ## 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/`. + +
+Manual setup (if you prefer) ```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 ``` +
+ ## 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 diff --git a/electron/src/main/appDetection/detectCommonWindowsApps.ts b/electron/src/main/appDetection/detectCommonWindowsApps.ts new file mode 100644 index 0000000..a3f8412 --- /dev/null +++ b/electron/src/main/appDetection/detectCommonWindowsApps.ts @@ -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, + }, + ] + }) +} \ No newline at end of file diff --git a/electron/src/main/index.ts b/electron/src/main/index.ts index ae80b61..cc48912 100644 --- a/electron/src/main/index.ts +++ b/electron/src/main/index.ts @@ -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) @@ -32,6 +35,7 @@ function createWindow() { minWidth: 1000, minHeight: 700, webPreferences: { + preload: path.join(__dirname, '../preload/index.js'), nodeIntegration: false, contextIsolation: true, }, @@ -40,7 +44,9 @@ function createWindow() { win.loadURL('http://localhost:5173') } + app.whenReady().then(() => { + registerIpcHandlers() createWindow() createTray() }) diff --git a/electron/src/main/ipc-handlers.ts b/electron/src/main/ipc-handlers.ts index 76789c4..6d4794e 100644 --- a/electron/src/main/ipc-handlers.ts +++ b/electron/src/main/ipc-handlers.ts @@ -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. \ No newline at end of file +// 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 + }) +} \ No newline at end of file diff --git a/electron/src/preload/index.js b/electron/src/preload/index.js new file mode 100644 index 0000000..08acabf --- /dev/null +++ b/electron/src/preload/index.js @@ -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'), +}) \ No newline at end of file diff --git a/electron/src/preload/index.ts b/electron/src/preload/index.ts deleted file mode 100644 index feaa357..0000000 --- a/electron/src/preload/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -// 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. \ No newline at end of file diff --git a/electron/src/renderer/components/onboarding/BrowserActivitySelectionStep.tsx b/electron/src/renderer/components/onboarding/BrowserActivitySelectionStep.tsx new file mode 100644 index 0000000..5741011 --- /dev/null +++ b/electron/src/renderer/components/onboarding/BrowserActivitySelectionStep.tsx @@ -0,0 +1,184 @@ +/** + * Browser activity onboarding step. + * + * This screen lets the user decide which common websites should be treated as + * allowed or blocked during focus sessions. + * + * It does not detect websites yet. Later, Taskmaster can compare the active + * browser window title against the matchText values stored in the settings. + */ + +import { useFocusEnvironmentSettings } from '../../hooks/useFocusEnvironmentSettings' +import type { + BrowserActivityRule, + BrowserActivityRuleStatus, +} from '../../hooks/useFocusEnvironmentSettings' + + +type BrowserActivitySelectionStepProps = { + onBack: () => void + onContinue: () => void +} + +type BrowserActivityRuleSectionProps = { + title: string + description: string + rules: BrowserActivityRule[] + onUpdateRuleStatus: ( + ruleId: string, + status: BrowserActivityRuleStatus + ) => void +} + + + + + +/** + * Renders browser activity rules from useFocusEnvironmentSettings. + * + * The settings hook owns the data and update logic. This component only renders + * the onboarding UI and forwards toggle changes back to the hook. + */ + +function BrowserActivityRuleSection({ + title, + description, + rules, + onUpdateRuleStatus, +}: BrowserActivityRuleSectionProps) { + + return ( +
+
+

{title}

+

{description}

+
+ +
+ {rules.map((rule) => { + const isBlocked = rule.status === 'blocked' + + return ( +
+
+ {rule.label} + + {rule.description} + +
+ + +
+ ) + })} +
+
+ ) +} + +/** + * Onboarding step for configuring common browser page rules. + * + * For now, this is only static UI state. In the next step, these rules should + * be moved into useFocusEnvironmentSettings so Back/Continue can save them to + * localStorage together with the desktop app rules. + */ +export default function BrowserActivitySelectionStep({ + onBack, + onContinue, +}: BrowserActivitySelectionStepProps) { + const { + blockedBrowserActivityRules, + flexibleBrowserActivityRules, + updateBrowserActivityRuleStatus, + saveFocusEnvironmentSettings, + } = useFocusEnvironmentSettings() + + function handleBack() { + saveFocusEnvironmentSettings() + onBack() + } + + function handleContinue() { + saveFocusEnvironmentSettings() + onContinue() + } + return ( +
+

Step 4

+ +
+
+
+

+ Browser activity +

+

+ Choose which websites should count as distractions during focus + sessions. +

+
+ +

+ For the MVP, Taskmaster will estimate browser activity from the + active window title, such as “YouTube - Google Chrome”. +

+
+ +
+
+

+ These websites do not need to be installed. They are common page + patterns that Taskmaster can later match while your browser is + open. +

+
+ +
+ + + +
+
+ +
+ + + +
+
+
+ ) +} \ No newline at end of file diff --git a/electron/src/renderer/components/onboarding/OnboardingAdditionalFunctions.tsx b/electron/src/renderer/components/onboarding/OnboardingAdditionalFunctions.tsx index bebc18a..1a9595b 100644 --- a/electron/src/renderer/components/onboarding/OnboardingAdditionalFunctions.tsx +++ b/electron/src/renderer/components/onboarding/OnboardingAdditionalFunctions.tsx @@ -1,3 +1,9 @@ +/** + * Final onboarding options screen. + * + * This screen is currently a placeholder for future focus-session guardrails. + * The options shown here are not fully wired into session behavior yet. + */ type DistractionOptionsStepProps = { onBack: () => void onFinish: () => void diff --git a/electron/src/renderer/components/onboarding/OnboardingCameraSetup.tsx b/electron/src/renderer/components/onboarding/OnboardingCameraSetup.tsx index c0e9ce1..d24000a 100644 --- a/electron/src/renderer/components/onboarding/OnboardingCameraSetup.tsx +++ b/electron/src/renderer/components/onboarding/OnboardingCameraSetup.tsx @@ -1,5 +1,14 @@ +/** + * Camera setup onboarding screen. + * + * This screen lets the user preview and select the camera Taskmaster will use + * during focus sessions. + * + * The camera stream is owned by useCameraDevices. When this screen unmounts, + * the hook stops the stream so the camera turns off. + */ // === camera setup === -import { useEffect, useRef, useState, } from "react"; +import { useEffect, useRef } from "react"; import { useCameraDevices } from "../../hooks/useCameraDevices"; type CameraSetupStepProps = { @@ -33,6 +42,12 @@ export default function CameraSetupStep({ const isCameraConnected = cameraStatus === "connected"; + /** + * Attach the MediaStream from the hook to the video element. + * + * React cannot set srcObject directly through JSX, so this needs to be done + * imperatively through a ref. + */ useEffect(() => { if (videoRef.current && stream) { videoRef.current.srcObject = stream; @@ -63,8 +78,10 @@ const isCameraConnected = cameraStatus === "connected"; Camera + onUpdateAppStatus( + app.id, + event.target.checked ? 'blocked' : 'allowed' + ) + } + aria-label={`${app.status === 'blocked' ? 'Allow' : 'Block'} ${ + app.name + }`} + /> +