From f030578f83172c8eff398f5ec6fc4d730755cc1e Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 24 Aug 2026 05:58:45 +0100 Subject: [PATCH] refactor: semantically port to AffineScript --- lib/ocaml/Background.affine | 329 ++++++++++++++++++++++++++++++- lib/ocaml/Chrome.affine | 83 +++++++- lib/ocaml/Content.affine | 209 +++++++++++++++++++- lib/ocaml/Dashboard.affine | 259 +++++++++++++++++++++++- lib/ocaml/DomBindings.affine | 52 ++++- lib/ocaml/Options.affine | 275 +++++++++++++++++++++++++- lib/ocaml/Popup.affine | 274 ++++++++++++++++++++++++- lib/ocaml/Storage.affine | 144 +++++++++++++- lib/ocaml/Types.affine | 265 ++++++++++++++++++++++++- lib/ocaml/Wasm.affine | 303 +++++++++++++++++++++++++++- src/background/Background.affine | 329 ++++++++++++++++++++++++++++++- src/bindings/Chrome.affine | 83 +++++++- src/bindings/DomBindings.affine | 52 ++++- src/content/Content.affine | 209 +++++++++++++++++++- src/dashboard/Dashboard.affine | 259 +++++++++++++++++++++++- src/options/Options.affine | 275 +++++++++++++++++++++++++- src/popup/Popup.affine | 274 ++++++++++++++++++++++++- src/types/Types.affine | 265 ++++++++++++++++++++++++- src/utils/Storage.affine | 144 +++++++++++++- src/utils/Wasm.affine | 303 +++++++++++++++++++++++++++- 20 files changed, 4326 insertions(+), 60 deletions(-) diff --git a/lib/ocaml/Background.affine b/lib/ocaml/Background.affine index f3b8f31..4260174 100644 --- a/lib/ocaml/Background.affine +++ b/lib/ocaml/Background.affine @@ -1,7 +1,330 @@ // SPDX-License-Identifier: MPL-2.0 -// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell -// Ported via Harvard Engine bulk-processor +// Ported via Harvard Engine (Semantic pass) module Background; -// TODO: Complete semantic implementation +// SPDX-License-Identifier: MPL-2.0 +// Background service worker for DoubleTrack Browser + +open Types + +// State +fn isRunningRef: ref = ref(false) + +// Track fake tabs: tabId -> fakeTabInfo +fn fakeTabsRef: ref> = ref(Dict.make()) + +fn maxConcurrentFakeTabs = 3 + +@val external setTimeout: (unit => unit, int) => int = "setTimeout" + +fn getNextMidnight = (): float => { + fn now = Date.make() + // Create tomorrow at midnight using raw JS since ReScript 12 Date API uses int args + fn tomorrow: Date.t = %raw(` + (function(now) { + var d = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1); + return d; + }) + `)(now) + Date.getTime(tomorrow) +} + +fn setupAlarms = () => { + // Daily reset at midnight + Chrome.Alarms.create( + "daily-reset", + {"when": getNextMidnight(), "periodInMinutes": 24.0 *. 60.0}, + ) + + // Update profile age daily + Chrome.Alarms.create("update-profile-age", {"periodInMinutes": 60.0 *. 24.0}) +} + +fn activeFakeTabCount = (): int => { + Array.length(Dict.keysToArray(fakeTabsRef.contents)) +} + +// Close a fake tab and clean up tracking +fn closeFakeTab = async (tabId: int): unit => { + Dict.delete(fakeTabsRef.contents, Int.toString(tabId)) + switch await Chrome.Tabs.remove(tabId) { + | () => Console.log2("DoubleTrack: Closed fake tab", tabId) + | exception _exn => () // Tab may already be closed + } +} + +// Clean up stale fake tabs (open > 5 minutes) +fn cleanupStaleFakeTabs = async (): unit => { + fn now = Date.now() + fn staleThreshold = 5.0 *. 60.0 *. 1000.0 // 5 minutes in ms + fn keys = Dict.keysToArray(fakeTabsRef.contents) + + fn _: unit = await keys->Array.reduce(Promise.resolve(), async (accPromise, key) => { + await accPromise + switch Dict.get(fakeTabsRef.contents, key) { + | Some(info) if now -. info.openedAt > staleThreshold => + await closeFakeTab(info.tabId) + | _ => () + } + }) +} + +// Generate simulation parameters based on activity struct and profile +fn getSimulationParams = (activity: browsingActivity): simulationParams => { + fn baseScroll = switch activity.activityType { + | Research => 0.8 + | News => 0.6 + | Shopping => 0.5 + | PageVisit => 0.7 + | Search => 0.3 + | VideoWatch => 0.2 + | SocialMedia => 0.4 + } + + fn scrollJitter = Math.random() *. 0.3 -. 0.15 + fn scrollDepth = Math.min(1.0, Math.max(0.1, baseScroll +. scrollJitter)) + + { + scrollDepth, + clickLinks: activity.activityType != Search && Math.random() > 0.4, + dwellSeconds: Pervasives.max(5, activity.durationSeconds / 3), + fillForms: false, + } +} + +fn simulateActivity = async (): unit => { + fn config = await Storage.getConfig() + fn profile = await Storage.getProfile() + + switch (config.enabled, profile) { + | (true, Some(p)) => + // Don't open more tabs if at capacity + if activeFakeTabCount() >= maxConcurrentFakeTabs { + Console.log("DoubleTrack: At max fake tab capacity, skipping") + } else { + + // Check schedule if enabled + if config.respectSchedule { + fn _schedule = await Wasm.getActivitySchedule(p) + // Schedule checking logic would go here + } + + // Generate a single activity + fn activities = await Wasm.generateActivities(p, 0.25) // 15 minutes worth + + if Array.length(activities) > 0 { + switch activities[0] { + | Some(activity) => + // Record the activity in storage + await Storage.addActivity(activity) + + // Open a real tab with the generated URL + fn tab = await Chrome.Tabs.create({"url": activity.url, "active": false}) + + switch tab.id { + | Some(tabId) => + // Track this fake tab + fn info: fakeTabInfo = { + tabId, + activity, + openedAt: Date.now(), + } + Dict.set(fakeTabsRef.contents, Int.toString(tabId), info) + + Console.log2("DoubleTrack: Opened fake tab", activity.url) + + // After a short delay for the page to load, send simulation command + fn params = getSimulationParams(activity) + fn _ = setTimeout(() => { + fn _ = + Chrome.Tabs.sendMessage( + tabId, + Obj.magic({ + "struct": "SIMULATE_ACTIVITY", + "payload": Obj.magic(params), + }), + ) + ->Promise.thenResolve(_response => { + // Schedule tab close after simulation duration + fn closeDelay = (params.dwellSeconds + 5) * 1000 + fn _ = setTimeout(() => { + closeFakeTab(tabId)->ignore + }, closeDelay) + }) + ->Promise.catch(_err => { + // Content script may not be ready — close tab after dwell time anyway + fn _ = setTimeout(() => { + closeFakeTab(tabId)->ignore + }, activity.durationSeconds * 1000) + Promise.resolve() + }) + }, 3000) // 3s page load grace period + | None => Console.error("DoubleTrack: Tab created but no ID returned") + } + | None => () + } + } + } // end else (not at capacity) + | _ => () + } +} + +fn handleAlarm = async (alarm: Chrome.Alarms.alarm): unit => { + switch alarm.name { + | "daily-reset" => await Storage.resetDailyStatistics() + | "update-profile-age" => await Storage.updateProfileAge() + | "simulate-activity" => + if isRunningRef.contents { + await cleanupStaleFakeTabs() + await simulateActivity() + } + | _ => () + } +} + +fn startSimulation = async (): unit => { + if isRunningRef.contents { + Console.log("Simulation already running") + } else { + fn config = await Storage.getConfig() + fn profile = await Storage.getProfile() + + switch profile { + | None => Console.error("No profile found, cannot start simulation") + | Some(_) => + Console.log("Starting activity simulation") + isRunningRef.contents = true + + // Schedule periodic activity simulation + fn baseInterval = 15.0 + fn interval = baseInterval /. config.noiseLevel + + Chrome.Alarms.create( + "simulate-activity", + {"periodInMinutes": Math.max(5.0, interval)}, + ) + + // Run initial simulation + await simulateActivity() + } + } +} + +fn stopSimulation = async (): unit => { + Console.log("Stopping activity simulation") + isRunningRef.contents = false + Chrome.Alarms.clear("simulate-activity") + + // Close all fake tabs + fn keys = Dict.keysToArray(fakeTabsRef.contents) + fn _: unit = await keys->Array.reduce(Promise.resolve(), async (accPromise, key) => { + await accPromise + switch Dict.get(fakeTabsRef.contents, key) { + | Some(info) => await closeFakeTab(info.tabId) + | None => () + } + }) +} + +fn handleMessage = async (messageJson: JSON.t): JSON.t => { + fn message: message = Obj.magic(messageJson) + + switch message.messageType { + | "GET_CONFIG" => + fn config = await Storage.getConfig() + Obj.magic(config) + + | "UPDATE_CONFIG" => + fn config: extensionConfig = Obj.magic(message.payload) + await Storage.setConfig(config) + if config.enabled { + await startSimulation() + } else { + await stopSimulation() + } + Obj.magic({"success": true}) + + | "GENERATE_PROFILE" => + fn seed: option = Obj.magic(message.payload) + fn profile = await Wasm.generateProfile(seed) + await Storage.setProfile(profile) + await Storage.clearActivityHistory() + Obj.magic(profile) + + | "GET_STATISTICS" => + fn stats = await Storage.getStatistics() + Obj.magic(stats) + + | "GET_CURRENT_PROFILE" => + fn profile = await Storage.getProfile() + Obj.magic(profile) + + | "SIMULATE_ACTIVITY" => + await simulateActivity() + Obj.magic({"success": true}) + + | "ACTIVITY_COMPLETE" => + // Content script finished simulation on a page + Console.log("DoubleTrack: Content script reported activity complete") + Obj.magic({"acknowledged": true}) + + | "CLEAR_HISTORY" => + await Storage.clearActivityHistory() + Obj.magic({"success": true}) + + | _ => Obj.magic({"error": `Unknown message struct: ${message.messageType}`}) + } +} + +fn setupMessageListeners = () => { + Chrome.Runtime.onMessageAddListener((message, _sender, sendResponse) => { + fn _ = + handleMessage(message) + ->Promise.thenResolve(response => { + sendResponse(Obj.magic(response)) + }) + ->Promise.catch(error => { + Console.error2("Error handling message:", error) + sendResponse(Obj.magic({"error": "Internal error"})) + Promise.resolve() + }) + true // Return true to indicate async response + }) +} + +fn initialize = async (): unit => { + Console.log("DoubleTrack Browser: Initializing background service") + + // Initialize WASM module + switch await Wasm.initialize() { + | () => Console.log("WASM core initialized successfully") + | exception exn => Console.error2("Failed to initialize WASM core:", exn) + } + + // Set up message listeners + setupMessageListeners() + + // Set up alarms for scheduled tasks + setupAlarms() + Chrome.Alarms.onAlarmAddListener(alarm => { + fn _ = handleAlarm(alarm) + }) + + // Listen for tab removal to clean up tracking + Chrome.Tabs.OnRemoved.addListener(tabId => { + Dict.delete(fakeTabsRef.contents, Int.toString(tabId)) + }) + + // Load configuration and start if enabled + fn config = await Storage.getConfig() + if config.enabled && Option.isSome(config.currentProfile) { + await startSimulation() + } + + Console.log("DoubleTrack Browser: Background service initialized") +} + +// Initialize the background service +fn _ = initialize() + diff --git a/lib/ocaml/Chrome.affine b/lib/ocaml/Chrome.affine index 16852c2..80649d8 100644 --- a/lib/ocaml/Chrome.affine +++ b/lib/ocaml/Chrome.affine @@ -1,7 +1,84 @@ // SPDX-License-Identifier: MPL-2.0 -// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell -// Ported via Harvard Engine bulk-processor +// Ported via Harvard Engine (Semantic pass) module Chrome; -// TODO: Complete semantic implementation +// SPDX-License-Identifier: MPL-2.0 +// Chrome WebExtension API bindings for ReScript + +module Storage = { + module Local = { + @scope(("chrome", "storage", "local")) @val + external get: string => promise> = "get" + + @scope(("chrome", "storage", "local")) @val + external getMultiple: array => promise> = "get" + + @scope(("chrome", "storage", "local")) @val + external set: Dict.t => promise = "set" + } +} + +module Runtime = { + struct messageResponse + struct sender + + @scope(("chrome", "runtime")) @val + external sendMessage: JSON.t => promise = "sendMessage" + + @scope(("chrome", "runtime")) @val + external onMessageAddListener: ( + (JSON.t, sender, messageResponse => unit) => bool + ) => unit = "onMessage.addListener" + + @scope(("chrome", "runtime")) @val + external openOptionsPage: unit => unit = "openOptionsPage" + + @scope(("chrome", "runtime")) @val + external getURL: string => string = "getURL" + + module LastError = { + @scope(("chrome", "runtime")) @val @return(nullable) + external lastError: option<{..}> = "lastError" + } +} + +module Alarms = { + struct alarm { {name: string} + + struct alarmCreateInfo { { + when_: option, + periodInMinutes: option, + } + + @scope(("chrome", "alarms")) @val + external create: (string, {..}) => unit = "create" + + @scope(("chrome", "alarms")) @val + external clear: string => unit = "clear" + + @scope(("chrome", "alarms")) @val + external onAlarmAddListener: (alarm => unit) => unit = "onAlarm.addListener" +} + +module Tabs = { + struct tab { {id: option, url: option} + + @scope(("chrome", "tabs")) @val + external create: {..} => promise = "create" + + @scope(("chrome", "tabs")) @val + external remove: int => promise = "remove" + + @scope(("chrome", "tabs")) @val + external query: {..} => promise> = "query" + + @scope(("chrome", "tabs")) @val + external sendMessage: (int, JSON.t) => promise = "sendMessage" + + module OnRemoved = { + @scope(("chrome", "tabs", "onRemoved")) @val + external addListener: (int => unit) => unit = "addListener" + } +} + diff --git a/lib/ocaml/Content.affine b/lib/ocaml/Content.affine index 3f9fd35..846f714 100644 --- a/lib/ocaml/Content.affine +++ b/lib/ocaml/Content.affine @@ -1,7 +1,210 @@ // SPDX-License-Identifier: MPL-2.0 -// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell -// Ported via Harvard Engine bulk-processor +// Ported via Harvard Engine (Semantic pass) module Content; -// TODO: Complete semantic implementation +// SPDX-License-Identifier: MPL-2.0 +// Content script for DoubleTrack Browser +// Runs in the context of web pages — simulates realistic user behavior + +// External DOM bindings needed for behavior simulation +@val external setTimeout: (unit => unit, int) => int = "setTimeout" +@val external clearTimeout: int => unit = "clearTimeout" +@val external setInterval: (unit => unit, int) => int = "setInterval" +@val external clearInterval: int => unit = "clearInterval" + +module Window = { + @val external scrollTo: {"top": float, "behavior": string} => unit = "window.scrollTo" + @val external scrollY: float = "window.scrollY" + @val external innerHeight: float = "window.innerHeight" +} + +module Doc = { + @val external documentElement: {..} = "document.documentElement" + @scope("document") @val + external querySelectorAll: string => array<{..}> = "querySelectorAll" + + @scope("document") @val @return(nullable) + external querySelector: string => option<{..}> = "querySelector" +} + +// Dispatch a synthetic mouse event on a target element +fn dispatchMouseEvent = (target: {..}, eventType: string) => { + fn _: unit = %raw(` + function(target, eventType) { + var rect = target.getBoundingClientRect(); + var x = rect.left + rect.width * Math.random(); + var y = rect.top + rect.height * Math.random(); + var evt = new MouseEvent(eventType, { + bubbles: true, cancelable: true, view: window, + clientX: x, clientY: y + }); + target.dispatchEvent(evt); + } + `)(target, eventType) +} + +// Get the full scrollable height of the page +fn getScrollHeight = (): float => { + fn _el = Doc.documentElement + %raw(`el.scrollHeight`)->Obj.magic +} + +// Simulate gradual, human-like scrolling +fn simulateScroll = (targetDepth: float, onComplete: unit => unit) => { + fn maxScroll = getScrollHeight() -. Window.innerHeight + fn targetY = ref(0.0) + targetY := maxScroll *. targetDepth + + fn currentY = ref(Window.scrollY) + fn scrollStep = ref(0) + + fn intervalId = ref(0) + + fn step = () => { + scrollStep := scrollStep.contents + 1 + + // Variable scroll speed: sometimes fast, sometimes slow (human-like) + fn jitter = Math.random() *. 80.0 +. 20.0 + currentY := currentY.contents +. jitter + + if currentY.contents >= targetY.contents || scrollStep.contents > 200 { + clearInterval(intervalId.contents) + onComplete() + } else { + Window.scrollTo({"top": currentY.contents, "behavior": "smooth"}) + } + } + + // Variable interval between 50ms and 300ms for human-like pacing + fn baseInterval = 100 + Float.toInt(Math.random() *. 200.0) + intervalId := setInterval(step, baseInterval) +} + +// Simulate mouse movement over visible content elements +fn simulateMouseMovement = (onComplete: unit => unit) => { + fn elements = Doc.querySelectorAll("p, h1, h2, h3, a, img, li, span") + fn numMoves = Float.toInt(Math.random() *. 5.0) + 2 + fn moveIndex = ref(0) + + fn rec doMove = () => { + if moveIndex.contents >= numMoves || Array.length(elements) == 0 { + onComplete() + } else { + fn idx = Float.toInt(Math.random() *. Int.toFloat(Array.length(elements))) + switch elements[idx] { + | Some(el) => + dispatchMouseEvent(el, "mouseover") + dispatchMouseEvent(el, "mousemove") + // Pause like a human reading, then move on + fn delay = 300 + Float.toInt(Math.random() *. 1200.0) + fn _ = setTimeout(() => { + dispatchMouseEvent(el, "mouseout") + moveIndex := moveIndex.contents + 1 + doMove() + }, delay) + | None => + moveIndex := moveIndex.contents + 1 + doMove() + } + } + } + + doMove() +} + +// Simulate clicking an internal link (same-origin navigation) +fn simulateClick = (onComplete: unit => unit) => { + fn links = Doc.querySelectorAll("a[href]") + + // Filter to internal links only + fn internalLinks: array<{..}> = %raw(` + function(links) { + return Array.from(links).filter(function(a) { + try { + var href = a.getAttribute('href'); + if (!href || href.startsWith('#') || href.startsWith('javascript:')) return false; + var url = new URL(href, window.location.origin); + return url.origin === window.location.origin; + } catch(e) { return false; } + }); + } + `)(links) + + if Array.length(internalLinks) > 0 { + fn idx = Float.toInt(Math.random() *. Int.toFloat(Array.length(internalLinks))) + switch internalLinks[idx] { + | Some(link) => + dispatchMouseEvent(link, "mouseover") + fn _ = setTimeout(() => { + dispatchMouseEvent(link, "click") + onComplete() + }, 500 + Float.toInt(Math.random() *. 800.0)) + | None => onComplete() + } + } else { + onComplete() + } +} + +// Main simulation orchestrator — runs scroll, mouse, optional click, then dwells +fn runSimulation = (params: Types.simulationParams, onComplete: unit => unit) => { + Console.log("DoubleTrack: Starting page behavior simulation") + + // Phase 1: Scroll + simulateScroll(params.scrollDepth, () => { + // Phase 2: Mouse movement + simulateMouseMovement(() => { + // Phase 3: Optional link click + if params.clickLinks && Math.random() > 0.5 { + simulateClick(() => { + // Phase 4: Dwell for remaining time then signal completion + fn _ = setTimeout(onComplete, params.dwellSeconds * 1000) + }) + } else { + // Phase 4: Dwell then signal completion + fn _ = setTimeout(onComplete, params.dwellSeconds * 1000) + } + }) + }) +} + +// Listen for messages from background script +fn _ = Chrome.Runtime.onMessageAddListener((messageJson, _sender, sendResponse) => { + fn message: Types.message = Obj.magic(messageJson) + + switch message.messageType { + | "PING" => sendResponse(Obj.magic({"status": "alive"})) + | "GET_PAGE_INFO" => + sendResponse( + Obj.magic({ + "url": %raw(`window.location.href`), + "title": %raw(`document.title`), + "timestamp": Date.now(), + }), + ) + | "SIMULATE_ACTIVITY" => + fn params: Types.simulationParams = switch message.payload { + | Some(p) => Obj.magic(p) + | None => { + scrollDepth: 0.6, + clickLinks: true, + dwellSeconds: 15, + fillForms: false, + } + } + + runSimulation(params, () => { + Console.log("DoubleTrack: Simulation complete, signaling background") + fn _ = Chrome.Runtime.sendMessage( + Obj.magic({"struct": "ACTIVITY_COMPLETE", "payload": Nullable.null}), + ) + }) + + // Respond immediately — completion signaled asynchronously + sendResponse(Obj.magic({"status": "simulation_started"})) + | _ => sendResponse(Obj.magic({"error": "Unknown message struct"})) + } + true +}) + diff --git a/lib/ocaml/Dashboard.affine b/lib/ocaml/Dashboard.affine index cfd242c..88fbf38 100644 --- a/lib/ocaml/Dashboard.affine +++ b/lib/ocaml/Dashboard.affine @@ -1,7 +1,260 @@ // SPDX-License-Identifier: MPL-2.0 -// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell -// Ported via Harvard Engine bulk-processor +// Ported via Harvard Engine (Semantic pass) module Dashboard; -// TODO: Complete semantic implementation +// SPDX-License-Identifier: MPL-2.0 +// Dashboard page for activity analytics and visualization + +open Types +open DomBindings + +// State +fn activitiesRef: ref> = ref([]) +fn profileRef: ref> = ref(None) +fn statsRef: ref> = ref(None) + +fn loadData = async (): unit => { + fn keys = [StorageKey.activityHistory, StorageKey.profile, StorageKey.statistics] + fn result = await Chrome.Storage.Local.getMultiple(keys) + + activitiesRef.contents = + Dict.get(result, StorageKey.activityHistory) + ->Option.map(Obj.magic) + ->Option.getOr([]) + + profileRef.contents = Dict.get(result, StorageKey.profile)->Option.map(Obj.magic) + + statsRef.contents = Dict.get(result, StorageKey.statistics)->Option.map(Obj.magic) +} + +fn calculateStreak = (): int => { + fn activities = activitiesRef.contents + if Array.length(activities) == 0 { + 0 + } else { + fn today = Date.make() + fn _: unit = %raw(`today.setHours(0, 0, 0, 0)`) + + fn activityDates = + activities->Array.map(a => { + fn date = Date.fromTime(a.timestamp *. 1000.0) + fn _: unit = %raw(`date.setHours(0, 0, 0, 0)`) + Date.getTime(date) + }) + + // Build a lookup dict from activity dates for O(1) membership testing + fn dateDict = Dict.make() + activityDates->Array.forEach(d => Dict.set(dateDict, Float.toString(d), true)) + fn hasDate = (d: float) => Dict.get(dateDict, Float.toString(d))->Option.isSome + fn rec countStreak = (currentDate, streak) => { + if hasDate(currentDate) { + countStreak(currentDate -. 86400000.0, streak + 1) + } else { + streak + } + } + countStreak(Date.getTime(today), 0) + } +} + +fn showEmptyState = () => { + Document.querySelector(document, ".content") + ->Nullable.toOption + ->Option.forEach(content => + Element.setInnerHTML( + content, + ` +
+
📊
+
No Activity Data Yet
+
+ Enable DoubleTrack and generate a profile to start seeing analytics +
+
+ `, + ) + ) +} + +fn renderOverviewCards = () => { + fn activities = activitiesRef.contents + fn stats = statsRef.contents->Option.getOr({ + totalActivities: 0, + activitiesToday: 0, + profileAgeDays: 0, + lastActivity: None, + activityByType: Dict.make(), + }) + + fn setField = (id, value) => + Document.getElementById(document, id) + ->Nullable.toOption + ->Option.forEach(el => Element.setTextContent(el, value)) + + setField("total-activities", Int.toString(stats.totalActivities)) + + fn avgPerDay = switch profileRef.contents { + | Some(_) => + fn days = Pervasives.max(1, stats.profileAgeDays) + stats.totalActivities / days + | None => 0 + } + setField("daily-average", Int.toString(avgPerDay)) + + fn totalSeconds = + activities->Array.reduce(0, (sum, a) => sum + a.durationSeconds) + fn hours = totalSeconds / 3600 + setField("total-duration", `${Int.toString(hours)}h`) + + fn streak = calculateStreak() + setField("streak-days", Int.toString(streak)) +} + +fn getDailyCounts = (days: int): array => { + fn counts = Array.make(~length=days, 0) + fn today = Date.make() + fn _: unit = %raw(`today.setHours(0, 0, 0, 0)`) + fn todayTime = Date.getTime(today) + + activitiesRef.contents->Array.forEach(activity => { + fn activityDate = Date.fromTime(activity.timestamp *. 1000.0) + fn _: unit = %raw(`activityDate.setHours(0, 0, 0, 0)`) + + fn daysDiff = Float.toInt((todayTime -. Date.getTime(activityDate)) /. 86400000.0) + + if daysDiff >= 0 && daysDiff < days { + fn idx = days - 1 - daysDiff + counts[idx] = counts[idx]->Option.getOr(0) + 1 + } + }) + + counts +} + +fn renderRecentActivities = () => { + Document.getElementById(document, "recent-activities") + ->Nullable.toOption + ->Option.forEach(container => { + fn recent = + activitiesRef.contents + ->Array.slice(~start=-20, ~end=Array.length(activitiesRef.contents)) + ->Array.toReversed + + fn icons = Dict.fromArray([ + ("Search", `🔍`), + ("PageVisit", `📄`), + ("VideoWatch", `🎥`), + ("Shopping", `🛒`), + ("SocialMedia", `💬`), + ("News", `📰`), + ("Research", `📚`), + ]) + + fn html = + recent + ->Array.map(activity => { + fn structStr = activityTypeToString(activity.activityType) + fn icon = Dict.get(icons, structStr)->Option.getOr(`📄`) + fn date = Date.fromTime(activity.timestamp *. 1000.0) + fn timeStr = Date.toLocaleTimeString(date) + + ` +
+
${icon}
+
+
${activity.title}
+
${activity.url}
+
+
${timeStr}
+
+ ` + }) + ->Array.join("") + + Element.setInnerHTML(container, html) + }) +} + +fn renderInsights = () => { + switch profileRef.contents { + | Some(profile) => + fn activities = activitiesRef.contents + fn stats = statsRef.contents->Option.getOr({ + totalActivities: 0, + activitiesToday: 0, + profileAgeDays: 0, + lastActivity: None, + activityByType: Dict.make(), + }) + + fn setField = (id, value) => + Document.getElementById(document, id) + ->Nullable.toOption + ->Option.forEach(el => Element.setTextContent(el, value)) + + fn age = Int.toString(profile.demographics.age) + fn occupation = Obj.magic(profile.demographics.occupationCategory)->String.toLowerCase + fn interestCount = Int.toString(Array.length(profile.interests)) + setField( + "insight-profile", + `Your profile "${profile.name}" is a ${age}-year-old ${occupation} with ${interestCount} main interests.`, + ) + + fn activityCount = Int.toString(Array.length(activities)) + fn days = Int.toString(stats.profileAgeDays) + fn avgPerDay = Int.toString( + Array.length(activities) / Pervasives.max(1, stats.profileAgeDays), + ) + setField( + "insight-activity", + `Generated ${activityCount} activities over ${days} days, averaging ${avgPerDay} per day.`, + ) + + fn style = browsingStyleToString(profile.browsingStyle)->String.toLowerCase + setField( + "insight-pattern", + `Your ${style} browsing style creates a natural, believable activity pattern.`, + ) + + fn level = activityLevelToString(profile.activityLevel)->String.toLowerCase + setField( + "insight-performance", + `Activity level is ${level}, which provides good privacy coverage without excessive resource usage.`, + ) + | None => () + } +} + +fn renderDashboard = () => { + if Array.length(activitiesRef.contents) == 0 { + showEmptyState() + } else { + renderOverviewCards() + renderRecentActivities() + renderInsights() + // Charts would be rendered here with canvas API + } +} + +fn setupEventListeners = () => { + Document.getElementById(document, "back-btn") + ->Nullable.toOption + ->Option.forEach(btn => + Element.addEventListener(btn, "click", _ => { + Chrome.Runtime.openOptionsPage() + Window.close(window) + }) + ) +} + +fn initialize = async (): unit => { + await loadData() + setupEventListeners() + renderDashboard() +} + +fn _ = Document.addEventListener(document, "DOMContentLoaded", () => { + fn _ = initialize() +}) + diff --git a/lib/ocaml/DomBindings.affine b/lib/ocaml/DomBindings.affine index 62d016b..47c1f0f 100644 --- a/lib/ocaml/DomBindings.affine +++ b/lib/ocaml/DomBindings.affine @@ -1,7 +1,53 @@ // SPDX-License-Identifier: MPL-2.0 -// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell -// Ported via Harvard Engine bulk-processor +// Ported via Harvard Engine (Semantic pass) module DomBindings; -// TODO: Complete semantic implementation +// SPDX-License-Identifier: MPL-2.0 +// DOM bindings for ReScript + +@val external document: Dom.document = "document" +@val external window: Dom.window = "window" + +module Document = { + @send external getElementById: (Dom.document, string) => Nullable.t = "getElementById" + @send external querySelector: (Dom.document, string) => Nullable.t = "querySelector" + @get external title: Dom.document => string = "title" + @send external addEventListener: (Dom.document, string, unit => unit) => unit = "addEventListener" +} + +module Window = { + @send external close: Dom.window => unit = "close" + module Location = { + @get external href: Dom.window => string = "location.href" + } +} + +module Element = { + @send external addEventListener: (Dom.element, string, Dom.event => unit) => unit = "addEventListener" + @get external classList: Dom.element => Dom.domTokenList = "classList" + @get external textContent: Dom.element => string = "textContent" + @set external setTextContent: (Dom.element, string) => unit = "textContent" + @get external value: Dom.element => string = "value" + @set external setValue: (Dom.element, string) => unit = "value" + @get external checked: Dom.element => bool = "checked" + @set external setChecked: (Dom.element, bool) => unit = "checked" + @get external disabled: Dom.element => bool = "disabled" + @set external setDisabled: (Dom.element, bool) => unit = "disabled" + @get external style: Dom.element => Dom.cssStyleDeclaration = "style" + @set external setInnerHTML: (Dom.element, string) => unit = "innerHTML" +} + +module ClassList = { + @send external add: (Dom.domTokenList, string) => unit = "add" + @send external remove: (Dom.domTokenList, string) => unit = "remove" +} + +module Style = { + @set external setDisplay: (Dom.cssStyleDeclaration, string) => unit = "display" +} + +module Event = { + @get external target: Dom.event => Dom.element = "target" +} + diff --git a/lib/ocaml/Options.affine b/lib/ocaml/Options.affine index 58c0554..8bbb168 100644 --- a/lib/ocaml/Options.affine +++ b/lib/ocaml/Options.affine @@ -1,7 +1,276 @@ // SPDX-License-Identifier: MPL-2.0 -// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell -// Ported via Harvard Engine bulk-processor +// Ported via Harvard Engine (Semantic pass) module Options; -// TODO: Complete semantic implementation +// SPDX-License-Identifier: MPL-2.0 +// Options page controller for DoubleTrack Browser + +open Types +open DomBindings + +// State +fn configRef: ref> = ref(None) +fn profileRef: ref> = ref(None) +fn statsRef: ref> = ref(None) + +fn sendMessage = async (msg: message): JSON.t => { + await Chrome.Runtime.sendMessage(Obj.magic(msg)) +} + +fn loadState = async (): unit => { + fn configResult = await sendMessage({messageType: "GET_CONFIG", payload: None}) + configRef.contents = Some(Obj.magic(configResult)) + + fn profileResult = await sendMessage({messageType: "GET_CURRENT_PROFILE", payload: None}) + profileRef.contents = Obj.magic(profileResult) + + fn statsResult = await sendMessage({messageType: "GET_STATISTICS", payload: None}) + statsRef.contents = Some(Obj.magic(statsResult)) +} + +fn updateProfileDisplay = () => { + fn noProfileMsg = Document.getElementById(document, "no-profile-message")->Nullable.toOption + fn profileDetails = Document.getElementById(document, "profile-details")->Nullable.toOption + fn exportBtn = Document.getElementById(document, "export-profile-btn")->Nullable.toOption + + switch (profileRef.contents, noProfileMsg, profileDetails, exportBtn) { + | (None, Some(msg), Some(details), Some(btn)) => + Style.setDisplay(Element.style(msg), "block") + Style.setDisplay(Element.style(details), "none") + Element.setDisabled(btn, true) + | (Some(profile), Some(msg), Some(details), Some(btn)) => + Style.setDisplay(Element.style(msg), "none") + Style.setDisplay(Element.style(details), "block") + Element.setDisabled(btn, false) + + fn setField = (id, value) => + Document.getElementById(document, id) + ->Nullable.toOption + ->Option.forEach(el => Element.setTextContent(el, value)) + + setField("profile-name", profile.name) + setField("profile-age", Int.toString(profile.demographics.age)) + setField("profile-gender", genderToString(profile.demographics.gender)) + setField("profile-location", Obj.magic(profile.demographics.locationType)) + setField("profile-occupation", Obj.magic(profile.demographics.occupationCategory)) + setField("profile-education", Obj.magic(profile.demographics.educationLevel)) + setField( + "profile-interests", + profile.interests->Array.map(interestCategoryToString)->Array.join(", "), + ) + setField("profile-style", browsingStyleToString(profile.browsingStyle)) + setField("profile-activity", activityLevelToString(profile.activityLevel)) + | _ => () + } +} + +fn updateStatistics = () => { + switch statsRef.contents { + | Some(stats) => + fn setField = (id, value) => + Document.getElementById(document, id) + ->Nullable.toOption + ->Option.forEach(el => Element.setTextContent(el, value)) + + setField("total-activities", Int.toString(stats.totalActivities)) + setField("today-activities", Int.toString(stats.activitiesToday)) + setField("profile-age-days", Int.toString(stats.profileAgeDays)) + | None => () + } +} + +fn updateUI = () => { + switch configRef.contents { + | Some(config) => + Document.getElementById(document, "enabled") + ->Nullable.toOption + ->Option.forEach(el => Element.setChecked(el, config.enabled)) + + Document.getElementById(document, "privacy-mode") + ->Nullable.toOption + ->Option.forEach(el => Element.setValue(el, privacyModeToString(config.privacyMode))) + + Document.getElementById(document, "noise-level") + ->Nullable.toOption + ->Option.forEach(slider => { + fn percentage = Float.toInt(config.noiseLevel *. 100.0) + Element.setValue(slider, Int.toString(percentage)) + }) + + Document.getElementById(document, "noise-value") + ->Nullable.toOption + ->Option.forEach(el => { + fn percentage = Float.toInt(config.noiseLevel *. 100.0) + Element.setTextContent(el, `${Int.toString(percentage)}%`) + }) + + Document.getElementById(document, "respect-schedule") + ->Nullable.toOption + ->Option.forEach(el => Element.setChecked(el, config.respectSchedule)) + + updateProfileDisplay() + updateStatistics() + | None => () + } +} + +fn handleEnabledChange = async (enabled: bool): unit => { + switch (configRef.contents, enabled, profileRef.contents) { + | (Some(_), true, None) => + Console.log("Please generate a profile first") + Document.getElementById(document, "enabled") + ->Nullable.toOption + ->Option.forEach(el => Element.setChecked(el, false)) + | (Some(config), _, _) => + fn newConfig = {...config, enabled} + fn _ = await sendMessage({messageType: "UPDATE_CONFIG", payload: Some(Obj.magic(newConfig))}) + | _ => () + } +} + +fn handleNoiseChange = async (value: string): unit => { + switch configRef.contents { + | Some(config) => + fn newLevel = Float.fromString(value)->Option.getOr(50.0) /. 100.0 + fn newConfig = {...config, noiseLevel: newLevel} + fn _ = await sendMessage({messageType: "UPDATE_CONFIG", payload: Some(Obj.magic(newConfig))}) + | None => () + } +} + +fn handleScheduleChange = async (checked: bool): unit => { + switch configRef.contents { + | Some(config) => + fn newConfig = {...config, respectSchedule: checked} + fn _ = await sendMessage({messageType: "UPDATE_CONFIG", payload: Some(Obj.magic(newConfig))}) + | None => () + } +} + +fn handleGenerateProfile = async (): unit => { + fn btn = Document.getElementById(document, "generate-profile-btn")->Nullable.toOption + + switch btn { + | Some(b) => + Element.setTextContent(b, "Generating...") + Element.setDisabled(b, true) + + fn result = await sendMessage({messageType: "GENERATE_PROFILE", payload: None}) + profileRef.contents = Some(Obj.magic(result)) + await loadState() + updateUI() + + Element.setTextContent(b, "Generate New Profile") + Element.setDisabled(b, false) + | None => () + } +} + +fn handleExportProfile = () => { + switch profileRef.contents { + | Some(profile) => + fn dataStr = JSON.stringifyAny(Obj.magic(profile))->Option.getOr("{}") + Console.log2("Export profile:", dataStr) + // In browser, would create blob and download link + | None => () + } +} + +fn handleViewDashboard = () => { + fn url = Chrome.Runtime.getURL("dashboard.html") + fn _ = Chrome.Tabs.create({"url": url, "active": true}) +} + +fn handleClearHistory = async (): unit => { + fn _ = await sendMessage({messageType: "CLEAR_HISTORY", payload: None}) + await loadState() + updateUI() +} + +fn showModal = () => { + Document.getElementById(document, "activity-modal") + ->Nullable.toOption + ->Option.forEach(modal => Style.setDisplay(Element.style(modal), "flex")) +} + +fn closeModal = () => { + Document.getElementById(document, "activity-modal") + ->Nullable.toOption + ->Option.forEach(modal => Style.setDisplay(Element.style(modal), "none")) +} + +fn setupEventListeners = () => { + Document.getElementById(document, "enabled") + ->Nullable.toOption + ->Option.forEach(el => + Element.addEventListener(el, "change", e => { + fn _ = handleEnabledChange(Element.checked(Event.target(e))) + }) + ) + + Document.getElementById(document, "noise-level") + ->Nullable.toOption + ->Option.forEach(slider => { + Element.addEventListener(slider, "input", e => { + fn value = Element.value(Event.target(e)) + Document.getElementById(document, "noise-value") + ->Nullable.toOption + ->Option.forEach(el => Element.setTextContent(el, `${value}%`)) + }) + Element.addEventListener(slider, "change", e => { + fn _ = handleNoiseChange(Element.value(Event.target(e))) + }) + }) + + Document.getElementById(document, "respect-schedule") + ->Nullable.toOption + ->Option.forEach(el => + Element.addEventListener(el, "change", e => { + fn _ = handleScheduleChange(Element.checked(Event.target(e))) + }) + ) + + Document.getElementById(document, "generate-profile-btn") + ->Nullable.toOption + ->Option.forEach(btn => + Element.addEventListener(btn, "click", _ => { + fn _ = handleGenerateProfile() + }) + ) + + Document.getElementById(document, "export-profile-btn") + ->Nullable.toOption + ->Option.forEach(btn => Element.addEventListener(btn, "click", _ => handleExportProfile())) + + Document.getElementById(document, "view-dashboard-btn") + ->Nullable.toOption + ->Option.forEach(btn => Element.addEventListener(btn, "click", _ => handleViewDashboard())) + + Document.getElementById(document, "view-history-btn") + ->Nullable.toOption + ->Option.forEach(btn => Element.addEventListener(btn, "click", _ => showModal())) + + Document.getElementById(document, "clear-history-btn") + ->Nullable.toOption + ->Option.forEach(btn => + Element.addEventListener(btn, "click", _ => { + fn _ = handleClearHistory() + }) + ) + + Document.querySelector(document, ".modal-close") + ->Nullable.toOption + ->Option.forEach(btn => Element.addEventListener(btn, "click", _ => closeModal())) +} + +fn initialize = async (): unit => { + await loadState() + setupEventListeners() + updateUI() +} + +fn _ = Document.addEventListener(document, "DOMContentLoaded", () => { + fn _ = initialize() +}) + diff --git a/lib/ocaml/Popup.affine b/lib/ocaml/Popup.affine index 6e0513a..af7564a 100644 --- a/lib/ocaml/Popup.affine +++ b/lib/ocaml/Popup.affine @@ -1,7 +1,275 @@ // SPDX-License-Identifier: MPL-2.0 -// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell -// Ported via Harvard Engine bulk-processor +// Ported via Harvard Engine (Semantic pass) module Popup; -// TODO: Complete semantic implementation +// SPDX-License-Identifier: MPL-2.0 +// Popup UI controller for DoubleTrack Browser + +open Types +open DomBindings + +// State +fn configRef: ref> = ref(None) +fn profileRef: ref> = ref(None) +fn statsRef: ref> = ref(None) + +fn sendMessage = async (msg: message): JSON.t => { + await Chrome.Runtime.sendMessage(Obj.magic(msg)) +} + +fn loadState = async (): unit => { + fn configResult = await sendMessage({messageType: "GET_CONFIG", payload: None}) + configRef.contents = Some(Obj.magic(configResult)) + + fn profileResult = await sendMessage({messageType: "GET_CURRENT_PROFILE", payload: None}) + profileRef.contents = Obj.magic(profileResult) + + fn statsResult = await sendMessage({messageType: "GET_STATISTICS", payload: None}) + statsRef.contents = Some(Obj.magic(statsResult)) +} + +fn updateProfileInfo = () => { + fn noProfile = Document.getElementById(document, "no-profile")->Nullable.toOption + fn profileDetails = Document.getElementById(document, "profile-details")->Nullable.toOption + + switch (profileRef.contents, noProfile, profileDetails) { + | (None, Some(np), Some(pd)) => + Style.setDisplay(Element.style(np), "block") + Style.setDisplay(Element.style(pd), "none") + | (Some(profile), Some(np), Some(pd)) => + Style.setDisplay(Element.style(np), "none") + Style.setDisplay(Element.style(pd), "block") + + Document.getElementById(document, "profile-name") + ->Nullable.toOption + ->Option.forEach(el => Element.setTextContent(el, profile.name)) + + Document.getElementById(document, "profile-age") + ->Nullable.toOption + ->Option.forEach(el => Element.setTextContent(el, Int.toString(profile.demographics.age))) + + Document.getElementById(document, "profile-occupation") + ->Nullable.toOption + ->Option.forEach(el => + Element.setTextContent(el, Obj.magic(profile.demographics.occupationCategory)) + ) + + Document.getElementById(document, "profile-interests") + ->Nullable.toOption + ->Option.forEach(el => + Element.setTextContent( + el, + profile.interests->Array.map(interestCategoryToString)->Array.join(", "), + ) + ) + + Document.getElementById(document, "profile-activity-level") + ->Nullable.toOption + ->Option.forEach(el => Element.setTextContent(el, activityLevelToString(profile.activityLevel))) + | _ => () + } +} + +fn updateStatistics = () => { + switch statsRef.contents { + | Some(stats) => + Document.getElementById(document, "total-activities") + ->Nullable.toOption + ->Option.forEach(el => Element.setTextContent(el, Int.toString(stats.totalActivities))) + + Document.getElementById(document, "today-activities") + ->Nullable.toOption + ->Option.forEach(el => Element.setTextContent(el, Int.toString(stats.activitiesToday))) + + Document.getElementById(document, "profile-age") + ->Nullable.toOption + ->Option.forEach(el => Element.setTextContent(el, Int.toString(stats.profileAgeDays))) + | None => () + } +} + +fn updateUI = () => { + switch configRef.contents { + | Some(config) => + fn statusDot = Document.getElementById(document, "status-dot")->Nullable.toOption + fn statusText = Document.getElementById(document, "status-text")->Nullable.toOption + fn toggleBtn = Document.getElementById(document, "toggle-btn")->Nullable.toOption + + switch (statusDot, statusText, toggleBtn) { + | (Some(dot), Some(text), Some(btn)) => + if config.enabled { + ClassList.add(Element.classList(dot), "active") + ClassList.remove(Element.classList(dot), "inactive") + Element.setTextContent(text, "Enabled") + Element.setTextContent(btn, "Disable") + ClassList.add(Element.classList(btn), "active") + } else { + ClassList.remove(Element.classList(dot), "active") + ClassList.add(Element.classList(dot), "inactive") + Element.setTextContent(text, "Disabled") + Element.setTextContent(btn, "Enable") + ClassList.remove(Element.classList(btn), "active") + } + | _ => () + } + + updateProfileInfo() + updateStatistics() + + // Update controls + Document.getElementById(document, "noise-level") + ->Nullable.toOption + ->Option.forEach(slider => { + fn percentage = Float.toInt(config.noiseLevel *. 100.0) + Element.setValue(slider, Int.toString(percentage)) + }) + + Document.getElementById(document, "noise-value") + ->Nullable.toOption + ->Option.forEach(el => { + fn percentage = Float.toInt(config.noiseLevel *. 100.0) + Element.setTextContent(el, `${Int.toString(percentage)}%`) + }) + + Document.getElementById(document, "respect-schedule") + ->Nullable.toOption + ->Option.forEach(checkbox => Element.setChecked(checkbox, config.respectSchedule)) + | None => () + } +} + +fn handleToggle = async (): unit => { + switch configRef.contents { + | Some(config) => + if Option.isNone(profileRef.contents) && !config.enabled { + // Would show confirmation dialog in browser + Console.log("No profile exists. Generate one first.") + } else { + fn newConfig = {...config, enabled: !config.enabled} + fn _ = await sendMessage({ + messageType: "UPDATE_CONFIG", + payload: Some(Obj.magic(newConfig)), + }) + await loadState() + updateUI() + } + | None => () + } +} + +fn handleGenerateProfile = async (): unit => { + fn generateBtn = Document.getElementById(document, "generate-profile-btn")->Nullable.toOption + + switch generateBtn { + | Some(btn) => + Element.setTextContent(btn, "Generating...") + Element.setDisabled(btn, true) + + fn result = await sendMessage({messageType: "GENERATE_PROFILE", payload: None}) + profileRef.contents = Some(Obj.magic(result)) + await loadState() + updateUI() + + Element.setTextContent(btn, "Generate New Profile") + Element.setDisabled(btn, false) + | None => () + } +} + +fn handleNoiseChange = async (value: string): unit => { + switch configRef.contents { + | Some(config) => + fn newLevel = Float.fromString(value)->Option.getOr(50.0) /. 100.0 + fn newConfig = {...config, noiseLevel: newLevel} + fn _ = await sendMessage({ + messageType: "UPDATE_CONFIG", + payload: Some(Obj.magic(newConfig)), + }) + | None => () + } +} + +fn handleScheduleChange = async (checked: bool): unit => { + switch configRef.contents { + | Some(config) => + fn newConfig = {...config, respectSchedule: checked} + fn _ = await sendMessage({ + messageType: "UPDATE_CONFIG", + payload: Some(Obj.magic(newConfig)), + }) + | None => () + } +} + +fn handleClearHistory = async (): unit => { + fn _ = await sendMessage({messageType: "CLEAR_HISTORY", payload: None}) + await loadState() + updateUI() +} + +fn setupEventListeners = () => { + Document.getElementById(document, "toggle-btn") + ->Nullable.toOption + ->Option.forEach(btn => + Element.addEventListener(btn, "click", _ => { + fn _ = handleToggle() + }) + ) + + Document.getElementById(document, "generate-profile-btn") + ->Nullable.toOption + ->Option.forEach(btn => + Element.addEventListener(btn, "click", _ => { + fn _ = handleGenerateProfile() + }) + ) + + Document.getElementById(document, "noise-level") + ->Nullable.toOption + ->Option.forEach(slider => { + Element.addEventListener(slider, "input", e => { + fn value = Element.value(Event.target(e)) + Document.getElementById(document, "noise-value") + ->Nullable.toOption + ->Option.forEach(el => Element.setTextContent(el, `${value}%`)) + }) + Element.addEventListener(slider, "change", e => { + fn _ = handleNoiseChange(Element.value(Event.target(e))) + }) + }) + + Document.getElementById(document, "respect-schedule") + ->Nullable.toOption + ->Option.forEach(checkbox => + Element.addEventListener(checkbox, "change", e => { + fn _ = handleScheduleChange(Element.checked(Event.target(e))) + }) + ) + + Document.getElementById(document, "options-btn") + ->Nullable.toOption + ->Option.forEach(btn => + Element.addEventListener(btn, "click", _ => Chrome.Runtime.openOptionsPage()) + ) + + Document.getElementById(document, "clear-history-btn") + ->Nullable.toOption + ->Option.forEach(btn => + Element.addEventListener(btn, "click", _ => { + fn _ = handleClearHistory() + }) + ) +} + +fn initialize = async (): unit => { + await loadState() + setupEventListeners() + updateUI() +} + +// Initialize when DOM is loaded +fn _ = Document.addEventListener(document, "DOMContentLoaded", () => { + fn _ = initialize() +}) + diff --git a/lib/ocaml/Storage.affine b/lib/ocaml/Storage.affine index 7cd202f..62821ed 100644 --- a/lib/ocaml/Storage.affine +++ b/lib/ocaml/Storage.affine @@ -1,7 +1,145 @@ // SPDX-License-Identifier: MPL-2.0 -// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell -// Ported via Harvard Engine bulk-processor +// Ported via Harvard Engine (Semantic pass) module Storage; -// TODO: Complete semantic implementation +// SPDX-License-Identifier: MPL-2.0 +// Storage utility for managing extension data + +open Types + +// JSON encoding/decoding helpers +external jsonParse: string => JSON.t = "JSON.parse" +external jsonStringify: 'a => string = "JSON.stringify" + +fn defaultConfig: extensionConfig = { + enabled: false, + currentProfile: None, + noiseLevel: 0.5, + respectSchedule: true, + privacyMode: Moderate, +} + +fn defaultStatistics: unit => statistics = () => { + totalActivities: 0, + activitiesToday: 0, + profileAgeDays: 0, + lastActivity: None, + activityByType: Dict.make(), +} + +fn getConfig = async (): extensionConfig => { + fn result = await Chrome.Storage.Local.get(StorageKey.config) + switch Dict.get(result, StorageKey.config) { + | Some(json) => + // In production, proper JSON decoding would be used + Obj.magic(json) + | None => defaultConfig + } +} + +fn setConfig = async (config: extensionConfig): unit => { + fn dict = Dict.make() + Dict.set(dict, StorageKey.config, Obj.magic(config)) + await Chrome.Storage.Local.set(dict) +} + +fn getProfile = async (): option => { + fn result = await Chrome.Storage.Local.get(StorageKey.profile) + switch Dict.get(result, StorageKey.profile) { + | Some(json) => Some(Obj.magic(json)) + | None => None + } +} + +fn setProfile = async (profile: profile): unit => { + fn dict = Dict.make() + Dict.set(dict, StorageKey.profile, Obj.magic(profile)) + await Chrome.Storage.Local.set(dict) +} + +fn getStatistics = async (): statistics => { + fn result = await Chrome.Storage.Local.get(StorageKey.statistics) + switch Dict.get(result, StorageKey.statistics) { + | Some(json) => Obj.magic(json) + | None => defaultStatistics() + } +} + +fn setStatistics = async (stats: statistics): unit => { + fn dict = Dict.make() + Dict.set(dict, StorageKey.statistics, Obj.magic(stats)) + await Chrome.Storage.Local.set(dict) +} + +fn getActivityHistory = async (): array => { + fn result = await Chrome.Storage.Local.get(StorageKey.activityHistory) + switch Dict.get(result, StorageKey.activityHistory) { + | Some(json) => Obj.magic(json) + | None => [] + } +} + +fn addActivity = async (activity: browsingActivity): unit => { + fn history = await getActivityHistory() + fn newHistory = Array.concat(history, [activity]) + // Keep only the last 1000 activities + fn trimmedHistory = if Array.length(newHistory) > 1000 { + Array.slice(newHistory, ~start=Array.length(newHistory) - 1000, ~end=Array.length(newHistory)) + } else { + newHistory + } + fn dict = Dict.make() + Dict.set(dict, StorageKey.activityHistory, Obj.magic(trimmedHistory)) + await Chrome.Storage.Local.set(dict) + + // Update statistics + fn stats = await getStatistics() + stats.totalActivities = stats.totalActivities + 1 + stats.lastActivity = Some(activity.timestamp) + + // Update activities today + fn today = Date.make() + fn _: unit = %raw(`today.setHours(0, 0, 0, 0)`) + fn activityDate = Date.fromTime(activity.timestamp *. 1000.0) + if Date.getTime(activityDate) >= Date.getTime(today) { + stats.activitiesToday = stats.activitiesToday + 1 + } + + // Update activity by struct + fn structStr = activityTypeToString(activity.activityType) + fn currentCount = switch Dict.get(stats.activityByType, structStr) { + | Some(c) => c + | None => 0 + } + Dict.set(stats.activityByType, structStr, currentCount + 1) + + await setStatistics(stats) +} + +fn clearActivityHistory = async (): unit => { + fn dict = Dict.make() + Dict.set(dict, StorageKey.activityHistory, Obj.magic([])) + await Chrome.Storage.Local.set(dict) + await setStatistics(defaultStatistics()) +} + +fn resetDailyStatistics = async (): unit => { + fn stats = await getStatistics() + stats.activitiesToday = 0 + await setStatistics(stats) +} + +fn updateProfileAge = async (): unit => { + fn profile = await getProfile() + switch profile { + | Some(p) => + fn stats = await getStatistics() + fn now = Date.now() /. 1000.0 + fn ageSeconds = now -. p.createdAt + stats.profileAgeDays = Float.toInt(ageSeconds /. 86400.0) + await setStatistics(stats) + | None => () + } +} + diff --git a/lib/ocaml/Types.affine b/lib/ocaml/Types.affine index 4006671..669e16d 100644 --- a/lib/ocaml/Types.affine +++ b/lib/ocaml/Types.affine @@ -1,7 +1,266 @@ // SPDX-License-Identifier: MPL-2.0 -// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell -// Ported via Harvard Engine bulk-processor +// Ported via Harvard Engine (Semantic pass) module Types; -// TODO: Complete semantic implementation +// SPDX-License-Identifier: MPL-2.0 +// Type definitions for DoubleTrack Browser + +struct gender { + | Male + | Female + | NonBinary + | PreferNotToSay + +struct locationType { + | Urban + | Suburban + | Rural + +struct occupationCategory { + | Technology + | Healthcare + | Education + | Finance + | Creative + | Service + | Trades + | Retired + | Student + +struct educationLevel { + | HighSchool + | SomeCollege + | Bachelor + | Master + | Doctorate + +struct interestCategory { + | Technology + | Gaming + | Sports + | Fitness + | Cooking + | Travel + | Fashion + | Music + | Movies + | Books + | Art + | Science + | Politics + | News + | FinanceInterest + | HomeImprovement + | Gardening + | Photography + | Programming + | DataScience + +struct browsingStyle { + | Focused + | Explorer + | Researcher + | Casual + +struct activityLevel { + | Low + | Medium + | High + | VeryHigh + +struct activityType { + | Search + | PageVisit + | VideoWatch + | Shopping + | SocialMedia + | News + | Research + +struct privacyMode { + | Full + | Moderate + | Minimal + | Disabled + +struct dayOfWeek { + | Monday + | Tuesday + | Wednesday + | Thursday + | Friday + | Saturday + | Sunday + +struct demographics { { + age: int, + gender: gender, + locationType: locationType, + occupationCategory: occupationCategory, + educationLevel: educationLevel, +} + +struct profile { { + id: string, + name: string, + demographics: demographics, + interests: array, + browsingStyle: browsingStyle, + activityLevel: activityLevel, + createdAt: float, +} + +struct browsingActivity { { + activityType: activityType, + url: string, + title: string, + durationSeconds: int, + timestamp: float, + interestCategory: option, +} + +struct hourRange { { + startHour: int, + endHour: int, +} + +struct timePattern { { + dayOfWeek: dayOfWeek, + activeHours: array, + activityIntensity: float, +} + +struct schedule { { + timePatterns: array, + timezoneOffset: int, +} + +struct extensionConfig { { + enabled: bool, + currentProfile: option, + noiseLevel: float, + respectSchedule: bool, + privacyMode: privacyMode, +} + +struct statistics { { + mutable totalActivities: int, + mutable activitiesToday: int, + mutable profileAgeDays: int, + mutable lastActivity: option, + mutable activityByType: Dict.t, +} + +struct messageType { + | GetConfig + | UpdateConfig + | GenerateProfile + | GetStatistics + | GetCurrentProfile + | SimulateActivity + | ClearHistory + | Ping + | GetPageInfo + +struct message { { + @as("struct") messageType: string, + payload: option, +} + +// Simulation parameters sent to content script for behavior simulation +struct simulationParams { { + scrollDepth: float, + clickLinks: bool, + dwellSeconds: int, + fillForms: bool, +} + +// Tracking info for fake tabs opened by the background script +struct fakeTabInfo { { + tabId: int, + activity: browsingActivity, + openedAt: float, +} + +// Extended message structs for tab orchestration +struct bgMessage { + | SimulateOnPage(simulationParams) + | ActivityComplete({tabId: int}) + | CloseFakeTab({tabId: int}) + +module StorageKey = { + fn config = "doubletrack_config" + fn profile = "doubletrack_profile" + fn statistics = "doubletrack_statistics" + fn activityHistory = "doubletrack_activity_history" +} + +// Serialization helpers +fn genderToString = (g: gender): string => + switch g { + | Male => "Male" + | Female => "Female" + | NonBinary => "NonBinary" + | PreferNotToSay => "PreferNotToSay" + } + +fn activityTypeToString = (a: activityType): string => + switch a { + | Search => "Search" + | PageVisit => "PageVisit" + | VideoWatch => "VideoWatch" + | Shopping => "Shopping" + | SocialMedia => "SocialMedia" + | News => "News" + | Research => "Research" + } + +fn privacyModeToString = (p: privacyMode): string => + switch p { + | Full => "Full" + | Moderate => "Moderate" + | Minimal => "Minimal" + | Disabled => "Disabled" + } + +fn browsingStyleToString = (b: browsingStyle): string => + switch b { + | Focused => "Focused" + | Explorer => "Explorer" + | Researcher => "Researcher" + | Casual => "Casual" + } + +fn activityLevelToString = (a: activityLevel): string => + switch a { + | Low => "Low" + | Medium => "Medium" + | High => "High" + | VeryHigh => "VeryHigh" + } + +fn interestCategoryToString = (i: interestCategory): string => + switch i { + | Technology => "Technology" + | Gaming => "Gaming" + | Sports => "Sports" + | Fitness => "Fitness" + | Cooking => "Cooking" + | Travel => "Travel" + | Fashion => "Fashion" + | Music => "Music" + | Movies => "Movies" + | Books => "Books" + | Art => "Art" + | Science => "Science" + | Politics => "Politics" + | News => "News" + | FinanceInterest => "Finance" + | HomeImprovement => "HomeImprovement" + | Gardening => "Gardening" + | Photography => "Photography" + | Programming => "Programming" + | DataScience => "DataScience" + } + diff --git a/lib/ocaml/Wasm.affine b/lib/ocaml/Wasm.affine index 3b49485..f6ec51e 100644 --- a/lib/ocaml/Wasm.affine +++ b/lib/ocaml/Wasm.affine @@ -1,7 +1,304 @@ // SPDX-License-Identifier: MPL-2.0 -// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell -// Ported via Harvard Engine bulk-processor +// Ported via Harvard Engine (Semantic pass) module Wasm; -// TODO: Complete semantic implementation +// SPDX-License-Identifier: MPL-2.0 +// WASM module wrapper for Rust core +// Loads compiled wasm-pack output with mock fallback + +open Types + +// Singleton state +// Use a concrete struct to avoid polymorphic ref issues +struct wasmModule +fn wasmModuleRef: ref> = ref(None) +fn initializedRef: ref = ref(false) + +// Dynamic import of the compiled WASM module +fn importWasmModule: unit => promise<{..}> = %raw(` + function() { + // wasm-pack produces a JS glue file alongside the .wasm binary + // In the built extension, it lives at wasm/doubletrack_core.js + return import(chrome.runtime.getURL('wasm/doubletrack_core.js')) + .then(function(mod) { + // wasm-pack web target exports a default init function + if (mod.default && structof mod.default === 'function') { + return mod.default().then(function() { return mod; }); + } + return mod; + }); + } +`) + +fn initialize = async (): unit => { + if initializedRef.contents { + () + } else { + // Try loading real WASM module + switch await importWasmModule() { + | wasmModule => + wasmModuleRef := Some(Obj.magic(wasmModule)) + initializedRef := true + Console.log("DoubleTrack: WASM core loaded successfully") + | exception _exn => + Console.warn("DoubleTrack: WASM load failed, falling back to mock engine") + initializedRef := true + } + } +} + +fn ensureInitialized = async (): unit => { + if !initializedRef.contents { + await initialize() + } +} + +// Call a WASM export if available, otherwise fall back to mock +fn callWasm: (string, array) => option = (fnName, args) => { + switch wasmModuleRef.contents { + | Some(m) => + fn fn: option = %raw(`function(m, name) { return m[name]; }`)(Obj.magic(m), fnName) + switch fn { + | Some(f) => + fn result: Obj.t = %raw(`function(f, args) { return f.apply(null, args); }`)(f, args) + Some(result) + | None => None + } + | None => None + } +} + +// --- Mock implementations (fallback when WASM is unavailable) --- + +fn mockGenerateProfile = (seed: option): profile => { + fn timestamp = Date.now() + fn randStr = Math.random()->Float.toString + fn randPart = randStr->String.slice(~start=2, ~end=String.length(randStr)) + fn id = `profile_${Float.toString(timestamp)}_${randPart}` + + fn names = ["Alex Smith", "Jordan Johnson", "Taylor Williams", "Casey Brown", "Morgan Davis"] + fn nameIndex = switch seed { + | Some(s) => mod(s, 5) + | None => Float.toInt(Math.floor(Math.random() *. 5.0)) + } + fn name = names[nameIndex]->Option.getOr("Alex Smith") + + fn allInterests: array = [ + Technology, + Gaming, + Sports, + Fitness, + Cooking, + Travel, + Fashion, + Music, + Movies, + Books, + Art, + Science, + News, + FinanceInterest, + HomeImprovement, + Photography, + Programming, + DataScience, + ] + + // Pick 3-6 random interests + fn numInterests = 3 + Float.toInt(Math.floor(Math.random() *. 4.0)) + fn shuffled = Array.copy(allInterests) + // Fisher-Yates shuffle on first numInterests elements + for i in 0 to numInterests - 1 { + fn j = i + Float.toInt(Math.floor(Math.random() *. Int.toFloat(Array.length(shuffled) - i))) + fn tmp = shuffled[i]->Option.getOrThrow(~message="shuffle source index") + Array.setUnsafe(shuffled, i, shuffled[j]->Option.getOrThrow(~message="shuffle swap index")) + Array.setUnsafe(shuffled, j, tmp) + } + fn interests = Array.slice(shuffled, ~start=0, ~end=numInterests) + + fn genders: array = [Male, Female, NonBinary, PreferNotToSay] + fn gender = + genders[Float.toInt(Math.floor(Math.random() *. 4.0))]->Option.getOr( + Male, + ) + + fn locations: array = [Urban, Suburban, Rural] + fn locationType = + locations[Float.toInt(Math.floor(Math.random() *. 3.0))]->Option.getOr( + Urban, + ) + + fn styles: array = [Focused, Explorer, Researcher, Casual] + fn browsingStyle = + styles[Float.toInt(Math.floor(Math.random() *. 4.0))]->Option.getOr( + Explorer, + ) + + fn levels: array = [Low, Medium, High, VeryHigh] + fn activityLevel = + levels[Float.toInt(Math.floor(Math.random() *. 4.0))]->Option.getOr( + Medium, + ) + + { + id, + name, + demographics: { + age: 22 + Float.toInt(Math.floor(Math.random() *. 45.0)), + gender, + locationType, + occupationCategory: Technology, + educationLevel: Bachelor, + }, + interests, + browsingStyle, + activityLevel, + createdAt: Date.now() /. 1000.0, + } +} + +// Search queries and domains for mock activity generation +fn mockSearchQueries: array = [ + "best coffee shops nearby", + "how to fix a leaky faucet", + "latest smartphone reviews 2026", + "healthy dinner recipes under 30 minutes", + "vintage car restoration tips", + "Slovenian poetry translations", + "home espresso machine comparison", + "weekend hiking trails", + "beginner piano lessons online", + "climate change research papers", + "best board games for adults", + "how to start a vegetable garden", + "mechanical keyboard guide", + "travel insurance comparison", + "history of Japanese ceramics", +] + +fn mockDomains: array = [ + "reddit.com", + "stackoverflow.com", + "medium.com", + "theguardian.com", + "arstechnica.com", + "bbc.com", + "allrecipes.com", + "instructables.com", + "wikipedia.org", + "youtube.com", + "nytimes.com", + "bonappetit.com", + "nature.com", + "wired.com", +] + +fn mockGenerateActivities = (profile: profile, durationHours: float): array => { + fn baseTime = Date.now() /. 1000.0 + fn activitiesCount = Pervasives.max(1, Float.toInt(durationHours *. 4.0)) + + fn activityTypes: array = [ + Search, + PageVisit, + VideoWatch, + Shopping, + SocialMedia, + News, + Research, + ] + + Array.fromInitializer(~length=activitiesCount, i => { + fn timestamp = baseTime +. Int.toFloat(i) *. 900.0 +. Math.random() *. 300.0 + + fn at = + activityTypes[Float.toInt( + Math.floor(Math.random() *. Int.toFloat(Array.length(activityTypes))), + )]->Option.getOr(PageVisit) + + fn queryIdx = Float.toInt( + Math.floor(Math.random() *. Int.toFloat(Array.length(mockSearchQueries))), + ) + fn domainIdx = Float.toInt( + Math.floor(Math.random() *. Int.toFloat(Array.length(mockDomains))), + ) + + fn query = mockSearchQueries[queryIdx]->Option.getOr("latest news") + fn domain = mockDomains[domainIdx]->Option.getOr("example.com") + + fn (url, title) = switch at { + | Search => { + fn encoded = String.replaceRegExp(query, %re("/\\s+/g"), "+") + (`https://www.google.com/search?q=${encoded}`, `${query} - Google Search`) + } + | VideoWatch => { + fn vidStr = Math.random()->Float.toString + fn vidId = vidStr->String.slice(~start=2, ~end=String.length(vidStr))->String.slice(~start=0, ~end=11) + ( + `https://www.youtube.com/watch?v=${vidId}`, + `Video: ${query}`, + ) + } + | _ => (`https://${domain}/${query->String.replaceRegExp(%re("/\\s+/g"), "-")->String.toLowerCase}`, `${query} | ${domain}`) + } + + { + activityType: at, + url, + title, + durationSeconds: 30 + Float.toInt(Math.floor(Math.random() *. 240.0)), + timestamp, + interestCategory: profile.interests[Float.toInt( + Math.floor(Math.random() *. Int.toFloat(Array.length(profile.interests))), + )], + } + }) +} + +fn mockGetSchedule = (_profile: profile): schedule => { + { + timePatterns: [], + timezoneOffset: 0, + } +} + +// --- Public API --- + +fn generateProfile = async (seed: option): profile => { + await ensureInitialized() + fn seedArg: Obj.t = switch seed { + | Some(s) => Obj.magic(s) + | None => Obj.magic(Nullable.null) + } + + switch callWasm("generate_profile", [seedArg]) { + | Some(result) => Obj.magic(result) + | None => mockGenerateProfile(seed) + } +} + +fn generateActivities = async (profile: profile, durationHours: float): array => { + await ensureInitialized() + switch callWasm("generate_activities", [Obj.magic(profile), Obj.magic(durationHours)]) { + | Some(result) => Obj.magic(result) + | None => mockGenerateActivities(profile, durationHours) + } +} + +fn validateProfile = async (profile: profile): bool => { + await ensureInitialized() + switch callWasm("validate_profile", [Obj.magic(profile)]) { + | Some(result) => Obj.magic(result) + | None => + profile.name != "" && Array.length(profile.interests) > 0 && profile.demographics.age >= 18 + } +} + +fn getActivitySchedule = async (profile: profile): schedule => { + await ensureInitialized() + switch callWasm("get_activity_schedule", [Obj.magic(profile)]) { + | Some(result) => Obj.magic(result) + | None => mockGetSchedule(profile) + } +} + diff --git a/src/background/Background.affine b/src/background/Background.affine index f3b8f31..4260174 100644 --- a/src/background/Background.affine +++ b/src/background/Background.affine @@ -1,7 +1,330 @@ // SPDX-License-Identifier: MPL-2.0 -// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell -// Ported via Harvard Engine bulk-processor +// Ported via Harvard Engine (Semantic pass) module Background; -// TODO: Complete semantic implementation +// SPDX-License-Identifier: MPL-2.0 +// Background service worker for DoubleTrack Browser + +open Types + +// State +fn isRunningRef: ref = ref(false) + +// Track fake tabs: tabId -> fakeTabInfo +fn fakeTabsRef: ref> = ref(Dict.make()) + +fn maxConcurrentFakeTabs = 3 + +@val external setTimeout: (unit => unit, int) => int = "setTimeout" + +fn getNextMidnight = (): float => { + fn now = Date.make() + // Create tomorrow at midnight using raw JS since ReScript 12 Date API uses int args + fn tomorrow: Date.t = %raw(` + (function(now) { + var d = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1); + return d; + }) + `)(now) + Date.getTime(tomorrow) +} + +fn setupAlarms = () => { + // Daily reset at midnight + Chrome.Alarms.create( + "daily-reset", + {"when": getNextMidnight(), "periodInMinutes": 24.0 *. 60.0}, + ) + + // Update profile age daily + Chrome.Alarms.create("update-profile-age", {"periodInMinutes": 60.0 *. 24.0}) +} + +fn activeFakeTabCount = (): int => { + Array.length(Dict.keysToArray(fakeTabsRef.contents)) +} + +// Close a fake tab and clean up tracking +fn closeFakeTab = async (tabId: int): unit => { + Dict.delete(fakeTabsRef.contents, Int.toString(tabId)) + switch await Chrome.Tabs.remove(tabId) { + | () => Console.log2("DoubleTrack: Closed fake tab", tabId) + | exception _exn => () // Tab may already be closed + } +} + +// Clean up stale fake tabs (open > 5 minutes) +fn cleanupStaleFakeTabs = async (): unit => { + fn now = Date.now() + fn staleThreshold = 5.0 *. 60.0 *. 1000.0 // 5 minutes in ms + fn keys = Dict.keysToArray(fakeTabsRef.contents) + + fn _: unit = await keys->Array.reduce(Promise.resolve(), async (accPromise, key) => { + await accPromise + switch Dict.get(fakeTabsRef.contents, key) { + | Some(info) if now -. info.openedAt > staleThreshold => + await closeFakeTab(info.tabId) + | _ => () + } + }) +} + +// Generate simulation parameters based on activity struct and profile +fn getSimulationParams = (activity: browsingActivity): simulationParams => { + fn baseScroll = switch activity.activityType { + | Research => 0.8 + | News => 0.6 + | Shopping => 0.5 + | PageVisit => 0.7 + | Search => 0.3 + | VideoWatch => 0.2 + | SocialMedia => 0.4 + } + + fn scrollJitter = Math.random() *. 0.3 -. 0.15 + fn scrollDepth = Math.min(1.0, Math.max(0.1, baseScroll +. scrollJitter)) + + { + scrollDepth, + clickLinks: activity.activityType != Search && Math.random() > 0.4, + dwellSeconds: Pervasives.max(5, activity.durationSeconds / 3), + fillForms: false, + } +} + +fn simulateActivity = async (): unit => { + fn config = await Storage.getConfig() + fn profile = await Storage.getProfile() + + switch (config.enabled, profile) { + | (true, Some(p)) => + // Don't open more tabs if at capacity + if activeFakeTabCount() >= maxConcurrentFakeTabs { + Console.log("DoubleTrack: At max fake tab capacity, skipping") + } else { + + // Check schedule if enabled + if config.respectSchedule { + fn _schedule = await Wasm.getActivitySchedule(p) + // Schedule checking logic would go here + } + + // Generate a single activity + fn activities = await Wasm.generateActivities(p, 0.25) // 15 minutes worth + + if Array.length(activities) > 0 { + switch activities[0] { + | Some(activity) => + // Record the activity in storage + await Storage.addActivity(activity) + + // Open a real tab with the generated URL + fn tab = await Chrome.Tabs.create({"url": activity.url, "active": false}) + + switch tab.id { + | Some(tabId) => + // Track this fake tab + fn info: fakeTabInfo = { + tabId, + activity, + openedAt: Date.now(), + } + Dict.set(fakeTabsRef.contents, Int.toString(tabId), info) + + Console.log2("DoubleTrack: Opened fake tab", activity.url) + + // After a short delay for the page to load, send simulation command + fn params = getSimulationParams(activity) + fn _ = setTimeout(() => { + fn _ = + Chrome.Tabs.sendMessage( + tabId, + Obj.magic({ + "struct": "SIMULATE_ACTIVITY", + "payload": Obj.magic(params), + }), + ) + ->Promise.thenResolve(_response => { + // Schedule tab close after simulation duration + fn closeDelay = (params.dwellSeconds + 5) * 1000 + fn _ = setTimeout(() => { + closeFakeTab(tabId)->ignore + }, closeDelay) + }) + ->Promise.catch(_err => { + // Content script may not be ready — close tab after dwell time anyway + fn _ = setTimeout(() => { + closeFakeTab(tabId)->ignore + }, activity.durationSeconds * 1000) + Promise.resolve() + }) + }, 3000) // 3s page load grace period + | None => Console.error("DoubleTrack: Tab created but no ID returned") + } + | None => () + } + } + } // end else (not at capacity) + | _ => () + } +} + +fn handleAlarm = async (alarm: Chrome.Alarms.alarm): unit => { + switch alarm.name { + | "daily-reset" => await Storage.resetDailyStatistics() + | "update-profile-age" => await Storage.updateProfileAge() + | "simulate-activity" => + if isRunningRef.contents { + await cleanupStaleFakeTabs() + await simulateActivity() + } + | _ => () + } +} + +fn startSimulation = async (): unit => { + if isRunningRef.contents { + Console.log("Simulation already running") + } else { + fn config = await Storage.getConfig() + fn profile = await Storage.getProfile() + + switch profile { + | None => Console.error("No profile found, cannot start simulation") + | Some(_) => + Console.log("Starting activity simulation") + isRunningRef.contents = true + + // Schedule periodic activity simulation + fn baseInterval = 15.0 + fn interval = baseInterval /. config.noiseLevel + + Chrome.Alarms.create( + "simulate-activity", + {"periodInMinutes": Math.max(5.0, interval)}, + ) + + // Run initial simulation + await simulateActivity() + } + } +} + +fn stopSimulation = async (): unit => { + Console.log("Stopping activity simulation") + isRunningRef.contents = false + Chrome.Alarms.clear("simulate-activity") + + // Close all fake tabs + fn keys = Dict.keysToArray(fakeTabsRef.contents) + fn _: unit = await keys->Array.reduce(Promise.resolve(), async (accPromise, key) => { + await accPromise + switch Dict.get(fakeTabsRef.contents, key) { + | Some(info) => await closeFakeTab(info.tabId) + | None => () + } + }) +} + +fn handleMessage = async (messageJson: JSON.t): JSON.t => { + fn message: message = Obj.magic(messageJson) + + switch message.messageType { + | "GET_CONFIG" => + fn config = await Storage.getConfig() + Obj.magic(config) + + | "UPDATE_CONFIG" => + fn config: extensionConfig = Obj.magic(message.payload) + await Storage.setConfig(config) + if config.enabled { + await startSimulation() + } else { + await stopSimulation() + } + Obj.magic({"success": true}) + + | "GENERATE_PROFILE" => + fn seed: option = Obj.magic(message.payload) + fn profile = await Wasm.generateProfile(seed) + await Storage.setProfile(profile) + await Storage.clearActivityHistory() + Obj.magic(profile) + + | "GET_STATISTICS" => + fn stats = await Storage.getStatistics() + Obj.magic(stats) + + | "GET_CURRENT_PROFILE" => + fn profile = await Storage.getProfile() + Obj.magic(profile) + + | "SIMULATE_ACTIVITY" => + await simulateActivity() + Obj.magic({"success": true}) + + | "ACTIVITY_COMPLETE" => + // Content script finished simulation on a page + Console.log("DoubleTrack: Content script reported activity complete") + Obj.magic({"acknowledged": true}) + + | "CLEAR_HISTORY" => + await Storage.clearActivityHistory() + Obj.magic({"success": true}) + + | _ => Obj.magic({"error": `Unknown message struct: ${message.messageType}`}) + } +} + +fn setupMessageListeners = () => { + Chrome.Runtime.onMessageAddListener((message, _sender, sendResponse) => { + fn _ = + handleMessage(message) + ->Promise.thenResolve(response => { + sendResponse(Obj.magic(response)) + }) + ->Promise.catch(error => { + Console.error2("Error handling message:", error) + sendResponse(Obj.magic({"error": "Internal error"})) + Promise.resolve() + }) + true // Return true to indicate async response + }) +} + +fn initialize = async (): unit => { + Console.log("DoubleTrack Browser: Initializing background service") + + // Initialize WASM module + switch await Wasm.initialize() { + | () => Console.log("WASM core initialized successfully") + | exception exn => Console.error2("Failed to initialize WASM core:", exn) + } + + // Set up message listeners + setupMessageListeners() + + // Set up alarms for scheduled tasks + setupAlarms() + Chrome.Alarms.onAlarmAddListener(alarm => { + fn _ = handleAlarm(alarm) + }) + + // Listen for tab removal to clean up tracking + Chrome.Tabs.OnRemoved.addListener(tabId => { + Dict.delete(fakeTabsRef.contents, Int.toString(tabId)) + }) + + // Load configuration and start if enabled + fn config = await Storage.getConfig() + if config.enabled && Option.isSome(config.currentProfile) { + await startSimulation() + } + + Console.log("DoubleTrack Browser: Background service initialized") +} + +// Initialize the background service +fn _ = initialize() + diff --git a/src/bindings/Chrome.affine b/src/bindings/Chrome.affine index 16852c2..80649d8 100644 --- a/src/bindings/Chrome.affine +++ b/src/bindings/Chrome.affine @@ -1,7 +1,84 @@ // SPDX-License-Identifier: MPL-2.0 -// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell -// Ported via Harvard Engine bulk-processor +// Ported via Harvard Engine (Semantic pass) module Chrome; -// TODO: Complete semantic implementation +// SPDX-License-Identifier: MPL-2.0 +// Chrome WebExtension API bindings for ReScript + +module Storage = { + module Local = { + @scope(("chrome", "storage", "local")) @val + external get: string => promise> = "get" + + @scope(("chrome", "storage", "local")) @val + external getMultiple: array => promise> = "get" + + @scope(("chrome", "storage", "local")) @val + external set: Dict.t => promise = "set" + } +} + +module Runtime = { + struct messageResponse + struct sender + + @scope(("chrome", "runtime")) @val + external sendMessage: JSON.t => promise = "sendMessage" + + @scope(("chrome", "runtime")) @val + external onMessageAddListener: ( + (JSON.t, sender, messageResponse => unit) => bool + ) => unit = "onMessage.addListener" + + @scope(("chrome", "runtime")) @val + external openOptionsPage: unit => unit = "openOptionsPage" + + @scope(("chrome", "runtime")) @val + external getURL: string => string = "getURL" + + module LastError = { + @scope(("chrome", "runtime")) @val @return(nullable) + external lastError: option<{..}> = "lastError" + } +} + +module Alarms = { + struct alarm { {name: string} + + struct alarmCreateInfo { { + when_: option, + periodInMinutes: option, + } + + @scope(("chrome", "alarms")) @val + external create: (string, {..}) => unit = "create" + + @scope(("chrome", "alarms")) @val + external clear: string => unit = "clear" + + @scope(("chrome", "alarms")) @val + external onAlarmAddListener: (alarm => unit) => unit = "onAlarm.addListener" +} + +module Tabs = { + struct tab { {id: option, url: option} + + @scope(("chrome", "tabs")) @val + external create: {..} => promise = "create" + + @scope(("chrome", "tabs")) @val + external remove: int => promise = "remove" + + @scope(("chrome", "tabs")) @val + external query: {..} => promise> = "query" + + @scope(("chrome", "tabs")) @val + external sendMessage: (int, JSON.t) => promise = "sendMessage" + + module OnRemoved = { + @scope(("chrome", "tabs", "onRemoved")) @val + external addListener: (int => unit) => unit = "addListener" + } +} + diff --git a/src/bindings/DomBindings.affine b/src/bindings/DomBindings.affine index 62d016b..47c1f0f 100644 --- a/src/bindings/DomBindings.affine +++ b/src/bindings/DomBindings.affine @@ -1,7 +1,53 @@ // SPDX-License-Identifier: MPL-2.0 -// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell -// Ported via Harvard Engine bulk-processor +// Ported via Harvard Engine (Semantic pass) module DomBindings; -// TODO: Complete semantic implementation +// SPDX-License-Identifier: MPL-2.0 +// DOM bindings for ReScript + +@val external document: Dom.document = "document" +@val external window: Dom.window = "window" + +module Document = { + @send external getElementById: (Dom.document, string) => Nullable.t = "getElementById" + @send external querySelector: (Dom.document, string) => Nullable.t = "querySelector" + @get external title: Dom.document => string = "title" + @send external addEventListener: (Dom.document, string, unit => unit) => unit = "addEventListener" +} + +module Window = { + @send external close: Dom.window => unit = "close" + module Location = { + @get external href: Dom.window => string = "location.href" + } +} + +module Element = { + @send external addEventListener: (Dom.element, string, Dom.event => unit) => unit = "addEventListener" + @get external classList: Dom.element => Dom.domTokenList = "classList" + @get external textContent: Dom.element => string = "textContent" + @set external setTextContent: (Dom.element, string) => unit = "textContent" + @get external value: Dom.element => string = "value" + @set external setValue: (Dom.element, string) => unit = "value" + @get external checked: Dom.element => bool = "checked" + @set external setChecked: (Dom.element, bool) => unit = "checked" + @get external disabled: Dom.element => bool = "disabled" + @set external setDisabled: (Dom.element, bool) => unit = "disabled" + @get external style: Dom.element => Dom.cssStyleDeclaration = "style" + @set external setInnerHTML: (Dom.element, string) => unit = "innerHTML" +} + +module ClassList = { + @send external add: (Dom.domTokenList, string) => unit = "add" + @send external remove: (Dom.domTokenList, string) => unit = "remove" +} + +module Style = { + @set external setDisplay: (Dom.cssStyleDeclaration, string) => unit = "display" +} + +module Event = { + @get external target: Dom.event => Dom.element = "target" +} + diff --git a/src/content/Content.affine b/src/content/Content.affine index 3f9fd35..846f714 100644 --- a/src/content/Content.affine +++ b/src/content/Content.affine @@ -1,7 +1,210 @@ // SPDX-License-Identifier: MPL-2.0 -// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell -// Ported via Harvard Engine bulk-processor +// Ported via Harvard Engine (Semantic pass) module Content; -// TODO: Complete semantic implementation +// SPDX-License-Identifier: MPL-2.0 +// Content script for DoubleTrack Browser +// Runs in the context of web pages — simulates realistic user behavior + +// External DOM bindings needed for behavior simulation +@val external setTimeout: (unit => unit, int) => int = "setTimeout" +@val external clearTimeout: int => unit = "clearTimeout" +@val external setInterval: (unit => unit, int) => int = "setInterval" +@val external clearInterval: int => unit = "clearInterval" + +module Window = { + @val external scrollTo: {"top": float, "behavior": string} => unit = "window.scrollTo" + @val external scrollY: float = "window.scrollY" + @val external innerHeight: float = "window.innerHeight" +} + +module Doc = { + @val external documentElement: {..} = "document.documentElement" + @scope("document") @val + external querySelectorAll: string => array<{..}> = "querySelectorAll" + + @scope("document") @val @return(nullable) + external querySelector: string => option<{..}> = "querySelector" +} + +// Dispatch a synthetic mouse event on a target element +fn dispatchMouseEvent = (target: {..}, eventType: string) => { + fn _: unit = %raw(` + function(target, eventType) { + var rect = target.getBoundingClientRect(); + var x = rect.left + rect.width * Math.random(); + var y = rect.top + rect.height * Math.random(); + var evt = new MouseEvent(eventType, { + bubbles: true, cancelable: true, view: window, + clientX: x, clientY: y + }); + target.dispatchEvent(evt); + } + `)(target, eventType) +} + +// Get the full scrollable height of the page +fn getScrollHeight = (): float => { + fn _el = Doc.documentElement + %raw(`el.scrollHeight`)->Obj.magic +} + +// Simulate gradual, human-like scrolling +fn simulateScroll = (targetDepth: float, onComplete: unit => unit) => { + fn maxScroll = getScrollHeight() -. Window.innerHeight + fn targetY = ref(0.0) + targetY := maxScroll *. targetDepth + + fn currentY = ref(Window.scrollY) + fn scrollStep = ref(0) + + fn intervalId = ref(0) + + fn step = () => { + scrollStep := scrollStep.contents + 1 + + // Variable scroll speed: sometimes fast, sometimes slow (human-like) + fn jitter = Math.random() *. 80.0 +. 20.0 + currentY := currentY.contents +. jitter + + if currentY.contents >= targetY.contents || scrollStep.contents > 200 { + clearInterval(intervalId.contents) + onComplete() + } else { + Window.scrollTo({"top": currentY.contents, "behavior": "smooth"}) + } + } + + // Variable interval between 50ms and 300ms for human-like pacing + fn baseInterval = 100 + Float.toInt(Math.random() *. 200.0) + intervalId := setInterval(step, baseInterval) +} + +// Simulate mouse movement over visible content elements +fn simulateMouseMovement = (onComplete: unit => unit) => { + fn elements = Doc.querySelectorAll("p, h1, h2, h3, a, img, li, span") + fn numMoves = Float.toInt(Math.random() *. 5.0) + 2 + fn moveIndex = ref(0) + + fn rec doMove = () => { + if moveIndex.contents >= numMoves || Array.length(elements) == 0 { + onComplete() + } else { + fn idx = Float.toInt(Math.random() *. Int.toFloat(Array.length(elements))) + switch elements[idx] { + | Some(el) => + dispatchMouseEvent(el, "mouseover") + dispatchMouseEvent(el, "mousemove") + // Pause like a human reading, then move on + fn delay = 300 + Float.toInt(Math.random() *. 1200.0) + fn _ = setTimeout(() => { + dispatchMouseEvent(el, "mouseout") + moveIndex := moveIndex.contents + 1 + doMove() + }, delay) + | None => + moveIndex := moveIndex.contents + 1 + doMove() + } + } + } + + doMove() +} + +// Simulate clicking an internal link (same-origin navigation) +fn simulateClick = (onComplete: unit => unit) => { + fn links = Doc.querySelectorAll("a[href]") + + // Filter to internal links only + fn internalLinks: array<{..}> = %raw(` + function(links) { + return Array.from(links).filter(function(a) { + try { + var href = a.getAttribute('href'); + if (!href || href.startsWith('#') || href.startsWith('javascript:')) return false; + var url = new URL(href, window.location.origin); + return url.origin === window.location.origin; + } catch(e) { return false; } + }); + } + `)(links) + + if Array.length(internalLinks) > 0 { + fn idx = Float.toInt(Math.random() *. Int.toFloat(Array.length(internalLinks))) + switch internalLinks[idx] { + | Some(link) => + dispatchMouseEvent(link, "mouseover") + fn _ = setTimeout(() => { + dispatchMouseEvent(link, "click") + onComplete() + }, 500 + Float.toInt(Math.random() *. 800.0)) + | None => onComplete() + } + } else { + onComplete() + } +} + +// Main simulation orchestrator — runs scroll, mouse, optional click, then dwells +fn runSimulation = (params: Types.simulationParams, onComplete: unit => unit) => { + Console.log("DoubleTrack: Starting page behavior simulation") + + // Phase 1: Scroll + simulateScroll(params.scrollDepth, () => { + // Phase 2: Mouse movement + simulateMouseMovement(() => { + // Phase 3: Optional link click + if params.clickLinks && Math.random() > 0.5 { + simulateClick(() => { + // Phase 4: Dwell for remaining time then signal completion + fn _ = setTimeout(onComplete, params.dwellSeconds * 1000) + }) + } else { + // Phase 4: Dwell then signal completion + fn _ = setTimeout(onComplete, params.dwellSeconds * 1000) + } + }) + }) +} + +// Listen for messages from background script +fn _ = Chrome.Runtime.onMessageAddListener((messageJson, _sender, sendResponse) => { + fn message: Types.message = Obj.magic(messageJson) + + switch message.messageType { + | "PING" => sendResponse(Obj.magic({"status": "alive"})) + | "GET_PAGE_INFO" => + sendResponse( + Obj.magic({ + "url": %raw(`window.location.href`), + "title": %raw(`document.title`), + "timestamp": Date.now(), + }), + ) + | "SIMULATE_ACTIVITY" => + fn params: Types.simulationParams = switch message.payload { + | Some(p) => Obj.magic(p) + | None => { + scrollDepth: 0.6, + clickLinks: true, + dwellSeconds: 15, + fillForms: false, + } + } + + runSimulation(params, () => { + Console.log("DoubleTrack: Simulation complete, signaling background") + fn _ = Chrome.Runtime.sendMessage( + Obj.magic({"struct": "ACTIVITY_COMPLETE", "payload": Nullable.null}), + ) + }) + + // Respond immediately — completion signaled asynchronously + sendResponse(Obj.magic({"status": "simulation_started"})) + | _ => sendResponse(Obj.magic({"error": "Unknown message struct"})) + } + true +}) + diff --git a/src/dashboard/Dashboard.affine b/src/dashboard/Dashboard.affine index cfd242c..88fbf38 100644 --- a/src/dashboard/Dashboard.affine +++ b/src/dashboard/Dashboard.affine @@ -1,7 +1,260 @@ // SPDX-License-Identifier: MPL-2.0 -// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell -// Ported via Harvard Engine bulk-processor +// Ported via Harvard Engine (Semantic pass) module Dashboard; -// TODO: Complete semantic implementation +// SPDX-License-Identifier: MPL-2.0 +// Dashboard page for activity analytics and visualization + +open Types +open DomBindings + +// State +fn activitiesRef: ref> = ref([]) +fn profileRef: ref> = ref(None) +fn statsRef: ref> = ref(None) + +fn loadData = async (): unit => { + fn keys = [StorageKey.activityHistory, StorageKey.profile, StorageKey.statistics] + fn result = await Chrome.Storage.Local.getMultiple(keys) + + activitiesRef.contents = + Dict.get(result, StorageKey.activityHistory) + ->Option.map(Obj.magic) + ->Option.getOr([]) + + profileRef.contents = Dict.get(result, StorageKey.profile)->Option.map(Obj.magic) + + statsRef.contents = Dict.get(result, StorageKey.statistics)->Option.map(Obj.magic) +} + +fn calculateStreak = (): int => { + fn activities = activitiesRef.contents + if Array.length(activities) == 0 { + 0 + } else { + fn today = Date.make() + fn _: unit = %raw(`today.setHours(0, 0, 0, 0)`) + + fn activityDates = + activities->Array.map(a => { + fn date = Date.fromTime(a.timestamp *. 1000.0) + fn _: unit = %raw(`date.setHours(0, 0, 0, 0)`) + Date.getTime(date) + }) + + // Build a lookup dict from activity dates for O(1) membership testing + fn dateDict = Dict.make() + activityDates->Array.forEach(d => Dict.set(dateDict, Float.toString(d), true)) + fn hasDate = (d: float) => Dict.get(dateDict, Float.toString(d))->Option.isSome + fn rec countStreak = (currentDate, streak) => { + if hasDate(currentDate) { + countStreak(currentDate -. 86400000.0, streak + 1) + } else { + streak + } + } + countStreak(Date.getTime(today), 0) + } +} + +fn showEmptyState = () => { + Document.querySelector(document, ".content") + ->Nullable.toOption + ->Option.forEach(content => + Element.setInnerHTML( + content, + ` +
+
📊
+
No Activity Data Yet
+
+ Enable DoubleTrack and generate a profile to start seeing analytics +
+
+ `, + ) + ) +} + +fn renderOverviewCards = () => { + fn activities = activitiesRef.contents + fn stats = statsRef.contents->Option.getOr({ + totalActivities: 0, + activitiesToday: 0, + profileAgeDays: 0, + lastActivity: None, + activityByType: Dict.make(), + }) + + fn setField = (id, value) => + Document.getElementById(document, id) + ->Nullable.toOption + ->Option.forEach(el => Element.setTextContent(el, value)) + + setField("total-activities", Int.toString(stats.totalActivities)) + + fn avgPerDay = switch profileRef.contents { + | Some(_) => + fn days = Pervasives.max(1, stats.profileAgeDays) + stats.totalActivities / days + | None => 0 + } + setField("daily-average", Int.toString(avgPerDay)) + + fn totalSeconds = + activities->Array.reduce(0, (sum, a) => sum + a.durationSeconds) + fn hours = totalSeconds / 3600 + setField("total-duration", `${Int.toString(hours)}h`) + + fn streak = calculateStreak() + setField("streak-days", Int.toString(streak)) +} + +fn getDailyCounts = (days: int): array => { + fn counts = Array.make(~length=days, 0) + fn today = Date.make() + fn _: unit = %raw(`today.setHours(0, 0, 0, 0)`) + fn todayTime = Date.getTime(today) + + activitiesRef.contents->Array.forEach(activity => { + fn activityDate = Date.fromTime(activity.timestamp *. 1000.0) + fn _: unit = %raw(`activityDate.setHours(0, 0, 0, 0)`) + + fn daysDiff = Float.toInt((todayTime -. Date.getTime(activityDate)) /. 86400000.0) + + if daysDiff >= 0 && daysDiff < days { + fn idx = days - 1 - daysDiff + counts[idx] = counts[idx]->Option.getOr(0) + 1 + } + }) + + counts +} + +fn renderRecentActivities = () => { + Document.getElementById(document, "recent-activities") + ->Nullable.toOption + ->Option.forEach(container => { + fn recent = + activitiesRef.contents + ->Array.slice(~start=-20, ~end=Array.length(activitiesRef.contents)) + ->Array.toReversed + + fn icons = Dict.fromArray([ + ("Search", `🔍`), + ("PageVisit", `📄`), + ("VideoWatch", `🎥`), + ("Shopping", `🛒`), + ("SocialMedia", `💬`), + ("News", `📰`), + ("Research", `📚`), + ]) + + fn html = + recent + ->Array.map(activity => { + fn structStr = activityTypeToString(activity.activityType) + fn icon = Dict.get(icons, structStr)->Option.getOr(`📄`) + fn date = Date.fromTime(activity.timestamp *. 1000.0) + fn timeStr = Date.toLocaleTimeString(date) + + ` +
+
${icon}
+
+
${activity.title}
+
${activity.url}
+
+
${timeStr}
+
+ ` + }) + ->Array.join("") + + Element.setInnerHTML(container, html) + }) +} + +fn renderInsights = () => { + switch profileRef.contents { + | Some(profile) => + fn activities = activitiesRef.contents + fn stats = statsRef.contents->Option.getOr({ + totalActivities: 0, + activitiesToday: 0, + profileAgeDays: 0, + lastActivity: None, + activityByType: Dict.make(), + }) + + fn setField = (id, value) => + Document.getElementById(document, id) + ->Nullable.toOption + ->Option.forEach(el => Element.setTextContent(el, value)) + + fn age = Int.toString(profile.demographics.age) + fn occupation = Obj.magic(profile.demographics.occupationCategory)->String.toLowerCase + fn interestCount = Int.toString(Array.length(profile.interests)) + setField( + "insight-profile", + `Your profile "${profile.name}" is a ${age}-year-old ${occupation} with ${interestCount} main interests.`, + ) + + fn activityCount = Int.toString(Array.length(activities)) + fn days = Int.toString(stats.profileAgeDays) + fn avgPerDay = Int.toString( + Array.length(activities) / Pervasives.max(1, stats.profileAgeDays), + ) + setField( + "insight-activity", + `Generated ${activityCount} activities over ${days} days, averaging ${avgPerDay} per day.`, + ) + + fn style = browsingStyleToString(profile.browsingStyle)->String.toLowerCase + setField( + "insight-pattern", + `Your ${style} browsing style creates a natural, believable activity pattern.`, + ) + + fn level = activityLevelToString(profile.activityLevel)->String.toLowerCase + setField( + "insight-performance", + `Activity level is ${level}, which provides good privacy coverage without excessive resource usage.`, + ) + | None => () + } +} + +fn renderDashboard = () => { + if Array.length(activitiesRef.contents) == 0 { + showEmptyState() + } else { + renderOverviewCards() + renderRecentActivities() + renderInsights() + // Charts would be rendered here with canvas API + } +} + +fn setupEventListeners = () => { + Document.getElementById(document, "back-btn") + ->Nullable.toOption + ->Option.forEach(btn => + Element.addEventListener(btn, "click", _ => { + Chrome.Runtime.openOptionsPage() + Window.close(window) + }) + ) +} + +fn initialize = async (): unit => { + await loadData() + setupEventListeners() + renderDashboard() +} + +fn _ = Document.addEventListener(document, "DOMContentLoaded", () => { + fn _ = initialize() +}) + diff --git a/src/options/Options.affine b/src/options/Options.affine index 58c0554..8bbb168 100644 --- a/src/options/Options.affine +++ b/src/options/Options.affine @@ -1,7 +1,276 @@ // SPDX-License-Identifier: MPL-2.0 -// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell -// Ported via Harvard Engine bulk-processor +// Ported via Harvard Engine (Semantic pass) module Options; -// TODO: Complete semantic implementation +// SPDX-License-Identifier: MPL-2.0 +// Options page controller for DoubleTrack Browser + +open Types +open DomBindings + +// State +fn configRef: ref> = ref(None) +fn profileRef: ref> = ref(None) +fn statsRef: ref> = ref(None) + +fn sendMessage = async (msg: message): JSON.t => { + await Chrome.Runtime.sendMessage(Obj.magic(msg)) +} + +fn loadState = async (): unit => { + fn configResult = await sendMessage({messageType: "GET_CONFIG", payload: None}) + configRef.contents = Some(Obj.magic(configResult)) + + fn profileResult = await sendMessage({messageType: "GET_CURRENT_PROFILE", payload: None}) + profileRef.contents = Obj.magic(profileResult) + + fn statsResult = await sendMessage({messageType: "GET_STATISTICS", payload: None}) + statsRef.contents = Some(Obj.magic(statsResult)) +} + +fn updateProfileDisplay = () => { + fn noProfileMsg = Document.getElementById(document, "no-profile-message")->Nullable.toOption + fn profileDetails = Document.getElementById(document, "profile-details")->Nullable.toOption + fn exportBtn = Document.getElementById(document, "export-profile-btn")->Nullable.toOption + + switch (profileRef.contents, noProfileMsg, profileDetails, exportBtn) { + | (None, Some(msg), Some(details), Some(btn)) => + Style.setDisplay(Element.style(msg), "block") + Style.setDisplay(Element.style(details), "none") + Element.setDisabled(btn, true) + | (Some(profile), Some(msg), Some(details), Some(btn)) => + Style.setDisplay(Element.style(msg), "none") + Style.setDisplay(Element.style(details), "block") + Element.setDisabled(btn, false) + + fn setField = (id, value) => + Document.getElementById(document, id) + ->Nullable.toOption + ->Option.forEach(el => Element.setTextContent(el, value)) + + setField("profile-name", profile.name) + setField("profile-age", Int.toString(profile.demographics.age)) + setField("profile-gender", genderToString(profile.demographics.gender)) + setField("profile-location", Obj.magic(profile.demographics.locationType)) + setField("profile-occupation", Obj.magic(profile.demographics.occupationCategory)) + setField("profile-education", Obj.magic(profile.demographics.educationLevel)) + setField( + "profile-interests", + profile.interests->Array.map(interestCategoryToString)->Array.join(", "), + ) + setField("profile-style", browsingStyleToString(profile.browsingStyle)) + setField("profile-activity", activityLevelToString(profile.activityLevel)) + | _ => () + } +} + +fn updateStatistics = () => { + switch statsRef.contents { + | Some(stats) => + fn setField = (id, value) => + Document.getElementById(document, id) + ->Nullable.toOption + ->Option.forEach(el => Element.setTextContent(el, value)) + + setField("total-activities", Int.toString(stats.totalActivities)) + setField("today-activities", Int.toString(stats.activitiesToday)) + setField("profile-age-days", Int.toString(stats.profileAgeDays)) + | None => () + } +} + +fn updateUI = () => { + switch configRef.contents { + | Some(config) => + Document.getElementById(document, "enabled") + ->Nullable.toOption + ->Option.forEach(el => Element.setChecked(el, config.enabled)) + + Document.getElementById(document, "privacy-mode") + ->Nullable.toOption + ->Option.forEach(el => Element.setValue(el, privacyModeToString(config.privacyMode))) + + Document.getElementById(document, "noise-level") + ->Nullable.toOption + ->Option.forEach(slider => { + fn percentage = Float.toInt(config.noiseLevel *. 100.0) + Element.setValue(slider, Int.toString(percentage)) + }) + + Document.getElementById(document, "noise-value") + ->Nullable.toOption + ->Option.forEach(el => { + fn percentage = Float.toInt(config.noiseLevel *. 100.0) + Element.setTextContent(el, `${Int.toString(percentage)}%`) + }) + + Document.getElementById(document, "respect-schedule") + ->Nullable.toOption + ->Option.forEach(el => Element.setChecked(el, config.respectSchedule)) + + updateProfileDisplay() + updateStatistics() + | None => () + } +} + +fn handleEnabledChange = async (enabled: bool): unit => { + switch (configRef.contents, enabled, profileRef.contents) { + | (Some(_), true, None) => + Console.log("Please generate a profile first") + Document.getElementById(document, "enabled") + ->Nullable.toOption + ->Option.forEach(el => Element.setChecked(el, false)) + | (Some(config), _, _) => + fn newConfig = {...config, enabled} + fn _ = await sendMessage({messageType: "UPDATE_CONFIG", payload: Some(Obj.magic(newConfig))}) + | _ => () + } +} + +fn handleNoiseChange = async (value: string): unit => { + switch configRef.contents { + | Some(config) => + fn newLevel = Float.fromString(value)->Option.getOr(50.0) /. 100.0 + fn newConfig = {...config, noiseLevel: newLevel} + fn _ = await sendMessage({messageType: "UPDATE_CONFIG", payload: Some(Obj.magic(newConfig))}) + | None => () + } +} + +fn handleScheduleChange = async (checked: bool): unit => { + switch configRef.contents { + | Some(config) => + fn newConfig = {...config, respectSchedule: checked} + fn _ = await sendMessage({messageType: "UPDATE_CONFIG", payload: Some(Obj.magic(newConfig))}) + | None => () + } +} + +fn handleGenerateProfile = async (): unit => { + fn btn = Document.getElementById(document, "generate-profile-btn")->Nullable.toOption + + switch btn { + | Some(b) => + Element.setTextContent(b, "Generating...") + Element.setDisabled(b, true) + + fn result = await sendMessage({messageType: "GENERATE_PROFILE", payload: None}) + profileRef.contents = Some(Obj.magic(result)) + await loadState() + updateUI() + + Element.setTextContent(b, "Generate New Profile") + Element.setDisabled(b, false) + | None => () + } +} + +fn handleExportProfile = () => { + switch profileRef.contents { + | Some(profile) => + fn dataStr = JSON.stringifyAny(Obj.magic(profile))->Option.getOr("{}") + Console.log2("Export profile:", dataStr) + // In browser, would create blob and download link + | None => () + } +} + +fn handleViewDashboard = () => { + fn url = Chrome.Runtime.getURL("dashboard.html") + fn _ = Chrome.Tabs.create({"url": url, "active": true}) +} + +fn handleClearHistory = async (): unit => { + fn _ = await sendMessage({messageType: "CLEAR_HISTORY", payload: None}) + await loadState() + updateUI() +} + +fn showModal = () => { + Document.getElementById(document, "activity-modal") + ->Nullable.toOption + ->Option.forEach(modal => Style.setDisplay(Element.style(modal), "flex")) +} + +fn closeModal = () => { + Document.getElementById(document, "activity-modal") + ->Nullable.toOption + ->Option.forEach(modal => Style.setDisplay(Element.style(modal), "none")) +} + +fn setupEventListeners = () => { + Document.getElementById(document, "enabled") + ->Nullable.toOption + ->Option.forEach(el => + Element.addEventListener(el, "change", e => { + fn _ = handleEnabledChange(Element.checked(Event.target(e))) + }) + ) + + Document.getElementById(document, "noise-level") + ->Nullable.toOption + ->Option.forEach(slider => { + Element.addEventListener(slider, "input", e => { + fn value = Element.value(Event.target(e)) + Document.getElementById(document, "noise-value") + ->Nullable.toOption + ->Option.forEach(el => Element.setTextContent(el, `${value}%`)) + }) + Element.addEventListener(slider, "change", e => { + fn _ = handleNoiseChange(Element.value(Event.target(e))) + }) + }) + + Document.getElementById(document, "respect-schedule") + ->Nullable.toOption + ->Option.forEach(el => + Element.addEventListener(el, "change", e => { + fn _ = handleScheduleChange(Element.checked(Event.target(e))) + }) + ) + + Document.getElementById(document, "generate-profile-btn") + ->Nullable.toOption + ->Option.forEach(btn => + Element.addEventListener(btn, "click", _ => { + fn _ = handleGenerateProfile() + }) + ) + + Document.getElementById(document, "export-profile-btn") + ->Nullable.toOption + ->Option.forEach(btn => Element.addEventListener(btn, "click", _ => handleExportProfile())) + + Document.getElementById(document, "view-dashboard-btn") + ->Nullable.toOption + ->Option.forEach(btn => Element.addEventListener(btn, "click", _ => handleViewDashboard())) + + Document.getElementById(document, "view-history-btn") + ->Nullable.toOption + ->Option.forEach(btn => Element.addEventListener(btn, "click", _ => showModal())) + + Document.getElementById(document, "clear-history-btn") + ->Nullable.toOption + ->Option.forEach(btn => + Element.addEventListener(btn, "click", _ => { + fn _ = handleClearHistory() + }) + ) + + Document.querySelector(document, ".modal-close") + ->Nullable.toOption + ->Option.forEach(btn => Element.addEventListener(btn, "click", _ => closeModal())) +} + +fn initialize = async (): unit => { + await loadState() + setupEventListeners() + updateUI() +} + +fn _ = Document.addEventListener(document, "DOMContentLoaded", () => { + fn _ = initialize() +}) + diff --git a/src/popup/Popup.affine b/src/popup/Popup.affine index 6e0513a..af7564a 100644 --- a/src/popup/Popup.affine +++ b/src/popup/Popup.affine @@ -1,7 +1,275 @@ // SPDX-License-Identifier: MPL-2.0 -// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell -// Ported via Harvard Engine bulk-processor +// Ported via Harvard Engine (Semantic pass) module Popup; -// TODO: Complete semantic implementation +// SPDX-License-Identifier: MPL-2.0 +// Popup UI controller for DoubleTrack Browser + +open Types +open DomBindings + +// State +fn configRef: ref> = ref(None) +fn profileRef: ref> = ref(None) +fn statsRef: ref> = ref(None) + +fn sendMessage = async (msg: message): JSON.t => { + await Chrome.Runtime.sendMessage(Obj.magic(msg)) +} + +fn loadState = async (): unit => { + fn configResult = await sendMessage({messageType: "GET_CONFIG", payload: None}) + configRef.contents = Some(Obj.magic(configResult)) + + fn profileResult = await sendMessage({messageType: "GET_CURRENT_PROFILE", payload: None}) + profileRef.contents = Obj.magic(profileResult) + + fn statsResult = await sendMessage({messageType: "GET_STATISTICS", payload: None}) + statsRef.contents = Some(Obj.magic(statsResult)) +} + +fn updateProfileInfo = () => { + fn noProfile = Document.getElementById(document, "no-profile")->Nullable.toOption + fn profileDetails = Document.getElementById(document, "profile-details")->Nullable.toOption + + switch (profileRef.contents, noProfile, profileDetails) { + | (None, Some(np), Some(pd)) => + Style.setDisplay(Element.style(np), "block") + Style.setDisplay(Element.style(pd), "none") + | (Some(profile), Some(np), Some(pd)) => + Style.setDisplay(Element.style(np), "none") + Style.setDisplay(Element.style(pd), "block") + + Document.getElementById(document, "profile-name") + ->Nullable.toOption + ->Option.forEach(el => Element.setTextContent(el, profile.name)) + + Document.getElementById(document, "profile-age") + ->Nullable.toOption + ->Option.forEach(el => Element.setTextContent(el, Int.toString(profile.demographics.age))) + + Document.getElementById(document, "profile-occupation") + ->Nullable.toOption + ->Option.forEach(el => + Element.setTextContent(el, Obj.magic(profile.demographics.occupationCategory)) + ) + + Document.getElementById(document, "profile-interests") + ->Nullable.toOption + ->Option.forEach(el => + Element.setTextContent( + el, + profile.interests->Array.map(interestCategoryToString)->Array.join(", "), + ) + ) + + Document.getElementById(document, "profile-activity-level") + ->Nullable.toOption + ->Option.forEach(el => Element.setTextContent(el, activityLevelToString(profile.activityLevel))) + | _ => () + } +} + +fn updateStatistics = () => { + switch statsRef.contents { + | Some(stats) => + Document.getElementById(document, "total-activities") + ->Nullable.toOption + ->Option.forEach(el => Element.setTextContent(el, Int.toString(stats.totalActivities))) + + Document.getElementById(document, "today-activities") + ->Nullable.toOption + ->Option.forEach(el => Element.setTextContent(el, Int.toString(stats.activitiesToday))) + + Document.getElementById(document, "profile-age") + ->Nullable.toOption + ->Option.forEach(el => Element.setTextContent(el, Int.toString(stats.profileAgeDays))) + | None => () + } +} + +fn updateUI = () => { + switch configRef.contents { + | Some(config) => + fn statusDot = Document.getElementById(document, "status-dot")->Nullable.toOption + fn statusText = Document.getElementById(document, "status-text")->Nullable.toOption + fn toggleBtn = Document.getElementById(document, "toggle-btn")->Nullable.toOption + + switch (statusDot, statusText, toggleBtn) { + | (Some(dot), Some(text), Some(btn)) => + if config.enabled { + ClassList.add(Element.classList(dot), "active") + ClassList.remove(Element.classList(dot), "inactive") + Element.setTextContent(text, "Enabled") + Element.setTextContent(btn, "Disable") + ClassList.add(Element.classList(btn), "active") + } else { + ClassList.remove(Element.classList(dot), "active") + ClassList.add(Element.classList(dot), "inactive") + Element.setTextContent(text, "Disabled") + Element.setTextContent(btn, "Enable") + ClassList.remove(Element.classList(btn), "active") + } + | _ => () + } + + updateProfileInfo() + updateStatistics() + + // Update controls + Document.getElementById(document, "noise-level") + ->Nullable.toOption + ->Option.forEach(slider => { + fn percentage = Float.toInt(config.noiseLevel *. 100.0) + Element.setValue(slider, Int.toString(percentage)) + }) + + Document.getElementById(document, "noise-value") + ->Nullable.toOption + ->Option.forEach(el => { + fn percentage = Float.toInt(config.noiseLevel *. 100.0) + Element.setTextContent(el, `${Int.toString(percentage)}%`) + }) + + Document.getElementById(document, "respect-schedule") + ->Nullable.toOption + ->Option.forEach(checkbox => Element.setChecked(checkbox, config.respectSchedule)) + | None => () + } +} + +fn handleToggle = async (): unit => { + switch configRef.contents { + | Some(config) => + if Option.isNone(profileRef.contents) && !config.enabled { + // Would show confirmation dialog in browser + Console.log("No profile exists. Generate one first.") + } else { + fn newConfig = {...config, enabled: !config.enabled} + fn _ = await sendMessage({ + messageType: "UPDATE_CONFIG", + payload: Some(Obj.magic(newConfig)), + }) + await loadState() + updateUI() + } + | None => () + } +} + +fn handleGenerateProfile = async (): unit => { + fn generateBtn = Document.getElementById(document, "generate-profile-btn")->Nullable.toOption + + switch generateBtn { + | Some(btn) => + Element.setTextContent(btn, "Generating...") + Element.setDisabled(btn, true) + + fn result = await sendMessage({messageType: "GENERATE_PROFILE", payload: None}) + profileRef.contents = Some(Obj.magic(result)) + await loadState() + updateUI() + + Element.setTextContent(btn, "Generate New Profile") + Element.setDisabled(btn, false) + | None => () + } +} + +fn handleNoiseChange = async (value: string): unit => { + switch configRef.contents { + | Some(config) => + fn newLevel = Float.fromString(value)->Option.getOr(50.0) /. 100.0 + fn newConfig = {...config, noiseLevel: newLevel} + fn _ = await sendMessage({ + messageType: "UPDATE_CONFIG", + payload: Some(Obj.magic(newConfig)), + }) + | None => () + } +} + +fn handleScheduleChange = async (checked: bool): unit => { + switch configRef.contents { + | Some(config) => + fn newConfig = {...config, respectSchedule: checked} + fn _ = await sendMessage({ + messageType: "UPDATE_CONFIG", + payload: Some(Obj.magic(newConfig)), + }) + | None => () + } +} + +fn handleClearHistory = async (): unit => { + fn _ = await sendMessage({messageType: "CLEAR_HISTORY", payload: None}) + await loadState() + updateUI() +} + +fn setupEventListeners = () => { + Document.getElementById(document, "toggle-btn") + ->Nullable.toOption + ->Option.forEach(btn => + Element.addEventListener(btn, "click", _ => { + fn _ = handleToggle() + }) + ) + + Document.getElementById(document, "generate-profile-btn") + ->Nullable.toOption + ->Option.forEach(btn => + Element.addEventListener(btn, "click", _ => { + fn _ = handleGenerateProfile() + }) + ) + + Document.getElementById(document, "noise-level") + ->Nullable.toOption + ->Option.forEach(slider => { + Element.addEventListener(slider, "input", e => { + fn value = Element.value(Event.target(e)) + Document.getElementById(document, "noise-value") + ->Nullable.toOption + ->Option.forEach(el => Element.setTextContent(el, `${value}%`)) + }) + Element.addEventListener(slider, "change", e => { + fn _ = handleNoiseChange(Element.value(Event.target(e))) + }) + }) + + Document.getElementById(document, "respect-schedule") + ->Nullable.toOption + ->Option.forEach(checkbox => + Element.addEventListener(checkbox, "change", e => { + fn _ = handleScheduleChange(Element.checked(Event.target(e))) + }) + ) + + Document.getElementById(document, "options-btn") + ->Nullable.toOption + ->Option.forEach(btn => + Element.addEventListener(btn, "click", _ => Chrome.Runtime.openOptionsPage()) + ) + + Document.getElementById(document, "clear-history-btn") + ->Nullable.toOption + ->Option.forEach(btn => + Element.addEventListener(btn, "click", _ => { + fn _ = handleClearHistory() + }) + ) +} + +fn initialize = async (): unit => { + await loadState() + setupEventListeners() + updateUI() +} + +// Initialize when DOM is loaded +fn _ = Document.addEventListener(document, "DOMContentLoaded", () => { + fn _ = initialize() +}) + diff --git a/src/types/Types.affine b/src/types/Types.affine index 4006671..669e16d 100644 --- a/src/types/Types.affine +++ b/src/types/Types.affine @@ -1,7 +1,266 @@ // SPDX-License-Identifier: MPL-2.0 -// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell -// Ported via Harvard Engine bulk-processor +// Ported via Harvard Engine (Semantic pass) module Types; -// TODO: Complete semantic implementation +// SPDX-License-Identifier: MPL-2.0 +// Type definitions for DoubleTrack Browser + +struct gender { + | Male + | Female + | NonBinary + | PreferNotToSay + +struct locationType { + | Urban + | Suburban + | Rural + +struct occupationCategory { + | Technology + | Healthcare + | Education + | Finance + | Creative + | Service + | Trades + | Retired + | Student + +struct educationLevel { + | HighSchool + | SomeCollege + | Bachelor + | Master + | Doctorate + +struct interestCategory { + | Technology + | Gaming + | Sports + | Fitness + | Cooking + | Travel + | Fashion + | Music + | Movies + | Books + | Art + | Science + | Politics + | News + | FinanceInterest + | HomeImprovement + | Gardening + | Photography + | Programming + | DataScience + +struct browsingStyle { + | Focused + | Explorer + | Researcher + | Casual + +struct activityLevel { + | Low + | Medium + | High + | VeryHigh + +struct activityType { + | Search + | PageVisit + | VideoWatch + | Shopping + | SocialMedia + | News + | Research + +struct privacyMode { + | Full + | Moderate + | Minimal + | Disabled + +struct dayOfWeek { + | Monday + | Tuesday + | Wednesday + | Thursday + | Friday + | Saturday + | Sunday + +struct demographics { { + age: int, + gender: gender, + locationType: locationType, + occupationCategory: occupationCategory, + educationLevel: educationLevel, +} + +struct profile { { + id: string, + name: string, + demographics: demographics, + interests: array, + browsingStyle: browsingStyle, + activityLevel: activityLevel, + createdAt: float, +} + +struct browsingActivity { { + activityType: activityType, + url: string, + title: string, + durationSeconds: int, + timestamp: float, + interestCategory: option, +} + +struct hourRange { { + startHour: int, + endHour: int, +} + +struct timePattern { { + dayOfWeek: dayOfWeek, + activeHours: array, + activityIntensity: float, +} + +struct schedule { { + timePatterns: array, + timezoneOffset: int, +} + +struct extensionConfig { { + enabled: bool, + currentProfile: option, + noiseLevel: float, + respectSchedule: bool, + privacyMode: privacyMode, +} + +struct statistics { { + mutable totalActivities: int, + mutable activitiesToday: int, + mutable profileAgeDays: int, + mutable lastActivity: option, + mutable activityByType: Dict.t, +} + +struct messageType { + | GetConfig + | UpdateConfig + | GenerateProfile + | GetStatistics + | GetCurrentProfile + | SimulateActivity + | ClearHistory + | Ping + | GetPageInfo + +struct message { { + @as("struct") messageType: string, + payload: option, +} + +// Simulation parameters sent to content script for behavior simulation +struct simulationParams { { + scrollDepth: float, + clickLinks: bool, + dwellSeconds: int, + fillForms: bool, +} + +// Tracking info for fake tabs opened by the background script +struct fakeTabInfo { { + tabId: int, + activity: browsingActivity, + openedAt: float, +} + +// Extended message structs for tab orchestration +struct bgMessage { + | SimulateOnPage(simulationParams) + | ActivityComplete({tabId: int}) + | CloseFakeTab({tabId: int}) + +module StorageKey = { + fn config = "doubletrack_config" + fn profile = "doubletrack_profile" + fn statistics = "doubletrack_statistics" + fn activityHistory = "doubletrack_activity_history" +} + +// Serialization helpers +fn genderToString = (g: gender): string => + switch g { + | Male => "Male" + | Female => "Female" + | NonBinary => "NonBinary" + | PreferNotToSay => "PreferNotToSay" + } + +fn activityTypeToString = (a: activityType): string => + switch a { + | Search => "Search" + | PageVisit => "PageVisit" + | VideoWatch => "VideoWatch" + | Shopping => "Shopping" + | SocialMedia => "SocialMedia" + | News => "News" + | Research => "Research" + } + +fn privacyModeToString = (p: privacyMode): string => + switch p { + | Full => "Full" + | Moderate => "Moderate" + | Minimal => "Minimal" + | Disabled => "Disabled" + } + +fn browsingStyleToString = (b: browsingStyle): string => + switch b { + | Focused => "Focused" + | Explorer => "Explorer" + | Researcher => "Researcher" + | Casual => "Casual" + } + +fn activityLevelToString = (a: activityLevel): string => + switch a { + | Low => "Low" + | Medium => "Medium" + | High => "High" + | VeryHigh => "VeryHigh" + } + +fn interestCategoryToString = (i: interestCategory): string => + switch i { + | Technology => "Technology" + | Gaming => "Gaming" + | Sports => "Sports" + | Fitness => "Fitness" + | Cooking => "Cooking" + | Travel => "Travel" + | Fashion => "Fashion" + | Music => "Music" + | Movies => "Movies" + | Books => "Books" + | Art => "Art" + | Science => "Science" + | Politics => "Politics" + | News => "News" + | FinanceInterest => "Finance" + | HomeImprovement => "HomeImprovement" + | Gardening => "Gardening" + | Photography => "Photography" + | Programming => "Programming" + | DataScience => "DataScience" + } + diff --git a/src/utils/Storage.affine b/src/utils/Storage.affine index 7cd202f..62821ed 100644 --- a/src/utils/Storage.affine +++ b/src/utils/Storage.affine @@ -1,7 +1,145 @@ // SPDX-License-Identifier: MPL-2.0 -// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell -// Ported via Harvard Engine bulk-processor +// Ported via Harvard Engine (Semantic pass) module Storage; -// TODO: Complete semantic implementation +// SPDX-License-Identifier: MPL-2.0 +// Storage utility for managing extension data + +open Types + +// JSON encoding/decoding helpers +external jsonParse: string => JSON.t = "JSON.parse" +external jsonStringify: 'a => string = "JSON.stringify" + +fn defaultConfig: extensionConfig = { + enabled: false, + currentProfile: None, + noiseLevel: 0.5, + respectSchedule: true, + privacyMode: Moderate, +} + +fn defaultStatistics: unit => statistics = () => { + totalActivities: 0, + activitiesToday: 0, + profileAgeDays: 0, + lastActivity: None, + activityByType: Dict.make(), +} + +fn getConfig = async (): extensionConfig => { + fn result = await Chrome.Storage.Local.get(StorageKey.config) + switch Dict.get(result, StorageKey.config) { + | Some(json) => + // In production, proper JSON decoding would be used + Obj.magic(json) + | None => defaultConfig + } +} + +fn setConfig = async (config: extensionConfig): unit => { + fn dict = Dict.make() + Dict.set(dict, StorageKey.config, Obj.magic(config)) + await Chrome.Storage.Local.set(dict) +} + +fn getProfile = async (): option => { + fn result = await Chrome.Storage.Local.get(StorageKey.profile) + switch Dict.get(result, StorageKey.profile) { + | Some(json) => Some(Obj.magic(json)) + | None => None + } +} + +fn setProfile = async (profile: profile): unit => { + fn dict = Dict.make() + Dict.set(dict, StorageKey.profile, Obj.magic(profile)) + await Chrome.Storage.Local.set(dict) +} + +fn getStatistics = async (): statistics => { + fn result = await Chrome.Storage.Local.get(StorageKey.statistics) + switch Dict.get(result, StorageKey.statistics) { + | Some(json) => Obj.magic(json) + | None => defaultStatistics() + } +} + +fn setStatistics = async (stats: statistics): unit => { + fn dict = Dict.make() + Dict.set(dict, StorageKey.statistics, Obj.magic(stats)) + await Chrome.Storage.Local.set(dict) +} + +fn getActivityHistory = async (): array => { + fn result = await Chrome.Storage.Local.get(StorageKey.activityHistory) + switch Dict.get(result, StorageKey.activityHistory) { + | Some(json) => Obj.magic(json) + | None => [] + } +} + +fn addActivity = async (activity: browsingActivity): unit => { + fn history = await getActivityHistory() + fn newHistory = Array.concat(history, [activity]) + // Keep only the last 1000 activities + fn trimmedHistory = if Array.length(newHistory) > 1000 { + Array.slice(newHistory, ~start=Array.length(newHistory) - 1000, ~end=Array.length(newHistory)) + } else { + newHistory + } + fn dict = Dict.make() + Dict.set(dict, StorageKey.activityHistory, Obj.magic(trimmedHistory)) + await Chrome.Storage.Local.set(dict) + + // Update statistics + fn stats = await getStatistics() + stats.totalActivities = stats.totalActivities + 1 + stats.lastActivity = Some(activity.timestamp) + + // Update activities today + fn today = Date.make() + fn _: unit = %raw(`today.setHours(0, 0, 0, 0)`) + fn activityDate = Date.fromTime(activity.timestamp *. 1000.0) + if Date.getTime(activityDate) >= Date.getTime(today) { + stats.activitiesToday = stats.activitiesToday + 1 + } + + // Update activity by struct + fn structStr = activityTypeToString(activity.activityType) + fn currentCount = switch Dict.get(stats.activityByType, structStr) { + | Some(c) => c + | None => 0 + } + Dict.set(stats.activityByType, structStr, currentCount + 1) + + await setStatistics(stats) +} + +fn clearActivityHistory = async (): unit => { + fn dict = Dict.make() + Dict.set(dict, StorageKey.activityHistory, Obj.magic([])) + await Chrome.Storage.Local.set(dict) + await setStatistics(defaultStatistics()) +} + +fn resetDailyStatistics = async (): unit => { + fn stats = await getStatistics() + stats.activitiesToday = 0 + await setStatistics(stats) +} + +fn updateProfileAge = async (): unit => { + fn profile = await getProfile() + switch profile { + | Some(p) => + fn stats = await getStatistics() + fn now = Date.now() /. 1000.0 + fn ageSeconds = now -. p.createdAt + stats.profileAgeDays = Float.toInt(ageSeconds /. 86400.0) + await setStatistics(stats) + | None => () + } +} + diff --git a/src/utils/Wasm.affine b/src/utils/Wasm.affine index 3b49485..f6ec51e 100644 --- a/src/utils/Wasm.affine +++ b/src/utils/Wasm.affine @@ -1,7 +1,304 @@ // SPDX-License-Identifier: MPL-2.0 -// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell -// Ported via Harvard Engine bulk-processor +// Ported via Harvard Engine (Semantic pass) module Wasm; -// TODO: Complete semantic implementation +// SPDX-License-Identifier: MPL-2.0 +// WASM module wrapper for Rust core +// Loads compiled wasm-pack output with mock fallback + +open Types + +// Singleton state +// Use a concrete struct to avoid polymorphic ref issues +struct wasmModule +fn wasmModuleRef: ref> = ref(None) +fn initializedRef: ref = ref(false) + +// Dynamic import of the compiled WASM module +fn importWasmModule: unit => promise<{..}> = %raw(` + function() { + // wasm-pack produces a JS glue file alongside the .wasm binary + // In the built extension, it lives at wasm/doubletrack_core.js + return import(chrome.runtime.getURL('wasm/doubletrack_core.js')) + .then(function(mod) { + // wasm-pack web target exports a default init function + if (mod.default && structof mod.default === 'function') { + return mod.default().then(function() { return mod; }); + } + return mod; + }); + } +`) + +fn initialize = async (): unit => { + if initializedRef.contents { + () + } else { + // Try loading real WASM module + switch await importWasmModule() { + | wasmModule => + wasmModuleRef := Some(Obj.magic(wasmModule)) + initializedRef := true + Console.log("DoubleTrack: WASM core loaded successfully") + | exception _exn => + Console.warn("DoubleTrack: WASM load failed, falling back to mock engine") + initializedRef := true + } + } +} + +fn ensureInitialized = async (): unit => { + if !initializedRef.contents { + await initialize() + } +} + +// Call a WASM export if available, otherwise fall back to mock +fn callWasm: (string, array) => option = (fnName, args) => { + switch wasmModuleRef.contents { + | Some(m) => + fn fn: option = %raw(`function(m, name) { return m[name]; }`)(Obj.magic(m), fnName) + switch fn { + | Some(f) => + fn result: Obj.t = %raw(`function(f, args) { return f.apply(null, args); }`)(f, args) + Some(result) + | None => None + } + | None => None + } +} + +// --- Mock implementations (fallback when WASM is unavailable) --- + +fn mockGenerateProfile = (seed: option): profile => { + fn timestamp = Date.now() + fn randStr = Math.random()->Float.toString + fn randPart = randStr->String.slice(~start=2, ~end=String.length(randStr)) + fn id = `profile_${Float.toString(timestamp)}_${randPart}` + + fn names = ["Alex Smith", "Jordan Johnson", "Taylor Williams", "Casey Brown", "Morgan Davis"] + fn nameIndex = switch seed { + | Some(s) => mod(s, 5) + | None => Float.toInt(Math.floor(Math.random() *. 5.0)) + } + fn name = names[nameIndex]->Option.getOr("Alex Smith") + + fn allInterests: array = [ + Technology, + Gaming, + Sports, + Fitness, + Cooking, + Travel, + Fashion, + Music, + Movies, + Books, + Art, + Science, + News, + FinanceInterest, + HomeImprovement, + Photography, + Programming, + DataScience, + ] + + // Pick 3-6 random interests + fn numInterests = 3 + Float.toInt(Math.floor(Math.random() *. 4.0)) + fn shuffled = Array.copy(allInterests) + // Fisher-Yates shuffle on first numInterests elements + for i in 0 to numInterests - 1 { + fn j = i + Float.toInt(Math.floor(Math.random() *. Int.toFloat(Array.length(shuffled) - i))) + fn tmp = shuffled[i]->Option.getOrThrow(~message="shuffle source index") + Array.setUnsafe(shuffled, i, shuffled[j]->Option.getOrThrow(~message="shuffle swap index")) + Array.setUnsafe(shuffled, j, tmp) + } + fn interests = Array.slice(shuffled, ~start=0, ~end=numInterests) + + fn genders: array = [Male, Female, NonBinary, PreferNotToSay] + fn gender = + genders[Float.toInt(Math.floor(Math.random() *. 4.0))]->Option.getOr( + Male, + ) + + fn locations: array = [Urban, Suburban, Rural] + fn locationType = + locations[Float.toInt(Math.floor(Math.random() *. 3.0))]->Option.getOr( + Urban, + ) + + fn styles: array = [Focused, Explorer, Researcher, Casual] + fn browsingStyle = + styles[Float.toInt(Math.floor(Math.random() *. 4.0))]->Option.getOr( + Explorer, + ) + + fn levels: array = [Low, Medium, High, VeryHigh] + fn activityLevel = + levels[Float.toInt(Math.floor(Math.random() *. 4.0))]->Option.getOr( + Medium, + ) + + { + id, + name, + demographics: { + age: 22 + Float.toInt(Math.floor(Math.random() *. 45.0)), + gender, + locationType, + occupationCategory: Technology, + educationLevel: Bachelor, + }, + interests, + browsingStyle, + activityLevel, + createdAt: Date.now() /. 1000.0, + } +} + +// Search queries and domains for mock activity generation +fn mockSearchQueries: array = [ + "best coffee shops nearby", + "how to fix a leaky faucet", + "latest smartphone reviews 2026", + "healthy dinner recipes under 30 minutes", + "vintage car restoration tips", + "Slovenian poetry translations", + "home espresso machine comparison", + "weekend hiking trails", + "beginner piano lessons online", + "climate change research papers", + "best board games for adults", + "how to start a vegetable garden", + "mechanical keyboard guide", + "travel insurance comparison", + "history of Japanese ceramics", +] + +fn mockDomains: array = [ + "reddit.com", + "stackoverflow.com", + "medium.com", + "theguardian.com", + "arstechnica.com", + "bbc.com", + "allrecipes.com", + "instructables.com", + "wikipedia.org", + "youtube.com", + "nytimes.com", + "bonappetit.com", + "nature.com", + "wired.com", +] + +fn mockGenerateActivities = (profile: profile, durationHours: float): array => { + fn baseTime = Date.now() /. 1000.0 + fn activitiesCount = Pervasives.max(1, Float.toInt(durationHours *. 4.0)) + + fn activityTypes: array = [ + Search, + PageVisit, + VideoWatch, + Shopping, + SocialMedia, + News, + Research, + ] + + Array.fromInitializer(~length=activitiesCount, i => { + fn timestamp = baseTime +. Int.toFloat(i) *. 900.0 +. Math.random() *. 300.0 + + fn at = + activityTypes[Float.toInt( + Math.floor(Math.random() *. Int.toFloat(Array.length(activityTypes))), + )]->Option.getOr(PageVisit) + + fn queryIdx = Float.toInt( + Math.floor(Math.random() *. Int.toFloat(Array.length(mockSearchQueries))), + ) + fn domainIdx = Float.toInt( + Math.floor(Math.random() *. Int.toFloat(Array.length(mockDomains))), + ) + + fn query = mockSearchQueries[queryIdx]->Option.getOr("latest news") + fn domain = mockDomains[domainIdx]->Option.getOr("example.com") + + fn (url, title) = switch at { + | Search => { + fn encoded = String.replaceRegExp(query, %re("/\\s+/g"), "+") + (`https://www.google.com/search?q=${encoded}`, `${query} - Google Search`) + } + | VideoWatch => { + fn vidStr = Math.random()->Float.toString + fn vidId = vidStr->String.slice(~start=2, ~end=String.length(vidStr))->String.slice(~start=0, ~end=11) + ( + `https://www.youtube.com/watch?v=${vidId}`, + `Video: ${query}`, + ) + } + | _ => (`https://${domain}/${query->String.replaceRegExp(%re("/\\s+/g"), "-")->String.toLowerCase}`, `${query} | ${domain}`) + } + + { + activityType: at, + url, + title, + durationSeconds: 30 + Float.toInt(Math.floor(Math.random() *. 240.0)), + timestamp, + interestCategory: profile.interests[Float.toInt( + Math.floor(Math.random() *. Int.toFloat(Array.length(profile.interests))), + )], + } + }) +} + +fn mockGetSchedule = (_profile: profile): schedule => { + { + timePatterns: [], + timezoneOffset: 0, + } +} + +// --- Public API --- + +fn generateProfile = async (seed: option): profile => { + await ensureInitialized() + fn seedArg: Obj.t = switch seed { + | Some(s) => Obj.magic(s) + | None => Obj.magic(Nullable.null) + } + + switch callWasm("generate_profile", [seedArg]) { + | Some(result) => Obj.magic(result) + | None => mockGenerateProfile(seed) + } +} + +fn generateActivities = async (profile: profile, durationHours: float): array => { + await ensureInitialized() + switch callWasm("generate_activities", [Obj.magic(profile), Obj.magic(durationHours)]) { + | Some(result) => Obj.magic(result) + | None => mockGenerateActivities(profile, durationHours) + } +} + +fn validateProfile = async (profile: profile): bool => { + await ensureInitialized() + switch callWasm("validate_profile", [Obj.magic(profile)]) { + | Some(result) => Obj.magic(result) + | None => + profile.name != "" && Array.length(profile.interests) > 0 && profile.demographics.age >= 18 + } +} + +fn getActivitySchedule = async (profile: profile): schedule => { + await ensureInitialized() + switch callWasm("get_activity_schedule", [Obj.magic(profile)]) { + | Some(result) => Obj.magic(result) + | None => mockGetSchedule(profile) + } +} +