From b53ca0485127f6a28295016468355dceea65d90d Mon Sep 17 00:00:00 2001 From: Aram Petrosyan Date: Mon, 30 Mar 2026 12:11:52 +0100 Subject: [PATCH 1/7] refactor: extract sandbox into internal package and load monaco types dynamically --- build/ios/Assets.xcassets | 116 ----- build/ios/Info.dev.plist | 57 --- build/ios/Info.plist | 55 --- build/ios/LaunchScreen.storyboard | 53 --- build/ios/Taskfile.yml | 293 ------------ build/ios/app_options_default.go | 10 - build/ios/app_options_ios.go | 11 - build/ios/build.sh | 72 --- build/ios/entitlements.plist | 14 - build/ios/icon.png | 3 - build/ios/main.m | 23 - build/ios/main_ios.go | 24 - build/ios/project.pbxproj | 222 --------- build/ios/scripts/deps/install_deps.go | 319 ------------- frontend/src/components/custom-rules.tsx | 9 +- frontend/src/components/ui/code-block.tsx | 31 +- frontend/src/lib/rules/runtime-types.ts | 225 +-------- internal/fs/service.go | 35 ++ internal/sandbox/registry.go | 42 ++ internal/sandbox/sandbox.go | 206 +++++++++ internal/usage/classifier_custom_rules.go | 58 ++- internal/usage/protection.go | 61 ++- internal/usage/sandbox.go | 533 ---------------------- internal/usage/sandbox_contributor.go | 430 +++++++++++++++++ internal/usage/service.go | 2 + main.go | 10 + 26 files changed, 865 insertions(+), 2049 deletions(-) delete mode 100644 build/ios/Assets.xcassets delete mode 100644 build/ios/Info.dev.plist delete mode 100644 build/ios/Info.plist delete mode 100644 build/ios/LaunchScreen.storyboard delete mode 100644 build/ios/Taskfile.yml delete mode 100644 build/ios/app_options_default.go delete mode 100644 build/ios/app_options_ios.go delete mode 100644 build/ios/build.sh delete mode 100644 build/ios/entitlements.plist delete mode 100644 build/ios/icon.png delete mode 100644 build/ios/main.m delete mode 100644 build/ios/main_ios.go delete mode 100644 build/ios/project.pbxproj delete mode 100644 build/ios/scripts/deps/install_deps.go create mode 100644 internal/fs/service.go create mode 100644 internal/sandbox/registry.go create mode 100644 internal/sandbox/sandbox.go delete mode 100644 internal/usage/sandbox.go create mode 100644 internal/usage/sandbox_contributor.go diff --git a/build/ios/Assets.xcassets b/build/ios/Assets.xcassets deleted file mode 100644 index 46fbb87..0000000 --- a/build/ios/Assets.xcassets +++ /dev/null @@ -1,116 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - }, - "images" : [ - { - "filename" : "icon-20@2x.png", - "idiom" : "iphone", - "scale" : "2x", - "size" : "20x20" - }, - { - "filename" : "icon-20@3x.png", - "idiom" : "iphone", - "scale" : "3x", - "size" : "20x20" - }, - { - "filename" : "icon-29@2x.png", - "idiom" : "iphone", - "scale" : "2x", - "size" : "29x29" - }, - { - "filename" : "icon-29@3x.png", - "idiom" : "iphone", - "scale" : "3x", - "size" : "29x29" - }, - { - "filename" : "icon-40@2x.png", - "idiom" : "iphone", - "scale" : "2x", - "size" : "40x40" - }, - { - "filename" : "icon-40@3x.png", - "idiom" : "iphone", - "scale" : "3x", - "size" : "40x40" - }, - { - "filename" : "icon-60@2x.png", - "idiom" : "iphone", - "scale" : "2x", - "size" : "60x60" - }, - { - "filename" : "icon-60@3x.png", - "idiom" : "iphone", - "scale" : "3x", - "size" : "60x60" - }, - { - "filename" : "icon-20.png", - "idiom" : "ipad", - "scale" : "1x", - "size" : "20x20" - }, - { - "filename" : "icon-20@2x.png", - "idiom" : "ipad", - "scale" : "2x", - "size" : "20x20" - }, - { - "filename" : "icon-29.png", - "idiom" : "ipad", - "scale" : "1x", - "size" : "29x29" - }, - { - "filename" : "icon-29@2x.png", - "idiom" : "ipad", - "scale" : "2x", - "size" : "29x29" - }, - { - "filename" : "icon-40.png", - "idiom" : "ipad", - "scale" : "1x", - "size" : "40x40" - }, - { - "filename" : "icon-40@2x.png", - "idiom" : "ipad", - "scale" : "2x", - "size" : "40x40" - }, - { - "filename" : "icon-76.png", - "idiom" : "ipad", - "scale" : "1x", - "size" : "76x76" - }, - { - "filename" : "icon-76@2x.png", - "idiom" : "ipad", - "scale" : "2x", - "size" : "76x76" - }, - { - "filename" : "icon-83.5@2x.png", - "idiom" : "ipad", - "scale" : "2x", - "size" : "83.5x83.5" - }, - { - "filename" : "icon-1024.png", - "idiom" : "ios-marketing", - "scale" : "1x", - "size" : "1024x1024" - } - ] -} \ No newline at end of file diff --git a/build/ios/Info.dev.plist b/build/ios/Info.dev.plist deleted file mode 100644 index 1d9f671..0000000 --- a/build/ios/Info.dev.plist +++ /dev/null @@ -1,57 +0,0 @@ - - - - - CFBundleDisplayName - Focusd (Dev) - CFBundleExecutable - focusd - CFBundleGetInfoString - Some Product Comments - CFBundleIdentifier - app.focusd.so.dev - CFBundleName - Focusd (Dev) - CFBundlePackageType - APPL - CFBundleShortVersionString - 0.0.1-dev - CFBundleVersion - 0.0.1 - LSRequiresIPhoneOS - - MinimumOSVersion - 15.0 - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - NSAllowsLocalNetworking - - - NSHumanReadableCopyright - (c) 2025, My Company - UILaunchStoryboardName - LaunchScreen - UIRequiredDeviceCapabilities - - armv7 - arm64 - - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - WailsDevelopmentMode - - - \ No newline at end of file diff --git a/build/ios/Info.plist b/build/ios/Info.plist deleted file mode 100644 index e4b8eb9..0000000 --- a/build/ios/Info.plist +++ /dev/null @@ -1,55 +0,0 @@ - - - - - CFBundleDisplayName - Focusd - CFBundleExecutable - focusd - CFBundleGetInfoString - Some Product Comments - CFBundleIdentifier - app.focusd.so - CFBundleName - Focusd - CFBundlePackageType - APPL - CFBundleShortVersionString - 0.0.1 - CFBundleVersion - 0.0.1 - LSRequiresIPhoneOS - - MinimumOSVersion - 15.0 - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - NSAllowsLocalNetworking - - - NSHumanReadableCopyright - (c) 2025, My Company - UILaunchStoryboardName - LaunchScreen - UIRequiredDeviceCapabilities - - armv7 - arm64 - - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - - \ No newline at end of file diff --git a/build/ios/LaunchScreen.storyboard b/build/ios/LaunchScreen.storyboard deleted file mode 100644 index 1b8d105..0000000 --- a/build/ios/LaunchScreen.storyboard +++ /dev/null @@ -1,53 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/build/ios/Taskfile.yml b/build/ios/Taskfile.yml deleted file mode 100644 index 19f57f0..0000000 --- a/build/ios/Taskfile.yml +++ /dev/null @@ -1,293 +0,0 @@ -version: '3' - -includes: - common: ../Taskfile.yml - -vars: - BUNDLE_ID: '{{.BUNDLE_ID | default "com.wails.app"}}' - # SDK_PATH is computed lazily at task-level to avoid errors on non-macOS systems - # Each task that needs it defines SDK_PATH in its own vars section - -tasks: - install:deps: - summary: Check and install iOS development dependencies - cmds: - - go run build/ios/scripts/deps/install_deps.go - env: - TASK_FORCE_YES: '{{if .YES}}true{{else}}false{{end}}' - prompt: This will check and install iOS development dependencies. Continue? - - # Note: Bindings generation may show CGO warnings for iOS C imports. - # These warnings are harmless and don't affect the generated bindings, - # as the generator only needs to parse Go types, not C implementations. - build: - summary: Creates a build of the application for iOS - deps: - - task: generate:ios:overlay - - task: generate:ios:xcode - - task: common:go:mod:tidy - - task: generate:ios:bindings - vars: - BUILD_FLAGS: - ref: .BUILD_FLAGS - - task: common:build:frontend - vars: - BUILD_FLAGS: - ref: .BUILD_FLAGS - PRODUCTION: - ref: .PRODUCTION - - task: common:generate:icons - cmds: - - echo "Building iOS app {{.APP_NAME}}..." - - go build -buildmode=c-archive -overlay build/ios/xcode/overlay.json {{.BUILD_FLAGS}} -o {{.OUTPUT}}.a - vars: - BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production,ios -trimpath -buildvcs=false -ldflags="-w -s"{{else}}-tags ios,debug -buildvcs=false -gcflags=all="-l"{{end}}' - DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}' - OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}' - SDK_PATH: - sh: xcrun --sdk iphonesimulator --show-sdk-path - env: - GOOS: ios - CGO_ENABLED: 1 - GOARCH: '{{.ARCH | default "arm64"}}' - PRODUCTION: '{{.PRODUCTION | default "false"}}' - CGO_CFLAGS: '-isysroot {{.SDK_PATH}} -target arm64-apple-ios15.0-simulator -mios-simulator-version-min=15.0' - CGO_LDFLAGS: '-isysroot {{.SDK_PATH}} -target arm64-apple-ios15.0-simulator' - - compile:objc: - summary: Compile Objective-C iOS wrapper - vars: - SDK_PATH: - sh: xcrun --sdk iphonesimulator --show-sdk-path - cmds: - - xcrun -sdk iphonesimulator clang -target arm64-apple-ios15.0-simulator -isysroot {{.SDK_PATH}} -framework Foundation -framework UIKit -framework WebKit -o {{.BIN_DIR}}/{{.APP_NAME}} build/ios/main.m - - codesign --force --sign - {{.BIN_DIR}}/{{.APP_NAME}} - - package: - summary: Packages a production build of the application into a `.app` bundle - deps: - - task: build - vars: - PRODUCTION: "true" - cmds: - - task: create:app:bundle - - create:app:bundle: - summary: Creates an iOS `.app` bundle - cmds: - - rm -rf {{.BIN_DIR}}/{{.APP_NAME}}.app - - mkdir -p {{.BIN_DIR}}/{{.APP_NAME}}.app - - cp {{.BIN_DIR}}/{{.APP_NAME}} {{.BIN_DIR}}/{{.APP_NAME}}.app/ - - cp build/ios/Info.plist {{.BIN_DIR}}/{{.APP_NAME}}.app/ - - | - # Compile asset catalog and embed icons in the app bundle - APP_BUNDLE="{{.BIN_DIR}}/{{.APP_NAME}}.app" - AC_IN="build/ios/xcode/main/Assets.xcassets" - if [ -d "$AC_IN" ]; then - TMP_AC=$(mktemp -d) - xcrun actool \ - --compile "$TMP_AC" \ - --app-icon AppIcon \ - --platform iphonesimulator \ - --minimum-deployment-target 15.0 \ - --product-type com.apple.product-type.application \ - --target-device iphone \ - --target-device ipad \ - --output-partial-info-plist "$APP_BUNDLE/assetcatalog_generated_info.plist" \ - "$AC_IN" - if [ -f "$TMP_AC/Assets.car" ]; then - cp -f "$TMP_AC/Assets.car" "$APP_BUNDLE/Assets.car" - fi - rm -rf "$TMP_AC" - if [ -f "$APP_BUNDLE/assetcatalog_generated_info.plist" ]; then - /usr/libexec/PlistBuddy -c "Merge $APP_BUNDLE/assetcatalog_generated_info.plist" "$APP_BUNDLE/Info.plist" || true - fi - fi - - codesign --force --sign - {{.BIN_DIR}}/{{.APP_NAME}}.app - - deploy-simulator: - summary: Deploy to iOS Simulator - deps: [package] - cmds: - - xcrun simctl terminate booted {{.BUNDLE_ID}} 2>/dev/null || true - - xcrun simctl uninstall booted {{.BUNDLE_ID}} 2>/dev/null || true - - xcrun simctl install booted {{.BIN_DIR}}/{{.APP_NAME}}.app - - xcrun simctl launch booted {{.BUNDLE_ID}} - - compile:ios: - summary: Compile the iOS executable from Go archive and main.m - deps: - - task: build - vars: - SDK_PATH: - sh: xcrun --sdk iphonesimulator --show-sdk-path - cmds: - - | - MAIN_M=build/ios/xcode/main/main.m - if [ ! -f "$MAIN_M" ]; then - MAIN_M=build/ios/main.m - fi - xcrun -sdk iphonesimulator clang \ - -target arm64-apple-ios15.0-simulator \ - -isysroot {{.SDK_PATH}} \ - -framework Foundation -framework UIKit -framework WebKit \ - -framework Security -framework CoreFoundation \ - -lresolv \ - -o {{.BIN_DIR}}/{{.APP_NAME | lower}} \ - "$MAIN_M" {{.BIN_DIR}}/{{.APP_NAME}}.a - - generate:ios:bindings: - internal: true - summary: Generates bindings for iOS with proper CGO flags - sources: - - "**/*.go" - - go.mod - - go.sum - generates: - - frontend/bindings/**/* - vars: - SDK_PATH: - sh: xcrun --sdk iphonesimulator --show-sdk-path - cmds: - - wails3 generate bindings -f '{{.BUILD_FLAGS}}' -clean=true - env: - GOOS: ios - CGO_ENABLED: 1 - GOARCH: '{{.ARCH | default "arm64"}}' - CGO_CFLAGS: '-isysroot {{.SDK_PATH}} -target arm64-apple-ios15.0-simulator -mios-simulator-version-min=15.0' - CGO_LDFLAGS: '-isysroot {{.SDK_PATH}} -target arm64-apple-ios15.0-simulator' - - ensure-simulator: - internal: true - summary: Ensure iOS Simulator is running and booted - silent: true - cmds: - - | - if ! xcrun simctl list devices booted | grep -q "Booted"; then - echo "Starting iOS Simulator..." - # Get first available iPhone device - DEVICE_ID=$(xcrun simctl list devices available | grep "iPhone" | head -1 | grep -o "[A-F0-9-]\{36\}" || true) - if [ -z "$DEVICE_ID" ]; then - echo "No iPhone simulator found. Creating one..." - RUNTIME=$(xcrun simctl list runtimes | grep iOS | tail -1 | awk '{print $NF}') - DEVICE_ID=$(xcrun simctl create "iPhone 15 Pro" "iPhone 15 Pro" "$RUNTIME") - fi - # Boot the device - echo "Booting device $DEVICE_ID..." - xcrun simctl boot "$DEVICE_ID" 2>/dev/null || true - # Open Simulator app - open -a Simulator - # Wait for boot (max 30 seconds) - for i in {1..30}; do - if xcrun simctl list devices booted | grep -q "Booted"; then - echo "Simulator booted successfully" - break - fi - sleep 1 - done - # Final check - if ! xcrun simctl list devices booted | grep -q "Booted"; then - echo "Failed to boot simulator after 30 seconds" - exit 1 - fi - fi - preconditions: - - sh: command -v xcrun - msg: "xcrun not found. Please run 'wails3 task ios:install:deps' to install iOS development dependencies" - - generate:ios:overlay: - internal: true - summary: Generate Go build overlay and iOS shim - sources: - - build/config.yml - generates: - - build/ios/xcode/overlay.json - - build/ios/xcode/gen/main_ios.gen.go - cmds: - - wails3 ios overlay:gen -out build/ios/xcode/overlay.json -config build/config.yml - - generate:ios:xcode: - internal: true - summary: Generate iOS Xcode project structure and assets - sources: - - build/config.yml - - build/appicon.png - generates: - - build/ios/xcode/main/main.m - - build/ios/xcode/main/Assets.xcassets/**/* - - build/ios/xcode/project.pbxproj - cmds: - - wails3 ios xcode:gen -outdir build/ios/xcode -config build/config.yml - - run: - summary: Run the application in iOS Simulator - deps: - - task: ensure-simulator - - task: compile:ios - cmds: - - rm -rf {{.BIN_DIR}}/{{.APP_NAME}}.dev.app - - mkdir -p {{.BIN_DIR}}/{{.APP_NAME}}.dev.app - - cp {{.BIN_DIR}}/{{.APP_NAME | lower}} {{.BIN_DIR}}/{{.APP_NAME}}.dev.app/{{.APP_NAME | lower}} - - cp build/ios/Info.dev.plist {{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Info.plist - - | - # Compile asset catalog and embed icons for dev bundle - APP_BUNDLE="{{.BIN_DIR}}/{{.APP_NAME}}.dev.app" - AC_IN="build/ios/xcode/main/Assets.xcassets" - if [ -d "$AC_IN" ]; then - TMP_AC=$(mktemp -d) - xcrun actool \ - --compile "$TMP_AC" \ - --app-icon AppIcon \ - --platform iphonesimulator \ - --minimum-deployment-target 15.0 \ - --product-type com.apple.product-type.application \ - --target-device iphone \ - --target-device ipad \ - --output-partial-info-plist "$APP_BUNDLE/assetcatalog_generated_info.plist" \ - "$AC_IN" - if [ -f "$TMP_AC/Assets.car" ]; then - cp -f "$TMP_AC/Assets.car" "$APP_BUNDLE/Assets.car" - fi - rm -rf "$TMP_AC" - if [ -f "$APP_BUNDLE/assetcatalog_generated_info.plist" ]; then - /usr/libexec/PlistBuddy -c "Merge $APP_BUNDLE/assetcatalog_generated_info.plist" "$APP_BUNDLE/Info.plist" || true - fi - fi - - codesign --force --sign - {{.BIN_DIR}}/{{.APP_NAME}}.dev.app - - xcrun simctl terminate booted com.wails.{{.APP_NAME | lower}}.dev 2>/dev/null || true - - xcrun simctl uninstall booted com.wails.{{.APP_NAME | lower}}.dev 2>/dev/null || true - - xcrun simctl install booted {{.BIN_DIR}}/{{.APP_NAME}}.dev.app - - xcrun simctl launch booted com.wails.{{.APP_NAME | lower}}.dev - - xcode: - summary: Open the generated Xcode project for this app - cmds: - - task: generate:ios:xcode - - open build/ios/xcode/main.xcodeproj - - logs: - summary: Stream iOS Simulator logs filtered to this app - cmds: - - | - xcrun simctl spawn booted log stream \ - --level debug \ - --style compact \ - --predicate 'senderImagePath CONTAINS[c] "{{.APP_NAME | lower}}.app/" OR composedMessage CONTAINS[c] "{{.APP_NAME | lower}}" OR eventMessage CONTAINS[c] "{{.APP_NAME | lower}}" OR process == "{{.APP_NAME | lower}}" OR category CONTAINS[c] "{{.APP_NAME | lower}}"' - - logs:dev: - summary: Stream logs for the dev bundle (used by `task ios:run`) - cmds: - - | - xcrun simctl spawn booted log stream \ - --level debug \ - --style compact \ - --predicate 'senderImagePath CONTAINS[c] ".dev.app/" OR subsystem == "com.wails.{{.APP_NAME | lower}}.dev" OR process == "{{.APP_NAME | lower}}"' - - logs:wide: - summary: Wide log stream to help discover the exact process/bundle identifiers - cmds: - - | - xcrun simctl spawn booted log stream \ - --level debug \ - --style compact \ - --predicate 'senderImagePath CONTAINS[c] ".app/"' \ No newline at end of file diff --git a/build/ios/app_options_default.go b/build/ios/app_options_default.go deleted file mode 100644 index 04e4f1b..0000000 --- a/build/ios/app_options_default.go +++ /dev/null @@ -1,10 +0,0 @@ -//go:build !ios - -package main - -import "github.com/wailsapp/wails/v3/pkg/application" - -// modifyOptionsForIOS is a no-op on non-iOS platforms -func modifyOptionsForIOS(opts *application.Options) { - // No modifications needed for non-iOS platforms -} \ No newline at end of file diff --git a/build/ios/app_options_ios.go b/build/ios/app_options_ios.go deleted file mode 100644 index 8f6ac31..0000000 --- a/build/ios/app_options_ios.go +++ /dev/null @@ -1,11 +0,0 @@ -//go:build ios - -package main - -import "github.com/wailsapp/wails/v3/pkg/application" - -// modifyOptionsForIOS adjusts the application options for iOS -func modifyOptionsForIOS(opts *application.Options) { - // Disable signal handlers on iOS to prevent crashes - opts.DisableDefaultSignalHandler = true -} \ No newline at end of file diff --git a/build/ios/build.sh b/build/ios/build.sh deleted file mode 100644 index 58741f7..0000000 --- a/build/ios/build.sh +++ /dev/null @@ -1,72 +0,0 @@ -#!/bin/bash -set -e - -# Build configuration -APP_NAME="focusd" -BUNDLE_ID="app.focusd.so" -VERSION="0.0.1" -BUILD_NUMBER="0.0.1" -BUILD_DIR="build/ios" -TARGET="simulator" - -echo "Building iOS app: $APP_NAME" -echo "Bundle ID: $BUNDLE_ID" -echo "Version: $VERSION ($BUILD_NUMBER)" -echo "Target: $TARGET" - -# Ensure build directory exists -mkdir -p "$BUILD_DIR" - -# Determine SDK and target architecture -if [ "$TARGET" = "simulator" ]; then - SDK="iphonesimulator" - ARCH="arm64-apple-ios15.0-simulator" -elif [ "$TARGET" = "device" ]; then - SDK="iphoneos" - ARCH="arm64-apple-ios15.0" -else - echo "Unknown target: $TARGET" - exit 1 -fi - -# Get SDK path -SDK_PATH=$(xcrun --sdk $SDK --show-sdk-path) - -# Compile the application -echo "Compiling with SDK: $SDK" -xcrun -sdk $SDK clang \ - -target $ARCH \ - -isysroot "$SDK_PATH" \ - -framework Foundation \ - -framework UIKit \ - -framework WebKit \ - -framework CoreGraphics \ - -o "$BUILD_DIR/$APP_NAME" \ - "$BUILD_DIR/main.m" - -# Create app bundle -echo "Creating app bundle..." -APP_BUNDLE="$BUILD_DIR/$APP_NAME.app" -rm -rf "$APP_BUNDLE" -mkdir -p "$APP_BUNDLE" - -# Move executable -mv "$BUILD_DIR/$APP_NAME" "$APP_BUNDLE/" - -# Copy Info.plist -cp "$BUILD_DIR/Info.plist" "$APP_BUNDLE/" - -# Sign the app -echo "Signing app..." -codesign --force --sign - "$APP_BUNDLE" - -echo "Build complete: $APP_BUNDLE" - -# Deploy to simulator if requested -if [ "$TARGET" = "simulator" ]; then - echo "Deploying to simulator..." - xcrun simctl terminate booted "$BUNDLE_ID" 2>/dev/null || true - xcrun simctl install booted "$APP_BUNDLE" - xcrun simctl launch booted "$BUNDLE_ID" - echo "App launched on simulator" -fi \ No newline at end of file diff --git a/build/ios/entitlements.plist b/build/ios/entitlements.plist deleted file mode 100644 index acec2a2..0000000 --- a/build/ios/entitlements.plist +++ /dev/null @@ -1,14 +0,0 @@ - - - - - com.apple.security.app-sandbox - - com.apple.security.files.user-selected.read-only - - com.apple.security.network.client - - get-task-allow - - - \ No newline at end of file diff --git a/build/ios/icon.png b/build/ios/icon.png deleted file mode 100644 index be7d591..0000000 --- a/build/ios/icon.png +++ /dev/null @@ -1,3 +0,0 @@ -# iOS Icon Placeholder -# This file should be replaced with the actual app icon (1024x1024 PNG) -# The build process will generate all required icon sizes from this base icon \ No newline at end of file diff --git a/build/ios/main.m b/build/ios/main.m deleted file mode 100644 index 366767a..0000000 --- a/build/ios/main.m +++ /dev/null @@ -1,23 +0,0 @@ -//go:build ios -// Minimal bootstrap: delegate comes from Go archive (WailsAppDelegate) -#import -#include - -// External Go initialization function from the c-archive (declare before use) -extern void WailsIOSMain(); - -int main(int argc, char * argv[]) { - @autoreleasepool { - // Disable buffering so stdout/stderr from Go log.Printf flush immediately - setvbuf(stdout, NULL, _IONBF, 0); - setvbuf(stderr, NULL, _IONBF, 0); - - // Start Go runtime on a background queue to avoid blocking main thread/UI - dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ - WailsIOSMain(); - }); - - // Run UIApplicationMain using WailsAppDelegate provided by the Go archive - return UIApplicationMain(argc, argv, nil, @"WailsAppDelegate"); - } -} \ No newline at end of file diff --git a/build/ios/main_ios.go b/build/ios/main_ios.go deleted file mode 100644 index b75a403..0000000 --- a/build/ios/main_ios.go +++ /dev/null @@ -1,24 +0,0 @@ -//go:build ios - -package main - -import ( - "C" -) - -// For iOS builds, we need to export a function that can be called from Objective-C -// This wrapper allows us to keep the original main.go unmodified - -//export WailsIOSMain -func WailsIOSMain() { - // DO NOT lock the goroutine to the current OS thread on iOS! - // This causes signal handling issues: - // "signal 16 received on thread with no signal stack" - // "fatal error: non-Go code disabled sigaltstack" - // iOS apps run in a sandboxed environment where the Go runtime's - // signal handling doesn't work the same way as desktop platforms. - - // Call the actual main function from main.go - // This ensures all the user's code is executed - main() -} \ No newline at end of file diff --git a/build/ios/project.pbxproj b/build/ios/project.pbxproj deleted file mode 100644 index 7bbbbfe..0000000 --- a/build/ios/project.pbxproj +++ /dev/null @@ -1,222 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = {}; - objectVersion = 56; - objects = { - -/* Begin PBXBuildFile section */ - C0DEBEEF0000000000000001 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000002 /* main.m */; }; - C0DEBEEF00000000000000F1 /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000101 /* UIKit.framework */; }; - C0DEBEEF00000000000000F2 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000102 /* Foundation.framework */; }; - C0DEBEEF00000000000000F3 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000103 /* WebKit.framework */; }; - C0DEBEEF00000000000000F4 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000104 /* Security.framework */; }; - C0DEBEEF00000000000000F5 /* CoreFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000105 /* CoreFoundation.framework */; }; - C0DEBEEF00000000000000F6 /* libresolv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000106 /* libresolv.tbd */; }; - C0DEBEEF00000000000000F7 /* Focusd.a in Frameworks */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000107 /* Focusd.a */; }; -/* End PBXBuildFile section */ - -/* Begin PBXFileReference section */ - C0DEBEEF0000000000000002 /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = ""; }; - C0DEBEEF0000000000000003 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - C0DEBEEF0000000000000004 /* Focusd.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Focusd.app"; sourceTree = BUILT_PRODUCTS_DIR; }; - C0DEBEEF0000000000000101 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; }; - C0DEBEEF0000000000000102 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = System/Library/Frameworks/Foundation.framework; sourceTree = SDKROOT; }; - C0DEBEEF0000000000000103 /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; }; - C0DEBEEF0000000000000104 /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; }; - C0DEBEEF0000000000000105 /* CoreFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreFoundation.framework; path = System/Library/Frameworks/CoreFoundation.framework; sourceTree = SDKROOT; }; - C0DEBEEF0000000000000106 /* libresolv.tbd */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.text-based-dylib-definition; name = libresolv.tbd; path = usr/lib/libresolv.tbd; sourceTree = SDKROOT; }; - C0DEBEEF0000000000000107 /* Focusd.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = "Focusd.a"; path = ../../../bin/Focusd.a; sourceTree = SOURCE_ROOT; }; -/* End PBXFileReference section */ - -/* Begin PBXGroup section */ - C0DEBEEF0000000000000010 = { - isa = PBXGroup; - children = ( - C0DEBEEF0000000000000020 /* Products */, - C0DEBEEF0000000000000045 /* Frameworks */, - C0DEBEEF0000000000000030 /* main */, - ); - sourceTree = ""; - }; - C0DEBEEF0000000000000020 /* Products */ = { - isa = PBXGroup; - children = ( - C0DEBEEF0000000000000004 /* Focusd.app */, - ); - name = Products; - sourceTree = ""; - }; - C0DEBEEF0000000000000030 /* main */ = { - isa = PBXGroup; - children = ( - C0DEBEEF0000000000000002 /* main.m */, - C0DEBEEF0000000000000003 /* Info.plist */, - ); - path = main; - sourceTree = SOURCE_ROOT; - }; - C0DEBEEF0000000000000045 /* Frameworks */ = { - isa = PBXGroup; - children = ( - C0DEBEEF0000000000000101 /* UIKit.framework */, - C0DEBEEF0000000000000102 /* Foundation.framework */, - C0DEBEEF0000000000000103 /* WebKit.framework */, - C0DEBEEF0000000000000104 /* Security.framework */, - C0DEBEEF0000000000000105 /* CoreFoundation.framework */, - C0DEBEEF0000000000000106 /* libresolv.tbd */, - C0DEBEEF0000000000000107 /* Focusd.a */, - ); - name = Frameworks; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - C0DEBEEF0000000000000040 /* Focusd */ = { - isa = PBXNativeTarget; - buildConfigurationList = C0DEBEEF0000000000000070 /* Build configuration list for PBXNativeTarget "Focusd" */; - buildPhases = ( - C0DEBEEF0000000000000055 /* Prebuild: Wails Go Archive */, - C0DEBEEF0000000000000050 /* Sources */, - C0DEBEEF0000000000000056 /* Frameworks */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = "Focusd"; - productName = "Focusd"; - productReference = C0DEBEEF0000000000000004 /* Focusd.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - C0DEBEEF0000000000000060 /* Project object */ = { - isa = PBXProject; - attributes = { - LastUpgradeCheck = 1500; - ORGANIZATIONNAME = "My Company"; - TargetAttributes = { - C0DEBEEF0000000000000040 = { - CreatedOnToolsVersion = 15.0; - }; - }; - }; - buildConfigurationList = C0DEBEEF0000000000000080 /* Build configuration list for PBXProject "main" */; - compatibilityVersion = "Xcode 15.0"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - ); - mainGroup = C0DEBEEF0000000000000010; - productRefGroup = C0DEBEEF0000000000000020 /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - C0DEBEEF0000000000000040 /* Focusd */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXFrameworksBuildPhase section */ - C0DEBEEF0000000000000056 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - C0DEBEEF00000000000000F7 /* Focusd.a in Frameworks */, - C0DEBEEF00000000000000F1 /* UIKit.framework in Frameworks */, - C0DEBEEF00000000000000F2 /* Foundation.framework in Frameworks */, - C0DEBEEF00000000000000F3 /* WebKit.framework in Frameworks */, - C0DEBEEF00000000000000F4 /* Security.framework in Frameworks */, - C0DEBEEF00000000000000F5 /* CoreFoundation.framework in Frameworks */, - C0DEBEEF00000000000000F6 /* libresolv.tbd in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXShellScriptBuildPhase section */ - C0DEBEEF0000000000000055 /* Prebuild: Wails Go Archive */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - ); - name = "Prebuild: Wails Go Archive"; - outputFileListPaths = ( - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "set -e\nAPP_ROOT=\"${PROJECT_DIR}/../../..\"\nSDK_PATH=$(xcrun --sdk iphonesimulator --show-sdk-path)\nexport GOOS=ios\nexport GOARCH=arm64\nexport CGO_ENABLED=1\nexport CGO_CFLAGS=\"-isysroot ${SDK_PATH} -target arm64-apple-ios15.0-simulator -mios-simulator-version-min=15.0\"\nexport CGO_LDFLAGS=\"-isysroot ${SDK_PATH} -target arm64-apple-ios15.0-simulator\"\ncd \"${APP_ROOT}\"\n# Ensure overlay exists\nif [ ! -f build/ios/xcode/overlay.json ]; then\n wails3 ios overlay:gen -out build/ios/xcode/overlay.json -config build/config.yml || true\nfi\n# Build Go c-archive if missing or older than sources\nif [ ! -f bin/Focusd.a ]; then\n echo \"Building Go c-archive...\"\n go build -buildmode=c-archive -overlay build/ios/xcode/overlay.json -o bin/Focusd.a\nfi\n"; - }; -/* End PBXShellScriptBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - C0DEBEEF0000000000000050 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - C0DEBEEF0000000000000001 /* main.m in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin XCBuildConfiguration section */ - C0DEBEEF0000000000000090 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - INFOPLIST_FILE = main/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; - PRODUCT_BUNDLE_IDENTIFIER = "app.focusd.so"; - PRODUCT_NAME = "Focusd"; - CODE_SIGNING_ALLOWED = NO; - SDKROOT = iphonesimulator; - }; - name = Debug; - }; - C0DEBEEF00000000000000A0 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - INFOPLIST_FILE = main/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; - PRODUCT_BUNDLE_IDENTIFIER = "app.focusd.so"; - PRODUCT_NAME = "Focusd"; - CODE_SIGNING_ALLOWED = NO; - SDKROOT = iphonesimulator; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - C0DEBEEF0000000000000070 /* Build configuration list for PBXNativeTarget "Focusd" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - C0DEBEEF0000000000000090 /* Debug */, - C0DEBEEF00000000000000A0 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Debug; - }; - C0DEBEEF0000000000000080 /* Build configuration list for PBXProject "main" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - C0DEBEEF0000000000000090 /* Debug */, - C0DEBEEF00000000000000A0 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Debug; - }; -/* End XCConfigurationList section */ - }; - rootObject = C0DEBEEF0000000000000060 /* Project object */; -} diff --git a/build/ios/scripts/deps/install_deps.go b/build/ios/scripts/deps/install_deps.go deleted file mode 100644 index 88ed47a..0000000 --- a/build/ios/scripts/deps/install_deps.go +++ /dev/null @@ -1,319 +0,0 @@ -// install_deps.go - iOS development dependency checker -// This script checks for required iOS development tools. -// It's designed to be portable across different shells by using Go instead of shell scripts. -// -// Usage: -// go run install_deps.go # Interactive mode -// TASK_FORCE_YES=true go run install_deps.go # Auto-accept prompts -// CI=true go run install_deps.go # CI mode (auto-accept) - -package main - -import ( - "bufio" - "fmt" - "os" - "os/exec" - "strings" -) - -type Dependency struct { - Name string - CheckFunc func() (bool, string) // Returns (success, details) - Required bool - InstallCmd []string - InstallMsg string - SuccessMsg string - FailureMsg string -} - -func main() { - fmt.Println("Checking iOS development dependencies...") - fmt.Println("=" + strings.Repeat("=", 50)) - fmt.Println() - - hasErrors := false - dependencies := []Dependency{ - { - Name: "Xcode", - CheckFunc: func() (bool, string) { - // Check if xcodebuild exists - if !checkCommand([]string{"xcodebuild", "-version"}) { - return false, "" - } - // Get version info - out, err := exec.Command("xcodebuild", "-version").Output() - if err != nil { - return false, "" - } - lines := strings.Split(string(out), "\n") - if len(lines) > 0 { - return true, strings.TrimSpace(lines[0]) - } - return true, "" - }, - Required: true, - InstallMsg: "Please install Xcode from the Mac App Store:\n https://apps.apple.com/app/xcode/id497799835\n Xcode is REQUIRED for iOS development (includes iOS SDKs, simulators, and frameworks)", - SuccessMsg: "✅ Xcode found", - FailureMsg: "❌ Xcode not found (REQUIRED)", - }, - { - Name: "Xcode Developer Path", - CheckFunc: func() (bool, string) { - // Check if xcode-select points to a valid Xcode path - out, err := exec.Command("xcode-select", "-p").Output() - if err != nil { - return false, "xcode-select not configured" - } - path := strings.TrimSpace(string(out)) - - // Check if path exists and is in Xcode.app - if _, err := os.Stat(path); err != nil { - return false, "Invalid Xcode path" - } - - // Verify it's pointing to Xcode.app (not just Command Line Tools) - if !strings.Contains(path, "Xcode.app") { - return false, fmt.Sprintf("Points to %s (should be Xcode.app)", path) - } - - return true, path - }, - Required: true, - InstallCmd: []string{"sudo", "xcode-select", "-s", "/Applications/Xcode.app/Contents/Developer"}, - InstallMsg: "Xcode developer path needs to be configured", - SuccessMsg: "✅ Xcode developer path configured", - FailureMsg: "❌ Xcode developer path not configured correctly", - }, - { - Name: "iOS SDK", - CheckFunc: func() (bool, string) { - // Get the iOS Simulator SDK path - cmd := exec.Command("xcrun", "--sdk", "iphonesimulator", "--show-sdk-path") - output, err := cmd.Output() - if err != nil { - return false, "Cannot find iOS SDK" - } - sdkPath := strings.TrimSpace(string(output)) - - // Check if the SDK path exists - if _, err := os.Stat(sdkPath); err != nil { - return false, "iOS SDK path not found" - } - - // Check for UIKit framework (essential for iOS development) - uikitPath := fmt.Sprintf("%s/System/Library/Frameworks/UIKit.framework", sdkPath) - if _, err := os.Stat(uikitPath); err != nil { - return false, "UIKit.framework not found" - } - - // Get SDK version - versionCmd := exec.Command("xcrun", "--sdk", "iphonesimulator", "--show-sdk-version") - versionOut, _ := versionCmd.Output() - version := strings.TrimSpace(string(versionOut)) - - return true, fmt.Sprintf("iOS %s SDK", version) - }, - Required: true, - InstallMsg: "iOS SDK comes with Xcode. Please ensure Xcode is properly installed.", - SuccessMsg: "✅ iOS SDK found with UIKit framework", - FailureMsg: "❌ iOS SDK not found or incomplete", - }, - { - Name: "iOS Simulator Runtime", - CheckFunc: func() (bool, string) { - if !checkCommand([]string{"xcrun", "simctl", "help"}) { - return false, "" - } - // Check if we can list runtimes - out, err := exec.Command("xcrun", "simctl", "list", "runtimes").Output() - if err != nil { - return false, "Cannot access simulator" - } - // Count iOS runtimes - lines := strings.Split(string(out), "\n") - count := 0 - var versions []string - for _, line := range lines { - if strings.Contains(line, "iOS") && !strings.Contains(line, "unavailable") { - count++ - // Extract version number - if parts := strings.Fields(line); len(parts) > 2 { - for _, part := range parts { - if strings.HasPrefix(part, "(") && strings.HasSuffix(part, ")") { - versions = append(versions, strings.Trim(part, "()")) - break - } - } - } - } - } - if count > 0 { - return true, fmt.Sprintf("%d runtime(s): %s", count, strings.Join(versions, ", ")) - } - return false, "No iOS runtimes installed" - }, - Required: true, - InstallMsg: "iOS Simulator runtimes come with Xcode. You may need to download them:\n Xcode → Settings → Platforms → iOS", - SuccessMsg: "✅ iOS Simulator runtime available", - FailureMsg: "❌ iOS Simulator runtime not available", - }, - } - - // Check each dependency - for _, dep := range dependencies { - success, details := dep.CheckFunc() - if success { - msg := dep.SuccessMsg - if details != "" { - msg = fmt.Sprintf("%s (%s)", dep.SuccessMsg, details) - } - fmt.Println(msg) - } else { - fmt.Println(dep.FailureMsg) - if details != "" { - fmt.Printf(" Details: %s\n", details) - } - if dep.Required { - hasErrors = true - if len(dep.InstallCmd) > 0 { - fmt.Println() - fmt.Println(" " + dep.InstallMsg) - fmt.Printf(" Fix command: %s\n", strings.Join(dep.InstallCmd, " ")) - if promptUser("Do you want to run this command?") { - fmt.Println("Running command...") - cmd := exec.Command(dep.InstallCmd[0], dep.InstallCmd[1:]...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - cmd.Stdin = os.Stdin - if err := cmd.Run(); err != nil { - fmt.Printf("Command failed: %v\n", err) - os.Exit(1) - } - fmt.Println("✅ Command completed. Please run this check again.") - } else { - fmt.Printf(" Please run manually: %s\n", strings.Join(dep.InstallCmd, " ")) - } - } else { - fmt.Println(" " + dep.InstallMsg) - } - } - } - } - - // Check for iPhone simulators - fmt.Println() - fmt.Println("Checking for iPhone simulator devices...") - if !checkCommand([]string{"xcrun", "simctl", "list", "devices"}) { - fmt.Println("❌ Cannot check for iPhone simulators") - hasErrors = true - } else { - out, err := exec.Command("xcrun", "simctl", "list", "devices").Output() - if err != nil { - fmt.Println("❌ Failed to list simulator devices") - hasErrors = true - } else if !strings.Contains(string(out), "iPhone") { - fmt.Println("⚠️ No iPhone simulator devices found") - fmt.Println() - - // Get the latest iOS runtime - runtimeOut, err := exec.Command("xcrun", "simctl", "list", "runtimes").Output() - if err != nil { - fmt.Println(" Failed to get iOS runtimes:", err) - } else { - lines := strings.Split(string(runtimeOut), "\n") - var latestRuntime string - for _, line := range lines { - if strings.Contains(line, "iOS") && !strings.Contains(line, "unavailable") { - // Extract runtime identifier - parts := strings.Fields(line) - if len(parts) > 0 { - latestRuntime = parts[len(parts)-1] - } - } - } - - if latestRuntime == "" { - fmt.Println(" No iOS runtime found. Please install iOS simulators in Xcode:") - fmt.Println(" Xcode → Settings → Platforms → iOS") - } else { - fmt.Println(" Would you like to create an iPhone 15 Pro simulator?") - createCmd := []string{"xcrun", "simctl", "create", "iPhone 15 Pro", "iPhone 15 Pro", latestRuntime} - fmt.Printf(" Command: %s\n", strings.Join(createCmd, " ")) - if promptUser("Create simulator?") { - cmd := exec.Command(createCmd[0], createCmd[1:]...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - fmt.Printf(" Failed to create simulator: %v\n", err) - } else { - fmt.Println(" ✅ iPhone 15 Pro simulator created") - } - } else { - fmt.Println(" Skipping simulator creation") - fmt.Printf(" Create manually: %s\n", strings.Join(createCmd, " ")) - } - } - } - } else { - // Count iPhone devices - count := 0 - lines := strings.Split(string(out), "\n") - for _, line := range lines { - if strings.Contains(line, "iPhone") && !strings.Contains(line, "unavailable") { - count++ - } - } - fmt.Printf("✅ %d iPhone simulator device(s) available\n", count) - } - } - - // Final summary - fmt.Println() - fmt.Println("=" + strings.Repeat("=", 50)) - if hasErrors { - fmt.Println("❌ Some required dependencies are missing or misconfigured.") - fmt.Println() - fmt.Println("Quick setup guide:") - fmt.Println("1. Install Xcode from Mac App Store (if not installed)") - fmt.Println("2. Open Xcode once and agree to the license") - fmt.Println("3. Install additional components when prompted") - fmt.Println("4. Run: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer") - fmt.Println("5. Download iOS simulators: Xcode → Settings → Platforms → iOS") - fmt.Println("6. Run this check again") - os.Exit(1) - } else { - fmt.Println("✅ All required dependencies are installed!") - fmt.Println(" You're ready for iOS development with Wails!") - } -} - -func checkCommand(args []string) bool { - if len(args) == 0 { - return false - } - cmd := exec.Command(args[0], args[1:]...) - cmd.Stdout = nil - cmd.Stderr = nil - err := cmd.Run() - return err == nil -} - -func promptUser(question string) bool { - // Check if we're in a non-interactive environment - if os.Getenv("CI") != "" || os.Getenv("TASK_FORCE_YES") == "true" { - fmt.Printf("%s [y/N]: y (auto-accepted)\n", question) - return true - } - - reader := bufio.NewReader(os.Stdin) - fmt.Printf("%s [y/N]: ", question) - - response, err := reader.ReadString('\n') - if err != nil { - return false - } - - response = strings.ToLower(strings.TrimSpace(response)) - return response == "y" || response == "yes" -} \ No newline at end of file diff --git a/frontend/src/components/custom-rules.tsx b/frontend/src/components/custom-rules.tsx index 8e4d522..2b7d6cc 100644 --- a/frontend/src/components/custom-rules.tsx +++ b/frontend/src/components/custom-rules.tsx @@ -15,7 +15,7 @@ import { ExecutionLogsSheet } from "@/components/execution-logs"; import { TestRulesSheet } from "@/components/test-rules-sheet"; import { RulesReferenceSheet } from "@/components/rules-reference-sheet"; import { STARTER_RULES_TS } from "@/lib/rules/starter-template"; -import { RUNTIME_TYPES_FILE_PATH, RUNTIME_TYPES_SOURCE } from "@/lib/rules/runtime-types"; +import { RUNTIME_TYPES_FILE_PATH, fetchRuntimeTypes } from "@/lib/rules/runtime-types"; const SETTINGS_KEY = "custom_rules"; const DRAFT_STORAGE_KEY = "focusd_custom_rules_draft"; @@ -122,12 +122,13 @@ export function CustomRules() { }); }, []); - const handleEditorWillMount = useCallback((monaco: Monaco) => { - monaco.languages.typescript.typescriptDefaults.addExtraLib(RUNTIME_TYPES_SOURCE, RUNTIME_TYPES_FILE_PATH); + const handleEditorWillMount = useCallback(async (monaco: Monaco) => { + const typesSource = await fetchRuntimeTypes(); + monaco.languages.typescript.typescriptDefaults.addExtraLib(typesSource, RUNTIME_TYPES_FILE_PATH); const typesUri = monaco.Uri.parse(RUNTIME_TYPES_FILE_PATH); if (!monaco.editor.getModel(typesUri)) { - monaco.editor.createModel(RUNTIME_TYPES_SOURCE, "typescript", typesUri); + monaco.editor.createModel(typesSource, "typescript", typesUri); } }, []); diff --git a/frontend/src/components/ui/code-block.tsx b/frontend/src/components/ui/code-block.tsx index 7dbc366..b129b87 100644 --- a/frontend/src/components/ui/code-block.tsx +++ b/frontend/src/components/ui/code-block.tsx @@ -1,42 +1,45 @@ import Editor, { type Monaco } from "@monaco-editor/react"; import { useCallback } from "react"; -import { RUNTIME_TYPES_FILE_PATH, RUNTIME_TYPES_SOURCE } from "@/lib/rules/runtime-types"; +import { RUNTIME_TYPES_FILE_PATH, fetchRuntimeTypes } from "@/lib/rules/runtime-types"; export function CodeBlock({ code, height = "100px" }: { code: string; height?: string }) { - const handleEditorWillMount = useCallback((monaco: Monaco) => { - monaco.languages.typescript.typescriptDefaults.addExtraLib(RUNTIME_TYPES_SOURCE, RUNTIME_TYPES_FILE_PATH); + const handleEditorWillMount = useCallback(async (monaco: Monaco) => { + const typesSource = await fetchRuntimeTypes(); + monaco.languages.typescript.typescriptDefaults.addExtraLib(typesSource, RUNTIME_TYPES_FILE_PATH); const typesUri = monaco.Uri.parse(RUNTIME_TYPES_FILE_PATH); if (!monaco.editor.getModel(typesUri)) { - monaco.editor.createModel(RUNTIME_TYPES_SOURCE, "typescript", typesUri); + monaco.editor.createModel(typesSource, "typescript", typesUri); } }, []); return (
diff --git a/frontend/src/lib/rules/runtime-types.ts b/frontend/src/lib/rules/runtime-types.ts index d4a062d..54a0b7f 100644 --- a/frontend/src/lib/rules/runtime-types.ts +++ b/frontend/src/lib/rules/runtime-types.ts @@ -1,221 +1,12 @@ -export const RUNTIME_TYPES_FILE_PATH = "file:///focusd-runtime.d.ts"; - -export const RUNTIME_TYPES_SOURCE = `declare module "@focusd/runtime" { - export type ClassificationType = "unknown" | "productive" | "distracting" | "neutral" | "system"; - export type EnforcementActionType = "none" | "block" | "paused" | "allow"; - export type WeekdayType = "Sunday" | "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday" | "Saturday"; - export type Minutes = number; - - export const Classification: { - readonly Unknown: "unknown"; - readonly Productive: "productive"; - readonly Distracting: "distracting"; - readonly Neutral: "neutral"; - readonly System: "system"; - }; - - export const EnforcementAction: { - readonly None: "none"; - readonly Block: "block"; - readonly Paused: "paused"; - readonly Allow: "allow"; - }; - - export const Timezone: { - readonly America_New_York: "America/New_York"; - readonly America_Chicago: "America/Chicago"; - readonly America_Denver: "America/Denver"; - readonly America_Los_Angeles: "America/Los_Angeles"; - readonly America_Anchorage: "America/Anchorage"; - readonly America_Toronto: "America/Toronto"; - readonly America_Vancouver: "America/Vancouver"; - readonly America_Mexico_City: "America/Mexico_City"; - readonly America_Sao_Paulo: "America/Sao_Paulo"; - readonly America_Buenos_Aires: "America/Buenos_Aires"; - readonly America_Bogota: "America/Bogota"; - readonly America_Santiago: "America/Santiago"; - readonly Europe_London: "Europe/London"; - readonly Europe_Paris: "Europe/Paris"; - readonly Europe_Berlin: "Europe/Berlin"; - readonly Europe_Madrid: "Europe/Madrid"; - readonly Europe_Rome: "Europe/Rome"; - readonly Europe_Amsterdam: "Europe/Amsterdam"; - readonly Europe_Zurich: "Europe/Zurich"; - readonly Europe_Brussels: "Europe/Brussels"; - readonly Europe_Stockholm: "Europe/Stockholm"; - readonly Europe_Oslo: "Europe/Oslo"; - readonly Europe_Helsinki: "Europe/Helsinki"; - readonly Europe_Warsaw: "Europe/Warsaw"; - readonly Europe_Prague: "Europe/Prague"; - readonly Europe_Vienna: "Europe/Vienna"; - readonly Europe_Athens: "Europe/Athens"; - readonly Europe_Bucharest: "Europe/Bucharest"; - readonly Europe_Istanbul: "Europe/Istanbul"; - readonly Europe_Moscow: "Europe/Moscow"; - readonly Europe_Dublin: "Europe/Dublin"; - readonly Europe_Lisbon: "Europe/Lisbon"; - readonly Asia_Dubai: "Asia/Dubai"; - readonly Asia_Riyadh: "Asia/Riyadh"; - readonly Asia_Tehran: "Asia/Tehran"; - readonly Asia_Kolkata: "Asia/Kolkata"; - readonly Asia_Dhaka: "Asia/Dhaka"; - readonly Asia_Bangkok: "Asia/Bangkok"; - readonly Asia_Singapore: "Asia/Singapore"; - readonly Asia_Hong_Kong: "Asia/Hong_Kong"; - readonly Asia_Shanghai: "Asia/Shanghai"; - readonly Asia_Tokyo: "Asia/Tokyo"; - readonly Asia_Seoul: "Asia/Seoul"; - readonly Asia_Taipei: "Asia/Taipei"; - readonly Asia_Jakarta: "Asia/Jakarta"; - readonly Asia_Manila: "Asia/Manila"; - readonly Asia_Karachi: "Asia/Karachi"; - readonly Asia_Jerusalem: "Asia/Jerusalem"; - readonly Asia_Yerevan: "Asia/Yerevan"; - readonly Asia_Tbilisi: "Asia/Tbilisi"; - readonly Asia_Baku: "Asia/Baku"; - readonly Africa_Cairo: "Africa/Cairo"; - readonly Africa_Lagos: "Africa/Lagos"; - readonly Africa_Johannesburg: "Africa/Johannesburg"; - readonly Africa_Nairobi: "Africa/Nairobi"; - readonly Africa_Casablanca: "Africa/Casablanca"; - readonly Australia_Sydney: "Australia/Sydney"; - readonly Australia_Melbourne: "Australia/Melbourne"; - readonly Australia_Perth: "Australia/Perth"; - readonly Australia_Brisbane: "Australia/Brisbane"; - readonly Pacific_Auckland: "Pacific/Auckland"; - readonly Pacific_Honolulu: "Pacific/Honolulu"; - readonly UTC: "UTC"; - }; - - export const Weekday: { - readonly Sunday: "Sunday"; - readonly Monday: "Monday"; - readonly Tuesday: "Tuesday"; - readonly Wednesday: "Wednesday"; - readonly Thursday: "Thursday"; - readonly Friday: "Friday"; - readonly Saturday: "Saturday"; - }; - - export interface Classify { - classification: ClassificationType; - classificationReasoning: string; - tags?: string[]; - } - - export interface Enforce { - enforcementAction: EnforcementActionType; - enforcementReason: string; - } - - export function productive(reason: string, tags?: string[]): Classify; - export function distracting(reason: string, tags?: string[]): Classify; - export function neutral(reason: string, tags?: string[]): Classify; - export function block(reason: string): Enforce; - export function allow(reason: string): Enforce; - export function pause(reason: string): Enforce; +import { ReadConfigFileSync } from "../../../bindings/github.com/focusd-so/focusd/internal/fs/service"; - /** - * Summary of time spent in a specific period (e.g., today, this hour). - */ - export interface TimeSummary { - /** - * Overall productivity score for this period, ranging from 0 to 100. - * Higher score indicates more time spent on productive activities. - */ - readonly focusScore: number; - - /** Total minutes classified as productive during this period. */ - readonly productiveMinutes: Minutes; - - /** Total minutes classified as distracting during this period. */ - readonly distractingMinutes: Minutes; - } - - /** - * Insights and duration metrics specific to the currently active application or website. - */ - export interface CurrentUsage { - /** Total minutes spent on this specific app/site today. */ - readonly usedToday: Minutes; - - /** Number of times this specific app/site was blocked today. */ - readonly blocks: number; - - /** - * Minutes elapsed since the last block event for this app/site. - * Returns null if it hasn't been blocked today. - */ - readonly sinceBlock: Minutes | null; - - /** - * Minutes actually spent using this app/site since it was last blocked. - * Returns null if it hasn't been blocked today. - */ - readonly usedSinceBlock: Minutes | null; - - /** - * Calculates how many minutes were spent on this specific app/site - * within the given sliding window of minutes. - * - * @param minutes - The sliding window size in minutes (e.g., 60 for the last hour). - * @returns Minutes spent on this app/site in that window. - */ - last(minutes: number): number; - } - - /** - * The global runtime context available to your custom rules. - */ - export interface Runtime { - /** Aggregate time and score metrics for the entire day. */ - readonly today: TimeSummary; - - /** Aggregate time and score metrics for the current hour. */ - readonly hour: TimeSummary; - - /** Real-time metadata and metrics for the currently active app or website. */ - readonly usage: Usage; - - /** Time utilities bound to specific timezones. */ - readonly time: { - /** Returns a Date object for the current time in the given timezone. */ - now(timezone?: string): Date; - /** Returns the current day of the week in the given timezone. */ - day(timezone?: string): WeekdayType; - }; - } - - /** The global runtime instance. */ - export const runtime: Runtime; +export const RUNTIME_TYPES_FILE_PATH = "file:///focusd-runtime.d.ts"; - /** - * Real-time metadata about the active application or website. - */ - export interface Usage { - /** Name of the desktop application (e.g., "Chrome", "Slack"). */ - readonly app: string; - - /** Active window title. */ - readonly title: string; - - /** Root domain of the website (e.g., "youtube.com"), empty for desktop apps. */ - readonly domain: string; - - /** Full hostname of the website (e.g., "www.youtube.com"), empty for desktop apps. */ - readonly host: string; - - /** URL path (e.g., "/watch"), empty for desktop apps. */ - readonly path: string; - - /** Complete URL, empty for desktop apps. */ - readonly url: string; - - /** Current classification of this app/site before custom rules run. */ - readonly classification: string; - - /** Granular usage durations and limits for this specific app/site. */ - readonly current: CurrentUsage; +export async function fetchRuntimeTypes(): Promise { + try { + return await ReadConfigFileSync("types.d.ts"); + } catch (error) { + console.error("Failed to load runtime types", error); + return ""; } } -`; diff --git a/internal/fs/service.go b/internal/fs/service.go new file mode 100644 index 0000000..430e934 --- /dev/null +++ b/internal/fs/service.go @@ -0,0 +1,35 @@ +package fs + +import ( + "os" + "path/filepath" + "strings" +) + +type Service struct { + configDir string +} + +func NewService(configDir string) *Service { + return &Service{ + configDir: configDir, + } +} + +// ReadConfigFileSync securely reads a file from the .focusd config directory +// It is intended to be called synchronously by the frontend. +func (s *Service) ReadConfigFileSync(filename string) (string, error) { + // Security check to prevent path traversal + cleanName := filepath.Clean(filename) + if strings.Contains(cleanName, "..") { + return "", os.ErrNotExist + } + + fullPath := filepath.Join(s.configDir, cleanName) + bytes, err := os.ReadFile(fullPath) + if err != nil { + return "", err + } + + return string(bytes), nil +} diff --git a/internal/sandbox/registry.go b/internal/sandbox/registry.go new file mode 100644 index 0000000..9c72c3c --- /dev/null +++ b/internal/sandbox/registry.go @@ -0,0 +1,42 @@ +package sandbox + +import ( + "fmt" + "os" + "strings" + + v8 "rogchap.com/v8go" +) + +// Contributor defines a package that extends the JS sandbox environment +type Contributor interface { + Name() string + PolyfillSource() string // JS code prepended to every execution + TypesDefinition() string // TS types combined into types.d.ts + RegisterGlobals(iso *v8.Isolate, global *v8.ObjectTemplate) error +} + +var contributors []Contributor + +// Register adds a new sandbox contributor +func Register(c Contributor) { + contributors = append(contributors, c) +} + +// GenerateTypes combines all TypeScript definitions from contributors and writes them to the specified path +func GenerateTypes(outputPath string) error { + var sb strings.Builder + + sb.WriteString("// Auto-generated by focusd\n\n") + + for _, c := range contributors { + types := c.TypesDefinition() + if types != "" { + sb.WriteString(fmt.Sprintf("// --- Types for %s ---\n", c.Name())) + sb.WriteString(types) + sb.WriteString("\n\n") + } + } + + return os.WriteFile(outputPath, []byte(sb.String()), 0644) +} diff --git a/internal/sandbox/sandbox.go b/internal/sandbox/sandbox.go new file mode 100644 index 0000000..6e9173a --- /dev/null +++ b/internal/sandbox/sandbox.go @@ -0,0 +1,206 @@ +package sandbox + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/evanw/esbuild/pkg/api" + v8 "rogchap.com/v8go" +) + +// Result contains the output of the sandbox execution and the console logs +type Result struct { + Output string + Logs []string +} + +// Sandbox represents a single execution context +type Sandbox struct { + isolate *v8.Isolate + global *v8.ObjectTemplate + logs []string +} + +// New creates a new V8 sandbox +func New() (*Sandbox, error) { + isolate := v8.NewIsolate() + global := v8.NewObjectTemplate(isolate) + + s := &Sandbox{ + isolate: isolate, + global: global, + logs: make([]string, 0), + } + + // Inject console.log + consoleCb := v8.NewFunctionTemplate(isolate, func(info *v8.FunctionCallbackInfo) *v8.Value { + args := info.Args() + var parts []string + for _, arg := range args { + parts = append(parts, arg.String()) + } + s.logs = append(s.logs, strings.Join(parts, " ")) + return nil + }) + + if err := global.Set("__console_log", consoleCb); err != nil { + s.Close() + return nil, fmt.Errorf("failed to set __console_log function: %w", err) + } + + // Register globals from static contributors + for _, c := range contributors { + if err := c.RegisterGlobals(isolate, global); err != nil { + s.Close() + return nil, fmt.Errorf("failed to register globals for contributor %s: %w", c.Name(), err) + } + } + + return s, nil +} + +// RegisterGlobal injects a dynamic Go function into this specific Sandbox execution context +func (s *Sandbox) RegisterGlobal(name string, cb v8.FunctionCallback) error { + fn := v8.NewFunctionTemplate(s.isolate, cb) + return s.global.Set(name, fn) +} + +// Close releases V8 resources +func (s *Sandbox) Close() { + if s.isolate != nil { + s.isolate.Dispose() + s.isolate = nil + } +} + +// Execute runs the JavaScript code and calls the specified function with JSON-serialized args +func (s *Sandbox) Execute(code string, fnName string, args ...any) (*Result, error) { + // Transpile user code with CommonJS format to handle export statements + // Use ES2016 target to transpile async/await to generators which can run synchronously + result := api.Transform(code, api.TransformOptions{ + Loader: api.LoaderTS, + Target: api.ES2016, + Format: api.FormatCommonJS, + }) + + if len(result.Errors) > 0 { + messages := api.FormatMessages(result.Errors, api.FormatMessagesOptions{ + Kind: api.ErrorMessage, + Color: false, + }) + return nil, fmt.Errorf("failed to transpile script: %s", strings.Join(messages, "\n")) + } + + transpiledCode := string(result.Code) + + var sb strings.Builder + + // Setup basic exports mechanism for CJS transpiled code + sb.WriteString(` +var exports = {}; +var module = { exports: exports }; +`) + + // Inject all contributor polyfills BEFORE the user code + for _, c := range contributors { + sb.WriteString(fmt.Sprintf("\n// --- Polyfill %s ---\n", c.Name())) + sb.WriteString(c.PolyfillSource()) + } + + sb.WriteString("\n// --- User Code ---\n") + sb.WriteString(transpiledCode) + + sb.WriteString("\n// Expose exported functions to globalThis\n") + sb.WriteString("var _exported = module.exports || exports;\n") + sb.WriteString("for (var key in _exported) { if (typeof _exported[key] === 'function') { globalThis[key] = _exported[key]; } }\n") + + preparedScript := sb.String() + + v8ctx := v8.NewContext(s.isolate, s.global) + defer v8ctx.Close() + + // Polyfill basic console in JS + _, err := v8ctx.RunScript(` + if (typeof console === 'undefined') { + globalThis.console = { + log: __console_log, + info: __console_log, + warn: __console_log, + error: __console_log, + debug: __console_log + }; + } else { + console.log = __console_log; + console.info = __console_log; + console.warn = __console_log; + console.error = __console_log; + console.debug = __console_log; + } + `, "console_polyfill.js") + + if err != nil { + return nil, fmt.Errorf("failed to polyfill console: %w", err) + } + + // Run the prepared script to define functions + _, err = v8ctx.RunScript(preparedScript, "user_rules.js") + if err != nil { + return &Result{Logs: s.logs}, fmt.Errorf("failed to execute user script: %w", err) + } + + global := v8ctx.Global() + funcVal, err := global.Get(fnName) + if err != nil { + return &Result{Logs: s.logs}, fmt.Errorf("failed to get %s function: %w", fnName, err) + } + + if funcVal.IsUndefined() || funcVal.IsNull() { + // Function not defined - return empty + return &Result{Logs: s.logs}, nil + } + + // Serialize arguments to JSON + var jsonArgs []string + for _, arg := range args { + b, err := json.Marshal(arg) + if err != nil { + return nil, fmt.Errorf("failed to marshal arg to JSON: %w", err) + } + jsonArgs = append(jsonArgs, string(b)) + } + + callScript := fmt.Sprintf(` + (function() { + var args = [%s]; + var fn = globalThis['%s']; + if (typeof fn !== 'function') return undefined; + + var result = fn.apply(null, args); + if (result === undefined || result === null) { + return undefined; + } + return JSON.stringify(result); + })() + `, strings.Join(jsonArgs, ", "), fnName) + + resultVal, err := v8ctx.RunScript(callScript, "call_function.js") + if err != nil { + return &Result{Logs: s.logs}, fmt.Errorf("failed to call %s function: %w", fnName, err) + } + + if resultVal == nil || resultVal.IsUndefined() || resultVal.IsNull() { + return &Result{Logs: s.logs}, nil + } + + resultJSON := resultVal.String() + if resultJSON == "null" || resultJSON == "undefined" { + return &Result{Logs: s.logs}, nil + } + + return &Result{ + Output: resultJSON, + Logs: s.logs, + }, nil +} + diff --git a/internal/usage/classifier_custom_rules.go b/internal/usage/classifier_custom_rules.go index 7798936..41c5671 100644 --- a/internal/usage/classifier_custom_rules.go +++ b/internal/usage/classifier_custom_rules.go @@ -8,9 +8,18 @@ import ( "log/slog" "time" + "github.com/focusd-so/focusd/internal/sandbox" "github.com/focusd-so/focusd/internal/settings" + v8 "rogchap.com/v8go" ) +// classificationResult is returned from the classify function. +type classificationResult struct { + Classification string `json:"classification"` + ClassificationReasoning string `json:"classificationReasoning"` + Tags []string `json:"tags"` +} + func (s *Service) ClassifyCustomRules(ctx context.Context, opts ...sandboxContextOption) (*ClassificationResponse, error) { slog.Info("classifying application usage with custom rules") @@ -95,11 +104,52 @@ func (s *Service) classifySandbox(ctx context.Context, sandboxCtx sandboxContext return nil, nil, nil } - // Create a new sandbox with the custom rules code - sb, err := newSandbox(customRules) + sb, err := sandbox.New() + if err != nil { + return nil, nil, fmt.Errorf("failed to create sandbox: %w", err) + } + defer sb.Close() + + if sandboxCtx.MinutesUsedInPeriod != nil { + err = sb.RegisterGlobal("__minutesUsedInPeriod", func(info *v8.FunctionCallbackInfo) *v8.Value { + args := info.Args() + if len(args) < 3 { + val, _ := v8.NewValue(info.Context().Isolate(), int32(0)) + return val + } + + appName := args[0].String() + hostname := args[1].String() + minutes := int64(args[2].Integer()) + + result, err := sandboxCtx.MinutesUsedInPeriod(appName, hostname, minutes) + if err != nil { + slog.Debug("failed to query minutes used", "error", err) + val, _ := v8.NewValue(info.Context().Isolate(), int32(0)) + return val + } + + val, _ := v8.NewValue(info.Context().Isolate(), int32(result)) + return val + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to register minutes query func: %w", err) + } + } + + result, err := sb.Execute(customRules, "__classify_wrapper", sandboxCtx) if err != nil { - return nil, nil, err + return nil, result.Logs, err + } + + if result.Output == "" || result.Output == "null" || result.Output == "undefined" { + return nil, result.Logs, nil + } + + var d classificationResult + if err := json.Unmarshal([]byte(result.Output), &d); err != nil { + return nil, result.Logs, fmt.Errorf("failed to parse classification decision: %w", err) } - return sb.invokeClassify(sandboxCtx) + return &d, result.Logs, nil } diff --git a/internal/usage/protection.go b/internal/usage/protection.go index 864b3a8..ede2b68 100644 --- a/internal/usage/protection.go +++ b/internal/usage/protection.go @@ -5,15 +5,24 @@ import ( "context" "encoding/json" "fmt" + "log/slog" "strings" "time" "gorm.io/gorm" "github.com/focusd-so/focusd/internal/identity" + "github.com/focusd-so/focusd/internal/sandbox" "github.com/focusd-so/focusd/internal/settings" + v8 "rogchap.com/v8go" ) +// enforcement is returned from the enforcement function. +type enforcement struct { + EnforcementAction string `json:"enforcementAction"` + EnforcementReason string `json:"enforcementReason"` +} + // PauseProtection temporarily disables focus protection for the specified duration. // // The function starts a background goroutine that automatically resumes protection @@ -387,27 +396,69 @@ func (s *Service) calculateEnforcementDecisionWithCustomRules(_ context.Context, } // Create a new sandbox with the custom rules code - sb, err := newSandbox(customRules) + sb, err := sandbox.New() if err != nil { if logErr := finalizeExecutionLog(nil, nil, err); logErr != nil { return EnforcementDecision{}, logErr } return EnforcementDecision{}, err } + defer sb.Close() - decision, logs, err := sb.invokeEnforcement(sandboxCtx) - if logErr := finalizeExecutionLog(decision, logs, err); logErr != nil { - return EnforcementDecision{}, logErr + if sandboxCtx.MinutesUsedInPeriod != nil { + err = sb.RegisterGlobal("__minutesUsedInPeriod", func(info *v8.FunctionCallbackInfo) *v8.Value { + args := info.Args() + if len(args) < 3 { + val, _ := v8.NewValue(info.Context().Isolate(), int32(0)) + return val + } + + appName := args[0].String() + hostname := args[1].String() + minutes := int64(args[2].Integer()) + + result, err := sandboxCtx.MinutesUsedInPeriod(appName, hostname, minutes) + if err != nil { + slog.Debug("failed to query minutes used", "error", err) + val, _ := v8.NewValue(info.Context().Isolate(), int32(0)) + return val + } + + val, _ := v8.NewValue(info.Context().Isolate(), int32(result)) + return val + }) + if err != nil { + return EnforcementDecision{}, fmt.Errorf("failed to register minutes query func: %w", err) + } } + execResult, err := sb.Execute(customRules, "__enforcement_wrapper", sandboxCtx) if err != nil { + if logErr := finalizeExecutionLog(nil, execResult.Logs, err); logErr != nil { + return EnforcementDecision{}, logErr + } return EnforcementDecision{}, err } - if decision == nil { + if execResult.Output == "" || execResult.Output == "null" || execResult.Output == "undefined" { + if logErr := finalizeExecutionLog(nil, execResult.Logs, nil); logErr != nil { + return EnforcementDecision{}, logErr + } return EnforcementDecision{Action: EnforcementActionNone}, nil } + var decision enforcement + if err := json.Unmarshal([]byte(execResult.Output), &decision); err != nil { + if logErr := finalizeExecutionLog(nil, execResult.Logs, err); logErr != nil { + return EnforcementDecision{}, logErr + } + return EnforcementDecision{}, err + } + + if logErr := finalizeExecutionLog(&decision, execResult.Logs, nil); logErr != nil { + return EnforcementDecision{}, logErr + } + return EnforcementDecision{ Action: EnforcementAction(decision.EnforcementAction), Reason: EnforcementReason(decision.EnforcementReason), diff --git a/internal/usage/sandbox.go b/internal/usage/sandbox.go deleted file mode 100644 index 4c00ec6..0000000 --- a/internal/usage/sandbox.go +++ /dev/null @@ -1,533 +0,0 @@ -package usage - -import ( - "encoding/json" - "fmt" - "log/slog" - "strings" - "time" - - "github.com/evanw/esbuild/pkg/api" - v8 "rogchap.com/v8go" -) - -// classificationResult is returned from the classify function. -type classificationResult struct { - Classification string `json:"classification"` - ClassificationReasoning string `json:"classificationReasoning"` - Tags []string `json:"tags"` -} - -// enforcement is returned from the enforcement function. -type enforcement struct { - EnforcementAction string `json:"enforcementAction"` - EnforcementReason string `json:"enforcementReason"` -} - -// sandbox executes user-defined JavaScript rules using V8 -type sandbox struct { - isolate *v8.Isolate - code string - - logs []string -} - -// newSandbox creates a new V8 sandbox with the given JavaScript code -func newSandbox(code string) (*sandbox, error) { - return &sandbox{ - isolate: v8.NewIsolate(), - code: code, - }, nil -} - -func formatEsbuildErrors(errors []api.Message) string { - if len(errors) == 0 { - return "" - } - - messages := api.FormatMessages(errors, api.FormatMessagesOptions{ - Kind: api.ErrorMessage, - Color: false, - }) - - return strings.Join(messages, "\n") -} - -// prepareScript transpiles TypeScript and exposes @focusd/runtime as the only importable module. -func prepareScript(code string) (string, error) { - // Transpile user code with CommonJS format to handle export statements - // Use ES2016 target to transpile async/await to generators which can run synchronously - result := api.Transform(code, api.TransformOptions{ - Loader: api.LoaderTS, - Target: api.ES2016, - Format: api.FormatCommonJS, - }) - - if len(result.Errors) > 0 { - return "", fmt.Errorf("failed to transpile script: %s", formatEsbuildErrors(result.Errors)) - } - - transpiledCode := string(result.Code) - - // Wrap transpiled CommonJS with runtime module + function exports. - preparedScript := fmt.Sprintf(` -var Classification = Object.freeze({ - Unknown: "unknown", - Productive: "productive", - Distracting: "distracting", - Neutral: "neutral", - System: "system" -}); - -var EnforcementAction = Object.freeze({ - None: "none", - Block: "block", - Paused: "paused", - Allow: "allow" -}); - -var Weekday = Object.freeze({ - Sunday: "Sunday", - Monday: "Monday", - Tuesday: "Tuesday", - Wednesday: "Wednesday", - Thursday: "Thursday", - Friday: "Friday", - Saturday: "Saturday" -}); - -var Timezone = Object.freeze({ - // Americas - America_New_York: "America/New_York", - America_Chicago: "America/Chicago", - America_Denver: "America/Denver", - America_Los_Angeles: "America/Los_Angeles", - America_Anchorage: "America/Anchorage", - America_Toronto: "America/Toronto", - America_Vancouver: "America/Vancouver", - America_Mexico_City: "America/Mexico_City", - America_Sao_Paulo: "America/Sao_Paulo", - America_Buenos_Aires: "America/Buenos_Aires", - America_Bogota: "America/Bogota", - America_Santiago: "America/Santiago", - // Europe - Europe_London: "Europe/London", - Europe_Paris: "Europe/Paris", - Europe_Berlin: "Europe/Berlin", - Europe_Madrid: "Europe/Madrid", - Europe_Rome: "Europe/Rome", - Europe_Amsterdam: "Europe/Amsterdam", - Europe_Zurich: "Europe/Zurich", - Europe_Brussels: "Europe/Brussels", - Europe_Stockholm: "Europe/Stockholm", - Europe_Oslo: "Europe/Oslo", - Europe_Helsinki: "Europe/Helsinki", - Europe_Warsaw: "Europe/Warsaw", - Europe_Prague: "Europe/Prague", - Europe_Vienna: "Europe/Vienna", - Europe_Athens: "Europe/Athens", - Europe_Bucharest: "Europe/Bucharest", - Europe_Istanbul: "Europe/Istanbul", - Europe_Moscow: "Europe/Moscow", - Europe_Dublin: "Europe/Dublin", - Europe_Lisbon: "Europe/Lisbon", - // Asia - Asia_Dubai: "Asia/Dubai", - Asia_Riyadh: "Asia/Riyadh", - Asia_Tehran: "Asia/Tehran", - Asia_Kolkata: "Asia/Kolkata", - Asia_Dhaka: "Asia/Dhaka", - Asia_Bangkok: "Asia/Bangkok", - Asia_Singapore: "Asia/Singapore", - Asia_Hong_Kong: "Asia/Hong_Kong", - Asia_Shanghai: "Asia/Shanghai", - Asia_Tokyo: "Asia/Tokyo", - Asia_Seoul: "Asia/Seoul", - Asia_Taipei: "Asia/Taipei", - Asia_Jakarta: "Asia/Jakarta", - Asia_Manila: "Asia/Manila", - Asia_Karachi: "Asia/Karachi", - Asia_Jerusalem: "Asia/Jerusalem", - Asia_Yerevan: "Asia/Yerevan", - Asia_Tbilisi: "Asia/Tbilisi", - Asia_Baku: "Asia/Baku", - // Africa - Africa_Cairo: "Africa/Cairo", - Africa_Lagos: "Africa/Lagos", - Africa_Johannesburg: "Africa/Johannesburg", - Africa_Nairobi: "Africa/Nairobi", - Africa_Casablanca: "Africa/Casablanca", - // Oceania - Australia_Sydney: "Australia/Sydney", - Australia_Melbourne: "Australia/Melbourne", - Australia_Perth: "Australia/Perth", - Australia_Brisbane: "Australia/Brisbane", - Pacific_Auckland: "Pacific/Auckland", - Pacific_Honolulu: "Pacific/Honolulu", - // UTC - UTC: "UTC" -}); - -function __runtimeNow(timezone) { - const ts = __getShiftedTimestamp(timezone); - return new Date(ts); -} - -function __runtimeDayOfWeek(timezone) { - const days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; - return days[__runtimeNow(timezone).getDay()]; -} - -function productive(reason, tags) { - return { classification: "productive", classificationReasoning: reason, tags: tags }; -} -function distracting(reason, tags) { - return { classification: "distracting", classificationReasoning: reason, tags: tags }; -} -function neutral(reason, tags) { - return { classification: "neutral", classificationReasoning: reason, tags: tags }; -} -function block(reason) { - return { enforcementAction: "block", enforcementReason: reason }; -} -function allow(reason) { - return { enforcementAction: "allow", enforcementReason: reason }; -} -function pause(reason) { - return { enforcementAction: "paused", enforcementReason: reason }; -} - -var __runtimeModule = { - Classification: Classification, - EnforcementAction: EnforcementAction, - Timezone: Timezone, - Weekday: Weekday, - productive: productive, - distracting: distracting, - neutral: neutral, - block: block, - allow: allow, - pause: pause, - get runtime() { - return globalThis.__focusd_runtime_context || { - today: { focusScore: 0, productiveMinutes: 0, distractingMinutes: 0 }, - hour: { focusScore: 0, productiveMinutes: 0, distractingMinutes: 0 }, - time: { - now: __runtimeNow, - day: __runtimeDayOfWeek - } - }; - } -}; -Object.freeze(__runtimeModule.Classification); -Object.freeze(__runtimeModule.EnforcementAction); -Object.freeze(__runtimeModule.Timezone); -Object.freeze(__runtimeModule.Weekday); - -function require(specifier) { - if (specifier === "@focusd/runtime") { - return __runtimeModule; - } - - throw new Error("Unsupported import: " + specifier + ". Only '@focusd/runtime' is available."); -} - -var exports = {}; -var module = { exports: exports }; - -%s - -// Expose exported functions to globalThis -// Check both module.exports and exports for functions -var _exported = module.exports || exports; -if (_exported && typeof _exported.classify === 'function') { globalThis.__classify = _exported.classify; } -if (_exported && typeof _exported.enforcement === 'function') { globalThis.__enforcement = _exported.enforcement; } -// Also check for top-level function declarations (non-exported) -if (typeof classify === 'function') { globalThis.__classify = classify; } -if (typeof enforcement === 'function') { globalThis.__enforcement = enforcement; } - -// Polyfill console -if (typeof console === 'undefined') { - globalThis.console = { - log: __console_log, - info: __console_log, - warn: __console_log, - error: __console_log, - debug: __console_log - }; -} else { - console.log = __console_log; - console.info = __console_log; - console.warn = __console_log; - console.error = __console_log; - console.debug = __console_log; -} -`, transpiledCode) - - return preparedScript, nil -} - -// setupContext prepares the V8 context with globals like __console_log and __getShiftedTimestamp -func (s *sandbox) setupContext(ctx sandboxContext, v8ctx *v8.Context) error { - global := v8ctx.Global() - - // Inject __getShiftedTimestamp function - cb := v8.NewFunctionTemplate(s.isolate, func(info *v8.FunctionCallbackInfo) *v8.Value { - args := info.Args() - var loc *time.Location - var err error - - if len(args) > 0 && args[0].IsString() { - loc, err = time.LoadLocation(args[0].String()) - } - - // Default to local if not found or error or not provided - if loc == nil || err != nil { - loc = time.Local - } - - var t time.Time - if ctx.Now != nil { - t = ctx.Now(loc) - } else { - t = time.Now().In(loc) - } - - // Shift time to appear as Local time but with target wall clock values - year, month, day := t.Date() - hour, min, sec := t.Clock() - nsec := t.Nanosecond() - - shifted := time.Date(year, month, day, hour, min, sec, nsec, time.Local) - ts := shifted.UnixMilli() - - val, _ := v8.NewValue(s.isolate, float64(ts)) - return val - }) - - fn := cb.GetFunction(v8ctx) - if err := global.Set("__getShiftedTimestamp", fn); err != nil { - return fmt.Errorf("failed to set __getShiftedTimestamp function: %w", err) - } - - // Inject console.log - consoleCb := v8.NewFunctionTemplate(s.isolate, func(info *v8.FunctionCallbackInfo) *v8.Value { - args := info.Args() - var parts []string - for _, arg := range args { - parts = append(parts, arg.String()) - } - s.logs = append(s.logs, strings.Join(parts, " ")) - - return nil - }) - - consoleFn := consoleCb.GetFunction(v8ctx) - if err := global.Set("__console_log", consoleFn); err != nil { - return fmt.Errorf("failed to set __console_log function: %w", err) - } - - // Inject __minutesUsedInPeriod function (appName, hostname, minutes) -> int64 - if ctx.MinutesUsedInPeriod != nil { - usageCb := v8.NewFunctionTemplate(s.isolate, func(info *v8.FunctionCallbackInfo) *v8.Value { - args := info.Args() - if len(args) < 3 { - val, _ := v8.NewValue(s.isolate, int32(0)) - return val - } - - appName := args[0].String() - hostname := args[1].String() - minutes := int64(args[2].Integer()) - - result, err := ctx.MinutesUsedInPeriod(appName, hostname, minutes) - if err != nil { - slog.Debug("failed to query minutes used", "error", err) - val, _ := v8.NewValue(s.isolate, int32(0)) - return val - } - - val, _ := v8.NewValue(s.isolate, int32(result)) - return val - }) - - usageFn := usageCb.GetFunction(v8ctx) - if err := global.Set("__minutesUsedInPeriod", usageFn); err != nil { - return fmt.Errorf("failed to set __minutesUsedInPeriod function: %w", err) - } - } - - return nil -} - -// executeFunction runs the prepared script and then calls the specified function with context -func (s *sandbox) executeFunction(v8ctx *v8.Context, preparedScript string, functionName string, ctx sandboxContext) (string, error) { - // Run the prepared script to define functions - _, err := v8ctx.RunScript(preparedScript, "user_rules.js") - if err != nil { - return "", fmt.Errorf("failed to execute user script: %w", err) - } - - // Check if the function exists - global := v8ctx.Global() - funcVal, err := global.Get(functionName) - if err != nil { - return "", fmt.Errorf("failed to get %s function: %w", functionName, err) - } - - if funcVal.IsUndefined() || funcVal.IsNull() { - // Function not defined - return empty - return "", nil - } - - // Marshal context to JSON - ctxJSON, err := json.Marshal(ctx) - if err != nil { - return "", fmt.Errorf("failed to marshal context: %w", err) - } - - // Call the function with flat usage context. - callScript := fmt.Sprintf(` - (function() { - var raw = %s; - var u = raw.usage || {}; - var meta = u.meta || {}; - var ins = u.insights || {}; - var cur = ins.current || {}; - var dur = cur.duration || {}; - var blk = cur.blocks || {}; - - var lastFn = (typeof __minutesUsedInPeriod === 'function') - ? function(m) { return __minutesUsedInPeriod(meta.appName || "", meta.host || "", m); } - : function() { return 0; }; - - var ctx = { - app: meta.appName || "", - title: meta.title || "", - domain: meta.domain || "", - host: meta.host || "", - path: meta.path || "", - url: meta.url || "", - classification: meta.classification || "", - current: { - usedToday: dur.today || 0, - blocks: blk.count || 0, - sinceBlock: dur.sinceLastBlock != null ? dur.sinceLastBlock : null, - usedSinceBlock: dur.usedSinceLastBlock != null ? dur.usedSinceLastBlock : null, - last: lastFn - } - }; - - globalThis.__focusd_runtime_context = { - today: ins.today || { focusScore: 0, productiveMinutes: 0, distractingMinutes: 0 }, - hour: ins.hour || { focusScore: 0, productiveMinutes: 0, distractingMinutes: 0 }, - time: { - now: __runtimeNow, - day: __runtimeDayOfWeek - }, - usage: ctx - }; - - var result = %s(); - if (result === undefined || result === null) { - return undefined; - } - return JSON.stringify(result); - })() - `, string(ctxJSON), functionName) - - resultVal, err := v8ctx.RunScript(callScript, "call_function.js") - if err != nil { - return "", fmt.Errorf("failed to call %s function: %w", functionName, err) - } - - if resultVal == nil || resultVal.IsUndefined() || resultVal.IsNull() { - return "", nil - } - - resultJSON := resultVal.String() - if resultJSON == "null" || resultJSON == "undefined" { - return "", nil - } - - return resultJSON, nil -} - -// close releases V8 resources -func (s *sandbox) close() { - if s.isolate != nil { - s.isolate.Dispose() - s.isolate = nil - } -} - -// invokeClassify executes the classify function and returns the result -// Returns nil if the function returns undefined -func (s *sandbox) invokeClassify(ctx sandboxContext) (*classificationResult, []string, error) { - // Prepare script with function exports and helpers - preparedScript, err := prepareScript(s.code) - if err != nil { - return nil, nil, fmt.Errorf("failed to prepare script: %w", err) - } - - v8ctx := v8.NewContext(s.isolate) - defer s.close() - - // Setup V8 context with __console_log and __getShiftedTimestamp - if err := s.setupContext(ctx, v8ctx); err != nil { - return nil, nil, fmt.Errorf("failed to setup context: %w", err) - } - - // Execute the function - resultJSON, err := s.executeFunction(v8ctx, preparedScript, "__classify", ctx) - if err != nil { - return nil, s.logs, fmt.Errorf("failed to execute classify: %w", err) - } - - var decision classificationResult - - if resultJSON == "" { - return nil, s.logs, nil - } - - if err := json.Unmarshal([]byte(resultJSON), &decision); err != nil { - return nil, s.logs, fmt.Errorf("failed to parse classification decision: %w", err) - } - - return &decision, s.logs, nil -} - -// invokeEnforcement executes the enforcement function and returns the result. -// Returns nil if the function returns undefined -func (s *sandbox) invokeEnforcement(ctx sandboxContext) (*enforcement, []string, error) { - // Prepare script with function exports and helpers - preparedScript, err := prepareScript(s.code) - if err != nil { - return nil, nil, fmt.Errorf("failed to prepare script: %w", err) - } - - v8ctx := v8.NewContext(s.isolate) - defer s.close() - - // Setup V8 context with __console_log and __getShiftedTimestamp - if err := s.setupContext(ctx, v8ctx); err != nil { - return nil, nil, fmt.Errorf("failed to setup context: %w", err) - } - - // Execute the function - resultJSON, err := s.executeFunction(v8ctx, preparedScript, "__enforcement", ctx) - if err != nil { - return nil, s.logs, fmt.Errorf("failed to execute enforcement: %w", err) - } - - if resultJSON == "" { - return nil, s.logs, nil - } - - var decision enforcement - if err := json.Unmarshal([]byte(resultJSON), &decision); err != nil { - return nil, s.logs, fmt.Errorf("failed to parse enforcement decision: %w", err) - } - - return &decision, s.logs, nil -} diff --git a/internal/usage/sandbox_contributor.go b/internal/usage/sandbox_contributor.go new file mode 100644 index 0000000..e6b974f --- /dev/null +++ b/internal/usage/sandbox_contributor.go @@ -0,0 +1,430 @@ +package usage + +import ( + "fmt" + "time" + + v8 "rogchap.com/v8go" +) + +type UsageContributor struct{} + +func NewUsageContributor() *UsageContributor { + return &UsageContributor{} +} + +func (c *UsageContributor) Name() string { + return "usage" +} + +func (c *UsageContributor) TypesDefinition() string { + return `declare module "@focusd/runtime" { + export type ClassificationType = "unknown" | "productive" | "distracting" | "neutral" | "system"; + export type EnforcementActionType = "none" | "block" | "paused" | "allow"; + export type WeekdayType = "Sunday" | "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday" | "Saturday"; + export type Minutes = number; + + export const Classification: { + readonly Unknown: "unknown"; + readonly Productive: "productive"; + readonly Distracting: "distracting"; + readonly Neutral: "neutral"; + readonly System: "system"; + }; + + export const EnforcementAction: { + readonly None: "none"; + readonly Block: "block"; + readonly Paused: "paused"; + readonly Allow: "allow"; + }; + + export const Timezone: { + readonly America_New_York: "America/New_York"; + readonly America_Chicago: "America/Chicago"; + readonly America_Denver: "America/Denver"; + readonly America_Los_Angeles: "America/Los_Angeles"; + readonly America_Anchorage: "America/Anchorage"; + readonly America_Toronto: "America/Toronto"; + readonly America_Vancouver: "America/Vancouver"; + readonly America_Mexico_City: "America/Mexico_City"; + readonly America_Sao_Paulo: "America/Sao_Paulo"; + readonly America_Buenos_Aires: "America/Buenos_Aires"; + readonly America_Bogota: "America/Bogota"; + readonly America_Santiago: "America/Santiago"; + readonly Europe_London: "Europe/London"; + readonly Europe_Paris: "Europe/Paris"; + readonly Europe_Berlin: "Europe/Berlin"; + readonly Europe_Madrid: "Europe/Madrid"; + readonly Europe_Rome: "Europe/Rome"; + readonly Europe_Amsterdam: "Europe/Amsterdam"; + readonly Europe_Zurich: "Europe/Zurich"; + readonly Europe_Brussels: "Europe/Brussels"; + readonly Europe_Stockholm: "Europe/Stockholm"; + readonly Europe_Oslo: "Europe/Oslo"; + readonly Europe_Helsinki: "Europe/Helsinki"; + readonly Europe_Warsaw: "Europe/Warsaw"; + readonly Europe_Prague: "Europe/Prague"; + readonly Europe_Vienna: "Europe/Vienna"; + readonly Europe_Athens: "Europe/Athens"; + readonly Europe_Bucharest: "Europe/Bucharest"; + readonly Europe_Istanbul: "Europe/Istanbul"; + readonly Europe_Moscow: "Europe/Moscow"; + readonly Europe_Dublin: "Europe/Dublin"; + readonly Europe_Lisbon: "Europe/Lisbon"; + readonly Asia_Dubai: "Asia/Dubai"; + readonly Asia_Riyadh: "Asia/Riyadh"; + readonly Asia_Tehran: "Asia/Tehran"; + readonly Asia_Kolkata: "Asia/Kolkata"; + readonly Asia_Dhaka: "Asia/Dhaka"; + readonly Asia_Bangkok: "Asia/Bangkok"; + readonly Asia_Singapore: "Asia/Singapore"; + readonly Asia_Hong_Kong: "Asia/Hong_Kong"; + readonly Asia_Shanghai: "Asia/Shanghai"; + readonly Asia_Tokyo: "Asia/Tokyo"; + readonly Asia_Seoul: "Asia/Seoul"; + readonly Asia_Taipei: "Asia/Taipei"; + readonly Asia_Jakarta: "Asia/Jakarta"; + readonly Asia_Manila: "Asia/Manila"; + readonly Asia_Karachi: "Asia/Karachi"; + readonly Asia_Jerusalem: "Asia/Jerusalem"; + readonly Asia_Yerevan: "Asia/Yerevan"; + readonly Asia_Tbilisi: "Asia/Tbilisi"; + readonly Asia_Baku: "Asia/Baku"; + readonly Africa_Cairo: "Africa/Cairo"; + readonly Africa_Lagos: "Africa/Lagos"; + readonly Africa_Johannesburg: "Africa/Johannesburg"; + readonly Africa_Nairobi: "Africa/Nairobi"; + readonly Africa_Casablanca: "Africa/Casablanca"; + readonly Australia_Sydney: "Australia/Sydney"; + readonly Australia_Melbourne: "Australia/Melbourne"; + readonly Australia_Perth: "Australia/Perth"; + readonly Australia_Brisbane: "Australia/Brisbane"; + readonly Pacific_Auckland: "Pacific/Auckland"; + readonly Pacific_Honolulu: "Pacific/Honolulu"; + readonly UTC: "UTC"; + }; + + export const Weekday: { + readonly Sunday: "Sunday"; + readonly Monday: "Monday"; + readonly Tuesday: "Tuesday"; + readonly Wednesday: "Wednesday"; + readonly Thursday: "Thursday"; + readonly Friday: "Friday"; + readonly Saturday: "Saturday"; + }; + + export interface Classify { + classification: ClassificationType; + classificationReasoning: string; + tags?: string[]; + } + + export interface Enforce { + enforcementAction: EnforcementActionType; + enforcementReason: string; + } + + export function productive(reason: string, tags?: string[]): Classify; + export function distracting(reason: string, tags?: string[]): Classify; + export function neutral(reason: string, tags?: string[]): Classify; + export function block(reason: string): Enforce; + export function allow(reason: string): Enforce; + export function pause(reason: string): Enforce; + + export interface TimeSummary { + readonly focusScore: number; + readonly productiveMinutes: Minutes; + readonly distractingMinutes: Minutes; + } + + export interface CurrentUsage { + readonly usedToday: Minutes; + readonly blocks: number; + readonly sinceBlock: Minutes | null; + readonly usedSinceBlock: Minutes | null; + last(minutes: number): number; + } + + export interface Runtime { + readonly today: TimeSummary; + readonly hour: TimeSummary; + readonly usage: Usage; + readonly time: { + now(timezone?: string): Date; + day(timezone?: string): WeekdayType; + }; + } + + export const runtime: Runtime; + + export interface Usage { + readonly app: string; + readonly title: string; + readonly domain: string; + readonly host: string; + readonly path: string; + readonly url: string; + readonly classification: string; + readonly current: CurrentUsage; + } +}` +} + +func (c *UsageContributor) PolyfillSource() string { + return ` +var Classification = Object.freeze({ + Unknown: "unknown", + Productive: "productive", + Distracting: "distracting", + Neutral: "neutral", + System: "system" +}); + +var EnforcementAction = Object.freeze({ + None: "none", + Block: "block", + Paused: "paused", + Allow: "allow" +}); + +var Weekday = Object.freeze({ + Sunday: "Sunday", + Monday: "Monday", + Tuesday: "Tuesday", + Wednesday: "Wednesday", + Thursday: "Thursday", + Friday: "Friday", + Saturday: "Saturday" +}); + +var Timezone = Object.freeze({ + America_New_York: "America/New_York", + America_Chicago: "America/Chicago", + America_Denver: "America/Denver", + America_Los_Angeles: "America/Los_Angeles", + America_Anchorage: "America/Anchorage", + America_Toronto: "America/Toronto", + America_Vancouver: "America/Vancouver", + America_Mexico_City: "America/Mexico_City", + America_Sao_Paulo: "America/Sao_Paulo", + America_Buenos_Aires: "America/Buenos_Aires", + America_Bogota: "America/Bogota", + America_Santiago: "America/Santiago", + Europe_London: "Europe/London", + Europe_Paris: "Europe/Paris", + Europe_Berlin: "Europe/Berlin", + Europe_Madrid: "Europe/Madrid", + Europe_Rome: "Europe/Rome", + Europe_Amsterdam: "Europe/Amsterdam", + Europe_Zurich: "Europe/Zurich", + Europe_Brussels: "Europe/Brussels", + Europe_Stockholm: "Europe/Stockholm", + Europe_Oslo: "Europe/Oslo", + Europe_Helsinki: "Europe/Helsinki", + Europe_Warsaw: "Europe/Warsaw", + Europe_Prague: "Europe/Prague", + Europe_Vienna: "Europe/Vienna", + Europe_Athens: "Europe/Athens", + Europe_Bucharest: "Europe/Bucharest", + Europe_Istanbul: "Europe/Istanbul", + Europe_Moscow: "Europe/Moscow", + Europe_Dublin: "Europe/Dublin", + Europe_Lisbon: "Europe/Lisbon", + Asia_Dubai: "Asia/Dubai", + Asia_Riyadh: "Asia/Riyadh", + Asia_Tehran: "Asia/Tehran", + Asia_Kolkata: "Asia/Kolkata", + Asia_Dhaka: "Asia/Dhaka", + Asia_Bangkok: "Asia/Bangkok", + Asia_Singapore: "Asia/Singapore", + Asia_Hong_Kong: "Asia/Hong_Kong", + Asia_Shanghai: "Asia/Shanghai", + Asia_Tokyo: "Asia/Tokyo", + Asia_Seoul: "Asia/Seoul", + Asia_Taipei: "Asia/Taipei", + Asia_Jakarta: "Asia/Jakarta", + Asia_Manila: "Asia/Manila", + Asia_Karachi: "Asia/Karachi", + Asia_Jerusalem: "Asia/Jerusalem", + Asia_Yerevan: "Asia/Yerevan", + Asia_Tbilisi: "Asia/Tbilisi", + Asia_Baku: "Asia/Baku", + Africa_Cairo: "Africa/Cairo", + Africa_Lagos: "Africa/Lagos", + Africa_Johannesburg: "Africa/Johannesburg", + Africa_Nairobi: "Africa/Nairobi", + Africa_Casablanca: "Africa/Casablanca", + Australia_Sydney: "Australia/Sydney", + Australia_Melbourne: "Australia/Melbourne", + Australia_Perth: "Australia/Perth", + Australia_Brisbane: "Australia/Brisbane", + Pacific_Auckland: "Pacific/Auckland", + Pacific_Honolulu: "Pacific/Honolulu", + UTC: "UTC" +}); + +function __runtimeNow(timezone) { + const ts = __getShiftedTimestamp(timezone); + return new Date(ts); +} + +function __runtimeDayOfWeek(timezone) { + const days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + return days[__runtimeNow(timezone).getDay()]; +} + +function productive(reason, tags) { + return { classification: "productive", classificationReasoning: reason, tags: tags }; +} +function distracting(reason, tags) { + return { classification: "distracting", classificationReasoning: reason, tags: tags }; +} +function neutral(reason, tags) { + return { classification: "neutral", classificationReasoning: reason, tags: tags }; +} +function block(reason) { + return { enforcementAction: "block", enforcementReason: reason }; +} +function allow(reason) { + return { enforcementAction: "allow", enforcementReason: reason }; +} +function pause(reason) { + return { enforcementAction: "paused", enforcementReason: reason }; +} + +var __runtimeModule = { + Classification: Classification, + EnforcementAction: EnforcementAction, + Timezone: Timezone, + Weekday: Weekday, + productive: productive, + distracting: distracting, + neutral: neutral, + block: block, + allow: allow, + pause: pause, + get runtime() { + return globalThis.__focusd_runtime_context || { + today: { focusScore: 0, productiveMinutes: 0, distractingMinutes: 0 }, + hour: { focusScore: 0, productiveMinutes: 0, distractingMinutes: 0 }, + time: { + now: __runtimeNow, + day: __runtimeDayOfWeek + } + }; + } +}; +Object.freeze(__runtimeModule.Classification); +Object.freeze(__runtimeModule.EnforcementAction); +Object.freeze(__runtimeModule.Timezone); +Object.freeze(__runtimeModule.Weekday); + +function require(specifier) { + if (specifier === "@focusd/runtime") { + return __runtimeModule; + } + throw new Error("Unsupported import: " + specifier + ". Only '@focusd/runtime' is available."); +} + +// Wrapper execution functions that unpack the serialized context and assign global state +globalThis.__classify_wrapper = function(ctx) { + __hydrateContext(ctx); + if (typeof globalThis.classify !== 'function' && typeof globalThis.__classify !== 'function') { + return undefined; + } + var fn = globalThis.classify || globalThis.__classify; + return fn(); +} + +globalThis.__enforcement_wrapper = function(ctx) { + __hydrateContext(ctx); + if (typeof globalThis.enforcement !== 'function' && typeof globalThis.__enforcement !== 'function') { + return undefined; + } + var fn = globalThis.enforcement || globalThis.__enforcement; + return fn(); +} + +function __hydrateContext(rawCtx) { + var u = rawCtx.usage || {}; + var meta = u.meta || {}; + var ins = u.insights || {}; + var cur = ins.current || {}; + var dur = cur.duration || {}; + var blk = cur.blocks || {}; + + // Create a unique closure to capture meta values for lastFn so they don't leak across calls + var appName = meta.appName || ""; + var host = meta.host || ""; + var lastFn = (typeof __minutesUsedInPeriod === 'function') + ? function(m) { return __minutesUsedInPeriod(appName, host, m); } + : function() { return 0; }; + + var ctxObj = { + app: meta.appName || "", + title: meta.title || "", + domain: meta.domain || "", + host: meta.host || "", + path: meta.path || "", + url: meta.url || "", + classification: meta.classification || "", + current: { + usedToday: dur.today || 0, + blocks: blk.count || 0, + sinceBlock: dur.sinceLastBlock != null ? dur.sinceLastBlock : null, + usedSinceBlock: dur.usedSinceLastBlock != null ? dur.usedSinceLastBlock : null, + last: lastFn + } + }; + + globalThis.__focusd_runtime_context = { + today: ins.today || { focusScore: 0, productiveMinutes: 0, distractingMinutes: 0 }, + hour: ins.hour || { focusScore: 0, productiveMinutes: 0, distractingMinutes: 0 }, + time: { + now: __runtimeNow, + day: __runtimeDayOfWeek + }, + usage: ctxObj + }; +} +` +} + +func (c *UsageContributor) RegisterGlobals(iso *v8.Isolate, global *v8.ObjectTemplate) error { + // Inject __getShiftedTimestamp function + timeCb := v8.NewFunctionTemplate(iso, func(info *v8.FunctionCallbackInfo) *v8.Value { + args := info.Args() + var loc *time.Location + var err error + + if len(args) > 0 && args[0].IsString() { + loc, err = time.LoadLocation(args[0].String()) + } + + // Default to local if not found or error or not provided + if loc == nil || err != nil { + loc = time.Local + } + + t := time.Now().In(loc) + + // Shift time to appear as Local time but with target wall clock values + year, month, day := t.Date() + hour, min, sec := t.Clock() + nsec := t.Nanosecond() + + shifted := time.Date(year, month, day, hour, min, sec, nsec, time.Local) + ts := shifted.UnixMilli() + + val, _ := v8.NewValue(iso, float64(ts)) + return val + }) + + if err := global.Set("__getShiftedTimestamp", timeCb); err != nil { + return fmt.Errorf("failed to set __getShiftedTimestamp function: %w", err) + } + + return nil +} diff --git a/internal/usage/service.go b/internal/usage/service.go index 87221b5..c0b2586 100644 --- a/internal/usage/service.go +++ b/internal/usage/service.go @@ -1,6 +1,7 @@ package usage import ( + "github.com/focusd-so/focusd/internal/sandbox" "context" "fmt" "log/slog" @@ -29,6 +30,7 @@ type Service struct { } func NewService(ctx context.Context, db *gorm.DB, options ...Option) (*Service, error) { + sandbox.Register(NewUsageContributor()) if err := migrateEnforcementColumns(db); err != nil { return nil, fmt.Errorf("failed to migrate enforcement columns: %w", err) } diff --git a/main.go b/main.go index 706c81e..253a115 100644 --- a/main.go +++ b/main.go @@ -29,6 +29,8 @@ import ( "gorm.io/gorm" "github.com/focusd-so/focusd/internal/api" + "github.com/focusd-so/focusd/internal/fs" + "github.com/focusd-so/focusd/internal/sandbox" "github.com/focusd-so/focusd/internal/extension" "github.com/focusd-so/focusd/internal/identity" "github.com/focusd-so/focusd/internal/native" @@ -125,11 +127,18 @@ func main() { identityService := identity.NewService(apiAuthenticatedClient) + fsService := fs.NewService(configDir) + usageService, err := usage.NewService(ctx, db) if err != nil { log.Fatal("failed to create usage service: %w", err) } + // Generate sandbox types after contributors are registered + if err := sandbox.GenerateTypes(filepath.Join(configDir, "types.d.ts")); err != nil { + slog.Error("failed to generate sandbox types", "error", err) + } + mux, _, err := setUpWebServer(ctx, extensionSessionAPIKey, usageService) if err != nil { log.Fatal("failed to setup web server: %w", err) @@ -189,6 +198,7 @@ func main() { services := []application.Service{ application.NewService(usageService), application.NewService(settingsService), + application.NewService(fsService), application.NewService(identityService), application.NewService(nativeService), } From 625f3148bf4bc2feee9832c8dcca03034dcf81e7 Mon Sep 17 00:00:00 2001 From: Aram Petrosyan Date: Mon, 30 Mar 2026 12:16:35 +0100 Subject: [PATCH 2/7] refactor: extract time and tz polyfills into @focusd/core sandbox module --- internal/sandbox/core_contributor.go | 250 ++++++++++++++++++++++++++ internal/sandbox/registry.go | 6 +- internal/sandbox/sandbox.go | 25 ++- internal/usage/sandbox_contributor.go | 249 +++---------------------- 4 files changed, 295 insertions(+), 235 deletions(-) create mode 100644 internal/sandbox/core_contributor.go diff --git a/internal/sandbox/core_contributor.go b/internal/sandbox/core_contributor.go new file mode 100644 index 0000000..bc102fc --- /dev/null +++ b/internal/sandbox/core_contributor.go @@ -0,0 +1,250 @@ +package sandbox + +import ( + "fmt" + "time" + + v8 "rogchap.com/v8go" +) + +// coreContributor provides fundamental utilities available to all sandboxes +type coreContributor struct{} + +func (c *coreContributor) Name() string { + return "core" +} + +func (c *coreContributor) TypesDefinition() string { + return `declare module "@focusd/core" { + export type WeekdayType = "Sunday" | "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday" | "Saturday"; + + export const Timezone: { + readonly America_New_York: "America/New_York"; + readonly America_Chicago: "America/Chicago"; + readonly America_Denver: "America/Denver"; + readonly America_Los_Angeles: "America/Los_Angeles"; + readonly America_Anchorage: "America/Anchorage"; + readonly America_Toronto: "America/Toronto"; + readonly America_Vancouver: "America/Vancouver"; + readonly America_Mexico_City: "America/Mexico_City"; + readonly America_Sao_Paulo: "America/Sao_Paulo"; + readonly America_Buenos_Aires: "America/Buenos_Aires"; + readonly America_Bogota: "America/Bogota"; + readonly America_Santiago: "America/Santiago"; + readonly Europe_London: "Europe/London"; + readonly Europe_Paris: "Europe/Paris"; + readonly Europe_Berlin: "Europe/Berlin"; + readonly Europe_Madrid: "Europe/Madrid"; + readonly Europe_Rome: "Europe/Rome"; + readonly Europe_Amsterdam: "Europe/Amsterdam"; + readonly Europe_Zurich: "Europe/Zurich"; + readonly Europe_Brussels: "Europe/Brussels"; + readonly Europe_Stockholm: "Europe/Stockholm"; + readonly Europe_Oslo: "Europe/Oslo"; + readonly Europe_Helsinki: "Europe/Helsinki"; + readonly Europe_Warsaw: "Europe/Warsaw"; + readonly Europe_Prague: "Europe/Prague"; + readonly Europe_Vienna: "Europe/Vienna"; + readonly Europe_Athens: "Europe/Athens"; + readonly Europe_Bucharest: "Europe/Bucharest"; + readonly Europe_Istanbul: "Europe/Istanbul"; + readonly Europe_Moscow: "Europe/Moscow"; + readonly Europe_Dublin: "Europe/Dublin"; + readonly Europe_Lisbon: "Europe/Lisbon"; + readonly Asia_Dubai: "Asia/Dubai"; + readonly Asia_Riyadh: "Asia/Riyadh"; + readonly Asia_Tehran: "Asia/Tehran"; + readonly Asia_Kolkata: "Asia/Kolkata"; + readonly Asia_Dhaka: "Asia/Dhaka"; + readonly Asia_Bangkok: "Asia/Bangkok"; + readonly Asia_Singapore: "Asia/Singapore"; + readonly Asia_Hong_Kong: "Asia/Hong_Kong"; + readonly Asia_Shanghai: "Asia/Shanghai"; + readonly Asia_Tokyo: "Asia/Tokyo"; + readonly Asia_Seoul: "Asia/Seoul"; + readonly Asia_Taipei: "Asia/Taipei"; + readonly Asia_Jakarta: "Asia/Jakarta"; + readonly Asia_Manila: "Asia/Manila"; + readonly Asia_Karachi: "Asia/Karachi"; + readonly Asia_Jerusalem: "Asia/Jerusalem"; + readonly Asia_Yerevan: "Asia/Yerevan"; + readonly Asia_Tbilisi: "Asia/Tbilisi"; + readonly Asia_Baku: "Asia/Baku"; + readonly Africa_Cairo: "Africa/Cairo"; + readonly Africa_Lagos: "Africa/Lagos"; + readonly Africa_Johannesburg: "Africa/Johannesburg"; + readonly Africa_Nairobi: "Africa/Nairobi"; + readonly Africa_Casablanca: "Africa/Casablanca"; + readonly Australia_Sydney: "Australia/Sydney"; + readonly Australia_Melbourne: "Australia/Melbourne"; + readonly Australia_Perth: "Australia/Perth"; + readonly Australia_Brisbane: "Australia/Brisbane"; + readonly Pacific_Auckland: "Pacific/Auckland"; + readonly Pacific_Honolulu: "Pacific/Honolulu"; + readonly UTC: "UTC"; + }; + + export const Weekday: { + readonly Sunday: "Sunday"; + readonly Monday: "Monday"; + readonly Tuesday: "Tuesday"; + readonly Wednesday: "Wednesday"; + readonly Thursday: "Thursday"; + readonly Friday: "Friday"; + readonly Saturday: "Saturday"; + }; + + export interface Time { + now(timezone?: string): Date; + day(timezone?: string): WeekdayType; + } + + export const time: Time; +} +` +} + +func (c *coreContributor) PolyfillSource() string { + return ` +var Weekday = Object.freeze({ + Sunday: "Sunday", + Monday: "Monday", + Tuesday: "Tuesday", + Wednesday: "Wednesday", + Thursday: "Thursday", + Friday: "Friday", + Saturday: "Saturday" +}); + +var Timezone = Object.freeze({ + America_New_York: "America/New_York", + America_Chicago: "America/Chicago", + America_Denver: "America/Denver", + America_Los_Angeles: "America/Los_Angeles", + America_Anchorage: "America/Anchorage", + America_Toronto: "America/Toronto", + America_Vancouver: "America/Vancouver", + America_Mexico_City: "America/Mexico_City", + America_Sao_Paulo: "America/Sao_Paulo", + America_Buenos_Aires: "America/Buenos_Aires", + America_Bogota: "America/Bogota", + America_Santiago: "America/Santiago", + Europe_London: "Europe/London", + Europe_Paris: "Europe/Paris", + Europe_Berlin: "Europe/Berlin", + Europe_Madrid: "Europe/Madrid", + Europe_Rome: "Europe/Rome", + Europe_Amsterdam: "Europe/Amsterdam", + Europe_Zurich: "Europe/Zurich", + Europe_Brussels: "Europe/Brussels", + Europe_Stockholm: "Europe/Stockholm", + Europe_Oslo: "Europe/Oslo", + Europe_Helsinki: "Europe/Helsinki", + Europe_Warsaw: "Europe/Warsaw", + Europe_Prague: "Europe/Prague", + Europe_Vienna: "Europe/Vienna", + Europe_Athens: "Europe/Athens", + Europe_Bucharest: "Europe/Bucharest", + Europe_Istanbul: "Europe/Istanbul", + Europe_Moscow: "Europe/Moscow", + Europe_Dublin: "Europe/Dublin", + Europe_Lisbon: "Europe/Lisbon", + Asia_Dubai: "Asia/Dubai", + Asia_Riyadh: "Asia/Riyadh", + Asia_Tehran: "Asia/Tehran", + Asia_Kolkata: "Asia/Kolkata", + Asia_Dhaka: "Asia/Dhaka", + Asia_Bangkok: "Asia/Bangkok", + Asia_Singapore: "Asia/Singapore", + Asia_Hong_Kong: "Asia/Hong_Kong", + Asia_Shanghai: "Asia/Shanghai", + Asia_Tokyo: "Asia/Tokyo", + Asia_Seoul: "Asia/Seoul", + Asia_Taipei: "Asia/Taipei", + Asia_Jakarta: "Asia/Jakarta", + Asia_Manila: "Asia/Manila", + Asia_Karachi: "Asia/Karachi", + Asia_Jerusalem: "Asia/Jerusalem", + Asia_Yerevan: "Asia/Yerevan", + Asia_Tbilisi: "Asia/Tbilisi", + Asia_Baku: "Asia/Baku", + Africa_Cairo: "Africa/Cairo", + Africa_Lagos: "Africa/Lagos", + Africa_Johannesburg: "Africa/Johannesburg", + Africa_Nairobi: "Africa/Nairobi", + Africa_Casablanca: "Africa/Casablanca", + Australia_Sydney: "Australia/Sydney", + Australia_Melbourne: "Australia/Melbourne", + Australia_Perth: "Australia/Perth", + Australia_Brisbane: "Australia/Brisbane", + Pacific_Auckland: "Pacific/Auckland", + Pacific_Honolulu: "Pacific/Honolulu", + UTC: "UTC" +}); + +function __runtimeNow(timezone) { + const ts = __getShiftedTimestamp(timezone); + return new Date(ts); +} + +function __runtimeDayOfWeek(timezone) { + const days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + return days[__runtimeNow(timezone).getDay()]; +} + +// Make core modules available for importing if a module system exists +if (typeof __modules === 'undefined') { + globalThis.__modules = {}; +} + +__modules["@focusd/core"] = { + Timezone: Timezone, + Weekday: Weekday, + time: { + now: __runtimeNow, + day: __runtimeDayOfWeek + } +}; + +// Also inject into global scope for convenience in basic scripts +globalThis.Timezone = Timezone; +globalThis.Weekday = Weekday; +` +} + +func (c *coreContributor) RegisterGlobals(iso *v8.Isolate, global *v8.ObjectTemplate) error { + // Inject __getShiftedTimestamp function + timeCb := v8.NewFunctionTemplate(iso, func(info *v8.FunctionCallbackInfo) *v8.Value { + args := info.Args() + var loc *time.Location + var err error + + if len(args) > 0 && args[0].IsString() { + loc, err = time.LoadLocation(args[0].String()) + } + + // Default to local if not found or error or not provided + if loc == nil || err != nil { + loc = time.Local + } + + t := time.Now().In(loc) + + // Shift time to appear as Local time but with target wall clock values + year, month, day := t.Date() + hour, min, sec := t.Clock() + nsec := t.Nanosecond() + + shifted := time.Date(year, month, day, hour, min, sec, nsec, time.Local) + ts := shifted.UnixMilli() + + val, _ := v8.NewValue(iso, float64(ts)) + return val + }) + + if err := global.Set("__getShiftedTimestamp", timeCb); err != nil { + return fmt.Errorf("failed to set __getShiftedTimestamp function: %w", err) + } + + return nil +} diff --git a/internal/sandbox/registry.go b/internal/sandbox/registry.go index 9c72c3c..9977003 100644 --- a/internal/sandbox/registry.go +++ b/internal/sandbox/registry.go @@ -4,8 +4,6 @@ import ( "fmt" "os" "strings" - - v8 "rogchap.com/v8go" ) // Contributor defines a package that extends the JS sandbox environment @@ -13,10 +11,10 @@ type Contributor interface { Name() string PolyfillSource() string // JS code prepended to every execution TypesDefinition() string // TS types combined into types.d.ts - RegisterGlobals(iso *v8.Isolate, global *v8.ObjectTemplate) error } -var contributors []Contributor +// Global registry, automatically includes core contributor +var contributors = []Contributor{&coreContributor{}} // Register adds a new sandbox contributor func Register(c Contributor) { diff --git a/internal/sandbox/sandbox.go b/internal/sandbox/sandbox.go index 6e9173a..25e5ed8 100644 --- a/internal/sandbox/sandbox.go +++ b/internal/sandbox/sandbox.go @@ -49,12 +49,11 @@ func New() (*Sandbox, error) { return nil, fmt.Errorf("failed to set __console_log function: %w", err) } - // Register globals from static contributors - for _, c := range contributors { - if err := c.RegisterGlobals(isolate, global); err != nil { - s.Close() - return nil, fmt.Errorf("failed to register globals for contributor %s: %w", c.Name(), err) - } + // Internal core contributor registration + core := &coreContributor{} + if err := core.RegisterGlobals(isolate, global); err != nil { + s.Close() + return nil, fmt.Errorf("failed to register core globals: %w", err) } return s, nil @@ -100,6 +99,7 @@ func (s *Sandbox) Execute(code string, fnName string, args ...any) (*Result, err sb.WriteString(` var exports = {}; var module = { exports: exports }; +globalThis.__modules = globalThis.__modules || {}; `) // Inject all contributor polyfills BEFORE the user code @@ -108,6 +108,18 @@ var module = { exports: exports }; sb.WriteString(c.PolyfillSource()) } + // Simple polyfill for require() if not already defined by a contributor + sb.WriteString(` +if (typeof globalThis.require === 'undefined') { + globalThis.require = function(specifier) { + if (globalThis.__modules && globalThis.__modules[specifier]) { + return globalThis.__modules[specifier]; + } + throw new Error("Unsupported import: " + specifier + ". Only mapped modules are available."); + }; +} +`) + sb.WriteString("\n// --- User Code ---\n") sb.WriteString(transpiledCode) @@ -203,4 +215,3 @@ var module = { exports: exports }; Logs: s.logs, }, nil } - diff --git a/internal/usage/sandbox_contributor.go b/internal/usage/sandbox_contributor.go index e6b974f..0229bfb 100644 --- a/internal/usage/sandbox_contributor.go +++ b/internal/usage/sandbox_contributor.go @@ -1,12 +1,5 @@ package usage -import ( - "fmt" - "time" - - v8 "rogchap.com/v8go" -) - type UsageContributor struct{} func NewUsageContributor() *UsageContributor { @@ -19,9 +12,10 @@ func (c *UsageContributor) Name() string { func (c *UsageContributor) TypesDefinition() string { return `declare module "@focusd/runtime" { + import { WeekdayType, Timezone } from "@focusd/core"; + export type ClassificationType = "unknown" | "productive" | "distracting" | "neutral" | "system"; export type EnforcementActionType = "none" | "block" | "paused" | "allow"; - export type WeekdayType = "Sunday" | "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday" | "Saturday"; export type Minutes = number; export const Classification: { @@ -39,82 +33,6 @@ func (c *UsageContributor) TypesDefinition() string { readonly Allow: "allow"; }; - export const Timezone: { - readonly America_New_York: "America/New_York"; - readonly America_Chicago: "America/Chicago"; - readonly America_Denver: "America/Denver"; - readonly America_Los_Angeles: "America/Los_Angeles"; - readonly America_Anchorage: "America/Anchorage"; - readonly America_Toronto: "America/Toronto"; - readonly America_Vancouver: "America/Vancouver"; - readonly America_Mexico_City: "America/Mexico_City"; - readonly America_Sao_Paulo: "America/Sao_Paulo"; - readonly America_Buenos_Aires: "America/Buenos_Aires"; - readonly America_Bogota: "America/Bogota"; - readonly America_Santiago: "America/Santiago"; - readonly Europe_London: "Europe/London"; - readonly Europe_Paris: "Europe/Paris"; - readonly Europe_Berlin: "Europe/Berlin"; - readonly Europe_Madrid: "Europe/Madrid"; - readonly Europe_Rome: "Europe/Rome"; - readonly Europe_Amsterdam: "Europe/Amsterdam"; - readonly Europe_Zurich: "Europe/Zurich"; - readonly Europe_Brussels: "Europe/Brussels"; - readonly Europe_Stockholm: "Europe/Stockholm"; - readonly Europe_Oslo: "Europe/Oslo"; - readonly Europe_Helsinki: "Europe/Helsinki"; - readonly Europe_Warsaw: "Europe/Warsaw"; - readonly Europe_Prague: "Europe/Prague"; - readonly Europe_Vienna: "Europe/Vienna"; - readonly Europe_Athens: "Europe/Athens"; - readonly Europe_Bucharest: "Europe/Bucharest"; - readonly Europe_Istanbul: "Europe/Istanbul"; - readonly Europe_Moscow: "Europe/Moscow"; - readonly Europe_Dublin: "Europe/Dublin"; - readonly Europe_Lisbon: "Europe/Lisbon"; - readonly Asia_Dubai: "Asia/Dubai"; - readonly Asia_Riyadh: "Asia/Riyadh"; - readonly Asia_Tehran: "Asia/Tehran"; - readonly Asia_Kolkata: "Asia/Kolkata"; - readonly Asia_Dhaka: "Asia/Dhaka"; - readonly Asia_Bangkok: "Asia/Bangkok"; - readonly Asia_Singapore: "Asia/Singapore"; - readonly Asia_Hong_Kong: "Asia/Hong_Kong"; - readonly Asia_Shanghai: "Asia/Shanghai"; - readonly Asia_Tokyo: "Asia/Tokyo"; - readonly Asia_Seoul: "Asia/Seoul"; - readonly Asia_Taipei: "Asia/Taipei"; - readonly Asia_Jakarta: "Asia/Jakarta"; - readonly Asia_Manila: "Asia/Manila"; - readonly Asia_Karachi: "Asia/Karachi"; - readonly Asia_Jerusalem: "Asia/Jerusalem"; - readonly Asia_Yerevan: "Asia/Yerevan"; - readonly Asia_Tbilisi: "Asia/Tbilisi"; - readonly Asia_Baku: "Asia/Baku"; - readonly Africa_Cairo: "Africa/Cairo"; - readonly Africa_Lagos: "Africa/Lagos"; - readonly Africa_Johannesburg: "Africa/Johannesburg"; - readonly Africa_Nairobi: "Africa/Nairobi"; - readonly Africa_Casablanca: "Africa/Casablanca"; - readonly Australia_Sydney: "Australia/Sydney"; - readonly Australia_Melbourne: "Australia/Melbourne"; - readonly Australia_Perth: "Australia/Perth"; - readonly Australia_Brisbane: "Australia/Brisbane"; - readonly Pacific_Auckland: "Pacific/Auckland"; - readonly Pacific_Honolulu: "Pacific/Honolulu"; - readonly UTC: "UTC"; - }; - - export const Weekday: { - readonly Sunday: "Sunday"; - readonly Monday: "Monday"; - readonly Tuesday: "Tuesday"; - readonly Wednesday: "Wednesday"; - readonly Thursday: "Thursday"; - readonly Friday: "Friday"; - readonly Saturday: "Saturday"; - }; - export interface Classify { classification: ClassificationType; classificationReasoning: string; @@ -189,92 +107,6 @@ var EnforcementAction = Object.freeze({ Allow: "allow" }); -var Weekday = Object.freeze({ - Sunday: "Sunday", - Monday: "Monday", - Tuesday: "Tuesday", - Wednesday: "Wednesday", - Thursday: "Thursday", - Friday: "Friday", - Saturday: "Saturday" -}); - -var Timezone = Object.freeze({ - America_New_York: "America/New_York", - America_Chicago: "America/Chicago", - America_Denver: "America/Denver", - America_Los_Angeles: "America/Los_Angeles", - America_Anchorage: "America/Anchorage", - America_Toronto: "America/Toronto", - America_Vancouver: "America/Vancouver", - America_Mexico_City: "America/Mexico_City", - America_Sao_Paulo: "America/Sao_Paulo", - America_Buenos_Aires: "America/Buenos_Aires", - America_Bogota: "America/Bogota", - America_Santiago: "America/Santiago", - Europe_London: "Europe/London", - Europe_Paris: "Europe/Paris", - Europe_Berlin: "Europe/Berlin", - Europe_Madrid: "Europe/Madrid", - Europe_Rome: "Europe/Rome", - Europe_Amsterdam: "Europe/Amsterdam", - Europe_Zurich: "Europe/Zurich", - Europe_Brussels: "Europe/Brussels", - Europe_Stockholm: "Europe/Stockholm", - Europe_Oslo: "Europe/Oslo", - Europe_Helsinki: "Europe/Helsinki", - Europe_Warsaw: "Europe/Warsaw", - Europe_Prague: "Europe/Prague", - Europe_Vienna: "Europe/Vienna", - Europe_Athens: "Europe/Athens", - Europe_Bucharest: "Europe/Bucharest", - Europe_Istanbul: "Europe/Istanbul", - Europe_Moscow: "Europe/Moscow", - Europe_Dublin: "Europe/Dublin", - Europe_Lisbon: "Europe/Lisbon", - Asia_Dubai: "Asia/Dubai", - Asia_Riyadh: "Asia/Riyadh", - Asia_Tehran: "Asia/Tehran", - Asia_Kolkata: "Asia/Kolkata", - Asia_Dhaka: "Asia/Dhaka", - Asia_Bangkok: "Asia/Bangkok", - Asia_Singapore: "Asia/Singapore", - Asia_Hong_Kong: "Asia/Hong_Kong", - Asia_Shanghai: "Asia/Shanghai", - Asia_Tokyo: "Asia/Tokyo", - Asia_Seoul: "Asia/Seoul", - Asia_Taipei: "Asia/Taipei", - Asia_Jakarta: "Asia/Jakarta", - Asia_Manila: "Asia/Manila", - Asia_Karachi: "Asia/Karachi", - Asia_Jerusalem: "Asia/Jerusalem", - Asia_Yerevan: "Asia/Yerevan", - Asia_Tbilisi: "Asia/Tbilisi", - Asia_Baku: "Asia/Baku", - Africa_Cairo: "Africa/Cairo", - Africa_Lagos: "Africa/Lagos", - Africa_Johannesburg: "Africa/Johannesburg", - Africa_Nairobi: "Africa/Nairobi", - Africa_Casablanca: "Africa/Casablanca", - Australia_Sydney: "Australia/Sydney", - Australia_Melbourne: "Australia/Melbourne", - Australia_Perth: "Australia/Perth", - Australia_Brisbane: "Australia/Brisbane", - Pacific_Auckland: "Pacific/Auckland", - Pacific_Honolulu: "Pacific/Honolulu", - UTC: "UTC" -}); - -function __runtimeNow(timezone) { - const ts = __getShiftedTimestamp(timezone); - return new Date(ts); -} - -function __runtimeDayOfWeek(timezone) { - const days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; - return days[__runtimeNow(timezone).getDay()]; -} - function productive(reason, tags) { return { classification: "productive", classificationReasoning: reason, tags: tags }; } @@ -294,38 +126,44 @@ function pause(reason) { return { enforcementAction: "paused", enforcementReason: reason }; } -var __runtimeModule = { +if (typeof globalThis.__modules === 'undefined') { + globalThis.__modules = {}; +} + +globalThis.__modules["@focusd/runtime"] = { Classification: Classification, EnforcementAction: EnforcementAction, - Timezone: Timezone, - Weekday: Weekday, productive: productive, distracting: distracting, neutral: neutral, block: block, allow: allow, pause: pause, + // Add pass-throughs from core for backwards compatibility + Timezone: globalThis.Timezone, + Weekday: globalThis.Weekday, get runtime() { return globalThis.__focusd_runtime_context || { today: { focusScore: 0, productiveMinutes: 0, distractingMinutes: 0 }, hour: { focusScore: 0, productiveMinutes: 0, distractingMinutes: 0 }, time: { - now: __runtimeNow, - day: __runtimeDayOfWeek + now: function(tz) { return globalThis.__modules["@focusd/core"].time.now(tz); }, + day: function(tz) { return globalThis.__modules["@focusd/core"].time.day(tz); } } }; } }; -Object.freeze(__runtimeModule.Classification); -Object.freeze(__runtimeModule.EnforcementAction); -Object.freeze(__runtimeModule.Timezone); -Object.freeze(__runtimeModule.Weekday); - -function require(specifier) { - if (specifier === "@focusd/runtime") { - return __runtimeModule; - } - throw new Error("Unsupported import: " + specifier + ". Only '@focusd/runtime' is available."); +Object.freeze(globalThis.__modules["@focusd/runtime"].Classification); +Object.freeze(globalThis.__modules["@focusd/runtime"].EnforcementAction); + +// Setup the single custom require polyfill handler if it hasn't been done yet +if (typeof globalThis.require === 'undefined') { + globalThis.require = function(specifier) { + if (globalThis.__modules && globalThis.__modules[specifier]) { + return globalThis.__modules[specifier]; + } + throw new Error("Unsupported import: " + specifier + ". Only mapped modules are available."); + }; } // Wrapper execution functions that unpack the serialized context and assign global state @@ -383,48 +221,11 @@ function __hydrateContext(rawCtx) { today: ins.today || { focusScore: 0, productiveMinutes: 0, distractingMinutes: 0 }, hour: ins.hour || { focusScore: 0, productiveMinutes: 0, distractingMinutes: 0 }, time: { - now: __runtimeNow, - day: __runtimeDayOfWeek + now: function(tz) { return globalThis.__modules["@focusd/core"].time.now(tz); }, + day: function(tz) { return globalThis.__modules["@focusd/core"].time.day(tz); } }, usage: ctxObj }; } ` } - -func (c *UsageContributor) RegisterGlobals(iso *v8.Isolate, global *v8.ObjectTemplate) error { - // Inject __getShiftedTimestamp function - timeCb := v8.NewFunctionTemplate(iso, func(info *v8.FunctionCallbackInfo) *v8.Value { - args := info.Args() - var loc *time.Location - var err error - - if len(args) > 0 && args[0].IsString() { - loc, err = time.LoadLocation(args[0].String()) - } - - // Default to local if not found or error or not provided - if loc == nil || err != nil { - loc = time.Local - } - - t := time.Now().In(loc) - - // Shift time to appear as Local time but with target wall clock values - year, month, day := t.Date() - hour, min, sec := t.Clock() - nsec := t.Nanosecond() - - shifted := time.Date(year, month, day, hour, min, sec, nsec, time.Local) - ts := shifted.UnixMilli() - - val, _ := v8.NewValue(iso, float64(ts)) - return val - }) - - if err := global.Set("__getShiftedTimestamp", timeCb); err != nil { - return fmt.Errorf("failed to set __getShiftedTimestamp function: %w", err) - } - - return nil -} From 279f1924559871d2c289c06fe6f96b49379f1566 Mon Sep 17 00:00:00 2001 From: Aram Petrosyan Date: Mon, 30 Mar 2026 12:26:49 +0100 Subject: [PATCH 3/7] refactor: simplify UsageContributor by removing dependency injection --- internal/usage/classifier_custom_rules.go | 28 ------------- internal/usage/protection.go | 29 -------------- internal/usage/sandbox_context.go | 9 +---- internal/usage/sandbox_context_enrich.go | 4 -- internal/usage/sandbox_contributor.go | 49 +++++++++++++++++++---- internal/usage/service.go | 3 +- main.go | 2 + 7 files changed, 46 insertions(+), 78 deletions(-) diff --git a/internal/usage/classifier_custom_rules.go b/internal/usage/classifier_custom_rules.go index 41c5671..08b5a2c 100644 --- a/internal/usage/classifier_custom_rules.go +++ b/internal/usage/classifier_custom_rules.go @@ -10,7 +10,6 @@ import ( "github.com/focusd-so/focusd/internal/sandbox" "github.com/focusd-so/focusd/internal/settings" - v8 "rogchap.com/v8go" ) // classificationResult is returned from the classify function. @@ -110,33 +109,6 @@ func (s *Service) classifySandbox(ctx context.Context, sandboxCtx sandboxContext } defer sb.Close() - if sandboxCtx.MinutesUsedInPeriod != nil { - err = sb.RegisterGlobal("__minutesUsedInPeriod", func(info *v8.FunctionCallbackInfo) *v8.Value { - args := info.Args() - if len(args) < 3 { - val, _ := v8.NewValue(info.Context().Isolate(), int32(0)) - return val - } - - appName := args[0].String() - hostname := args[1].String() - minutes := int64(args[2].Integer()) - - result, err := sandboxCtx.MinutesUsedInPeriod(appName, hostname, minutes) - if err != nil { - slog.Debug("failed to query minutes used", "error", err) - val, _ := v8.NewValue(info.Context().Isolate(), int32(0)) - return val - } - - val, _ := v8.NewValue(info.Context().Isolate(), int32(result)) - return val - }) - if err != nil { - return nil, nil, fmt.Errorf("failed to register minutes query func: %w", err) - } - } - result, err := sb.Execute(customRules, "__classify_wrapper", sandboxCtx) if err != nil { return nil, result.Logs, err diff --git a/internal/usage/protection.go b/internal/usage/protection.go index ede2b68..86a966c 100644 --- a/internal/usage/protection.go +++ b/internal/usage/protection.go @@ -5,7 +5,6 @@ import ( "context" "encoding/json" "fmt" - "log/slog" "strings" "time" @@ -14,7 +13,6 @@ import ( "github.com/focusd-so/focusd/internal/identity" "github.com/focusd-so/focusd/internal/sandbox" "github.com/focusd-so/focusd/internal/settings" - v8 "rogchap.com/v8go" ) // enforcement is returned from the enforcement function. @@ -405,33 +403,6 @@ func (s *Service) calculateEnforcementDecisionWithCustomRules(_ context.Context, } defer sb.Close() - if sandboxCtx.MinutesUsedInPeriod != nil { - err = sb.RegisterGlobal("__minutesUsedInPeriod", func(info *v8.FunctionCallbackInfo) *v8.Value { - args := info.Args() - if len(args) < 3 { - val, _ := v8.NewValue(info.Context().Isolate(), int32(0)) - return val - } - - appName := args[0].String() - hostname := args[1].String() - minutes := int64(args[2].Integer()) - - result, err := sandboxCtx.MinutesUsedInPeriod(appName, hostname, minutes) - if err != nil { - slog.Debug("failed to query minutes used", "error", err) - val, _ := v8.NewValue(info.Context().Isolate(), int32(0)) - return val - } - - val, _ := v8.NewValue(info.Context().Isolate(), int32(result)) - return val - }) - if err != nil { - return EnforcementDecision{}, fmt.Errorf("failed to register minutes query func: %w", err) - } - } - execResult, err := sb.Execute(customRules, "__enforcement_wrapper", sandboxCtx) if err != nil { if logErr := finalizeExecutionLog(nil, execResult.Logs, err); logErr != nil { diff --git a/internal/usage/sandbox_context.go b/internal/usage/sandbox_context.go index b49b747..852c677 100644 --- a/internal/usage/sandbox_context.go +++ b/internal/usage/sandbox_context.go @@ -57,8 +57,7 @@ type sandboxContext struct { Usage sandboxUsageContext `json:"usage"` // Helper functions - Now func(loc *time.Location) time.Time `json:"-"` - MinutesUsedInPeriod func(appName, hostname string, durationMinutes int64) (int64, error) `json:"-"` + Now func(loc *time.Location) time.Time `json:"-"` } type sandboxContextOption func(*sandboxContext) @@ -96,12 +95,6 @@ func WithNowContext(now time.Time) sandboxContextOption { } } -func WithMinutesUsedInPeriodContext(minutesUsedInPeriod func(appName, hostname string, durationMinutes int64) (int64, error)) sandboxContextOption { - return func(ctx *sandboxContext) { - ctx.MinutesUsedInPeriod = minutesUsedInPeriod - } -} - func WithMinutesSinceLastBlockContext(minutesSinceLastBlock int) sandboxContextOption { return func(ctx *sandboxContext) { ctx.Usage.Insights.Current.Duration.SinceLastBlock = &minutesSinceLastBlock diff --git a/internal/usage/sandbox_context_enrich.go b/internal/usage/sandbox_context_enrich.go index bd6b638..559def4 100644 --- a/internal/usage/sandbox_context_enrich.go +++ b/internal/usage/sandbox_context_enrich.go @@ -15,10 +15,6 @@ func (s *Service) enrichSandboxContext(ctx *sandboxContext) { } } - if ctx.MinutesUsedInPeriod == nil { - ctx.MinutesUsedInPeriod = s.minutesUsedInPeriod - } - if err := s.populateInsightsContext(ctx); err != nil { slog.Debug("failed to populate sandbox insights context", "error", err) } diff --git a/internal/usage/sandbox_contributor.go b/internal/usage/sandbox_contributor.go index 0229bfb..e93621a 100644 --- a/internal/usage/sandbox_contributor.go +++ b/internal/usage/sandbox_contributor.go @@ -1,16 +1,19 @@ package usage -type UsageContributor struct{} +import ( + "fmt" + "log/slog" -func NewUsageContributor() *UsageContributor { - return &UsageContributor{} -} + v8 "rogchap.com/v8go" +) -func (c *UsageContributor) Name() string { +// Name implements sandbox.Contributor +func (s *Service) Name() string { return "usage" } -func (c *UsageContributor) TypesDefinition() string { +// TypesDefinition implements sandbox.Contributor +func (s *Service) TypesDefinition() string { return `declare module "@focusd/runtime" { import { WeekdayType, Timezone } from "@focusd/core"; @@ -90,7 +93,8 @@ func (c *UsageContributor) TypesDefinition() string { }` } -func (c *UsageContributor) PolyfillSource() string { +// PolyfillSource implements sandbox.Contributor +func (s *Service) PolyfillSource() string { return ` var Classification = Object.freeze({ Unknown: "unknown", @@ -229,3 +233,34 @@ function __hydrateContext(rawCtx) { } ` } + +// RegisterGlobals implements sandbox.Contributor and statically provides Usage DB methods +func (s *Service) RegisterGlobals(iso *v8.Isolate, global *v8.ObjectTemplate) error { + usageCb := v8.NewFunctionTemplate(iso, func(info *v8.FunctionCallbackInfo) *v8.Value { + args := info.Args() + if len(args) < 3 { + val, _ := v8.NewValue(iso, int32(0)) + return val + } + + appName := args[0].String() + hostname := args[1].String() + minutes := int64(args[2].Integer()) + + result, err := s.minutesUsedInPeriod(appName, hostname, minutes) + if err != nil { + slog.Debug("failed to query minutes used", "error", err) + val, _ := v8.NewValue(iso, int32(0)) + return val + } + + val, _ := v8.NewValue(iso, int32(result)) + return val + }) + + if err := global.Set("__minutesUsedInPeriod", usageCb); err != nil { + return fmt.Errorf("failed to set __minutesUsedInPeriod function: %w", err) + } + + return nil +} diff --git a/internal/usage/service.go b/internal/usage/service.go index c0b2586..37606f9 100644 --- a/internal/usage/service.go +++ b/internal/usage/service.go @@ -1,7 +1,6 @@ package usage import ( - "github.com/focusd-so/focusd/internal/sandbox" "context" "fmt" "log/slog" @@ -30,7 +29,7 @@ type Service struct { } func NewService(ctx context.Context, db *gorm.DB, options ...Option) (*Service, error) { - sandbox.Register(NewUsageContributor()) + if err := migrateEnforcementColumns(db); err != nil { return nil, fmt.Errorf("failed to migrate enforcement columns: %w", err) } diff --git a/main.go b/main.go index 253a115..910a84d 100644 --- a/main.go +++ b/main.go @@ -134,6 +134,8 @@ func main() { log.Fatal("failed to create usage service: %w", err) } + sandbox.Register(usageService) + // Generate sandbox types after contributors are registered if err := sandbox.GenerateTypes(filepath.Join(configDir, "types.d.ts")); err != nil { slog.Error("failed to generate sandbox types", "error", err) From ef1f37555951835c2f34b3c735615ed4685ed50e Mon Sep 17 00:00:00 2001 From: Aram Petrosyan Date: Mon, 30 Mar 2026 12:52:15 +0100 Subject: [PATCH 4/7] fix(sandbox): correctly export core types in runtime module declaration --- Taskfile.yml | 2 - frontend/bindings/rogchap.com/v8go/index.js | 8 +++ frontend/bindings/rogchap.com/v8go/models.js | 58 ++++++++++++++++++++ internal/usage/sandbox_contributor.go | 2 +- test_ts.ts | 10 ++++ 5 files changed, 77 insertions(+), 3 deletions(-) create mode 100644 frontend/bindings/rogchap.com/v8go/index.js create mode 100644 frontend/bindings/rogchap.com/v8go/models.js create mode 100644 test_ts.ts diff --git a/Taskfile.yml b/Taskfile.yml index 9813b24..313ede7 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -5,8 +5,6 @@ includes: windows: ./build/windows/Taskfile.yml darwin: ./build/darwin/Taskfile.yml linux: ./build/linux/Taskfile.yml - ios: ./build/ios/Taskfile.yml - android: ./build/android/Taskfile.yml vars: APP_NAME: "Focusd" diff --git a/frontend/bindings/rogchap.com/v8go/index.js b/frontend/bindings/rogchap.com/v8go/index.js new file mode 100644 index 0000000..935416f --- /dev/null +++ b/frontend/bindings/rogchap.com/v8go/index.js @@ -0,0 +1,8 @@ +// @ts-check +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +export { + Isolate, + ObjectTemplate +} from "./models.js"; diff --git a/frontend/bindings/rogchap.com/v8go/models.js b/frontend/bindings/rogchap.com/v8go/models.js new file mode 100644 index 0000000..f166ecf --- /dev/null +++ b/frontend/bindings/rogchap.com/v8go/models.js @@ -0,0 +1,58 @@ +// @ts-check +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import { Create as $Create } from "@wailsio/runtime"; + +/** + * Isolate is a JavaScript VM instance with its own heap and + * garbage collector. Most applications will create one isolate + * with many V8 contexts for execution. + */ +export class Isolate { + /** + * Creates a new Isolate instance. + * @param {Partial} [$$source = {}] - The source object to create the Isolate. + */ + constructor($$source = {}) { + + Object.assign(this, $$source); + } + + /** + * Creates a new Isolate instance from a string or object. + * @param {any} [$$source = {}] + * @returns {Isolate} + */ + static createFrom($$source = {}) { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new Isolate(/** @type {Partial} */($$parsedSource)); + } +} + +/** + * ObjectTemplate is used to create objects at runtime. + * Properties added to an ObjectTemplate are added to each object created from the ObjectTemplate. + */ +export class ObjectTemplate { + /** + * Creates a new ObjectTemplate instance. + * @param {Partial} [$$source = {}] - The source object to create the ObjectTemplate. + */ + constructor($$source = {}) { + + Object.assign(this, $$source); + } + + /** + * Creates a new ObjectTemplate instance from a string or object. + * @param {any} [$$source = {}] + * @returns {ObjectTemplate} + */ + static createFrom($$source = {}) { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new ObjectTemplate(/** @type {Partial} */($$parsedSource)); + } +} diff --git a/internal/usage/sandbox_contributor.go b/internal/usage/sandbox_contributor.go index e93621a..14a1ff6 100644 --- a/internal/usage/sandbox_contributor.go +++ b/internal/usage/sandbox_contributor.go @@ -15,7 +15,7 @@ func (s *Service) Name() string { // TypesDefinition implements sandbox.Contributor func (s *Service) TypesDefinition() string { return `declare module "@focusd/runtime" { - import { WeekdayType, Timezone } from "@focusd/core"; + export { WeekdayType, Timezone, Weekday } from "@focusd/core"; export type ClassificationType = "unknown" | "productive" | "distracting" | "neutral" | "system"; export type EnforcementActionType = "none" | "block" | "paused" | "allow"; diff --git a/test_ts.ts b/test_ts.ts new file mode 100644 index 0000000..a7579c1 --- /dev/null +++ b/test_ts.ts @@ -0,0 +1,10 @@ +declare module "@focusd/core" { + export type WeekdayType = "Sunday"; + export const Timezone: { UTC: "UTC" }; + export const Weekday: { Sunday: "Sunday" }; +} + +declare module "@focusd/runtime" { + export { WeekdayType, Timezone, Weekday } from "@focusd/core"; + export const foo = 1; +} From 31afa0e70f139e94f50eff60fee9e25e5c3f9455 Mon Sep 17 00:00:00 2001 From: Aram Petrosyan Date: Mon, 30 Mar 2026 12:53:32 +0100 Subject: [PATCH 5/7] fix(sandbox): hide usage contributor methods from wails IPC bindings --- frontend/bindings/rogchap.com/v8go/index.js | 8 --- frontend/bindings/rogchap.com/v8go/models.js | 58 -------------------- internal/usage/sandbox_contributor.go | 20 +++++-- main.go | 2 +- 4 files changed, 16 insertions(+), 72 deletions(-) delete mode 100644 frontend/bindings/rogchap.com/v8go/index.js delete mode 100644 frontend/bindings/rogchap.com/v8go/models.js diff --git a/frontend/bindings/rogchap.com/v8go/index.js b/frontend/bindings/rogchap.com/v8go/index.js deleted file mode 100644 index 935416f..0000000 --- a/frontend/bindings/rogchap.com/v8go/index.js +++ /dev/null @@ -1,8 +0,0 @@ -// @ts-check -// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL -// This file is automatically generated. DO NOT EDIT - -export { - Isolate, - ObjectTemplate -} from "./models.js"; diff --git a/frontend/bindings/rogchap.com/v8go/models.js b/frontend/bindings/rogchap.com/v8go/models.js deleted file mode 100644 index f166ecf..0000000 --- a/frontend/bindings/rogchap.com/v8go/models.js +++ /dev/null @@ -1,58 +0,0 @@ -// @ts-check -// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL -// This file is automatically generated. DO NOT EDIT - -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore: Unused imports -import { Create as $Create } from "@wailsio/runtime"; - -/** - * Isolate is a JavaScript VM instance with its own heap and - * garbage collector. Most applications will create one isolate - * with many V8 contexts for execution. - */ -export class Isolate { - /** - * Creates a new Isolate instance. - * @param {Partial} [$$source = {}] - The source object to create the Isolate. - */ - constructor($$source = {}) { - - Object.assign(this, $$source); - } - - /** - * Creates a new Isolate instance from a string or object. - * @param {any} [$$source = {}] - * @returns {Isolate} - */ - static createFrom($$source = {}) { - let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; - return new Isolate(/** @type {Partial} */($$parsedSource)); - } -} - -/** - * ObjectTemplate is used to create objects at runtime. - * Properties added to an ObjectTemplate are added to each object created from the ObjectTemplate. - */ -export class ObjectTemplate { - /** - * Creates a new ObjectTemplate instance. - * @param {Partial} [$$source = {}] - The source object to create the ObjectTemplate. - */ - constructor($$source = {}) { - - Object.assign(this, $$source); - } - - /** - * Creates a new ObjectTemplate instance from a string or object. - * @param {any} [$$source = {}] - * @returns {ObjectTemplate} - */ - static createFrom($$source = {}) { - let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; - return new ObjectTemplate(/** @type {Partial} */($$parsedSource)); - } -} diff --git a/internal/usage/sandbox_contributor.go b/internal/usage/sandbox_contributor.go index 14a1ff6..363a95d 100644 --- a/internal/usage/sandbox_contributor.go +++ b/internal/usage/sandbox_contributor.go @@ -5,15 +5,25 @@ import ( "log/slog" v8 "rogchap.com/v8go" + "github.com/focusd-so/focusd/internal/sandbox" ) +type usageContributor struct { + svc *Service +} + +// NewUsageContributor wraps the usage service into a sandbox contributor +func NewUsageContributor(svc *Service) sandbox.Contributor { + return &usageContributor{svc: svc} +} + // Name implements sandbox.Contributor -func (s *Service) Name() string { +func (c *usageContributor) Name() string { return "usage" } // TypesDefinition implements sandbox.Contributor -func (s *Service) TypesDefinition() string { +func (c *usageContributor) TypesDefinition() string { return `declare module "@focusd/runtime" { export { WeekdayType, Timezone, Weekday } from "@focusd/core"; @@ -94,7 +104,7 @@ func (s *Service) TypesDefinition() string { } // PolyfillSource implements sandbox.Contributor -func (s *Service) PolyfillSource() string { +func (c *usageContributor) PolyfillSource() string { return ` var Classification = Object.freeze({ Unknown: "unknown", @@ -235,7 +245,7 @@ function __hydrateContext(rawCtx) { } // RegisterGlobals implements sandbox.Contributor and statically provides Usage DB methods -func (s *Service) RegisterGlobals(iso *v8.Isolate, global *v8.ObjectTemplate) error { +func (c *usageContributor) RegisterGlobals(iso *v8.Isolate, global *v8.ObjectTemplate) error { usageCb := v8.NewFunctionTemplate(iso, func(info *v8.FunctionCallbackInfo) *v8.Value { args := info.Args() if len(args) < 3 { @@ -247,7 +257,7 @@ func (s *Service) RegisterGlobals(iso *v8.Isolate, global *v8.ObjectTemplate) er hostname := args[1].String() minutes := int64(args[2].Integer()) - result, err := s.minutesUsedInPeriod(appName, hostname, minutes) + result, err := c.svc.minutesUsedInPeriod(appName, hostname, minutes) if err != nil { slog.Debug("failed to query minutes used", "error", err) val, _ := v8.NewValue(iso, int32(0)) diff --git a/main.go b/main.go index 910a84d..ceaf4bc 100644 --- a/main.go +++ b/main.go @@ -134,7 +134,7 @@ func main() { log.Fatal("failed to create usage service: %w", err) } - sandbox.Register(usageService) + sandbox.Register(usage.NewUsageContributor(usageService)) // Generate sandbox types after contributors are registered if err := sandbox.GenerateTypes(filepath.Join(configDir, "types.d.ts")); err != nil { From ca471edfcfe5196c2a7bbec601f2ddc53bbefd78 Mon Sep 17 00:00:00 2001 From: Aram Petrosyan Date: Mon, 30 Mar 2026 13:40:29 +0100 Subject: [PATCH 6/7] fix(sandbox): re-export core modules explicitly in runtime definition --- internal/usage/sandbox_contributor.go | 5 +++-- test_ts.ts | 10 ++++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/internal/usage/sandbox_contributor.go b/internal/usage/sandbox_contributor.go index 363a95d..eaa5384 100644 --- a/internal/usage/sandbox_contributor.go +++ b/internal/usage/sandbox_contributor.go @@ -4,8 +4,8 @@ import ( "fmt" "log/slog" - v8 "rogchap.com/v8go" "github.com/focusd-so/focusd/internal/sandbox" + v8 "rogchap.com/v8go" ) type usageContributor struct { @@ -25,7 +25,8 @@ func (c *usageContributor) Name() string { // TypesDefinition implements sandbox.Contributor func (c *usageContributor) TypesDefinition() string { return `declare module "@focusd/runtime" { - export { WeekdayType, Timezone, Weekday } from "@focusd/core"; + import { WeekdayType, Timezone, Weekday } from "@focusd/core"; + export { WeekdayType, Timezone, Weekday }; export type ClassificationType = "unknown" | "productive" | "distracting" | "neutral" | "system"; export type EnforcementActionType = "none" | "block" | "paused" | "allow"; diff --git a/test_ts.ts b/test_ts.ts index a7579c1..7d61f24 100644 --- a/test_ts.ts +++ b/test_ts.ts @@ -5,6 +5,12 @@ declare module "@focusd/core" { } declare module "@focusd/runtime" { - export { WeekdayType, Timezone, Weekday } from "@focusd/core"; - export const foo = 1; + import { WeekdayType, Timezone, Weekday } from "@focusd/core"; + export { WeekdayType, Timezone, Weekday }; + + export interface Runtime { + time: { + day(): WeekdayType; + }; + } } From 404d6dba86008c187bcd5a9a7fc4e833256079f1 Mon Sep 17 00:00:00 2001 From: Aram Petrosyan Date: Mon, 30 Mar 2026 15:25:32 +0100 Subject: [PATCH 7/7] fix(sandbox): fix Wails IPC mapping for custom rules testing UI --- frontend/src/components/test-rules-sheet.tsx | 4 ++-- internal/usage/classifier_custom_rules.go | 21 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/test-rules-sheet.tsx b/frontend/src/components/test-rules-sheet.tsx index 94fff51..a7e3bee 100644 --- a/frontend/src/components/test-rules-sheet.tsx +++ b/frontend/src/components/test-rules-sheet.tsx @@ -27,7 +27,7 @@ import { IconTerminal, IconTag, } from "@tabler/icons-react"; -import { ClassifyCustomRules, GetSandboxExecutionLogs } from "../../bindings/github.com/focusd-so/focusd/internal/usage/service"; +import { TestClassifyCustomRules, GetSandboxExecutionLogs } from "../../bindings/github.com/focusd-so/focusd/internal/usage/service"; import type { ClassificationResponse, SandboxExecutionLog } from "../../bindings/github.com/focusd-so/focusd/internal/usage/models"; const TIMEZONES = [ @@ -181,7 +181,7 @@ export function TestRulesSheet({ const urlParam = url.trim() || null; const nowTime = buildISOInTimezone(datetime, timezone); - const response = await ClassifyCustomRules( + const response = await TestClassifyCustomRules( appName.trim(), urlParam, nowTime diff --git a/internal/usage/classifier_custom_rules.go b/internal/usage/classifier_custom_rules.go index 08b5a2c..40df911 100644 --- a/internal/usage/classifier_custom_rules.go +++ b/internal/usage/classifier_custom_rules.go @@ -125,3 +125,24 @@ func (s *Service) classifySandbox(ctx context.Context, sandboxCtx sandboxContext return &d, result.Logs, nil } + +// TestClassifyCustomRules is exposed to Wails specifically for the Test Rules UI. +// It parses standard JSON arguments from the frontend and converts them into sandbox options. +func (s *Service) TestClassifyCustomRules(appName string, url *string, simulatedTimeISO *string) (*ClassificationResponse, error) { + opts := []sandboxContextOption{ + WithAppNameContext(appName), + } + + if url != nil && *url != "" { + opts = append(opts, WithBrowserURLContext(*url)) + } + + if simulatedTimeISO != nil && *simulatedTimeISO != "" { + t, err := time.Parse(time.RFC3339, *simulatedTimeISO) + if err == nil { + opts = append(opts, WithNowContext(t)) + } + } + + return s.ClassifyCustomRules(context.Background(), opts...) +}