Skip to content
Open
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
1,306 changes: 1,293 additions & 13 deletions electron/package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions electron/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"preview": "vite preview"
},
"dependencies": {
"active-win": "^8.2.1",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-router-dom": "^7.14.2"
Expand Down
93 changes: 91 additions & 2 deletions electron/src/main/activity-monitor.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,91 @@
// Polls the active OS window using active-win library.
// Checks the current app against the user's whitelist and emits events when a disallowed app is focused during a session.
// Polls the active OS window while a focus session is active.
// The renderer classifies these raw snapshots against saved focus rules.

import { createRequire } from 'node:module'
import path from 'node:path'
import type { DesktopActivityPayload } from '../shared/focusMonitoring.ts'

type ActiveWindowModule = typeof import('active-win')
type DesktopActivityListener = (activity: DesktopActivityPayload) => void

const require = createRequire(import.meta.url)
const activeWindow = require('active-win') as ActiveWindowModule
const POLL_INTERVAL_MS = 1000

let pollTimer: NodeJS.Timeout | null = null
let isPolling = false
let latestActivity: DesktopActivityPayload | null = null
let notifyRenderer: DesktopActivityListener | null = null

/* Starts active-window polling and immediately emits the first snapshot. */
export function startDesktopActivityMonitoring(onActivity: DesktopActivityListener) {
notifyRenderer = onActivity
clearPollTimer()
void pollActiveWindow()
pollTimer = setInterval(() => {
void pollActiveWindow()
}, POLL_INTERVAL_MS)
}

/* Pauses polling while leaving the last snapshot available to the renderer. */
export function pauseDesktopActivityMonitoring() {
clearPollTimer()
}

/* Stops polling and clears the last in-memory desktop snapshot. */
export function stopDesktopActivityMonitoring() {
clearPollTimer()
latestActivity = null
}

export function getLatestDesktopActivity() {
return latestActivity
}

async function pollActiveWindow() {
if (isPolling) {
return
}

isPolling = true

try {
const focusedWindow = await activeWindow()

if (!focusedWindow) {
return
}

latestActivity = {
appName: focusedWindow.owner.name || 'Unknown app',
processName: getProcessName(
focusedWindow.owner.path,
focusedWindow.owner.name,
),
windowTitle: focusedWindow.title || 'Untitled window',
timestamp: Date.now(),
}
notifyRenderer?.(latestActivity)
} catch (error) {
console.error('[Taskmaster] Could not read active window:', error)
} finally {
isPolling = false
}
}

function clearPollTimer() {
if (!pollTimer) {
return
}

clearInterval(pollTimer)
pollTimer = null
}

function getProcessName(executablePath: string | undefined, fallbackName: string) {
if (!executablePath) {
return fallbackName || 'Unknown process'
}

return path.basename(executablePath)
}
33 changes: 33 additions & 0 deletions electron/src/main/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
import { ipcMain } from 'electron'
import { detectCommonWindowsApps } from './appDetection/detectCommonWindowsApps.ts'
import { requestPythonWorker, releasePythonWorker } from './python-bridge.ts'
import {
getLatestDesktopActivity,
pauseDesktopActivityMonitoring,
startDesktopActivityMonitoring,
stopDesktopActivityMonitoring,
} from './activity-monitor.ts'
import {
getLatestBrowserActivity,
setBrowserMonitoringActive,
Expand All @@ -11,6 +17,9 @@ import {
export function registerIpcHandlers() {
ipcMain.removeHandler('taskmaster:detect-common-apps')
ipcMain.removeAllListeners('taskmaster:browser-monitoring-active')
ipcMain.removeAllListeners('taskmaster:desktop-monitoring-start')
ipcMain.removeAllListeners('taskmaster:desktop-monitoring-pause')
ipcMain.removeAllListeners('taskmaster:desktop-monitoring-stop')

ipcMain.handle('taskmaster:detect-common-apps', () => {
const detectedApps = detectCommonWindowsApps()
Expand Down Expand Up @@ -41,4 +50,28 @@ export function registerIpcHandlers() {
}
}
})

ipcMain.on('taskmaster:desktop-monitoring-start', (event) => {
const sender = event.sender

startDesktopActivityMonitoring((activity) => {
if (!sender.isDestroyed()) {
sender.send('taskmaster:desktop-activity', activity)
}
})

const latestActivity = getLatestDesktopActivity()

if (latestActivity) {
sender.send('taskmaster:desktop-activity', latestActivity)
}
})

ipcMain.on('taskmaster:desktop-monitoring-pause', () => {
pauseDesktopActivityMonitoring()
})

ipcMain.on('taskmaster:desktop-monitoring-stop', () => {
stopDesktopActivityMonitoring()
})
}
15 changes: 15 additions & 0 deletions electron/src/preload/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,21 @@ contextBridge.exposeInMainWorld('taskmaster', {
ipcRenderer.send('taskmaster:mini-timer-command', command),
setBrowserMonitoringActive: (isActive) =>
ipcRenderer.send('taskmaster:browser-monitoring-active', isActive),
startDesktopMonitoring: () =>
ipcRenderer.send('taskmaster:desktop-monitoring-start'),
pauseDesktopMonitoring: () =>
ipcRenderer.send('taskmaster:desktop-monitoring-pause'),
stopDesktopMonitoring: () =>
ipcRenderer.send('taskmaster:desktop-monitoring-stop'),
onDesktopActivity: (callback) => {
const listener = (_event, activity) => callback(activity)

ipcRenderer.on('taskmaster:desktop-activity', listener)

return () => {
ipcRenderer.removeListener('taskmaster:desktop-activity', listener)
}
},
onBrowserActivity: (callback) => {
const listener = (_event, activity) => callback(activity)

Expand Down
65 changes: 46 additions & 19 deletions electron/src/renderer/components/deepSesh/FocusMonitorPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,48 +1,75 @@
// Shows browser extension activity during an active focus session.
// This prototype reports active tab metadata only and does not classify or block.
// Shows live focus monitoring during an active focus session.
// Blocked activities warn only; Taskmaster does not close apps or block sites.

import type { BrowserActivityPayload } from '../../../shared/browserActivity'
import type { FocusMonitorViewState } from '../../hooks/useFocusMonitoringSession'

type FocusMonitorPanelProps = {
browserActivity: BrowserActivityPayload | null
focusMonitor: FocusMonitorViewState
}

export default function FocusMonitorPanel({
browserActivity,
focusMonitor,
}: FocusMonitorPanelProps) {
const { activity, classification, stats } = focusMonitor
const isBlocked = classification.status === 'blocked'
const isWarningVisible = focusMonitor.shouldShowWarning

return (
<aside className="deep-sesh-monitor-panel" aria-label="Focus monitor">
<aside
className={`deep-sesh-monitor-panel ${
isWarningVisible ? 'deep-sesh-monitor-panel--warning' : ''
}`}
aria-label="Focus monitor"
>
<div className="deep-sesh-monitor-status">
<span className="status-pill">Timer active</span>
<h2>Browser activity</h2>
<span className="status-pill">{getStatusLabel(classification.status)}</span>
<h2>{activity?.label ?? 'Waiting for activity'}</h2>
<p className="muted-text">
{browserActivity
? 'Extension connected through the local dev bridge.'
: 'Waiting for browser extension.'}
{isBlocked && !isWarningVisible
? `Warning in ${focusMonitor.warningDelaySeconds} seconds if this stays active.`
: classification.reason}
</p>
</div>

<div className="deep-sesh-monitor-current">
<span>Browser activity</span>
<strong>{browserActivity?.domain ?? 'Waiting for browser extension'}</strong>
<span>{activity?.kind === 'browser-page' ? 'Browser activity' : 'Active app'}</span>
<strong>{activity?.detail ?? 'Waiting for browser extension or app'}</strong>
</div>

<div className="deep-sesh-monitor-list">
<div>
<span>Title</span>
<strong>{browserActivity?.title ?? 'No active tab reported yet'}</strong>
<div className={`deep-sesh-monitor-rule deep-sesh-monitor-rule--${classification.status}`}>
<span>Status</span>
<strong>{getStatusLabel(classification.status)}</strong>
</div>

<div>
<span>Status</span>
<strong>{browserActivity ? 'Extension connected' : 'Waiting'}</strong>
<span>Rule</span>
<strong>{classification.matchedRuleLabel ?? 'No matching rule'}</strong>
</div>

<div>
<span>Source</span>
<strong>{browserActivity?.browser ?? 'Chromium extension'}</strong>
<strong>{activity?.source ?? 'Waiting'}</strong>
</div>

<div>
<span>Distractions</span>
<strong>{stats.distractionEvents}</strong>
</div>

<div>
<span>Unknown found</span>
<strong>{stats.unknownCount}</strong>
</div>
</div>
</aside>
)
}

function getStatusLabel(status: FocusMonitorViewState['classification']['status']) {
if (status === 'allowed') return 'Allowed'
if (status === 'blocked') return 'Distracting'
if (status === 'ignored') return 'Ignored'

return 'Unknown'
}
77 changes: 70 additions & 7 deletions electron/src/renderer/hooks/useFocusEnvironmentSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
} from '../../shared/browserActivity/commonBrowserActivityRules.ts'

export type AppCategory = 'productivity' | 'distraction'
export type AppRuleStatus = 'allowed' | 'blocked'
export type AppRuleStatus = 'allowed' | 'blocked' | 'ignored'

export type FocusApp = {
id: string
Expand Down Expand Up @@ -86,13 +86,39 @@ function loadFocusEnvironmentSettings(): FocusEnvironmentSettings | null {
}

try {
return JSON.parse(savedSettings) as FocusEnvironmentSettings
return mergeDefaultRules(JSON.parse(savedSettings) as FocusEnvironmentSettings)
} catch {
localStorage.removeItem(FOCUS_ENVIRONMENT_SETTINGS_KEY)
return null
}
}

/**
* Adds newly shipped default rules to older saved settings without replacing
* the user's existing allow/block choices.
*/
function mergeDefaultRules(
savedSettings: FocusEnvironmentSettings
): FocusEnvironmentSettings {
return {
...savedSettings,
appRules: [
...savedSettings.appRules,
...defaultFocusApps.filter((defaultApp) => {
return !savedSettings.appRules.some((savedApp) => savedApp.id === defaultApp.id)
}),
],
browserActivityRules: [
...savedSettings.browserActivityRules,
...defaultBrowserActivityRules.filter((defaultRule) => {
return !savedSettings.browserActivityRules.some(
(savedRule) => savedRule.id === defaultRule.id,
)
}),
],
}
}


/**
* Narrows detected apps to desktop app rules.
Expand Down Expand Up @@ -264,13 +290,42 @@ export function useFocusEnvironmentSettings() {
}))
}

function saveFocusEnvironmentSettings() {
localStorage.setItem(
FOCUS_ENVIRONMENT_SETTINGS_KEY,
JSON.stringify(settings)
/**
* Adds a new desktop app rule from the end-of-session unknown activity review.
*/
function addAppRule(app: FocusApp) {
setSettings((currentSettings) =>
persistFocusEnvironmentSettings({
...currentSettings,
appRules: [
...currentSettings.appRules.filter((rule) => rule.id !== app.id),
app,
],
}),
)
}

/**
* Adds a browser page rule from the end-of-session unknown activity review.
*/
function addBrowserActivityRule(rule: BrowserActivityRule) {
setSettings((currentSettings) =>
persistFocusEnvironmentSettings({
...currentSettings,
browserActivityRules: [
...currentSettings.browserActivityRules.filter(
(existingRule) => existingRule.id !== rule.id,
),
rule,
],
}),
)
}

function saveFocusEnvironmentSettings() {
persistFocusEnvironmentSettings(settings)
}

return {
settings,
browserOptions,
Expand All @@ -280,9 +335,17 @@ export function useFocusEnvironmentSettings() {
setSelectedBrowserId,
setBlockSelectedBrowser,
updateAppStatus,
addAppRule,
saveFocusEnvironmentSettings,
blockedBrowserActivityRules,
flexibleBrowserActivityRules,
updateBrowserActivityRuleStatus,
addBrowserActivityRule,
}
}
}

function persistFocusEnvironmentSettings(settings: FocusEnvironmentSettings) {
localStorage.setItem(FOCUS_ENVIRONMENT_SETTINGS_KEY, JSON.stringify(settings))

return settings
}
Loading
Loading