Compare commits

...

10 Commits

Author SHA1 Message Date
Michael Freno
4bd80245cd prevent overflow 2026-01-30 20:32:38 -05:00
Michael Freno
1e20283afc consolidation 2026-01-30 13:52:25 -05:00
Michael Freno
b725f9cfd7 post update 2026-01-30 13:18:55 -05:00
Michael Freno
a992bc8374 Version bump to v0.5.0 2026-01-30 12:57:08 -05:00
Michael Freno
4b446db817 fix fullscreen 2026-01-30 12:55:41 -05:00
Michael Freno
cbd60fdd08 adding macrovisionkit for fullscreen detection 2026-01-30 12:48:37 -05:00
Michael Freno
6e41c4059c building 2026-01-30 12:20:56 -05:00
Michael Freno
7d6e51a183 fix ref 2026-01-30 12:09:44 -05:00
Michael Freno
0b6dd3f903 fix one 2026-01-30 11:50:41 -05:00
Michael Freno
7a23ae9bad checking 2026-01-30 09:01:25 -05:00
23 changed files with 1050 additions and 1812 deletions

View File

@@ -8,6 +8,7 @@
/* Begin PBXBuildFile section */
275915892F132A9200D0E60D /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 27AE10B12F10B1FC00E00DBC /* Lottie */; };
27CF3CCB2F2D266600D67058 /* MacroVisionKit in Frameworks */ = {isa = PBXBuildFile; productRef = 27CF3CCA2F2D266600D67058 /* MacroVisionKit */; };
27SPARKLE00000000003 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 27SPARKLE00000000002 /* Sparkle */; };
/* End PBXBuildFile section */
@@ -70,6 +71,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
27CF3CCB2F2D266600D67058 /* MacroVisionKit in Frameworks */,
275915892F132A9200D0E60D /* Lottie in Frameworks */,
27SPARKLE00000000003 /* Sparkle in Frameworks */,
);
@@ -134,6 +136,7 @@
packageProductDependencies = (
27AE10B12F10B1FC00E00DBC /* Lottie */,
27SPARKLE00000000002 /* Sparkle */,
27CF3CCA2F2D266600D67058 /* MacroVisionKit */,
);
productName = Gaze;
productReference = 27A21B3C2F0F69DC0018C4F3 /* Gaze.app */;
@@ -220,6 +223,7 @@
packageReferences = (
27AE10B02F10B1FC00E00DBC /* XCRemoteSwiftPackageReference "lottie-spm" */,
27SPARKLE00000000001 /* XCRemoteSwiftPackageReference "Sparkle" */,
27CF3CC92F2D266600D67058 /* XCRemoteSwiftPackageReference "MacroVisionKit" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 27A21B3D2F0F69DC0018C4F3 /* Products */;
@@ -424,7 +428,7 @@
CODE_SIGN_ENTITLEMENTS = Gaze/Gaze.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 9;
CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = 6GK4F9L62V;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
@@ -439,7 +443,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 0.4.1;
MARKETING_VERSION = 0.5.0;
PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.Gaze;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@@ -462,7 +466,7 @@
CODE_SIGN_ENTITLEMENTS = Gaze/Gaze.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 9;
CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = 6GK4F9L62V;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
@@ -477,7 +481,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 0.4.1;
MARKETING_VERSION = 0.5.0;
PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.Gaze;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@@ -496,11 +500,11 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 9;
CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = 6GK4F9L62V;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 0.4.1;
MARKETING_VERSION = 0.5.0;
PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.GazeTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -517,11 +521,11 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 9;
CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = 6GK4F9L62V;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 0.4.1;
MARKETING_VERSION = 0.5.0;
PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.GazeTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -537,10 +541,10 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 9;
CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = 6GK4F9L62V;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 0.4.1;
MARKETING_VERSION = 0.5.0;
PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.GazeUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -556,10 +560,10 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 9;
CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = 6GK4F9L62V;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 0.4.1;
MARKETING_VERSION = 0.5.0;
PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.GazeUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -621,6 +625,14 @@
minimumVersion = 4.6.0;
};
};
27CF3CC92F2D266600D67058 /* XCRemoteSwiftPackageReference "MacroVisionKit" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/TheBoredTeam/MacroVisionKit.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 0.1.0;
};
};
27SPARKLE00000000001 /* XCRemoteSwiftPackageReference "Sparkle" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/sparkle-project/Sparkle";
@@ -637,6 +649,11 @@
package = 27AE10B02F10B1FC00E00DBC /* XCRemoteSwiftPackageReference "lottie-spm" */;
productName = Lottie;
};
27CF3CCA2F2D266600D67058 /* MacroVisionKit */ = {
isa = XCSwiftPackageProductDependency;
package = 27CF3CC92F2D266600D67058 /* XCRemoteSwiftPackageReference "MacroVisionKit" */;
productName = MacroVisionKit;
};
27SPARKLE00000000002 /* Sparkle */ = {
isa = XCSwiftPackageProductDependency;
package = 27SPARKLE00000000001 /* XCRemoteSwiftPackageReference "Sparkle" */;

View File

@@ -1,646 +0,0 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
275915892F132A9200D0E60D /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 27AE10B12F10B1FC00E00DBC /* Lottie */; };
27SPARKLE00000000003 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 27SPARKLE00000000002 /* Sparkle */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
27A21B4A2F0F69DD0018C4F3 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 27A21B342F0F69DC0018C4F3 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 27A21B3B2F0F69DC0018C4F3;
remoteInfo = Gaze;
};
27A21B542F0F69DD0018C4F3 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 27A21B342F0F69DC0018C4F3 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 27A21B3B2F0F69DC0018C4F3;
remoteInfo = Gaze;
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
27A21B3C2F0F69DC0018C4F3 /* Gaze.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Gaze.app; sourceTree = BUILT_PRODUCTS_DIR; };
27A21B492F0F69DD0018C4F3 /* GazeTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = GazeTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
27A21B532F0F69DD0018C4F3 /* GazeUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = GazeUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
270D22E92F1474F1008BCE42 /* Exceptions for "Gaze" folder in "Gaze" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = 27A21B3B2F0F69DC0018C4F3 /* Gaze */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
27A21B3E2F0F69DC0018C4F3 /* Gaze */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
270D22E92F1474F1008BCE42 /* Exceptions for "Gaze" folder in "Gaze" target */,
);
path = Gaze;
sourceTree = "<group>";
};
27A21B4C2F0F69DD0018C4F3 /* GazeTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = GazeTests;
sourceTree = "<group>";
};
27A21B562F0F69DD0018C4F3 /* GazeUITests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = GazeUITests;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
27A21B392F0F69DC0018C4F3 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
275915892F132A9200D0E60D /* Lottie in Frameworks */,
27SPARKLE00000000003 /* Sparkle in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
27A21B462F0F69DD0018C4F3 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
27A21B502F0F69DD0018C4F3 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
27A21B332F0F69DC0018C4F3 = {
isa = PBXGroup;
children = (
27A21B3E2F0F69DC0018C4F3 /* Gaze */,
27A21B4C2F0F69DD0018C4F3 /* GazeTests */,
27A21B562F0F69DD0018C4F3 /* GazeUITests */,
27A21B3D2F0F69DC0018C4F3 /* Products */,
);
sourceTree = "<group>";
};
27A21B3D2F0F69DC0018C4F3 /* Products */ = {
isa = PBXGroup;
children = (
27A21B3C2F0F69DC0018C4F3 /* Gaze.app */,
27A21B492F0F69DD0018C4F3 /* GazeTests.xctest */,
27A21B532F0F69DD0018C4F3 /* GazeUITests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
27A21B3B2F0F69DC0018C4F3 /* Gaze */ = {
isa = PBXNativeTarget;
buildConfigurationList = 27A21B5D2F0F69DD0018C4F3 /* Build configuration list for PBXNativeTarget "Gaze" */;
buildPhases = (
27A21B382F0F69DC0018C4F3 /* Sources */,
27A21B392F0F69DC0018C4F3 /* Frameworks */,
27A21B3A2F0F69DC0018C4F3 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
27A21B3E2F0F69DC0018C4F3 /* Gaze */,
);
name = Gaze;
packageProductDependencies = (
27AE10B12F10B1FC00E00DBC /* Lottie */,
27SPARKLE00000000002 /* Sparkle */,
);
productName = Gaze;
productReference = 27A21B3C2F0F69DC0018C4F3 /* Gaze.app */;
productType = "com.apple.product-type.application";
};
27A21B482F0F69DD0018C4F3 /* GazeTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 27A21B602F0F69DD0018C4F3 /* Build configuration list for PBXNativeTarget "GazeTests" */;
buildPhases = (
27A21B452F0F69DD0018C4F3 /* Sources */,
27A21B462F0F69DD0018C4F3 /* Frameworks */,
27A21B472F0F69DD0018C4F3 /* Resources */,
);
buildRules = (
);
dependencies = (
27A21B4B2F0F69DD0018C4F3 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
27A21B4C2F0F69DD0018C4F3 /* GazeTests */,
);
name = GazeTests;
packageProductDependencies = (
);
productName = GazeTests;
productReference = 27A21B492F0F69DD0018C4F3 /* GazeTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
27A21B522F0F69DD0018C4F3 /* GazeUITests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 27A21B632F0F69DD0018C4F3 /* Build configuration list for PBXNativeTarget "GazeUITests" */;
buildPhases = (
27A21B4F2F0F69DD0018C4F3 /* Sources */,
27A21B502F0F69DD0018C4F3 /* Frameworks */,
27A21B512F0F69DD0018C4F3 /* Resources */,
);
buildRules = (
);
dependencies = (
27A21B552F0F69DD0018C4F3 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
27A21B562F0F69DD0018C4F3 /* GazeUITests */,
);
name = GazeUITests;
packageProductDependencies = (
);
productName = GazeUITests;
productReference = 27A21B532F0F69DD0018C4F3 /* GazeUITests.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
27A21B342F0F69DC0018C4F3 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2620;
LastUpgradeCheck = 2620;
TargetAttributes = {
27A21B3B2F0F69DC0018C4F3 = {
CreatedOnToolsVersion = 26.2;
};
27A21B482F0F69DD0018C4F3 = {
CreatedOnToolsVersion = 26.2;
TestTargetID = 27A21B3B2F0F69DC0018C4F3;
};
27A21B522F0F69DD0018C4F3 = {
CreatedOnToolsVersion = 26.2;
TestTargetID = 27A21B3B2F0F69DC0018C4F3;
};
};
};
buildConfigurationList = 27A21B372F0F69DC0018C4F3 /* Build configuration list for PBXProject "Gaze" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 27A21B332F0F69DC0018C4F3;
minimizedProjectReferenceProxies = 1;
packageReferences = (
27AE10B02F10B1FC00E00DBC /* XCRemoteSwiftPackageReference "lottie-spm" */,
27SPARKLE00000000001 /* XCRemoteSwiftPackageReference "Sparkle" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 27A21B3D2F0F69DC0018C4F3 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
27A21B3B2F0F69DC0018C4F3 /* Gaze */,
27A21B482F0F69DD0018C4F3 /* GazeTests */,
27A21B522F0F69DD0018C4F3 /* GazeUITests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
27A21B3A2F0F69DC0018C4F3 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
27A21B472F0F69DD0018C4F3 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
27A21B512F0F69DD0018C4F3 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
27A21B382F0F69DC0018C4F3 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
27A21B452F0F69DD0018C4F3 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
27A21B4F2F0F69DD0018C4F3 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
27A21B4B2F0F69DD0018C4F3 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 27A21B3B2F0F69DC0018C4F3 /* Gaze */;
targetProxy = 27A21B4A2F0F69DD0018C4F3 /* PBXContainerItemProxy */;
};
27A21B552F0F69DD0018C4F3 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 27A21B3B2F0F69DC0018C4F3 /* Gaze */;
targetProxy = 27A21B542F0F69DD0018C4F3 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
27A21B5B2F0F69DD0018C4F3 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 6GK4F9L62V;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 26.2;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
27A21B5C2F0F69DD0018C4F3 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 6GK4F9L62V;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 26.2;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
};
name = Release;
};
27A21B5E2F0F69DD0018C4F3 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = Gaze;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
CODE_SIGN_ENTITLEMENTS = Gaze/Gaze.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 8;
DEVELOPMENT_TEAM = 6GK4F9L62V;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Gaze/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 0.4.0;
OTHER_SWIFT_FLAGS = "-D APPSTORE";
PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.Gaze;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
};
name = Debug;
};
27A21B5F2F0F69DD0018C4F3 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = Gaze;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
CODE_SIGN_ENTITLEMENTS = Gaze/Gaze.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 8;
DEVELOPMENT_TEAM = 6GK4F9L62V;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Gaze/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 0.4.0;
OTHER_SWIFT_FLAGS = "-D APPSTORE";
PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.Gaze;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
};
name = Release;
};
27A21B612F0F69DD0018C4F3 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 6;
DEVELOPMENT_TEAM = 6GK4F9L62V;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 0.4.0;
PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.GazeTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Gaze.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Gaze";
};
name = Debug;
};
27A21B622F0F69DD0018C4F3 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 6;
DEVELOPMENT_TEAM = 6GK4F9L62V;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 0.4.0;
PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.GazeTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Gaze.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Gaze";
};
name = Release;
};
27A21B642F0F69DD0018C4F3 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 6;
DEVELOPMENT_TEAM = 6GK4F9L62V;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 0.4.0;
PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.GazeUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TEST_TARGET_NAME = Gaze;
};
name = Debug;
};
27A21B652F0F69DD0018C4F3 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 6;
DEVELOPMENT_TEAM = 6GK4F9L62V;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 0.4.0;
PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.GazeUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TEST_TARGET_NAME = Gaze;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
27A21B372F0F69DC0018C4F3 /* Build configuration list for PBXProject "Gaze" */ = {
isa = XCConfigurationList;
buildConfigurations = (
27A21B5B2F0F69DD0018C4F3 /* Debug */,
27A21B5C2F0F69DD0018C4F3 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
27A21B5D2F0F69DD0018C4F3 /* Build configuration list for PBXNativeTarget "Gaze" */ = {
isa = XCConfigurationList;
buildConfigurations = (
27A21B5E2F0F69DD0018C4F3 /* Debug */,
27A21B5F2F0F69DD0018C4F3 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
27A21B602F0F69DD0018C4F3 /* Build configuration list for PBXNativeTarget "GazeTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
27A21B612F0F69DD0018C4F3 /* Debug */,
27A21B622F0F69DD0018C4F3 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
27A21B632F0F69DD0018C4F3 /* Build configuration list for PBXNativeTarget "GazeUITests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
27A21B642F0F69DD0018C4F3 /* Debug */,
27A21B652F0F69DD0018C4F3 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
27AE10B02F10B1FC00E00DBC /* XCRemoteSwiftPackageReference "lottie-spm" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/airbnb/lottie-spm.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 4.6.0;
};
};
27SPARKLE00000000001 /* XCRemoteSwiftPackageReference "Sparkle" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/sparkle-project/Sparkle";
requirement = {
kind = exactVersion;
version = 2.8.1;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
27AE10B12F10B1FC00E00DBC /* Lottie */ = {
isa = XCSwiftPackageProductDependency;
package = 27AE10B02F10B1FC00E00DBC /* XCRemoteSwiftPackageReference "lottie-spm" */;
productName = Lottie;
};
27SPARKLE00000000002 /* Sparkle */ = {
isa = XCSwiftPackageProductDependency;
package = 27SPARKLE00000000001 /* XCRemoteSwiftPackageReference "Sparkle" */;
productName = Sparkle;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 27A21B342F0F69DC0018C4F3 /* Project object */;
}

View File

@@ -1,5 +1,5 @@
{
"originHash" : "513d974fbede884a919977d3446360023f6e3239ac314f4fbd9657e80aca7560",
"originHash" : "83c4b4b69555e54712e60721606a120fe3f01308b1af84957cd0941e93e64f8a",
"pins" : [
{
"identity" : "lottie-spm",
@@ -10,6 +10,15 @@
"version" : "4.6.0"
}
},
{
"identity" : "macrovisionkit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/TheBoredTeam/MacroVisionKit.git",
"state" : {
"revision" : "da481a6be8d8b1bf7fcb218507a72428bbcae7b0",
"version" : "0.2.0"
}
},
{
"identity" : "sparkle",
"kind" : "remoteSourceControl",

View File

@@ -7,11 +7,35 @@
import Foundation
enum PauseReason: Codable, Equatable, Hashable {
enum PauseReason: nonisolated Codable, nonisolated Sendable, nonisolated Equatable, nonisolated Hashable {
case manual
case fullscreen
case idle
case system
nonisolated static func == (lhs: PauseReason, rhs: PauseReason) -> Bool {
switch (lhs, rhs) {
case (.manual, .manual),
(.fullscreen, .fullscreen),
(.idle, .idle),
(.system, .system):
return true
default:
return false
}
}
nonisolated func hash(into hasher: inout Hasher) {
switch self {
case .manual:
hasher.combine(0)
case .fullscreen:
hasher.combine(1)
case .idle:
hasher.combine(2)
case .system:
hasher.combine(3)
}
}
}
extension PauseReason: Sendable {}

View File

@@ -0,0 +1,16 @@
//
// SetupPresentation.swift
// Gaze
//
// Created by Mike Freno on 1/30/26.
//
import Foundation
enum SetupPresentation {
case window
case card
var isWindow: Bool { self == .window }
var isCard: Bool { self == .card }
}

View File

@@ -8,7 +8,7 @@
import Foundation
/// Unified identifier for both built-in and user-defined timers
enum TimerIdentifier: Hashable, Codable {
enum TimerIdentifier: Hashable, Codable, Sendable {
case builtIn(TimerType)
case user(id: String)

View File

@@ -7,7 +7,7 @@
import Foundation
struct TimerState: Equatable, Hashable {
struct TimerState: Equatable, Hashable, Sendable {
let identifier: TimerIdentifier
var remainingSeconds: Int
var isPaused: Bool
@@ -45,7 +45,7 @@ struct TimerState: Equatable, Hashable {
}
}
enum TimerStateBuilder {
enum TimerStateBuilder: Sendable {
static func make(
identifier: TimerIdentifier,
intervalSeconds: Int,

View File

@@ -10,16 +10,20 @@ import Combine
import Foundation
protocol CameraSessionDelegate: AnyObject {
nonisolated func cameraSession(
@MainActor func cameraSession(
_ manager: CameraSessionManager,
didOutput pixelBuffer: CVPixelBuffer,
imageSize: CGSize
)
}
private struct PixelBufferBox: @unchecked Sendable {
let buffer: CVPixelBuffer
}
final class CameraSessionManager: NSObject, ObservableObject {
@Published private(set) var isRunning = false
weak var delegate: CameraSessionDelegate?
nonisolated(unsafe) weak var delegate: CameraSessionDelegate?
private var captureSession: AVCaptureSession?
private var videoOutput: AVCaptureVideoDataOutput?
@@ -116,6 +120,11 @@ extension CameraSessionManager: AVCaptureVideoDataOutputSampleBufferDelegate {
height: CVPixelBufferGetHeight(pixelBuffer)
)
delegate?.cameraSession(self, didOutput: pixelBuffer, imageSize: size)
let bufferBox = PixelBufferBox(buffer: pixelBuffer)
DispatchQueue.main.async { [weak self, bufferBox] in
guard let self else { return }
self.delegate?.cameraSession(self, didOutput: bufferBox.buffer, imageSize: size)
}
}
}

View File

@@ -161,7 +161,7 @@ class EyeTrackingService: NSObject, ObservableObject {
}
extension EyeTrackingService: CameraSessionDelegate {
nonisolated func cameraSession(
@MainActor func cameraSession(
_ manager: CameraSessionManager,
didOutput pixelBuffer: CVPixelBuffer,
imageSize: CGSize
@@ -174,7 +174,6 @@ extension EyeTrackingService: CameraSessionDelegate {
if let leftRatio = result.leftPupilRatio,
let rightRatio = result.rightPupilRatio,
let faceWidth = result.faceWidthRatio {
Task { @MainActor in
guard CalibratorService.shared.isCalibrating else { return }
CalibratorService.shared.submitSampleToBridge(
leftRatio: leftRatio,
@@ -184,10 +183,7 @@ extension EyeTrackingService: CameraSessionDelegate {
faceWidthRatio: faceWidth
)
}
}
Task { @MainActor [weak self] in
guard let self else { return }
self.faceDetected = result.faceDetected
self.isEyesClosed = result.isEyesClosed
self.userLookingAtScreen = result.userLookingAtScreen
@@ -196,7 +192,6 @@ extension EyeTrackingService: CameraSessionDelegate {
self.syncDebugState()
self.updateGazeConfiguration()
}
}
}
// MARK: - Error Handling

View File

@@ -6,7 +6,7 @@
//
import Foundation
import Vision
@preconcurrency import Vision
import simd
struct EyeTrackingProcessingResult: Sendable {
@@ -54,19 +54,19 @@ final class GazeDetector: @unchecked Sendable {
}
private let lock = NSLock()
private var configuration: Configuration
private nonisolated(unsafe) var configuration: Configuration
init(configuration: Configuration) {
nonisolated init(configuration: Configuration) {
self.configuration = configuration
}
func updateConfiguration(_ configuration: Configuration) {
nonisolated func updateConfiguration(_ configuration: Configuration) {
lock.lock()
self.configuration = configuration
lock.unlock()
}
nonisolated func process(
func process(
analysis: VisionPipeline.FaceAnalysis,
pixelBuffer: CVPixelBuffer
) -> EyeTrackingProcessingResult {
@@ -75,7 +75,7 @@ final class GazeDetector: @unchecked Sendable {
config = configuration
lock.unlock()
guard analysis.faceDetected, let face = analysis.face else {
guard analysis.faceDetected, let face = analysis.face?.value else {
return EyeTrackingProcessingResult(
faceDetected: false,
isEyesClosed: false,

View File

@@ -18,7 +18,7 @@ import Accelerate
import CoreImage
import ImageIO
import UniformTypeIdentifiers
import Vision
@preconcurrency import Vision
struct PupilPosition: Equatable, Sendable {
let x: CGFloat

View File

@@ -6,17 +6,21 @@
//
import Foundation
import Vision
@preconcurrency import Vision
final class VisionPipeline: @unchecked Sendable {
struct FaceAnalysis: Sendable {
let faceDetected: Bool
let face: VNFaceObservation?
let face: NonSendableFaceObservation?
let imageSize: CGSize
let debugYaw: Double?
let debugPitch: Double?
}
struct NonSendableFaceObservation: @unchecked Sendable {
nonisolated(unsafe) let value: VNFaceObservation
}
nonisolated func analyze(
pixelBuffer: CVPixelBuffer,
imageSize: CGSize
@@ -46,7 +50,7 @@ final class VisionPipeline: @unchecked Sendable {
)
}
guard let face = (request.results as? [VNFaceObservation])?.first else {
guard let face = request.results?.first else {
return FaceAnalysis(
faceDetected: false,
face: nil,
@@ -58,7 +62,7 @@ final class VisionPipeline: @unchecked Sendable {
return FaceAnalysis(
faceDetected: true,
face: face,
face: NonSendableFaceObservation(value: face),
imageSize: imageSize,
debugYaw: face.yaw?.doubleValue,
debugPitch: face.pitch?.doubleValue

View File

@@ -7,139 +7,45 @@
import AppKit
import Combine
import CoreGraphics
import Foundation
public struct FullscreenWindowDescriptor: Equatable {
public let ownerPID: pid_t
public let layer: Int
public let bounds: CGRect
public init(ownerPID: pid_t, layer: Int, bounds: CGRect) {
self.ownerPID = ownerPID
self.layer = layer
self.bounds = bounds
}
}
protocol FullscreenEnvironmentProviding {
func frontmostProcessIdentifier() -> pid_t?
func windowDescriptors() -> [FullscreenWindowDescriptor]
func screenFrames() -> [CGRect]
}
struct SystemFullscreenEnvironmentProvider: FullscreenEnvironmentProviding {
func frontmostProcessIdentifier() -> pid_t? {
NSWorkspace.shared.frontmostApplication?.processIdentifier
}
func windowDescriptors() -> [FullscreenWindowDescriptor] {
let options: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements]
guard let windowList = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] else {
return []
}
return windowList.compactMap { window in
guard let ownerPID = window[kCGWindowOwnerPID as String] as? pid_t,
let layer = window[kCGWindowLayer as String] as? Int,
let boundsDict = window[kCGWindowBounds as String] as? [String: CGFloat] else {
return nil
}
let bounds = CGRect(
x: boundsDict["X"] ?? 0,
y: boundsDict["Y"] ?? 0,
width: boundsDict["Width"] ?? 0,
height: boundsDict["Height"] ?? 0
)
return FullscreenWindowDescriptor(ownerPID: ownerPID, layer: layer, bounds: bounds)
}
}
public func screenFrames() -> [CGRect] {
NSScreen.screens.map(\.frame)
}
}
import MacroVisionKit
final class FullscreenDetectionService: ObservableObject {
@Published private(set) var isFullscreenActive = false
private var observers: [NSObjectProtocol] = []
private var frontmostAppObserver: AnyCancellable?
private var fullscreenTask: Task<Void, Never>?
private let permissionManager: ScreenCapturePermissionManaging
private let environmentProvider: FullscreenEnvironmentProviding
private let windowMatcher = FullscreenWindowMatcher()
#if canImport(MacroVisionKit)
private let monitor = FullScreenMonitor.shared
#endif
init(
permissionManager: ScreenCapturePermissionManaging,
environmentProvider: FullscreenEnvironmentProviding
permissionManager: ScreenCapturePermissionManaging
) {
self.permissionManager = permissionManager
self.environmentProvider = environmentProvider
setupObservers()
startMonitoring()
}
/// Convenience initializer using default services
convenience init() {
self.init(
permissionManager: ScreenCapturePermissionManager.shared,
environmentProvider: SystemFullscreenEnvironmentProvider()
permissionManager: ScreenCapturePermissionManager.shared
)
}
// Factory method to safely create instances from non-main actor contexts
static func create(
permissionManager: ScreenCapturePermissionManaging? = nil,
environmentProvider: FullscreenEnvironmentProviding? = nil
permissionManager: ScreenCapturePermissionManaging? = nil
) async -> FullscreenDetectionService {
await MainActor.run {
return FullscreenDetectionService(
permissionManager: permissionManager ?? ScreenCapturePermissionManager.shared,
environmentProvider: environmentProvider ?? SystemFullscreenEnvironmentProvider()
permissionManager: permissionManager ?? ScreenCapturePermissionManager.shared
)
}
}
deinit {
let notificationCenter = NSWorkspace.shared.notificationCenter
observers.forEach { notificationCenter.removeObserver($0) }
frontmostAppObserver?.cancel()
}
private func setupObservers() {
let workspace = NSWorkspace.shared
let notificationCenter = workspace.notificationCenter
let stateChangeHandler: (Notification) -> Void = { [weak self] _ in
self?.checkFullscreenState()
}
let notifications: [(NSNotification.Name, Any?)] = [
(NSWorkspace.activeSpaceDidChangeNotification, workspace),
(NSApplication.didChangeScreenParametersNotification, nil),
(NSWindow.willEnterFullScreenNotification, nil),
(NSWindow.willExitFullScreenNotification, nil),
]
observers = notifications.map { notification, object in
notificationCenter.addObserver(
forName: notification,
object: object,
queue: .main,
using: stateChangeHandler
)
}
frontmostAppObserver = NotificationCenter.default.publisher(
for: NSWorkspace.didActivateApplicationNotification,
object: workspace
)
.sink { [weak self] _ in
self?.checkFullscreenState()
}
checkFullscreenState()
fullscreenTask?.cancel()
}
private func canReadWindowInfo() -> Bool {
@@ -151,25 +57,17 @@ final class FullscreenDetectionService: ObservableObject {
return true
}
private func checkFullscreenState() {
guard canReadWindowInfo() else { return }
guard let frontmostPID = environmentProvider.frontmostProcessIdentifier() else {
setFullscreenState(false)
return
}
let windows = environmentProvider.windowDescriptors()
let screens = environmentProvider.screenFrames()
for window in windows where window.ownerPID == frontmostPID && window.layer == 0 {
if windowMatcher.isFullscreen(windowBounds: window.bounds, screenFrames: screens) {
setFullscreenState(true)
return
private func startMonitoring() {
fullscreenTask = Task { [weak self] in
guard let self else { return }
let stream = await monitor.spaceChanges()
for await spaces in stream {
guard self.canReadWindowInfo() else { continue }
self.setFullscreenState(!spaces.isEmpty)
}
}
setFullscreenState(false)
forceUpdate()
}
fileprivate func setFullscreenState(_ isActive: Bool) {
@@ -179,7 +77,12 @@ final class FullscreenDetectionService: ObservableObject {
}
func forceUpdate() {
checkFullscreenState()
Task { [weak self] in
guard let self else { return }
guard self.canReadWindowInfo() else { return }
let spaces = await monitor.detectFullscreenApps()
self.setFullscreenState(!spaces.isEmpty)
}
}
#if DEBUG
@@ -188,16 +91,3 @@ final class FullscreenDetectionService: ObservableObject {
}
#endif
}
struct FullscreenWindowMatcher {
func isFullscreen(windowBounds: CGRect, screenFrames: [CGRect], tolerance: CGFloat = 1) -> Bool {
screenFrames.contains { matches(windowBounds, screenFrame: $0, tolerance: tolerance) }
}
private func matches(_ windowBounds: CGRect, screenFrame: CGRect, tolerance: CGFloat) -> Bool {
abs(windowBounds.width - screenFrame.width) < tolerance
&& abs(windowBounds.height - screenFrame.height) < tolerance
&& abs(windowBounds.origin.x - screenFrame.origin.x) < tolerance
&& abs(windowBounds.origin.y - screenFrame.origin.y) < tolerance
}
}

View File

@@ -0,0 +1,552 @@
//
// EnforceModeSetupContent.swift
// Gaze
//
// Created by Mike Freno on 1/30/26.
//
import AVFoundation
import SwiftUI
struct EnforceModeSetupContent: View {
@Bindable var settingsManager: SettingsManager
@ObservedObject var cameraService = CameraAccessService.shared
@ObservedObject var eyeTrackingService = EyeTrackingService.shared
@ObservedObject var enforceModeService = EnforceModeService.shared
@ObservedObject var calibratorService = CalibratorService.shared
@Environment(\.isCompactLayout) private var isCompact
let presentation: SetupPresentation
@Binding var isTestModeActive: Bool
@Binding var cachedPreviewLayer: AVCaptureVideoPreviewLayer?
@Binding var showAdvancedSettings: Bool
@Binding var showCalibrationWindow: Bool
@Binding var isViewActive: Bool
let isProcessingToggle: Bool
let handleEnforceModeToggle: (Bool) -> Void
private var cameraHardwareAvailable: Bool {
cameraService.hasCameraHardware
}
private var sectionCornerRadius: CGFloat {
presentation.isCard ? 10 : 12
}
private var sectionPadding: CGFloat {
presentation.isCard ? 10 : 16
}
private var headerFont: Font {
presentation.isCard ? .subheadline : .headline
}
private var iconSize: CGFloat {
presentation.isCard ? AdaptiveLayout.Font.cardIconSmall : AdaptiveLayout.Font.cardIcon
}
var body: some View {
VStack(spacing: presentation.isCard ? 10 : 24) {
if presentation.isCard {
Image(systemName: "video.fill")
.font(.system(size: iconSize))
.foregroundStyle(Color.accentColor)
Text("Enforce Mode")
.font(.title2)
.fontWeight(.bold)
}
Text("Use your camera to ensure you take breaks")
.font(presentation.isCard ? .subheadline : (isCompact ? .subheadline : .title3))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
if presentation.isCard {
Spacer(minLength: 0)
}
VStack(spacing: presentation.isCard ? 10 : 20) {
enforceModeToggleView
cameraStatusView
if enforceModeService.isEnforceModeEnabled {
testModeButton
}
if isTestModeActive && enforceModeService.isCameraActive {
testModePreviewView
trackingConstantsView
} else if enforceModeService.isCameraActive && !isTestModeActive {
eyeTrackingStatusView
trackingConstantsView
}
privacyInfoView
}
if presentation.isCard {
Spacer(minLength: 0)
}
}
.sheet(isPresented: $showCalibrationWindow) {
EyeTrackingCalibrationView()
}
}
private var testModeButton: some View {
Button(action: {
Task { @MainActor in
if isTestModeActive {
enforceModeService.stopTestMode()
isTestModeActive = false
cachedPreviewLayer = nil
} else {
await enforceModeService.startTestMode()
isTestModeActive = enforceModeService.isCameraActive
if isTestModeActive {
cachedPreviewLayer = eyeTrackingService.previewLayer
}
}
}
}) {
HStack {
Image(systemName: isTestModeActive ? "stop.circle.fill" : "play.circle.fill")
.font(.title3)
Text(isTestModeActive ? "Stop Test" : "Test Tracking")
.font(.headline)
}
.frame(maxWidth: .infinity)
.padding()
}
.buttonStyle(.borderedProminent)
.controlSize(presentation.isCard ? .regular : .large)
}
private var calibrationSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: "target")
.font(.title3)
.foregroundStyle(.blue)
Text("Eye Tracking Calibration")
.font(.headline)
}
if calibratorService.calibrationData.isComplete {
VStack(alignment: .leading, spacing: 8) {
Text(calibratorService.getCalibrationSummary())
.font(.caption)
.foregroundStyle(.secondary)
if calibratorService.needsRecalibration() {
Label(
"Calibration expired - recalibration recommended",
systemImage: "exclamationmark.triangle.fill"
)
.font(.caption)
.foregroundStyle(.orange)
} else {
Label("Calibration active and valid", systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(.green)
}
}
} else {
Text("Not calibrated - using default thresholds")
.font(.caption)
.foregroundStyle(.secondary)
}
Button(action: {
showCalibrationWindow = true
}) {
HStack {
Image(systemName: "target")
Text(
calibratorService.calibrationData.isComplete
? "Recalibrate" : "Run Calibration")
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
}
.buttonStyle(.bordered)
.controlSize(.regular)
}
.padding(sectionPadding)
.glassEffectIfAvailable(
GlassStyle.regular.tint(.blue.opacity(0.1)), in: .rect(cornerRadius: sectionCornerRadius)
)
}
private var testModePreviewView: some View {
VStack(spacing: 16) {
let lookingAway = !eyeTrackingService.userLookingAtScreen
let borderColor: NSColor = lookingAway ? .systemGreen : .systemRed
let previewLayer = eyeTrackingService.previewLayer ?? cachedPreviewLayer
if let layer = previewLayer {
ZStack {
CameraPreviewView(previewLayer: layer, borderColor: borderColor)
PupilOverlayView(eyeTrackingService: eyeTrackingService)
VStack {
HStack {
Spacer()
GazeOverlayView(eyeTrackingService: eyeTrackingService)
}
Spacer()
}
}
.frame(height: presentation.isCard ? 180 : (isCompact ? 200 : 300))
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: sectionCornerRadius))
.onAppear {
if cachedPreviewLayer == nil {
cachedPreviewLayer = eyeTrackingService.previewLayer
}
}
}
}
}
private var cameraStatusView: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Camera Access")
.font(headerFont)
if cameraService.isCameraAuthorized {
Label("Authorized", systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(.green)
} else if let error = cameraService.cameraError {
Label(error.localizedDescription, systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(.orange)
} else {
Label("Not authorized", systemImage: "xmark.circle.fill")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
if !cameraService.isCameraAuthorized {
Button("Request Access") {
Task { @MainActor in
do {
try await cameraService.requestCameraAccess()
} catch {
print("⚠️ Camera access failed: \(error.localizedDescription)")
}
}
}
.buttonStyle(.bordered)
.controlSize(presentation.isCard ? .small : .regular)
}
}
.padding(sectionPadding)
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: sectionCornerRadius))
}
private var eyeTrackingStatusView: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Eye Tracking Status")
.font(headerFont)
HStack(spacing: 20) {
statusIndicator(
title: "Face Detected",
isActive: eyeTrackingService.faceDetected,
icon: "person.fill"
)
statusIndicator(
title: "Looking Away",
isActive: !eyeTrackingService.userLookingAtScreen,
icon: "arrow.turn.up.right"
)
}
}
.padding(sectionPadding)
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: sectionCornerRadius))
}
private func statusIndicator(title: String, isActive: Bool, icon: String) -> some View {
VStack(spacing: 8) {
Image(systemName: icon)
.font(.title2)
.foregroundStyle(isActive ? .green : .secondary)
Text(title)
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
}
private var privacyInfoView: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: "lock.shield.fill")
.font(.title3)
.foregroundStyle(.blue)
Text("Privacy Information")
.font(headerFont)
}
VStack(alignment: .leading, spacing: 8) {
privacyBullet("All processing happens on-device")
privacyBullet("No images are stored or transmitted")
privacyBullet("Camera only active during lookaway reminders (3 second window)")
privacyBullet("You can always force quit with cmd+q")
}
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(sectionPadding)
.glassEffectIfAvailable(
GlassStyle.regular.tint(.blue.opacity(0.1)), in: .rect(cornerRadius: sectionCornerRadius)
)
}
private func privacyBullet(_ text: String) -> some View {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "checkmark")
.font(.caption2)
.foregroundStyle(.blue)
Text(text)
}
}
private var enforceModeToggleView: some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Enable Enforce Mode")
.font(headerFont)
if !cameraHardwareAvailable {
Text("No camera hardware detected")
.font(.caption2)
.foregroundStyle(.orange)
} else {
Text("Camera activates 3 seconds before lookaway reminders")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
Spacer()
Toggle(
"",
isOn: Binding(
get: {
settingsManager.isTimerEnabled(for: .lookAway)
|| settingsManager.isTimerEnabled(for: .blink)
|| settingsManager.isTimerEnabled(for: .posture)
},
set: { newValue in
guard !isProcessingToggle else { return }
handleEnforceModeToggle(newValue)
}
)
)
.labelsHidden()
.disabled(isProcessingToggle || !cameraHardwareAvailable)
.controlSize(presentation.isCard ? .small : (isCompact ? .small : .regular))
}
.padding(sectionPadding)
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: sectionCornerRadius))
}
private var trackingConstantsView: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text("Tracking Sensitivity")
.font(headerFont)
Spacer()
Button(action: {
eyeTrackingService.enableDebugLogging.toggle()
}) {
Image(
systemName: eyeTrackingService.enableDebugLogging
? "ant.circle.fill" : "ant.circle"
)
.foregroundStyle(eyeTrackingService.enableDebugLogging ? .orange : .secondary)
}
.buttonStyle(.plain)
.help("Toggle console debug logging")
Button(showAdvancedSettings ? "Hide Settings" : "Show Settings") {
withAnimation {
showAdvancedSettings.toggle()
}
}
.buttonStyle(.bordered)
.controlSize(.small)
}
VStack(alignment: .leading, spacing: 8) {
Text("Live Values:")
.font(.caption)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
if let leftRatio = eyeTrackingService.debugLeftPupilRatio,
let rightRatio = eyeTrackingService.debugRightPupilRatio
{
HStack(spacing: 16) {
VStack(alignment: .leading, spacing: 2) {
Text("Left Pupil: \(String(format: "%.3f", leftRatio))")
.font(.caption2)
.foregroundStyle(
!EyeTrackingConstants.minPupilEnabled
&& !EyeTrackingConstants.maxPupilEnabled
? .secondary
: (leftRatio < EyeTrackingConstants.minPupilRatio
|| leftRatio > EyeTrackingConstants.maxPupilRatio)
? Color.orange : Color.green
)
Text("Right Pupil: \(String(format: "%.3f", rightRatio))")
.font(.caption2)
.foregroundStyle(
!EyeTrackingConstants.minPupilEnabled
&& !EyeTrackingConstants.maxPupilEnabled
? .secondary
: (rightRatio < EyeTrackingConstants.minPupilRatio
|| rightRatio > EyeTrackingConstants.maxPupilRatio)
? Color.orange : Color.green
)
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(
"Range: \(String(format: "%.2f", EyeTrackingConstants.minPupilRatio)) - \(String(format: "%.2f", EyeTrackingConstants.maxPupilRatio))"
)
.font(.caption2)
.foregroundStyle(.secondary)
let bothEyesOut =
(leftRatio < EyeTrackingConstants.minPupilRatio
|| leftRatio > EyeTrackingConstants.maxPupilRatio)
&& (rightRatio < EyeTrackingConstants.minPupilRatio
|| rightRatio > EyeTrackingConstants.maxPupilRatio)
Text(bothEyesOut ? "Both Out ⚠️" : "In Range ✓")
.font(.caption2)
.foregroundStyle(bothEyesOut ? .orange : .green)
}
}
} else {
Text("Pupil data unavailable")
.font(.caption2)
.foregroundStyle(.secondary)
}
if let yaw = eyeTrackingService.debugYaw,
let pitch = eyeTrackingService.debugPitch
{
HStack(spacing: 16) {
VStack(alignment: .leading, spacing: 2) {
Text("Yaw: \(String(format: "%.3f", yaw))")
.font(.caption2)
.foregroundStyle(
!EyeTrackingConstants.yawEnabled
? .secondary
: abs(yaw) > EyeTrackingConstants.yawThreshold
? Color.orange : Color.green
)
Text("Pitch: \(String(format: "%.3f", pitch))")
.font(.caption2)
.foregroundStyle(
!EyeTrackingConstants.pitchUpEnabled
&& !EyeTrackingConstants.pitchDownEnabled
? .secondary
: (pitch > EyeTrackingConstants.pitchUpThreshold
|| pitch < EyeTrackingConstants.pitchDownThreshold)
? Color.orange : Color.green
)
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(
"Yaw Max: \(String(format: "%.2f", EyeTrackingConstants.yawThreshold))"
)
.font(.caption2)
.foregroundStyle(.secondary)
Text(
"Pitch: \(String(format: "%.2f", EyeTrackingConstants.pitchDownThreshold)) to \(String(format: "%.2f", EyeTrackingConstants.pitchUpThreshold))"
)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
}
.padding(.top, 4)
if showAdvancedSettings {
VStack(spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
Text("Current Threshold Values:")
.font(.caption)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
HStack {
Text("Yaw Threshold:")
Spacer()
Text("\(String(format: "%.2f", EyeTrackingConstants.yawThreshold)) rad")
.foregroundStyle(.secondary)
}
HStack {
Text("Pitch Up Threshold:")
Spacer()
Text(
"\(String(format: "%.2f", EyeTrackingConstants.pitchUpThreshold)) rad"
)
.foregroundStyle(.secondary)
}
HStack {
Text("Pitch Down Threshold:")
Spacer()
Text(
"\(String(format: "%.2f", EyeTrackingConstants.pitchDownThreshold)) rad"
)
.foregroundStyle(.secondary)
}
HStack {
Text("Min Pupil Ratio:")
Spacer()
Text("\(String(format: "%.2f", EyeTrackingConstants.minPupilRatio))")
.foregroundStyle(.secondary)
}
HStack {
Text("Max Pupil Ratio:")
Spacer()
Text("\(String(format: "%.2f", EyeTrackingConstants.maxPupilRatio))")
.foregroundStyle(.secondary)
}
HStack {
Text("Eye Closed Threshold:")
Spacer()
Text(
"\(String(format: "%.3f", EyeTrackingConstants.eyeClosedThreshold))"
)
.foregroundStyle(.secondary)
}
}
.padding(.top, 8)
}
.padding(.top, 8)
}
}
.padding(sectionPadding)
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: sectionCornerRadius))
}
}

View File

@@ -0,0 +1,201 @@
//
// SmartModeSetupContent.swift
// Gaze
//
// Created by Mike Freno on 1/30/26.
//
import SwiftUI
struct SmartModeSetupContent: View {
@Bindable var settingsManager: SettingsManager
@State private var permissionManager = ScreenCapturePermissionManager.shared
let presentation: SetupPresentation
private var iconSize: CGFloat {
presentation.isCard ? AdaptiveLayout.Font.cardIconSmall : AdaptiveLayout.Font.cardIcon
}
private var sectionCornerRadius: CGFloat {
presentation.isCard ? 10 : 12
}
private var sectionPadding: CGFloat {
presentation.isCard ? 10 : 16
}
private var sectionSpacing: CGFloat {
presentation.isCard ? 8 : 12
}
var body: some View {
VStack(spacing: presentation.isCard ? 10 : 24) {
if presentation.isCard {
Image(systemName: "brain.fill")
.font(.system(size: iconSize))
.foregroundStyle(.purple)
Text("Smart Mode")
.font(.title2)
.fontWeight(.bold)
}
Text("Automatically manage timers based on your activity")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
if presentation.isCard {
Spacer(minLength: 0)
}
VStack(spacing: sectionSpacing) {
fullscreenSection
idleSection
#if DEBUG
usageTrackingSection
#endif
}
.frame(maxWidth: presentation.isCard ? .infinity : 600)
if presentation.isCard {
Spacer(minLength: 0)
}
}
}
private var fullscreenSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: "arrow.up.left.and.arrow.down.right")
.foregroundStyle(.blue)
Text("Auto-pause on Fullscreen")
.font(presentation.isCard ? .subheadline : .headline)
}
Text("Timers will automatically pause when you enter fullscreen mode (videos, games, presentations)")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Toggle("", isOn: $settingsManager.settings.smartMode.autoPauseOnFullscreen)
.labelsHidden()
.controlSize(presentation.isCard ? .small : .regular)
.onChange(of: settingsManager.settings.smartMode.autoPauseOnFullscreen) { _, newValue in
if newValue {
permissionManager.requestAuthorizationIfNeeded()
}
}
}
if settingsManager.settings.smartMode.autoPauseOnFullscreen,
permissionManager.authorizationStatus != .authorized
{
permissionWarningView
}
}
.padding(sectionPadding)
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: sectionCornerRadius))
}
private var permissionWarningView: some View {
VStack(alignment: .leading, spacing: 8) {
Label(
permissionManager.authorizationStatus == .denied
? "Screen Recording permission required"
: "Grant Screen Recording access",
systemImage: "exclamationmark.shield"
)
.foregroundStyle(.orange)
Text("macOS requires Screen Recording permission to detect other apps in fullscreen.")
.font(.caption)
.foregroundStyle(.secondary)
HStack {
Button("Grant Access") {
permissionManager.requestAuthorizationIfNeeded()
permissionManager.openSystemSettings()
}
.buttonStyle(.bordered)
.controlSize(presentation.isCard ? .small : .regular)
Button("Open Settings") {
permissionManager.openSystemSettings()
}
.buttonStyle(.borderless)
}
.font(.caption)
.padding(.top, 4)
}
.padding(.top, 8)
}
private var idleSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: "moon.zzz.fill")
.foregroundStyle(.indigo)
Text("Auto-pause on Idle")
.font(presentation.isCard ? .subheadline : .headline)
}
Text("Timers will pause when you're inactive for more than the threshold below")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Toggle("", isOn: $settingsManager.settings.smartMode.autoPauseOnIdle)
.labelsHidden()
.controlSize(presentation.isCard ? .small : .regular)
}
if settingsManager.settings.smartMode.autoPauseOnIdle {
ThresholdSlider(
label: "Idle Threshold:",
value: $settingsManager.settings.smartMode.idleThresholdMinutes,
range: 1...30,
unit: "min"
)
}
}
.padding(sectionPadding)
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: sectionCornerRadius))
}
private var usageTrackingSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: "chart.line.uptrend.xyaxis")
.foregroundStyle(.green)
Text("Track Usage Statistics")
.font(presentation.isCard ? .subheadline : .headline)
}
Text("Monitor active and idle time, with automatic reset after the specified duration")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Toggle("", isOn: $settingsManager.settings.smartMode.trackUsage)
.labelsHidden()
.controlSize(presentation.isCard ? .small : .regular)
}
if settingsManager.settings.smartMode.trackUsage {
ThresholdSlider(
label: "Reset After:",
value: $settingsManager.settings.smartMode.usageResetAfterMinutes,
range: 15...240,
step: 15,
unit: "min"
)
}
}
.padding(sectionPadding)
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: sectionCornerRadius))
}
}

