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}
+
+
+
+
+
+ onUpdateRuleStatus(
+ rule.id,
+ event.target.checked ? 'blocked' : 'allowed'
+ )
+ }
+ aria-label={`${isBlocked ? 'Allow' : 'Block'} ${rule.label}`}
+ />
+
+
+
+ )
+ })}
+
+
+ )
+}
+
+/**
+ * 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.
+
+
+
+
+
+
+
+
+
+
+
+
+ Back
+
+
+
+ Continue
+
+
+
+
+ )
+}
\ 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
selectCamera(e.target.value)}
+ onChange={(e) => selectCamera(e.target.value)}
+ disabled={cameras.length === 0}
>
{cameras.map((camera, index) => (
@@ -109,7 +126,7 @@ const isCameraConnected = cameraStatus === "connected";
Back
-
+
Continue
diff --git a/electron/src/renderer/components/onboarding/OnboardingWelcome.tsx b/electron/src/renderer/components/onboarding/OnboardingWelcome.tsx
index 56c24b8..c7a5feb 100644
--- a/electron/src/renderer/components/onboarding/OnboardingWelcome.tsx
+++ b/electron/src/renderer/components/onboarding/OnboardingWelcome.tsx
@@ -1,3 +1,9 @@
+/**
+ * First onboarding screen.
+ *
+ * This screen introduces Taskmaster and starts the setup flow. It does not own
+ * any onboarding settings, it only sends the user to the next step.
+ */
type WelcomeStepProps = {
onStartSetup: () => void
}
diff --git a/electron/src/renderer/components/onboarding/WhitelistSelectionStep.tsx b/electron/src/renderer/components/onboarding/WhitelistSelectionStep.tsx
index 1fa4964..85ae15f 100644
--- a/electron/src/renderer/components/onboarding/WhitelistSelectionStep.tsx
+++ b/electron/src/renderer/components/onboarding/WhitelistSelectionStep.tsx
@@ -1,26 +1,129 @@
+/**
+ * Desktop app whitelist step for onboarding.
+ *
+ * This screen lets the user choose which detected desktop apps should be
+ * treated as allowed or blocked during focus sessions.
+ *
+ * Important:
+ * - This file is only for installed desktop apps.
+ * - Browser tabs/websites such as YouTube, Gmail, Netflix, or ChatGPT
+ * should be handled in a separate browser activity step later.
+ * - The detection and localStorage logic lives in useFocusEnvironmentSettings.
+ */
+
+import { useFocusEnvironmentSettings } from '../../hooks/useFocusEnvironmentSettings'
+import type {
+ AppRuleStatus,
+ FocusApp,
+} from '../../hooks/useFocusEnvironmentSettings'
+
type FocusEnvironmentStepProps = {
onBack: () => void
onContinue: () => void
}
-const browserItems = [
- { label: 'Chrome: GitHub', allowed: true },
- { label: 'Chrome: YouTube', allowed: false },
- { label: 'Chrome: OnTrack', allowed: true },
-]
+type FocusAppRuleSectionProps = {
+ title: string
+ description: string
+ apps: FocusApp[]
+ onUpdateAppStatus: (appId: string, status: AppRuleStatus) => void
+}
-const appItems = [
- { label: 'VS Code', allowed: true },
- { label: 'Discord', allowed: false },
-]
+/**
+ * Renders one group of desktop app rules.
+ *
+ * Example groups:
+ * - Productivity apps
+ * - Potential distractions
+ *
+ * Each app stays in its original group, but the user can toggle whether it is
+ * currently allowed or blocked during focus sessions.
+ */
+function FocusAppRuleSection({
+ title,
+ description,
+ apps,
+ onUpdateAppStatus,
+}: FocusAppRuleSectionProps) {
+ return (
+
+
+
{title}
+
{description}
+
+
+
+ {apps.map((app) => (
+
+ {app.name}
+
+
+ onUpdateAppStatus(
+ app.id,
+ event.target.checked ? 'blocked' : 'allowed'
+ )
+ }
+ aria-label={`${app.status === 'blocked' ? 'Allow' : 'Block'} ${
+ app.name
+ }`}
+ />
+
+
+
+ ))}
+
+
+ )
+}
+
+/**
+ * Onboarding step for configuring desktop app focus rules.
+ *
+ * The user can:
+ * - Pick their main browser.
+ * - Decide whether the selected browser should be blocked entirely.
+ * - Mark detected desktop apps as allowed or blocked.
+ *
+ * Saving happens when the user presses Back or Continue.
+ */
export default function FocusEnvironmentStep({
onBack,
onContinue,
}: FocusEnvironmentStepProps) {
+ const {
+ settings,
+ browserOptions,
+ productivityApps,
+ distractionApps,
+ shouldSplitAppRules,
+ setSelectedBrowserId,
+ setBlockSelectedBrowser,
+ updateAppStatus,
+ saveFocusEnvironmentSettings,
+ } = useFocusEnvironmentSettings()
+
+ function handleBack() {
+ saveFocusEnvironmentSettings()
+ onBack()
+ }
+
+ function handleContinue() {
+ saveFocusEnvironmentSettings()
+ onContinue()
+ }
+
return (
Step 3
+
@@ -28,64 +131,83 @@ export default function FocusEnvironmentStep({
Focus environment
- Choose which apps or tabs are allowed during your deep work
- sessions.
+ Choose which apps are allowed during your deep work sessions.
+
- Taskmaster will use this list to understand when you are working and
- when you might be drifting.
+ Taskmaster checks the active app during focus sessions. Apps not
+ recognised yet will be marked as unknown and can be reviewed after
+ the session.
- Browser
-
- Chrome
- Edge
- Opera
+ Main browser
+ setSelectedBrowserId(event.target.value)}
+ >
+ {browserOptions.map((browser) => (
+
+ {browser.name}
+
+ ))}
-
- Recommended setup
-
+
+
+ setBlockSelectedBrowser(event.target.checked)
+ }
+ />
+ Block selected browser during focus sessions
+
+
+
+
+
+ Taskmaster will also learn from apps you open during sessions.
+ Unknown apps can be reviewed after each session.
+
-
-
-
Browser tabs
-
- {browserItems.map((item) => (
-
- {item.label}
-
-
- ))}
-
-
-
-
-
Apps
-
- {appItems.map((item) => (
-
- {item.label}
-
-
- ))}
-
-
+
+
+
+
-
+
Back
-
+
Continue
diff --git a/electron/src/renderer/hooks/useCameraDevices.ts b/electron/src/renderer/hooks/useCameraDevices.ts
index 35406f6..80002ce 100644
--- a/electron/src/renderer/hooks/useCameraDevices.ts
+++ b/electron/src/renderer/hooks/useCameraDevices.ts
@@ -1,5 +1,20 @@
-// detect camera devices and manage camera stream for onboarding camera setup step
-import { useEffect, useState } from "react";
+/**
+ * Camera device hook for the onboarding camera setup step.
+ *
+ * This hook is responsible for:
+ * - requesting camera permission
+ * - listing available video input devices
+ * - remembering the selected camera in localStorage
+ * - opening a preview stream for the selected camera
+ * - stopping the preview stream when the camera step unmounts
+ *
+ * Important:
+ * This hook should only be used by the camera setup screen. When that screen is
+ * no longer mounted, the cleanup effect stops the camera so the webcam light
+ * turns off.
+ */
+
+import { useCallback, useEffect, useRef, useState } from "react";
type CameraStatus =
| "checking"
@@ -8,98 +23,193 @@ type CameraStatus =
| "permission-denied"
| "error";
+const SELECTED_CAMERA_KEY = "taskmaster:selectedCameraId";
+
export function useCameraDevices() {
const [cameras, setCameras] = useState
([]);
const [selectedCameraId, setSelectedCameraId] = useState("");
const [stream, setStream] = useState(null);
const [cameraStatus, setCameraStatus] = useState("checking");
- const SELECTED_CAMERA_KEY = "taskmaster:selectedCameraId";
+ /**
+ * Keep the active stream in a ref so cleanup functions can stop the latest
+ * stream without depending on React state timing.
+ */
+ const streamRef = useRef(null);
+
+ /**
+ * Stops the currently active camera stream.
+ *
+ * This is used when:
+ * - switching from one camera to another
+ * - leaving the camera setup step
+ * - cancelling an async camera request after the component unmounts
+ */
+ const stopCurrentStream = useCallback(() => {
+ if (!streamRef.current) {
+ return;
+ }
- async function selectCamera(cameraId: string): Promise {
+ streamRef.current.getTracks().forEach((track) => track.stop());
+ streamRef.current = null;
+ setStream(null);
+ }, []);
+
+ /**
+ * Updates the selected camera and persists the choice.
+ *
+ * The actual camera stream is opened by the selectedCameraId effect below.
+ */
+ const selectCamera = useCallback((cameraId: string): void => {
setSelectedCameraId(cameraId);
localStorage.setItem(SELECTED_CAMERA_KEY, cameraId);
- }
+ }, []);
- async function loadCameras(): Promise {
- try {
- setCameraStatus("checking");
- const permissionStream = await navigator.mediaDevices.getUserMedia({
- video: true,
- audio: false,
- });
+/**
+ * On mount, request permission and load the list of available cameras.
+ *
+ * getUserMedia is called first because some browsers/Electron builds do not
+ * reveal camera labels until permission has been granted.
+ */
+useEffect(() => {
+ let isCancelled = false;
+
+ async function detectCameras() {
+ try {
+ const permissionStream =
+ await navigator.mediaDevices.getUserMedia({
+ video: true,
+ audio: false,
+ });
+
+ /**
+ * This stream is only used to unlock permission/device labels.
+ * Stop it immediately because the selected camera preview is opened in
+ * the next effect.
+ */
+ permissionStream.getTracks().forEach((track) => track.stop());
+
+ const devices = await navigator.mediaDevices.enumerateDevices();
+ const videoDevices = devices.filter(
+ (device) => device.kind === "videoinput",
+ );
+
+ if (isCancelled) {
+ return;
+ }
+
+ setCameras(videoDevices);
+
+ if (videoDevices.length === 0) {
+ setCameraStatus("no-camera");
+ return;
+ }
+
+ const savedCameraId = localStorage.getItem(SELECTED_CAMERA_KEY);
+
+ const savedCameraStillExists = videoDevices.some(
+ (camera) => camera.deviceId === savedCameraId,
+ );
+
+ const cameraIdToUse =
+ savedCameraId && savedCameraStillExists
+ ? savedCameraId
+ : videoDevices[0].deviceId;
+
+ setSelectedCameraId(cameraIdToUse);
+ localStorage.setItem(SELECTED_CAMERA_KEY, cameraIdToUse);
+ } catch (error) {
+ console.error("Error accessing cameras:", error);
+
+ if (isCancelled) {
+ return;
+ }
+
+ if (
+ error instanceof DOMException &&
+ error.name === "NotAllowedError"
+ ) {
+ setCameraStatus("permission-denied");
+ } else {
+ setCameraStatus("error");
+ }
+ }
+ }
- permissionStream.getTracks().forEach((track) => track.stop());
+ void detectCameras();
- const devices = await navigator.mediaDevices.enumerateDevices();
- const videoDevices = devices.filter(
- (device) => device.kind === "videoinput",
- );
+ return () => {
+ isCancelled = true;
+ };
+ }, []);
+
+ /**
+ * Open the preview stream whenever the selected camera changes.
+ *
+ * Leaving the camera step unmounts the component, which triggers the final
+ * cleanup effect below and turns the camera off.
+ */
+ useEffect(() => {
+ if (!selectedCameraId) {
+ return;
+ }
- setCameras(videoDevices);
+ let isCancelled = false;
- if (videoDevices.length === 0) {
- setCameraStatus("no-camera");
- return;
- }
+ async function openSelectedCamera() {
+ try {
+ stopCurrentStream();
- const savedCameraId = localStorage.getItem(SELECTED_CAMERA_KEY);
+ const newStream = await navigator.mediaDevices.getUserMedia({
+ video: { deviceId: { exact: selectedCameraId } },
+ audio: false,
+ });
- const savedCameraStillExists = videoDevices.some(
- (camera) => camera.deviceId === savedCameraId,
- );
+ if (isCancelled) {
+ newStream.getTracks().forEach((track) => track.stop());
+ return;
+ }
- if (savedCameraId && savedCameraStillExists) {
- await selectCamera(savedCameraId);
- } else {
- await selectCamera(videoDevices[0].deviceId);
- }
- } catch (error) {
- console.error("Error accessing cameras:", error);
+ streamRef.current = newStream;
+ setStream(newStream);
+ setCameraStatus("connected");
+ } catch (error) {
+ console.error("Error starting camera:", error);
- if (error instanceof DOMException && error.name === "NotAllowedError") {
- setCameraStatus("permission-denied");
- } else {
- setCameraStatus("error");
+ if (!isCancelled) {
+ setCameraStatus("error");
+ }
}
}
- }
-
- async function startCamera(deviceId: string): Promise {
- try {
- if (stream) {
- stream.getTracks().forEach((track) => track.stop());
- }
- const newStream = await navigator.mediaDevices.getUserMedia({
- video: { deviceId: { exact: deviceId } },
- audio: false,
- });
+ void openSelectedCamera();
- setStream(newStream);
- setCameraStatus("connected");
- } catch (error) {
- console.error(error);
- setCameraStatus("error");
- }
- }
+ return () => {
+ isCancelled = true;
+ };
+ }, [selectedCameraId, stopCurrentStream]);
+ /**
+ * Final unmount cleanup.
+ *
+ * This is an important part for the onboarding flow:
+ * when the user leaves the camera setup step, the preview stream stops and
+ * the webcam is released.
+ */
useEffect(() => {
- loadCameras();
+ return () => {
+ if (streamRef.current) {
+ streamRef.current.getTracks().forEach((track) => track.stop());
+ }
+ };
}, []);
- useEffect(() => {
- if (selectedCameraId) {
- startCamera(selectedCameraId);
- }
- }, [selectedCameraId]);
-
return {
cameras,
selectedCameraId,
selectCamera,
stream,
- cameraStatus,
+ cameraStatus,
};
}
diff --git a/electron/src/renderer/hooks/useFocusEnvironmentSettings.ts b/electron/src/renderer/hooks/useFocusEnvironmentSettings.ts
new file mode 100644
index 0000000..73c4bdb
--- /dev/null
+++ b/electron/src/renderer/hooks/useFocusEnvironmentSettings.ts
@@ -0,0 +1,288 @@
+/**
+ * Shared focus environment settings hook.
+ *
+ * This hook owns the onboarding settings for:
+ * - selected main browser
+ * - whether the selected browser is blocked during focus sessions
+ * - detected desktop app rules
+ * - common browser activity rules
+ *
+ * UI components should stay mostly presentational and call this hook instead
+ * of owning local copies of the settings logic.
+ */
+
+
+import { useEffect, useState } from 'react'
+import { getDefaultBrowserOptions, getDefaultFocusApps, } from '../../shared/appDetection/commonApps.ts'
+import {
+ getDefaultBrowserActivityRules,
+ type BrowserActivityRule,
+ type BrowserActivityRuleStatus,
+} from '../../shared/browserActivity/commonBrowserActivityRules.ts'
+
+export type AppCategory = 'productivity' | 'distraction'
+export type AppRuleStatus = 'allowed' | 'blocked'
+
+export type FocusApp = {
+ id: string
+ name: string
+ category: AppCategory
+ status: AppRuleStatus
+}
+
+export type BrowserOption = {
+ id: string
+ name: string
+}
+
+export type FocusEnvironmentSettings = {
+ selectedBrowserId: string
+ blockSelectedBrowser: boolean
+ appRules: FocusApp[]
+ browserActivityRules: BrowserActivityRule[]
+}
+
+export type {
+ BrowserActivityRule,
+ BrowserActivityRuleStatus,
+} from '../../shared/browserActivity/commonBrowserActivityRules.ts'
+
+type DetectedCommonApp = {
+ id: string
+ displayName: string
+ category: 'productivity' | 'distraction' | 'browser'
+ executablePath: string
+ defaultStatus: 'allowed' | 'blocked'
+}
+
+const FOCUS_ENVIRONMENT_SETTINGS_KEY = 'taskmaster:focusEnvironmentSettings'
+
+const defaultBrowserOptions: BrowserOption[] = getDefaultBrowserOptions()
+const defaultFocusApps: FocusApp[] = getDefaultFocusApps()
+const defaultBrowserActivityRules: BrowserActivityRule[] = getDefaultBrowserActivityRules()
+
+/**
+ * Creates the fallback settings used before the real app detector returns data.
+ *
+ * Desktop apps can later be replaced by detected installed apps.
+ * Browser activity rules are static defaults because websites are not installed
+ * programs.
+ */
+function createDefaultSettings(): FocusEnvironmentSettings {
+ return {
+ selectedBrowserId: defaultBrowserOptions[0]?.id ?? '',
+ blockSelectedBrowser: false,
+ appRules: defaultFocusApps,
+ browserActivityRules: defaultBrowserActivityRules,
+ }
+}
+
+
+function loadFocusEnvironmentSettings(): FocusEnvironmentSettings | null {
+ const savedSettings = localStorage.getItem(FOCUS_ENVIRONMENT_SETTINGS_KEY)
+
+ if (!savedSettings) {
+ return null
+ }
+
+ try {
+ return JSON.parse(savedSettings) as FocusEnvironmentSettings
+ } catch {
+ localStorage.removeItem(FOCUS_ENVIRONMENT_SETTINGS_KEY)
+ return null
+ }
+}
+
+
+/**
+ * Narrows detected apps to desktop app rules.
+ *
+ * Browser apps are handled separately as browser options, so this prevents
+ * TypeScript from treating the category as "browser" after filtering.
+ */
+function isDetectedFocusApp(
+ app: DetectedCommonApp
+): app is DetectedCommonApp & { category: AppCategory } {
+ return app.category === 'productivity' || app.category === 'distraction'
+}
+// ====== \\
+
+
+function convertDetectedAppsToFocusApps(
+ detectedApps: DetectedCommonApp[]
+): FocusApp[] {
+ return detectedApps.filter(isDetectedFocusApp).map((app) => ({
+ id: app.id,
+ name: app.displayName,
+ category: app.category,
+ status: app.defaultStatus,
+ }))
+}
+
+function convertDetectedAppsToBrowserOptions(
+ detectedApps: DetectedCommonApp[]
+): BrowserOption[] {
+ return detectedApps
+ .filter((app) => app.category === 'browser')
+ .map((app) => ({
+ id: app.id,
+ name: app.displayName,
+ }))
+}
+
+export function useFocusEnvironmentSettings() {
+ const [hasSavedSettings] = useState(() => {
+ return loadFocusEnvironmentSettings() !== null
+ })
+
+ const [settings, setSettings] = useState(() => {
+ return loadFocusEnvironmentSettings() ?? createDefaultSettings()
+ })
+
+ const [browserOptions, setBrowserOptions] =
+ useState(defaultBrowserOptions)
+
+
+
+ /**
+ * On first load, ask Electron main process to detect installed desktop apps.
+ *
+ * This only runs when there are no saved settings, so the user's previous
+ * allowed/blocked choices are not overwritten.
+ */
+ useEffect(() => {
+ async function loadDetectedApps() {
+ if (hasSavedSettings) {
+ return
+ }
+
+ if (!window.taskmaster?.detectCommonApps) {
+ console.warn('Taskmaster preload API is not available')
+ return
+ }
+
+ const detectedApps = await window.taskmaster.detectCommonApps()
+
+ const detectedFocusApps = convertDetectedAppsToFocusApps(detectedApps)
+ const detectedBrowserOptions =
+ convertDetectedAppsToBrowserOptions(detectedApps)
+
+ if (detectedBrowserOptions.length > 0) {
+ setBrowserOptions(detectedBrowserOptions)
+ }
+
+ setSettings((currentSettings) => ({
+ ...currentSettings,
+ selectedBrowserId:
+ detectedBrowserOptions[0]?.id ?? currentSettings.selectedBrowserId,
+ appRules:
+ detectedFocusApps.length > 0
+ ? detectedFocusApps
+ : currentSettings.appRules,
+ }))
+ }
+
+ loadDetectedApps()
+ }, [hasSavedSettings])
+ // ===== \\
+
+ /**
+ * Derived desktop app groups for the desktop app whitelist UI.
+ */
+ const productivityApps = settings.appRules.filter(
+ (app) => app.category === 'productivity'
+ )
+
+ const distractionApps = settings.appRules.filter(
+ (app) => app.category === 'distraction'
+ )
+ // ===== \\
+
+ /**
+ * Browser activity groups shown by BrowserActivitySelectionStep.
+ *
+ * These are website/page rules, not installed desktop apps.
+ * AI tools are separated because they can be productive or distracting
+ * depending on the user's work.
+ */
+ const blockedBrowserActivityRules = settings.browserActivityRules.filter(
+ (rule) => rule.id !== 'ai-tools'
+ )
+
+ const flexibleBrowserActivityRules = settings.browserActivityRules.filter(
+ (rule) => rule.id === 'ai-tools'
+ )
+
+ const shouldSplitAppRules = settings.appRules.length > 6
+ // ===== \\
+
+ /**
+ * Setting update helpers used by onboarding UI components.
+ */
+ function setSelectedBrowserId(selectedBrowserId: string) {
+ setSettings((currentSettings) => ({
+ ...currentSettings,
+ selectedBrowserId,
+ }))
+ }
+
+ function setBlockSelectedBrowser(blockSelectedBrowser: boolean) {
+ setSettings((currentSettings) => ({
+ ...currentSettings,
+ blockSelectedBrowser,
+ }))
+ }
+
+ function updateAppStatus(appId: string, status: AppRuleStatus) {
+ setSettings((currentSettings) => ({
+ ...currentSettings,
+ appRules: currentSettings.appRules.map((app) =>
+ app.id === appId
+ ? {
+ ...app,
+ status,
+ }
+ : app
+ ),
+ }))
+ }
+
+ function updateBrowserActivityRuleStatus(
+ ruleId: string,
+ status: BrowserActivityRuleStatus
+ ) {
+ setSettings((currentSettings) => ({
+ ...currentSettings,
+ browserActivityRules: currentSettings.browserActivityRules.map((rule) =>
+ rule.id === ruleId
+ ? {
+ ...rule,
+ status,
+ }
+ : rule
+ ),
+ }))
+ }
+
+ function saveFocusEnvironmentSettings() {
+ localStorage.setItem(
+ FOCUS_ENVIRONMENT_SETTINGS_KEY,
+ JSON.stringify(settings)
+ )
+ }
+
+ return {
+ settings,
+ browserOptions,
+ productivityApps,
+ distractionApps,
+ shouldSplitAppRules,
+ setSelectedBrowserId,
+ setBlockSelectedBrowser,
+ updateAppStatus,
+ saveFocusEnvironmentSettings,
+ blockedBrowserActivityRules,
+ flexibleBrowserActivityRules,
+ updateBrowserActivityRuleStatus,
+ }
+}
\ No newline at end of file
diff --git a/electron/src/renderer/index.css b/electron/src/renderer/index.css
index 4000e00..36855e3 100644
--- a/electron/src/renderer/index.css
+++ b/electron/src/renderer/index.css
@@ -751,7 +751,7 @@ a {
display: grid;
align-content: start;
gap: var(--space-md);
- padding: clamp(var(--space-lg), 2.4vw, var(--space-xl));
+ padding: clamp(var(--space-lg), 2vw, var(--space-xl));
}
.allowed-environment-panel {
@@ -1021,6 +1021,15 @@ a {
.onboarding-fixed-actions .secondary-button {
flex: 1 1 0;
}
+
+ .browser-block-toggle {
+ max-width: none;
+ }
+
+ .focus-app-rules--split {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
}
@@ -1039,4 +1048,225 @@ a {
.camera-status-dot--error {
background: var(--color-distracted);
+}
+
+
+
+
+/* app sections */
+
+.focus-app-rules {
+ display: grid;
+ gap: var(--space-md);
+}
+
+.focus-app-rules--split {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ align-items: start;
+}
+
+.focus-app-rule-section {
+ display: grid;
+ align-content: start;
+ gap: var(--space-sm);
+ padding: 0.70rem;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ background: color-mix(in srgb, var(--color-bg-elevated) 48%, transparent);
+}
+
+.focus-app-rule-section-header {
+ display: grid;
+ gap: 0.12rem;
+}
+
+.focus-app-rule-section-header h2 {
+ margin: 0;
+ color: var(--color-text-main);
+ font-size: clamp(0.92rem, 0.86vw, 1rem);
+ font-weight: 850;
+}
+
+.focus-app-rule-section-header p {
+ margin: 0;
+ color: var(--color-text-muted);
+ font-size: clamp(0.72rem, 0.66vw, 0.8rem);
+ line-height: 1.35;
+}
+
+.focus-app-rule-list {
+ display: grid;
+ gap: 0.4rem;
+}
+
+.focus-app-rule-row {
+ display: grid;
+ min-height: 2.25rem;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ gap: var(--space-sm);
+ padding: 0.30rem 0.48rem;
+ border: 1px solid color-mix(in srgb, var(--color-border) 76%, transparent);
+ border-radius: var(--radius-sm);
+ background: color-mix(in srgb, var(--color-bg-card) 66%, transparent);
+}
+
+.focus-app-rule-name {
+ overflow: hidden;
+ color: var(--color-text-main);
+ font-size: clamp(0.82rem, 0.76vw, 0.9rem);
+ font-weight: 760;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* Simple allow/block toggle */
+
+.focus-app-toggle {
+ position: relative;
+ display: inline-block;
+ width: 2.5rem;
+ height: 1.25rem;
+ border-radius: 999px;
+ cursor: pointer;
+}
+
+.focus-app-toggle input {
+ position: absolute;
+ inset: 0;
+ z-index: 2;
+ width: 100%;
+ height: 100%;
+ margin: 0;
+ opacity: 0;
+ cursor: pointer;
+}
+
+.focus-app-toggle span {
+ position: absolute;
+ inset: 0;
+ border: 1px solid color-mix(in srgb, var(--color-focused) 46%, var(--color-border));
+ border-radius: 999px;
+ background:
+ linear-gradient(
+ 90deg,
+ color-mix(in srgb, var(--color-focused) 34%, var(--color-bg-elevated)),
+ color-mix(in srgb, var(--color-focused) 18%, var(--color-bg-card))
+ );
+ transition:
+ background 220ms ease,
+ border-color 220ms ease,
+ box-shadow 220ms ease;
+}
+
+.focus-app-toggle span::before {
+ position: absolute;
+ top: 0.13rem;
+ left: 0.14rem;
+ width: 0.94rem;
+ height: 0.94rem;
+ content: "";
+ border-radius: 999px;
+ background: linear-gradient(145deg, #f6f2e8, #bfb8aa);
+ box-shadow: 0 0.22rem 0.48rem rgba(0, 0, 0, 0.36);
+ transition:
+ left 220ms cubic-bezier(0.22, 1, 0.36, 1),
+ transform 220ms cubic-bezier(0.22, 1, 0.36, 1);
+}
+
+.focus-app-toggle input:checked + span {
+ border-color: color-mix(in srgb, var(--color-distracted) 52%, var(--color-border));
+ background:
+ linear-gradient(
+ 90deg,
+ color-mix(in srgb, var(--color-distracted) 20%, var(--color-bg-card)),
+ color-mix(in srgb, var(--color-distracted) 40%, var(--color-bg-elevated))
+ );
+ box-shadow: 0 0 0.7rem color-mix(in srgb, var(--color-distracted) 14%, transparent);
+}
+
+.focus-app-toggle input:checked + span::before {
+ left: 1.32rem;
+}
+
+.focus-app-toggle input:focus-visible + span {
+ outline: 2px solid var(--color-accent-bright);
+ outline-offset: 3px;
+}
+
+
+/* browser activity rules */
+.browser-activity-rule-copy {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.browser-activity-rule-description {
+ color: var(--color-text-muted);
+ font-size: 0.82rem;
+ line-height: 1.35;
+}
+
+/* Browser activity onboarding step
+ This layout uses one column because website rules need more description text
+ than desktop app rules. */
+
+.browser-activity-panel {
+ max-width: 620px;
+}
+
+.browser-activity-rules {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.browser-activity-rule-section {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.browser-activity-rule-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.3rem;
+}
+
+.browser-activity-rule-row {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ align-items: center;
+ min-height: 64px;
+ padding: 0.70rem 0.80rem;
+ border: 1px solid var(--color-border);
+ border-radius: 0.85rem;
+ background: rgba(255, 255, 255, 0.035);
+}
+
+.browser-activity-rule-copy {
+ display: flex;
+ flex-direction: column;
+ gap: 0rem;
+ min-width: 0;
+}
+
+.browser-activity-rule-description {
+ max-width: 440px;
+ color: var(--color-text-muted);
+ font-size: 0.82rem;
+ line-height: 1.35;
+ opacity: 0.80;
+}
+
+@media (max-width: 760px) {
+ .browser-activity-panel {
+ max-width: 100%;
+ }
+
+ .browser-activity-rule-row {
+ grid-template-columns: 1fr;
+ align-items: flex-start;
+ }
}
\ No newline at end of file
diff --git a/electron/src/renderer/pages/OnboardingPage.tsx b/electron/src/renderer/pages/OnboardingPage.tsx
index c2843c1..49f9709 100644
--- a/electron/src/renderer/pages/OnboardingPage.tsx
+++ b/electron/src/renderer/pages/OnboardingPage.tsx
@@ -1,9 +1,17 @@
+/**
+ * Main onboarding flow controller.
+ *
+ * This page owns the current onboarding step, transition direction, and screen
+ * animations. Individual onboarding screens should keep their own UI focused
+ * and receive only navigation callbacks from this page.
+ */
import { useEffect, useRef, useState } from 'react'
import CameraSetupStep from '../components/onboarding/OnboardingCameraSetup'
import DistractionOptionsStep from '../components/onboarding/OnboardingAdditionalFunctions'
import FocusEnvironmentStep from '../components/onboarding/WhitelistSelectionStep'
import MenuPage from './MenuPage'
import WelcomeStep from '../components/onboarding/OnboardingWelcome'
+import BrowserActivitySelectionStep from '../components/onboarding/BrowserActivitySelectionStep'
type Direction = 'forward' | 'backward'
@@ -12,6 +20,7 @@ const lightStateByStep = [
'top-right',
'top-left',
'ambient',
+ 'ambient',
'off',
] as const
@@ -48,6 +57,7 @@ export default function OnboardingPage() {
}, 700)
}
+
function renderStep(stepToRender: number) {
if (stepToRender === 0) {
return goToStep(1)} />
@@ -73,9 +83,18 @@ export default function OnboardingPage() {
if (stepToRender === 3) {
return (
- goToStep(2)}
- onFinish={() => goToStep(4)}
+ onContinue={() => goToStep(4)}
+ />
+ )
+ }
+
+ if (stepToRender === 4) {
+ return (
+ goToStep(3)}
+ onFinish={() => goToStep(5)}
/>
)
}
diff --git a/electron/src/renderer/vite-env.d.ts b/electron/src/renderer/vite-env.d.ts
new file mode 100644
index 0000000..c33d10f
--- /dev/null
+++ b/electron/src/renderer/vite-env.d.ts
@@ -0,0 +1,19 @@
+///
+
+type DetectedCommonApp = {
+ id: string
+ displayName: string
+ category: 'productivity' | 'distraction' | 'browser'
+ executablePath: string
+ defaultStatus: 'allowed' | 'blocked'
+}
+
+declare global {
+ interface Window {
+ taskmaster: {
+ detectCommonApps: () => Promise
+ }
+ }
+}
+
+export {}
\ No newline at end of file
diff --git a/electron/src/shared/appDetection/commonApps.ts b/electron/src/shared/appDetection/commonApps.ts
new file mode 100644
index 0000000..5459bfa
--- /dev/null
+++ b/electron/src/shared/appDetection/commonApps.ts
@@ -0,0 +1,164 @@
+/**
+ * Common desktop app catalogue used by Taskmaster onboarding.
+ *
+ * These definitions describe known Windows apps that Taskmaster can try to
+ * detect on the user's computer.
+ *
+ * Browser websites/pages do not belong here. Browser activity rules live in:
+ * shared/browserActivity/commonBrowserActivityRules.ts
+ */
+
+export type CommonAppCategory = 'productivity' | 'distraction' | 'browser'
+
+export type CommonAppDefinition = {
+ id: string
+ displayName: string
+ category: CommonAppCategory
+ executableNames: string[]
+ commonWindowsPaths: string[]
+ defaultStatus: 'allowed' | 'blocked'
+}
+
+export const COMMON_APPS: CommonAppDefinition[] = [
+ {
+ id: 'vscode',
+ displayName: 'Visual Studio Code',
+ category: 'productivity',
+ executableNames: ['Code.exe'],
+ commonWindowsPaths: [
+ '%LOCALAPPDATA%\\Programs\\Microsoft VS Code\\Code.exe',
+ '%PROGRAMFILES%\\Microsoft VS Code\\Code.exe',
+ '%PROGRAMFILES(X86)%\\Microsoft VS Code\\Code.exe',
+ ],
+ defaultStatus: 'allowed',
+ },
+ {
+ id: 'windows-terminal',
+ displayName: 'Windows Terminal',
+ category: 'productivity',
+ executableNames: ['WindowsTerminal.exe', 'wt.exe'],
+ commonWindowsPaths: [
+ '%LOCALAPPDATA%\\Microsoft\\WindowsApps\\wt.exe',
+ ],
+ defaultStatus: 'allowed',
+ },
+ {
+ id: 'notion',
+ displayName: 'Notion',
+ category: 'productivity',
+ executableNames: ['Notion.exe'],
+ commonWindowsPaths: [
+ '%LOCALAPPDATA%\\Programs\\Notion\\Notion.exe',
+ ],
+ defaultStatus: 'allowed',
+ },
+ {
+ id: 'chrome',
+ displayName: 'Google Chrome',
+ category: 'browser',
+ executableNames: ['chrome.exe'],
+ commonWindowsPaths: [
+ '%PROGRAMFILES%\\Google\\Chrome\\Application\\chrome.exe',
+ '%PROGRAMFILES(X86)%\\Google\\Chrome\\Application\\chrome.exe',
+ '%LOCALAPPDATA%\\Google\\Chrome\\Application\\chrome.exe',
+ ],
+ defaultStatus: 'allowed',
+ },
+ {
+ id: 'edge',
+ displayName: 'Microsoft Edge',
+ category: 'browser',
+ executableNames: ['msedge.exe'],
+ commonWindowsPaths: [
+ '%PROGRAMFILES(X86)%\\Microsoft\\Edge\\Application\\msedge.exe',
+ '%PROGRAMFILES%\\Microsoft\\Edge\\Application\\msedge.exe',
+ ],
+ defaultStatus: 'allowed',
+ },
+ {
+ id: 'opera-gx',
+ displayName: 'Opera GX',
+ category: 'browser',
+ executableNames: ['opera.exe', 'launcher.exe'],
+ commonWindowsPaths: [
+ '%LOCALAPPDATA%\\Programs\\Opera GX\\launcher.exe',
+ '%LOCALAPPDATA%\\Programs\\Opera GX\\opera.exe',
+ ],
+ defaultStatus: 'allowed',
+ },
+ {
+ id: 'discord',
+ displayName: 'Discord',
+ category: 'distraction',
+ executableNames: ['Discord.exe'],
+ commonWindowsPaths: [
+ '%LOCALAPPDATA%\\Discord\\Update.exe',
+ '%LOCALAPPDATA%\\Discord\\app-*\\Discord.exe',
+ ],
+ defaultStatus: 'blocked',
+ },
+ {
+ id: 'spotify',
+ displayName: 'Spotify',
+ category: 'distraction',
+ executableNames: ['Spotify.exe'],
+ commonWindowsPaths: [
+ '%APPDATA%\\Spotify\\Spotify.exe',
+ '%LOCALAPPDATA%\\Microsoft\\WindowsApps\\Spotify.exe',
+ ],
+ defaultStatus: 'blocked',
+ },
+ {
+ id: 'steam',
+ displayName: 'Steam',
+ category: 'distraction',
+ executableNames: ['steam.exe'],
+ commonWindowsPaths: [
+ '%PROGRAMFILES(X86)%\\Steam\\steam.exe',
+ '%PROGRAMFILES%\\Steam\\steam.exe',
+ ],
+ defaultStatus: 'blocked',
+ },
+]
+
+
+/**
+ * Converts the common app catalogue into the desktop app rules shown during
+ * onboarding before real detection results are available.
+ */
+export type DefaultFocusApp = {
+ id: string
+ name: string
+ category: 'productivity' | 'distraction'
+ status: 'allowed' | 'blocked'
+}
+
+export type DefaultBrowserOption = {
+ id: string
+ name: string
+}
+
+export function getDefaultFocusApps(): DefaultFocusApp[] {
+ return COMMON_APPS
+ .filter((app) => app.category !== 'browser')
+ .map((app) => ({
+ id: app.id,
+ name: app.displayName,
+ category: app.category as 'productivity' | 'distraction',
+ status: app.defaultStatus,
+ }))
+}
+
+
+/**
+ * Converts detected/common browser apps into options for the main browser
+ * dropdown in onboarding.
+ */
+export function getDefaultBrowserOptions(): DefaultBrowserOption[] {
+ return COMMON_APPS
+ .filter((app) => app.category === 'browser')
+ .map((app) => ({
+ id: app.id,
+ name: app.displayName,
+ }))
+}
\ No newline at end of file
diff --git a/electron/src/shared/browserActivity/commonBrowserActivityRules.ts b/electron/src/shared/browserActivity/commonBrowserActivityRules.ts
new file mode 100644
index 0000000..cb6f8ce
--- /dev/null
+++ b/electron/src/shared/browserActivity/commonBrowserActivityRules.ts
@@ -0,0 +1,112 @@
+/**
+ * Common browser activity rules used during onboarding.
+ *
+ * These rules are not installed programs. They are common website/page patterns
+ * that Taskmaster can later match against the active browser window title.
+ *
+ * Example:
+ * - Active window title: "YouTube - Google Chrome"
+ * - Rule matchText: ["youtube"]
+ *
+ * Later, a browser extension can replace this weak title-matching approach
+ * with accurate tab URL detection.
+ */
+
+export type BrowserActivityRuleStatus = 'allowed' | 'blocked' | 'ignored'
+
+export type BrowserActivityCategory =
+ | 'entertainment'
+ | 'music'
+ | 'messaging'
+ | 'communication'
+ | 'ai'
+ | 'social'
+ | 'shopping'
+
+export type BrowserActivityRule = {
+ id: string
+ label: string
+ description: string
+ matchText: string[]
+ category: BrowserActivityCategory
+ status: BrowserActivityRuleStatus
+}
+
+export const COMMON_BROWSER_ACTIVITY_RULES: BrowserActivityRule[] = [
+ {
+ id: 'youtube',
+ label: 'YouTube',
+ description: 'Videos, recommendations, shorts, and general browsing.',
+ matchText: ['youtube'],
+ category: 'entertainment',
+ status: 'blocked',
+ },
+ {
+ id: 'youtube-music',
+ label: 'YouTube Music',
+ description: 'Music streaming through YouTube Music.',
+ matchText: ['music.youtube', 'youtube music'],
+ category: 'music',
+ status: 'blocked',
+ },
+ {
+ id: 'spotify-web',
+ label: 'Spotify Web',
+ description: 'Spotify in the browser.',
+ matchText: ['spotify'],
+ category: 'music',
+ status: 'blocked',
+ },
+ {
+ id: 'whatsapp-web',
+ label: 'WhatsApp Web',
+ description: 'Messaging through WhatsApp in the browser.',
+ matchText: ['whatsapp'],
+ category: 'messaging',
+ status: 'blocked',
+ },
+ {
+ id: 'email',
+ label: 'Email',
+ description: 'Gmail, Outlook, Yahoo Mail, Proton Mail, and similar inboxes.',
+ matchText: ['gmail', 'outlook', 'yahoo mail', 'proton mail'],
+ category: 'communication',
+ status: 'blocked',
+ },
+ {
+ id: 'streaming',
+ label: 'Streaming services',
+ description: 'Netflix, Disney+, Prime Video, Crunchyroll, Apple TV, etc.',
+ matchText: ['netflix', 'disney+', 'prime video', 'crunchyroll', 'apple tv'],
+ category: 'entertainment',
+ status: 'blocked',
+ },
+ {
+ id: 'ai-tools',
+ label: 'AI tools',
+ description: 'ChatGPT, Claude, Gemini, Perplexity, and similar tools.',
+ matchText: ['chatgpt', 'claude', 'gemini', 'perplexity'],
+ category: 'ai',
+ status: 'allowed',
+ },
+ {
+ id: 'social-media',
+ label: 'Social media',
+ description: 'Instagram, TikTok, Facebook, Reddit, X, and similar sites.',
+ matchText: ['instagram', 'tiktok', 'facebook', 'reddit', 'x.com'],
+ category: 'social',
+ status: 'blocked',
+ },
+ {
+ id: 'shopping',
+ label: 'Shopping',
+ description: 'Amazon, eBay, AliExpress, marketplace browsing, and similar.',
+ matchText: ['amazon', 'ebay', 'aliexpress', 'marketplace'],
+ category: 'shopping',
+ status: 'blocked',
+ },
+]
+
+export function getDefaultBrowserActivityRules(): BrowserActivityRule[] {
+ return COMMON_BROWSER_ACTIVITY_RULES
+}
\ No newline at end of file
diff --git a/electron/src/shared/protocol.ts b/electron/src/shared/protocol.ts
deleted file mode 100644
index 9f37611..0000000
--- a/electron/src/shared/protocol.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-// TypeScript types for IPC messages (renderer ↔ main) and WebSocket events (main ↔ Python).
-// Single source of truth so neither side drifts out of sync.
\ No newline at end of file
diff --git a/python/cv/phone_detect_test.py b/python/cv/phone_detect_test.py
index fe2d3c4..a939b98 100644
--- a/python/cv/phone_detect_test.py
+++ b/python/cv/phone_detect_test.py
@@ -13,7 +13,7 @@
Press 'q' (video window focused) or Ctrl+C to quit.
"""
-import cv2
+import cv2
import phone_detector
diff --git a/python/docker_README.md b/python/docker_README.md
new file mode 100644
index 0000000..c1fcacd
--- /dev/null
+++ b/python/docker_README.md
@@ -0,0 +1,121 @@
+# Taskmaster CV Worker
+
+Computer vision worker responsible for phone detection and future focus monitoring features.
+
+## Option 1: Docker (recommended)
+
+No local Python setup required.
+
+Build the image:
+
+```bash
+docker build -t taskmaster-cv-worker ./python
+```
+
+Run image-based detection:
+
+```bash
+docker run --rm taskmaster-cv-worker python cv/phone_image_test.py test_assets/phone_sample.jpg
+```
+
+Expected output:
+
+```text
+{'type': 'phone', 'status': 'detected', ...}
+```
+
+This uses a sample image and does not require a webcam.
+
+---
+
+## Option 2: Local development
+
+Recommended for webcam testing.
+
+Create a virtual environment:
+
+```bash
+cd python
+python3.11 -m venv .venv
+```
+
+Activate it:
+
+Linux/macOS:
+
+```bash
+source .venv/bin/activate
+```
+
+Windows:
+
+```powershell
+.\.venv\Scripts\Activate.ps1
+```
+
+Install dependencies:
+
+```bash
+pip install -r requirements.txt
+```
+
+Download the model:
+
+```bash
+./setup.sh
+```
+
+Run webcam test:
+
+```bash
+python cv/phone_detect_test.py
+```
+
+Run detection loop:
+
+```bash
+python cv/detection_loop.py
+```
+
+---
+
+## Install Docker
+
+### Windows / macOS
+
+Download Docker Desktop:
+
+https://www.docker.com/products/docker-desktop/
+
+Verify installation:
+
+```bash
+docker --version
+docker run hello-world
+```
+
+### Ubuntu
+
+```bash
+sudo apt update
+sudo apt install -y docker.io
+sudo systemctl enable docker
+sudo systemctl start docker
+```
+
+Verify installation:
+
+```bash
+docker --version
+docker run hello-world
+```
+
+---
+
+## Notes
+
+- Python 3.11 is required.
+- The YOLOX-S model is not committed to Git.
+- Docker downloads the model during image build.
+- Docker is intended for environment consistency and automated testing.
+- Webcam testing is currently easier to perform locally.
\ No newline at end of file