diff --git a/browser-extension/README.md b/browser-extension/README.md index 9df13d5..ca426bc 100644 --- a/browser-extension/README.md +++ b/browser-extension/README.md @@ -31,7 +31,7 @@ Start a Deep Sesh or Pomodoro session before testing browser activity. 3. Click Load unpacked. 4. Select the project `browser-extension` folder. 5. Start a Taskmaster focus session, then switch tabs. -6. Native Messaging requires a host install step that will be added in the next phase. +6. Install the native host using the steps below. ## Load In Opera GX @@ -50,9 +50,24 @@ Start a Deep Sesh or Pomodoro session before testing browser activity. 5. Stop the Taskmaster session. 6. Switch browser tabs again and confirm the panel no longer receives new activity. +## Install Native Messaging Host + +From the project root: + +```powershell +.\native-host\install-chrome-native-host.ps1 +``` + +This registers the host under the current Windows user: + +```txt +HKCU\Software\Google\Chrome\NativeMessagingHosts\com.taskmaster.browser_monitor +``` + +Restart Chrome after installing the host. + ## Current Limitations -- Native Messaging host setup is not implemented yet. - It only reports the active tab in the focused browser window. - It does not classify, block, notify, or persist browsing activity. - Internal browser pages such as `chrome://`, `edge://`, `opera://`, `about:`, and `devtools://` are ignored. @@ -76,6 +91,12 @@ The package includes only: - `manifest.json` - `background.js` -## Future Production Plan +## Native Messaging + +The extension uses: + +```txt +com.taskmaster.browser_monitor +``` -The next phase will add the Native Messaging host and install scripts. +The native host forwards validated messages to the local Taskmaster desktop app on the same device. diff --git a/browser-extension/background.js b/browser-extension/background.js index 5975ddc..9bceaf8 100644 --- a/browser-extension/background.js +++ b/browser-extension/background.js @@ -7,8 +7,8 @@ // - Page content, cookies, form inputs, and the browsing history API are never read. // - The active tab URL/title is queried only after Taskmaster says monitoring is active. // -// Transport is intentionally separated from tab collection so the same privacy -// gate can work with the current dev localhost bridge and future Native Messaging. +// Transport is intentionally separated from tab collection. Production uses +// Native Messaging; the old localhost path is retained only for local debugging. const BRIDGE_ORIGIN = 'http://127.0.0.1:17382' const STATUS_URL = `${BRIDGE_ORIGIN}/taskmaster-browser-monitor/status` @@ -91,7 +91,8 @@ async function isMonitoringEnabled() { return isDevLocalhostMonitoringEnabled() } -// Placeholder production status check. Phase 2 will wire this to the native host. +// Production status check. Chrome launches the registered Native Messaging host, +// then the host asks the local Taskmaster bridge whether monitoring is active. async function isNativeMonitoringEnabled() { try { const response = await chrome.runtime.sendNativeMessage(NATIVE_HOST_NAME, { @@ -156,7 +157,7 @@ async function sendActivity(payload) { await sendDevLocalhostActivity(payload) } -// Placeholder production sender. Phase 2 will add the native host implementation. +// Production sender. The native host validates and forwards this to Taskmaster. async function sendNativeActivity(payload) { try { await chrome.runtime.sendNativeMessage(NATIVE_HOST_NAME, { diff --git a/electron/src/main/browser-activity-bridge.ts b/electron/src/main/browser-activity-bridge.ts index 3a875d0..33315c4 100644 --- a/electron/src/main/browser-activity-bridge.ts +++ b/electron/src/main/browser-activity-bridge.ts @@ -1,5 +1,6 @@ -// Dev-only localhost bridge for the Taskmaster browser extension prototype. -// This accepts active tab metadata only while a focus session has enabled monitoring. +// Local Taskmaster app bridge for browser activity messages. +// Native Messaging host forwards validated active-tab metadata here while a +// focus session has enabled monitoring. import http from 'node:http' import type { BrowserActivityPayload } from '../shared/browserActivity.ts' @@ -16,7 +17,7 @@ let isBrowserMonitoringActive = false let latestBrowserActivity: BrowserActivityPayload | null = null let notifyRenderer: BrowserActivityListener | null = null -/* Starts the dev HTTP bridge once the Electron app is ready. */ +/* Starts the local HTTP bridge once the Electron app is ready. */ export function startBrowserActivityBridge(onActivity: BrowserActivityListener) { notifyRenderer = onActivity @@ -57,7 +58,7 @@ export function startBrowserActivityBridge(onActivity: BrowserActivityListener) }) } -/* Closes the local bridge during app shutdown. */ +/* Closes the local app bridge during app shutdown. */ export function stopBrowserActivityBridge() { setBrowserMonitoringActive(false) @@ -186,7 +187,7 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null } -/* Allows the unpacked dev extension to call the local bridge during development. */ +/* Allows local development and the native host to call the app bridge. */ function addCorsHeaders(response: http.ServerResponse) { response.setHeader('Access-Control-Allow-Origin', '*') response.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS') diff --git a/native-host/.gitignore b/native-host/.gitignore new file mode 100644 index 0000000..1d7ebf0 --- /dev/null +++ b/native-host/.gitignore @@ -0,0 +1,2 @@ +# Ignore machine-specific launchers/manifests generated by the native host installer. +.generated/ diff --git a/native-host/README.md b/native-host/README.md new file mode 100644 index 0000000..6082760 --- /dev/null +++ b/native-host/README.md @@ -0,0 +1,69 @@ +# Taskmaster Browser Monitor Native Host + +This folder contains the Chrome Native Messaging host for the Taskmaster Browser Monitor extension. + +The extension talks to this host through Chrome Native Messaging. The host then forwards validated messages to the local Taskmaster desktop app bridge on `127.0.0.1:17382`. + +The extension itself does not require localhost host permissions. + +## Host Name + +```txt +com.taskmaster.browser_monitor +``` + +## Allowed Chrome Extension + +```txt +kibldolnfbpajohdnkbjfjefnemllapm +``` + +## Install On Windows + +From the repo root: + +```powershell +.\native-host\install-chrome-native-host.ps1 +``` + +This registers the host under: + +```txt +HKCU\Software\Google\Chrome\NativeMessagingHosts\com.taskmaster.browser_monitor +``` + +HKCU is used so admin rights are not required. + +## Verify Registration + +1. Open `regedit`. +2. Go to: + ```txt + HKEY_CURRENT_USER\Software\Google\Chrome\NativeMessagingHosts\com.taskmaster.browser_monitor + ``` +3. Confirm the default value points to the generated manifest in: + ```txt + native-host\.generated\com.taskmaster.browser_monitor.chrome.json + ``` + +## Test Flow + +1. Install the native host. +2. Start Taskmaster Electron dev. +3. Load or install the Chrome extension with ID `kibldolnfbpajohdnkbjfjefnemllapm`. +4. Start a Deep Sesh or Pomodoro session. +5. Open GitHub, YouTube, or ChatGPT. +6. Confirm FocusMonitorPanel shows the current domain and title. +7. Stop the focus session and confirm tab updates stop. + +## Debugging + +If Chrome says the native host was not found: + +- Confirm the registry key exists under HKCU, not HKLM. +- Confirm the manifest path in the registry exists. +- Confirm the generated manifest `path` points to the generated `.cmd` launcher. +- Confirm `node` is installed and available when running the install script. +- Restart Chrome after installing the native host. + +Raw URLs are not logged by default. diff --git a/native-host/com.taskmaster.browser_monitor.chrome.json b/native-host/com.taskmaster.browser_monitor.chrome.json new file mode 100644 index 0000000..17c226e --- /dev/null +++ b/native-host/com.taskmaster.browser_monitor.chrome.json @@ -0,0 +1,9 @@ +{ + "name": "com.taskmaster.browser_monitor", + "description": "Taskmaster Browser Monitor Native Host", + "path": "ABSOLUTE_PATH_CREATED_BY_install-chrome-native-host.ps1", + "type": "stdio", + "allowed_origins": [ + "chrome-extension://kibldolnfbpajohdnkbjfjefnemllapm/" + ] +} diff --git a/native-host/install-chrome-native-host.ps1 b/native-host/install-chrome-native-host.ps1 new file mode 100644 index 0000000..178ca15 --- /dev/null +++ b/native-host/install-chrome-native-host.ps1 @@ -0,0 +1,41 @@ +# Registers the Taskmaster Browser Monitor native host for Chrome under HKCU. +# This does not require admin rights and writes a generated local manifest. + +$ErrorActionPreference = "Stop" + +$hostName = "com.taskmaster.browser_monitor" +$nativeHostDir = $PSScriptRoot +$generatedDir = Join-Path $nativeHostDir ".generated" +$generatedManifestPath = Join-Path $generatedDir "$hostName.chrome.json" +$generatedLauncherPath = Join-Path $generatedDir "taskmaster-browser-monitor-host.cmd" +$hostScriptPath = Join-Path $nativeHostDir "taskmaster-browser-monitor-host.js" +$nodePath = (Get-Command node -ErrorAction Stop).Source +$registryPath = "HKCU:\Software\Google\Chrome\NativeMessagingHosts\$hostName" + +New-Item -ItemType Directory -Path $generatedDir -Force | Out-Null + +$launcherContent = @" +@echo off +"$nodePath" "$hostScriptPath" +"@ + +Set-Content -LiteralPath $generatedLauncherPath -Value $launcherContent -Encoding ASCII + +$manifest = @{ + name = $hostName + description = "Taskmaster Browser Monitor Native Host" + path = $generatedLauncherPath + type = "stdio" + allowed_origins = @( + "chrome-extension://kibldolnfbpajohdnkbjfjefnemllapm/" + ) +} + +$manifest | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $generatedManifestPath -Encoding UTF8 + +New-Item -Path $registryPath -Force | Out-Null +Set-Item -Path $registryPath -Value $generatedManifestPath + +Write-Host "Registered $hostName" +Write-Host "Manifest: $generatedManifestPath" +Write-Host "Registry: $registryPath" diff --git a/native-host/taskmaster-browser-monitor-host.js b/native-host/taskmaster-browser-monitor-host.js new file mode 100644 index 0000000..f5a4e78 --- /dev/null +++ b/native-host/taskmaster-browser-monitor-host.js @@ -0,0 +1,201 @@ +#!/usr/bin/env node +// Native Messaging host for Taskmaster Browser Monitor. +// Reads Chrome length-prefixed JSON from stdin, validates messages, and forwards +// active-tab metadata to the local Taskmaster desktop app bridge. + +const http = require('node:http') + +const BRIDGE_HOST = '127.0.0.1' +const BRIDGE_PORT = 17382 +const STATUS_PATH = '/taskmaster-browser-monitor/status' +const ACTIVITY_PATH = '/taskmaster-browser-monitor/activity' +const MAX_MESSAGE_BYTES = 1024 * 1024 + +let inputBuffer = Buffer.alloc(0) + +process.stdin.on('data', (chunk) => { + inputBuffer = Buffer.concat([inputBuffer, chunk]) + readAvailableMessages() +}) + +process.stdin.on('end', () => { + process.exit(0) +}) + +process.stdin.resume() + +function readAvailableMessages() { + while (inputBuffer.length >= 4) { + const messageLength = inputBuffer.readUInt32LE(0) + + if (messageLength > MAX_MESSAGE_BYTES) { + writeNativeResponse({ ok: false, error: 'Message too large' }) + process.exit(1) + } + + if (inputBuffer.length < messageLength + 4) { + return + } + + const messageBuffer = inputBuffer.subarray(4, messageLength + 4) + inputBuffer = inputBuffer.subarray(messageLength + 4) + + void handleNativeMessage(messageBuffer) + } +} + +async function handleNativeMessage(messageBuffer) { + let message + + try { + message = JSON.parse(messageBuffer.toString('utf8')) + } catch { + writeNativeResponse({ ok: false, error: 'Invalid JSON' }) + return + } + + if (!isRecord(message) || typeof message.type !== 'string') { + writeNativeResponse({ ok: false, error: 'Invalid message shape' }) + return + } + + if (message.type === 'taskmaster-browser-monitor-status') { + await handleStatusMessage() + return + } + + if (message.type === 'taskmaster-browser-activity') { + await handleActivityMessage(message.payload) + return + } + + writeNativeResponse({ ok: false, error: 'Unknown message type' }) +} + +async function handleStatusMessage() { + try { + const status = await requestTaskmaster({ + method: 'GET', + path: STATUS_PATH, + }) + + writeNativeResponse({ + ok: true, + enabled: status.enabled === true, + }) + } catch { + writeNativeResponse({ + ok: false, + enabled: false, + error: 'Taskmaster bridge unavailable', + }) + } +} + +async function handleActivityMessage(payload) { + const validatedPayload = parseBrowserActivityPayload(payload) + + if (!validatedPayload) { + writeNativeResponse({ ok: false, error: 'Invalid activity payload' }) + return + } + + try { + const response = await requestTaskmaster({ + method: 'POST', + path: ACTIVITY_PATH, + body: validatedPayload, + }) + + writeNativeResponse({ + ok: true, + accepted: response.accepted === true, + }) + } catch { + writeNativeResponse({ + ok: false, + accepted: false, + error: 'Taskmaster bridge unavailable', + }) + } +} + +function requestTaskmaster({ method, path, body }) { + const requestBody = body ? JSON.stringify(body) : '' + + return new Promise((resolve, reject) => { + const request = http.request( + { + host: BRIDGE_HOST, + port: BRIDGE_PORT, + path, + method, + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(requestBody), + }, + }, + (response) => { + const chunks = [] + + response.on('data', (chunk) => chunks.push(chunk)) + response.on('end', () => { + const rawBody = Buffer.concat(chunks).toString('utf8') + + if (!response.statusCode || response.statusCode >= 400) { + reject(new Error(`Taskmaster returned ${response.statusCode}`)) + return + } + + try { + resolve(rawBody ? JSON.parse(rawBody) : {}) + } catch { + reject(new Error('Taskmaster returned invalid JSON')) + } + }) + } + ) + + request.on('error', reject) + request.write(requestBody) + request.end() + }) +} + +function parseBrowserActivityPayload(value) { + if (!isRecord(value)) { + return null + } + + if ( + value.source !== 'taskmaster-browser-extension' || + value.browser !== 'chromium' || + typeof value.title !== 'string' || + typeof value.url !== 'string' || + typeof value.domain !== 'string' || + typeof value.timestamp !== 'number' + ) { + return null + } + + return { + source: 'taskmaster-browser-extension', + title: value.title.slice(0, 500), + url: value.url.slice(0, 2048), + domain: value.domain.slice(0, 255), + browser: 'chromium', + timestamp: value.timestamp, + } +} + +function writeNativeResponse(message) { + const response = Buffer.from(JSON.stringify(message), 'utf8') + const header = Buffer.alloc(4) + + header.writeUInt32LE(response.length, 0) + process.stdout.write(Buffer.concat([header, response])) +} + +function isRecord(value) { + return typeof value === 'object' && value !== null +} diff --git a/native-host/uninstall-chrome-native-host.ps1 b/native-host/uninstall-chrome-native-host.ps1 new file mode 100644 index 0000000..81b9b98 --- /dev/null +++ b/native-host/uninstall-chrome-native-host.ps1 @@ -0,0 +1,14 @@ +# Unregisters the Taskmaster Browser Monitor native host from Chrome under HKCU. +# Generated files are left on disk so local debugging artifacts are not removed unexpectedly. + +$ErrorActionPreference = "Stop" + +$hostName = "com.taskmaster.browser_monitor" +$registryPath = "HKCU:\Software\Google\Chrome\NativeMessagingHosts\$hostName" + +if (Test-Path $registryPath) { + Remove-Item -LiteralPath $registryPath -Recurse -Force + Write-Host "Unregistered $hostName" +} else { + Write-Host "$hostName was not registered" +}