View File

@@ -0,0 +1,39 @@
//
// ThresholdSlider.swift
// Gaze
//
// Created by Mike Freno on 1/30/26.
//
import SwiftUI
struct ThresholdSlider: View {
let label: String
@Binding var value: Int
let range: ClosedRange<Int>
var step: Int = 1
let unit: String
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(label)
.font(.subheadline)
Spacer()
Text("\(value) \(unit)")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Slider(
value: Binding(
get: { Double(value) },
set: { value = Int($0) }
),
in: Double(range.lowerBound)...Double(range.upperBound),
step: Double(step)
)
}
.padding(.top, 8)
}
}

View File

@@ -5,6 +5,7 @@
// Created by Mike Freno on 1/18/26.
//
import AVFoundation
import SwiftUI
struct AdditionalModifiersView: View {
@@ -12,6 +13,13 @@ struct AdditionalModifiersView: View {
@State private var frontCardIndex: Int = 0
@State private var dragOffset: CGFloat = 0
@State private var isDragging: Bool = false
@State private var isTestModeActive = false
@State private var cachedPreviewLayer: AVCaptureVideoPreviewLayer?
@State private var showAdvancedSettings = false
@State private var showCalibrationWindow = false
@State private var isViewActive = false
@State private var isProcessingToggle = false
@ObservedObject var cameraService = CameraAccessService.shared
@Environment(\.isCompactLayout) private var isCompact
private var backCardOffset: CGFloat { isCompact ? 20 : AdaptiveLayout.Card.backOffset }
@@ -50,15 +58,41 @@ struct AdditionalModifiersView: View {
ZStack {
#if DEBUG
cardView(for: 0, width: cardWidth, height: cardHeight)
.zIndex(zIndex(for: 0))
.scaleEffect(scale(for: 0))
.offset(x: xOffset(for: 0), y: yOffset(for: 0))
setupCard(
presentation: .card,
content:
EnforceModeSetupContent(
settingsManager: settingsManager,
presentation: .card,
isTestModeActive: $isTestModeActive,
cachedPreviewLayer: $cachedPreviewLayer,
showAdvancedSettings: $showAdvancedSettings,
showCalibrationWindow: $showCalibrationWindow,
isViewActive: $isViewActive,
isProcessingToggle: isProcessingToggle,
handleEnforceModeToggle: { enabled in
if enabled {
Task { @MainActor in
try await cameraService.requestCameraAccess()
}
}
}
),
width: cardWidth,
height: cardHeight,
index: 0
)
#endif
cardView(for: 1, width: cardWidth, height: cardHeight)
.zIndex(zIndex(for: 1))
.scaleEffect(scale(for: 1))
.offset(x: xOffset(for: 1), y: yOffset(for: 1))
setupCard(
presentation: .card,
content: SmartModeSetupContent(
settingsManager: settingsManager,
presentation: .card
),
width: cardWidth,
height: cardHeight,
index: 1
)
}
.padding(isCompact ? 12 : 20)
.gesture(dragGesture)
@@ -198,226 +232,28 @@ struct AdditionalModifiersView: View {
// MARK: - Card Views
@ViewBuilder
private func cardView(for index: Int, width: CGFloat, height: CGFloat) -> some View {
private func setupCard(
presentation: SetupPresentation,
content: some View,
width: CGFloat,
height: CGFloat,
index: Int
) -> some View {
ZStack {
RoundedRectangle(cornerRadius: 16)
.fill(Color(NSColor.windowBackgroundColor))
.shadow(color: Color.black.opacity(0.2), radius: 10, x: 0, y: 4)
Group {
if index == 0 {
enforceModeContent
} else {
smartModeContent
}
}
ScrollView {
content
.padding(isCompact ? 12 : 20)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
}
.frame(width: width, height: height)
}
@ObservedObject var cameraService = CameraAccessService.shared
private var enforceModeContent: some View {
VStack(spacing: isCompact ? 10 : 16) {
Image(systemName: "video.fill")
.font(
.system(
size: isCompact
? AdaptiveLayout.Font.cardIconSmall : AdaptiveLayout.Font.cardIcon)
)
.foregroundStyle(Color.accentColor)
Text("Enforce Mode")
.font(isCompact ? .headline : .title2)
.fontWeight(.bold)
if !cameraService.hasCameraHardware {
Text("Camera hardware not detected")
.font(isCompact ? .caption : .subheadline)
.foregroundStyle(.orange)
.multilineTextAlignment(.center)
} else {
Text("Use your camera to ensure you take breaks")
.font(isCompact ? .caption : .subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
Spacer()
VStack(spacing: isCompact ? 10 : 16) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Enable Enforce Mode")
.font(isCompact ? .subheadline : .headline)
if !cameraService.hasCameraHardware {
Text("No camera hardware detected")
.font(.caption2)
.foregroundStyle(.orange)
} else {
Text("Camera activates before lookaway reminders")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
Spacer()
Toggle(
"",
isOn: Binding(
get: {
settingsManager.isTimerEnabled(for: .lookAway)
|| settingsManager.isTimerEnabled(for: .blink)
|| settingsManager.isTimerEnabled(for: .posture)
},
set: { newValue in
if newValue {
Task { @MainActor in
try await cameraService.requestCameraAccess()
}
}
}
)
)
.labelsHidden()
.disabled(!cameraService.hasCameraHardware)
.controlSize(isCompact ? .small : .regular)
}
.padding(isCompact ? 10 : 16)
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Camera Access")
.font(isCompact ? .subheadline : .headline)
if !cameraService.hasCameraHardware {
Label("No camera", systemImage: "xmark.circle.fill")
.font(.caption2)
.foregroundStyle(.orange)
} else if cameraService.isCameraAuthorized {
Label("Authorized", systemImage: "checkmark.circle.fill")
.font(.caption2)
.foregroundStyle(.green)
} else if let error = cameraService.cameraError {
Label(
error.localizedDescription,
systemImage: "exclamationmark.triangle.fill"
)
.font(.caption2)
.foregroundStyle(.orange)
} else {
Label("Not authorized", systemImage: "xmark.circle.fill")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
Spacer()
if !cameraService.isCameraAuthorized {
Button("Request Access") {
Task { @MainActor in
do {
try await cameraService.requestCameraAccess()
} catch {
print("Camera access failed: \(error.localizedDescription)")
}
}
}
.buttonStyle(.bordered)
.controlSize(isCompact ? .small : .regular)
}
}
.padding(isCompact ? 10 : 16)
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
}
Spacer()
}
}
private var smartModeContent: some View {
VStack(spacing: isCompact ? 10 : 16) {
Image(systemName: "brain.fill")
.font(
.system(
size: isCompact
? AdaptiveLayout.Font.cardIconSmall : AdaptiveLayout.Font.cardIcon)
)
.foregroundStyle(.purple)
Text("Smart Mode")
.font(isCompact ? .headline : .title2)
.fontWeight(.bold)
Text("Automatically manage timers based on activity")
.font(isCompact ? .caption : .subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Spacer()
VStack(spacing: isCompact ? 8 : 12) {
smartModeToggle(
icon: "arrow.up.left.and.arrow.down.right",
iconColor: .blue,
title: "Auto-pause on Fullscreen",
subtitle: "Pause during videos, games, presentations",
isOn: $settingsManager.settings.smartMode.autoPauseOnFullscreen
)
smartModeToggle(
icon: "moon.zzz.fill",
iconColor: .indigo,
title: "Auto-pause on Idle",
subtitle: "Pause when you're inactive",
isOn: $settingsManager.settings.smartMode.autoPauseOnIdle
)
#if DEBUG
smartModeToggle(
icon: "chart.line.uptrend.xyaxis",
iconColor: .green,
title: "Track Usage Statistics",
subtitle: "Monitor active and idle time",
isOn: $settingsManager.settings.smartMode.trackUsage
)
#endif
}
Spacer()
}
}
@ViewBuilder
private func smartModeToggle(
icon: String, iconColor: Color, title: String, subtitle: String, isOn: Binding<Bool>
) -> some View {
HStack {
Image(systemName: icon)
.foregroundStyle(iconColor)
.frame(width: isCompact ? 20 : 24)
VStack(alignment: .leading, spacing: 1) {
Text(title)
.font(isCompact ? .caption : .subheadline)
.fontWeight(.medium)
Text(subtitle)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer()
Toggle("", isOn: isOn)
.labelsHidden()
.controlSize(.small)
}
.padding(.horizontal, isCompact ? 8 : 12)
.padding(.vertical, isCompact ? 6 : 10)
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 10))
.zIndex(zIndex(for: index))
.scaleEffect(scale(for: index))
.offset(x: xOffset(for: index), y: yOffset(for: index))
}
// MARK: - Gestures & Navigation

View File

@@ -76,7 +76,10 @@ final class MenuBarGuideOverlayPresenter {
private func startCheckTimer() {
checkTimer?.invalidate()
checkTimer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { [weak self] _ in
self?.checkWindowFrame()
guard let self else { return }
Task { @MainActor in
self.checkWindowFrame()
}
}
}
@@ -129,7 +132,10 @@ final class MenuBarGuideOverlayPresenter {
// Set up KVO for window frame changes
onboardingWindowObserver = onboardingWindow.observe(\.frame, options: [.new, .old]) {
[weak self] _, _ in
self?.checkWindowFrame()
guard let self else { return }
Task { @MainActor in
self.checkWindowFrame()
}
}
// Add observer for when the onboarding window is closed
@@ -145,7 +151,10 @@ final class MenuBarGuideOverlayPresenter {
}
// Hide the overlay when onboarding window closes
self?.hide()
guard let self else { return }
Task { @MainActor in
self.hide()
}
}
}
}

View File

@@ -79,7 +79,7 @@ struct SettingsWindowView: View {
BlinkSetupView(settingsManager: settingsManager)
case .posture:
PostureSetupView(settingsManager: settingsManager)
#if ENFORCE_READY
#if DEBUG
case .enforceMode:
EnforceModeSetupView(settingsManager: settingsManager)
#endif

View File

@@ -6,15 +6,12 @@
//
import AVFoundation
import Foundation
import SwiftUI
struct EnforceModeSetupView: View {
@Bindable var settingsManager: SettingsManager
@ObservedObject var cameraService = CameraAccessService.shared
@ObservedObject var eyeTrackingService = EyeTrackingService.shared
@ObservedObject var enforceModeService = EnforceModeService.shared
@Environment(\.isCompactLayout) private var isCompact
@State private var isProcessingToggle = false
@State private var isTestModeActive = false
@@ -23,7 +20,6 @@ struct EnforceModeSetupView: View {
@State private var isViewActive = false
@State private var showAdvancedSettings = false
@State private var showCalibrationWindow = false
@ObservedObject var calibratorService = CalibratorService.shared
private var cameraHardwareAvailable: Bool {
cameraService.hasCameraHardware
@@ -33,72 +29,27 @@ struct EnforceModeSetupView: View {
VStack(spacing: 0) {
SetupHeader(icon: "video.fill", title: "Enforce Mode", color: .accentColor)
Spacer()
VStack(spacing: isCompact ? 16 : 30) {
Text("Use your camera to ensure you take breaks")
.font(isCompact ? .subheadline : .title3)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
VStack(spacing: isCompact ? 12 : 20) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Enable Enforce Mode")
.font(isCompact ? .subheadline : .headline)
if !cameraHardwareAvailable {
Text("No camera hardware detected")
.font(.caption2)
.foregroundStyle(.orange)
} else {
Text("Camera activates 3 seconds before lookaway reminders")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
Spacer()
Toggle(
"",
isOn: Binding(
get: {
settingsManager.isTimerEnabled(for: .lookAway) ||
settingsManager.isTimerEnabled(for: .blink) ||
settingsManager.isTimerEnabled(for: .posture)
},
set: { newValue in
print("🎛️ Toggle changed to: \(newValue)")
EnforceModeSetupContent(
settingsManager: settingsManager,
presentation: .window,
isTestModeActive: $isTestModeActive,
cachedPreviewLayer: $cachedPreviewLayer,
showAdvancedSettings: $showAdvancedSettings,
showCalibrationWindow: $showCalibrationWindow,
isViewActive: $isViewActive,
isProcessingToggle: isProcessingToggle,
handleEnforceModeToggle: { enabled in
print("🎛️ Toggle changed to: \(enabled)")
guard !isProcessingToggle else {
print("⚠️ Already processing toggle")
return
}
handleEnforceModeToggle(enabled: newValue)
handleEnforceModeToggle(enabled: enabled)
}
)
)
.labelsHidden()
.disabled(isProcessingToggle || !cameraHardwareAvailable)
.controlSize(isCompact ? .small : .regular)
}
.padding(isCompact ? 10 : 16)
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
.padding(.top, 20)
cameraStatusView
if enforceModeService.isEnforceModeEnabled {
testModeButton
}
if isTestModeActive && enforceModeService.isCameraActive {
testModePreviewView
trackingConstantsView
} else if enforceModeService.isCameraActive && !isTestModeActive {
eyeTrackingStatusView
trackingConstantsView
}
privacyInfoView
}
}
Spacer()
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
@@ -115,272 +66,6 @@ struct EnforceModeSetupView: View {
}
}
}
private var testModeButton: some View {
Button(action: {
Task { @MainActor in
if isTestModeActive {
enforceModeService.stopTestMode()
isTestModeActive = false
cachedPreviewLayer = nil
} else {
await enforceModeService.startTestMode()
isTestModeActive = enforceModeService.isCameraActive
if isTestModeActive {
cachedPreviewLayer = eyeTrackingService.previewLayer
}
}
}
}) {
HStack {
Image(systemName: isTestModeActive ? "stop.circle.fill" : "play.circle.fill")
.font(.title3)
Text(isTestModeActive ? "Stop Test" : "Test Tracking")
.font(.headline)
}
.frame(maxWidth: .infinity)
.padding()
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
private var calibrationSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: "target")
.font(.title3)
.foregroundStyle(.blue)
Text("Eye Tracking Calibration")
.font(.headline)
}
if calibratorService.calibrationData.isComplete {
VStack(alignment: .leading, spacing: 8) {
Text(calibratorService.getCalibrationSummary())
.font(.caption)
.foregroundStyle(.secondary)
if calibratorService.needsRecalibration() {
Label(
"Calibration expired - recalibration recommended",
systemImage: "exclamationmark.triangle.fill"
)
.font(.caption)
.foregroundStyle(.orange)
} else {
Label("Calibration active and valid", systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(.green)
}
}
} else {
Text("Not calibrated - using default thresholds")
.font(.caption)
.foregroundStyle(.secondary)
}
Button(action: {
showCalibrationWindow = true
}) {
HStack {
Image(systemName: "target")
Text(
calibratorService.calibrationData.isComplete
? "Recalibrate" : "Run Calibration")
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
}
.buttonStyle(.bordered)
.controlSize(.regular)
}
.padding()
.glassEffectIfAvailable(
GlassStyle.regular.tint(.blue.opacity(0.1)), in: .rect(cornerRadius: 12)
)
.sheet(isPresented: $showCalibrationWindow) {
EyeTrackingCalibrationView()
}
}
private var testModePreviewView: some View {
VStack(spacing: 16) {
let lookingAway = !eyeTrackingService.userLookingAtScreen
let borderColor: NSColor = lookingAway ? .systemGreen : .systemRed
// Cache the preview layer to avoid recreating it
let previewLayer = eyeTrackingService.previewLayer ?? cachedPreviewLayer
if let layer = previewLayer {
ZStack {
CameraPreviewView(previewLayer: layer, borderColor: borderColor)
// Pupil detection overlay (drawn on video)
PupilOverlayView(eyeTrackingService: eyeTrackingService)
// Debug info overlay (top-right corner)
VStack {
HStack {
Spacer()
GazeOverlayView(eyeTrackingService: eyeTrackingService)
}
Spacer()
}
}
.frame(height: isCompact ? 200 : 300)
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
.onAppear {
if cachedPreviewLayer == nil {
cachedPreviewLayer = eyeTrackingService.previewLayer
}
}
/*VStack(alignment: .leading, spacing: 12) {*/
/*Text("Live Tracking Status")*/
/*.font(.headline)*/
/*HStack(spacing: 20) {*/
/*statusIndicator(*/
/*title: "Face Detected",*/
/*isActive: eyeTrackingService.faceDetected,*/
/*icon: "person.fill"*/
/*)*/
/*statusIndicator(*/
/*title: "Looking Away",*/
/*isActive: !eyeTrackingService.userLookingAtScreen,*/
/*icon: "arrow.turn.up.right"*/
/*)*/
/*}*/
/*Text(*/
/*lookingAway*/
/*? " Break compliance detected" : " Please look away from screen"*/
/*)*/
/*.font(.caption)*/
/*.foregroundStyle(lookingAway ? .green : .orange)*/
/*.frame(maxWidth: .infinity, alignment: .center)*/
/*.padding(.top, 4)*/
/*}*/
/*.padding()*/
/*.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))*/
}
}
}
private var cameraStatusView: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Camera Access")
.font(.headline)
if cameraService.isCameraAuthorized {
Label("Authorized", systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(.green)
} else if let error = cameraService.cameraError {
Label(error.localizedDescription, systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(.orange)
} else {
Label("Not authorized", systemImage: "xmark.circle.fill")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
if !cameraService.isCameraAuthorized {
Button("Request Access") {
print("📷 Request Access button clicked")
Task { @MainActor in
do {
try await cameraService.requestCameraAccess()
print("✓ Camera access granted via button")
} catch {
print("⚠️ Camera access failed: \(error.localizedDescription)")
}
}
}
.buttonStyle(.bordered)
}
}
.padding()
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
}
private var eyeTrackingStatusView: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Eye Tracking Status")
.font(.headline)
HStack(spacing: 20) {
statusIndicator(
title: "Face Detected",
isActive: eyeTrackingService.faceDetected,
icon: "person.fill"
)
statusIndicator(
title: "Looking Away",
isActive: !eyeTrackingService.userLookingAtScreen,
icon: "arrow.turn.up.right"
)
}
}
.padding()
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
}
private func statusIndicator(title: String, isActive: Bool, icon: String) -> some View {
VStack(spacing: 8) {
Image(systemName: icon)
.font(.title2)
.foregroundStyle(isActive ? .green : .secondary)
Text(title)
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
}
private var privacyInfoView: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: "lock.shield.fill")
.font(.title3)
.foregroundStyle(.blue)
Text("Privacy Information")
.font(.headline)
}
VStack(alignment: .leading, spacing: 8) {
privacyBullet("All processing happens on-device")
privacyBullet("No images are stored or transmitted")
privacyBullet("Camera only active during lookaway reminders (3 second window)")
privacyBullet("You can always force quit with cmd+q")
}
.font(.caption)
.foregroundStyle(.secondary)
}
.padding()
.glassEffectIfAvailable(
GlassStyle.regular.tint(.blue.opacity(0.1)), in: .rect(cornerRadius: 12))
}
private func privacyBullet(_ text: String) -> some View {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "checkmark")
.font(.caption2)
.foregroundStyle(.blue)
Text(text)
}
}
private func handleEnforceModeToggle(enabled: Bool) {
print("🎛️ handleEnforceModeToggle called with enabled: \(enabled)")
isProcessingToggle = true
@@ -411,232 +96,6 @@ struct EnforceModeSetupView: View {
}
}
}
private var trackingConstantsView: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text("Tracking Sensitivity")
.font(.headline)
Spacer()
Button(action: {
eyeTrackingService.enableDebugLogging.toggle()
}) {
Image(
systemName: eyeTrackingService.enableDebugLogging
? "ant.circle.fill" : "ant.circle"
)
.foregroundStyle(eyeTrackingService.enableDebugLogging ? .orange : .secondary)
}
.buttonStyle(.plain)
.help("Toggle console debug logging")
Button(showAdvancedSettings ? "Hide Settings" : "Show Settings") {
withAnimation {
showAdvancedSettings.toggle()
}
}
.buttonStyle(.bordered)
.controlSize(.small)
}
// Debug info always visible when tracking
VStack(alignment: .leading, spacing: 8) {
Text("Live Values:")
.font(.caption)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
if let leftRatio = eyeTrackingService.debugLeftPupilRatio,
let rightRatio = eyeTrackingService.debugRightPupilRatio
{
HStack(spacing: 16) {
VStack(alignment: .leading, spacing: 2) {
Text("Left Pupil: \(String(format: "%.3f", leftRatio))")
.font(.caption2)
.foregroundStyle(
!EyeTrackingConstants.minPupilEnabled
&& !EyeTrackingConstants.maxPupilEnabled
? .secondary
: (leftRatio < EyeTrackingConstants.minPupilRatio
|| leftRatio > EyeTrackingConstants.maxPupilRatio)
? Color.orange : Color.green
)
Text("Right Pupil: \(String(format: "%.3f", rightRatio))")
.font(.caption2)
.foregroundStyle(
!EyeTrackingConstants.minPupilEnabled
&& !EyeTrackingConstants.maxPupilEnabled
? .secondary
: (rightRatio < EyeTrackingConstants.minPupilRatio
|| rightRatio > EyeTrackingConstants.maxPupilRatio)
? Color.orange : Color.green
)
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(
"Range: \(String(format: "%.2f", EyeTrackingConstants.minPupilRatio)) - \(String(format: "%.2f", EyeTrackingConstants.maxPupilRatio))"
)
.font(.caption2)
.foregroundStyle(.secondary)
let bothEyesOut =
(leftRatio < EyeTrackingConstants.minPupilRatio
|| leftRatio > EyeTrackingConstants.maxPupilRatio)
&& (rightRatio < EyeTrackingConstants.minPupilRatio
|| rightRatio > EyeTrackingConstants.maxPupilRatio)
Text(bothEyesOut ? "Both Out ⚠️" : "In Range ✓")
.font(.caption2)
.foregroundStyle(bothEyesOut ? .orange : .green)
}
}
} else {
Text("Pupil data unavailable")
.font(.caption2)
.foregroundStyle(.secondary)
}
if let yaw = eyeTrackingService.debugYaw,
let pitch = eyeTrackingService.debugPitch
{
HStack(spacing: 16) {
VStack(alignment: .leading, spacing: 2) {
Text("Yaw: \(String(format: "%.3f", yaw))")
.font(.caption2)
.foregroundStyle(
!EyeTrackingConstants.yawEnabled
? .secondary
: abs(yaw) > EyeTrackingConstants.yawThreshold
? Color.orange : Color.green
)
Text("Pitch: \(String(format: "%.3f", pitch))")
.font(.caption2)
.foregroundStyle(
!EyeTrackingConstants.pitchUpEnabled
&& !EyeTrackingConstants.pitchDownEnabled
? .secondary
: (pitch > EyeTrackingConstants.pitchUpThreshold
|| pitch < EyeTrackingConstants.pitchDownThreshold)
? Color.orange : Color.green
)
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(
"Yaw Max: \(String(format: "%.2f", EyeTrackingConstants.yawThreshold))"
)
.font(.caption2)
.foregroundStyle(.secondary)
Text(
"Pitch: \(String(format: "%.2f", EyeTrackingConstants.pitchDownThreshold)) to \(String(format: "%.2f", EyeTrackingConstants.pitchUpThreshold))"
)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
}
.padding(.top, 4)
if showAdvancedSettings {
VStack(spacing: 16) {
// Display the current constant values
VStack(alignment: .leading, spacing: 8) {
Text("Current Threshold Values:")
.font(.caption)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
HStack {
Text("Yaw Threshold:")
Spacer()
Text("\(String(format: "%.2f", EyeTrackingConstants.yawThreshold)) rad")
.foregroundStyle(.secondary)
}
HStack {
Text("Pitch Up Threshold:")
Spacer()
Text(
"\(String(format: "%.2f", EyeTrackingConstants.pitchUpThreshold)) rad"
)
.foregroundStyle(.secondary)
}
HStack {
Text("Pitch Down Threshold:")
Spacer()
Text(
"\(String(format: "%.2f", EyeTrackingConstants.pitchDownThreshold)) rad"
)
.foregroundStyle(.secondary)
}
HStack {
Text("Min Pupil Ratio:")
Spacer()
Text("\(String(format: "%.2f", EyeTrackingConstants.minPupilRatio))")
.foregroundStyle(.secondary)
}
HStack {
Text("Max Pupil Ratio:")
Spacer()
Text("\(String(format: "%.2f", EyeTrackingConstants.maxPupilRatio))")
.foregroundStyle(.secondary)
}
HStack {
Text("Eye Closed Threshold:")
Spacer()
Text(
"\(String(format: "%.3f", EyeTrackingConstants.eyeClosedThreshold))"
)
.foregroundStyle(.secondary)
}
}
.padding(.top, 8)
}
.padding(.top, 8)
}
}
.padding()
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
}
private var debugEyeTrackingView: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Debug Eye Tracking Data")
.font(.headline)
.foregroundStyle(.blue)
VStack(alignment: .leading, spacing: 8) {
Text("Face Detected: \(eyeTrackingService.faceDetected ? "Yes" : "No")")
.font(.caption)
Text("Looking at Screen: \(eyeTrackingService.userLookingAtScreen ? "Yes" : "No")")
.font(.caption)
Text("Eyes Closed: \(eyeTrackingService.isEyesClosed ? "Yes" : "No")")
.font(.caption)
if eyeTrackingService.faceDetected {
Text("Yaw: 0.0")
.font(.caption)
Text("Roll: 0.0")
.font(.caption)
}
}
.font(.caption)
.foregroundStyle(.secondary)
}
.padding()
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
}
}
#Preview {

View File

@@ -54,7 +54,6 @@ struct LookAwaySetupView: View {
private func previewLookAway() {
guard let screen = NSScreen.main else { return }
let sizePercentage = settingsManager.settings.subtleReminderSize.percentage
let lookAwayIntervalMinutes = settingsManager.settings.lookAwayIntervalMinutes
PreviewWindowHelper.showPreview(on: screen) { dismiss in
LookAwayReminderView(countdownSeconds: lookAwayIntervalMinutes * 60, onDismiss: dismiss)

View File

@@ -9,201 +9,23 @@ import SwiftUI
struct SmartModeSetupView: View {
@Bindable var settingsManager: SettingsManager
@State private var permissionManager = ScreenCapturePermissionManager.shared
var body: some View {
VStack(spacing: 0) {
SetupHeader(icon: "brain.fill", title: "Smart Mode", color: .purple)
Text("Automatically manage timers based on your activity")
.font(.subheadline)
.foregroundStyle(.secondary)
.padding(.bottom, 30)
SmartModeSetupContent(
settingsManager: settingsManager,
presentation: .window
)
.padding(.top, 24)
Spacer()
VStack(spacing: 24) {
fullscreenSection
idleSection
#if DEBUG
usageTrackingSection
#endif
}
.frame(maxWidth: 600)
Spacer()
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
.background(.clear)
}
private var fullscreenSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: "arrow.up.left.and.arrow.down.right")
.foregroundStyle(.blue)
Text("Auto-pause on Fullscreen")
.font(.headline)
}
Text(
"Timers will automatically pause when you enter fullscreen mode (videos, games, presentations)"
)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Toggle("", isOn: $settingsManager.settings.smartMode.autoPauseOnFullscreen)
.labelsHidden()
.onChange(of: settingsManager.settings.smartMode.autoPauseOnFullscreen) {
_, newValue in
if newValue {
permissionManager.requestAuthorizationIfNeeded()
}
}
}
if settingsManager.settings.smartMode.autoPauseOnFullscreen,
permissionManager.authorizationStatus != .authorized
{
permissionWarningView
}
}
.padding()
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 8))
}
private var permissionWarningView: some View {
VStack(alignment: .leading, spacing: 8) {
Label(
permissionManager.authorizationStatus == .denied
? "Screen Recording permission required"
: "Grant Screen Recording access",
systemImage: "exclamationmark.shield"
)
.foregroundStyle(.orange)
Text("macOS requires Screen Recording permission to detect other apps in fullscreen.")
.font(.caption)
.foregroundStyle(.secondary)
HStack {
Button("Grant Access") {
permissionManager.requestAuthorizationIfNeeded()
permissionManager.openSystemSettings()
}
.buttonStyle(.bordered)
Button("Open Settings") {
permissionManager.openSystemSettings()
}
.buttonStyle(.borderless)
}
.font(.caption)
.padding(.top, 4)
}
.padding(.top, 8)
}
private var idleSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: "moon.zzz.fill")
.foregroundStyle(.indigo)
Text("Auto-pause on Idle")
.font(.headline)
}
Text("Timers will pause when you're inactive for more than the threshold below")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Toggle("", isOn: $settingsManager.settings.smartMode.autoPauseOnIdle)
.labelsHidden()
}
if settingsManager.settings.smartMode.autoPauseOnIdle {
ThresholdSlider(
label: "Idle Threshold:",
value: $settingsManager.settings.smartMode.idleThresholdMinutes,
range: 1...30,
unit: "min"
)
}
}
.padding()
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 8))
}
private var usageTrackingSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: "chart.line.uptrend.xyaxis")
.foregroundStyle(.green)
Text("Track Usage Statistics")
.font(.headline)
}
Text(
"Monitor active and idle time, with automatic reset after the specified duration"
)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Toggle("", isOn: $settingsManager.settings.smartMode.trackUsage)
.labelsHidden()
}
if settingsManager.settings.smartMode.trackUsage {
ThresholdSlider(
label: "Reset After:",
value: $settingsManager.settings.smartMode.usageResetAfterMinutes,
range: 15...240,
step: 15,
unit: "min"
)
}
}
.padding()
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 8))
}
}
struct ThresholdSlider: View {
let label: String
@Binding var value: Int
let range: ClosedRange<Int>
var step: Int = 1
let unit: String
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(label)
.font(.subheadline)
Spacer()
Text("\(value) \(unit)")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Slider(
value: Binding(
get: { Double(value) },
set: { value = Int($0) }
),
in: Double(range.lowerBound)...Double(range.upperBound),
step: Double(step)
)
}
.padding(.top, 8)
}
}
#Preview {

