diff --git a/BusyMirror.xcodeproj/project.pbxproj b/BusyMirror.xcodeproj/project.pbxproj new file mode 100644 index 0000000..75ed77b --- /dev/null +++ b/BusyMirror.xcodeproj/project.pbxproj @@ -0,0 +1,569 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXContainerItemProxy section */ + 37FF488E2E58682D00DDCE6A /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 37FF48772E58682A00DDCE6A /* Project object */; + proxyType = 1; + remoteGlobalIDString = 37FF487E2E58682A00DDCE6A; + remoteInfo = BusyMirror; + }; + 37FF48982E58682D00DDCE6A /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 37FF48772E58682A00DDCE6A /* Project object */; + proxyType = 1; + remoteGlobalIDString = 37FF487E2E58682A00DDCE6A; + remoteInfo = BusyMirror; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 37FF487F2E58682A00DDCE6A /* BusyMirror.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BusyMirror.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 37FF488D2E58682D00DDCE6A /* BusyMirrorTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BusyMirrorTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 37FF48972E58682D00DDCE6A /* BusyMirrorUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BusyMirrorUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 37FF48AB2E586A0700DDCE6A /* Exceptions for "BusyMirror" folder in "BusyMirror" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 37FF487E2E58682A00DDCE6A /* BusyMirror */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 37FF48812E58682A00DDCE6A /* BusyMirror */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 37FF48AB2E586A0700DDCE6A /* Exceptions for "BusyMirror" folder in "BusyMirror" target */, + ); + path = BusyMirror; + sourceTree = ""; + }; + 37FF48902E58682D00DDCE6A /* BusyMirrorTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = BusyMirrorTests; + sourceTree = ""; + }; + 37FF489A2E58682D00DDCE6A /* BusyMirrorUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = BusyMirrorUITests; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 37FF487C2E58682A00DDCE6A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 37FF488A2E58682D00DDCE6A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 37FF48942E58682D00DDCE6A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 37FF48762E58682A00DDCE6A = { + isa = PBXGroup; + children = ( + 37FF48812E58682A00DDCE6A /* BusyMirror */, + 37FF48902E58682D00DDCE6A /* BusyMirrorTests */, + 37FF489A2E58682D00DDCE6A /* BusyMirrorUITests */, + 37FF48802E58682A00DDCE6A /* Products */, + ); + sourceTree = ""; + }; + 37FF48802E58682A00DDCE6A /* Products */ = { + isa = PBXGroup; + children = ( + 37FF487F2E58682A00DDCE6A /* BusyMirror.app */, + 37FF488D2E58682D00DDCE6A /* BusyMirrorTests.xctest */, + 37FF48972E58682D00DDCE6A /* BusyMirrorUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 37FF487E2E58682A00DDCE6A /* BusyMirror */ = { + isa = PBXNativeTarget; + buildConfigurationList = 37FF48A12E58682D00DDCE6A /* Build configuration list for PBXNativeTarget "BusyMirror" */; + buildPhases = ( + 37FF487B2E58682A00DDCE6A /* Sources */, + 37FF487C2E58682A00DDCE6A /* Frameworks */, + 37FF487D2E58682A00DDCE6A /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 37FF48812E58682A00DDCE6A /* BusyMirror */, + ); + name = BusyMirror; + packageProductDependencies = ( + ); + productName = BusyMirror; + productReference = 37FF487F2E58682A00DDCE6A /* BusyMirror.app */; + productType = "com.apple.product-type.application"; + }; + 37FF488C2E58682D00DDCE6A /* BusyMirrorTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 37FF48A42E58682D00DDCE6A /* Build configuration list for PBXNativeTarget "BusyMirrorTests" */; + buildPhases = ( + 37FF48892E58682D00DDCE6A /* Sources */, + 37FF488A2E58682D00DDCE6A /* Frameworks */, + 37FF488B2E58682D00DDCE6A /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 37FF488F2E58682D00DDCE6A /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 37FF48902E58682D00DDCE6A /* BusyMirrorTests */, + ); + name = BusyMirrorTests; + packageProductDependencies = ( + ); + productName = BusyMirrorTests; + productReference = 37FF488D2E58682D00DDCE6A /* BusyMirrorTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 37FF48962E58682D00DDCE6A /* BusyMirrorUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 37FF48A72E58682D00DDCE6A /* Build configuration list for PBXNativeTarget "BusyMirrorUITests" */; + buildPhases = ( + 37FF48932E58682D00DDCE6A /* Sources */, + 37FF48942E58682D00DDCE6A /* Frameworks */, + 37FF48952E58682D00DDCE6A /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 37FF48992E58682D00DDCE6A /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 37FF489A2E58682D00DDCE6A /* BusyMirrorUITests */, + ); + name = BusyMirrorUITests; + packageProductDependencies = ( + ); + productName = BusyMirrorUITests; + productReference = 37FF48972E58682D00DDCE6A /* BusyMirrorUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 37FF48772E58682A00DDCE6A /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1640; + LastUpgradeCheck = 1640; + TargetAttributes = { + 37FF487E2E58682A00DDCE6A = { + CreatedOnToolsVersion = 16.4; + }; + 37FF488C2E58682D00DDCE6A = { + CreatedOnToolsVersion = 16.4; + TestTargetID = 37FF487E2E58682A00DDCE6A; + }; + 37FF48962E58682D00DDCE6A = { + CreatedOnToolsVersion = 16.4; + TestTargetID = 37FF487E2E58682A00DDCE6A; + }; + }; + }; + buildConfigurationList = 37FF487A2E58682A00DDCE6A /* Build configuration list for PBXProject "BusyMirror" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 37FF48762E58682A00DDCE6A; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 37FF48802E58682A00DDCE6A /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 37FF487E2E58682A00DDCE6A /* BusyMirror */, + 37FF488C2E58682D00DDCE6A /* BusyMirrorTests */, + 37FF48962E58682D00DDCE6A /* BusyMirrorUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 37FF487D2E58682A00DDCE6A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 37FF488B2E58682D00DDCE6A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 37FF48952E58682D00DDCE6A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 37FF487B2E58682A00DDCE6A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 37FF48892E58682D00DDCE6A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 37FF48932E58682D00DDCE6A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 37FF488F2E58682D00DDCE6A /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 37FF487E2E58682A00DDCE6A /* BusyMirror */; + targetProxy = 37FF488E2E58682D00DDCE6A /* PBXContainerItemProxy */; + }; + 37FF48992E58682D00DDCE6A /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 37FF487E2E58682A00DDCE6A /* BusyMirror */; + targetProxy = 37FF48982E58682D00DDCE6A /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 37FF489F2E58682D00DDCE6A /* 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; + 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 = 15.5; + 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; + }; + 37FF48A02E58682D00DDCE6A /* 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"; + 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 = 15.5; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + }; + name = Release; + }; + 37FF48A22E58682D00DDCE6A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; + CODE_SIGN_ENTITLEMENTS = BusyMirror/BusyMirror.entitlements; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = BusyMirror/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = BusyMirror; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 1.0.0; + PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 37FF48A32E58682D00DDCE6A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; + CODE_SIGN_ENTITLEMENTS = BusyMirror/BusyMirror.entitlements; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = BusyMirror/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = BusyMirror; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 1.0.0; + PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 37FF48A52E58682D00DDCE6A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 15.5; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirrorTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/BusyMirror.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/BusyMirror"; + }; + name = Debug; + }; + 37FF48A62E58682D00DDCE6A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 15.5; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirrorTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/BusyMirror.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/BusyMirror"; + }; + name = Release; + }; + 37FF48A82E58682D00DDCE6A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirrorUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TEST_TARGET_NAME = BusyMirror; + }; + name = Debug; + }; + 37FF48A92E58682D00DDCE6A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirrorUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TEST_TARGET_NAME = BusyMirror; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 37FF487A2E58682A00DDCE6A /* Build configuration list for PBXProject "BusyMirror" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 37FF489F2E58682D00DDCE6A /* Debug */, + 37FF48A02E58682D00DDCE6A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 37FF48A12E58682D00DDCE6A /* Build configuration list for PBXNativeTarget "BusyMirror" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 37FF48A22E58682D00DDCE6A /* Debug */, + 37FF48A32E58682D00DDCE6A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 37FF48A42E58682D00DDCE6A /* Build configuration list for PBXNativeTarget "BusyMirrorTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 37FF48A52E58682D00DDCE6A /* Debug */, + 37FF48A62E58682D00DDCE6A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 37FF48A72E58682D00DDCE6A /* Build configuration list for PBXNativeTarget "BusyMirrorUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 37FF48A82E58682D00DDCE6A /* Debug */, + 37FF48A92E58682D00DDCE6A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 37FF48772E58682A00DDCE6A /* Project object */; +} diff --git a/BusyMirror.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/BusyMirror.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/BusyMirror.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/BusyMirror/Assets.xcassets/AccentColor.colorset/Contents.json b/BusyMirror/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/BusyMirror/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/BusyMirror/Assets.xcassets/AppIcon.appiconset/Contents.json b/BusyMirror/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..3f00db4 --- /dev/null +++ b/BusyMirror/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,58 @@ +{ + "images" : [ + { + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/BusyMirror/Assets.xcassets/Contents.json b/BusyMirror/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/BusyMirror/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/BusyMirror/BusyMirror.entitlements b/BusyMirror/BusyMirror.entitlements new file mode 100644 index 0000000..59f1bdb --- /dev/null +++ b/BusyMirror/BusyMirror.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-only + + com.apple.security.personal-information.calendars + + + diff --git a/BusyMirror/BusyMirrorApp.swift b/BusyMirror/BusyMirrorApp.swift new file mode 100644 index 0000000..84b8daa --- /dev/null +++ b/BusyMirror/BusyMirrorApp.swift @@ -0,0 +1,11 @@ +import SwiftUI + +@main +struct BusyMirrorApp: App { + var body: some Scene { + WindowGroup { + ContentView() + .frame(minWidth: 720, minHeight: 520) + } + } +} diff --git a/BusyMirror/ContentView.swift b/BusyMirror/ContentView.swift new file mode 100644 index 0000000..2219d2a --- /dev/null +++ b/BusyMirror/ContentView.swift @@ -0,0 +1,1059 @@ +import SwiftUI +import EventKit +import AppKit + +/// Placeholder title is configurable via state (see `placeholderTitle`) +private let SAME_TIME_TOL_MIN: Double = 5 +private let SKIP_ALL_DAY_DEFAULT = true + +enum OverlapMode: String, CaseIterable, Identifiable { + case allow, skipCovered, fillGaps + var id: String { rawValue } +} + +// Calendar label helper to disambiguate identical names +private func calLabel(_ cal: EKCalendar) -> String { + let src = cal.source.title + return src.isEmpty ? cal.title : "\(cal.title) — \(src)" +} + +// Calendar color helpers +private func calColor(_ cal: EKCalendar) -> Color { + #if os(macOS) + return Color(cal.cgColor ?? NSColor.systemGray.cgColor) + #else + return Color(cgColor: cal.cgColor ?? UIColor.systemGray.cgColor) + #endif +} + +@ViewBuilder +private func calChip(_ cal: EKCalendar) -> some View { + HStack(spacing: 6) { + Circle().fill(calColor(cal)).frame(width: 10, height: 10) + Text(calLabel(cal)) + } +} + +// Remove our prefix when building titles so it never doubles up +func stripPrefix(_ title: String?, prefix: String) -> String { + guard let t = title else { return "" } + if prefix.isEmpty { return t } + return t.hasPrefix(prefix) ? String(t.dropFirst(prefix.count)) : t +} + +// De-dup blocks by occurrence (preferred) or by time range +func uniqueBlocks(_ blocks: [Block], trackByID: Bool) -> [Block] { + var seen = Set() + var out: [Block] = [] + for b in blocks { + let key: String + if trackByID, let sid = b.srcEventID { + let occ = b.occurrence?.timeIntervalSince1970 ?? b.start.timeIntervalSince1970 + key = "id|\(sid)|\(occ)" + } else { + key = "t|\(b.start.timeIntervalSince1970)|\(b.end.timeIntervalSince1970)" + } + if seen.insert(key).inserted { out.append(b) } + } + return out +} + +// Parse mirror URL: mirror://||||| +private func parseMirrorURL(_ url: URL?) -> (srcEventID: String?, occ: Date?, start: Date?, end: Date?) { + guard let abs = url?.absoluteString, abs.hasPrefix("mirror://") else { return (nil, nil, nil, nil) } + let body = abs.dropFirst("mirror://".count) + let parts = body.split(separator: "|") + var srcID: String? = nil + var occDate: Date? = nil + var sDate: Date? = nil + var eDate: Date? = nil + if parts.count >= 3 { srcID = String(parts[2]) } + if parts.count >= 4, let ts = TimeInterval(parts[3]) { occDate = Date(timeIntervalSince1970: ts) } + if parts.count >= 6, let sTS = TimeInterval(parts[4]), let eTS = TimeInterval(parts[5]) { + sDate = Date(timeIntervalSince1970: sTS) + eDate = Date(timeIntervalSince1970: eTS) + } + return (srcID, occDate, sDate, eDate) +} + +// Recognize a mirrored placeholder even if URL is missing +private func isMirrorEvent(_ ev: EKEvent, prefix: String, placeholder: String) -> Bool { + if ev.url?.absoluteString.hasPrefix("mirror://") ?? false { return true } + let t = ev.title ?? "" + if !prefix.isEmpty && t.hasPrefix(prefix) { return true } + if t == placeholder || (!prefix.isEmpty && t == (prefix + placeholder)) { return true } + return false +} + +struct Block: Hashable { + let start: Date + let end: Date + let srcEventID: String? // for reschedule tracking + let label: String? // source title (for dry-run / non-private) + let notes: String? // source notes (for optional copy) + let occurrence: Date? // occurrenceDate for recurring instances +} + +struct Route: Identifiable, Hashable { + let id = UUID() + var sourceID: String + var targetIDs: Set + var privacy: Bool // true = hide details for this source + var copyNotes: Bool // copy description when privacy is OFF + var mergeGapHours: Int // per-route merge gap (hours) + var overlap: OverlapMode // per-route overlap behavior + var allDay: Bool // per-route mirror all-day +} + +struct ContentView: View { + @State private var store = EKEventStore() + @State private var hasAccess = false + @State private var calendars: [EKCalendar] = [] + @State private var sourceIndex: Int = 0 + @State private var targetSelections = Set() // indices in calendars + // Stable selection storage by persistent identifiers (survives reordering) + @State private var sourceID: String? = nil + @State private var targetIDs = Set() + @State private var routes: [Route] = [] + @State private var daysForward: Int = 7 + @State private var daysBack: Int = 1 + @State private var mergeGapMin: Int = 0 + @State private var mergeGapHours: Int = 0 + @State private var hideDetails = true // Privacy ON by default -> use "Busy" + @State private var copyDescription = false // Only applies when hideDetails == false + @State private var overlapMode: OverlapMode = .allow + @State private var mirrorAllDay = false + @State private var writeEnabled = false // dry-run unless checked + @State private var logText = "Ready." + @State private var isRunning = false + @State private var isCLIRun = false + @State private var confirmCleanup = false + // Run-session guard: prevents the same source event from being mirrored + // into the same target more than once across multiple routes within a + // single "Mirror Now" click. + @State private var sessionGuard = Set() + @State private var titlePrefix: String = "🪞 " // global title prefix for mirrored placeholders + @State private var placeholderTitle: String = "Busy" // global customizable placeholder title + @State private var autoDeleteMissing: Bool = true // delete mirrors whose source instance no longer exists + + private static let intFormatter: NumberFormatter = { + let f = NumberFormatter() + f.numberStyle = .none + f.minimum = 0 + f.maximum = 720 + return f + }() + + // Deterministic ordering to keep indices stable across runs + private func sortedCalendars(_ cals: [EKCalendar]) -> [EKCalendar] { + return cals.sorted { a, b in + if a.source.title != b.source.title { return a.source.title < b.source.title } + if a.title != b.title { return a.title < b.title } + return a.calendarIdentifier < b.calendarIdentifier + } + } + + private func rebuildSelectionsFromIDs() { + // Map IDs -> indices in current calendars + var idToIndex: [String:Int] = [:] + for (i, c) in calendars.enumerated() { idToIndex[c.calendarIdentifier] = i } + // Restore source index from sourceID if possible + if let sid = sourceID, let idx = idToIndex[sid] { sourceIndex = idx } + else if !calendars.isEmpty { sourceIndex = min(sourceIndex, calendars.count - 1); sourceID = calendars[sourceIndex].calendarIdentifier } + // Restore targets from IDs + let restored = targetIDs.compactMap { idToIndex[$0] } + targetSelections = Set(restored).filter { $0 != sourceIndex } + } + + private func indexForCalendar(id: String) -> Int? { calendars.firstIndex(where: { $0.calendarIdentifier == id }) } + private func labelForCalendar(id: String) -> String { calendars.first(where: { $0.calendarIdentifier == id }).map(calLabel) ?? id } + + + // Ensure the currently selected source calendar is not present in targets + private func enforceNoSourceInTargets() { + guard sourceIndex < calendars.count else { return } + let sid = calendars[sourceIndex].calendarIdentifier + // Remove by index + if targetSelections.contains(sourceIndex) { + targetSelections.remove(sourceIndex) + } + // Remove by ID (in case indices shifted) + targetIDs.remove(sid) + } + + // MARK: - Extracted UI sections to simplify type-checking + @ViewBuilder + private func calendarsSection() -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("Source calendar") + Picker("Source", selection: $sourceIndex) { + ForEach(Array(calendars.indices), id: \.self) { i in + Text("\(i+1): \(calLabel(calendars[i]))").tag(i) + } + } + .pickerStyle(.menu) + .frame(maxWidth: 360) + .disabled(isRunning || calendars.isEmpty) + + Text("Target calendars (check one or more)") + ScrollView { + VStack(alignment: .leading, spacing: 6) { + ForEach(Array(calendars.indices), id: \.self) { i in + let isSource = (i == sourceIndex) + let binding = Binding( + get: { !isSource && targetSelections.contains(i) }, + set: { newValue in + // Never allow selecting the source as a target + if isSource { return } + if newValue { + targetSelections.insert(i) + targetIDs.insert(calendars[i].calendarIdentifier) + } else { + targetSelections.remove(i) + targetIDs.remove(calendars[i].calendarIdentifier) + } + } + ) + Toggle(isOn: binding) { + HStack { Text("\(i+1):"); calChip(calendars[i]) } + } + .disabled(isRunning || isSource) + } + } + } + .frame(height: 180) + } + } + + @ViewBuilder + private func routesSection() -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Routes (multi-source)").font(.headline) + Spacer() + Button("Add from current selection") { + guard let sid = sourceID, !targetIDs.isEmpty else { return } + let r = Route(sourceID: sid, + targetIDs: targetIDs, + privacy: hideDetails, + copyNotes: copyDescription, + mergeGapHours: mergeGapHours, + overlap: overlapMode, + allDay: mirrorAllDay) + routes.append(r) + }.disabled(isRunning || calendars.isEmpty || targetIDs.isEmpty) + Button("Clear") { routes.removeAll() }.disabled(isRunning || routes.isEmpty) + } + if routes.isEmpty { + Text("No routes yet. Pick a Source and Targets above, then click ‘Add from current selection’.") + .foregroundStyle(.secondary) + } else { + ForEach(Array(routes.enumerated()), id: \.element.id) { idx, route in + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text("Source:") + if let sCal = calendars.first(where: { $0.calendarIdentifier == route.sourceID }) { + HStack(spacing: 6) { + Circle().fill(calColor(sCal)).frame(width: 10, height: 10) + Text(calLabel(sCal)).bold() + } + } else { + Text(labelForCalendar(id: route.sourceID)).bold() + } + Text("→ Targets:") + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + ForEach(route.targetIDs.sorted(by: <), id: \.self) { tid in + if let tCal = calendars.first(where: { $0.calendarIdentifier == tid }) { + HStack(spacing: 6) { + Circle().fill(calColor(tCal)).frame(width: 10, height: 10) + Text(calLabel(tCal)) + } + } else { + Text(labelForCalendar(id: tid)) + } + } + } + } + .frame(maxWidth: 420) + Spacer() + Toggle("Private", isOn: Binding(get: { routes[idx].privacy }, set: { routes[idx].privacy = $0 })) + .help("If ON, mirror as ‘\(titlePrefix)\(placeholderTitle)’ with no notes. If OFF, mirror source title (and optionally notes).") + Text("·").foregroundStyle(.secondary) + HStack(spacing: 6) { + Text("Merge gap:") + TextField("0", value: Binding( + get: { routes[idx].mergeGapHours }, + set: { routes[idx].mergeGapHours = max(0, $0) } + ), formatter: Self.intFormatter) + .frame(width: 48) + .disabled(isRunning) + .help("Merge adjacent source events separated by ≤ this many hours (e.g., flight legs). 0 = no merge.") + Text("h").foregroundStyle(.secondary) + } + Toggle("Copy desc", isOn: Binding( + get: { routes[idx].copyNotes }, + set: { routes[idx].copyNotes = $0 } + )) + .disabled(isRunning || routes[idx].privacy) + .help("If ON and Private is OFF, copy the source event’s notes/description into the placeholder.") + HStack(spacing: 6) { + Text("Overlap:") + Picker("Overlap", selection: Binding( + get: { routes[idx].overlap }, + set: { routes[idx].overlap = $0 } + )) { + ForEach(OverlapMode.allCases) { m in Text(m.rawValue).tag(m) } + } + .frame(width: 160) + .help("allow = always place; skipCovered = skip if target already has a block covering the time; fillGaps = only fill uncovered gaps within the source block.") + } + .disabled(isRunning) + Toggle("All‑day", isOn: Binding( + get: { routes[idx].allDay }, + set: { routes[idx].allDay = $0 } + )) + .disabled(isRunning) + .help("Mirror all‑day events for this source.") + Text("·").foregroundStyle(.secondary) + Button(role: .destructive) { routes.remove(at: idx) } label: { Text("Remove") } + } + } + .padding(6) + .background(.quaternary.opacity(0.1)) + .cornerRadius(6) + } + } + } + } + + @ViewBuilder + private func optionsSection() -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 12) { + Text("Days back:") + TextField("1", value: $daysBack, formatter: Self.intFormatter) + .frame(width: 60) + .disabled(isRunning) + Text("Days forward:") + TextField("7", value: $daysForward, formatter: Self.intFormatter) + .frame(width: 60) + .disabled(isRunning) + } + .onChange(of: daysBack) { _, v in daysBack = max(0, v) } + .onChange(of: daysForward) { _, v in daysForward = max(0, v) } + HStack(spacing: 8) { + Text("Default merge gap:") + TextField("0", value: $mergeGapHours, formatter: Self.intFormatter) + .frame(width: 60) + .disabled(isRunning) + Text("hours").foregroundStyle(.secondary) + } + .onChange(of: mergeGapHours) { _, newVal in + mergeGapMin = max(0, newVal * 60) + } + Toggle("Hide details (use \"Busy\" title)", isOn: $hideDetails) + .disabled(isRunning) + + Toggle("Copy description when mirroring", isOn: $copyDescription) + .disabled(isRunning || hideDetails) + Toggle("Mirror all-day events", isOn: $mirrorAllDay) + .disabled(isRunning) + Picker("Overlap mode", selection: $overlapMode) { + ForEach(OverlapMode.allCases) { mode in + Text(mode.rawValue).tag(mode) + } + } + .pickerStyle(.segmented) + .disabled(isRunning) + + HStack(spacing: 8) { + Text("Title prefix:") + TextField("🪞 ", text: $titlePrefix) + .frame(width: 72) + .disabled(isRunning) + Text("(left blank for none)").foregroundStyle(.secondary) + } + // Insert placeholder title UI + HStack(spacing: 8) { + Text("Placeholder title:") + TextField("Busy", text: $placeholderTitle) + .frame(width: 160) + .disabled(isRunning) + } + + Toggle("Write to calendars (disable for Dry-Run)", isOn: $writeEnabled) + .disabled(isRunning) + + // Insert auto-delete toggle after writeEnabled + Toggle("Auto-delete mirrors if source is removed", isOn: $autoDeleteMissing) + .disabled(isRunning) + + HStack { + Button(isRunning ? "Running…" : "Mirror Now") { + Task { + // New click -> reset the guard so we don't re-process + sessionGuard.removeAll() + if routes.isEmpty { + await runMirror() + } else { + for r in routes { + // Resolve source index and target IDs for this route + if let sIdx = indexForCalendar(id: r.sourceID) { + sourceIndex = sIdx + sourceID = r.sourceID + targetIDs = r.targetIDs + // Belt-and-suspenders: ensure source is not in targets even if UI state is stale + targetIDs.remove(r.sourceID) + // Save globals + let prevPrivacy = hideDetails + let prevCopy = copyDescription + let prevGapH = mergeGapHours + let prevGapM = mergeGapMin + let prevOverlap = overlapMode + let prevAllDay = mirrorAllDay + // Apply per-route + hideDetails = r.privacy + copyDescription = r.copyNotes + mergeGapHours = max(0, r.mergeGapHours) + mergeGapMin = mergeGapHours * 60 + overlapMode = r.overlap + mirrorAllDay = r.allDay + await runMirror() + // Restore globals + hideDetails = prevPrivacy + copyDescription = prevCopy + mergeGapHours = prevGapH + mergeGapMin = prevGapM + overlapMode = prevOverlap + mirrorAllDay = prevAllDay + } + } + } + } + } + .disabled(isRunning || targetSelections.isEmpty || calendars.isEmpty) + + Button("Cleanup Placeholders") { + if writeEnabled { + // Real delete: ask for confirmation first + confirmCleanup = true + } else { + // Dry-run: run without confirmation + Task { + if routes.isEmpty { + await runCleanup() + } else { + for r in routes { + if let sIdx = indexForCalendar(id: r.sourceID) { + sourceIndex = sIdx + sourceID = r.sourceID + targetIDs = r.targetIDs + await runCleanup() + } + } + } + } + } + } + .disabled(isRunning) + + Button("Refresh Calendars") { + reloadCalendars() + } + .disabled(isRunning) + + Spacer() + } + } + } + + @ViewBuilder + private func logSection() -> some View { + Text("Log") + TextEditor(text: $logText) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 180) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(.quaternary)) + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("BusyMirror").font(.title2).bold() + Spacer() + Button(hasAccess ? "Recheck Permission" : "Request Calendar Access") { + requestAccess() + } + .disabled(isRunning) + } + Divider() + + if !hasAccess { + Text("Calendar access not granted yet. Click “Request Calendar Access”.") + .foregroundStyle(.secondary) + } else { + HStack(alignment: .top, spacing: 24) { + calendarsSection() + optionsSection() + } + routesSection() + logSection() + } + } + .padding(16) + .confirmationDialog( + "Delete mirrored placeholders?", + isPresented: $confirmCleanup, + titleVisibility: .visible + ) { + Button("Delete now", role: .destructive) { + Task { + if routes.isEmpty { + await runCleanup() + } else { + for r in routes { + if let sIdx = indexForCalendar(id: r.sourceID) { + sourceIndex = sIdx + sourceID = r.sourceID + targetIDs = r.targetIDs + await runCleanup() + } + } + } + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This will remove events identified as mirrored (by URL prefix or title prefix ‘\(titlePrefix)’) within the current window (Days back/forward) from the selected target calendars.") + } + .onAppear { + requestAccess() + mergeGapHours = mergeGapMin / 60 + tryRunCLIIfPresent() + enforceNoSourceInTargets() + } + .onChange(of: sourceIndex) { oldValue, newValue in + // Track selected source by persistent ID and ensure it is not a target + if newValue < calendars.count { sourceID = calendars[newValue].calendarIdentifier } + enforceNoSourceInTargets() + } + .onChange(of: targetSelections) { _, _ in + // If the new source is accidentally included, drop it + enforceNoSourceInTargets() + } + .onChange(of: targetIDs) { _, _ in + // If IDs contain the source’s ID, drop it + enforceNoSourceInTargets() + } + } + + // MARK: - CLI support + func tryRunCLIIfPresent() { + let args = CommandLine.arguments + guard let routesIdx = args.firstIndex(of: "--routes") else { return } + isCLIRun = true + + func boolArg(_ name: String, default def: Bool) -> Bool { + if let i = args.firstIndex(of: name), i+1 < args.count { + let v = args[i+1].lowercased() + return v == "1" || v == "true" || v == "yes" || v == "on" + } + return def + } + func intArg(_ name: String, default def: Int) -> Int { + if let i = args.firstIndex(of: name), i+1 < args.count, let n = Int(args[i+1]) { return n } + return def + } + func strArg(_ name: String) -> String? { + if let i = args.firstIndex(of: name), i+1 < args.count { return args[i+1] } + return nil + } + + // Configure options from CLI flags + hideDetails = boolArg("--privacy", default: hideDetails) + copyDescription = boolArg("--copy-notes", default: copyDescription) + writeEnabled = boolArg("--write", default: writeEnabled) + mirrorAllDay = boolArg("--all-day", default: mirrorAllDay) + daysForward = intArg("--days-forward", default: daysForward) + daysBack = intArg("--days-back", default: daysBack) + mergeGapHours = intArg("--merge-gap-hours", default: mergeGapHours) + mergeGapMin = max(0, mergeGapHours * 60) + if let modeStr = strArg("--mode")?.lowercased() { + switch modeStr { + case "allow": overlapMode = .allow + case "skipcovered", "skip": overlapMode = .skipCovered + case "fillgaps", "gaps": overlapMode = .fillGaps + default: break + } + } + + let routesSpec = (routesIdx+1 < args.count) ? args[routesIdx+1] : "" + let routeParts = routesSpec.split(separator: ";").map { $0.trimmingCharacters(in: .whitespaces) } + log("CLI: routes=\(routesSpec)") + Task { + // Wait up to ~10s for calendars to load + for _ in 0..<50 { + if hasAccess && !calendars.isEmpty { break } + try? await Task.sleep(nanoseconds: 200_000_000) + } + guard hasAccess, !calendars.isEmpty else { + log("CLI: no calendar access; aborting") + NSApp.terminate(nil) + return + } + + for part in routeParts where !part.isEmpty { + // Format: "S->T1,T2,T3" (indices are 1-based as shown in UI) + let lr = part.split(separator: "->", maxSplits: 1).map { String($0) } + guard lr.count == 2, let s1 = Int(lr[0].trimmingCharacters(in: .whitespaces)) else { continue } + let srcIdx0 = max(0, s1 - 1) + let tgtIdxs0: [Int] = lr[1].split(separator: ",").compactMap { Int($0.trimmingCharacters(in: .whitespaces))?.advanced(by: -1) }.filter { $0 >= 0 } + if srcIdx0 >= calendars.count { continue } + sourceIndex = srcIdx0 + sourceID = calendars[srcIdx0].calendarIdentifier + targetSelections = Set(tgtIdxs0) + targetIDs = Set(tgtIdxs0.compactMap { i in calendars.indices.contains(i) ? calendars[i].calendarIdentifier : nil }) + + if boolArg("--cleanup-only", default: false) { + log("CLI: cleanup route \(part)") + await runCleanup() + } else { + log("CLI: mirror route \(part)") + await runMirror() + } + } + if CommandLine.arguments.contains("--exit") || isCLIRun { + NSApp.terminate(nil) + } + } + } + + // MARK: - Permissions & Calendars + func requestAccess() { + log("Requesting calendar access…") + if #available(macOS 14.0, *) { + store.requestFullAccessToEvents { granted, _ in + DispatchQueue.main.async { + hasAccess = granted + if granted { reloadCalendars() } + log(granted ? "Access granted." : "Access denied.") + } + } + } else { + store.requestAccess(to: .event) { granted, _ in + DispatchQueue.main.async { + hasAccess = granted + if granted { reloadCalendars() } + log(granted ? "Access granted." : "Access denied.") + } + } + } + } + + func reloadCalendars() { + let fetched = store.calendars(for: .event) + calendars = sortedCalendars(fetched) + // Initialize IDs on first load + if sourceID == nil, let first = calendars.first { sourceID = first.calendarIdentifier } + // Rebuild index-based selections from stored IDs + rebuildSelectionsFromIDs() + log("Loaded \(calendars.count) calendars.") + } + + // MARK: - Mirror engine (EventKit) + func runMirror() async { + guard hasAccess, !calendars.isEmpty else { return } + isRunning = true + defer { isRunning = false } + + let srcCal = calendars[sourceIndex] + // Ensure sourceID is set when we start + sourceID = srcCal.calendarIdentifier + // Extra safety: drop source from targets before computing them + enforceNoSourceInTargets() + let srcName = calLabel(srcCal) + // Build targets by identifier to be robust against index/order changes + let targetSet = Set(targetIDs) + // Additional guard: log and strip if source sneaks into targets + if targetSet.contains(srcCal.calendarIdentifier) { log("- WARN: source is present in targets, removing: \(srcName)") } + let targetSetNoSrc = targetSet.subtracting([srcCal.calendarIdentifier]) + let targets = calendars.filter { targetSetNoSrc.contains($0.calendarIdentifier) } + + let cal = Calendar.current + let todayStart = cal.startOfDay(for: Date()) + let windowStart = cal.date(byAdding: .day, value: -daysBack, to: todayStart)! + let windowEnd = cal.date(byAdding: .day, value: daysForward, to: todayStart)! + log("=== BusyMirror ===") + log("Source: \(srcName) Targets: \(targets.map { calLabel($0) }.joined(separator: ", "))") + log("Window: \(windowStart) -> \(windowEnd)") + log("WRITE: \(writeEnabled) \(writeEnabled ? "" : "(DRY-RUN)") mode: \(overlapMode.rawValue) mergeGapMin: \(mergeGapMin) allDay: \(mirrorAllDay)") + log("Route: \(srcName) → {\(targets.map { calLabel($0) }.joined(separator: ", "))}") + + // Source events (recurrences expanded by EventKit) + let srcPred = store.predicateForEvents(withStart: windowStart, end: windowEnd, calendars: [srcCal]) + var srcEvents = store.events(matching: srcPred) + let srcFetched = srcEvents.count + // HARD FILTER: even if EventKit returns events from other calendars, keep only exact source calendar + srcEvents = srcEvents.filter { $0.calendar.calendarIdentifier == srcCal.calendarIdentifier } + let srcKept = srcEvents.count + if srcKept != srcFetched { + log("- WARN: filtered \(srcFetched - srcKept) stray source event(s) not in \(srcName)") + } + srcEvents.sort { ($0.startDate ?? .distantPast) < ($1.startDate ?? .distantPast) } + + var srcBlocks: [Block] = [] + var skippedMirrors = 0 + for ev in srcEvents { + if SKIP_ALL_DAY_DEFAULT == true && mirrorAllDay == false && ev.isAllDay { continue } + if isMirrorEvent(ev, prefix: titlePrefix, placeholder: placeholderTitle) { + // Aggregate skip count for mirrored-on-source + skippedMirrors += 1 + continue + } + guard let s = ev.startDate, let e = ev.endDate, e > s else { continue } + // Defensive: never treat events from another calendar as source + guard ev.calendar.calendarIdentifier == srcCal.calendarIdentifier else { continue } + let srcID = ev.eventIdentifier // stable across launches unless event is deleted + srcBlocks.append(Block(start: s, end: e, srcEventID: srcID, label: ev.title, notes: ev.notes, occurrence: ev.occurrenceDate)) + } + if skippedMirrors > 0 { + log("- SKIP mirrored-on-source: \(skippedMirrors) instance(s)") + } + // Deduplicate source blocks to avoid duplicates from EventKit (recurrences / sync races) + srcBlocks = uniqueBlocks(srcBlocks, trackByID: mergeGapMin == 0) + + // Merge for Flights or similar + let baseBlocks = (mergeGapMin > 0) ? mergeBlocks(srcBlocks, gapMinutes: mergeGapMin) : srcBlocks + let trackByID = (mergeGapMin == 0) + + for tgt in targets { + let tgtName = calLabel(tgt) + log(">>> Target: \(tgtName)") + if tgt.calendarIdentifier == srcCal.calendarIdentifier { + log("- SKIP target is same as source: \(tgtName)") + continue + } + // Prefetch target window + let tgtPred = store.predicateForEvents(withStart: windowStart, end: windowEnd, calendars: [tgt]) + var tgtEvents = store.events(matching: tgtPred) + let tgtFetched = tgtEvents.count + // HARD FILTER: ensure we only consider events truly on the target calendar + tgtEvents = tgtEvents.filter { $0.calendar.calendarIdentifier == tgt.calendarIdentifier } + if tgtFetched != tgtEvents.count { + log("- WARN: filtered \(tgtFetched - tgtEvents.count) stray target event(s) not in \(tgtName)") + } + + var placeholderSet = Set() + var occupied: [Block] = [] + var placeholdersByOccurrenceID: [String: EKEvent] = [:] + var placeholdersByTime: [String: EKEvent] = [:] + for tv in tgtEvents { + // Defensive: should already be filtered, but double-check target identity + guard tv.calendar.calendarIdentifier == tgt.calendarIdentifier else { continue } + if let ts = tv.startDate, let te = tv.endDate { + let timeKey = "\(ts.timeIntervalSince1970)|\(te.timeIntervalSince1970)" + if isMirrorEvent(tv, prefix: titlePrefix, placeholder: placeholderTitle) { + placeholderSet.insert(timeKey) + placeholdersByTime[timeKey] = tv + let parsed = parseMirrorURL(tv.url) + if let sid = parsed.srcEventID, let occ = parsed.occ { + let key = "\(sid)|\(occ.timeIntervalSince1970)" + placeholdersByOccurrenceID[key] = tv + } + } + occupied.append(Block(start: ts, end: te, srcEventID: nil, label: nil, notes: nil, occurrence: nil)) + } + } + occupied = coalesce(occupied) + + var created = 0 + var skipped = 0 + var updated = 0 + + // Cross-route loop guard: unique key generator for (source, occurrence/time, target) + func guardKey(for blk: Block, targetID: String) -> String { + if trackByID, let sid = blk.srcEventID { + let occ = blk.occurrence?.timeIntervalSince1970 ?? blk.start.timeIntervalSince1970 + return "\(srcCal.calendarIdentifier)|\(sid)|\(occ)|\(targetID)" + } else { + return "\(srcCal.calendarIdentifier)|\(blk.start.timeIntervalSince1970)|\(blk.end.timeIntervalSince1970)|\(targetID)" + } + } + + func createOrUpdateIfNeeded(_ blk: Block) { + // Cross-route loop guard: skip if this (source occurrence -> target) was handled earlier this click + let gKey = guardKey(for: blk, targetID: tgt.calendarIdentifier) + if sessionGuard.contains(gKey) { + skipped += 1 + log("- SKIP loop-guard [\(srcName) -> \(tgtName)] \(blk.start) -> \(blk.end)") + return + } + // Privacy-aware title/notes (strip our prefix so it never doubles up) + let baseSourceTitle = stripPrefix(blk.label, prefix: titlePrefix) + let effectiveTitle = hideDetails ? placeholderTitle : (baseSourceTitle.isEmpty ? placeholderTitle : baseSourceTitle) + let titleSuffix = hideDetails ? "" : (baseSourceTitle.isEmpty ? "" : " — \(baseSourceTitle)") + let displayTitle = (titlePrefix.isEmpty ? "" : titlePrefix) + effectiveTitle + + // Fallback 0: if an existing mirrored event has the exact same time, update it + let exactTimeKey = "\(blk.start.timeIntervalSince1970)|\(blk.end.timeIntervalSince1970)" + if let existingByTime = placeholdersByTime[exactTimeKey] { + if !writeEnabled { + sessionGuard.insert(gKey) + log("~ WOULD UPDATE [\(srcName) -> \(tgtName)] (by time) \(blk.start) -> \(blk.end) [title: \(displayTitle)]") + updated += 1 + return + } + existingByTime.title = displayTitle + existingByTime.startDate = blk.start + existingByTime.endDate = blk.end + existingByTime.isAllDay = false + if !hideDetails && copyDescription { + existingByTime.notes = blk.notes + } else { + existingByTime.notes = nil + } + let sid0 = blk.srcEventID ?? "" + let occ0 = blk.occurrence?.timeIntervalSince1970 ?? blk.start.timeIntervalSince1970 + existingByTime.url = URL(string: "mirror://\(tgt.calendarIdentifier)|\(srcCal.calendarIdentifier)|\(sid0)|\(occ0)|\(blk.start.timeIntervalSince1970)|\(blk.end.timeIntervalSince1970)") + do { + try store.save(existingByTime, span: .thisEvent, commit: true) + log("✓ UPDATED [\(srcName) -> \(tgtName)] (by time) \(blk.start) -> \(blk.end)") + placeholderSet.insert(exactTimeKey) + placeholdersByTime[exactTimeKey] = existingByTime + occupied = coalesce(occupied + [Block(start: blk.start, end: blk.end, srcEventID: nil, label: nil, notes: nil, occurrence: nil)]) + sessionGuard.insert(gKey) + updated += 1 + } catch { + log("Update failed: \(error.localizedDescription)") + } + return + } + + // If we can track by source event ID and we have one, prefer updating the existing placeholder + if trackByID, let sid = blk.srcEventID { + let occTS = blk.occurrence?.timeIntervalSince1970 ?? blk.start.timeIntervalSince1970 + let lookupKey = "\(sid)|\(occTS)" + if let existing = placeholdersByOccurrenceID[lookupKey] { + let curS = existing.startDate ?? blk.start + let curE = existing.endDate ?? blk.end + let needsUpdate = abs(curS.timeIntervalSince(blk.start)) > SAME_TIME_TOL_MIN*60 || abs(curE.timeIntervalSince(blk.end)) > SAME_TIME_TOL_MIN*60 + if !needsUpdate { + skipped += 1 + return + } + if !writeEnabled { + sessionGuard.insert(gKey) + log("~ WOULD UPDATE [\(srcName) -> \(tgtName)] \(curS) -> \(curE) TO \(blk.start) -> \(blk.end)\(titleSuffix) [title: \(displayTitle)]") + updated += 1 + return + } + existing.startDate = blk.start + existing.endDate = blk.end + existing.title = displayTitle + existing.isAllDay = false + if !hideDetails && copyDescription { + existing.notes = blk.notes + } else { + existing.notes = nil + } + existing.url = URL(string: "mirror://\(tgt.calendarIdentifier)|\(srcCal.calendarIdentifier)|\(sid)|\(occTS)|\(blk.start.timeIntervalSince1970)|\(blk.end.timeIntervalSince1970)") + do { + try store.save(existing, span: .thisEvent, commit: true) + log("✓ UPDATED [\(srcName) -> \(tgtName)] \(blk.start) -> \(blk.end)") + placeholderSet.insert("\(blk.start.timeIntervalSince1970)|\(blk.end.timeIntervalSince1970)") + let timeKey = "\(blk.start.timeIntervalSince1970)|\(blk.end.timeIntervalSince1970)" + placeholdersByTime[timeKey] = existing + occupied = coalesce(occupied + [Block(start: blk.start, end: blk.end, srcEventID: nil, label: nil, notes: nil, occurrence: nil)]) + placeholdersByOccurrenceID[lookupKey] = existing + sessionGuard.insert(gKey) + updated += 1 + } catch { + log("Update failed: \(error.localizedDescription)") + } + return + } + } + + // No existing placeholder for this block; dedupe by exact times + let k = "\(blk.start.timeIntervalSince1970)|\(blk.end.timeIntervalSince1970)" + if placeholderSet.contains(k) { skipped += 1; return } + if !writeEnabled { + sessionGuard.insert(gKey) + log("+ WOULD CREATE [\(srcName) -> \(tgtName)] \(blk.start) -> \(blk.end)\(titleSuffix) [title: \(displayTitle)]") + return + } + // Invariant: never write to the source calendar by mistake + guard tgt.calendarIdentifier != srcCal.calendarIdentifier else { skipped += 1; log("- SKIP invariant: target is source [\(srcName)]"); return } + let newEv = EKEvent(eventStore: store) + newEv.calendar = tgt + newEv.title = displayTitle + newEv.startDate = blk.start + newEv.endDate = blk.end + newEv.isAllDay = false + if !hideDetails && copyDescription { + newEv.notes = blk.notes + } + let sid = blk.srcEventID ?? "" + let occTS = blk.occurrence?.timeIntervalSince1970 ?? blk.start.timeIntervalSince1970 + newEv.url = URL(string: "mirror://\(tgt.calendarIdentifier)|\(srcCal.calendarIdentifier)|\(sid)|\(occTS)|\(blk.start.timeIntervalSince1970)|\(blk.end.timeIntervalSince1970)") + newEv.availability = .busy + do { + try store.save(newEv, span: .thisEvent, commit: true) + created += 1 + log("✓ CREATED [\(srcName) -> \(tgtName)] \(blk.start) -> \(blk.end)") + placeholderSet.insert(k) + occupied = coalesce(occupied + [Block(start: blk.start, end: blk.end, srcEventID: nil, label: nil, notes: nil, occurrence: nil)]) + if !sid.isEmpty { + let key = "\(sid)|\(occTS)" + placeholdersByOccurrenceID[key] = newEv + } + let timeKey = k + placeholdersByTime[timeKey] = newEv + sessionGuard.insert(gKey) + } catch { + log("Save failed: \(error.localizedDescription)") + } + } + + for b in baseBlocks { + switch overlapMode { + case .allow: + createOrUpdateIfNeeded(b) + case .skipCovered: + if fullyCovered(occupied, block: b, tolMin: SAME_TIME_TOL_MIN) { + log("- SKIP covered [\(srcName) -> \(tgtName)] \(b.start) -> \(b.end)") + skipped += 1 + } else { + createOrUpdateIfNeeded(b) + } + case .fillGaps: + let gaps = gapsWithin(occupied, in: b) + if gaps.isEmpty { + log("- SKIP no gaps [\(srcName) -> \(tgtName)] \(b.start) -> \(b.end)") + skipped += 1 + } else { + for g in gaps { createOrUpdateIfNeeded(g) } + } + } + } + log("[Summary → \(tgtName)] created=\(created), updated=\(updated), skipped=\(skipped)") + // Auto-delete placeholders whose source instance no longer exists + if autoDeleteMissing { + let validKeys: Set = Set(baseBlocks.compactMap { blk in + if (mergeGapMin == 0), let sid = blk.srcEventID { + let occTS = blk.occurrence?.timeIntervalSince1970 ?? blk.start.timeIntervalSince1970 + return "\(sid)|\(occTS)" + } + return nil + }) + if !validKeys.isEmpty { + var removed = 0 + for (_, ev) in placeholdersByOccurrenceID { + if ev.calendar.calendarIdentifier != tgt.calendarIdentifier { continue } + let parsed = parseMirrorURL(ev.url) + if let sid = parsed.srcEventID, let occ = parsed.occ { + let k = "\(sid)|\(occ.timeIntervalSince1970)" + if !validKeys.contains(k) { + if !writeEnabled { + log("~ WOULD DELETE (missing source) [\(srcName) -> \(tgtName)] \(ev.startDate ?? windowStart) -> \(ev.endDate ?? windowEnd)") + } else { + do { try store.remove(ev, span: .thisEvent, commit: true); removed += 1 } + catch { log("Delete failed: \(error.localizedDescription)") } + } + } + } + } + if removed > 0 { log("[Cleanup missing for \(tgtName)] deleted=\(removed)") } + } + } + } + } + + // MARK: - Logging + func log(_ s: String) { + logText.append("\n" + s) + } + + // MARK: - Block helpers + func key(_ b: Block) -> String { + "\(b.start.timeIntervalSince1970)|\(b.end.timeIntervalSince1970)" + } + func mergeBlocks(_ blocks: [Block], gapMinutes: Int) -> [Block] { + guard !blocks.isEmpty else { return [] } + let sorted = blocks.sorted { $0.start < $1.start } + var out: [Block] = [] + var cur = Block(start: sorted[0].start, end: sorted[0].end, srcEventID: nil, label: nil, notes: nil, occurrence: nil) + for b in sorted.dropFirst() { + let gap = b.start.timeIntervalSince(cur.end) / 60.0 + if gap <= Double(gapMinutes) { + if b.end > cur.end { cur = Block(start: cur.start, end: b.end, srcEventID: nil, label: nil, notes: nil, occurrence: nil) } + } else { + out.append(cur) + cur = Block(start: b.start, end: b.end, srcEventID: nil, label: nil, notes: nil, occurrence: nil) + } + } + out.append(cur) + return out + } + func coalesce(_ segs: [Block]) -> [Block] { mergeBlocks(segs, gapMinutes: 0) } + func fullyCovered(_ mergedSegs: [Block], block: Block, tolMin: Double) -> Bool { + for s in mergedSegs { + if s.start <= block.start.addingTimeInterval(tolMin*60), + s.end >= block.end.addingTimeInterval(-tolMin*60) { return true } + } + return false + } + func gapsWithin(_ mergedSegs: [Block], in block: Block) -> [Block] { + if mergedSegs.isEmpty { return [Block(start: block.start, end: block.end, srcEventID: nil, label: nil, notes: nil, occurrence: nil)] } + var segs: [Block] = [] + for s in mergedSegs where s.end > block.start && s.start < block.end { + let ss = max(s.start, block.start) + let ee = min(s.end, block.end) + if ee > ss { segs.append(Block(start: ss, end: ee, srcEventID: nil, label: nil, notes: nil, occurrence: nil)) } + } + if segs.isEmpty { return [Block(start: block.start, end: block.end, srcEventID: nil, label: nil, notes: nil, occurrence: nil)] } + let merged = coalesce(segs) + var gaps: [Block] = [] + var prevEnd = block.start + for s in merged { + if s.start > prevEnd { gaps.append(Block(start: prevEnd, end: s.start, srcEventID: nil, label: nil, notes: nil, occurrence: nil)) } + if s.end > prevEnd { prevEnd = s.end } + } + if prevEnd < block.end { gaps.append(Block(start: prevEnd, end: block.end, srcEventID: nil, label: nil, notes: nil, occurrence: nil)) } + return gaps + } + +// MARK: - Cleanup: delete Busy placeholders in the active window on selected targets + func runCleanup() async { + guard hasAccess, !calendars.isEmpty else { return } + isRunning = true + defer { isRunning = false } + + let cal = Calendar.current + let todayStart = cal.startOfDay(for: Date()) + let windowStart = cal.date(byAdding: .day, value: -daysBack, to: todayStart)! + let windowEnd = cal.date(byAdding: .day, value: daysForward, to: todayStart)! + let targetSet = Set(targetIDs) + let targets = calendars.filter { targetSet.contains($0.calendarIdentifier) && $0.calendarIdentifier != calendars[sourceIndex].calendarIdentifier } + log("=== Cleanup Busy placeholders in window ===") + log("(Cleanup is SAFE: mirrored events detected by url prefix or title prefix ‘\(titlePrefix)’)") + log("Window: \(windowStart) -> \(windowEnd)") + + for tgt in targets { + let tgtPred = store.predicateForEvents(withStart: windowStart, end: windowEnd, calendars: [tgt]) + let tgtEvents = store.events(matching: tgtPred) + var delCount = 0 + for ev in tgtEvents { + guard isMirrorEvent(ev, prefix: titlePrefix, placeholder: placeholderTitle) else { continue } + if !writeEnabled { + log("~ WOULD DELETE [\(tgt.title)] \(ev.startDate ?? todayStart) -> \(ev.endDate ?? todayStart)") + } else { + do { try store.remove(ev, span: .thisEvent, commit: true); delCount += 1 } + catch { log("Delete failed: \(error.localizedDescription)") } + } + } + log("[Cleanup \(tgt.title)] deleted=\(delCount)") + } +} +} diff --git a/BusyMirror/Info.plist b/BusyMirror/Info.plist new file mode 100644 index 0000000..eef3dde --- /dev/null +++ b/BusyMirror/Info.plist @@ -0,0 +1,10 @@ + + + + + NSCalendarsFullAccessUsageDescription + BusyMirror needs access to your calendars to create busy placeholders. + NSRemindersFullAccessUsageDescription + BusyMirror may also use Reminders in the future. + +