diff --git a/.swiftlint.yml b/.swiftlint.yml index e02032b..5f82533 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -56,3 +56,10 @@ nesting: excluded: - DerivedData - build + +custom_rules: + sf_safe_symbol: + name: "Safe SFSymbol" + message: "Use `SFSafeSymbols` via `systemSymbol` parameters for type safety." + regex: "(Image\\(systemName:)|(NSImage\\(symbolName:)|(Label[^,]+?,\\s*systemImage:)|(UIApplicationShortcutIcon\\(systemImageName:)" + severity: warning diff --git a/Shared/Constants.swift b/Shared/Constants.swift index 1656621..b07b54f 100644 --- a/Shared/Constants.swift +++ b/Shared/Constants.swift @@ -20,7 +20,6 @@ enum Constants { enum URLs { static let timeMachinePreferencesPlist = URL(fileURLWithPath: "/Library/Preferences/com.apple.TimeMachine.plist") - static let settingsFullDiskAccess: URL = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles")! static let timeMachineSystemSettings: URL = URL(string: "x-apple.systempreferences:com.apple.Time-Machine-Settings.extension")! static let authorURL: URL = URL(string: "https://lukaspistrol.com")! @@ -30,9 +29,7 @@ enum Constants { static let githubSponsor: URL = URL(string: "https://github.com/sponsors/lukepistrol")! static let buymeacoffee: URL = URL(string: "http://buymeacoffee.com/lukeeep")! - static var timeMachineApp: URL? { - URL(filePath: "/Applications/Time Machine.app") - } + static let timeMachineApp: URL? = URL(filePath: "/Applications/Time Machine.app") } enum Commands { diff --git a/TimeMachineStatus.xcodeproj/project.pbxproj b/TimeMachineStatus.xcodeproj/project.pbxproj index 7c9fb2f..118def9 100644 --- a/TimeMachineStatus.xcodeproj/project.pbxproj +++ b/TimeMachineStatus.xcodeproj/project.pbxproj @@ -7,6 +7,10 @@ objects = { /* Begin PBXBuildFile section */ + 280AF0D32CAC4B2C000B389B /* PreferencesFileImporterViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 280AF0D22CAC4B2C000B389B /* PreferencesFileImporterViewModifier.swift */; }; + 280AF0D52CAD33A1000B389B /* VisualEffectBackgroundViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 280AF0D42CAD33A1000B389B /* VisualEffectBackgroundViewModifier.swift */; }; + 280AF0D72CAD33D4000B389B /* HideWindowControlsViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 280AF0D62CAD33D4000B389B /* HideWindowControlsViewModifier.swift */; }; + 280AF0DA2CAD3409000B389B /* InitializeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 280AF0D92CAD3409000B389B /* InitializeView.swift */; }; 2818224F2AFCF8500067E564 /* MenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2818224E2AFCF8500067E564 /* MenuView.swift */; }; 281822512AFCF8750067E564 /* DestinationCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 281822502AFCF8750067E564 /* DestinationCell.swift */; }; 281822532AFCF97C0067E564 /* FormatStyle+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 281822522AFCF97C0067E564 /* FormatStyle+.swift */; }; @@ -25,8 +29,9 @@ 2818227C2AFE33CA0067E564 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2818227B2AFE33CA0067E564 /* Constants.swift */; }; 2818227E2AFE36780067E564 /* Bundle+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2818227D2AFE36780067E564 /* Bundle+.swift */; }; 28263F912B023D4E00F74655 /* HelperAppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28263F902B023D4E00F74655 /* HelperAppDelegate.swift */; }; - 28263FA02B023FD100F74655 /* TimeMachineStatusHelper.app in Copy Helper */ = {isa = PBXBuildFile; fileRef = 28263F8E2B023D4E00F74655 /* TimeMachineStatusHelper.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 28263FA32B023FFE00F74655 /* ServiceManagement.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 28263FA22B023FFE00F74655 /* ServiceManagement.framework */; }; + 28710D2F2CAF16AF00033855 /* Timer+Sendable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28710D2E2CAF16AF00033855 /* Timer+Sendable.swift */; }; + 28710D342CAFE7BB00033855 /* SFSafeSymbols in Frameworks */ = {isa = PBXBuildFile; productRef = 28710D332CAFE7BB00033855 /* SFSafeSymbols */; }; 2885D6652B024D0B00C260DB /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2885D6642B024D0B00C260DB /* main.swift */; }; 2888D17C2C99B3E80081FBBB /* KeyedDecodingContainer+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2888D17B2C99B3E80081FBBB /* KeyedDecodingContainer+.swift */; }; 288F12FB2B011A9300678FAD /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 288F12FA2B011A9300678FAD /* Localizable.xcstrings */; }; @@ -36,7 +41,6 @@ 28A002222AFBBFC400E2A01E /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 28A002212AFBBFC400E2A01E /* Preview Assets.xcassets */; }; 28A0024A2AFBC91500E2A01E /* ShellOut in Frameworks */ = {isa = PBXBuildFile; productRef = 28A002492AFBC91500E2A01E /* ShellOut */; }; 28A0024D2AFC02DA00E2A01E /* Bool+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28A0024C2AFC02DA00E2A01E /* Bool+.swift */; }; - 28A0024F2AFC030500E2A01E /* Symbols.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28A0024E2AFC030500E2A01E /* Symbols.swift */; }; 28A002522AFC03A200E2A01E /* TMUtility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28A002512AFC03A200E2A01E /* TMUtility.swift */; }; 28A002552AFC03E300E2A01E /* BackupState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28A002542AFC03E300E2A01E /* BackupState.swift */; }; 28A002582AFC042200E2A01E /* BackupStateError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28A002572AFC042200E2A01E /* BackupStateError.swift */; }; @@ -67,7 +71,6 @@ dstPath = Contents/Library/LoginItems; dstSubfolderSpec = 1; files = ( - 28263FA02B023FD100F74655 /* TimeMachineStatusHelper.app in Copy Helper */, ); name = "Copy Helper"; runOnlyForDeploymentPostprocessing = 0; @@ -75,6 +78,10 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 280AF0D22CAC4B2C000B389B /* PreferencesFileImporterViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesFileImporterViewModifier.swift; sourceTree = ""; }; + 280AF0D42CAD33A1000B389B /* VisualEffectBackgroundViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualEffectBackgroundViewModifier.swift; sourceTree = ""; }; + 280AF0D62CAD33D4000B389B /* HideWindowControlsViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HideWindowControlsViewModifier.swift; sourceTree = ""; }; + 280AF0D92CAD3409000B389B /* InitializeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InitializeView.swift; sourceTree = ""; }; 2818224E2AFCF8500067E564 /* MenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuView.swift; sourceTree = ""; }; 281822502AFCF8750067E564 /* DestinationCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DestinationCell.swift; sourceTree = ""; }; 281822522AFCF97C0067E564 /* FormatStyle+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FormatStyle+.swift"; sourceTree = ""; }; @@ -92,21 +99,21 @@ 281822792AFE292A0067E564 /* UserfacingError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserfacingError.swift; sourceTree = ""; }; 2818227B2AFE33CA0067E564 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; 2818227D2AFE36780067E564 /* Bundle+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+.swift"; sourceTree = ""; }; - 28263F8E2B023D4E00F74655 /* TimeMachineStatusHelper.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TimeMachineStatusHelper.app; sourceTree = BUILT_PRODUCTS_DIR; }; 28263F902B023D4E00F74655 /* HelperAppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HelperAppDelegate.swift; sourceTree = ""; }; 28263FA22B023FFE00F74655 /* ServiceManagement.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ServiceManagement.framework; path = System/Library/Frameworks/ServiceManagement.framework; sourceTree = SDKROOT; }; 28263FA42B0241E200F74655 /* TimeMachineStatusHelper.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = TimeMachineStatusHelper.entitlements; sourceTree = ""; }; + 28710D2E2CAF16AF00033855 /* Timer+Sendable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Timer+Sendable.swift"; sourceTree = ""; }; + 28710D302CAFE3CE00033855 /* TimeMachineStatus.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TimeMachineStatus.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 28710D312CAFE3CE00033855 /* TimeMachineStatusHelper.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TimeMachineStatusHelper.app; sourceTree = BUILT_PRODUCTS_DIR; }; 2885D6642B024D0B00C260DB /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; 2888D17B2C99B3E80081FBBB /* KeyedDecodingContainer+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KeyedDecodingContainer+.swift"; sourceTree = ""; }; 288F12FA2B011A9300678FAD /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; - 28A002172AFBBFC300E2A01E /* TimeMachineStatus.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TimeMachineStatus.app; sourceTree = BUILT_PRODUCTS_DIR; }; 28A0021A2AFBBFC300E2A01E /* TimeMachineStatusApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeMachineStatusApp.swift; sourceTree = ""; }; 28A0021C2AFBBFC300E2A01E /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 28A0021E2AFBBFC400E2A01E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 28A002212AFBBFC400E2A01E /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 28A002232AFBBFC400E2A01E /* TimeMachineStatus.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = TimeMachineStatus.entitlements; sourceTree = ""; }; 28A0024C2AFC02DA00E2A01E /* Bool+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bool+.swift"; sourceTree = ""; }; - 28A0024E2AFC030500E2A01E /* Symbols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Symbols.swift; sourceTree = ""; }; 28A002512AFC03A200E2A01E /* TMUtility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TMUtility.swift; sourceTree = ""; }; 28A002542AFC03E300E2A01E /* BackupState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupState.swift; sourceTree = ""; }; 28A002572AFC042200E2A01E /* BackupStateError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupStateError.swift; sourceTree = ""; }; @@ -141,6 +148,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 28710D342CAFE7BB00033855 /* SFSafeSymbols in Frameworks */, 28F2D4452B02F2F300B75998 /* Sparkle in Frameworks */, 281822622AFD40920067E564 /* Logging in Frameworks */, 28A0024A2AFBC91500E2A01E /* ShellOut in Frameworks */, @@ -160,6 +168,7 @@ 28FAD5A82AFE9BCB00F642E7 /* DestinationInfoView.swift */, 28A0021C2AFBBFC300E2A01E /* SettingsView.swift */, 2818225A2AFD10200067E564 /* StatusBarItem.swift */, + 280AF0D92CAD3409000B389B /* InitializeView.swift */, ); path = Views; sourceTree = ""; @@ -167,6 +176,9 @@ 281822542AFCF98E0067E564 /* Components */ = { isa = PBXGroup; children = ( + 280AF0D22CAC4B2C000B389B /* PreferencesFileImporterViewModifier.swift */, + 280AF0D62CAD33D4000B389B /* HideWindowControlsViewModifier.swift */, + 280AF0D42CAD33A1000B389B /* VisualEffectBackgroundViewModifier.swift */, 281822562AFCF9B20067E564 /* CustomButtonStyle.swift */, 28FAD5AA2AFE9BF300F642E7 /* CustomLabeledContentStyle.swift */, 28FAD5A62AFE3DBF00F642E7 /* CardViewModifier.swift */, @@ -220,8 +232,8 @@ 28A002182AFBBFC300E2A01E /* Products */ = { isa = PBXGroup; children = ( - 28A002172AFBBFC300E2A01E /* TimeMachineStatus.app */, - 28263F8E2B023D4E00F74655 /* TimeMachineStatusHelper.app */, + 28710D302CAFE3CE00033855 /* TimeMachineStatus.app */, + 28710D312CAFE3CE00033855 /* TimeMachineStatusHelper.app */, ); name = Products; sourceTree = ""; @@ -232,7 +244,6 @@ 28F2D4482B03000900B75998 /* Info.plist */, 281822582AFCFAE50067E564 /* AppDelegate.swift */, 28A0021A2AFBBFC300E2A01E /* TimeMachineStatusApp.swift */, - 28A0024E2AFC030500E2A01E /* Symbols.swift */, 2818224D2AFCF83D0067E564 /* Views */, 281822542AFCF98E0067E564 /* Components */, 28A002562AFC041700E2A01E /* Error */, @@ -263,6 +274,7 @@ 281822522AFCF97C0067E564 /* FormatStyle+.swift */, 281822662AFD86AC0067E564 /* Color+RawRepresentable.swift */, 2888D17B2C99B3E80081FBBB /* KeyedDecodingContainer+.swift */, + 28710D2E2CAF16AF00033855 /* Timer+Sendable.swift */, ); path = Extensions; sourceTree = ""; @@ -347,7 +359,7 @@ ); name = TimeMachineStatusHelper; productName = TimeMachineStatusHelper; - productReference = 28263F8E2B023D4E00F74655 /* TimeMachineStatusHelper.app */; + productReference = 28710D312CAFE3CE00033855 /* TimeMachineStatusHelper.app */; productType = "com.apple.product-type.application"; }; 28A002162AFBBFC300E2A01E /* TimeMachineStatus */ = { @@ -370,9 +382,10 @@ 281822612AFD40920067E564 /* Logging */, 281822642AFD438B0067E564 /* LoggingOSLog */, 28F2D4442B02F2F300B75998 /* Sparkle */, + 28710D332CAFE7BB00033855 /* SFSafeSymbols */, ); productName = TimeMachineStatus; - productReference = 28A002172AFBBFC300E2A01E /* TimeMachineStatus.app */; + productReference = 28710D302CAFE3CE00033855 /* TimeMachineStatus.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ @@ -383,7 +396,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1500; - LastUpgradeCheck = 1500; + LastUpgradeCheck = 1600; ORGANIZATIONNAME = "Lukas Pistrol"; TargetAttributes = { 28263F8D2B023D4E00F74655 = { @@ -410,6 +423,7 @@ 281822602AFD40920067E564 /* XCRemoteSwiftPackageReference "swift-log" */, 281822632AFD438B0067E564 /* XCRemoteSwiftPackageReference "swift-log-oslog" */, 28F2D4432B02F2F300B75998 /* XCRemoteSwiftPackageReference "Sparkle" */, + 28710D322CAFE7BB00033855 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */, ); productRefGroup = 28A002182AFBBFC300E2A01E /* Products */; projectDirPath = ""; @@ -479,14 +493,18 @@ buildActionMask = 2147483647; files = ( 28FAD5A72AFE3DBF00F642E7 /* CardViewModifier.swift in Sources */, + 28710D2F2CAF16AF00033855 /* Timer+Sendable.swift in Sources */, 2818226E2AFD8EC30067E564 /* _InProgressState.swift in Sources */, 28A0021D2AFBBFC300E2A01E /* SettingsView.swift in Sources */, 28A0025C2AFC04B300E2A01E /* Idle.swift in Sources */, 28A002522AFC03A200E2A01E /* TMUtility.swift in Sources */, + 280AF0D32CAC4B2C000B389B /* PreferencesFileImporterViewModifier.swift in Sources */, 281822672AFD86AC0067E564 /* Color+RawRepresentable.swift in Sources */, 2818224F2AFCF8500067E564 /* MenuView.swift in Sources */, 28A002622AFC052B00E2A01E /* FindingChanges.swift in Sources */, 2818226A2AFD8E670067E564 /* _State.swift in Sources */, + 280AF0D52CAD33A1000B389B /* VisualEffectBackgroundViewModifier.swift in Sources */, + 280AF0D72CAD33D4000B389B /* HideWindowControlsViewModifier.swift in Sources */, 28A002602AFC050200E2A01E /* Preparing.swift in Sources */, 2818227C2AFE33CA0067E564 /* Constants.swift in Sources */, 2818225B2AFD10200067E564 /* StatusBarItem.swift in Sources */, @@ -496,7 +514,6 @@ 28FAD5AD2AFF0D7200F642E7 /* ExpandableSection.swift in Sources */, 2818225F2AFD3FF20067E564 /* Stopping.swift in Sources */, 2888D17C2C99B3E80081FBBB /* KeyedDecodingContainer+.swift in Sources */, - 28A0024F2AFC030500E2A01E /* Symbols.swift in Sources */, 28A0025E2AFC04D300E2A01E /* Mounting.swift in Sources */, 28FAD5AF2AFF0D9600F642E7 /* UserfacingErrorView.swift in Sources */, 2818227A2AFE292A0067E564 /* UserfacingError.swift in Sources */, @@ -504,6 +521,7 @@ 28A0021B2AFBBFC300E2A01E /* TimeMachineStatusApp.swift in Sources */, 28A002582AFC042200E2A01E /* BackupStateError.swift in Sources */, 28A0024D2AFC02DA00E2A01E /* Bool+.swift in Sources */, + 280AF0DA2CAD3409000B389B /* InitializeView.swift in Sources */, 28A002662AFC056600E2A01E /* Copying.swift in Sources */, 28A002642AFC055000E2A01E /* Thinning.swift in Sources */, 281822572AFCF9B20067E564 /* CustomButtonStyle.swift in Sources */, @@ -530,7 +548,8 @@ CODE_SIGN_ENTITLEMENTS = TimeMachineStatusHelper/TimeMachineStatusHelper.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 18; + CURRENT_PROJECT_VERSION = 20; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = NO; GENERATE_INFOPLIST_FILE = YES; @@ -543,7 +562,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.1.4; + MARKETING_VERSION = 0.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.lukaspistrol.TimeMachineStatusHelper; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -559,7 +578,8 @@ CODE_SIGN_ENTITLEMENTS = TimeMachineStatusHelper/TimeMachineStatusHelper.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 18; + CURRENT_PROJECT_VERSION = 20; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = NO; GENERATE_INFOPLIST_FILE = YES; @@ -572,7 +592,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.1.4; + MARKETING_VERSION = 0.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.lukaspistrol.TimeMachineStatusHelper; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -586,6 +606,7 @@ buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -615,6 +636,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -649,6 +671,7 @@ buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -678,6 +701,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -707,7 +731,8 @@ CODE_SIGN_ENTITLEMENTS = TimeMachineStatus/TimeMachineStatus.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 18; + CURRENT_PROJECT_VERSION = 20; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"TimeMachineStatus/Preview Content\""; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = NO; @@ -724,7 +749,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.1.4; + MARKETING_VERSION = 0.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.lukaspistrol.TimeMachineStatus; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -741,7 +766,8 @@ CODE_SIGN_ENTITLEMENTS = TimeMachineStatus/TimeMachineStatus.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 18; + CURRENT_PROJECT_VERSION = 20; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"TimeMachineStatus/Preview Content\""; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = NO; @@ -758,7 +784,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.1.4; + MARKETING_VERSION = 0.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.lukaspistrol.TimeMachineStatus; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -816,6 +842,14 @@ minimumVersion = 0.2.2; }; }; + 28710D322CAFE7BB00033855 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/SFSafeSymbols/SFSafeSymbols.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 5.3.0; + }; + }; 28A002482AFBC91500E2A01E /* XCRemoteSwiftPackageReference "ShellOut" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/JohnSundell/ShellOut.git"; @@ -845,6 +879,11 @@ package = 281822632AFD438B0067E564 /* XCRemoteSwiftPackageReference "swift-log-oslog" */; productName = LoggingOSLog; }; + 28710D332CAFE7BB00033855 /* SFSafeSymbols */ = { + isa = XCSwiftPackageProductDependency; + package = 28710D322CAFE7BB00033855 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */; + productName = SFSafeSymbols; + }; 28A002492AFBC91500E2A01E /* ShellOut */ = { isa = XCSwiftPackageProductDependency; package = 28A002482AFBC91500E2A01E /* XCRemoteSwiftPackageReference "ShellOut" */; diff --git a/TimeMachineStatus.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/TimeMachineStatus.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 29cde41..880c6bc 100644 --- a/TimeMachineStatus.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/TimeMachineStatus.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,15 @@ { + "originHash" : "90288fc062e15ae6d002014a29c6e503504dcbb9aecef39d93ed46eaaa54b1b9", "pins" : [ + { + "identity" : "sfsafesymbols", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SFSafeSymbols/SFSafeSymbols.git", + "state" : { + "revision" : "e2e28f4e56e1769c2ec3c61c9355fc64eb7a535a", + "version" : "5.3.0" + } + }, { "identity" : "shellout", "kind" : "remoteSourceControl", @@ -23,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log.git", "state" : { - "revision" : "532d8b529501fb73a2455b179e0bbb6d49b652ed", - "version" : "1.5.3" + "revision" : "9cb486020ebf03bfa5b5df985387a14a98744537", + "version" : "1.6.1" } }, { @@ -37,5 +47,5 @@ } } ], - "version" : 2 + "version" : 3 } diff --git a/TimeMachineStatus/AppDelegate.swift b/TimeMachineStatus/AppDelegate.swift index 81f9e3e..c4fa421 100644 --- a/TimeMachineStatus/AppDelegate.swift +++ b/TimeMachineStatus/AppDelegate.swift @@ -43,7 +43,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { return windowController }() - @MainActor let utility: TMUtility = .init() + @MainActor let utility: TMUtilityImpl = .init() var updaterController: SPUStandardUpdaterController! diff --git a/TimeMachineStatus/Components/HideWindowControlsViewModifier.swift b/TimeMachineStatus/Components/HideWindowControlsViewModifier.swift new file mode 100644 index 0000000..8c25922 --- /dev/null +++ b/TimeMachineStatus/Components/HideWindowControlsViewModifier.swift @@ -0,0 +1,43 @@ +// +// HideWindowControlsViewModifier.swift +// TimeMachineStatus +// +// Created by Lukas Pistrol on 02.10.24. +// +// Copyright © 2024 Lukas Pistrol. All rights reserved. +// +// See LICENSE.md for license information. +// + +import SwiftUI + +private struct HideWindowControllsViewModifier: ViewModifier { + + let types: [NSWindow.ButtonType] + + func body(content: Content) -> some View { + content + .onReceive(NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification)) { notification in + guard let window = NSApplication.shared.windows.first(where: { $0.isKeyWindow }) else { return } + hideWindowControls(for: window) + } + } + + private func hideWindowControls(for window: NSWindow) { + types.forEach { + window.standardWindowButton($0)?.isHidden = true + } + } +} + +extension View { + func hideWindowControls( + _ types: [NSWindow.ButtonType] = [ + .closeButton, + .miniaturizeButton, + .zoomButton + ] + ) -> some View { + modifier(HideWindowControllsViewModifier(types: types)) + } +} diff --git a/TimeMachineStatus/Components/PreferencesFileImporterViewModifier.swift b/TimeMachineStatus/Components/PreferencesFileImporterViewModifier.swift new file mode 100644 index 0000000..c58687f --- /dev/null +++ b/TimeMachineStatus/Components/PreferencesFileImporterViewModifier.swift @@ -0,0 +1,35 @@ +// +// PreferencesFileImporterViewModifier.swift +// TimeMachineStatus +// +// Created by Lukas Pistrol on 01.10.24. +// +// Copyright © 2024 Lukas Pistrol. All rights reserved. +// +// See LICENSE.md for license information. +// + +import SwiftUI + +struct PreferencesFileImporterViewModifier: ViewModifier { + @Binding var showPicker: Bool + + func body(content: Content) -> some View { + content + .fileImporter(isPresented: $showPicker, allowedContentTypes: [.propertyList]) { _ in } + .fileDialogDefaultDirectory(Constants.URLs.timeMachinePreferencesPlist) + .fileDialogMessage(Text("dialog_label_select_file_\(lastPathComponent)")) + .fileDialogConfirmationLabel(Text("button_select")) + .fileDialogURLEnabled(#Predicate { url in + url.lastPathComponent == lastPathComponent + }) + } + + private var lastPathComponent: String { Constants.URLs.timeMachinePreferencesPlist.lastPathComponent } +} + +extension View { + func preferencesFileImporter(_ showPicker: Binding) -> some View { + modifier(PreferencesFileImporterViewModifier(showPicker: showPicker)) + } +} diff --git a/TimeMachineStatus/Components/UserfacingErrorView.swift b/TimeMachineStatus/Components/UserfacingErrorView.swift index bb84162..9dc4f66 100644 --- a/TimeMachineStatus/Components/UserfacingErrorView.swift +++ b/TimeMachineStatus/Components/UserfacingErrorView.swift @@ -14,12 +14,14 @@ import SwiftUI struct UserfacingErrorView: View { let error: UserfacingError? + @State private var openPreferencesFile: Bool = false + @ViewBuilder var body: some View { if let error { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 4) { - Symbols.exclamationMarkTriangleFill.image + Image(systemSymbol: .exclamationmarkTriangleFill) .foregroundStyle(.red) Text(error.title) .frame(maxWidth: .infinity, alignment: .leading) @@ -35,14 +37,22 @@ struct UserfacingErrorView: View { } if let action = error.action { Divider() - Button(action.title) { - NSWorkspace.shared.open(action.url) + switch action { + case .link(let title, let url): + Button(title) { + NSWorkspace.shared.open(url) + } + case .grantAccess: + Button("button_grant_access") { + openPreferencesFile = true + } } } } .padding(8) .card(.bar) .padding() + .preferencesFileImporter($openPreferencesFile) } } } diff --git a/TimeMachineStatus/Components/VisualEffectBackgroundViewModifier.swift b/TimeMachineStatus/Components/VisualEffectBackgroundViewModifier.swift new file mode 100644 index 0000000..a87b902 --- /dev/null +++ b/TimeMachineStatus/Components/VisualEffectBackgroundViewModifier.swift @@ -0,0 +1,56 @@ +// +// VisualEffectBackgroundViewModifier.swift +// TimeMachineStatus +// +// Created by Lukas Pistrol on 02.10.24. +// +// Copyright © 2024 Lukas Pistrol. All rights reserved. +// +// See LICENSE.md for license information. +// + +import SwiftUI + +private struct VisualEffectBackgroundViewModifier: ViewModifier { + + let material: NSVisualEffectView.Material + let state: NSVisualEffectView.State + + func body(content: Content) -> some View { + content + .background { + _VisualEffectView(material: material, state: state) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea() + } + } +} + +extension View { + @ViewBuilder + func backgroundVisualEffect( + _ material: NSVisualEffectView.Material, + state: NSVisualEffectView.State = .active + ) -> some View { + if isPreview { + self + } else { + modifier(VisualEffectBackgroundViewModifier(material: material, state: state)) + } + } +} + +private struct _VisualEffectView: NSViewRepresentable { + + let material: NSVisualEffectView.Material + let state: NSVisualEffectView.State + + func makeNSView(context: Context) -> NSVisualEffectView { + let effectView = NSVisualEffectView() + effectView.material = material + effectView.state = state + return effectView + } + + func updateNSView(_ nsView: NSVisualEffectView, context: Context) {} +} diff --git a/TimeMachineStatus/Error/UserfacingError.swift b/TimeMachineStatus/Error/UserfacingError.swift index 498023a..40a0aaa 100644 --- a/TimeMachineStatus/Error/UserfacingError.swift +++ b/TimeMachineStatus/Error/UserfacingError.swift @@ -12,13 +12,13 @@ import SwiftUI enum UserfacingError: Error { - case fullDiskPermissionDenied + case preferencesFilePermissionNotGranted case debugError(error: Error) var title: LocalizedStringKey { switch self { - case .fullDiskPermissionDenied: - return "error_fulldiskpermissiondenied_title" + case .preferencesFilePermissionNotGranted: + return "button_grant_access" case .debugError: return "error_debug_title" } @@ -26,8 +26,8 @@ enum UserfacingError: Error { var failureReason: LocalizedStringKey? { switch self { - case .fullDiskPermissionDenied: - return "error_fulldiskpermissiondenied_description" + case .preferencesFilePermissionNotGranted: + return "settings_item_preferences_file_permission" case .debugError(let error): return "error_debug_description\(String(describing: error))" } @@ -35,17 +35,14 @@ enum UserfacingError: Error { var action: Action? { switch self { - case .fullDiskPermissionDenied: - Action( - title: "button_opensystemsettings", - url: Constants.URLs.settingsFullDiskAccess - ) - default: nil + case .preferencesFilePermissionNotGranted: + return .grantAccess + default: return nil } } - struct Action { - let title: LocalizedStringKey - let url: URL + enum Action { + case link(title: LocalizedStringKey, url: URL) + case grantAccess } } diff --git a/TimeMachineStatus/Extensions/Bool+.swift b/TimeMachineStatus/Extensions/Bool+.swift index 75e420b..12b5e02 100644 --- a/TimeMachineStatus/Extensions/Bool+.swift +++ b/TimeMachineStatus/Extensions/Bool+.swift @@ -13,7 +13,7 @@ import SwiftUI extension Bool { var image: some View { - Image(systemName: self ? Symbols.checkmarkCircleFill() : Symbols.xmarkCircleFill()) + Image(systemSymbol: self ? .checkmarkCircleFill : .xmarkCircleFill) .foregroundStyle(self ? .green : .red) } } diff --git a/TimeMachineStatus/Extensions/Timer+Sendable.swift b/TimeMachineStatus/Extensions/Timer+Sendable.swift new file mode 100644 index 0000000..de61b2e --- /dev/null +++ b/TimeMachineStatus/Extensions/Timer+Sendable.swift @@ -0,0 +1,14 @@ +// +// Timer+Sendable.swift +// TimeMachineStatus +// +// Created by Lukas Pistrol on 03.10.24. +// +// Copyright © 2024 Lukas Pistrol. All rights reserved. +// +// See LICENSE.md for license information. +// + +import Foundation + +extension Timer: @unchecked @retroactive Sendable {} diff --git a/TimeMachineStatus/Localizable.xcstrings b/TimeMachineStatus/Localizable.xcstrings index 98768c1..ec4d018 100644 --- a/TimeMachineStatus/Localizable.xcstrings +++ b/TimeMachineStatus/Localizable.xcstrings @@ -153,6 +153,29 @@ } } }, + "button_close" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schließen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Close" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vicino" + } + } + } + }, "button_feedback" : { "localizations" : { "de" : { @@ -197,6 +220,29 @@ } } }, + "button_grant_access" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zugriff erlauben" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Grant Access" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Concedi l'accesso" + } + } + } + }, "button_opensystemsettings" : { "extractionState" : "manual", "localizations" : { @@ -243,6 +289,29 @@ } } }, + "button_select" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Auswählen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Select" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Selezionare" + } + } + } + }, "button_show_info" : { "extractionState" : "manual", "localizations" : { @@ -748,6 +817,29 @@ } } }, + "dialog_label_select_file_%@" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die \"%@\" Datei auswählen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Select the \"%@\" file" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Selezionare il file \"%@\"" + } + } + } + }, "error_debug_description%@" : { "localizations" : { "de" : { @@ -1113,6 +1205,29 @@ } } }, + "label_initializing" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Initialisiere…" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Initializing..." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inizializzazione..." + } + } + } + }, "label_lastbackup_%@_on_%@" : { "extractionState" : "manual", "localizations" : { @@ -1654,6 +1769,29 @@ } } }, + "settings_item_preferences_file_permission" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Damit TimeMachineStatus funktioniert, ist Zugriff auf die Time Machine Einstellungsdatei erforderlich." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "TimeMachineStatus requires access to the Time Machine preferences file in order to work." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "TimeMachineStatus richiede l'accesso al file delle preferenze di Time Machine per funzionare." + } + } + } + }, "settings_item_showpercentage" : { "localizations" : { "de" : { diff --git a/TimeMachineStatus/Model/Preferences/Preferences.swift b/TimeMachineStatus/Model/Preferences/Preferences.swift index 088a47f..c017406 100644 --- a/TimeMachineStatus/Model/Preferences/Preferences.swift +++ b/TimeMachineStatus/Model/Preferences/Preferences.swift @@ -38,10 +38,36 @@ struct Preferences: Decodable { String.self, forKey: .localizedDiskImageVolumeName ) - self.destinations = try container.decodeIfPresent([Destination].self, forKey: .destinations) + self.destinations = try container + .decodeIfPresent([Destination].self, forKey: .destinations)? + .sorted {$0.lastKnownVolumeName ?? "" < $1.lastKnownVolumeName ?? "" } self.skipPaths = try container.decodeIfPresent([String].self, forKey: .skipPaths) } + private init( + autoBackup: Bool?, + autoBackupInterval: Int?, + excludedVolumeUUIDs: [UUID]?, + preferencesVersion: Int, + requiresACPower: Bool?, + lastConfigurationTraceDate: Date?, + lastDestinationID: UUID?, + localizedDiskImageVolumeName: String?, + skipPaths: [String]?, + destinations: [Destination]? + ) { + self.autoBackup = autoBackup + self.autoBackupInterval = autoBackupInterval + self.excludedVolumeUUIDs = excludedVolumeUUIDs + self.preferencesVersion = preferencesVersion + self.requiresACPower = requiresACPower + self.lastConfigurationTraceDate = lastConfigurationTraceDate + self.lastDestinationID = lastDestinationID + self.localizedDiskImageVolumeName = localizedDiskImageVolumeName + self.skipPaths = skipPaths + self.destinations = destinations + } + let autoBackup: Bool? let autoBackupInterval: Int? let excludedVolumeUUIDs: [UUID]? @@ -94,3 +120,37 @@ struct Destination: Decodable { let snapshotDates: [Date]? let attemptDates: [Date]? } + +extension Preferences { + static let mock = Preferences( + autoBackup: false, + autoBackupInterval: 0, + excludedVolumeUUIDs: nil, + preferencesVersion: 1, + requiresACPower: true, + lastConfigurationTraceDate: .distantPast, + lastDestinationID: nil, + localizedDiskImageVolumeName: "Backups of XX", + skipPaths: nil, + destinations: [.mock(name: "Test Drive 1"), .mock(name: "Test Drive 2", network: true)] + ) +} + +extension Destination { + static func mock(name: String, network: Bool = false) -> Self { + Destination( + lastKnownVolumeName: name, + bytesUsed: .random(in: 100_000_000_000...500_000_000_000), + bytesAvailable: .random(in: 10_000_000_000...100_000_000_000), + filesystemTypeName: "APFS", + lastKnownEncryptionState: "Encrypted", + quotaGB: .random(in: 400...1000), + networkURL: network ? "smb://nas.local/share" : nil, + destinationID: UUID(), + consistencyScanDate: .distantPast, + referenceLocalSnapshotDate: .now, + snapshotDates: [.distantPast, .now.addingTimeInterval(.random(in: -100000...0))], + attemptDates: [.distantPast, .now.addingTimeInterval(.random(in: -100000...0))] + ) + } +} diff --git a/TimeMachineStatus/Symbols.swift b/TimeMachineStatus/Symbols.swift deleted file mode 100644 index e24896f..0000000 --- a/TimeMachineStatus/Symbols.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// Symbols.swift -// TimeMachineStatus -// -// Created by Lukas Pistrol on 2023-11-10. -// -// Copyright © 2023 Lukas Pistrol. All rights reserved. -// -// See LICENSE.md for license information. -// - -import SwiftUI - -enum Symbols: String { - case arrowTriangleCirclepath = "arrow.triangle.2.circlepath" - case checkmarkCircleFill = "checkmark.circle.fill" - case exclamationMarkTriangleFill = "exclamationmark.triangle.fill" - case externalDrive = "externaldrive.fill.badge.timemachine" - case gear - case gearshapeFill = "gearshape.fill" - case infoCircle = "info.circle" - case nasDrive = "externaldrive.fill.badge.wifi" - case playFill = "play.fill" - case stopFill = "stop.fill" - case timeMachine = "clock.arrow.circlepath" - case wandAndStarsInverse = "wand.and.stars.inverse" - case xmarkCircleFill = "xmark.circle.fill" - - var image: Image { - Image(systemName: self.rawValue) - } - - var name: String { - self.rawValue - } - - func nsImage(accessibilityDescription: String? = nil) -> NSImage? { - return NSImage(systemSymbolName: self.rawValue, accessibilityDescription: accessibilityDescription) - } - - func callAsFunction() -> String { - return self.rawValue - } -} diff --git a/TimeMachineStatus/TimeMachineStatusApp.swift b/TimeMachineStatus/TimeMachineStatusApp.swift index dde0c47..798012d 100644 --- a/TimeMachineStatus/TimeMachineStatusApp.swift +++ b/TimeMachineStatus/TimeMachineStatusApp.swift @@ -12,6 +12,7 @@ import Logging import LoggingOSLog import SwiftUI +@_exported import SFSafeSymbols @main struct TimeMachineStatusApp: App { @@ -36,12 +37,26 @@ struct TimeMachineStatusApp: App { } var body: some Scene { + initializeScene + settingsScene + } - /* The menu bar item and menu view are set up in AppDelegate */ + private var initializeScene: some Scene { + WindowGroup { + InitializeView(utility: appDelegate.utility) + } + .defaultSize(width: 300, height: 200) + .windowResizability(.contentSize) + .windowStyle(.hiddenTitleBar) + .windowToolbarStyle(.unifiedCompact) + } + private var settingsScene: some Scene { Settings { - SettingsView(updater: appDelegate.updaterController.updater) - .environmentObject(appDelegate.utility) + SettingsView( + updater: appDelegate.updaterController.updater, + utility: appDelegate.utility + ) } } } diff --git a/TimeMachineStatus/ViewModel/TMUtility.swift b/TimeMachineStatus/ViewModel/TMUtility.swift index 01965fd..d416b7d 100644 --- a/TimeMachineStatus/ViewModel/TMUtility.swift +++ b/TimeMachineStatus/ViewModel/TMUtility.swift @@ -14,11 +14,70 @@ import Logging import ShellOut @MainActor -class TMUtility: ObservableObject { - @Published var status: BackupState._State = BackupState.None() - @Published var preferences: Preferences? - @Published var lastUpdated: Date? - @Published var error: UserfacingError? +protocol TMUtility { + var status: BackupState._State { get set } + var preferences: Preferences? { get set } + var lastUpdated: Date? { get set } + var canReadPreferences: Bool { get } + var error: UserfacingError? { get set } + + var isIdle: Bool { get } + + func start(force: Bool) + func startBackup(id: UUID?) + func stopBackup() + func launchTimeMachine() +} + +extension TMUtility { + func startBackup() { + startBackup(id: nil) + } +} + +@MainActor +@Observable +class TMUtilityMock: TMUtility { + var status: BackupState._State = BackupState.None() + var preferences: Preferences? + var lastUpdated: Date? + var error: UserfacingError? + var canReadPreferences: Bool { _canReadPreferences } + + private var _canReadPreferences: Bool + + var isIdle: Bool { !status.running } + + init( + status: BackupState._State = BackupState.None(), + preferences: Preferences? = nil, + lastUpdated: Date? = nil, + error: UserfacingError? = nil, + canReadPreferences: Bool = true + ) { + self.status = status + self.preferences = preferences + self.lastUpdated = lastUpdated + self.error = error + self._canReadPreferences = canReadPreferences + } + + func start(force: Bool = false) {} + + func startBackup(id: UUID? = nil) {} + + func stopBackup() {} + + func launchTimeMachine() {} +} + +@MainActor +@Observable +class TMUtilityImpl: TMUtility { + var status: BackupState._State = BackupState.None() + var preferences: Preferences? + var lastUpdated: Date? + var error: UserfacingError? let log = Logger(label: "\(Bundle.identifier).TMUtility") @@ -26,13 +85,25 @@ class TMUtility: ObservableObject { private var timer: Timer? + var canReadPreferences: Bool { + do { + let _ = try Data(contentsOf: Constants.URLs.timeMachinePreferencesPlist) + return true + } catch { + return false + } + } + init() { readPreferences() start(force: true) } deinit { - timer?.invalidate() + Task { [weak self] in + guard let timer = await self?.timer else { return } + timer.invalidate() + } } func start(force: Bool = false) { @@ -72,7 +143,7 @@ class TMUtility: ObservableObject { } catch { log.error("Error reading preferences: \(error)") if (error as NSError).code == 257 { - self.error = UserfacingError.fullDiskPermissionDenied + self.error = UserfacingError.preferencesFilePermissionNotGranted } else { self.error = UserfacingError.debugError(error: error) } diff --git a/TimeMachineStatus/Views/DestinationCell.swift b/TimeMachineStatus/Views/DestinationCell.swift index a447de0..f738f96 100644 --- a/TimeMachineStatus/Views/DestinationCell.swift +++ b/TimeMachineStatus/Views/DestinationCell.swift @@ -12,12 +12,13 @@ import SwiftUI struct DestinationCell: View { - @EnvironmentObject private var utility: TMUtility + @State private var utility: any TMUtility let dest: Destination - init(_ dest: Destination) { + init(_ dest: Destination, utility: any TMUtility) { self.dest = dest + self.utility = utility } private var isActive: Bool { @@ -60,7 +61,9 @@ struct DestinationCell: View { .contextMenu { contextMenuActions } .card(.background.secondary) .onHover { hovering in - self.hovering = hovering + withAnimation(.snappy) { + self.hovering = hovering + } } .popover(isPresented: $showInfo) { DestinationInfoView(dest: dest) @@ -71,7 +74,7 @@ struct DestinationCell: View { private var hoverOverlay: some View { if hovering { Rectangle() - .fill(.fill.secondary) + .fill(.fill.secondary.opacity(0.5)) .allowsHitTesting(false) } } @@ -79,9 +82,9 @@ struct DestinationCell: View { private var symbol: some View { Group { if dest.networkURL != nil { - Symbols.nasDrive.image + Image(systemSymbol: .externaldriveFillBadgeWifi) } else { - Symbols.externalDrive.image + Image(systemSymbol: .externaldriveFillBadgeTimemachine) } } .imageScale(.large) @@ -134,12 +137,12 @@ struct DestinationCell: View { } } label: { if utility.status.activeDestinationID == dest.destinationID { - Symbols.stopFill.image + Image(systemSymbol: .stopFill) .resizable() .aspectRatio(contentMode: .fit) .frame(width: 13) } else { - Symbols.playFill.image + Image(systemSymbol: .playFill) .resizable() .aspectRatio(contentMode: .fit) .frame(width: 13) @@ -206,7 +209,7 @@ import Sparkle #Preview("Light") { MenuView( - utility: .init(), + utility: TMUtilityMock(preferences: .mock), updater: SPUStandardUpdaterController(updaterDelegate: nil, userDriverDelegate: nil).updater ) .preferredColorScheme(.light) @@ -214,7 +217,7 @@ import Sparkle #Preview("Dark") { MenuView( - utility: .init(), + utility: TMUtilityMock(preferences: .mock), updater: SPUStandardUpdaterController(updaterDelegate: nil, userDriverDelegate: nil).updater ) .preferredColorScheme(.dark) diff --git a/TimeMachineStatus/Views/InitializeView.swift b/TimeMachineStatus/Views/InitializeView.swift new file mode 100644 index 0000000..3814122 --- /dev/null +++ b/TimeMachineStatus/Views/InitializeView.swift @@ -0,0 +1,96 @@ +// +// InitializeView.swift +// TimeMachineStatus +// +// Created by Lukas Pistrol on 02.10.24. +// +// Copyright © 2024 Lukas Pistrol. All rights reserved. +// +// See LICENSE.md for license information. +// + +import SwiftUI + +struct InitializeView: View { + @Environment(\.dismiss) private var dismiss + + @State var utility: any TMUtility + + @State private var showPicker = false + + var body: some View { + Group { + if case .preferencesFilePermissionNotGranted = utility.error { + VStack { + VStack { + Image(.icon) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 64, height: 64) + Text("settings_item_preferences_file_permission") + .multilineTextAlignment(.center) + .font(.headline) + } + .padding(.bottom) + VStack { + Button { + showPicker = true + } label: { + Text("button_grant_access") + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } + .buttonStyle(.borderedProminent) + .controlSize(.extraLarge) + Button { + dismiss() + } label: { + Text("button_close") + .frame(maxWidth: .infinity) + } + .controlSize(.large) + } + } + } else { + VStack { + Image(.icon) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 64, height: 64) + HStack { + ProgressView() + .controlSize(.small) + Text("label_initializing") + } + } + } + } + .padding() + .padding(.top, -26) + .frame(width: 300) + .backgroundVisualEffect(.hudWindow) + .task { + if utility.canReadPreferences { + try? await Task.sleep(for: .seconds(1)) + dismiss() + } else { + utility.error = .preferencesFilePermissionNotGranted + } + } + .preferencesFileImporter($showPicker) + .hideWindowControls() + } +} + +#Preview("Access") { + InitializeView(utility: TMUtilityMock(canReadPreferences: true)) +} + +#Preview("No Access") { + InitializeView( + utility: TMUtilityMock( + error: .preferencesFilePermissionNotGranted, + canReadPreferences: false + ) + ) +} diff --git a/TimeMachineStatus/Views/MenuView.swift b/TimeMachineStatus/Views/MenuView.swift index b89b8bd..8f7db13 100644 --- a/TimeMachineStatus/Views/MenuView.swift +++ b/TimeMachineStatus/Views/MenuView.swift @@ -9,18 +9,15 @@ // See LICENSE.md for license information. // -import Combine import Sparkle import SwiftUI struct MenuView: View { - @Environment(\.openWindow) private var openWindow - - @ObservedObject private var utility: TMUtility + @State private var utility: any TMUtility @ObservedObject private var updaterViewModel: UpdaterViewModel private let updater: SPUUpdater - init(utility: TMUtility, updater: SPUUpdater) { + init(utility: any TMUtility, updater: SPUUpdater) { self.utility = utility self.updater = updater self.updaterViewModel = UpdaterViewModel(updater: updater) @@ -48,9 +45,8 @@ struct MenuView: View { ExpandableSection { VStack(alignment: .leading) { ForEach(destinations, id: \.destinationID) { dest in - DestinationCell(dest) + DestinationCell(dest, utility: utility) .frame(maxWidth: .infinity) - .environmentObject(utility) } } } header: { @@ -135,7 +131,7 @@ struct MenuView: View { utility.stopBackup() } } label: { - Label("button_startbackup", systemImage: utility.isIdle ? Symbols.playFill() : Symbols.stopFill()) + Label("button_startbackup", systemSymbol: utility.isIdle ? .playFill : .stopFill) } .focusable(false) toolbarStatus @@ -262,7 +258,15 @@ struct MenuView: View { #Preview("Light") { MenuView( - utility: .init(), + utility: TMUtilityMock(preferences: .mock), + updater: SPUStandardUpdaterController(updaterDelegate: nil, userDriverDelegate: nil).updater + ) + .preferredColorScheme(.light) +} + +#Preview("Light Error") { + MenuView( + utility: TMUtilityMock(preferences: .mock, error: .preferencesFilePermissionNotGranted, canReadPreferences: false), updater: SPUStandardUpdaterController(updaterDelegate: nil, userDriverDelegate: nil).updater ) .preferredColorScheme(.light) @@ -270,7 +274,7 @@ struct MenuView: View { #Preview("Dark") { MenuView( - utility: .init(), + utility: TMUtilityImpl(), updater: SPUStandardUpdaterController(updaterDelegate: nil, userDriverDelegate: nil).updater ) .preferredColorScheme(.dark) diff --git a/TimeMachineStatus/Views/SettingsView.swift b/TimeMachineStatus/Views/SettingsView.swift index 9a96ce5..f3f7359 100644 --- a/TimeMachineStatus/Views/SettingsView.swift +++ b/TimeMachineStatus/Views/SettingsView.swift @@ -69,7 +69,7 @@ struct SettingsView: View { @AppStorage(StorageKeys.logLevel.id) private var logLevel: Logger.Level = StorageKeys.logLevel.default - private enum Tabs: Hashable, CaseIterable { + enum Tabs: Hashable, CaseIterable { case general case appearance case about @@ -87,14 +87,17 @@ struct SettingsView: View { } } - @State private var selection: Tabs = .general + @State private var selection: Tabs @StateObject private var launchItemProvider = LaunchItemProvider() @ObservedObject private var updaterViewModel: UpdaterViewModel + @State private var utility: any TMUtility private let updater: SPUUpdater - init(updater: SPUUpdater) { + init(updater: SPUUpdater, utility: any TMUtility, selection: Tabs = .general) { self.updater = updater self.updaterViewModel = UpdaterViewModel(updater: updater) + self.utility = utility + self.selection = selection } var body: some View { @@ -109,19 +112,22 @@ struct SettingsView: View { ) } + @State private var showPicker: Bool = false + private var generalTab: some View { Form { - Section("settings_section_permissions") { - VStack(alignment: .leading) { - Text("settings_item_fulldiskaccess_title") - Text("settings_item_fulldiskaccess_description") - .font(.caption) - .foregroundStyle(.secondary) - } - HStack { - Spacer() - Button("button_opensystemsettings") { - NSWorkspace.shared.open(Constants.URLs.settingsFullDiskAccess) + if !utility.canReadPreferences { + Section("settings_section_permissions") { + VStack(alignment: .leading) { + Text("settings_item_preferences_file_permission") + .font(.callout) + } + HStack { + Spacer() + Button("button_grant_access") { + showPicker = true + } + .buttonStyle(.borderedProminent) } } } @@ -154,9 +160,10 @@ struct SettingsView: View { } .formStyle(.grouped) .tabItem { - Label("settings_tab_item_general", systemImage: Symbols.gear()) + Label("settings_tab_item_general", systemSymbol: .gear) } .tag(Tabs.general) + .preferencesFileImporter($showPicker) } private var appearandeTab: some View { @@ -239,7 +246,7 @@ struct SettingsView: View { } .formStyle(.grouped) .tabItem { - Label("settings_tab_item_appearance", systemImage: Symbols.wandAndStarsInverse()) + Label("settings_tab_item_appearance", systemSymbol: .wandAndStarsInverse) } .tag(Tabs.appearance) } @@ -268,12 +275,40 @@ struct SettingsView: View { .font(.caption2) } .tabItem { - Label("settings_tab_item_about", systemImage: Symbols.infoCircle()) + Label("settings_tab_item_about", systemSymbol: .infoCircle) } .tag(Tabs.about) } } -#Preview { - SettingsView(updater: SPUStandardUpdaterController(updaterDelegate: nil, userDriverDelegate: nil).updater) +#Preview("General/Default") { + SettingsView( + updater: SPUStandardUpdaterController(updaterDelegate: nil, userDriverDelegate: nil).updater, + utility: TMUtilityMock(), + selection: .general + ) +} + +#Preview("General/No Permission") { + SettingsView( + updater: SPUStandardUpdaterController(updaterDelegate: nil, userDriverDelegate: nil).updater, + utility: TMUtilityMock(error: .preferencesFilePermissionNotGranted, canReadPreferences: false), + selection: .general + ) +} + +#Preview("Appearance") { + SettingsView( + updater: SPUStandardUpdaterController(updaterDelegate: nil, userDriverDelegate: nil).updater, + utility: TMUtilityMock(), + selection: .appearance + ) +} + +#Preview("About") { + SettingsView( + updater: SPUStandardUpdaterController(updaterDelegate: nil, userDriverDelegate: nil).updater, + utility: TMUtilityMock(), + selection: .about + ) } diff --git a/TimeMachineStatus/Views/StatusBarItem.swift b/TimeMachineStatus/Views/StatusBarItem.swift index 3756b1a..edff2d8 100644 --- a/TimeMachineStatus/Views/StatusBarItem.swift +++ b/TimeMachineStatus/Views/StatusBarItem.swift @@ -56,21 +56,21 @@ struct StatusBarItem: View { private var animateIcon: Bool = StorageKeys.animateIcon.default var sizePassthrough: PassthroughSubject - @ObservedObject var utility: TMUtility + @State var utility: TMUtilityImpl private let log = Logger(label: "\(Bundle.identifier).StatusBarItem") private var mainContent: some View { HStack(spacing: spacing) { if utility.isIdle { - Symbols.timeMachine.image + Image(systemSymbol: .clockArrowCirclepath) .font(.body.weight(boldIcon ? .bold : .medium)) } else { if animateIcon { AnimatedIcon() .font(.body.weight(boldIcon ? .bold : .medium)) } else { - Symbols.arrowTriangleCirclepath.image + Image(systemSymbol: .arrowTriangle2Circlepath) .font(.body.weight(boldIcon ? .bold : .medium)) } } @@ -116,7 +116,7 @@ struct StatusBarItem: View { private var rotationAnimation: Animation = .linear(duration: 2).repeatForever(autoreverses: false) var body: some View { - Symbols.arrowTriangleCirclepath.image + Image(systemSymbol: .arrowTriangle2Circlepath) .rotationEffect(Angle(degrees: isAnimating ? 360 : 0), anchor: .center) .animation(rotationAnimation, value: isAnimating) .task {