View File

@@ -2,6 +2,17 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>Gaze</title>
<item>
<title>0.5.0</title>
<pubDate>Fri, 30 Jan 2026 12:58:57 -0500</pubDate>
<sparkle:version>10</sparkle:version>
<sparkle:shortVersionString>0.5.0</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>14.6</sparkle:minimumSystemVersion>
<enclosure url="https://freno.me/api/downloads/Gaze-0.5.0.dmg" length="5253164" type="application/octet-stream" sparkle:edSignature="eXTeHXkMiAO4O1drqvdeYYn6oY9bpilm4toHNZ5BGvWVeNOtwzFC9YOWb+abPEYDRMmu5oodbPBFyPE65w6BDg=="/>
<sparkle:deltas>
<enclosure url="https://freno.me/api/downloads/Gaze10-9.delta" sparkle:deltaFrom="9" length="721382" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="860560" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,uk" sparkle:edSignature="kO4XClM9nikVhnhX4QypN5zFIikIhVdnVcOQDsvL8ciBD6opGM/IH7pqnCRKRwP6N8SRobgAQTHsee6oOZyWCA=="/>
</sparkle:deltas>
</item>
<item>
<title>0.4.1</title>
<pubDate>Tue, 13 Jan 2026 17:27:46 -0500</pubDate>
@@ -62,13 +73,5 @@
<enclosure url="https://freno.me/api/downloads/Gaze2-1.delta" sparkle:deltaFrom="1" length="94254" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="858560" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,uk" sparkle:edSignature="qfxSfqD9iVJ7GVL19V8T4OuOTz0ZgqJNceBH6W+dwoKel1R+BTPkU9Ia8xR12v07GoXkyyqc+ba79OOL7jIpBw=="/>
</sparkle:deltas>
</item>
<item>
<title>0.1.1</title>
<pubDate>Sun, 11 Jan 2026 18:07:02 -0500</pubDate>
<sparkle:version>1</sparkle:version>
<sparkle:shortVersionString>0.2.0</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>14.6</sparkle:minimumSystemVersion>
<enclosure url="https://freno.me/api/downloads/Gaze-0.2.0.dmg" length="4831161" type="application/octet-stream" sparkle:edSignature="zCEmiiO4Q7HV7uGbI/CQcfJElm1uqrYorznE6uCWaKm/Zg1bUrWaeTRf9+Uv9f9+0iptyiS2FNdglLQB8RKkCA=="/>
</item>
</channel>
</rss>