commit 31da35961ab4f5b7fae213ad5a25dd7a2bb3c0bd Author: Jan-Marlon Leibl Date: Sun Jan 19 04:43:22 2025 +0100 Initial commit diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..3d1759e Binary files /dev/null and b/.DS_Store differ diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..bf01c5d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "lldb.library": "/Applications/Xcode.app/Contents/SharedFrameworks/LLDB.framework/Versions/A/LLDB", + "lldb.launch.expressions": "native" +} \ No newline at end of file diff --git a/Ventry Upload Watcher.xcodeproj/project.pbxproj b/Ventry Upload Watcher.xcodeproj/project.pbxproj new file mode 100644 index 0000000..af95967 --- /dev/null +++ b/Ventry Upload Watcher.xcodeproj/project.pbxproj @@ -0,0 +1,560 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXContainerItemProxy section */ + 7A1D9AA42D3C1C26008898A6 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 7A1D9A8A2D3C1C25008898A6 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 7A1D9A912D3C1C25008898A6; + remoteInfo = "Ventry Upload Watcher"; + }; + 7A1D9AAE2D3C1C26008898A6 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 7A1D9A8A2D3C1C25008898A6 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 7A1D9A912D3C1C25008898A6; + remoteInfo = "Ventry Upload Watcher"; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 7A1D9A922D3C1C25008898A6 /* Ventry Upload Watcher.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Ventry Upload Watcher.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 7A1D9AA32D3C1C26008898A6 /* Ventry Upload WatcherTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Ventry Upload WatcherTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; + 7A1D9AAD2D3C1C26008898A6 /* Ventry Upload WatcherUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Ventry Upload WatcherUITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 7A1D9A942D3C1C25008898A6 /* Ventry Upload Watcher */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = "Ventry Upload Watcher"; + sourceTree = ""; + }; + 7A1D9AA62D3C1C26008898A6 /* Ventry Upload WatcherTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = "Ventry Upload WatcherTests"; + sourceTree = ""; + }; + 7A1D9AB02D3C1C26008898A6 /* Ventry Upload WatcherUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = "Ventry Upload WatcherUITests"; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 7A1D9A8F2D3C1C25008898A6 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7A1D9AA02D3C1C26008898A6 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7A1D9AAA2D3C1C26008898A6 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 7A1D9A892D3C1C25008898A6 = { + isa = PBXGroup; + children = ( + 7A1D9A942D3C1C25008898A6 /* Ventry Upload Watcher */, + 7A1D9AA62D3C1C26008898A6 /* Ventry Upload WatcherTests */, + 7A1D9AB02D3C1C26008898A6 /* Ventry Upload WatcherUITests */, + 7A1D9A932D3C1C25008898A6 /* Products */, + ); + sourceTree = ""; + }; + 7A1D9A932D3C1C25008898A6 /* Products */ = { + isa = PBXGroup; + children = ( + 7A1D9A922D3C1C25008898A6 /* Ventry Upload Watcher.app */, + 7A1D9AA32D3C1C26008898A6 /* Ventry Upload WatcherTests.xctest */, + 7A1D9AAD2D3C1C26008898A6 /* Ventry Upload WatcherUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 7A1D9A912D3C1C25008898A6 /* Ventry Upload Watcher */ = { + isa = PBXNativeTarget; + buildConfigurationList = 7A1D9AB72D3C1C26008898A6 /* Build configuration list for PBXNativeTarget "Ventry Upload Watcher" */; + buildPhases = ( + 7A1D9A8E2D3C1C25008898A6 /* Sources */, + 7A1D9A8F2D3C1C25008898A6 /* Frameworks */, + 7A1D9A902D3C1C25008898A6 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 7A1D9A942D3C1C25008898A6 /* Ventry Upload Watcher */, + ); + name = "Ventry Upload Watcher"; + packageProductDependencies = ( + ); + productName = "Ventry Upload Watcher"; + productReference = 7A1D9A922D3C1C25008898A6 /* Ventry Upload Watcher.app */; + productType = "com.apple.product-type.application"; + }; + 7A1D9AA22D3C1C26008898A6 /* Ventry Upload WatcherTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 7A1D9ABA2D3C1C26008898A6 /* Build configuration list for PBXNativeTarget "Ventry Upload WatcherTests" */; + buildPhases = ( + 7A1D9A9F2D3C1C26008898A6 /* Sources */, + 7A1D9AA02D3C1C26008898A6 /* Frameworks */, + 7A1D9AA12D3C1C26008898A6 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 7A1D9AA52D3C1C26008898A6 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 7A1D9AA62D3C1C26008898A6 /* Ventry Upload WatcherTests */, + ); + name = "Ventry Upload WatcherTests"; + packageProductDependencies = ( + ); + productName = "Ventry Upload WatcherTests"; + productReference = 7A1D9AA32D3C1C26008898A6 /* Ventry Upload WatcherTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 7A1D9AAC2D3C1C26008898A6 /* Ventry Upload WatcherUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 7A1D9ABD2D3C1C26008898A6 /* Build configuration list for PBXNativeTarget "Ventry Upload WatcherUITests" */; + buildPhases = ( + 7A1D9AA92D3C1C26008898A6 /* Sources */, + 7A1D9AAA2D3C1C26008898A6 /* Frameworks */, + 7A1D9AAB2D3C1C26008898A6 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 7A1D9AAF2D3C1C26008898A6 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 7A1D9AB02D3C1C26008898A6 /* Ventry Upload WatcherUITests */, + ); + name = "Ventry Upload WatcherUITests"; + packageProductDependencies = ( + ); + productName = "Ventry Upload WatcherUITests"; + productReference = 7A1D9AAD2D3C1C26008898A6 /* Ventry Upload WatcherUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 7A1D9A8A2D3C1C25008898A6 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1620; + LastUpgradeCheck = 1620; + TargetAttributes = { + 7A1D9A912D3C1C25008898A6 = { + CreatedOnToolsVersion = 16.2; + }; + 7A1D9AA22D3C1C26008898A6 = { + CreatedOnToolsVersion = 16.2; + TestTargetID = 7A1D9A912D3C1C25008898A6; + }; + 7A1D9AAC2D3C1C26008898A6 = { + CreatedOnToolsVersion = 16.2; + TestTargetID = 7A1D9A912D3C1C25008898A6; + }; + }; + }; + buildConfigurationList = 7A1D9A8D2D3C1C25008898A6 /* Build configuration list for PBXProject "Ventry Upload Watcher" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 7A1D9A892D3C1C25008898A6; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 7A1D9A932D3C1C25008898A6 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 7A1D9A912D3C1C25008898A6 /* Ventry Upload Watcher */, + 7A1D9AA22D3C1C26008898A6 /* Ventry Upload WatcherTests */, + 7A1D9AAC2D3C1C26008898A6 /* Ventry Upload WatcherUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 7A1D9A902D3C1C25008898A6 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7A1D9AA12D3C1C26008898A6 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7A1D9AAB2D3C1C26008898A6 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 7A1D9A8E2D3C1C25008898A6 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7A1D9A9F2D3C1C26008898A6 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7A1D9AA92D3C1C26008898A6 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 7A1D9AA52D3C1C26008898A6 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 7A1D9A912D3C1C25008898A6 /* Ventry Upload Watcher */; + targetProxy = 7A1D9AA42D3C1C26008898A6 /* PBXContainerItemProxy */; + }; + 7A1D9AAF2D3C1C26008898A6 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 7A1D9A912D3C1C25008898A6 /* Ventry Upload Watcher */; + targetProxy = 7A1D9AAE2D3C1C26008898A6 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 7A1D9AB52D3C1C26008898A6 /* 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.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; + }; + 7A1D9AB62D3C1C26008898A6 /* 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.2; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + }; + name = Release; + }; + 7A1D9AB82D3C1C26008898A6 /* 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 = "Ventry Upload Watcher/Ventry Upload WatcherDebug.entitlements"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"Ventry Upload Watcher/Preview Content\""; + DEVELOPMENT_TEAM = RYP9AN7LG4; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Ventry Watcher"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + INFOPLIST_KEY_NSHumanReadableCopyright = "Jan-Marlon Leibl"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "jleibl.Ventry-Upload-Watcher"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 7A1D9AB92D3C1C26008898A6 /* 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 = "Ventry Upload Watcher/Ventry_Upload_Watcher.entitlements"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"Ventry Upload Watcher/Preview Content\""; + DEVELOPMENT_TEAM = RYP9AN7LG4; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Ventry Watcher"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + INFOPLIST_KEY_NSHumanReadableCopyright = "Jan-Marlon Leibl"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "jleibl.Ventry-Upload-Watcher"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 7A1D9ABB2D3C1C26008898A6 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = RYP9AN7LG4; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 15.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "jleibl.Ventry-Upload-WatcherTests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Ventry Upload Watcher.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Ventry Upload Watcher"; + }; + name = Debug; + }; + 7A1D9ABC2D3C1C26008898A6 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = RYP9AN7LG4; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 15.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "jleibl.Ventry-Upload-WatcherTests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Ventry Upload Watcher.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Ventry Upload Watcher"; + }; + name = Release; + }; + 7A1D9ABE2D3C1C26008898A6 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = RYP9AN7LG4; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "jleibl.Ventry-Upload-WatcherUITests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TEST_TARGET_NAME = "Ventry Upload Watcher"; + }; + name = Debug; + }; + 7A1D9ABF2D3C1C26008898A6 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = RYP9AN7LG4; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "jleibl.Ventry-Upload-WatcherUITests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TEST_TARGET_NAME = "Ventry Upload Watcher"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 7A1D9A8D2D3C1C25008898A6 /* Build configuration list for PBXProject "Ventry Upload Watcher" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7A1D9AB52D3C1C26008898A6 /* Debug */, + 7A1D9AB62D3C1C26008898A6 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 7A1D9AB72D3C1C26008898A6 /* Build configuration list for PBXNativeTarget "Ventry Upload Watcher" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7A1D9AB82D3C1C26008898A6 /* Debug */, + 7A1D9AB92D3C1C26008898A6 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 7A1D9ABA2D3C1C26008898A6 /* Build configuration list for PBXNativeTarget "Ventry Upload WatcherTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7A1D9ABB2D3C1C26008898A6 /* Debug */, + 7A1D9ABC2D3C1C26008898A6 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 7A1D9ABD2D3C1C26008898A6 /* Build configuration list for PBXNativeTarget "Ventry Upload WatcherUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7A1D9ABE2D3C1C26008898A6 /* Debug */, + 7A1D9ABF2D3C1C26008898A6 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 7A1D9A8A2D3C1C25008898A6 /* Project object */; +} diff --git a/Ventry Upload Watcher.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Ventry Upload Watcher.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/Ventry Upload Watcher.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Ventry Upload Watcher.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/Ventry Upload Watcher.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/Ventry Upload Watcher.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,5 @@ + + + + + diff --git a/Ventry Upload Watcher.xcodeproj/project.xcworkspace/xcuserdata/jleibl.xcuserdatad/UserInterfaceState.xcuserstate b/Ventry Upload Watcher.xcodeproj/project.xcworkspace/xcuserdata/jleibl.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..122fdc6 Binary files /dev/null and b/Ventry Upload Watcher.xcodeproj/project.xcworkspace/xcuserdata/jleibl.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/Ventry Upload Watcher.xcodeproj/project.xcworkspace/xcuserdata/jleibl.xcuserdatad/WorkspaceSettings.xcsettings b/Ventry Upload Watcher.xcodeproj/project.xcworkspace/xcuserdata/jleibl.xcuserdatad/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..bbfef02 --- /dev/null +++ b/Ventry Upload Watcher.xcodeproj/project.xcworkspace/xcuserdata/jleibl.xcuserdatad/WorkspaceSettings.xcsettings @@ -0,0 +1,14 @@ + + + + + BuildLocationStyle + UseAppPreferences + CustomBuildLocationType + RelativeToDerivedData + DerivedDataLocationStyle + Default + ShowSharedSchemesAutomaticallyEnabled + + + diff --git a/Ventry Upload Watcher.xcodeproj/xcuserdata/jleibl.xcuserdatad/xcschemes/xcschememanagement.plist b/Ventry Upload Watcher.xcodeproj/xcuserdata/jleibl.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..b6c8703 --- /dev/null +++ b/Ventry Upload Watcher.xcodeproj/xcuserdata/jleibl.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + Ventry Upload Watcher.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/Ventry Upload Watcher/.DS_Store b/Ventry Upload Watcher/.DS_Store new file mode 100644 index 0000000..c6d75e1 Binary files /dev/null and b/Ventry Upload Watcher/.DS_Store differ diff --git a/Ventry Upload Watcher/Assets.xcassets/AccentColor.colorset/Contents.json b/Ventry Upload Watcher/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Ventry Upload Watcher/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Ventry Upload Watcher/Assets.xcassets/AppIcon.appiconset/Contents.json b/Ventry Upload Watcher/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..0762c27 --- /dev/null +++ b/Ventry Upload Watcher/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,187 @@ +{ + "images" : [ + { + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "language-direction" : "left-to-right", + "scale" : "1x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "language-direction" : "right-to-left", + "scale" : "1x", + "size" : "16x16" + }, + { + "filename" : "ventry-32 1.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "language-direction" : "left-to-right", + "scale" : "2x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "language-direction" : "right-to-left", + "scale" : "2x", + "size" : "16x16" + }, + { + "filename" : "ventry-32.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "language-direction" : "left-to-right", + "scale" : "1x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "language-direction" : "right-to-left", + "scale" : "1x", + "size" : "32x32" + }, + { + "filename" : "ventry-64.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "language-direction" : "left-to-right", + "scale" : "2x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "language-direction" : "right-to-left", + "scale" : "2x", + "size" : "32x32" + }, + { + "filename" : "ventry-128.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "language-direction" : "left-to-right", + "scale" : "1x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "language-direction" : "right-to-left", + "scale" : "1x", + "size" : "128x128" + }, + { + "filename" : "ventry-256.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "language-direction" : "left-to-right", + "scale" : "2x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "language-direction" : "right-to-left", + "scale" : "2x", + "size" : "128x128" + }, + { + "filename" : "ventry-256 1.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "language-direction" : "left-to-right", + "scale" : "1x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "language-direction" : "right-to-left", + "scale" : "1x", + "size" : "256x256" + }, + { + "filename" : "ventry-512.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "language-direction" : "left-to-right", + "scale" : "2x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "language-direction" : "right-to-left", + "scale" : "2x", + "size" : "256x256" + }, + { + "filename" : "ventry-512 1.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "idiom" : "mac", + "language-direction" : "left-to-right", + "scale" : "1x", + "size" : "512x512" + }, + { + "idiom" : "mac", + "language-direction" : "right-to-left", + "scale" : "1x", + "size" : "512x512" + }, + { + "filename" : "ventry-1024.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + }, + { + "idiom" : "mac", + "language-direction" : "left-to-right", + "scale" : "2x", + "size" : "512x512" + }, + { + "idiom" : "mac", + "language-direction" : "right-to-left", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Ventry Upload Watcher/Assets.xcassets/AppIcon.appiconset/ventry-1024.png b/Ventry Upload Watcher/Assets.xcassets/AppIcon.appiconset/ventry-1024.png new file mode 100644 index 0000000..7f84ce3 Binary files /dev/null and b/Ventry Upload Watcher/Assets.xcassets/AppIcon.appiconset/ventry-1024.png differ diff --git a/Ventry Upload Watcher/Assets.xcassets/AppIcon.appiconset/ventry-128.png b/Ventry Upload Watcher/Assets.xcassets/AppIcon.appiconset/ventry-128.png new file mode 100644 index 0000000..d5eb0c6 Binary files /dev/null and b/Ventry Upload Watcher/Assets.xcassets/AppIcon.appiconset/ventry-128.png differ diff --git a/Ventry Upload Watcher/Assets.xcassets/AppIcon.appiconset/ventry-256 1.png b/Ventry Upload Watcher/Assets.xcassets/AppIcon.appiconset/ventry-256 1.png new file mode 100644 index 0000000..0db10f1 Binary files /dev/null and b/Ventry Upload Watcher/Assets.xcassets/AppIcon.appiconset/ventry-256 1.png differ diff --git a/Ventry Upload Watcher/Assets.xcassets/AppIcon.appiconset/ventry-256.png b/Ventry Upload Watcher/Assets.xcassets/AppIcon.appiconset/ventry-256.png new file mode 100644 index 0000000..0db10f1 Binary files /dev/null and b/Ventry Upload Watcher/Assets.xcassets/AppIcon.appiconset/ventry-256.png differ diff --git a/Ventry Upload Watcher/Assets.xcassets/AppIcon.appiconset/ventry-32 1.png b/Ventry Upload Watcher/Assets.xcassets/AppIcon.appiconset/ventry-32 1.png new file mode 100644 index 0000000..a6101dc Binary files /dev/null and b/Ventry Upload Watcher/Assets.xcassets/AppIcon.appiconset/ventry-32 1.png differ diff --git a/Ventry Upload Watcher/Assets.xcassets/AppIcon.appiconset/ventry-32.png b/Ventry Upload Watcher/Assets.xcassets/AppIcon.appiconset/ventry-32.png new file mode 100644 index 0000000..a6101dc Binary files /dev/null and b/Ventry Upload Watcher/Assets.xcassets/AppIcon.appiconset/ventry-32.png differ diff --git a/Ventry Upload Watcher/Assets.xcassets/AppIcon.appiconset/ventry-512 1.png b/Ventry Upload Watcher/Assets.xcassets/AppIcon.appiconset/ventry-512 1.png new file mode 100644 index 0000000..018a0d0 Binary files /dev/null and b/Ventry Upload Watcher/Assets.xcassets/AppIcon.appiconset/ventry-512 1.png differ diff --git a/Ventry Upload Watcher/Assets.xcassets/AppIcon.appiconset/ventry-512.png b/Ventry Upload Watcher/Assets.xcassets/AppIcon.appiconset/ventry-512.png new file mode 100644 index 0000000..018a0d0 Binary files /dev/null and b/Ventry Upload Watcher/Assets.xcassets/AppIcon.appiconset/ventry-512.png differ diff --git a/Ventry Upload Watcher/Assets.xcassets/AppIcon.appiconset/ventry-64.png b/Ventry Upload Watcher/Assets.xcassets/AppIcon.appiconset/ventry-64.png new file mode 100644 index 0000000..1473645 Binary files /dev/null and b/Ventry Upload Watcher/Assets.xcassets/AppIcon.appiconset/ventry-64.png differ diff --git a/Ventry Upload Watcher/Assets.xcassets/Contents.json b/Ventry Upload Watcher/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Ventry Upload Watcher/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Ventry Upload Watcher/Assets.xcassets/ventry-logo-white.imageset/Contents.json b/Ventry Upload Watcher/Assets.xcassets/ventry-logo-white.imageset/Contents.json new file mode 100644 index 0000000..90c6f49 --- /dev/null +++ b/Ventry Upload Watcher/Assets.xcassets/ventry-logo-white.imageset/Contents.json @@ -0,0 +1,28 @@ +{ + "images" : [ + { + "filename" : "Ventry PNG to SVG (1).svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Ventry PNG to SVG (1) 1.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Ventry PNG to SVG (1) 2.svg", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "compression-type" : "automatic", + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Ventry Upload Watcher/Assets.xcassets/ventry-logo-white.imageset/Ventry PNG to SVG (1) 1.svg b/Ventry Upload Watcher/Assets.xcassets/ventry-logo-white.imageset/Ventry PNG to SVG (1) 1.svg new file mode 100644 index 0000000..1817169 --- /dev/null +++ b/Ventry Upload Watcher/Assets.xcassets/ventry-logo-white.imageset/Ventry PNG to SVG (1) 1.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/Ventry Upload Watcher/Assets.xcassets/ventry-logo-white.imageset/Ventry PNG to SVG (1) 2.svg b/Ventry Upload Watcher/Assets.xcassets/ventry-logo-white.imageset/Ventry PNG to SVG (1) 2.svg new file mode 100644 index 0000000..1817169 --- /dev/null +++ b/Ventry Upload Watcher/Assets.xcassets/ventry-logo-white.imageset/Ventry PNG to SVG (1) 2.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/Ventry Upload Watcher/Assets.xcassets/ventry-logo-white.imageset/Ventry PNG to SVG (1).svg b/Ventry Upload Watcher/Assets.xcassets/ventry-logo-white.imageset/Ventry PNG to SVG (1).svg new file mode 100644 index 0000000..1817169 --- /dev/null +++ b/Ventry Upload Watcher/Assets.xcassets/ventry-logo-white.imageset/Ventry PNG to SVG (1).svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/Ventry Upload Watcher/Assets.xcassets/ventry-watcher.imageset/Contents.json b/Ventry Upload Watcher/Assets.xcassets/ventry-watcher.imageset/Contents.json new file mode 100644 index 0000000..79e7fb0 --- /dev/null +++ b/Ventry Upload Watcher/Assets.xcassets/ventry-watcher.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "ventry-watcher.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Ventry Upload Watcher/Assets.xcassets/ventry-watcher.imageset/ventry-watcher.png b/Ventry Upload Watcher/Assets.xcassets/ventry-watcher.imageset/ventry-watcher.png new file mode 100644 index 0000000..932eaba Binary files /dev/null and b/Ventry Upload Watcher/Assets.xcassets/ventry-watcher.imageset/ventry-watcher.png differ diff --git a/Ventry Upload Watcher/ContentView.swift b/Ventry Upload Watcher/ContentView.swift new file mode 100644 index 0000000..36c5aa1 --- /dev/null +++ b/Ventry Upload Watcher/ContentView.swift @@ -0,0 +1,1152 @@ +// +// ContentView.swift +// Ventry Upload Watcher +// +// Created by Jan-Marlon Leibl on 18.01.25. +// + +import SwiftUI +import AppKit +import UserNotifications +import ServiceManagement +import CoreServices + +extension NSWindow { + open override func awakeFromNib() { + super.awakeFromNib() + self.styleMask.remove(.resizable) + self.styleMask.remove(.fullScreen) + } +} + +extension Color { + static let accentPurple = Color(hex: 0x8331FD) +} + +extension Color { + init(hex: UInt) { + self.init( + .sRGB, + red: Double((hex >> 16) & 0xff) / 255, + green: Double((hex >> 8) & 0xff) / 255, + blue: Double(hex & 0xff) / 255, + opacity: 1 + ) + } +} + +// MARK: - Upload Manager +class UploadManager { + private static let endpoint = URL(string: "https://ventry.host/api/upload")! + + static func uploadFile(_ fileURL: URL, apiKey: String, completion: @escaping (Result) -> Void) { + var request = URLRequest(url: endpoint) + request.httpMethod = "POST" + request.setValue(apiKey, forHTTPHeaderField: "Authorization") + let boundary = "Boundary-\(UUID().uuidString)" + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + + URLSession.shared.uploadTask(with: request, from: createMultipartFormData(fileURL: fileURL, boundary: boundary)) { data, response, error in + if let error = error { return completion(.failure(error)) } + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode), + let data = data, + let responseString = String(data: data, encoding: .utf8) else { + return completion(.failure(NSError(domain: "", code: (response as? HTTPURLResponse)?.statusCode ?? -1))) + } + completion(.success(responseString)) + }.resume() + } + + private static func createMultipartFormData(fileURL: URL, boundary: String) -> Data { + var data = Data() + let filename = fileURL.lastPathComponent.replacingOccurrences(of: " ", with: "%20") + data.append("--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\nContent-Type: \(fileURL.mimeType())\r\n\r\n".data(using: .utf8)!) + if let fileData = try? Data(contentsOf: fileURL) { + data.append(fileData) + data.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!) + } + return data + } +} + +extension URL { + func mimeType() -> String { + let mimeTypes = ["jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png", + "gif": "image/gif", "webp": "image/webp", "mp4": "video/mp4", + "mov": "video/quicktime"] + return mimeTypes[pathExtension.lowercased()] ?? "application/octet-stream" + } +} + +// MARK: - Folder Monitor +class FolderMonitor: ObservableObject { + @Published private(set) var isWatching = false + private var timer: Timer? + private var currentPaths: [String] = [] + private var knownFiles = Set() + var onFileDetected: ((URL) -> Void)? + + deinit { stopWatching() } + + func startWatching(paths: [String]) { + stopWatching() + currentPaths = paths + for path in paths { + let url = URL(fileURLWithPath: path) + guard url.startAccessingSecurityScopedResource() else { + NotificationManager.send(title: "Permission Required", message: "Please grant folder access") + continue + } + saveBookmark(for: url) + } + updateKnownFiles() + startMonitoring() + } + + private func startMonitoring() { + timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + self?.checkForNewFiles() + } + isWatching = true + } + + private func checkForNewFiles() { + for path in currentPaths { + do { + let contents = try FileManager.default.contentsOfDirectory(atPath: path) + let newFiles = Set(contents).subtracting(knownFiles) + + for file in newFiles { + let fileURL = URL(fileURLWithPath: path).appendingPathComponent(file) + if fileURL.lastPathModified.timeIntervalSinceNow > -2 { + onFileDetected?(fileURL) + } + } + + knownFiles = knownFiles.union(newFiles) + } catch { + print("Error checking files in \(path): \(error)") + } + } + } + + private func updateKnownFiles() { + var allFiles = Set() + for path in currentPaths { + guard let contents = try? FileManager.default.contentsOfDirectory(atPath: path) else { continue } + allFiles = allFiles.union(Set(contents.map { path + "/" + $0 })) + } + knownFiles = allFiles + } + + private func saveBookmark(for url: URL) { + guard let bookmarkData = try? url.bookmarkData( + options: .withSecurityScope, + includingResourceValuesForKeys: nil, + relativeTo: nil + ) else { return } + + UserDefaults.standard.set(bookmarkData, forKey: "FolderBookmark-\(url.path)") + } + + func stopWatching() { + timer?.invalidate() + timer = nil + isWatching = false + currentPaths.removeAll() + knownFiles.removeAll() + } + + func addPath(_ path: String) { + if !currentPaths.contains(path) { + currentPaths.append(path) + let url = URL(fileURLWithPath: path) + guard url.startAccessingSecurityScopedResource() else { + NotificationManager.send(title: "Permission Required", message: "Please grant folder access") + return + } + saveBookmark(for: url) + updateKnownFiles() + } + } +} + +// MARK: - Notification Manager +class NotificationManager { + static func send(title: String, message: String) { + let content = UNMutableNotificationContent() + content.title = title + content.body = message + content.sound = .default + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, _ in + if granted { + UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: UUID().uuidString, + content: content, trigger: nil)) + } + } + } +} + +// MARK: - Haptics +class HapticManager { + static func play(_ type: NSHapticFeedbackManager.FeedbackPattern) { + NSHapticFeedbackManager.defaultPerformer.perform(type, performanceTime: .default) + } + + static func success() { play(.levelChange) } + static func error() { play(.generic) } + static func tap() { play(.alignment) } +} + +// MARK: - Views +struct CardView: View { + let content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + var body: some View { + content + .padding() + .background(Color(.windowBackgroundColor)) + .cornerRadius(12) + } +} + +struct StatusBadge: View { + let status: String + let color: Color + + var body: some View { + Text(status) + .font(.caption.bold()) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(color.opacity(0.2)) + .foregroundColor(color) + .cornerRadius(6) + } +} + +class LaunchAtLoginHelper { + static func setLaunchAtLogin(_ enabled: Bool) { + let service = SMAppService.mainApp + do { + if enabled { + try service.register() + } else { + try service.unregister() + } + print("Successfully \(enabled ? "enabled" : "disabled") launch at login") + } catch { + print("Failed to \(enabled ? "enable" : "disable") launch at login: \(error)") + } + } +} + +struct SettingsView: View { + @Binding var isPresented: Bool + @Binding var apiKey: String + @Environment(\.colorScheme) var colorScheme + @FocusState private var isAPIKeyFocused: Bool + @AppStorage("launchAtLogin") private var launchAtLogin = false + + var body: some View { + VStack(spacing: Design.padding) { + HStack { + Text("Settings") + .font(.system(size: 24, weight: .bold)) + Spacer() + Button(action: { + withAnimation(Animation.spring) { + isPresented = false + } + }) { + Circle() + .fill(Color(.windowBackgroundColor).opacity(0.8)) + .frame(width: 32, height: 32) + .overlay( + Image(systemName: "xmark") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.secondary) + ) + } + .buttonStyle(.plain) + } + + VStack(alignment: .leading, spacing: Design.spacing) { + Text("API Key") + .font(.system(size: 15, weight: .semibold)) + + SecureField("Enter your ventry.host API key", text: $apiKey) + .textFieldStyle(.plain) + .padding(12) + .background( + RoundedRectangle(cornerRadius: Design.cornerRadius, style: .continuous) + .fill(Color(.textBackgroundColor)) + ) + .focused($isAPIKeyFocused) + + Text("Get your API key from ventry.host") + .font(.system(size: 12)) + .foregroundColor(.secondary) + } + .padding(.bottom, 8) + + VStack(alignment: .leading, spacing: Design.spacing) { + Text("Startup Options") + .font(.system(size: 15, weight: .semibold)) + .padding(.bottom, 4) + + Toggle(isOn: $launchAtLogin) { + VStack(alignment: .leading, spacing: 2) { + Text("Launch at login") + .font(.system(size: 14)) + Text("Automatically start the app when you log in") + .font(.system(size: 12)) + .foregroundColor(.secondary) + } + } + .onChange(of: launchAtLogin) { _, newValue in + LaunchAtLoginHelper.setLaunchAtLogin(newValue) + } + } + + Spacer() + + Button(action: { + HapticManager.success() + withAnimation(Animation.spring) { + isPresented = false + } + }) { + Text("Save") + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: Design.cornerRadius, style: .continuous) + .fill(Color.accentPurple) + ) + } + .buttonStyle(.plain) + .keyboardShortcut(.return) + } + .padding(24) + .frame(width: 400, height: 360) + .background(colorScheme == .dark ? Color(.windowBackgroundColor) : .white) + .onAppear { isAPIKeyFocused = true } + } +} + +@propertyWrapper +struct UserDefaultsArray { + private let key: String + + init(key: String) { + self.key = key + } + + var wrappedValue: [T] { + get { + guard let data = UserDefaults.standard.data(forKey: key), + let array = try? JSONDecoder().decode([T].self, from: data) else { + return [] + } + return array + } + set { + guard let data = try? JSONEncoder().encode(newValue) else { return } + UserDefaults.standard.set(data, forKey: key) + } + } +} + +struct HeaderView: View { + @Binding var showingSettings: Bool + + var body: some View { + HStack(spacing: Design.padding) { + VStack(alignment: .leading, spacing: 2) { + Text("ventry.host") + .font(.system(size: 24, weight: .bold)) + Text("Tool for uploading files to ventry.host") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.secondary) + } + + Spacer() + + Button(action: { + HapticManager.tap() + withAnimation(Animation.spring) { + showingSettings.toggle() + } + }) { + Image(systemName: "gear") + .font(.system(size: Design.iconSize)) + .foregroundColor(.secondary) + .frame(width: 32, height: 32) + .background(Color(.controlBackgroundColor)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + } + } +} + +struct StatusCardView: View { + let folderMonitor: FolderMonitor + let uploadStatus: String + let lastUpload: String + + var body: some View { + VStack(spacing: Design.padding) { + HStack { + ZStack { + Circle() + .fill(folderMonitor.isWatching ? Color.green.opacity(0.15) : Color.red.opacity(0.15)) + .frame(width: 24, height: 24) + + Circle() + .fill(folderMonitor.isWatching ? Color.green : Color.red) + .frame(width: 8, height: 8) + .blur(radius: folderMonitor.isWatching ? 2 : 0) + } + + Text(folderMonitor.isWatching ? "Watching" : "Inactive") + .font(.system(size: Design.iconSize, weight: .medium)) + .foregroundColor(folderMonitor.isWatching ? .green : .red) + Spacer() + } + + VStack(spacing: Design.spacing) { + statusRow( + icon: "arrow.up.circle", + title: "Status", + value: uploadStatus + ) + + Divider() + .background(Color.primary.opacity(Design.opacity)) + + statusRow( + icon: "clock", + title: "Last Upload", + value: lastUpload + ) + } + } + .padding(Design.padding) + .background(Color(.controlBackgroundColor)) + .cornerRadius(Design.cornerRadius) + .animation(Animation.spring, value: folderMonitor.isWatching) + } + + private func statusRow(icon: String, title: String, value: String) -> some View { + HStack(spacing: Design.spacing) { + Image(systemName: icon) + .font(.system(size: Design.iconSize)) + .foregroundColor(.secondary) + .frame(width: 16) + + Text(title) + .font(.system(size: Design.iconSize)) + .foregroundColor(.secondary) + + Spacer() + + Text(value) + .font(.system(size: Design.iconSize, weight: .medium)) + .multilineTextAlignment(.trailing) + } + } +} + +struct WatchedFoldersView: View { + @Binding var watchedFolders: [String] + let folderMonitor: FolderMonitor + let onAddFolder: () -> Void + let onStopWatching: () -> Void + + var body: some View { + VStack(spacing: Design.spacing) { + List { + ForEach(watchedFolders, id: \.self) { path in + folderCard(path) + .listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0)) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { + withAnimation(Animation.spring) { + watchedFolders.removeAll { $0 == path } + if watchedFolders.isEmpty { + folderMonitor.stopWatching() + } else { + folderMonitor.startWatching(paths: watchedFolders) + } + } + HapticManager.tap() + } label: { + Label("Remove", systemImage: "trash") + } + } + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .frame(height: CGFloat(watchedFolders.count * 60)) + .padding(.horizontal, -8) + + VStack(spacing: Design.spacing) { + Button(action: { + HapticManager.tap() + onAddFolder() + }) { + HStack(spacing: Design.spacing) { + Image(systemName: "folder.badge.plus") + .font(.system(size: Design.iconSize)) + Text("Add Another Folder") + .font(.system(size: Design.iconSize)) + } + .frame(maxWidth: .infinity) + .padding(12) + .background( + RoundedRectangle(cornerRadius: Design.cornerRadius) + .fill(Color.accentPurple.opacity(Design.opacity)) + ) + .foregroundColor(.accentPurple) + } + .buttonStyle(.plain) + + Button(action: { + HapticManager.tap() + onStopWatching() + }) { + HStack(spacing: Design.spacing) { + Image(systemName: "stop.circle.fill") + .font(.system(size: Design.iconSize)) + Text("Stop Watching") + .font(.system(size: Design.iconSize)) + } + .frame(maxWidth: .infinity) + .padding(12) + .background( + RoundedRectangle(cornerRadius: Design.cornerRadius) + .fill(Color.red.opacity(Design.opacity)) + ) + .foregroundColor(.red) + } + .buttonStyle(.plain) + } + } + } + + private func folderCard(_ path: String) -> some View { + HStack(spacing: Design.spacing) { + Image(systemName: "folder.fill") + .font(.system(size: Design.iconSize)) + .foregroundColor(.accentPurple) + + Text(path) + .font(.system(size: Design.iconSize)) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.vertical, 12) + .padding(.horizontal, Design.padding) + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: Design.cornerRadius, style: .continuous) + .fill(Color.accentPurple.opacity(Design.opacity)) + ) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } +} + +struct LeftSideView: View { + let folderMonitor: FolderMonitor + let uploadStatus: String + let lastUpload: String + @Binding var showingSettings: Bool + @Binding var watchedFolders: [String] + let apiKey: String + let onToggleWatching: () -> Void + let onAddFolder: () -> Void + + var body: some View { + ScrollView { + VStack(spacing: 16) { + HeaderView(showingSettings: $showingSettings) + StatusCardView(folderMonitor: folderMonitor, uploadStatus: uploadStatus, lastUpload: lastUpload) + + if !watchedFolders.isEmpty { + WatchedFoldersView( + watchedFolders: $watchedFolders, + folderMonitor: folderMonitor, + onAddFolder: onAddFolder, + onStopWatching: onToggleWatching + ) + } + + if watchedFolders.isEmpty { + ActionButtonView(folderMonitor: folderMonitor, apiKey: apiKey, onToggleWatching: onToggleWatching) + } + + Spacer(minLength: 0) + } + .padding(24) + } + .frame(width: 360) + .background(Color(.windowBackgroundColor)) + } +} + +struct RightSideView: View { + let isUploading: Bool + @Binding var uploadHistory: [(date: Date, url: String, filePath: String)] + @Binding var copiedURL: String? + let folderMonitor: FolderMonitor + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Recent Uploads") + .font(.system(size: 20, weight: .bold)) + + if isUploading { + UploadingCardView() + } + + UploadHistoryListView( + uploadHistory: $uploadHistory, + copiedURL: $copiedURL, + folderMonitor: folderMonitor + ) + } + .padding(24) + .frame(maxWidth: .infinity) + .background(Color(.textBackgroundColor)) + } +} + +struct ActionButtonView: View { + let folderMonitor: FolderMonitor + let apiKey: String + let onToggleWatching: () -> Void + + var body: some View { + Button(action: { + HapticManager.tap() + onToggleWatching() + }) { + HStack(spacing: 8) { + Image(systemName: folderMonitor.isWatching ? "stop.circle.fill" : "folder.badge.plus") + Text(folderMonitor.isWatching ? "Stop Watching" : "Add Folders...") + .font(.system(size: 15, weight: .semibold)) + } + .frame(maxWidth: .infinity) + .frame(height: 44) + .background(folderMonitor.isWatching ? Color.red : Color(.controlAccentColor)) + .cornerRadius(12) + .foregroundColor(.white) + } + .buttonStyle(.plain) + .disabled(apiKey.isEmpty) + .opacity(apiKey.isEmpty ? 0.5 : 1) + .help(folderMonitor.isWatching ? "Stop watching all folders" : "Select multiple folders to watch") + } +} + +struct UploadingCardView: View { + var body: some View { + HStack(spacing: 12) { + ProgressView() + .scaleEffect(0.8) + Text("Uploading file...") + .font(.system(size: 14, weight: .medium)) + Spacer() + } + .padding(16) + .background( + ZStack { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color.accentPurple.opacity(0.1)) + + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill( + LinearGradient( + colors: [Color.accentPurple.opacity(0.1), Color.clear], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + } + ) + .transition(.move(edge: .top).combined(with: .opacity)) + } +} + +private enum Animation { + static let spring = SwiftUI.Animation.spring(response: 0.3, dampingFraction: 0.7) + static let copyDelay: TimeInterval = 1.5 +} + +private enum Design { + static let cornerRadius: CGFloat = 12 + static let opacity = 0.1 + static let iconSize: CGFloat = 14 + static let spacing: CGFloat = 8 + static let padding: CGFloat = 16 +} + +struct UploadHistoryListView: View { + @Binding var uploadHistory: [(date: Date, url: String, filePath: String)] + @Binding var copiedURL: String? + let folderMonitor: FolderMonitor + @Namespace private var scrollSpace + + var body: some View { + ScrollViewReader { proxy in + List { + ForEach(uploadHistory, id: \.date) { upload in + lastUploadCard(upload.url, date: upload.date, filePath: upload.filePath) + .listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0)) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + withAnimation(Animation.spring) { + uploadHistory.removeAll { $0.url == upload.url } + } + HapticManager.tap() + } label: { + Label("Delete", systemImage: "trash") + } + + Button { + if let url = URL(string: upload.url) { + NSWorkspace.shared.open(url) + HapticManager.tap() + } + } label: { + Label("Open", systemImage: "safari") + } + .tint(.accentPurple) + } + .id(upload.date) + } + + if uploadHistory.isEmpty && !folderMonitor.isWatching { + VStack(spacing: Design.spacing) { + Image(systemName: "folder.badge.plus") + .font(.system(size: 32)) + .foregroundColor(.secondary) + Text("Select a folder to start watching") + .font(.system(size: Design.iconSize)) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity) + .frame(height: 160) + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .onChange(of: uploadHistory.map(\.url)) { oldValue, newValue in + if !newValue.isEmpty { + withAnimation(Animation.spring) { + proxy.scrollTo(uploadHistory[0].date, anchor: .top) + } + } + } + } + } + + private func lastUploadCard(_ url: String, date: Date = Date(), filePath: String? = nil) -> some View { + Button(action: { + copyToClipboard(url) + withAnimation(Animation.spring) { + copiedURL = url + } + HapticManager.success() + DispatchQueue.main.asyncAfter(deadline: .now() + Animation.copyDelay) { + withAnimation(Animation.spring) { + if copiedURL == url { + copiedURL = nil + } + } + } + }) { + VStack(alignment: .leading, spacing: Design.spacing) { + if let filePath = filePath, + let nsImage = NSImage(contentsOf: URL(fileURLWithPath: filePath)) { + Image(nsImage: nsImage) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(height: 120) + .frame(maxWidth: .infinity) + .clipShape(RoundedRectangle(cornerRadius: Design.cornerRadius)) + } + + VStack(alignment: .leading, spacing: Design.spacing) { + Text(date.formatted(date: .numeric, time: .shortened)) + .font(.system(size: 12)) + .foregroundColor(.secondary) + + HStack(spacing: Design.spacing) { + Image(systemName: "link") + .font(.system(size: Design.iconSize)) + .foregroundColor(copiedURL == url ? .green : .accentPurple) + + Text(url) + .font(.system(size: Design.iconSize)) + .lineLimit(1) + .truncationMode(.middle) + + Spacer() + + HStack(spacing: 4) { + if copiedURL == url { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: Design.iconSize)) + .transition(.scale.combined(with: .opacity)) + Text("Copied!") + } else { + Text("Copy") + } + } + .font(.system(size: 12, weight: .medium)) + .foregroundColor(copiedURL == url ? .green : .accentPurple) + .animation(Animation.spring, value: copiedURL) + } + } + } + .padding(Design.padding) + .background( + RoundedRectangle(cornerRadius: Design.cornerRadius, style: .continuous) + .fill(copiedURL == url ? Color.green.opacity(Design.opacity) : Color.accentPurple.opacity(Design.opacity)) + ) + } + .buttonStyle(.plain) + .contentShape(Rectangle()) + .animation(Animation.spring, value: copiedURL) + } + + private func copyToClipboard(_ text: String) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } +} + +class WindowManager { + static let shared = WindowManager() + private var window: NSWindow? + + func setWindow(_ window: NSWindow?) { + self.window = window + } + + func hideWindow() { + window?.orderOut(nil) + } + + func showWindow() { + window?.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } +} + +struct ContentView: View { + @StateObject private var folderMonitor = FolderMonitor() + @State private var uploadStatus = "No active uploads" + @State private var lastUpload = "Never" + @State private var showingSettings = false + @AppStorage("apiKey") private var apiKey = "" + @State private var watchedFolders: [String] = UserDefaults.standard.stringArray(forKey: "watchedFolders") ?? [] + @State private var errorMessage: String? + @State private var showError = false + @State private var lastUploadURL: String? + @State private var isUploading = false + @State private var showCopiedBadge = false + @Environment(\.colorScheme) var colorScheme + @State private var uploadHistory: [(date: Date, url: String, filePath: String)] = [] + @State private var copiedURL: String? = nil + + var body: some View { + MainLayout { + HStack(spacing: 0) { + LeftSideView( + folderMonitor: folderMonitor, + uploadStatus: uploadStatus, + lastUpload: lastUpload, + showingSettings: $showingSettings, + watchedFolders: $watchedFolders, + apiKey: apiKey, + onToggleWatching: toggleWatching, + onAddFolder: selectFolder + ) + + RightSideView( + isUploading: isUploading, + uploadHistory: $uploadHistory, + copiedURL: $copiedURL, + folderMonitor: folderMonitor + ) + } + } + .frame(width: 800, height: 400) + .background(Color(.windowBackgroundColor)) + .alert("Error", isPresented: $showError) { + Button("OK", role: .cancel) {} + } message: { + Text(errorMessage ?? "Unknown error") + } + .sheet(isPresented: $showingSettings) { + SettingsView(isPresented: $showingSettings, apiKey: $apiKey) + } + .onAppear { + setupFolderMonitor() + restoreWatching() + } + .onChange(of: watchedFolders) { oldValue, newValue in + UserDefaults.standard.set(newValue, forKey: "watchedFolders") + if !folderMonitor.isWatching && !newValue.isEmpty { + folderMonitor.startWatching(paths: newValue) + } + } + } + + private func toggleWatching() { + if folderMonitor.isWatching { + folderMonitor.stopWatching() + uploadStatus = "Watching stopped" + watchedFolders.removeAll() + } else { + selectFolder() + } + } + + private func selectFolder() { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = true + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.message = "Select folders to watch for screenshots" + panel.prompt = "Watch Folders" + + panel.begin { response in + guard response == .OK else { return } + + DispatchQueue.main.async { + for url in panel.urls { + guard url.startAccessingSecurityScopedResource() else { + NotificationManager.send(title: "Permission Required", message: "Please grant folder access for \(url.path)") + continue + } + + if !watchedFolders.contains(url.path) { + watchedFolders.append(url.path) + + if let bookmarkData = try? url.bookmarkData(options: .withSecurityScope) { + UserDefaults.standard.set(bookmarkData, forKey: "FolderBookmark-\(url.path)") + } + } + } + + if !watchedFolders.isEmpty { + if !folderMonitor.isWatching { + uploadStatus = "Starting to watch folders..." + folderMonitor.startWatching(paths: watchedFolders) + } else { + for url in panel.urls { + folderMonitor.addPath(url.path) + } + } + + uploadStatus = "Watching for new files..." + NotificationManager.send(title: "Folders Added", message: "Now watching \(panel.urls.count) new folder(s)") + } + } + } + } + + private func setupFolderMonitor() { + folderMonitor.onFileDetected = handleNewFile + } + + private func handleNewFile(_ fileURL: URL) { + uploadStatus = "Uploading \(fileURL.lastPathComponent)..." + NotificationManager.send(title: "Uploading File", message: "Starting upload of \(fileURL.lastPathComponent)") + withAnimation { isUploading = true } + + UploadManager.uploadFile(fileURL, apiKey: apiKey) { result in + DispatchQueue.main.async { + withAnimation { isUploading = false } + handleUploadResult(result) + } + } + } + + private func handleUploadResult(_ result: Result) { + switch result { + case .success(let response): handleSuccessfulUpload(response) + case .failure(let error): handleFailedUpload(error) + } + } + + private func handleSuccessfulUpload(_ response: String) { + do { + if let jsonData = response.data(using: .utf8), + let json = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any], + let url = json["url"] as? String, + let currentFile = lastUploadedFile?.path { + withAnimation(.spring()) { + uploadStatus = "Upload successful" + lastUpload = Date().formatted(date: .numeric, time: .shortened) + lastUploadURL = url + uploadHistory.insert((date: Date(), url: url, filePath: currentFile), at: 0) + if uploadHistory.count > 50 { + uploadHistory.removeLast() + } + } + copyToClipboard(url) + HapticManager.success() + NotificationManager.send(title: "Upload Successful", message: "URL copied to clipboard") + } else { + throw NSError(domain: "", code: -1) + } + } catch { + handleFailedUpload(error) + } + } + + private func handleFailedUpload(_ error: Error) { + withAnimation { + errorMessage = error.localizedDescription + showError = true + uploadStatus = "Upload failed" + } + HapticManager.error() + NotificationManager.send(title: "Upload Failed", message: error.localizedDescription) + } + + private var lastUploadedFile: URL? { + guard let path = watchedFolders.first else { return nil } + let fm = FileManager.default + let contents = try? fm.contentsOfDirectory(atPath: path) + return contents?.compactMap { URL(fileURLWithPath: path).appendingPathComponent($0) } + .sorted { $0.lastPathModified > $1.lastPathModified } + .first + } + + private func copyToClipboard(_ text: String) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + uploadStatus = "URL copied to clipboard" + } + + private func restoreWatching() { + guard !apiKey.isEmpty else { return } + + var validPaths: [String] = [] + + for path in watchedFolders { + guard FileManager.default.fileExists(atPath: path), + let bookmarkData = UserDefaults.standard.data(forKey: "FolderBookmark-\(path)") else { continue } + + var isStale = false + do { + let url = try URL(resolvingBookmarkData: bookmarkData, + options: .withSecurityScope, + relativeTo: nil, + bookmarkDataIsStale: &isStale) + + guard url.startAccessingSecurityScopedResource() else { + print("Failed to access security scoped resource for path: \(path)") + continue + } + + validPaths.append(url.path) + } catch { + print("Failed to resolve bookmark for path: \(path), error: \(error)") + } + } + + if !validPaths.isEmpty { + DispatchQueue.main.async { + watchedFolders = validPaths + folderMonitor.startWatching(paths: validPaths) + uploadStatus = "Watching for new files..." + NotificationManager.send(title: "Ventry Upload Watcher", message: "Resumed watching \(validPaths.count) folder(s)") + } + } + } +} + +struct MainLayout: View { + let content: Content + @Environment(\.colorScheme) var colorScheme + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + var body: some View { + ZStack { + Color(colorScheme == .dark ? .windowBackgroundColor : .white) + .ignoresSafeArea() + content + } + } +} + +#Preview { + ContentView() +} + +extension URL { + var lastPathModified: Date { + (try? resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate ?? Date.distantPast + } +} + +class AppDelegate: NSObject, NSApplicationDelegate { + var statusItem: NSStatusItem? + + func applicationDidFinishLaunching(_ notification: Notification) { + setupStatusItem() + + if let window = NSApplication.shared.windows.first { + WindowManager.shared.setWindow(window) + } + } + + private func setupStatusItem() { + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + + if let button = statusItem?.button { + let icon = NSImage(named: "ventry-logo-white") + icon?.isTemplate = true + icon?.size = NSSize(width: 18, height: 18) + button.image = icon + + let menu = NSMenu() + menu.addItem(NSMenuItem(title: "Show Window", action: #selector(showWindow), keyEquivalent: "")) + menu.addItem(NSMenuItem.separator()) + menu.addItem(NSMenuItem(title: "Quit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")) + + statusItem?.menu = menu + } + } + + @objc private func showWindow() { + WindowManager.shared.showWindow() + } +} diff --git a/Ventry Upload Watcher/Preview Content/Preview Assets.xcassets/Contents.json b/Ventry Upload Watcher/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Ventry Upload Watcher/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Ventry Upload Watcher/Ventry Upload WatcherDebug.entitlements b/Ventry Upload Watcher/Ventry Upload WatcherDebug.entitlements new file mode 100644 index 0000000..caede91 --- /dev/null +++ b/Ventry Upload Watcher/Ventry Upload WatcherDebug.entitlements @@ -0,0 +1,40 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.application-groups + + group.com.jleibl.ventryuploadwatcher + + com.apple.security.assets.movies.read-only + + com.apple.security.assets.music.read-only + + com.apple.security.assets.pictures.read-only + + com.apple.security.automation.apple-events + + com.apple.security.files.downloads.read-only + + com.apple.security.files.pictures.read-write + + com.apple.security.files.user-selected.read-only + + com.apple.security.network.client + + com.apple.security.network.server + + com.apple.security.personal-information.photos-library + + com.apple.security.temporary-exception.apple-events + + com.apple.systemevents + + com.apple.security.temporary-exception.files.home-relative-path.read-write + + / + + + diff --git a/Ventry Upload Watcher/Ventry_Upload_Watcher.entitlements b/Ventry Upload Watcher/Ventry_Upload_Watcher.entitlements new file mode 100644 index 0000000..679d6dd --- /dev/null +++ b/Ventry Upload Watcher/Ventry_Upload_Watcher.entitlements @@ -0,0 +1,20 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.assets.pictures.read-only + + com.apple.security.files.user-selected.read-write + + com.apple.security.files.downloads.read-write + + com.apple.security.files.pictures.read-write + + com.apple.security.network.client + + com.apple.security.network.server + + + diff --git a/Ventry Upload Watcher/Ventry_Upload_WatcherApp.swift b/Ventry Upload Watcher/Ventry_Upload_WatcherApp.swift new file mode 100644 index 0000000..793de42 --- /dev/null +++ b/Ventry Upload Watcher/Ventry_Upload_WatcherApp.swift @@ -0,0 +1,54 @@ +// +// Ventry_Upload_WatcherApp.swift +// Ventry Upload Watcher +// +// Created by Jan-Marlon Leibl on 18.01.25. +// + +import SwiftUI +import AppKit + +@main +struct Ventry_Upload_WatcherApp: App { + // MARK: - Properties + // MARK: - Scene + var body: some Scene { + WindowGroup { + ContentView() + .fixedSize() + } + .windowResizability(.contentSize) + + MenuBarExtra { + menuContent + } label: { + menuIcon + } + } + + // MARK: - Menu Components + @ViewBuilder + private var menuContent: some View { + Button("Show Window") { + NSApp.activate(ignoringOtherApps: true) + NSApp.windows.first?.makeKeyAndOrderFront(nil) + } + + Divider() + + Button("Quit") { + NSApplication.shared.terminate(nil) + } + .keyboardShortcut("q") + } + + @ViewBuilder + private var menuIcon: some View { + if let nsImage = NSImage(named: "ventry-logo-white") { + Image(nsImage: nsImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 16, height: 16) + } + } +} diff --git a/Ventry Upload WatcherTests/Ventry_Upload_WatcherTests.swift b/Ventry Upload WatcherTests/Ventry_Upload_WatcherTests.swift new file mode 100644 index 0000000..b969e80 --- /dev/null +++ b/Ventry Upload WatcherTests/Ventry_Upload_WatcherTests.swift @@ -0,0 +1,17 @@ +// +// Ventry_Upload_WatcherTests.swift +// Ventry Upload WatcherTests +// +// Created by Jan-Marlon Leibl on 18.01.25. +// + +import Testing +@testable import Ventry_Upload_Watcher + +struct Ventry_Upload_WatcherTests { + + @Test func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. + } + +} diff --git a/Ventry Upload WatcherUITests/Ventry_Upload_WatcherUITests.swift b/Ventry Upload WatcherUITests/Ventry_Upload_WatcherUITests.swift new file mode 100644 index 0000000..9465566 --- /dev/null +++ b/Ventry Upload WatcherUITests/Ventry_Upload_WatcherUITests.swift @@ -0,0 +1,43 @@ +// +// Ventry_Upload_WatcherUITests.swift +// Ventry Upload WatcherUITests +// +// Created by Jan-Marlon Leibl on 18.01.25. +// + +import XCTest + +final class Ventry_Upload_WatcherUITests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + + // In UI tests it is usually best to stop immediately when a failure occurs. + continueAfterFailure = false + + // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + @MainActor + func testExample() throws { + // UI tests must launch the application that they test. + let app = XCUIApplication() + app.launch() + + // Use XCTAssert and related functions to verify your tests produce the correct results. + } + + @MainActor + func testLaunchPerformance() throws { + if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { + // This measures how long it takes to launch your application. + measure(metrics: [XCTApplicationLaunchMetric()]) { + XCUIApplication().launch() + } + } + } +} diff --git a/Ventry Upload WatcherUITests/Ventry_Upload_WatcherUITestsLaunchTests.swift b/Ventry Upload WatcherUITests/Ventry_Upload_WatcherUITestsLaunchTests.swift new file mode 100644 index 0000000..8c26cfa --- /dev/null +++ b/Ventry Upload WatcherUITests/Ventry_Upload_WatcherUITestsLaunchTests.swift @@ -0,0 +1,33 @@ +// +// Ventry_Upload_WatcherUITestsLaunchTests.swift +// Ventry Upload WatcherUITests +// +// Created by Jan-Marlon Leibl on 18.01.25. +// + +import XCTest + +final class Ventry_Upload_WatcherUITestsLaunchTests: XCTestCase { + + override class var runsForEachTargetApplicationUIConfiguration: Bool { + true + } + + override func setUpWithError() throws { + continueAfterFailure = false + } + + @MainActor + func testLaunch() throws { + let app = XCUIApplication() + app.launch() + + // Insert steps here to perform after app launch but before taking a screenshot, + // such as logging into a test account or navigating somewhere in the app + + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Launch Screen" + attachment.lifetime = .keepAlways + add(attachment) + } +}