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/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/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/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/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
new file mode 100644
index 0000000..9977003
--- /dev/null
+++ b/internal/sandbox/registry.go
@@ -0,0 +1,40 @@
+package sandbox
+
+import (
+ "fmt"
+ "os"
+ "strings"
+)
+
+// 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
+}
+
+// Global registry, automatically includes core contributor
+var contributors = []Contributor{&coreContributor{}}
+
+// 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..25e5ed8
--- /dev/null
+++ b/internal/sandbox/sandbox.go
@@ -0,0 +1,217 @@
+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)
+ }
+
+ // 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
+}
+
+// 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 };
+globalThis.__modules = globalThis.__modules || {};
+`)
+
+ // 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())
+ }
+
+ // 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)
+
+ 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..40df911 100644
--- a/internal/usage/classifier_custom_rules.go
+++ b/internal/usage/classifier_custom_rules.go
@@ -8,9 +8,17 @@ import (
"log/slog"
"time"
+ "github.com/focusd-so/focusd/internal/sandbox"
"github.com/focusd-so/focusd/internal/settings"
)
+// 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 +103,46 @@ 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()
+
+ 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 &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 sb.invokeClassify(sandboxCtx)
+ return s.ClassifyCustomRules(context.Background(), opts...)
}
diff --git a/internal/usage/protection.go b/internal/usage/protection.go
index 864b3a8..86a966c 100644
--- a/internal/usage/protection.go
+++ b/internal/usage/protection.go
@@ -11,9 +11,16 @@ import (
"gorm.io/gorm"
"github.com/focusd-so/focusd/internal/identity"
+ "github.com/focusd-so/focusd/internal/sandbox"
"github.com/focusd-so/focusd/internal/settings"
)
+// 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 +394,42 @@ 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
- }
-
+ 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_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
new file mode 100644
index 0000000..eaa5384
--- /dev/null
+++ b/internal/usage/sandbox_contributor.go
@@ -0,0 +1,277 @@
+package usage
+
+import (
+ "fmt"
+ "log/slog"
+
+ "github.com/focusd-so/focusd/internal/sandbox"
+ v8 "rogchap.com/v8go"
+)
+
+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 (c *usageContributor) Name() string {
+ return "usage"
+}
+
+// TypesDefinition implements sandbox.Contributor
+func (c *usageContributor) TypesDefinition() string {
+ return `declare module "@focusd/runtime" {
+ 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";
+ 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 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;
+ }
+}`
+}
+
+// PolyfillSource implements sandbox.Contributor
+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"
+});
+
+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 };
+}
+
+if (typeof globalThis.__modules === 'undefined') {
+ globalThis.__modules = {};
+}
+
+globalThis.__modules["@focusd/runtime"] = {
+ Classification: Classification,
+ EnforcementAction: EnforcementAction,
+ 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: function(tz) { return globalThis.__modules["@focusd/core"].time.now(tz); },
+ day: function(tz) { return globalThis.__modules["@focusd/core"].time.day(tz); }
+ }
+ };
+ }
+};
+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
+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: function(tz) { return globalThis.__modules["@focusd/core"].time.now(tz); },
+ day: function(tz) { return globalThis.__modules["@focusd/core"].time.day(tz); }
+ },
+ usage: ctxObj
+ };
+}
+`
+}
+
+// RegisterGlobals implements sandbox.Contributor and statically provides Usage DB methods
+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 {
+ val, _ := v8.NewValue(iso, int32(0))
+ return val
+ }
+
+ appName := args[0].String()
+ hostname := args[1].String()
+ minutes := int64(args[2].Integer())
+
+ 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))
+ 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 87221b5..37606f9 100644
--- a/internal/usage/service.go
+++ b/internal/usage/service.go
@@ -29,6 +29,7 @@ type Service struct {
}
func NewService(ctx context.Context, db *gorm.DB, options ...Option) (*Service, error) {
+
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..ceaf4bc 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,20 @@ 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)
}
+ sandbox.Register(usage.NewUsageContributor(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)
+ }
+
mux, _, err := setUpWebServer(ctx, extensionSessionAPIKey, usageService)
if err != nil {
log.Fatal("failed to setup web server: %w", err)
@@ -189,6 +200,7 @@ func main() {
services := []application.Service{
application.NewService(usageService),
application.NewService(settingsService),
+ application.NewService(fsService),
application.NewService(identityService),
application.NewService(nativeService),
}
diff --git a/test_ts.ts b/test_ts.ts
new file mode 100644
index 0000000..7d61f24
--- /dev/null
+++ b/test_ts.ts
@@ -0,0 +1,16 @@
+declare module "@focusd/core" {
+ export type WeekdayType = "Sunday";
+ export const Timezone: { UTC: "UTC" };
+ export const Weekday: { Sunday: "Sunday" };
+}
+
+declare module "@focusd/runtime" {
+ import { WeekdayType, Timezone, Weekday } from "@focusd/core";
+ export { WeekdayType, Timezone, Weekday };
+
+ export interface Runtime {
+ time: {
+ day(): WeekdayType;
+ };
+ }
+}