diff --git a/.gitignore b/.gitignore index e8f44b64..ab849551 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ Pods/ ._.* xcuserdata buildServer.json +*.p8 diff --git a/Bark.xcodeproj/project.pbxproj b/Bark.xcodeproj/project.pbxproj index d03435d0..e2c9b210 100644 --- a/Bark.xcodeproj/project.pbxproj +++ b/Bark.xcodeproj/project.pbxproj @@ -3,10 +3,11 @@ archiveVersion = 1; classes = { }; - objectVersion = 54; + objectVersion = 70; objects = { /* Begin PBXBuildFile section */ + 000000000000000000000029 /* NotificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000000000000000000000028 /* NotificationTests.swift */; }; 0603706720E1E31600F4CA05 /* Defines.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0603706620E1E31600F4CA05 /* Defines.swift */; }; 0603706B20E20A7C00F4CA05 /* String+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0603706A20E20A7C00F4CA05 /* String+Extension.swift */; }; 0603706D20E23EC000F4CA05 /* BarkSFSafariViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0603706C20E23EC000F4CA05 /* BarkSFSafariViewController.swift */; }; @@ -82,6 +83,8 @@ 0647DB682CE604AA00102066 /* MessageSettingFooter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0647DB672CE604AA00102066 /* MessageSettingFooter.swift */; }; 0653677629B719BC0038BDB8 /* CryptoSettingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06F08EA329B098DD006AB9CA /* CryptoSettingManager.swift */; }; 0653677829B727A60038BDB8 /* CryptoSettingRelay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0653677729B727A60038BDB8 /* CryptoSettingRelay.swift */; }; + 065632CB3080A88400A0C5A4 /* Error+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06F08EA629B1DDFE006AB9CA /* Error+Extension.swift */; }; + 065632CC3080A88400A0C5A4 /* Error+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06F08EA629B1DDFE006AB9CA /* Error+Extension.swift */; }; 065752C12CB6844A001426D8 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 065752C02CB6844A001426D8 /* Localizable.xcstrings */; }; 065752C22CB6844A001426D8 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 065752C02CB6844A001426D8 /* Localizable.xcstrings */; }; 065752C32CB6844A001426D8 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 065752C02CB6844A001426D8 /* Localizable.xcstrings */; }; @@ -92,7 +95,6 @@ 065BE4502563D939002A8CA4 /* SoundCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 065BE44F2563D939002A8CA4 /* SoundCellViewModel.swift */; }; 065BE4552565055F002A8CA4 /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 065BE4542565055F002A8CA4 /* HomeViewModel.swift */; }; 0661A543204FDA4100965E4E /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0661A542204FDA4100965E4E /* AppDelegate.swift */; }; - 06A200022FED000100000001 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06A200012FED000100000001 /* SceneDelegate.swift */; }; 0661A545204FDA4100965E4E /* HomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0661A544204FDA4100965E4E /* HomeViewController.swift */; }; 0661A54A204FDA4100965E4E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0661A549204FDA4100965E4E /* Assets.xcassets */; }; 0661A54D204FDA4100965E4E /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 0661A54B204FDA4100965E4E /* LaunchScreen.storyboard */; }; @@ -146,9 +148,9 @@ 06A100152FEE0001000000A1 /* HomeSettingsCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06A100142FEE0001000000A1 /* HomeSettingsCard.swift */; }; 06A100172FEE0001000000A1 /* HomeDocumentsCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06A100162FEE0001000000A1 /* HomeDocumentsCard.swift */; }; 06A100192FEE0001000000A1 /* CodeHighlighter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06A100182FEE0001000000A1 /* CodeHighlighter.swift */; }; - 06C10A022FB20001000000D1 /* QRCodeGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06C10A012FB20001000000D1 /* QRCodeGenerator.swift */; }; 06A1001B2FEE0001000000A1 /* HomeFlatButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06A1001A2FEE0001000000A1 /* HomeFlatButton.swift */; }; 06A100212FEE0001000000A1 /* HomeSplitButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06A100202FEE0001000000A1 /* HomeSplitButton.swift */; }; + 06A200022FED000100000001 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06A200012FED000100000001 /* SceneDelegate.swift */; }; 06B1158D247BA6D5006D91FB /* CloudKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 06B1158C247BA6D5006D91FB /* CloudKit.framework */; }; 06B1158F247BB1FB006D91FB /* Message.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06B1158E247BB1FB006D91FB /* Message.swift */; }; 06B11591247BC132006D91FB /* Message.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06B1158E247BB1FB006D91FB /* Message.swift */; }; @@ -166,6 +168,8 @@ 06BCAE5B2CDB25120092867A /* MuteProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06BCAE5A2CDB25120092867A /* MuteProcessor.swift */; }; 06BD4DAA2901352E003364DB /* Object+Dictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06BD4DA92901352E003364DB /* Object+Dictionary.swift */; }; 06BE84042E6EAE7100E6F856 /* InsetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06BE84032E6EAE7100E6F856 /* InsetView.swift */; }; + 06C10A022FB20001000000D1 /* QRCodeGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06C10A012FB20001000000D1 /* QRCodeGenerator.swift */; }; + 06C10A042FB20001000000D1 /* PushQRCodeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06C10A032FB20001000000D1 /* PushQRCodeViewController.swift */; }; 06C2CF232685B88D0034B127 /* TextCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06C2CF222685B88D0034B127 /* TextCell.swift */; }; 06C2CF252685BDB80034B127 /* SpacerCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06C2CF242685BDB80034B127 /* SpacerCell.swift */; }; 06C5952D2480E3F8006B98F3 /* LabelCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06C5952C2480E3F8006B98F3 /* LabelCell.swift */; }; @@ -232,7 +236,6 @@ 06F08EA829B1DE0A006AB9CA /* Error+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06F08EA629B1DDFE006AB9CA /* Error+Extension.swift */; }; 06F08EAF29B5D9FF006AB9CA /* HUD.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06F08EAE29B5D9FF006AB9CA /* HUD.swift */; }; 06F11E7727D9D5FB00F00298 /* QRScannerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06F11E7627D9D5FB00F00298 /* QRScannerViewController.swift */; }; - 06C10A042FB20001000000D1 /* PushQRCodeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06C10A032FB20001000000D1 /* PushQRCodeViewController.swift */; }; 06F808912EF4056C0007352A /* AppDelegate+Realm.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06F808902EF4056C0007352A /* AppDelegate+Realm.swift */; }; 06FB04042C53575400F3A213 /* SharedDefines.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06FB04032C53575400F3A213 /* SharedDefines.swift */; }; 06FB04052C53575400F3A213 /* SharedDefines.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06FB04032C53575400F3A213 /* SharedDefines.swift */; }; @@ -240,13 +243,34 @@ 1E73F99E2C282822002BF649 /* SectionViewController-iPad.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E73F99D2C282822002BF649 /* SectionViewController-iPad.swift */; }; 1EFB545D2C314A6800B8E51B /* BarkSplitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EFB545C2C314A6800B8E51B /* BarkSplitViewController.swift */; }; 1EFB545F2C32514000B8E51B /* SectionViewModel-iPad.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EFB545E2C32514000B8E51B /* SectionViewModel-iPad.swift */; }; + 2FEAABEDC1A6FF1648620312 /* libPods-BarkWatchTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 35C5FA69E454ECCC30629574 /* libPods-BarkWatchTests.a */; }; 3428272069AFAFE2C683FEB0 /* libPods-Bark.a in Frameworks */ = {isa = PBXBuildFile; fileRef = CCC722470308049D180876C7 /* libPods-Bark.a */; }; 879AE4D4178855A9672009E4 /* libPods-NotificationServiceExtension.a in Frameworks */ = {isa = PBXBuildFile; fileRef = B7F8BDFAA047451561798F58 /* libPods-NotificationServiceExtension.a */; }; 88757E3ACE0EA6A48577C958 /* libPods-NotificationContentExtension.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 6616B1ACAA7E422700D90C2B /* libPods-NotificationContentExtension.a */; }; + A10000000000000000000006 /* WatchNotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000002 /* WatchNotificationService.swift */; }; + A10000000000000000000007 /* BarkWatchNotificationService.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = A10000000000000000000003 /* BarkWatchNotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + B20000000000000000000051 /* Markdown in Frameworks */ = {isa = PBXBuildFile; productRef = B20000000000000000000053 /* Markdown */; }; + B20000000000000000000052 /* Markdown in Frameworks */ = {isa = PBXBuildFile; productRef = B20000000000000000000054 /* Markdown */; }; B963F7D5BA7AC2571E71EF66 /* libPods-BarkTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 76381A752CCCD4DA6BB2A566 /* libPods-BarkTests.a */; }; + C30000000000000000000002 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C30000000000000000000001 /* Assets.xcassets */; }; + CFBE41F3462A6D4F0E1A4B12 /* libPods-BarkWatch.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 366FB0FC6350F5E40F91FC1D /* libPods-BarkWatch.a */; }; + E10000000000000000000000 /* Algorithm.swift in Sources */ = {isa = PBXBuildFile; fileRef = 061894C629A75BEA00E001C2 /* Algorithm.swift */; }; + E10000000000000000000001 /* MarkdownParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06C777CF2ECEFF210032A044 /* MarkdownParser.swift */; }; + E10000000000000000000002 /* UIFont+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0687F2A72CCB791A00B2A52F /* UIFont+Extension.swift */; }; + E10000000000000000000010 /* Algorithm.swift in Sources */ = {isa = PBXBuildFile; fileRef = 061894C629A75BEA00E001C2 /* Algorithm.swift */; }; + E10000000000000000000011 /* MarkdownParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06C777CF2ECEFF210032A044 /* MarkdownParser.swift */; }; + E10000000000000000000012 /* UIFont+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0687F2A72CCB791A00B2A52F /* UIFont+Extension.swift */; }; + E68AEB0E47866777F8755476 /* libPods-BarkWatchNotificationService.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 056A370B19AC77E9A43813B2 /* libPods-BarkWatchNotificationService.a */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 000000000000000000000050 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 0661A537204FDA4100965E4E /* Project object */; + proxyType = 1; + remoteGlobalIDString = 000000000000000000000004; + remoteInfo = BarkWatch; + }; 062E6D9F2F73B6A00080657A /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 0661A537204FDA4100965E4E /* Project object */; @@ -275,6 +299,13 @@ remoteGlobalIDString = 0661A53E204FDA4100965E4E; remoteInfo = Bark; }; + A10000000000000000000011 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 0661A537204FDA4100965E4E /* Project object */; + proxyType = 1; + remoteGlobalIDString = A10000000000000000000010; + remoteInfo = BarkWatchNotificationService; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -291,9 +322,26 @@ name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; }; + A10000000000000000000008 /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + A10000000000000000000007 /* BarkWatchNotificationService.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 000000000000000000000006 /* BarkWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; path = BarkWatch.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 000000000000000000000007 /* BarkWatchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; path = BarkWatchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 000000000000000000000028 /* NotificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationTests.swift; sourceTree = ""; }; + 000000000000000000000032 /* BarkWatch.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = BarkWatch.entitlements; sourceTree = ""; }; + 000000000000000000000033 /* TestNotification.apns */ = {isa = PBXFileReference; lastKnownFileType = text; path = TestNotification.apns; sourceTree = ""; }; + 056A370B19AC77E9A43813B2 /* libPods-BarkWatchNotificationService.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-BarkWatchNotificationService.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 0603706620E1E31600F4CA05 /* Defines.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Defines.swift; sourceTree = ""; }; 0603706A20E20A7C00F4CA05 /* String+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Extension.swift"; sourceTree = ""; }; 0603706C20E23EC000F4CA05 /* BarkSFSafariViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarkSFSafariViewController.swift; sourceTree = ""; }; @@ -375,7 +423,6 @@ 0660480A2700550600938904 /* Intents.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Intents.framework; path = System/Library/Frameworks/Intents.framework; sourceTree = SDKROOT; }; 0661A53F204FDA4100965E4E /* Bark.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Bark.app; sourceTree = BUILT_PRODUCTS_DIR; }; 0661A542204FDA4100965E4E /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 06A200012FED000100000001 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 0661A544204FDA4100965E4E /* HomeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewController.swift; sourceTree = ""; }; 0661A549204FDA4100965E4E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 0661A54C204FDA4100965E4E /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; @@ -420,9 +467,9 @@ 06A100142FEE0001000000A1 /* HomeSettingsCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeSettingsCard.swift; sourceTree = ""; }; 06A100162FEE0001000000A1 /* HomeDocumentsCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeDocumentsCard.swift; sourceTree = ""; }; 06A100182FEE0001000000A1 /* CodeHighlighter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeHighlighter.swift; sourceTree = ""; }; - 06C10A012FB20001000000D1 /* QRCodeGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeGenerator.swift; sourceTree = ""; }; 06A1001A2FEE0001000000A1 /* HomeFlatButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeFlatButton.swift; sourceTree = ""; }; 06A100202FEE0001000000A1 /* HomeSplitButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeSplitButton.swift; sourceTree = ""; }; + 06A200012FED000100000001 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 06B1158C247BA6D5006D91FB /* CloudKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CloudKit.framework; path = System/Library/Frameworks/CloudKit.framework; sourceTree = SDKROOT; }; 06B1158E247BB1FB006D91FB /* Message.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Message.swift; sourceTree = ""; }; 06B11590247BBC15006D91FB /* NotificationServiceExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NotificationServiceExtension.entitlements; sourceTree = ""; }; @@ -437,6 +484,8 @@ 06BCAE5A2CDB25120092867A /* MuteProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MuteProcessor.swift; sourceTree = ""; }; 06BD4DA92901352E003364DB /* Object+Dictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Object+Dictionary.swift"; sourceTree = ""; }; 06BE84032E6EAE7100E6F856 /* InsetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsetView.swift; sourceTree = ""; }; + 06C10A012FB20001000000D1 /* QRCodeGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeGenerator.swift; sourceTree = ""; }; + 06C10A032FB20001000000D1 /* PushQRCodeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushQRCodeViewController.swift; sourceTree = ""; }; 06C2CF222685B88D0034B127 /* TextCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextCell.swift; sourceTree = ""; }; 06C2CF242685BDB80034B127 /* SpacerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpacerCell.swift; sourceTree = ""; }; 06C5952C2480E3F8006B98F3 /* LabelCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabelCell.swift; sourceTree = ""; }; @@ -471,23 +520,36 @@ 06F08EA629B1DDFE006AB9CA /* Error+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Error+Extension.swift"; sourceTree = ""; }; 06F08EAE29B5D9FF006AB9CA /* HUD.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HUD.swift; sourceTree = ""; }; 06F11E7627D9D5FB00F00298 /* QRScannerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRScannerViewController.swift; sourceTree = ""; }; - 06C10A032FB20001000000D1 /* PushQRCodeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushQRCodeViewController.swift; sourceTree = ""; }; 06F808902EF4056C0007352A /* AppDelegate+Realm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+Realm.swift"; sourceTree = ""; }; 06FB04032C53575400F3A213 /* SharedDefines.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedDefines.swift; sourceTree = ""; }; 121D9B1ED4E8D26F345BC5C0 /* Pods-BarkTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-BarkTests.release.xcconfig"; path = "Target Support Files/Pods-BarkTests/Pods-BarkTests.release.xcconfig"; sourceTree = ""; }; 138CE8CB688587E893BC5C44 /* Pods-Bark.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Bark.debug.xcconfig"; path = "Target Support Files/Pods-Bark/Pods-Bark.debug.xcconfig"; sourceTree = ""; }; + 178D8742EC982295ED31D74C /* Pods-BarkWatchNotificationService.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-BarkWatchNotificationService.debug.xcconfig"; path = "Target Support Files/Pods-BarkWatchNotificationService/Pods-BarkWatchNotificationService.debug.xcconfig"; sourceTree = ""; }; 1E73F99D2C282822002BF649 /* SectionViewController-iPad.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SectionViewController-iPad.swift"; sourceTree = ""; }; 1EFB545C2C314A6800B8E51B /* BarkSplitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarkSplitViewController.swift; sourceTree = ""; }; 1EFB545E2C32514000B8E51B /* SectionViewModel-iPad.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SectionViewModel-iPad.swift"; sourceTree = ""; }; + 2E2E32D47067F72822D1BE12 /* Pods-BarkWatchNotificationService.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-BarkWatchNotificationService.release.xcconfig"; path = "Target Support Files/Pods-BarkWatchNotificationService/Pods-BarkWatchNotificationService.release.xcconfig"; sourceTree = ""; }; + 2EB8EE587788EAA2C88A9C1A /* Pods-BarkWatch.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-BarkWatch.release.xcconfig"; path = "Target Support Files/Pods-BarkWatch/Pods-BarkWatch.release.xcconfig"; sourceTree = ""; }; + 35C5FA69E454ECCC30629574 /* libPods-BarkWatchTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-BarkWatchTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 366FB0FC6350F5E40F91FC1D /* libPods-BarkWatch.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-BarkWatch.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 4A9C3B74281C8607B364CED7 /* Pods-NotificationContentExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NotificationContentExtension.release.xcconfig"; path = "Target Support Files/Pods-NotificationContentExtension/Pods-NotificationContentExtension.release.xcconfig"; sourceTree = ""; }; 519481D715B40109627E1B49 /* Pods-NotificationServiceExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NotificationServiceExtension.release.xcconfig"; path = "Target Support Files/Pods-NotificationServiceExtension/Pods-NotificationServiceExtension.release.xcconfig"; sourceTree = ""; }; + 5D0DEDA9FEBA14EA5DB8B9A1 /* Pods-BarkWatch.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-BarkWatch.debug.xcconfig"; path = "Target Support Files/Pods-BarkWatch/Pods-BarkWatch.debug.xcconfig"; sourceTree = ""; }; + 62DF9BBF3FDB3BE69663075F /* Pods-BarkWatchTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-BarkWatchTests.release.xcconfig"; path = "Target Support Files/Pods-BarkWatchTests/Pods-BarkWatchTests.release.xcconfig"; sourceTree = ""; }; 6616B1ACAA7E422700D90C2B /* libPods-NotificationContentExtension.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-NotificationContentExtension.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 76381A752CCCD4DA6BB2A566 /* libPods-BarkTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-BarkTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + A10000000000000000000002 /* WatchNotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchNotificationService.swift; sourceTree = ""; }; + A10000000000000000000003 /* BarkWatchNotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; path = BarkWatchNotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + A10000000000000000000021 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + A10000000000000000000022 /* NotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NotificationService.entitlements; sourceTree = ""; }; A69B47DA6DB3B168D5770B45 /* Pods-Bark.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Bark.release.xcconfig"; path = "Target Support Files/Pods-Bark/Pods-Bark.release.xcconfig"; sourceTree = ""; }; + B20000000000000000000055 /* MarkdownNotification.apns */ = {isa = PBXFileReference; lastKnownFileType = text; path = MarkdownNotification.apns; sourceTree = ""; }; B7F8BDFAA047451561798F58 /* libPods-NotificationServiceExtension.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-NotificationServiceExtension.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + C30000000000000000000001 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; CCC722470308049D180876C7 /* libPods-Bark.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Bark.a"; sourceTree = BUILT_PRODUCTS_DIR; }; EF45D2197673129A8128ADB7 /* Pods-NotificationContentExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NotificationContentExtension.debug.xcconfig"; path = "Target Support Files/Pods-NotificationContentExtension/Pods-NotificationContentExtension.debug.xcconfig"; sourceTree = ""; }; F20815A821395CCA155806A4 /* Pods-NotificationServiceExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NotificationServiceExtension.debug.xcconfig"; path = "Target Support Files/Pods-NotificationServiceExtension/Pods-NotificationServiceExtension.debug.xcconfig"; sourceTree = ""; }; + F877138E6E8864F7E43BC3EC /* Pods-BarkWatchTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-BarkWatchTests.debug.xcconfig"; path = "Target Support Files/Pods-BarkWatchTests/Pods-BarkWatchTests.debug.xcconfig"; sourceTree = ""; }; FB59D77AB30F7AD98BA72C3E /* Pods-BarkTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-BarkTests.debug.xcconfig"; path = "Target Support Files/Pods-BarkTests/Pods-BarkTests.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -503,9 +565,30 @@ /* Begin PBXFileSystemSynchronizedRootGroup section */ 06697FDE2F70ED9400C4680E /* Widget */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (06697FEA2F70ED9500C4680E /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Widget; sourceTree = ""; }; + 3D099DCC306DBCEE00A96591 /* Model */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Model; sourceTree = ""; }; + 3D099DD0306DBCF400A96591 /* App */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = App; sourceTree = ""; }; + 3D099DD6306DBCFB00A96591 /* Common */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Common; sourceTree = ""; }; + 3D2E504F306D002800D53E4F /* View */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = View; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + 00000000000000000000003D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + B20000000000000000000051 /* Markdown in Frameworks */, + CFBE41F3462A6D4F0E1A4B12 /* libPods-BarkWatch.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 000000000000000000000047 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 2FEAABEDC1A6FF1648620312 /* libPods-BarkWatchTests.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 0632CE1B20EC9098003FDF46 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -553,9 +636,35 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + B20000000000000000000013 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + B20000000000000000000052 /* Markdown in Frameworks */, + E68AEB0E47866777F8755476 /* libPods-BarkWatchNotificationService.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 000000000000000000000002 /* BarkWatch */ = { + isa = PBXGroup; + children = ( + 3D099DD0306DBCF400A96591 /* App */, + 3D099DCC306DBCEE00A96591 /* Model */, + 3D2E504F306D002800D53E4F /* View */, + 3D099DD6306DBCFB00A96591 /* Common */, + D40000000000000000000005 /* Tests */, + C30000000000000000000001 /* Assets.xcassets */, + 000000000000000000000032 /* BarkWatch.entitlements */, + 000000000000000000000033 /* TestNotification.apns */, + B20000000000000000000055 /* MarkdownNotification.apns */, + A10000000000000000000020 /* NotificationService */, + ); + path = BarkWatch; + sourceTree = ""; + }; 0604F7DB20620D2700B32F09 /* Controller */ = { isa = PBXGroup; children = ( @@ -745,8 +854,9 @@ 0661A541204FDA4100965E4E /* Bark */, 0632CE2120EC9098003FDF46 /* NotificationContentExtension */, 06CF784121C7A50300A052D7 /* NotificationServiceExtension */, - 06EE1FD126843E9300586708 /* BarkTests */, + 000000000000000000000002 /* BarkWatch */, 06697FDE2F70ED9400C4680E /* Widget */, + 06EE1FD126843E9300586708 /* BarkTests */, 0661A540204FDA4100965E4E /* Products */, 99BD309BDB7F62B5DC0CECE1 /* Frameworks */, E9062CB15F1B70C3719578FB /* Pods */, @@ -761,6 +871,9 @@ 06CF784021C7A50300A052D7 /* NotificationServiceExtension.appex */, 06EE1FD026843E9300586708 /* BarkTests.xctest */, 06697FD92F70ED9400C4680E /* WidgetExtension.appex */, + 000000000000000000000006 /* BarkWatch.app */, + 000000000000000000000007 /* BarkWatchTests.xctest */, + A10000000000000000000003 /* BarkWatchNotificationService.appex */, ); name = Products; sourceTree = ""; @@ -875,10 +988,31 @@ 6616B1ACAA7E422700D90C2B /* libPods-NotificationContentExtension.a */, 06697FDA2F70ED9400C4680E /* WidgetKit.framework */, 06697FDC2F70ED9400C4680E /* SwiftUI.framework */, + 366FB0FC6350F5E40F91FC1D /* libPods-BarkWatch.a */, + 056A370B19AC77E9A43813B2 /* libPods-BarkWatchNotificationService.a */, + 35C5FA69E454ECCC30629574 /* libPods-BarkWatchTests.a */, ); name = Frameworks; sourceTree = ""; }; + A10000000000000000000020 /* NotificationService */ = { + isa = PBXGroup; + children = ( + A10000000000000000000002 /* WatchNotificationService.swift */, + A10000000000000000000021 /* Info.plist */, + A10000000000000000000022 /* NotificationService.entitlements */, + ); + path = NotificationService; + sourceTree = ""; + }; + D40000000000000000000005 /* Tests */ = { + isa = PBXGroup; + children = ( + 000000000000000000000028 /* NotificationTests.swift */, + ); + path = Tests; + sourceTree = ""; + }; E9062CB15F1B70C3719578FB /* Pods */ = { isa = PBXGroup; children = ( @@ -890,6 +1024,12 @@ 519481D715B40109627E1B49 /* Pods-NotificationServiceExtension.release.xcconfig */, EF45D2197673129A8128ADB7 /* Pods-NotificationContentExtension.debug.xcconfig */, 4A9C3B74281C8607B364CED7 /* Pods-NotificationContentExtension.release.xcconfig */, + 5D0DEDA9FEBA14EA5DB8B9A1 /* Pods-BarkWatch.debug.xcconfig */, + 2EB8EE587788EAA2C88A9C1A /* Pods-BarkWatch.release.xcconfig */, + 178D8742EC982295ED31D74C /* Pods-BarkWatchNotificationService.debug.xcconfig */, + 2E2E32D47067F72822D1BE12 /* Pods-BarkWatchNotificationService.release.xcconfig */, + F877138E6E8864F7E43BC3EC /* Pods-BarkWatchTests.debug.xcconfig */, + 62DF9BBF3FDB3BE69663075F /* Pods-BarkWatchTests.release.xcconfig */, ); path = Pods; sourceTree = ""; @@ -897,6 +1037,55 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 000000000000000000000004 /* BarkWatch */ = { + isa = PBXNativeTarget; + buildConfigurationList = 000000000000000000000066 /* Build configuration list for PBXNativeTarget "BarkWatch" */; + buildPhases = ( + E5FD9C4368A7FF25FE7E6852 /* [CP] Check Pods Manifest.lock */, + 00000000000000000000003C /* Sources */, + 00000000000000000000003D /* Frameworks */, + 00000000000000000000003E /* Resources */, + A10000000000000000000008 /* Embed App Extensions */, + A2154E7DFCD0C8DCBE650FC7 /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + A10000000000000000000009 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 3D099DCC306DBCEE00A96591 /* Model */, + 3D099DD0306DBCF400A96591 /* App */, + 3D099DD6306DBCFB00A96591 /* Common */, + 3D2E504F306D002800D53E4F /* View */, + ); + name = BarkWatch; + packageProductDependencies = ( + B20000000000000000000053 /* Markdown */, + ); + productName = BarkWatch; + productReference = 000000000000000000000006 /* BarkWatch.app */; + productType = "com.apple.product-type.application"; + }; + 000000000000000000000005 /* BarkWatchTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 000000000000000000000070 /* Build configuration list for PBXNativeTarget "BarkWatchTests" */; + buildPhases = ( + F9620B9B24668A0183324C97 /* [CP] Check Pods Manifest.lock */, + 000000000000000000000046 /* Sources */, + 000000000000000000000047 /* Frameworks */, + 000000000000000000000048 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 000000000000000000000051 /* PBXTargetDependency */, + ); + name = BarkWatchTests; + productName = BarkWatchTests; + productReference = 000000000000000000000007 /* BarkWatchTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; 0632CE1D20EC9098003FDF46 /* NotificationContentExtension */ = { isa = PBXNativeTarget; buildConfigurationList = 0632CE2D20EC9098003FDF46 /* Build configuration list for PBXNativeTarget "NotificationContentExtension" */; @@ -955,8 +1144,6 @@ 06697FDE2F70ED9400C4680E /* Widget */, ); name = WidgetExtension; - packageProductDependencies = ( - ); productName = WidgetExtension; productReference = 06697FD92F70ED9400C4680E /* WidgetExtension.appex */; productType = "com.apple.product-type.app-extension"; @@ -999,6 +1186,30 @@ productReference = 06EE1FD026843E9300586708 /* BarkTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; + A10000000000000000000010 /* BarkWatchNotificationService */ = { + isa = PBXNativeTarget; + buildConfigurationList = A10000000000000000000015 /* Build configuration list for PBXNativeTarget "BarkWatchNotificationService" */; + buildPhases = ( + 3B98E5E78A1D36EC471122CC /* [CP] Check Pods Manifest.lock */, + A10000000000000000000012 /* Sources */, + B20000000000000000000013 /* Frameworks */, + A4998B6FADC364DE3903378A /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 3D099DD6306DBCFB00A96591 /* Common */, + ); + name = BarkWatchNotificationService; + packageProductDependencies = ( + B20000000000000000000054 /* Markdown */, + ); + productName = BarkWatchNotificationService; + productReference = A10000000000000000000003 /* BarkWatchNotificationService.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -1010,6 +1221,18 @@ LastUpgradeCheck = 1640; ORGANIZATIONNAME = Fin; TargetAttributes = { + 000000000000000000000004 = { + CreatedOnToolsVersion = 27.0; + SystemCapabilities = { + com.apple.Push = { + enabled = 1; + }; + }; + }; + 000000000000000000000005 = { + CreatedOnToolsVersion = 27.0; + TestTargetID = 000000000000000000000004; + }; 0632CE1D20EC9098003FDF46 = { CreatedOnToolsVersion = 9.3; ProvisioningStyle = Manual; @@ -1062,11 +1285,29 @@ 06CF783F21C7A50300A052D7 /* NotificationServiceExtension */, 06EE1FCF26843E9300586708 /* BarkTests */, 06697FD82F70ED9400C4680E /* WidgetExtension */, + 000000000000000000000004 /* BarkWatch */, + 000000000000000000000005 /* BarkWatchTests */, + A10000000000000000000010 /* BarkWatchNotificationService */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 00000000000000000000003E /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + C30000000000000000000002 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 000000000000000000000048 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 0632CE1C20EC9098003FDF46 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -1248,7 +1489,7 @@ inputPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Bark/Pods-Bark-resources.sh", "${PODS_CONFIGURATION_BUILD_DIR}/Alamofire/Alamofire.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/CryptoSwift/CryptoSwift.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/CryptoSwift-iOS/CryptoSwift.bundle", "${PODS_ROOT}/DropDown/DropDown/resources/DropDownCell.xib", "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardCore/IQKeyboardCore.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardNotification/IQKeyboardNotification.bundle", @@ -1304,6 +1545,28 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Bark/Pods-Bark-resources.sh\"\n"; showEnvVarsInLog = 0; }; + 3B98E5E78A1D36EC471122CC /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-BarkWatchNotificationService-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 73BB82AB466F692F871A6876 /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -1311,7 +1574,7 @@ ); inputPaths = ( "${PODS_ROOT}/Target Support Files/Pods-NotificationServiceExtension/Pods-NotificationServiceExtension-resources.sh", - "${PODS_CONFIGURATION_BUILD_DIR}/CryptoSwift/CryptoSwift.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/CryptoSwift-iOS/CryptoSwift.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/Kingfisher/Kingfisher.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/Realm/realm_objc_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/RealmSwift/realm_swift_privacy.bundle", @@ -1352,6 +1615,86 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; + A2154E7DFCD0C8DCBE650FC7 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-BarkWatch/Pods-BarkWatch-resources.sh", + "${PODS_CONFIGURATION_BUILD_DIR}/CryptoSwift-watchOS/CryptoSwift.bundle", + ); + name = "[CP] Copy Pods Resources"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/CryptoSwift.bundle", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-BarkWatch/Pods-BarkWatch-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + A4998B6FADC364DE3903378A /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-BarkWatchNotificationService/Pods-BarkWatchNotificationService-resources.sh", + "${PODS_CONFIGURATION_BUILD_DIR}/CryptoSwift-watchOS/CryptoSwift.bundle", + ); + name = "[CP] Copy Pods Resources"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/CryptoSwift.bundle", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-BarkWatchNotificationService/Pods-BarkWatchNotificationService-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + E5FD9C4368A7FF25FE7E6852 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-BarkWatch-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + F9620B9B24668A0183324C97 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-BarkWatchTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; FFCCE5E170E29FF10836E9C0 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -1377,6 +1720,25 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 00000000000000000000003C /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E10000000000000000000000 /* Algorithm.swift in Sources */, + E10000000000000000000001 /* MarkdownParser.swift in Sources */, + E10000000000000000000002 /* UIFont+Extension.swift in Sources */, + 065632CB3080A88400A0C5A4 /* Error+Extension.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 000000000000000000000046 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 000000000000000000000029 /* NotificationTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 0632CE1A20EC9098003FDF46 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -1561,9 +1923,26 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A10000000000000000000012 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E10000000000000000000010 /* Algorithm.swift in Sources */, + E10000000000000000000011 /* MarkdownParser.swift in Sources */, + E10000000000000000000012 /* UIFont+Extension.swift in Sources */, + A10000000000000000000006 /* WatchNotificationService.swift in Sources */, + 065632CC3080A88400A0C5A4 /* Error+Extension.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 000000000000000000000051 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 000000000000000000000004 /* BarkWatch */; + targetProxy = 000000000000000000000050 /* PBXContainerItemProxy */; + }; 062E6DA02F73B6A00080657A /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 06697FD82F70ED9400C4680E /* WidgetExtension */; @@ -1584,6 +1963,11 @@ target = 0661A53E204FDA4100965E4E /* Bark */; targetProxy = 06EE1FD526843E9300586708 /* PBXContainerItemProxy */; }; + A10000000000000000000009 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = A10000000000000000000010 /* BarkWatchNotificationService */; + targetProxy = A10000000000000000000011 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -1606,6 +1990,160 @@ /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ + 000000000000000000000064 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 5D0DEDA9FEBA14EA5DB8B9A1 /* Pods-BarkWatch.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + BARK_WATCH_ENTITLEMENTS = BarkWatch/BarkWatch.entitlements; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = "$(BARK_WATCH_ENTITLEMENTS)"; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=watchos*]" = 5U8LBRXG3A; + ENABLE_TESTABILITY = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = BarkWatch/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = BarkWatch; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_WKApplication = YES; + INFOPLIST_KEY_WKWatchOnly = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 0.1; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_BUNDLE_IDENTIFIER = me.fin.BarkWatch; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=watchos*]" = "match Development me.fin.BarkWatch"; + REGISTER_APP_GROUPS = YES; + SDKROOT = watchos; + SUPPORTED_PLATFORMS = "watchos watchsimulator"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Debug; + }; + 000000000000000000000065 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 2EB8EE587788EAA2C88A9C1A /* Pods-BarkWatch.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + BARK_WATCH_ENTITLEMENTS = BarkWatch/BarkWatch.entitlements; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = "$(BARK_WATCH_ENTITLEMENTS)"; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=watchos*]" = 5U8LBRXG3A; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = BarkWatch/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = BarkWatch; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_WKApplication = YES; + INFOPLIST_KEY_WKWatchOnly = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 0.1; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_BUNDLE_IDENTIFIER = me.fin.BarkWatch; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=watchos*]" = "match AppStore me.fin.BarkWatch"; + REGISTER_APP_GROUPS = YES; + SDKROOT = watchos; + SUPPORTED_PLATFORMS = "watchos watchsimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Release; + }; + 00000000000000000000006E /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F877138E6E8864F7E43BC3EC /* Pods-BarkWatchTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 5U8LBRXG3A; + ENABLE_TESTABILITY = YES; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 0.1; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_BUNDLE_IDENTIFIER = me.fin.BarkWatchTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SUPPORTED_PLATFORMS = "watchos watchsimulator"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 4; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/BarkWatch.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/BarkWatch"; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Debug; + }; + 00000000000000000000006F /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 62DF9BBF3FDB3BE69663075F /* Pods-BarkWatchTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 5U8LBRXG3A; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 0.1; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_BUNDLE_IDENTIFIER = me.fin.BarkWatchTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SUPPORTED_PLATFORMS = "watchos watchsimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 4; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/BarkWatch.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/BarkWatch"; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Release; + }; 0632CE2B20EC9098003FDF46 /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = EF45D2197673129A8128ADB7 /* Pods-NotificationContentExtension.debug.xcconfig */; @@ -2037,9 +2575,105 @@ }; name = Release; }; + A10000000000000000000013 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 178D8742EC982295ED31D74C /* Pods-BarkWatchNotificationService.debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = BarkWatch/NotificationService/NotificationService.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=watchos*]" = 5U8LBRXG3A; + ENABLE_TESTABILITY = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = BarkWatch/NotificationService/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "Bark Watch Notifications"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 0.1; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_BUNDLE_IDENTIFIER = me.fin.BarkWatch.NotificationService; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=watchos*]" = "match Development me.fin.BarkWatch.NotificationService"; + REGISTER_APP_GROUPS = YES; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "watchos watchsimulator"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Debug; + }; + A10000000000000000000014 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 2E2E32D47067F72822D1BE12 /* Pods-BarkWatchNotificationService.release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = BarkWatch/NotificationService/NotificationService.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=watchos*]" = 5U8LBRXG3A; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = BarkWatch/NotificationService/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "Bark Watch Notifications"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 0.1; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_BUNDLE_IDENTIFIER = me.fin.BarkWatch.NotificationService; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=watchos*]" = "match AppStore me.fin.BarkWatch.NotificationService"; + REGISTER_APP_GROUPS = YES; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "watchos watchsimulator"; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 000000000000000000000066 /* Build configuration list for PBXNativeTarget "BarkWatch" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 000000000000000000000064 /* Debug */, + 000000000000000000000065 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 000000000000000000000070 /* Build configuration list for PBXNativeTarget "BarkWatchTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 00000000000000000000006E /* Debug */, + 00000000000000000000006F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 0632CE2D20EC9098003FDF46 /* Build configuration list for PBXNativeTarget "NotificationContentExtension" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -2094,6 +2728,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + A10000000000000000000015 /* Build configuration list for PBXNativeTarget "BarkWatchNotificationService" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A10000000000000000000013 /* Debug */, + A10000000000000000000014 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ @@ -2118,6 +2761,16 @@ package = 06C777D12ECF05990032A044 /* XCRemoteSwiftPackageReference "swift-markdown" */; productName = Markdown; }; + B20000000000000000000053 /* Markdown */ = { + isa = XCSwiftPackageProductDependency; + package = 06C777D12ECF05990032A044 /* XCRemoteSwiftPackageReference "swift-markdown" */; + productName = Markdown; + }; + B20000000000000000000054 /* Markdown */ = { + isa = XCSwiftPackageProductDependency; + package = 06C777D12ECF05990032A044 /* XCRemoteSwiftPackageReference "swift-markdown" */; + productName = Markdown; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 0661A537204FDA4100965E4E /* Project object */; diff --git a/Bark.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bark.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..6bd7bbc8 --- /dev/null +++ b/Bark.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,24 @@ +{ + "originHash" : "bb354f5ee26f97a80bea96da70628d71c97b25e01e4e845afe4f5b1d0b6cf6e2", + "pins" : [ + { + "identity" : "swift-cmark", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-cmark.git", + "state" : { + "revision" : "5d9bdaa4228b381639fff09403e39a04926e2dbe", + "version" : "0.7.1" + } + }, + { + "identity" : "swift-markdown", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-markdown", + "state" : { + "revision" : "7d9a5ce307528578dfa777d505496bd5f544ad94", + "version" : "0.7.3" + } + } + ], + "version" : 3 +} diff --git a/Bark.xcodeproj/xcshareddata/xcschemes/BarkWatch.xcscheme b/Bark.xcodeproj/xcshareddata/xcschemes/BarkWatch.xcscheme new file mode 100644 index 00000000..ef81e978 --- /dev/null +++ b/Bark.xcodeproj/xcshareddata/xcschemes/BarkWatch.xcscheme @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Bark.xcodeproj/xcshareddata/xcschemes/BarkWatchNotificationService.xcscheme b/Bark.xcodeproj/xcshareddata/xcschemes/BarkWatchNotificationService.xcscheme new file mode 100644 index 00000000..6786612e --- /dev/null +++ b/Bark.xcodeproj/xcshareddata/xcschemes/BarkWatchNotificationService.xcscheme @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/BarkWatch/App/BarkWatchApp.swift b/BarkWatch/App/BarkWatchApp.swift new file mode 100644 index 00000000..e660db52 --- /dev/null +++ b/BarkWatch/App/BarkWatchApp.swift @@ -0,0 +1,23 @@ +// +// BarkWatchApp.swift +// Bark +// +// Created by Jian Qin on 9/30/26. +// + + +import SwiftUI +import WatchKit + +@main +struct BarkWatchApp: App { + @WKApplicationDelegateAdaptor(NotificationDelegate.self) private var delegate + + var body: some Scene { + WindowGroup { + ContentView(state: delegate.state) + } + WKNotificationScene(controller: MarkdownNotificationController.self, + category: WatchNotification.markdownCategoryIdentifier) + } +} diff --git a/BarkWatch/App/WatchMessage.swift b/BarkWatch/App/WatchMessage.swift new file mode 100644 index 00000000..0b884aa5 --- /dev/null +++ b/BarkWatch/App/WatchMessage.swift @@ -0,0 +1,251 @@ +// +// WatchMessage.swift +// Bark +// +// Created by Jian Qin on 9/30/26. +// + + +import Observation +import os +import UserNotifications +import WatchKit + +struct WatchMessage: Equatable, Sendable { + let title: String + let subtitle: String + let body: String + let markdown: String? + let group: String + let level: WatchNotificationLevel + let url: URL? + let image: URL? + let identifier: String? + let usedCriticalAlertFallback: Bool + + nonisolated init(content: UNNotificationContent) { + title = content.title + subtitle = content.subtitle + body = content.body + markdown = WatchNotification.stringValue("markdown", in: content.userInfo) + group = content.threadIdentifier + level = WatchNotificationLevel(content.interruptionLevel) + url = WatchNotification.validatedWebURL(WatchNotification.stringValue("url", in: content.userInfo)) + image = WatchNotification.validatedWebURL(WatchNotification.stringValue("image", in: content.userInfo)) + identifier = WatchNotification.stringValue("id", in: content.userInfo) + usedCriticalAlertFallback = content.userInfo[WatchNotification.criticalAlertFallbackKey] as? Bool ?? false + } + + nonisolated init(archived message: WatchArchivedMessage) { + title = message.title + subtitle = message.subtitle ?? "" + body = message.body + markdown = message.markdown + group = message.group + level = message.level ?? .active + url = WatchNotification.validatedWebURL(message.url) + image = WatchNotification.validatedWebURL(message.image) + identifier = message.id + usedCriticalAlertFallback = message.usedCriticalAlertFallback ?? false + } +} + +@Observable +@MainActor +final class NotificationState { + let servers: ServerManager + enum PushRegistrationState { + case idle + case registering + case registered(String) + case failed(String) + } + + var authorizationStatus: UNAuthorizationStatus? + var registrationState: PushRegistrationState = .idle + var isRequestingPermission = false + var permissionError: String? + var criticalAlertSetting: UNNotificationSetting? + /// Most recently presented or opened notification. Restored from `WatchArchive` + /// at launch; messages the archive skips (`isArchive=0`, `ttl<=0`) last only + /// until the app quits. + private(set) var lastMessage: WatchMessage? + /// A link tapped in the notification interface, which can't show a browser itself. + /// Held until the notification's response delivers the message, so the app can + /// show Last message before opening the link. + var requestedNotificationURL: URL? + /// A link the app should open once it's active; set together with `lastMessage`. + var pendingBrowserURL: URL? + + init(servers: ServerManager = ServerManager(), archive: WatchArchive = .shared) { + self.servers = servers + lastMessage = (try? archive.latest(group: nil)).flatMap(WatchMessage.init(archived:)) + } + + var hasNotificationPermission: Bool { + authorizationStatus == .authorized || authorizationStatus == .provisional + } + + /// Permission is granted and Apple has issued a device token; server delivery is separate. + var isPushRegistrationReady: Bool { + guard hasNotificationPermission, + case .registered = registrationState else { return false } + return true + } + + /// Hide setup only when a server is saved and notification delivery is ready. + var needsNotificationSetup: Bool { + servers.servers.isEmpty || !isPushRegistrationReady + } + + func receive(_ message: WatchMessage) { + lastMessage = message + Logger.links.debug("Received message, requested link: \(self.requestedNotificationURL?.absoluteString ?? "none")") + if let requestedNotificationURL { + pendingBrowserURL = requestedNotificationURL + self.requestedNotificationURL = nil + } + } + + func refreshAndRegister() async { + let settings = await UNUserNotificationCenter.current().notificationSettings() + authorizationStatus = settings.authorizationStatus + criticalAlertSetting = settings.criticalAlertSetting + let criticalAlerts = switch settings.criticalAlertSetting { + case .enabled: "enabled" + case .disabled: "disabled" + default: "not supported by this signed app" + } + Logger.registration.debug("Critical alerts: \(criticalAlerts, privacy: .public)") + guard hasNotificationPermission else { + registrationState = .idle + return + } + switch registrationState { + case .idle, .failed: + registrationState = .registering + WKApplication.shared().registerForRemoteNotifications() + case .registered(let deviceToken): + await servers.syncAllServers(deviceToken: deviceToken) + case .registering: + break + } + } + + func removeMessage(withIdentifier identifier: String) { + guard lastMessage?.identifier == identifier else { return } + lastMessage = nil + requestedNotificationURL = nil + pendingBrowserURL = nil + } + + func enableNotifications(includeCriticalAlerts: Bool = false) async { + guard !isRequestingPermission else { return } + isRequestingPermission = true + permissionError = nil + defer { isRequestingPermission = false } + var options: UNAuthorizationOptions = [.alert, .sound] + if includeCriticalAlerts { options.insert(.criticalAlert) } + do { + _ = try await UNUserNotificationCenter.current().requestAuthorization(options: options) + } catch { + permissionError = error.localizedDescription + } + await refreshAndRegister() + } +} + +@MainActor +final class NotificationDelegate: NSObject, WKApplicationDelegate, UNUserNotificationCenterDelegate { + let state = NotificationState() + private let logger = Logger(subsystem: WatchConstants.bundleIdentifier, category: "NotificationDeletion") + /// The live delegate. `WKApplication.shared().delegate` doesn't return it with + /// `@WKApplicationDelegateAdaptor`, so the notification interface uses this. + private(set) static weak var current: NotificationDelegate? + + override init() { + super.init() + Self.current = self + } + + func applicationDidFinishLaunching() { + let notificationCenter = UNUserNotificationCenter.current() + notificationCenter.delegate = self + notificationCenter.setNotificationCategories([ + UNNotificationCategory(identifier: WatchNotification.markdownCategoryIdentifier, + actions: [], intentIdentifiers: []) + ]) + Task { await state.refreshAndRegister() } + } + + func applicationDidBecomeActive() { + Task { await state.refreshAndRegister() } + } + + func didRegisterForRemoteNotifications(withDeviceToken deviceToken: Data) { + let tokenString = deviceToken.map { String(format: "%02x", $0) }.joined() + state.registrationState = .registered(tokenString) + Task { await state.servers.syncAllServers(deviceToken: tokenString) } + // Copyable from the Xcode console for manual push testing. Interpolated values + // are private by default, so they are redacted unless a debugger is attached. + Logger.registration.debug("Device token: \(tokenString)") + Logger.registration.debug("Push topic: \(Bundle.main.bundleIdentifier ?? "unknown")") + } + + func didFailToRegisterForRemoteNotificationsWithError(_ error: any Error) { + state.registrationState = .failed(error.localizedDescription) + } + + func didReceiveRemoteNotification( + _ userInfo: [AnyHashable: Any], + fetchCompletionHandler completionHandler: @escaping (WKBackgroundFetchResult) -> Void + ) { + completionHandler(handleRemoteNotification(userInfo)) + } + + func handleRemoteNotification( + _ userInfo: [AnyHashable: Any], archive: WatchArchive = .shared + ) -> WKBackgroundFetchResult { + guard WatchNotification.stringValue("delete", in: userInfo) == "1", + let identifier = WatchNotification.stringValue("id", in: userInfo), + !identifier.isEmpty else { return .noData } + + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [identifier]) + state.removeMessage(withIdentifier: identifier) + do { + try archive.remove(id: identifier) + return .newData + } catch { + logger.error("Could not delete archived notification: \(error.localizedDescription)") + return .failed + } + } + + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification + ) async -> UNNotificationPresentationOptions { + if WatchNotification.stringValue("delete", in: notification.request.content.userInfo) == "1" { + let identifier = WatchNotification.stringValue("id", in: notification.request.content.userInfo) + await MainActor.run { + if let identifier { _ = handleRemoteNotification(["delete": "1", "id": identifier]) } + } + return [] + } + let message = WatchMessage(content: notification.request.content) + await MainActor.run { state.receive(message) } + return WatchNotification.presentationOptions(for: notification.request.content) + } + + // Main-actor isolated so the system's completion handler runs on the main thread. + // Completing on a background thread crashes UIKit ("Call must be made on main + // thread") when the app is launched from the notification interface. + @MainActor + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse + ) async { + guard response.actionIdentifier != UNNotificationDismissActionIdentifier else { return } + state.receive(WatchMessage(content: response.notification.request.content)) + } +} diff --git a/BarkWatch/Assets.xcassets/AppIcon.appiconset/Contents.json b/BarkWatch/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..dfdc06da --- /dev/null +++ b/BarkWatch/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "bark.png", + "idiom" : "universal", + "platform" : "watchos", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/BarkWatch/Assets.xcassets/AppIcon.appiconset/bark.png b/BarkWatch/Assets.xcassets/AppIcon.appiconset/bark.png new file mode 100644 index 00000000..d7f5201f Binary files /dev/null and b/BarkWatch/Assets.xcassets/AppIcon.appiconset/bark.png differ diff --git a/BarkWatch/Assets.xcassets/Contents.json b/BarkWatch/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/BarkWatch/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/BarkWatch/BarkWatch.entitlements b/BarkWatch/BarkWatch.entitlements new file mode 100644 index 00000000..2a4cccc6 --- /dev/null +++ b/BarkWatch/BarkWatch.entitlements @@ -0,0 +1,22 @@ + + + + + aps-environment + development + com.apple.developer.usernotifications.communication + + com.apple.developer.usernotifications.time-sensitive + + com.apple.developer.usernotifications.critical-alerts + + com.apple.security.application-groups + + group.bark + + keychain-access-groups + + $(AppIdentifierPrefix)me.fin.bark.shared + + + diff --git a/BarkWatch/Common/BarkApi.swift b/BarkWatch/Common/BarkApi.swift new file mode 100644 index 00000000..77d68f83 --- /dev/null +++ b/BarkWatch/Common/BarkApi.swift @@ -0,0 +1,83 @@ +// +// BarkApi.swift +// Bark +// +// Created by Jian Qin on 10/10/26. +// + +import Foundation + +/// Mirrors the phone's `BarkApi` Moya target, built on plain `URLRequest`. +enum BarkApi { + case register(address: URL, key: String?, devicetoken: String) // 注册设备 + case push(address: URL, parameters: [String: String]) +} + +extension BarkApi { + var baseURL: URL { + switch self { + case let .register(address, _, _), let .push(address, _): + return address + } + } + + var path: String { + switch self { + case .register: + return "register" + case .push: + return "push" + } + } + + var method: String { + switch self { + case .register: + return "GET" + case .push: + return "POST" + } + } + + var parameters: [String: String]? { + switch self { + case let .register(_, key, devicetoken): + var params = ["devicetoken": devicetoken] + if let key = key { + params["key"] = key + } + return params + case let .push(_, parameters): + return parameters + } + } + + func asURLRequest() throws -> URLRequest { + let url = baseURL.appendingPathComponent(path) + switch self { + case .register: + // GET parameters go in the query, like Moya's `URLEncoding.default`. + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + throw BarkServerError("Invalid server address.") + } + components.queryItems = parameters?.sorted { $0.key < $1.key }.map { URLQueryItem(name: $0.key, value: $0.value) } + // A literal plus is interpreted as a space by form-style query parsers. + components.percentEncodedQuery = components.percentEncodedQuery?.replacingOccurrences(of: "+", with: "%2B") + guard let requestURL = components.url else { throw BarkServerError("Invalid registration address.") } + var request = URLRequest(url: requestURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 20) + request.httpMethod = method + return request + case .push: + var request = URLRequest(url: url, timeoutInterval: 20) + request.httpMethod = method + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder().encode(parameters ?? [:]) + return request + } + } +} + +struct BarkServerError: LocalizedError { + let errorDescription: String? + init(_ message: String) { errorDescription = message } +} diff --git a/BarkWatch/Common/PhoneCompatibility.swift b/BarkWatch/Common/PhoneCompatibility.swift new file mode 100644 index 00000000..34317090 --- /dev/null +++ b/BarkWatch/Common/PhoneCompatibility.swift @@ -0,0 +1,47 @@ +// +// PhoneCompatibility.swift +// Bark +// +// Created by Jian Qin on 9/30/26. +// + +import Foundation +import UIKit + +// Stand-ins for phone symbols used by the shared phone sources +// (Model/Algorithm.swift, Common/MarkdownParser.swift), scoped to watch targets. +// The phone files that define these are not compiled for watchOS. If the shared +// sources start using more symbols from those files, add watch versions here. + +// Stands in for Common/BKColor.swift, whose colors load from the phone's asset +// catalog. Only the colors read by MarkdownParser.Configuration defaults are provided. +enum BKColor { + enum grey { + static let darken4 = UIColor.white + static let lighten4 = UIColor.white.withAlphaComponent(0.12) + static let base = UIColor.lightGray + } + enum blue { + static let base = UIColor.cyan + } +} + +// Stands in for `localized` in Common/String+Extension.swift. The watch has no +// copy of Localizable.xcstrings, so the "image" key used by MarkdownParser falls back to "Image". +extension String { + var localized: String { + NSLocalizedString(self, value: self == "image" ? "Image" : self, comment: "") + } +} + +// Watch-only conformances added to phone types for the encryption settings UI. +extension Algorithm: CaseIterable { + static let allCases: [Self] = [.aes128, .aes192, .aes256] +} + +extension CryptoSettingFields: Equatable { + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.algorithm == rhs.algorithm && lhs.mode == rhs.mode && lhs.padding == rhs.padding + && lhs.key == rhs.key && lhs.iv == rhs.iv + } +} diff --git a/BarkWatch/Common/WatchArchive.swift b/BarkWatch/Common/WatchArchive.swift new file mode 100644 index 00000000..f72fb2cf --- /dev/null +++ b/BarkWatch/Common/WatchArchive.swift @@ -0,0 +1,129 @@ +// +// WatchArchive.swift +// Bark +// +// Created by Jian Qin on 9/30/26. +// + +import Foundation +import Darwin +import UserNotifications + +// Shares the phone widget's bounded snapshot approach without its database dependency. +nonisolated struct WatchArchivedMessage: Codable, Equatable, Sendable { + let id: String + let title: String + let body: String + let group: String + let date: Date + let expires: Date? + // Shown by the app's Last message page. Optional so archives written + // before these fields existed still decode. + let subtitle: String? + let markdown: String? + let level: WatchNotificationLevel? + let url: String? + let image: String? + let usedCriticalAlertFallback: Bool? + + /// Expects content already prepared by `WatchNotification.prepare`, so the + /// body, group, and interruption level match what was delivered. Encrypted + /// pushes arrive here already decrypted and are archived like the phone does. + init?(content: UNNotificationContent, identifier: String, date: Date = .now) { + let info = content.userInfo + func text(_ key: String) -> String? { + (info[key] as? String) ?? (info[key] as? NSNumber)?.stringValue + } + guard text("isarchive").map({ $0 == "1" }) ?? true, + text("delete") != "1" else { return nil } + let ttl = text("ttl").flatMap(TimeInterval.init) + if let ttl, !ttl.isFinite || ttl <= 0 { return nil } + let customID = text("id") ?? "" + id = customID.isEmpty ? identifier : customID + title = content.title.components(separatedBy: .newlines).joined(separator: " ") + body = content.body + group = text("group") ?? content.threadIdentifier + self.date = date + expires = ttl.map { date.addingTimeInterval($0) } + subtitle = content.subtitle + markdown = text("markdown") + level = WatchNotificationLevel(content.interruptionLevel) + url = text("url") + image = text("image") + usedCriticalAlertFallback = info[Self.criticalAlertFallbackKey] as? Bool + } + + static let criticalAlertFallbackKey = "bark_watch_critical_fallback" +} + +/// The interruption level a message was delivered with, as shown in the app. +/// The raw values are stored in the archive, so don't change them. +nonisolated enum WatchNotificationLevel: String, Codable, Sendable { + case passive = "Passive" + case active = "Active" + case timeSensitive = "Time sensitive" + case critical = "Critical" + + init(_ level: UNNotificationInterruptionLevel) { + switch level { + case .passive: self = .passive + case .timeSensitive: self = .timeSensitive + case .critical: self = .critical + default: self = .active + } + } +} + +nonisolated struct WatchArchive: Sendable { + static let shared = WatchArchive(directory: FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: WatchConstants.appGroupIdentifier)) + let directory: URL? + + func messages(at date: Date = .now) throws -> [WatchArchivedMessage] { + guard let directory else { throw CocoaError(.fileNoSuchFile) } + let url = directory.appendingPathComponent("recent_messages.json") + guard FileManager.default.fileExists(atPath: url.path) else { return [] } + return try JSONDecoder().decode([WatchArchivedMessage].self, from: Data(contentsOf: url)) + .filter { $0.expires.map { $0 > date } ?? true } + } + + func save(_ message: WatchArchivedMessage) throws { + _ = try updateMessages { items in + items.removeAll { $0.id == message.id } + items.append(message) + return true + } + } + + @discardableResult + func remove(id: String) throws -> Bool { + guard !id.isEmpty else { return false } + return try updateMessages { items in + let previousCount = items.count + items.removeAll { $0.id == id } + return items.count != previousCount + } + } + + private func updateMessages(_ update: (inout [WatchArchivedMessage]) -> Bool) throws -> Bool { + guard let directory else { throw CocoaError(.fileNoSuchFile) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + // A file lock covers separate notification-service processes; atomic replacement protects readers. + let descriptor = open(directory.appendingPathComponent("archive.lock").path, O_CREAT | O_RDWR, S_IRUSR | S_IWUSR) + guard descriptor >= 0 else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) } + defer { close(descriptor) } + guard flock(descriptor, LOCK_EX) == 0 else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) } + defer { flock(descriptor, LOCK_UN) } + var items = try messages() + guard update(&items) else { return false } + items.sort { $0.date > $1.date } + let data = try JSONEncoder().encode(Array(items.prefix(100))) + try data.write(to: directory.appendingPathComponent("recent_messages.json"), + options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) + return true + } + + func latest(group: String?, at date: Date = .now) throws -> WatchArchivedMessage? { + try messages(at: date).first { group == nil || $0.group == group } + } +} diff --git a/BarkWatch/Common/WatchConstants.swift b/BarkWatch/Common/WatchConstants.swift new file mode 100644 index 00000000..56122cd2 --- /dev/null +++ b/BarkWatch/Common/WatchConstants.swift @@ -0,0 +1,25 @@ +// +// WatchConstants.swift +// Bark +// +// Created by Jian Qin on 10/10/26. +// + +import os + +/// Identifiers shared by the watch app and its extensions. +/// +/// The keychain group must match the `.entitlements` files and the +/// `BarkKeychainAccessGroup` Info.plist entries; update them together when the +/// bundle identifier changes. +nonisolated enum WatchConstants { + static let bundleIdentifier = "me.fin.BarkWatch" + static let appGroupIdentifier = "group.bark" +} + +nonisolated extension Logger { + /// Opening links from the Last message page and the notification interface. + static let links = Logger(subsystem: WatchConstants.bundleIdentifier, category: "Links") + /// Notification permission, the device token, and server registration. + static let registration = Logger(subsystem: WatchConstants.bundleIdentifier, category: "Registration") +} diff --git a/BarkWatch/Common/WatchEncryption.swift b/BarkWatch/Common/WatchEncryption.swift new file mode 100644 index 00000000..1c08f0b3 --- /dev/null +++ b/BarkWatch/Common/WatchEncryption.swift @@ -0,0 +1,200 @@ +// +// WatchEncryption.swift — watch storage and notification presentation +// Bark +// +// Created by Jian Qin on 9/30/26. +// +// Builds on the shared phone source Model/Algorithm.swift (Algorithm, +// CryptoSettingFields, AESCryptoModel) without modifying it. +// + +import Foundation +import CryptoSwift +import Security +import UserNotifications + +nonisolated struct WatchCryptoError: LocalizedError { + let errorDescription: String? + init(_ message: String) { errorDescription = message } +} + +// Same Codable settings as the phone, stored in a shared Keychain item on the watch. +nonisolated enum WatchCryptoStore { + private static func query(account: String) throws -> [String: Any] { + guard let group = Bundle.main.object(forInfoDictionaryKey: "BarkKeychainAccessGroup") as? String, + !group.isEmpty, !group.contains("$(") else { + throw WatchCryptoError("Encryption key sharing is not configured in this build.") + } + return [kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "BarkWatchEncryption", + kSecAttrAccount as String: account, kSecAttrAccessGroup as String: group] + } + + static func load(account: String = "settings") throws -> CryptoSettingFields? { + var query = try query(account: account) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return nil } + try check(status) + guard let data = result as? Data else { throw WatchCryptoError("Cannot read encryption settings.") } + return try JSONDecoder().decode(CryptoSettingFields.self, from: data) + } + + static func save(_ fields: CryptoSettingFields?, account: String = "settings") throws { + let query = try query(account: account) + guard let fields else { + let status = SecItemDelete(query as CFDictionary) + if status != errSecItemNotFound { try check(status) } + return + } + try WatchEncryption.validate(fields) + if let iv = fields.iv, !iv.isEmpty { _ = try AESCryptoModel(watchFields: fields) } + let values: [String: Any] = [kSecValueData as String: try JSONEncoder().encode(fields), + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly] + let status = SecItemUpdate(query as CFDictionary, values as CFDictionary) + if status == errSecItemNotFound { + try check(SecItemAdd(query.merging(values) { _, value in value } as CFDictionary, nil)) + } else { try check(status) } + } + + private static func check(_ status: OSStatus) throws { + guard status == errSecSuccess else { + throw WatchCryptoError("Keychain error: \(status).") + } + } +} + +nonisolated enum WatchEncryption { + static func validate(_ fields: CryptoSettingFields) throws { + guard let algorithm = Algorithm(rawValue: fields.algorithm) else { + throw WatchCryptoError("Invalid algorithm") + } + guard let key = fields.key, key.utf8.count == algorithm.keyLength else { + throw WatchCryptoError("The key must contain \(algorithm.keyLength) UTF-8 bytes.") + } + guard algorithm.modes.contains(fields.mode) else { throw WatchCryptoError("Invalid Mode") } + guard ["noPadding", "pkcs7"].contains(fields.padding) else { throw WatchCryptoError("Invalid Padding") } + if fields.mode == "GCM", fields.padding != "noPadding" { + throw WatchCryptoError("GCM requires noPadding.") + } + } + + static func decryptPayload(_ ciphertext: String, fields: CryptoSettingFields) throws -> [AnyHashable: Any] { + let base64 = ciphertext.components(separatedBy: .whitespacesAndNewlines).joined() + guard let encoded = Data(base64Encoded: base64), !encoded.isEmpty else { + throw WatchCryptoError("Invalid ciphertext") + } + let json = try AESCryptoModel(watchFields: fields).decrypt(ciphertext: base64) + guard let map = try JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any] else { + throw WatchCryptoError("Decrypted content must be a JSON object.") + } + var result: [AnyHashable: Any] = [:] + for (key, value) in map { + let key = key.lowercased() + guard result[key] == nil else { throw WatchCryptoError("Duplicate notification field") } + result[key] = (value as? NSNumber)?.stringValue ?? value + } + return result + } + + /// The initialization vector length a mode needs, in UTF-8 bytes. ECB uses none. + static func vectorLength(for mode: String) -> Int { + switch mode { + case "CBC": 16 + case "GCM": 12 + default: 0 + } + } + + static func randomText(length: Int) throws -> String { + if length == 0 { return "" } + var bytes = [UInt8](repeating: 0, count: length) + guard SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) == errSecSuccess else { + throw WatchCryptoError("Could not generate random bytes.") + } + let alphabet = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_".utf8) + return String(decoding: bytes.map { alphabet[Int($0 & 63)] }, as: UTF8.self) + } + + static func encrypt(_ payload: [String: String], fields: CryptoSettingFields) throws -> [String: String] { + var fields = fields + fields.iv = try randomText(length: vectorLength(for: fields.mode)) + let data = try JSONEncoder().encode(payload) + let cipher = try AESCryptoModel(watchFields: fields).encrypt(text: String(decoding: data, as: UTF8.self)) + var result = ["ciphertext": cipher] + if let iv = fields.iv, !iv.isEmpty { result["iv"] = iv } + return result + } + + // Decode with the phone model, then apply watch-specific notification defaults. + static func decrypt(_ content: UNMutableNotificationContent, fields: CryptoSettingFields?) throws { + guard content.userInfo["ciphertext"] != nil else { return } + guard let ciphertext = content.userInfo["ciphertext"] as? String, var fields else { + throw WatchCryptoError("No encryption key set, or invalid ciphertext.") + } + if let value = content.userInfo["iv"] { + guard let iv = value as? String else { throw WatchCryptoError("Invalid initialization vector") } + fields.iv = iv + } + var result = try decryptPayload(ciphertext, fields: fields) + content.title = result["title"] as? String ?? "" + content.subtitle = result["subtitle"] as? String ?? "" + content.body = result["body"] as? String ?? "" + content.threadIdentifier = result["group"] as? String ?? "" + content.attachments = [] + content.interruptionLevel = .active + content.sound = .default + content.badge = nil + result["aps"] = ["alert": ["title": content.title, "subtitle": content.subtitle, "body": content.body], + "sound": result["sound"] as? String ?? "default"] + content.userInfo = result + } + + static func prepare(_ content: UNMutableNotificationContent, fields: () throws -> CryptoSettingFields?) -> Bool { + guard content.userInfo["ciphertext"] != nil else { return true } + do { + try decrypt(content, fields: fields()) + return true + } catch { + // Do not display outer plaintext fields or process outer image/icon URLs on failure. + content.title = "Bark" + content.subtitle = "" + content.body = "Decryption Failed" + content.threadIdentifier = "" + content.attachments = [] + content.sound = .default + content.interruptionLevel = .active + content.badge = nil + content.userInfo = ["aps": ["alert": ["body": content.body]]] + return false + } + } +} + +// Extend the phone model with byte-based validation without changing its source. +// The phone's AESCryptoModel.validate compares `key.count` (characters); AES needs +// UTF-8 bytes, which differ for non-ASCII keys. +// Assigns every stored property of AESCryptoModel in Model/Algorithm.swift, so it must +// be updated whenever that struct's stored properties change. +extension AESCryptoModel { + init(watchFields fields: CryptoSettingFields) throws { + try WatchEncryption.validate(fields) + let key = fields.key! + let vectorLength = WatchEncryption.vectorLength(for: fields.mode) + if vectorLength > 0, fields.iv?.utf8.count != vectorLength { + throw WatchCryptoError("The initialization vector must contain \(vectorLength) UTF-8 bytes.") + } + let mode: BlockMode + switch fields.mode { + case "CBC": mode = CBC(iv: Array(fields.iv!.utf8)) + case "GCM": mode = GCM(iv: Array(fields.iv!.utf8), mode: .combined) + default: mode = ECB() + } + self.key = key + self.mode = mode + self.padding = fields.padding == "noPadding" ? .noPadding : .pkcs7 + self.aes = try AES(key: Array(key.utf8), blockMode: mode, padding: padding) + } +} diff --git a/BarkWatch/Common/WatchMarkdownParser.swift b/BarkWatch/Common/WatchMarkdownParser.swift new file mode 100644 index 00000000..2f871d6a --- /dev/null +++ b/BarkWatch/Common/WatchMarkdownParser.swift @@ -0,0 +1,69 @@ +// +// WatchMarkdownParser.swift +// Bark +// +// Created by Jian Qin on 9/30/26. +// + +import Markdown +import UIKit + +extension NSAttributedString.Key { + static let markdownCodeBlock = NSAttributedString.Key("bark.markdown.codeBlock") + static let markdownListMarker = NSAttributedString.Key("bark.markdown.listMarker") +} + +// Retain the phone parser; adapt attachments and paragraph metadata for SwiftUI. +// Assumes Common/MarkdownParser.swift renders each task-list checkbox as one text +// attachment, in document order. +enum WatchMarkdownParser { + static func parse(_ source: String, configuration: MarkdownParser.Configuration = .default) -> NSAttributedString { + let text = NSMutableAttributedString(attributedString: MarkdownParser(configuration: configuration).parse(source)) + var checkboxes = CheckboxWalker() + checkboxes.visit(Document(parsing: source)) + var attachments: [NSRange] = [] + text.enumerateAttribute(.attachment, in: NSRange(location: 0, length: text.length)) { value, range, _ in + if value != nil { attachments.append(range) } + } + for (range, checked) in zip(attachments, checkboxes.checked).reversed() { + let attributes = text.attributes(at: NSMaxRange(range), effectiveRange: nil) + text.replaceCharacters(in: range, with: NSAttributedString(string: checked ? "☑" : "☐", attributes: attributes)) + } + // The phone distinguishes block code from inline code through its text color. + // This depends on Common/MarkdownParser.swift styling code blocks with a monospaced + // font, `codeBlockTextColor`, and a background color; update this if that styling changes. + var codeRanges: [NSRange] = [] + text.enumerateAttributes(in: NSRange(location: 0, length: text.length)) { attributes, range, _ in + if (attributes[.font] as? UIFont)?.fontDescriptor.symbolicTraits.contains(.traitMonoSpace) == true, + (attributes[.foregroundColor] as? UIColor) == configuration.codeBlockTextColor, + attributes[.backgroundColor] != nil { + codeRanges.append(range) + } + } + for range in codeRanges { text.addAttribute(.markdownCodeBlock, value: range.location, range: range) } + let string = text.string as NSString + var offset = 0 + while offset < text.length { + let line = string.lineRange(for: NSRange(location: offset, length: 0)) + offset = NSMaxRange(line) + guard text.attribute(.markdownCodeBlock, at: line.location, effectiveRange: nil) == nil, + let style = text.attribute(.paragraphStyle, at: line.location, effectiveRange: nil) as? NSParagraphStyle, + style.headIndent > style.firstLineHeadIndent else { continue } + let prefix = string.substring(with: line) + if let marker = prefix.range(of: "^(?:\\d+\\. (?:[☑☐] )?|\u{2022} |[☑☐] )", options: .regularExpression) { + let range = NSRange(marker, in: prefix) + text.addAttribute(.markdownListMarker, value: true, + range: NSRange(location: line.location, length: range.length)) + } + } + return text + } + + private struct CheckboxWalker: MarkupWalker { + var checked: [Bool] = [] + mutating func visitListItem(_ item: ListItem) { + if let checkbox = item.checkbox { checked.append(checkbox == .checked) } + defaultVisit(item) + } + } +} diff --git a/BarkWatch/Common/WatchNotification.swift b/BarkWatch/Common/WatchNotification.swift new file mode 100644 index 00000000..4b73bc96 --- /dev/null +++ b/BarkWatch/Common/WatchNotification.swift @@ -0,0 +1,128 @@ +// +// WatchNotification.swift +// Bark +// +// Created by Jian Qin on 9/30/26. +// + +import Foundation +import ImageIO +import Intents +import UserNotifications + +nonisolated enum WatchNotification { + static let markdownCategoryIdentifier = "BarkMarkdown" + static let criticalAlertFallbackKey = WatchArchivedMessage.criticalAlertFallbackKey + + static func stringValue(_ key: String, in userInfo: [AnyHashable: Any]) -> String? { + if let value = userInfo[key] as? String { return value } + return (userInfo[key] as? NSNumber)?.stringValue + } + + static func validatedWebURL(_ value: String?) -> URL? { + guard let value, let url = URL(string: value), + ["https", "http"].contains(url.scheme?.lowercased() ?? ""), + let host = url.host, !host.isEmpty, url.user == nil, url.password == nil else { return nil } + return url + } + + static func markdownSummary(_ source: String) -> String { + WatchMarkdownParser.parse(source, configuration: .clear).string + .replacingOccurrences(of: "\n\n+", with: "\n", options: .regularExpression) + } + + static func iconIntent(for content: UNNotificationContent, imageData: Data) -> INSendMessageIntent? { + guard let iconURL = validatedWebURL(stringValue("icon", in: content.userInfo)), + imageData.count <= 5 * 1024 * 1024, + let imageSource = CGImageSourceCreateWithData(imageData as CFData, nil), + CGImageSourceGetCount(imageSource) > 0 else { return nil } + let senderName = content.title.isEmpty ? "Bark" : content.title + var nameComponents = PersonNameComponents() + nameComponents.nickname = senderName + let sender = INPerson( + personHandle: INPersonHandle(value: iconURL.absoluteString, type: .unknown), + nameComponents: nameComponents, displayName: senderName, image: INImage(imageData: imageData), + contactIdentifier: nil, customIdentifier: iconURL.absoluteString + ) + return INSendMessageIntent( + recipients: nil, outgoingMessageType: .outgoingMessageText, content: content.body, + speakableGroupName: nil, + conversationIdentifier: content.threadIdentifier.isEmpty ? iconURL.absoluteString : content.threadIdentifier, + serviceName: nil, sender: sender, attachments: nil + ) + } + + static func criticalSoundVolume(_ userInfo: [AnyHashable: Any]) -> Float { + guard let volumeText = stringValue("volume", in: userInfo), + let volume = Float(volumeText), volume.isFinite else { return 0.5 } + return min(1, max(0, volume / 10)) + } + + /// Runs every content step in the same order as the notification pipeline. + static func prepare(_ content: UNMutableNotificationContent, criticalAlertsEnabled: Bool = false) { + applyMarkdown(content) + applyGroup(content) + applyLevel(content, criticalAlertsEnabled: criticalAlertsEnabled) + clearBadge(content) + } + + /// Matches the phone's `MarkdownProcessor`: shows a plain-text summary as the body. + static func applyMarkdown(_ content: UNMutableNotificationContent) { + guard let markdown = stringValue("markdown", in: content.userInfo), !markdown.isEmpty else { return } + content.body = markdownSummary(markdown) + content.categoryIdentifier = markdownCategoryIdentifier + var aps = content.userInfo["aps"] as? [String: Any] ?? [:] + var alert = aps["alert"] as? [String: Any] ?? [:] + alert["body"] = content.body + aps["alert"] = alert + content.userInfo["aps"] = aps + } + + static func applyGroup(_ content: UNMutableNotificationContent) { + if let group = stringValue("group", in: content.userInfo) { + content.threadIdentifier = group + } + } + + /// Matches the phone's `LevelProcessor`. The sound is chosen here because it + /// depends on the final level, after an unauthorized critical alert is downgraded. + static func applyLevel(_ content: UNMutableNotificationContent, criticalAlertsEnabled: Bool = false) { + let userInfo = content.userInfo + if let requestedLevel = stringValue("level", in: userInfo)?.lowercased() { + content.interruptionLevel = switch requestedLevel { + case "passive": .passive + case "timesensitive", "time-sensitive": .timeSensitive + case "critical": .critical + default: .active + } + } + let requestsCriticalAlert = content.interruptionLevel == .critical + if requestsCriticalAlert { + content.interruptionLevel = criticalAlertsEnabled ? .critical : .active + } + content.userInfo[criticalAlertFallbackKey] = requestsCriticalAlert && !criticalAlertsEnabled + let aps = userInfo["aps"] as? [String: Any] ?? [:] + let criticalSound = aps["sound"] as? [String: Any] + let soundName = (stringValue("sound", in: userInfo) ?? aps["sound"] as? String ?? criticalSound?["name"] as? String)?.lowercased() + if content.interruptionLevel == .passive || soundName == "silent" || soundName == "silent.caf" { + content.sound = nil + } else if content.interruptionLevel == .critical { + content.sound = .defaultCriticalSound(withAudioVolume: criticalSoundVolume(userInfo)) + } else { + // watchOS supports the system notification sound, not named custom sounds. + content.sound = .default + } + } + + /// Matches the phone's `BadgeProcessor`; the watch never shows a badge. + static func clearBadge(_ content: UNMutableNotificationContent) { + content.badge = nil + } + + static func presentationOptions(for content: UNNotificationContent) -> UNNotificationPresentationOptions { + guard content.interruptionLevel != .passive else { return [.list] } + var options: UNNotificationPresentationOptions = [.banner, .list] + if content.sound != nil { options.insert(.sound) } + return options + } +} diff --git a/BarkWatch/Common/WatchNotificationDelivery.swift b/BarkWatch/Common/WatchNotificationDelivery.swift new file mode 100644 index 00000000..1d71b82a --- /dev/null +++ b/BarkWatch/Common/WatchNotificationDelivery.swift @@ -0,0 +1,247 @@ +// +// WatchNotificationDelivery.swift +// Bark +// +// Created by Jian Qin on 10/9/26. +// + +import Foundation +import Intents +import os +import UniformTypeIdentifiers +import UserNotifications + +/// The notification service pipeline, kept out of the extension's principal class +/// so tests can drive it without APNs, the network, or a Siri donation. +/// +/// Like the phone's `NotificationService`, one task runs the steps in order and a +/// copy of the content is published after each step. If the extension's time +/// expires, `finish` delivers the last published copy and cancels the task. +/// `finish` clears `contentHandler` under the lock before calling it, so the +/// content handler runs exactly once. +nonisolated final class WatchNotificationDelivery: @unchecked Sendable { + /// Everything the pipeline touches outside its own state, so tests can replace it. + struct Dependencies { + var archive: WatchArchive = .shared + var session: URLSession = .shared + var cryptoFields: () throws -> CryptoSettingFields? = { try WatchCryptoStore.load() } + var criticalAlertsEnabled: () async -> Bool = { + await UNUserNotificationCenter.current().notificationSettings().criticalAlertSetting == .enabled + } + var donate: (INInteraction) async throws -> Void = { interaction in + try await interaction.donate() + } + } + + private let dependencies: Dependencies + private let logger = Logger(subsystem: WatchConstants.bundleIdentifier, category: "NotificationIcon") + /// Images larger than this are not downloaded or attached. + private let maximumImageSize = 5 * 1024 * 1024 + + private let lock = NSLock() + /// Cleared by `finish`; nil means the notification was already delivered. + private var contentHandler: ((UNNotificationContent) -> Void)? + /// A copy of the content after the last completed step, delivered on expiry. + private var bestAttemptContent: UNNotificationContent? + private var task: Task? + + init(dependencies: Dependencies = Dependencies()) { + self.dependencies = dependencies + } + + func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { + lock.withLock { + self.contentHandler = contentHandler + bestAttemptContent = request.content + } + let identifier = request.identifier + let task = Task { await run(identifier: identifier) } + lock.withLock { + // `finish` may already have run if the extension expired immediately. + if self.contentHandler == nil { + task.cancel() + } else { + self.task = task + } + } + } + + func serviceExtensionTimeWillExpire() { + finish() + } + + // MARK: - Pipeline + + /// Runs the steps in the same order as the phone's processors. + private func run(identifier: String) async { + // Start from the original content `didReceive` stored, so no + // non-Sendable request has to be captured by the task. + let original = lock.withLock { bestAttemptContent } + guard let content = original?.mutableCopy() as? UNMutableNotificationContent else { + finish() + return + } + + // ciphertext: must run first, the whole payload may be encrypted. + // On failure, deliver the "Decryption Failed" content right away. + guard decrypt(content) else { + finish(with: content) + return + } + publish(content) + + // markdown, level, badge + WatchNotification.applyMarkdown(content) + WatchNotification.applyGroup(content) + let criticalAlertsEnabled = await dependencies.criticalAlertsEnabled() + WatchNotification.applyLevel(content, criticalAlertsEnabled: criticalAlertsEnabled) + WatchNotification.clearBadge(content) + publish(content) + + // archive: after preparing, so the stored message matches what is shown. + guard !Task.isCancelled else { return } + archive(content, identifier: identifier) + + // setImage + await attachImage(to: content) + guard !Task.isCancelled else { return } + publish(content) + + // setIcon: last, because the download and Siri donation may run out of time. + let delivered = await applyIcon(to: content) + finish(with: delivered) + } + + private func decrypt(_ content: UNMutableNotificationContent) -> Bool { + WatchEncryption.prepare(content, fields: dependencies.cryptoFields) + } + + private func archive(_ content: UNMutableNotificationContent, identifier: String) { + guard let message = WatchArchivedMessage(content: content, identifier: identifier) else { + return + } + do { + try dependencies.archive.save(message) + } catch { + logger.error("Could not archive notification: \(error.localizedDescription)") + } + } + + private func attachImage(to content: UNMutableNotificationContent) async { + let address = WatchNotification.stringValue("image", in: content.userInfo) + guard let file = await downloadImage(address) else { + return + } + do { + let attachment = try UNNotificationAttachment(identifier: "image", url: file, options: nil) + content.attachments = [attachment] + } catch { + try? FileManager.default.removeItem(at: file) + } + } + + /// Shows the icon as the sender's avatar by donating a message intent. + /// Returns the content unchanged if any part of that fails. + private func applyIcon(to content: UNMutableNotificationContent) async -> UNMutableNotificationContent { + guard let address = WatchNotification.stringValue("icon", in: content.userInfo) else { + return content + } + let file = await downloadImage(address) + defer { + if let file { + try? FileManager.default.removeItem(at: file) + } + } + guard let file, + let data = try? Data(contentsOf: file), + let intent = WatchNotification.iconIntent(for: content, imageData: data) else { + logger.error("Icon download or image validation failed; delivering original notification") + return content + } + + let interaction = INInteraction(intent: intent, response: nil) + interaction.direction = .incoming + do { + try await dependencies.donate(interaction) + guard let updated = try content.updating(from: intent).mutableCopy() as? UNMutableNotificationContent else { + return content + } + // Preserve Bark's text and options while retaining the system's avatar metadata. + updated.title = content.title + updated.subtitle = content.subtitle + updated.body = content.body + updated.userInfo.merge(content.userInfo) { _, original in original } + updated.threadIdentifier = content.threadIdentifier + updated.categoryIdentifier = content.categoryIdentifier + updated.sound = content.sound + updated.interruptionLevel = content.interruptionLevel + updated.attachments = content.attachments + updated.badge = content.badge + logger.info("Notification avatar applied") + return updated + } catch { + logger.error("Notification avatar failed: \(error.localizedDescription)") + return content + } + } + + /// Downloads a PNG, JPEG, or GIF and returns a temporary file with a matching + /// extension, or nil if the address or response is not acceptable. + /// Cancelling the task cancels the download. + private func downloadImage(_ address: String?) async -> URL? { + guard let url = WatchNotification.validatedWebURL(address) else { + return nil + } + let request = URLRequest(url: url, timeoutInterval: 8) + guard let downloaded = try? await dependencies.session.download(for: request) else { + return nil + } + let (file, response) = downloaded + let acceptedTypes = ["image/png", "image/jpeg", "image/gif"] + guard let response = response as? HTTPURLResponse, + response.statusCode == 200, + let mime = response.mimeType, + acceptedTypes.contains(mime), + let suffix = UTType(mimeType: mime)?.preferredFilenameExtension, + let size = try? file.resourceValues(forKeys: [.fileSizeKey]).fileSize, + size <= maximumImageSize else { + try? FileManager.default.removeItem(at: file) + return nil + } + let destination = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension(suffix) + do { + try FileManager.default.moveItem(at: file, to: destination) + return destination + } catch { + try? FileManager.default.removeItem(at: file) + return nil + } + } + + // MARK: - Delivery + + /// Stores a copy, so the content handed to the system on expiry is never + /// changed afterwards by the running task. + private func publish(_ content: UNMutableNotificationContent) { + let snapshot = content.copy() as? UNNotificationContent + lock.withLock { + bestAttemptContent = snapshot + } + } + + /// Delivers `content`, or the last published copy, exactly once. + private func finish(with content: UNNotificationContent? = nil) { + let (handler, result, task) = lock.withLock { + let taken = (contentHandler, content ?? bestAttemptContent, self.task) + contentHandler = nil + self.task = nil + return taken + } + task?.cancel() + if let handler, let result { + handler(result) + } + } +} diff --git a/BarkWatch/Info.plist b/BarkWatch/Info.plist new file mode 100644 index 00000000..1d486f4a --- /dev/null +++ b/BarkWatch/Info.plist @@ -0,0 +1,21 @@ + + + + + UIBackgroundModes + + remote-notification + + BarkKeychainAccessGroup + $(AppIdentifierPrefix)me.fin.bark.shared + NSUserActivityTypes + + INSendMessageIntent + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + + diff --git a/BarkWatch/MarkdownNotification.apns b/BarkWatch/MarkdownNotification.apns new file mode 100644 index 00000000..eb8a4f61 --- /dev/null +++ b/BarkWatch/MarkdownNotification.apns @@ -0,0 +1,13 @@ +{ + "aps": { + "alert": { + "title": "Markdown on watch", + "body": "Open to see formatted Markdown." + }, + "mutable-content": 1, + "category": "BarkMarkdown", + "sound": "default" + }, + "markdown": "# Training\n**Strong**, *emphasis*, ~~removed~~ and `code`.\n\n> Steady effort.\n\n3. Warm up\n4. Main set\n - Recover\n\n- [x] Completed\n- [ ] Pending\n\n```swift\nlet distance = 42\nprint(distance)\n```\n\n[Bark](https://bark.day.app) and ![Photo](https://example.com/photo.png)", + "isarchive": "0" +} diff --git a/BarkWatch/Model/ServerManager.swift b/BarkWatch/Model/ServerManager.swift new file mode 100644 index 00000000..7070eab9 --- /dev/null +++ b/BarkWatch/Model/ServerManager.swift @@ -0,0 +1,213 @@ +// +// ServerManager.swift +// Bark +// +// Created by Jian Qin on 9/30/26. +// + + +import Foundation +import Observation +import os + +struct Server: Codable, Identifiable, Equatable { + let id: UUID + let address: URL + var key = "" + + init(address: String) throws { + let trimmedAddress = address.trimmingCharacters(in: .whitespacesAndNewlines) + guard var components = URLComponents(string: trimmedAddress), + let scheme = components.scheme?.lowercased(), ["http", "https"].contains(scheme), + let host = components.host, !host.isEmpty, + components.user == nil, components.password == nil, + components.query == nil, components.fragment == nil else { + throw BarkServerError("Enter a server address beginning with https:// or http://, without a key or query.") + } + components.scheme = scheme + components.host = host.lowercased() + while components.path.hasSuffix("/") { components.path.removeLast() } + guard let url = components.url else { throw BarkServerError("Invalid server address.") } + id = UUID() + self.address = url + } + + private enum CodingKeys: String, CodingKey { + case id + case address + case key + } + + var addressAndKey: URL { + guard !key.isEmpty else { return address } + return address.appendingPathComponent(key) + } + + func makeTestPushRequest(sound: String = "default", critical: Bool = false, volume: Int = 5, encryption: CryptoSettingFields? = nil) throws -> URLRequest { + // Exercise grouping, alert level, Markdown, links, images, and the notification icon. + var payload = [ + "title": encryption == nil ? "Bark Watch test" : "Bark Watch encrypted test", + "subtitle": "Feature check", + "body": "sent through your Bark server.", + "markdown": "sent through your **Bark server**.\n\n- *Markdown* ~~`code`~~ \n\n[Bark](https://bark.day.app)", + "group": "Bark Watch test", + "url": "https://day.app", + "image": "https://day.app/assets/images/avatar.jpg", + "icon": "https://day.app/assets/images/avatar.jpg", + "sound": sound, + "level": critical ? "critical" : "active", "volume": String(min(10, max(0, volume))) + ] + if let encryption { payload = try WatchEncryption.encrypt(payload, fields: encryption) } + payload["device_key"] = key + // Kept outside the ciphertext so the server can use it as the collapse identifier; + // repeated tests replace each other instead of piling up. + payload["id"] = "bark-watch-test" + return try BarkApi.push(address: address, parameters: payload).asURLRequest() + } +} + +struct BarkResponse: Decodable { + struct RegistrationData: Decodable { let key: String? } + let code: Int + let message: String? + let data: RegistrationData? + + static func decode(_ data: Data, response: URLResponse) throws -> Self { + guard let httpResponse = response as? HTTPURLResponse else { + throw BarkServerError("The server returned an invalid response.") + } + let decodedResponse = try? JSONDecoder().decode(Self.self, from: data) + guard (200..<300).contains(httpResponse.statusCode) else { + throw BarkServerError(decodedResponse?.message ?? "Server request failed (\(httpResponse.statusCode)).") + } + guard let decodedResponse else { throw BarkServerError("The server did not return a Bark response.") } + guard decodedResponse.code == 200 else { + throw BarkServerError(decodedResponse.message ?? "Bark request failed (\(decodedResponse.code)).") + } + return decodedResponse + } +} + +@Observable +@MainActor +final class ServerManager { + private(set) var servers: [Server] = [] + private(set) var statusMessages: [UUID: String] = [:] + private(set) var errorMessages: [UUID: String] = [:] + private(set) var busyServerIDs: Set = [] + private(set) var storageError: String? + private(set) var deviceToken: String? + private let userDefaults: UserDefaults + private let sendRequest: (URLRequest) async throws -> (Data, URLResponse) + + init( + defaults: UserDefaults = .standard, + send: @escaping (URLRequest) async throws -> (Data, URLResponse) = { try await URLSession.shared.data(for: $0) } + ) { + self.userDefaults = defaults + self.sendRequest = send + if let data = defaults.data(forKey: "barkWatchServers") { + do { servers = try JSONDecoder().decode([Server].self, from: data) } + catch { storageError = "Saved servers could not be loaded: \(error.localizedDescription)" } + } + } + + @discardableResult + func addServer(address: String) throws -> UUID { + if let storageError { throw BarkServerError(storageError) } + let server = try Server(address: address) + guard !servers.contains(where: { $0.address == server.address }) else { + throw BarkServerError("This server is already saved.") + } + servers.append(server) + saveServers() + return server.id + } + + func updateServerKey(serverID: UUID, key: String) { + guard let index = servers.firstIndex(where: { $0.id == serverID }) else { return } + servers[index].key = key + saveServers() + } + + func removeServer(serverID: UUID) async { + guard let server = servers.first(where: { $0.id == serverID }) else { return } + // Remove locally first so an in-flight registration can't save a key for it again. + servers.removeAll { $0.id == serverID } + statusMessages[serverID] = nil + errorMessages[serverID] = nil + saveServers() + // Send an invalid deviceToken for the removed server so it stops pushing the old key to this watch. + guard !server.key.isEmpty, + let request = try? BarkApi.register(address: server.address, key: server.key, devicetoken: "deleted").asURLRequest() + else { return } + _ = try? await sendRequest(request) + } + + func syncAllServers(deviceToken: String) async { + self.deviceToken = deviceToken + for serverID in servers.map(\.id) { await register(serverID: serverID) } + } + + func register(serverID: UUID) async { + guard !busyServerIDs.contains(serverID) else { return } + guard deviceToken != nil else { + statusMessages[serverID] = "Enable notifications to register this watch." + return + } + busyServerIDs.insert(serverID) + defer { busyServerIDs.remove(serverID) } + // If Apple rotates the token during a request, keep the returned key and update it again. + while let tokenToRegister = deviceToken, + let server = servers.first(where: { $0.id == serverID }) { + statusMessages[serverID] = "Registering key…" + errorMessages[serverID] = nil + do { + let request = try BarkApi.register(address: server.address, key: server.key, devicetoken: tokenToRegister).asURLRequest() + let (data, response) = try await sendRequest(request) + let decodedResponse = try BarkResponse.decode(data, response: response) + guard let registeredKey = decodedResponse.data?.key, !registeredKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw BarkServerError("The server did not return a device key.") + } + guard servers.contains(where: { $0.id == serverID }) else { return } + updateServerKey(serverID: serverID, key: registeredKey) + statusMessages[serverID] = "Key registered" + Logger.registration.debug("Registered \(server.address.absoluteString) with device key \(registeredKey)") + } catch { + guard servers.contains(where: { $0.id == serverID }) else { return } + statusMessages[serverID] = nil + errorMessages[serverID] = error.localizedDescription + } + if tokenToRegister == deviceToken { return } + } + } + + func sendTestPush(serverID: UUID, sound: String = "default", critical: Bool = false, volume: Int = 5, encrypted: Bool = false) async { + guard !busyServerIDs.contains(serverID), let server = servers.first(where: { $0.id == serverID }), !server.key.isEmpty else { return } + busyServerIDs.insert(serverID) + errorMessages[serverID] = nil + statusMessages[serverID] = "Sending…" + let tokenBeforeSending = deviceToken + do { + let fields = encrypted ? try WatchCryptoStore.load() : nil + if encrypted && fields == nil { throw WatchCryptoError("Save an encryption key first.") } + let (data, response) = try await sendRequest(server.makeTestPushRequest(sound: sound, critical: critical, volume: volume, encryption: fields)) + _ = try BarkResponse.decode(data, response: response) + if servers.contains(where: { $0.id == serverID }) { + statusMessages[serverID] = "Server accepted the test. Watch for the notification." + } + } catch { + if servers.contains(where: { $0.id == serverID }) { + statusMessages[serverID] = nil + errorMessages[serverID] = error.localizedDescription + } + } + busyServerIDs.remove(serverID) + if tokenBeforeSending != deviceToken { await register(serverID: serverID) } + } + + private func saveServers() { + do { userDefaults.set(try JSONEncoder().encode(servers), forKey: "barkWatchServers") } + catch { storageError = "Servers could not be saved: \(error.localizedDescription)" } + } +} diff --git a/BarkWatch/NotificationService/Info.plist b/BarkWatch/NotificationService/Info.plist new file mode 100644 index 00000000..73e8f39e --- /dev/null +++ b/BarkWatch/NotificationService/Info.plist @@ -0,0 +1,24 @@ + + + + + BarkKeychainAccessGroup + $(AppIdentifierPrefix)me.fin.bark.shared + NSUserActivityTypes + + INSendMessageIntent + + NSExtension + + NSExtensionPointIdentifier + com.apple.usernotifications.service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).NotificationService + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + + diff --git a/BarkWatch/NotificationService/NotificationService.entitlements b/BarkWatch/NotificationService/NotificationService.entitlements new file mode 100644 index 00000000..28393494 --- /dev/null +++ b/BarkWatch/NotificationService/NotificationService.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.application-groups + + group.bark + + keychain-access-groups + + $(AppIdentifierPrefix)me.fin.bark.shared + + + diff --git a/BarkWatch/NotificationService/WatchNotificationService.swift b/BarkWatch/NotificationService/WatchNotificationService.swift new file mode 100644 index 00000000..cbf7e0d2 --- /dev/null +++ b/BarkWatch/NotificationService/WatchNotificationService.swift @@ -0,0 +1,20 @@ +// +// WatchNotificationService.swift +// Bark +// +// Created by Jian Qin on 9/30/26. +// + +import UserNotifications + +/// The extension's principal class. The pipeline lives in `WatchNotificationDelivery` +/// so it can be exercised by tests without APNs driving it. +final class NotificationService: UNNotificationServiceExtension { + private let delivery = WatchNotificationDelivery() + + override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { + delivery.didReceive(request, withContentHandler: contentHandler) + } + + override func serviceExtensionTimeWillExpire() { delivery.serviceExtensionTimeWillExpire() } +} diff --git a/BarkWatch/TestNotification.apns b/BarkWatch/TestNotification.apns new file mode 100644 index 00000000..3d39decf --- /dev/null +++ b/BarkWatch/TestNotification.apns @@ -0,0 +1,10 @@ +{ + "aps": { + "alert": { + "title": "Bark Watch", + "body": "Test notification from the simulator." + }, + "mutable-content": 1, + "sound": "default" + } +} diff --git a/BarkWatch/Tests/NotificationTests.swift b/BarkWatch/Tests/NotificationTests.swift new file mode 100644 index 00000000..82a13c95 --- /dev/null +++ b/BarkWatch/Tests/NotificationTests.swift @@ -0,0 +1,692 @@ +// +// NotificationTests.swift +// Bark +// +// Created by Jian Qin on 9/30/26. +// + +import Testing +import Intents +import UserNotifications +import SwiftUI +import UIKit +import WatchKit +@testable import BarkWatch + +@MainActor +@Test func remoteDeletionRemovesOnlyMatchingMessagesAndHandlesFailures() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let archive = WatchArchive(directory: directory) + let delegate = NotificationDelegate() + let content = UNMutableNotificationContent() + let keptID = "keep-\(UUID())" + let deletedID = "delete-\(UUID())" + content.title = "Keep" + content.userInfo = ["id": keptID] + try archive.save(#require(WatchArchivedMessage(content: content, identifier: keptID))) + content.title = "Delete" + content.userInfo = ["id": deletedID] + try archive.save(#require(WatchArchivedMessage(content: content, identifier: deletedID, date: .now.addingTimeInterval(1)))) + delegate.state.receive(WatchMessage(content: content)) + let link = try #require(URL(string: "https://example.com/deleted")) + delegate.state.requestedNotificationURL = link + delegate.state.pendingBrowserURL = link + + for info: [AnyHashable: Any] in [[:], ["id": deletedID], ["delete": "0", "id": deletedID], ["delete": "1"], ["delete": "1", "id": ""], ["delete": "1", "id": [deletedID]]] { + #expect(delegate.handleRemoteNotification(info, archive: archive) == .noData) + } + #expect(try archive.messages().count == 2) + #expect(delegate.state.lastMessage?.identifier == deletedID) + #expect(delegate.handleRemoteNotification(["delete": "1", "id": "unknown"], archive: archive) == .newData) + #expect(delegate.state.lastMessage?.identifier == deletedID) + #expect(delegate.state.pendingBrowserURL == link) + + #expect(delegate.handleRemoteNotification(["delete": 1, "id": deletedID], archive: archive) == .newData) + #expect(try WatchArchive(directory: directory).messages().map(\.id) == [keptID]) + #expect(try archive.latest(group: nil)?.title == "Keep") + #expect(delegate.state.lastMessage == nil) + #expect(delegate.state.requestedNotificationURL == nil) + #expect(delegate.state.pendingBrowserURL == nil) + #expect(delegate.handleRemoteNotification(["delete": "1", "id": deletedID], archive: archive) == .newData) + #expect(try archive.messages().map(\.id) == [keptID]) + + content.userInfo = ["delete": "1", "id": deletedID] + #expect(WatchArchivedMessage(content: content, identifier: deletedID) == nil) + let file = directory.appendingPathComponent("recent_messages.json") + let corruptData = Data("invalid archive".utf8) + try corruptData.write(to: file) + #expect(delegate.handleRemoteNotification(["delete": "1", "id": deletedID], archive: archive) == .failed) + #expect(try Data(contentsOf: file) == corruptData) + + var results: [WKBackgroundFetchResult] = [] + delegate.didReceiveRemoteNotification(["delete": "1"]) { results.append($0) } + #expect(results == [.noData]) +} + +@Test func notificationIconIntentValidatesImageAndPreservesSender() throws { + let content = UNMutableNotificationContent() + content.title = "Build monitor" + content.body = "Build completed" + let png = try #require(Data(base64Encoded: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=")) + #expect(WatchNotification.iconIntent(for: content, imageData: png) == nil) + content.userInfo = ["icon": "file:///private/icon.png"] + #expect(WatchNotification.iconIntent(for: content, imageData: png) == nil) + content.userInfo = ["icon": "https://example.com/icon.png"] + #expect(WatchNotification.iconIntent(for: content, imageData: Data("not an image".utf8)) == nil) + #expect(WatchNotification.iconIntent(for: content, imageData: Data(repeating: 0, count: 5 * 1024 * 1024 + 1)) == nil) + let intent = try #require(WatchNotification.iconIntent(for: content, imageData: png)) + #expect(intent.sender?.displayName == content.title) + #expect(intent.sender?.image != nil) + #expect(intent.content == content.body) + #expect(intent.conversationIdentifier == "https://example.com/icon.png") + content.title = "" + content.threadIdentifier = "Builds" + let untitled = try #require(WatchNotification.iconIntent(for: content, imageData: png)) + #expect(untitled.sender?.displayName == "Bark") + #expect(untitled.conversationIdentifier == "Builds") +} + +@Test func watchNotificationOptionsAndFallbacks() throws { + let content = UNMutableNotificationContent() + content.body = "Fallback" + content.badge = 9 + content.userInfo = ["markdown": "**Bold** and `code`\nSecond line", "group": "Training", "level": "timeSensitive", "aps": ["sound": "minuet.caf"]] + WatchNotification.prepare(content) + #expect(content.body == "Bold and code Second line") + #expect(content.threadIdentifier == "Training") + #expect(content.interruptionLevel == .timeSensitive) + #expect(content.sound != nil) + #expect(content.badge == nil) + #expect(WatchNotification.presentationOptions(for: content).contains(.sound)) + + content.userInfo = ["level": "passive", "aps": ["sound": "default"]] + WatchNotification.applyLevel(content) + #expect(content.interruptionLevel == .passive) + #expect(content.sound == nil) + #expect(WatchNotification.presentationOptions(for: content) == [.list]) + content.userInfo = ["level": "critical", "aps": ["sound": "default"]] + WatchNotification.applyLevel(content) + #expect(content.interruptionLevel == .active) + #expect(WatchMessage(content: content).usedCriticalAlertFallback) + WatchNotification.applyLevel(content, criticalAlertsEnabled: true) + #expect(content.interruptionLevel == .critical) + #expect(content.sound != nil) + #expect(!WatchMessage(content: content).usedCriticalAlertFallback) + #expect(WatchNotification.presentationOptions(for: content).contains(.sound)) + #expect(WatchNotification.criticalSoundVolume([:]) == 0.5) + #expect(WatchNotification.criticalSoundVolume(["volume": "3"]) == 0.3) + #expect(WatchNotification.criticalSoundVolume(["volume": 20]) == 1) + #expect(WatchNotification.criticalSoundVolume(["volume": -3]) == 0) + #expect(WatchNotification.criticalSoundVolume(["volume": "nan"]) == 0.5) + #expect(WatchNotification.criticalSoundVolume(["volume": "inf"]) == 0.5) + content.userInfo = ["level": "critical", "aps": ["sound": "silent"]] + WatchNotification.applyLevel(content, criticalAlertsEnabled: true) + #expect(content.interruptionLevel == .critical) + #expect(content.sound == nil) + content.userInfo = ["level": "active", "aps": ["sound": "silent"], "url": "https://example.com/watch", "image": "https://example.com/image.png", "id": "progress"] + WatchNotification.applyLevel(content) + #expect(content.sound == nil) + #expect(!WatchNotification.presentationOptions(for: content).contains(.sound)) + let message = WatchMessage(content: content) + #expect(message.url?.host == "example.com") + #expect(message.image?.path == "/image.png") + #expect(message.identifier == "progress") + for address in ["javascript:alert(1)", "file:///private/data", "https://user:pass@example.com", "https://"] { + #expect(WatchNotification.validatedWebURL(address) == nil) + } + #expect(WatchNotification.stringValue("badge", in: ["badge": 3]) == "3") + content.userInfo = ["level": "active", "aps": ["sound": "system.caf"]] + WatchNotification.applyLevel(content) + #expect(content.sound != nil) +} + +@Test(arguments: ["silent", "silent.caf", "SILENT.CAF"]) +func silentSoundDisablesAlertSound(soundName: String) { + for level in ["active", "timeSensitive", "critical"] { + let content = UNMutableNotificationContent() + content.sound = .default + content.userInfo = ["level": level, "aps": ["sound": soundName]] + WatchNotification.applyLevel(content, criticalAlertsEnabled: true) + #expect(content.sound == nil) + #expect(WatchNotification.presentationOptions(for: content) == [.banner, .list]) + } +} + +@MainActor +@Test func notificationStatusReturnsWhenPermissionOrRegistrationIsLost() async throws { + let suite = "BarkWatchTests.\(UUID())" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let state = NotificationState(servers: ServerManager(defaults: defaults)) + state.authorizationStatus = .authorized + state.registrationState = .registered("test-token") + #expect(state.needsNotificationSetup) + let id = try state.servers.addServer(address: "https://bark.example") + #expect(!state.needsNotificationSetup) + state.authorizationStatus = .denied + #expect(state.needsNotificationSetup) + state.authorizationStatus = .provisional + #expect(!state.needsNotificationSetup) + for registrationState: NotificationState.PushRegistrationState in [.idle, .registering, .failed("Registration failed")] { + state.registrationState = registrationState + #expect(state.needsNotificationSetup) + } + state.authorizationStatus = .authorized + state.registrationState = .registered("test-token") + #expect(!state.needsNotificationSetup) + await state.servers.removeServer(serverID: id) + #expect(state.needsNotificationSetup) +} + +@MainActor +@Test func latestNotificationReplacesPreviousContent() { + let state = NotificationState() + let content = UNMutableNotificationContent() + content.title = "First" + content.subtitle = "Bark subtitle" + content.body = "First body" + state.receive(WatchMessage(content: content)) + #expect(state.lastMessage?.title == "First") + #expect(state.lastMessage?.subtitle == "Bark subtitle") + #expect(state.lastMessage?.body == "First body") + + content.title = "" + content.body = "Second body" + state.receive(WatchMessage(content: content)) + #expect(state.lastMessage?.title == "") + #expect(state.lastMessage?.body == "Second body") + + content.title = "Third" + content.body = "" + state.receive(WatchMessage(content: content)) + #expect(state.lastMessage?.title == "Third") + #expect(state.lastMessage?.body == "") +} + +@MainActor +@Test func barkRegistrationPersistenceRequestsAndFailures() async throws { + let suite = "BarkWatchTests.\(UUID())" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let store = ServerManager(defaults: defaults, send: barkResponse) + + for address in ["", "example.com", "file:///tmp/server", "https://example.com?key=secret", "https://user:pass@example.com", "https://example.com/#fragment"] { + #expect(throws: BarkServerError.self) { try store.addServer(address: address) } + } + let id = try store.addServer(address: " https://BARK.EXAMPLE/proxy/ ") + #expect(throws: BarkServerError.self) { try store.addServer(address: "https://bark.example/proxy") } + #expect(try Server(address: "http://192.168.1.2:8080/").address.absoluteString == "http://192.168.1.2:8080") + await store.register(serverID: id) + #expect(store.servers.first?.key == "") + await store.syncAllServers(deviceToken: "first-token") + #expect(store.servers.first?.key == "watch-key") + #expect(store.errorMessages[id] == nil) + #expect(store.busyServerIDs.isEmpty) + await store.syncAllServers(deviceToken: "rotated-token") + #expect(store.servers.first?.key == "watch-key") + + var server = try #require(store.servers.first) + let restored = ServerManager(defaults: defaults, send: barkResponse) + #expect(restored.servers == store.servers) + #expect(restored.deviceToken == nil) + // Existing installations persist "key"; the Swift property rename must not change it. + let legacyData = try JSONSerialization.data(withJSONObject: [ + "id": server.id.uuidString, "address": server.address.absoluteString, "key": server.key + ]) + #expect(try JSONDecoder().decode(Server.self, from: legacyData) == server) + let encodedServer = try #require(JSONSerialization.jsonObject(with: JSONEncoder().encode(server)) as? [String: Any]) + #expect(encodedServer["key"] as? String == server.key) + #expect(encodedServer["deviceKey"] == nil) + server.key = "a+b&c=d" + let request = try BarkApi.register(address: server.address, key: server.key, devicetoken: "token+with&symbols").asURLRequest() + let requestURL = try #require(request.url) + let components = try #require(URLComponents(url: requestURL, resolvingAgainstBaseURL: false)) + #expect(request.httpMethod == "GET") + #expect(components.path == "/proxy/register") + #expect(components.queryItems?.first(where: { $0.name == "key" })?.value == server.key) + #expect(components.queryItems?.first(where: { $0.name == "devicetoken" })?.value == "token+with&symbols") + #expect(components.percentEncodedQuery?.contains("%2B") == true) + + let push = try server.makeTestPushRequest() + #expect(push.httpMethod == "POST") + #expect(push.url?.path == "/proxy/push") + #expect(push.value(forHTTPHeaderField: "Content-Type") == "application/json") + let body = try JSONDecoder().decode([String: String].self, from: #require(push.httpBody)) + #expect(body["device_key"] == server.key) + #expect(body["sound"] == "default") + let criticalRequest = try server.makeTestPushRequest(sound: "default", critical: true, volume: 8) + let criticalBody = try JSONDecoder().decode([String: String].self, from: #require(criticalRequest.httpBody)) + #expect(criticalBody["level"] == "critical") + #expect(criticalBody["volume"] == "8") + let silentRequest = try server.makeTestPushRequest(sound: "none") + let silentBody = try JSONDecoder().decode([String: String].self, from: #require(silentRequest.httpBody)) + #expect(silentBody["sound"] == "none") + await store.sendTestPush(serverID: id) + #expect(store.statusMessages[id]?.contains("accepted") == true) + #expect(store.errorMessages[id] == nil) + + for host in ["rejected.example", "missing-key.example", "malformed.example", "offline.example"] { + let failedID = try store.addServer(address: "https://\(host)") + await store.register(serverID: failedID) + #expect(store.errorMessages[failedID] != nil) + #expect(store.servers.first(where: { $0.id == failedID })?.key == "") + #expect(!store.busyServerIDs.contains(failedID)) + await store.removeServer(serverID: failedID) + } + let response = try #require(HTTPURLResponse(url: server.address, statusCode: 503, httpVersion: nil, headerFields: nil)) + #expect(throws: BarkServerError.self) { + try BarkResponse.decode(Data(#"{"code":200,"data":{"key":"must-not-save"}}"#.utf8), response: response) + } + await store.removeServer(serverID: id) + #expect(ServerManager(defaults: defaults).servers.isEmpty) +} + +@MainActor +@Test func registrationUsesRotatedTokenAndIgnoresRemovedServers() async throws { + let suite = "BarkWatchTests.\(UUID())" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let (requestStarted, signalRequestStarted) = AsyncStream.makeStream() + let (resumeRequest, signalResumeRequest) = AsyncStream.makeStream() + defer { + signalRequestStarted.finish() + signalResumeRequest.finish() + } + var requestCount = 0 + var unregisteredKeys: [String] = [] + let store = ServerManager(defaults: defaults) { request in + let query = request.url.flatMap { URLComponents(url: $0, resolvingAgainstBaseURL: false)?.queryItems } ?? [] + if query.contains(URLQueryItem(name: "devicetoken", value: "deleted")) { + unregisteredKeys.append(query.first { $0.name == "key" }?.value ?? "") + return try await barkResponse(for: request) + } + requestCount += 1 + signalRequestStarted.yield(()) + var iterator = resumeRequest.makeAsyncIterator() + await iterator.next() + return try await barkResponse(for: request) + } + let serverID = try store.addServer(address: "https://bark.example") + var startedRequests = requestStarted.makeAsyncIterator() + let registration = Task { await store.syncAllServers(deviceToken: "first-token") } + await startedRequests.next() + // A concurrent token refresh must not send a duplicate request while this one is busy. + await store.syncAllServers(deviceToken: "rotated-token") + #expect(requestCount == 1) + signalResumeRequest.yield(()) + await startedRequests.next() + signalResumeRequest.yield(()) + await registration.value + #expect(requestCount == 2) + #expect(store.servers.first?.key == "watch-key") + #expect(store.errorMessages[serverID] == nil) + #expect(store.busyServerIDs.isEmpty) + + let refresh = Task { await store.register(serverID: serverID) } + await startedRequests.next() + await store.removeServer(serverID: serverID) + signalResumeRequest.yield(()) + await refresh.value + // Removal invalidates the old key on the server, like the phone's server list does. + #expect(unregisteredKeys == ["watch-key"]) + #expect(store.servers.isEmpty) + #expect(store.statusMessages[serverID] == nil) + #expect(store.errorMessages[serverID] == nil) + #expect(store.busyServerIDs.isEmpty) +} + +// Local response stub: no network requests are made by this check. +@MainActor +private func barkResponse(for request: URLRequest) async throws -> (Data, URLResponse) { + let url = request.url! + let body: String + switch url.host { + case "offline.example": + throw URLError(.notConnectedToInternet) + case "rejected.example": body = #"{"code":400,"message":"Registration rejected"}"# + case "missing-key.example": body = #"{"code":200,"data":{"key":""}}"# + case "malformed.example": body = "not a Bark response" + default: + if url.lastPathComponent == "register" { + let query = URLComponents(url: url, resolvingAgainstBaseURL: false)!.queryItems! + let key = query.first(where: { $0.name == "key" })?.value + let token = query.first(where: { $0.name == "devicetoken" })?.value + let valid = (token == "first-token" && key == "") || (token == "rotated-token" && key == "watch-key") + body = valid ? #"{"code":200,"data":{"key":"watch-key"}}"# : #"{"code":400,"message":"Wrong registration parameters"}"# + } else { + body = #"{"code":200,"message":"success"}"# + } + } + let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "application/json"])! + return (Data(body.utf8), response) +} + +// Independent compatibility vectors generated with node:crypto, matching the phone scripts. +@Test func encryptedNotificationsMatchPhoneWireFormat() throws { + let vectors: [(String, String, String)] = [ + ("AES128", "CBC", "ShFOkZwRwKcHQRzzwb9qvSGKwatwVEUWmbHv8ydX9kRfIgIyQEkrY0iLIZrg5Tc5GjxmVWTajM3CIDT9aAulkIU8Gb0VAKXhCp/0XRQxge5NL7rgGnYbZB1Bn5tO4TaEicZZC5baU7tpU4vdS+sXmMNSedjTTTCzbUWViAkMI6hzevILUUdHD7/9cEEabblnQ8xgAJVkbwdX87RZmmansxD6v0YDqWC4OS6oNyEBd/lrNaTIvM3Ct/fldEn3P0ErE5sDqkcwdhq3gJTwSKGEQw=="), + ("AES128", "ECB", "fVv0lv/GjbI8zCO7Xw55gpf+d11SP+1YhGM5VGndAnldDVwAMEmq4OG04n2U7XFw5jU1kAJhxoRzt5YUBSPm0crgSJiqSPIAs/USAYTHcBqiDrgeP8iyCfnt41Ns/qRV2Z2OHsTYm5rpP9JZir6xZmi108u9awbCZIXRgrSidYH6/BtihWEweZsvDdW51DsNR4a6eZSMwEOh1PIXmk6gPydbJEuLAMQ809ZZVUeapufUAh82uDqgdRhlwMevnrmvN3Ii4GGpJMWRzZwn6hY+1A=="), + ("AES128", "GCM", "nvAo/AfpHIdjEvFUlUymeX5wiMfIZKnmptDvCyc1PNhocjqzBJGTKfeeKDxAu3of/51mSCKis4WJEUfJW1EnoQsUZEeUuRm7Lg++o+DSl9oe1E+IhXG3wDBsgs/IBZxuQuf9qAHyzyXWpi8ypYTybAm9+Cetkw1Oal/z7ekBGZsU2OQ11mUvweFbHMG7oHrwnJYdtMX8hlghpZ9Oim3Y/hl0kOYNwh8JA7LsI2b9CBqwRjl9YvKkDWFdpQP1O9jHgcK1Lq3AI9IrjNzdxOu3mg=="), + ("AES192", "CBC", "iCAbTlSF1BfjAynBOSFhJ3uunLg5F3gvSTQpHD60rMz9tw/GZxBCpjFkd36BC/cJcNvcO3oZdugYR0zLdkLbrKaB7En9d1q56toktbAat8t/RW2QCB2In4NsQt+zPaVwtZ3+LgTC68fX2TYHwLOg2le9uMhg4qcul9kU/AGuvWI4lgVqoq9bPtAIKwzqUhNUBrKW2FI2aFghutzcjJdZbLYLcJtWqbZiNEGfavsfJ5dnXT1qm+eklMBTuLVJmYmHX8p/sdNEaFqNRhDfZtHBBQ=="), + ("AES192", "ECB", "kZvc1kLjCJAf+gTM6dTFbpQPt7jYa7MDtmk3UwbkvgFpUCtha9qonkYi21AT41PiKXXKsnLUdZ2TMlbqOg2clo4ahy1wQwE0OyqInRORwEdzULqA5yKVYSfmMw1QVw/K8QMK6vUsvLHcQLd1Lf6FNhYSEsiCaK29Y596CXxFhFEp/0Qjzd8/fk7Z/qKm1+2abIXNrVPT5oQZJadeuA4z2/EMzpiaq9dPnzbnbCPvBSME5B3NjNjyiOKwDDt2Jgt8DGdTta4MJQgKttDjLjGNMg=="), + ("AES192", "GCM", "g46nzO6hDrMnqVOWf+NUQPbu2bGZHL8iB4JxNuovBwymWP3SC/MUQvWrHoiMX8tlPmZxhUiHdND1K+TBj84gOtxH8JADrS9dQyTX4zMel7nw/WyK4EpOUJ+YZzBwdV80ayrCrf0oU87Rw/SyJXxsx90fzFSKMJsGq0zwRPrF1HBdmqGn9YyB8YJ00rw4rlq33vR+bbG0kmWF3UoRqmmh5DXoxHo0ZoG6d4xGkVfVuP7yg80pAuahgZrrTR+Mew61EifzOzHTzHm/3ijTseXDPA=="), + ("AES256", "CBC", "y8Jui/iMYIi7bZt/8vN1A5XdHtQcQosLm1Mc41003inCQLc7Q5Ku/s4+8wYQGGrpF1S3jllvyFwU3X2kxhwHB7Lc8AazTSTsLwM3o3/XThF0B/dlndsDpBdYfilSUa1B+3CeeYqwmSXOTc7QWgBOaFyQ4Ojn0H9sA2XtwRhqzjK8YrkopNdqsOdeBFPpkYt+gKaoRSszHEiNWALJivMoy3fPw5ylq2rQ4MgX7xLzEFBbwEHo/Sjv3RA86Xnw6NuCQTAh37FAGZebHylhcAbEBA=="), + ("AES256", "ECB", "z3VG2WcSCSfWrqeHMig9VANuOsO6iGZOBa4t8QrplUXQd06B+YQ3/XhM9lxjsh/l1hd8RcfYe15etBwWypGvYskeWzphT4aXmzDea3ZXwBc6GDwp17IfxAfpZBBo6Gu0zKHUOLcy2iEO5byMfDTWMuabBOQGDag/TcQfvLegNQMMmuVsa7KxCg5AIZsdZzYsGpsA6chdck5sDEt7zj8ZSS+xYd2fO+2sQSvQhi7/WT+B/v8ICQVeMgGRI4WdSAkziqNiQf3o3wVNwyXGxpW4ng=="), + ("AES256", "GCM", "Eu6d9p+pKPFZPrd/aeshTyhU3z9JH/XvPJIc76c+uoMYpMXeFHawwjgRsMNh/ve86jze3dc3AoL8020vw8nxZ/bR6qycM88RawpmC0YGgB2WnIURirMFbqM+BaIDhVhlw+aF243NaCKCvAfqFNRClSnUkzBOcF1Yk0HhNtRudyilcVtoWpoAx6cW7nybJ/TK4ptLiaelfyz32/q4yYh0WMLdVfJyz57txWRwOBXb5lncbHJr/7G8y1ONn6OFMIXybruLvEIa3l3UP2pnWRlnWg=="), + ] + for (algorithm, mode, ciphertext) in vectors { + let key = String("0123456789abcdef0123456789abcdef".prefix(Algorithm(rawValue: algorithm)!.keyLength)) + let iv = mode == "GCM" ? "abcdefghijkl" : "abcdefghijklmnop" + let fields = CryptoSettingFields(algorithm: algorithm, mode: mode, + padding: mode == "GCM" ? "noPadding" : "pkcs7", key: key, iv: nil) + let content = UNMutableNotificationContent() + content.title = "Outer title must disappear" + content.userInfo = ["ciphertext": ciphertext, "iv": iv, "url": "https://outer.invalid"] + #expect(WatchEncryption.prepare(content) { fields }) + WatchNotification.prepare(content) + #expect(content.title == "Encrypted 🚴") + #expect(content.subtitle == "Bark compatibility") + #expect(content.body == "café\nSecond line") + #expect(content.sound != nil) + #expect(content.interruptionLevel == .timeSensitive) + #expect(content.threadIdentifier == "Private") + #expect(content.userInfo["volume"] as? String == "3") + #expect(content.userInfo["icon"] as? String == "https://example.com/icon.png") + #expect(content.userInfo["ciphertext"] == nil) + #expect(content.userInfo["url"] == nil) + var fallback = fields + fallback.iv = iv + let storedIV = UNMutableNotificationContent() + storedIV.userInfo = ["ciphertext": ciphertext] + #expect(WatchEncryption.prepare(storedIV) { fallback }) + #expect(storedIV.body == content.body) + let wrapped = String(ciphertext.prefix(40)) + "\n" + String(ciphertext.dropFirst(40)) + #expect(try WatchEncryption.decryptPayload(wrapped, fields: fallback)["title"] as? String == "Encrypted 🚴") + } +} + +@Test func watchEncryptionValidatesBytesAndSettings() throws { + let fields = CryptoSettingFields(algorithm: "AES128", mode: "CBC", padding: "pkcs7", + key: String(repeating: "é", count: 8), iv: String(repeating: "é", count: 8)) + let model = try AESCryptoModel(watchFields: fields) + let ciphertext = try model.encrypt(text: #"{"BODY":"café","volume":3}"#) + let payload = try WatchEncryption.decryptPayload(ciphertext, fields: fields) + #expect(payload["body"] as? String == "café") + #expect(payload["volume"] as? String == "3") + #expect(try JSONDecoder().decode(CryptoSettingFields.self, + from: JSONEncoder().encode(fields)) == fields) + for invalid in [ + CryptoSettingFields(algorithm: "unknown", mode: "CBC", padding: "pkcs7", key: fields.key, iv: fields.iv), + CryptoSettingFields(algorithm: "AES128", mode: "unknown", padding: "pkcs7", key: fields.key, iv: fields.iv), + CryptoSettingFields(algorithm: "AES128", mode: "CBC", padding: "unknown", key: fields.key, iv: fields.iv), + CryptoSettingFields(algorithm: "AES128", mode: "GCM", padding: "pkcs7", key: fields.key, iv: "abcdefghijkl"), + CryptoSettingFields(algorithm: "AES128", mode: "CBC", padding: "pkcs7", key: fields.key, iv: String(repeating: "é", count: 16)) + ] { + #expect(throws: WatchCryptoError.self) { try AESCryptoModel(watchFields: invalid) } + } +} + +@Test func encryptedNotificationFailuresDoNotExposeOuterPayload() throws { + let fields = CryptoSettingFields(algorithm: "AES256", mode: "GCM", padding: "noPadding", + key: "0123456789abcdef0123456789abcdef", iv: "abcdefghijkl") + let model = try AESCryptoModel(watchFields: fields) + let valid = try model.encrypt(text: #"{"body":"Private"}"#) + var tampered = Array(try #require(Data(base64Encoded: valid))) + tampered[tampered.count - 1] ^= 1 + for ciphertext in ["!not-base64!", Data(tampered).base64EncodedString(), + try model.encrypt(text: "not JSON"), try model.encrypt(text: "[]"), + try model.encrypt(text: #"{"body":"first","BODY":"second"}"#)] { + let content = UNMutableNotificationContent() + content.title = "Untrusted title" + content.subtitle = "Untrusted subtitle" + content.body = "Untrusted body" + content.userInfo = ["ciphertext": ciphertext, "icon": "https://outer.invalid/icon.png"] + #expect(!WatchEncryption.prepare(content) { fields }) + #expect(content.title == "Bark") + #expect(content.subtitle.isEmpty) + #expect(content.body == "Decryption Failed") + #expect(content.userInfo["icon"] == nil) + } + let content = UNMutableNotificationContent() + content.userInfo = ["ciphertext": valid] + #expect(!WatchEncryption.prepare(content) { nil }) + content.userInfo = ["ciphertext": valid, "iv": "short"] + #expect(!WatchEncryption.prepare(content) { fields }) + let wrong = CryptoSettingFields(algorithm: "AES256", mode: "GCM", padding: "noPadding", + key: "fedcba9876543210fedcba9876543210", iv: fields.iv) + content.userInfo = ["ciphertext": valid] + #expect(!WatchEncryption.prepare(content) { wrong }) + let plain = UNMutableNotificationContent() + plain.body = "Plain notifications still work" + #expect(WatchEncryption.prepare(plain) { throw WatchCryptoError("Must not read Keychain") }) + #expect(plain.body == "Plain notifications still work") + #expect(throws: WatchCryptoError.self) { + try AESCryptoModel(watchFields: CryptoSettingFields(algorithm: "AES128", mode: "CBC", + padding: "pkcs7", key: String(repeating: "é", count: 16), iv: "abcdefghijklmnop")) + } +} + +@MainActor +@Test func encryptedTestRequestContainsNoPlaintextAndUsesFreshVectors() throws { + var server = try Server(address: "https://bark.example") + server.key = "watch-key" + for mode in ["CBC", "ECB", "GCM"] { + let fields = CryptoSettingFields(algorithm: "AES256", mode: mode, + padding: mode == "GCM" ? "noPadding" : "pkcs7", key: "0123456789abcdef0123456789abcdef", iv: nil) + let request = try server.makeTestPushRequest(sound: "silent", encryption: fields) + let payload = try JSONDecoder().decode([String: String].self, from: #require(request.httpBody)) + #expect(payload["device_key"] == server.key) + #expect(payload["title"] == nil && payload["body"] == nil && payload["sound"] == nil) + #expect(payload["key"] == nil) + let content = UNMutableNotificationContent() + content.userInfo = payload + #expect(WatchEncryption.prepare(content) { fields }) + #expect(content.title == "Bark Watch encrypted test") + WatchNotification.prepare(content) + #expect(content.sound == nil) + let again = try WatchEncryption.encrypt(["body": "test"], fields: fields) + if mode == "ECB" { #expect(payload["iv"] == nil) } + else { #expect(payload["iv"] != again["iv"]) } + } +} + +@Test func encryptionSettingsPersistWithoutDestroyingSavedKeysOnError() throws { + let account = "test-" + UUID().uuidString + defer { try? WatchCryptoStore.save(nil, account: account) } + let fields = CryptoSettingFields(algorithm: "AES256", mode: "GCM", padding: "noPadding", + key: "0123456789abcdef0123456789abcdef", iv: nil) + #expect(try WatchCryptoStore.load(account: account) == nil) + try WatchCryptoStore.save(fields, account: account) + #expect(try WatchCryptoStore.load(account: account) == fields) + let bad = CryptoSettingFields(algorithm: "AES256", mode: "GCM", padding: "noPadding", key: "short", iv: nil) + #expect(throws: WatchCryptoError.self) { try WatchCryptoStore.save(bad, account: account) } + #expect(try WatchCryptoStore.load(account: account) == fields) + var updated = fields + updated.iv = "abcdefghijkl" + try WatchCryptoStore.save(updated, account: account) + #expect(try WatchCryptoStore.load(account: account) == updated) + try WatchCryptoStore.save(nil, account: account) + #expect(try WatchCryptoStore.load(account: account) == nil) +} + +@Test func archivePersistsFiltersReplacesAndExpires() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let archive = WatchArchive(directory: directory) + let now = Date() + let content = UNMutableNotificationContent() + content.title = "First\nTitle" + content.body = "Body line one\nBody line two" + content.userInfo = ["group": "Training", "id": "replace-me"] + let first = try #require(WatchArchivedMessage(content: content, identifier: "request-1", date: now)) + try archive.save(first) + #expect(try WatchArchive(directory: directory).latest(group: "Training")?.title == "First Title") + #expect(try archive.latest(group: "Training")?.body == content.body) + #expect(try archive.latest(group: "Other") == nil) + content.title = "Updated" + try archive.save(#require(WatchArchivedMessage(content: content, identifier: "request-2", date: now.addingTimeInterval(1)))) + #expect(try archive.messages().count == 1) + content.userInfo = ["group": "Other", "ttl": "60"] + try archive.save(#require(WatchArchivedMessage(content: content, identifier: "request-3", date: now.addingTimeInterval(2)))) + #expect(try archive.latest(group: nil)?.group == "Other") + #expect(try archive.latest(group: "Training")?.title == "Updated") + #expect(try archive.latest(group: nil, at: now.addingTimeInterval(63))?.group == "Training") + for info: [String: Any] in [["isarchive": "0"], ["isarchive": 0], ["ttl": "0"], ["ttl": "-1"], ["ttl": "nan"]] { + content.userInfo = info + #expect(WatchArchivedMessage(content: content, identifier: "skip") == nil) + } + for index in 0..<110 { + content.userInfo = ["group": "Bulk"] + try archive.save(#require(WatchArchivedMessage(content: content, identifier: "bulk-\(index)", date: now.addingTimeInterval(Double(index + 3))))) + } + #expect(try archive.messages().count == 100) + #expect(try archive.latest(group: nil)?.id == "bulk-109") +} + +@MainActor +@Test func lastMessageIsRestoredFromArchive() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let archive = WatchArchive(directory: directory) + let defaults = try #require(UserDefaults(suiteName: UUID().uuidString)) + #expect(NotificationState(servers: ServerManager(defaults: defaults), archive: archive).lastMessage == nil) + + let content = UNMutableNotificationContent() + content.title = "Older" + content.subtitle = "Subtitle" + content.userInfo = ["group": "Ops", "level": "timeSensitive", "markdown": "**Bold**", + "url": "https://day.app", "image": "file:///private/image.png"] + WatchNotification.prepare(content) + try archive.save(#require(WatchArchivedMessage(content: content, identifier: "older", date: .now))) + content.title = "Newer" + try archive.save(#require(WatchArchivedMessage(content: content, identifier: "newer", date: .now.addingTimeInterval(1)))) + + let state = NotificationState(servers: ServerManager(defaults: defaults), archive: archive) + let restored = try #require(state.lastMessage) + #expect(restored.title == "Newer") + #expect(restored.subtitle == "Subtitle") + #expect(restored.body == "Bold") + #expect(restored.markdown == "**Bold**") + #expect(restored.group == "Ops") + #expect(restored.level == .timeSensitive) + #expect(restored.url == URL(string: "https://day.app")) + #expect(restored.image == nil) +} + +@MainActor +@Test func markdownUsesPhoneParserForFormattingAndNotificationFallback() throws { + let source = """ + # Heading + **Bold** and *italic*, ~~removed~~, `code` and [link](https://example.com). + Soft break. + Hard break. + + > A quotation. + + 3. Third + 4. Fourth + - Nested + + - [x] Done + - [ ] Pending + + ```swift + let distance = 42 + + print(distance) + ``` + + ``` + Separate block + ``` + + ![Photo](https://example.com/photo.png) + """ + let text = WatchMarkdownParser.parse(source, configuration: .init(baseFont: .systemFont(ofSize: 14))) + func attributes(_ value: String) throws -> [NSAttributedString.Key: Any] { + let range = (text.string as NSString).range(of: value) + try #require(range.location != NSNotFound) + return text.attributes(at: range.location, effectiveRange: nil) + } + #expect((try attributes("Heading")[.font] as? UIFont)?.pointSize == 20) + #expect((try attributes("Bold")[.font] as? UIFont)?.fontDescriptor.symbolicTraits.contains(.traitBold) == true) + #expect((try attributes("italic")[.font] as? UIFont)?.fontDescriptor.symbolicTraits.contains(.traitItalic) == true) + #expect(try attributes("removed")[.strikethroughStyle] as? Int == NSUnderlineStyle.single.rawValue) + #expect((try attributes("code")[.font] as? UIFont)?.fontDescriptor.symbolicTraits.contains(.traitMonoSpace) == true) + #expect(try attributes("link")[.link] as? String == "https://example.com") + #expect(try attributes("[Photo]")[.link] as? String == "https://example.com/photo.png") + #expect((try attributes("A quotation")[.paragraphStyle] as? NSParagraphStyle)?.headIndent == 10) + #expect(text.string.contains("3. Third") && text.string.contains("4. Fourth")) + #expect(text.string.contains("☑ Done") && text.string.contains("☐ Pending")) + #expect(text.string.contains("Soft break.\nHard break.")) + + let paragraphs = WatchMarkdownParagraph.paragraphs(from: text) + let nested = try #require(paragraphs.first { String($0.text.characters) == "Nested" }) + #expect(nested.indent == 20 && nested.markerWidth == 20) + let code = paragraphs.filter(\.isCode) + #expect(code.count == 2) + #expect(String(code.first!.text.characters) == "let distance = 42\n\nprint(distance)") + #expect(String(code.last!.text.characters) == "Separate block") + let continued = WatchMarkdownParagraph.paragraphs(from: WatchMarkdownParser.parse("- First \n Continuation words")) + let continuation = try #require(continued.last) + #expect(String(continuation.text.characters) == "Continuation words") + #expect(continuation.marker == nil && continuation.indent == 20) + let blankCode = WatchMarkdownParagraph.paragraphs(from: WatchMarkdownParser.parse("```\n\nline\n\n```")) + #expect(blankCode.count == 1 && blankCode.first?.isCode == true) + #expect(String(blankCode[0].text.characters) == "\nline\n") + let orderedTasks = WatchMarkdownParagraph.paragraphs(from: WatchMarkdownParser.parse("1. [x] Done\n2. [ ] Pending")) + #expect(orderedTasks.map { $0.marker.map { String($0.characters) } } == ["1. ☑ ", "2. ☐ "]) + #expect(orderedTasks.map { String($0.text.characters) } == ["Done", "Pending"]) + let nestedCode = WatchMarkdownParagraph.paragraphs(from: WatchMarkdownParser.parse("- Code\n\n ```\n 1. Literal code\n ```")) + let literalCode = try #require(nestedCode.first { $0.isCode }) + #expect(literalCode.marker == nil && String(literalCode.text.characters) == "1. Literal code") + #expect(WatchMarkdownParagraph.paragraphs(from: WatchMarkdownParser.parse("`inline code`")).allSatisfy { !$0.isCode }) + let unsafe = WatchMarkdownParagraph.paragraphs(from: WatchMarkdownParser.parse("[bad](javascript:alert) [good](https://example.com)")) + let links = unsafe.flatMap { $0.text.runs.compactMap { $0.link } } + #expect(links == [URL(string: "https://example.com")!]) + #expect(WatchMarkdownParagraph.paragraphs(from: WatchMarkdownParser.parse("")).isEmpty) + #expect(!WatchMarkdownParser.parse("**unfinished").string.isEmpty) + + let content = UNMutableNotificationContent() + content.title = "Markdown test" + content.subtitle = "Phone-compatible formatting" + content.userInfo = ["markdown": source, "level": "timeSensitive", "aps": ["alert": ["body": "fallback"], "sound": "silent"]] + WatchNotification.prepare(content) + #expect(content.categoryIdentifier == WatchNotification.markdownCategoryIdentifier) + #expect(content.body == WatchNotification.markdownSummary(source)) + #expect(content.interruptionLevel == .timeSensitive && content.sound == nil) + let alert = (content.userInfo["aps"] as? [String: Any])?["alert"] as? [String: Any] + #expect(alert?["body"] as? String == content.body) + #expect(WatchMessage(content: content).markdown == source) + + let fields = CryptoSettingFields(algorithm: "AES256", mode: "GCM", padding: "noPadding", + key: "0123456789abcdef0123456789abcdef", iv: nil) + let encrypted = UNMutableNotificationContent() + encrypted.userInfo = try WatchEncryption.encrypt(["markdown": source], fields: fields) + #expect(WatchEncryption.prepare(encrypted) { fields }) + WatchNotification.prepare(encrypted) + #expect(encrypted.body == content.body) + #expect(encrypted.categoryIdentifier == WatchNotification.markdownCategoryIdentifier) + #expect(encrypted.userInfo["ciphertext"] == nil) + + let plain = UNMutableNotificationContent() + plain.body = "Plain text" + plain.categoryIdentifier = "ExistingCategory" + WatchNotification.prepare(plain) + #expect(plain.body == "Plain text" && plain.categoryIdentifier == "ExistingCategory") + + var standardHeight: CGFloat = 0 + for size in [DynamicTypeSize.large, .accessibility3] { + let renderer = ImageRenderer(content: WatchMarkdownView(source: source) + .padding(12).frame(width: 184).background(.black) + .environment(\.colorScheme, .dark).environment(\.dynamicTypeSize, size)) + renderer.scale = 2 + let image = try #require(renderer.uiImage) + #expect(image.size.width == 184 && image.size.height > 200) + if size == .large { standardHeight = image.size.height } + else { #expect(image.size.height > standardHeight) } + try #require(image.pngData()).write(to: FileManager.default.temporaryDirectory + .appendingPathComponent("markdown-\(size).png")) + } +} diff --git a/BarkWatch/View/AddServerTabView.swift b/BarkWatch/View/AddServerTabView.swift new file mode 100644 index 00000000..cfac31aa --- /dev/null +++ b/BarkWatch/View/AddServerTabView.swift @@ -0,0 +1,31 @@ +// +// AddServerTabView.swift +// BarkWatch +// +// Created by Jian Qin on 9/30/26. +// + +import SwiftUI + +/// Trailing tab for adding a server, laid out like `NotificationStatusTab`. +struct AddServerTabView: View { + let servers: ServerManager + let onAdd: (UUID) -> Void + + var body: some View { + BigButtonView( + caption: servers.servers.isEmpty ? " " : "^[\(servers.servers.count) server](inflect: true) saved", + title:"Add a Server", + buttonColor: .secondary, + message: servers.storageError.map { Text($0).font(.footnote).foregroundStyle(.red) } + ) { + NavigationLink { + AddServerView(servers: servers, onAdd: onAdd) + } label: { + Image(systemName: "plus") + } + .accessibilityLabel("Add server") + } + .containerBackground(Color.black, for: .tabView) + } +} diff --git a/BarkWatch/View/AddServerView.swift b/BarkWatch/View/AddServerView.swift new file mode 100644 index 00000000..ee433a34 --- /dev/null +++ b/BarkWatch/View/AddServerView.swift @@ -0,0 +1,60 @@ +// +// AddServerView.swift +// Bark +// +// Created by Jian Qin on 9/30/26. +// + + +import SwiftUI + +struct AddServerView: View { + let servers: ServerManager + var onAdd: (UUID) -> Void = { _ in } + @Environment(\.dismiss) private var dismiss + @State private var address = "https://" + @State private var error: String? + @State private var isAdding = false + + var body: some View { + Form { + TextField("Server address", text: $address) + .textContentType(.URL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .submitLabel(.done) + .onSubmit(addServer) + .disabled(isAdding) + if let error { + Text(error).foregroundStyle(.red) + } + Text("Use a custom server. The public Bark service cannot send to this app.") + .font(.footnote) + } + .navigationTitle("Add server") + } + + private func addServer() { + guard !isAdding else { return } + error = nil + + do { + let id = try servers.addServer(address: address) + isAdding = true + Task { + await servers.register(serverID: id) + isAdding = false + + if let registrationError = servers.errorMessages[id] { + error = registrationError + await servers.removeServer(serverID: id) + } else { + onAdd(id) + dismiss() + } + } + } catch { + self.error = error.localizedDescription + } + } +} diff --git a/BarkWatch/View/BigButtonView.swift b/BarkWatch/View/BigButtonView.swift new file mode 100644 index 00000000..632282fa --- /dev/null +++ b/BarkWatch/View/BigButtonView.swift @@ -0,0 +1,89 @@ +// +// BigButtonView.swift +// Bark +// +// Created by Jian Qin on 9/30/26. +// + + +import SwiftUI +import WatchKit + +/// A full-page layout with a caption, a large title, an optional message, and +/// an optional button that fills the remaining height. +/// +/// Pass a `Button` or `NavigationLink` with a plain label; it is styled with +/// `buttonShape` in `tint`. The caller supplies the page's container background. +struct BigButtonView: View { + let caption: LocalizedStringKey + let title: LocalizedStringKey + var buttonColor: Color = .blue + var message: Text? + @ViewBuilder var action: Action + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 2) { + Text(caption) + .font(.title3.weight(.semibold)) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.8) + Text(title) + .font(.system(.title, design: .default, weight: .heavy)) + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.6) + .fixedSize(horizontal: false, vertical: true) + } + .accessibilityElement(children: .combine) + + if let message { + message + .font(.body.weight(.semibold)) + .foregroundStyle(.white.opacity(0.9)) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding([.bottom, .horizontal], 8) + .frame(maxWidth: .infinity, alignment: .leading) + + // Push the button to the bottom of the screen when it's shorter + // than the remaining space. + Spacer(minLength: 0) + + action + .buttonStyle(BigButtonStyle(tint: buttonColor)) + .padding([.horizontal, .bottom], 8) + .ignoresSafeArea(edges: [.horizontal, .bottom]) + } + // Pin to the top so the header stays put whether or not the button + // is present to fill the remaining height. + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } +} + + +private struct BigButtonStyle: ButtonStyle { + let tint: Color + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(.title2, design: .default, weight: .black)) + .foregroundStyle(.white) + // Cap the height at 2/5 of the screen so the button doesn't dominate + // larger watches; it still shrinks when less space is available. + .frame(maxWidth: .infinity, + maxHeight: WKInterfaceDevice.current().screenBounds.height * 2 / 5) + .background(tint, in: RoundedRectangle(cornerRadius: 38, style: .continuous)) + .opacity(configuration.isPressed ? 0.7 : 1) + } +} +#Preview("With button") { + BigButtonView(caption: "Servers", title: "None yet") { + Button {} label: { + Label("Add", systemImage: "plus") + } + } +} diff --git a/BarkWatch/View/ContentView.swift b/BarkWatch/View/ContentView.swift new file mode 100644 index 00000000..7bdee937 --- /dev/null +++ b/BarkWatch/View/ContentView.swift @@ -0,0 +1,73 @@ +// +// ContentView.swift +// BarkWatch +// +// Created by Jian Qin on 9/30/26. +// + +import AuthenticationServices +import os +import SwiftUI + +struct ContentView: View { + let state: NotificationState + @State private var selection: Tab = .status + @Environment(\.webAuthenticationSession) private var webAuthenticationSession + @Environment(\.scenePhase) private var scenePhase + + private enum Tab: Hashable { + case status, lastMessage, server(UUID), add + } + + var body: some View { + NavigationStack { + TabView(selection: $selection) { + if state.needsNotificationSetup { + NotificationStatusTab(state: state) { selection = .server($0) } + .tag(Tab.status) + } + + if let message = state.lastMessage { + LastMessageView(message: message) + .tag(Tab.lastMessage) + } + + ForEach(state.servers.servers) { server in + ServerTabView(servers: state.servers, id: server.id) + .tag(Tab.server(server.id)) + } + + // The status page offers adding the first server; this page appears afterwards. + if !state.servers.servers.isEmpty { + AddServerTabView(servers: state.servers) { selection = .server($0) } + .tag(Tab.add) + } + + } + .tabViewStyle(.verticalPage) + } + // A link tapped in the notification interface launches the app; show the + // message it came from and open the link in a browser session. + // `initial` covers a cold launch, where the link is set before this view exists. + .onChange(of: state.pendingBrowserURL, initial: true) { openPendingLink() } + .onChange(of: scenePhase) { openPendingLink() } + .onChange(of: state.lastMessage == nil) { _, isEmpty in + guard isEmpty, selection == .lastMessage else { return } + selection = state.needsNotificationSetup ? .status : + state.servers.servers.first.map { .server($0.id) } ?? .add + } + } + + /// Opens a link tapped in the notification interface. Waits for the app to be + /// active: presenting the browser while the app is still launching from the + /// notification leaves it stuck and unresponsive. + private func openPendingLink() { + Logger.links.debug("Pending link: \(state.pendingBrowserURL?.absoluteString ?? "none"), scene phase: \(String(describing: scenePhase))") + guard let url = state.pendingBrowserURL, scenePhase == .active else { return } + state.pendingBrowserURL = nil + selection = .lastMessage + Task { @MainActor in + await webAuthenticationSession.browse(url) + } + } +} diff --git a/BarkWatch/View/EncryptionView.swift b/BarkWatch/View/EncryptionView.swift new file mode 100644 index 00000000..30df0357 --- /dev/null +++ b/BarkWatch/View/EncryptionView.swift @@ -0,0 +1,88 @@ +// +// EncryptionView.swift +// Bark +// +// Created by Jian Qin on 9/30/26. +// + + +import SwiftUI + +struct EncryptionView: View { + @State private var algorithm = Algorithm.aes256.rawValue + @State private var mode = "GCM" + @State private var padding = "noPadding" + @State private var key = "" + @State private var error: String? + @State private var saved: CryptoSettingFields? + + private var paddings: [String] { mode == "GCM" ? ["noPadding"] : ["noPadding", "pkcs7"] } + + var body: some View { + Form { + Picker("Algorithm", selection: $algorithm) { + ForEach(Algorithm.allCases, id: \.rawValue) { Text($0.rawValue).tag($0.rawValue) } + } + Picker("Mode", selection: $mode) { + ForEach(Algorithm(rawValue: algorithm)?.modes ?? [], id: \.self) { Text($0).tag($0) } + } + Picker("Padding", selection: $padding) { + ForEach(paddings, id: \.self) { Text($0).tag($0) } + } + Section { + TextFieldLink(prompt: Text("Key")) { + if key.isEmpty { + Text("Key").foregroundStyle(.secondary) + } else { + // Zero-width spaces let the key wrap between any two characters + // instead of being truncated or hyphenated. + Text(key.map(String.init).joined(separator: "\u{200B}")) + .frame(maxWidth: .infinity, alignment: .leading) + } + } onSubmit: { key = $0 } + Button("Generate key") { + do { + key = try WatchEncryption.randomText(length: Algorithm(rawValue: algorithm)?.keyLength ?? 32) + } catch { self.error = error.localizedDescription } + } + } footer: { + if let error { Text(error).foregroundStyle(.red) } + } + } + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .navigationTitle("Encryption") + .onChange(of: mode) { + // GCM only supports noPadding. + if !paddings.contains(padding) { padding = "noPadding" } + } + .onChange(of: [algorithm, mode, padding, key]) { save() } + .task { + do { + if let fields = try WatchCryptoStore.load() { + var loaded = fields + loaded.iv = nil + saved = loaded + algorithm = fields.algorithm + mode = fields.mode + padding = fields.padding + key = fields.key ?? "" + } + } catch { self.error = error.localizedDescription } + } + } + + /// Saves the settings whenever they change. An empty key removes the saved settings; + /// an invalid combination is reported but not saved. + private func save() { + let fields = key.isEmpty ? nil : CryptoSettingFields(algorithm: algorithm, mode: mode, + padding: padding, key: key, iv: nil) + // Skip the change events caused by loading the saved settings. + guard fields != saved else { return } + error = nil + do { + try WatchCryptoStore.save(fields) + saved = fields + } catch { self.error = error.localizedDescription } + } +} diff --git a/BarkWatch/View/LastMessageView.swift b/BarkWatch/View/LastMessageView.swift new file mode 100644 index 00000000..aa762889 --- /dev/null +++ b/BarkWatch/View/LastMessageView.swift @@ -0,0 +1,168 @@ +// +// LastMessageView.swift +// Bark +// +// Created by Jian Qin on 9/30/26. +// + +import AuthenticationServices +import os +import SwiftUI +import UserNotifications +import WatchKit + +/// Full-page card for the last received or opened message, laid out like +/// `BigButtonView`: a caption, a large title, the message, and an optional +/// rounded button for the link. Scrolls because message bodies can be long. +struct LastMessageView: View { + let message: WatchMessage + + /// Tint follows the interruption level, like the status card follows its state. + private var tint: Color { + switch message.level { + case .critical: .red + case .timeSensitive: .orange + case .active, .passive: .secondary + } + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 8) { + header + content + details + if let image = message.image { + AsyncImage(url: image) { image in + image.resizable().scaledToFit() + } placeholder: { + ProgressView() + .frame(maxWidth: .infinity, minHeight: 60) + } + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .accessibilityLabel("Image") + } + } + .padding([.bottom, .horizontal], 8) + .frame(maxWidth: .infinity, alignment: .leading) + + if let url = message.url { + Link(destination: url) { + Text("Open Link") + } + .buttonStyle(LinkButtonStyle(tint: tint)) + .padding([.horizontal, .bottom], 8) + } + } + } + .containerBackground(tint.gradient, for: .tabView) + .modifier(WebBrowserSessionLinks()) + } + + private var header: some View { + VStack(alignment: .leading, spacing: 2) { + Text("Latest Notification") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) + .minimumScaleFactor(0.8) + Text(message.title) + .font(.system(.title3, design: .default, weight: .heavy)) + .foregroundStyle(.white) + .lineLimit(2) + .minimumScaleFactor(0.6) + .fixedSize(horizontal: false, vertical: true) + if !message.subtitle.isEmpty { + Text(message.subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + .accessibilityElement(children: .combine) + } + + @ViewBuilder + private var content: some View { + if let markdown = message.markdown, !markdown.isEmpty { + WatchMarkdownView(source: markdown) + } else { + Text(message.body) + .font(.body) + .foregroundStyle(.white.opacity(message.body.isEmpty ? 0.6 : 0.9)) + .fixedSize(horizontal: false, vertical: true) + } + } + + private var details: some View { + VStack(alignment: .leading, spacing: 2) { + if !message.group.isEmpty { + Label(message.group, systemImage: "folder") + } + Label(message.level.rawValue, systemImage: "bell") + } + .font(.footnote) + .foregroundStyle(.secondary) + } +} + +extension WebAuthenticationSession { + /// watchOS can't open web pages through `Link` or `openURL` ("URL failed to load"). + /// A web authentication session is the only system browser available to apps. + /// The callback scheme never fires; the person closes the page with Cancel, + /// which ends the session with an error that is ignored. + func browse(_ url: URL) async { + Logger.links.debug("Opening browser session for \(url)") + do { + // Ephemeral: don't share cookies with other sessions. + _ = try await authenticate( + using: url, + callbackURLScheme: "bark-watch-link", + preferredBrowserSession: .ephemeral + ) + } catch { + // Cancel is the normal way to close the page; anything else is worth seeing. + Logger.links.debug("Browser session ended: \(error)") + } + } +} + +/// Opens web links in a browser session; other schemes are discarded. +private struct WebBrowserSessionLinks: ViewModifier { + @Environment(\.webAuthenticationSession) private var webAuthenticationSession + + func body(content: Content) -> some View { + content.environment(\.openURL, OpenURLAction { url in + guard WatchNotification.validatedWebURL(url.absoluteString) != nil else { return .discarded } + Task { await webAuthenticationSession.browse(url) } + return .handled + }) + } +} + +private struct LinkButtonStyle: ButtonStyle { + let tint: Color + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(.title3, design: .default, weight: .black)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity, minHeight: 60) + .background(tint, in: RoundedRectangle(cornerRadius: 38, style: .continuous)) + .opacity(configuration.isPressed ? 0.7 : 1) + } +} + +#Preview { + let content = UNMutableNotificationContent() + content.title = "Server Alert" + content.subtitle = "Production" + content.body = "CPU usage exceeded 90% on web-01." + content.threadIdentifier = "Monitoring" + content.interruptionLevel = .critical + + return TabView { + LastMessageView(message: WatchMessage(content: content)) + } + .tabViewStyle(.verticalPage) +} diff --git a/BarkWatch/View/NotificationMarkdownView.swift b/BarkWatch/View/NotificationMarkdownView.swift new file mode 100644 index 00000000..28c12eae --- /dev/null +++ b/BarkWatch/View/NotificationMarkdownView.swift @@ -0,0 +1,87 @@ +// +// NotificationMarkdownView.swift +// Bark +// +// Created by Jian Qin on 9/30/26. +// + +import os +import SwiftUI +import UserNotifications +import WatchKit + +final class MarkdownNotificationController: WKUserNotificationHostingController { + private var content: UNNotificationContent? + + override var body: MarkdownNotificationView { + MarkdownNotificationView(content: content) { [weak self] url in self?.open(url) } + } + override class var isInteractive: Bool { true } + + /// Hand the link to the app, then launch it; the app opens the link in a browser session. + private func open(_ url: URL) { + let delegate = NotificationDelegate.current + delegate?.state.requestedNotificationURL = url + Logger.links.debug("Notification requested \(url), delegate found: \(delegate != nil)") + performNotificationDefaultAction() + } + + override func didReceive(_ notification: UNNotification) { + content = notification.request.content + } +} + +struct MarkdownNotificationView: View { + let content: UNNotificationContent? + var openLink: (URL) -> Void = { _ in } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + if let content { + if !content.title.isEmpty { Text(content.title).font(.headline) } + if !content.subtitle.isEmpty { Text(content.subtitle).font(.subheadline) } + if let source = WatchNotification.stringValue("markdown", in: content.userInfo), + !source.isEmpty, content.userInfo["ciphertext"] == nil { + WatchMarkdownView(source: source, openLink: openLink) + } else { + Text(content.body) + } + if let url = WatchNotification.validatedWebURL(WatchNotification.stringValue("image", in: content.userInfo)) { + AsyncImage(url: url) { image in + image.resizable().scaledToFit() + } placeholder: { + ProgressView() + } + } + if let url = WatchNotification.validatedWebURL(WatchNotification.stringValue("url", in: content.userInfo)) { + Button("Open link") { openLink(url) } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +#Preview("Markdown notification") { + let content = UNMutableNotificationContent() + content.title = "Markdown on watch" + content.userInfo = ["markdown": """ + # Training + **Strong**, *emphasis*, ~~removed~~ and `code`. + + > Steady effort. + + 3. Warm up + 4. Main set + - Recover + + ```swift + let distance = 42 + ``` + + [Bark](https://bark.day.app) + """] + return ScrollView { + MarkdownNotificationView(content: content).padding() + } +} diff --git a/BarkWatch/View/NotificationStatusTab.swift b/BarkWatch/View/NotificationStatusTab.swift new file mode 100644 index 00000000..e77f8a9c --- /dev/null +++ b/BarkWatch/View/NotificationStatusTab.swift @@ -0,0 +1,135 @@ +// +// NotificationStatusTab.swift +// Bark +// +// Created by Jian Qin on 9/30/26. +// + + +import SwiftUI + +struct NotificationStatusTab: View { + let state: NotificationState + var onAddServer: (UUID) -> Void = { _ in } + @State private var isSyncing = false + + private var isSyncDisabled: Bool { + if case .registering = state.registrationState { return true } + return isSyncing + } + + var status: (title: LocalizedStringKey, tint: Color) { + switch state.authorizationStatus { + case nil: + return ("Checking…", .secondary) + case .notDetermined: + return ("Not enabled", .orange) + case .denied: + return ("Disabled", .red) + case .authorized, .provisional: + switch state.registrationState { + case .idle: + return ("Not registered", .orange) + case .registering: + return ("Registering…", .green) + case .registered: + return state.authorizationStatus == .provisional + ? ("Delivered quietly", .green) + : ("Enabled", .green) + case .failed: + return ("Setup needed", .orange) + } + default: + return ("Unavailable", .secondary) + } + } + + var body: some View { + BigButtonView( + caption: "Notifications", + title: status.title, + // Once notifications work, the only remaining step is adding a server. + buttonColor: status.tint == .orange ? .orange : .secondary, + message: state.authorizationStatus == .denied + ? Text("On your iPhone, open Watch → My Watch → Notifications → Bark, then select Allow Notifications.") + : nil + ) { + if state.authorizationStatus == .notDetermined { + Button("Enable") { + Task { await state.enableNotifications() } + } + .disabled(state.isRequestingPermission) + } else if state.isPushRegistrationReady { + NavigationLink { + AddServerView(servers: state.servers, onAdd: onAddServer) + } label: { + Image(systemName: "plus") + } + } else if state.hasNotificationPermission { + Button { + isSyncing = true + Task { + defer { isSyncing = false } + await state.refreshAndRegister() + } + } label: { + Image(systemName: "arrow.triangle.2.circlepath") + } + .disabled(isSyncDisabled) + } + } + .containerBackground(status.tint.opacity(0.5).gradient, for: .tabView) + } +} + + +#Preview("Checking") { + ContentView(state: NotificationState()) +} + +#Preview("Permission needed") { + let state = NotificationState() + state.authorizationStatus = .notDetermined + return ContentView(state: state) +} + +#Preview("Disabled") { + let state = NotificationState() + state.authorizationStatus = .denied + return ContentView(state: state) +} + +#Preview("Not registered") { + let state = NotificationState() + state.authorizationStatus = .authorized + state.registrationState = .idle + return ContentView(state: state) +} + +#Preview("Registering") { + let state = NotificationState() + state.authorizationStatus = .authorized + state.registrationState = .registering + return ContentView(state: state) +} + +#Preview("Enabled") { + let state = NotificationState() + state.authorizationStatus = .authorized + state.registrationState = .registered("preview-token") + return ContentView(state: state) +} + +#Preview("Delivered quietly") { + let state = NotificationState() + state.authorizationStatus = .provisional + state.registrationState = .registered("preview-token") + return ContentView(state: state) +} + +#Preview("Setup needed") { + let state = NotificationState() + state.authorizationStatus = .authorized + state.registrationState = .failed("Preview error") + return ContentView(state: state) +} diff --git a/BarkWatch/View/ServerTabView.swift b/BarkWatch/View/ServerTabView.swift new file mode 100644 index 00000000..94008e90 --- /dev/null +++ b/BarkWatch/View/ServerTabView.swift @@ -0,0 +1,59 @@ +// +// ServerPageView.swift +// Bark +// +// Created by Jian Qin on 9/30/26. +// + + +import SwiftUI + +/// One tab per saved server: address, key, encryption, and test delivery. +struct ServerTabView: View { + let servers: ServerManager + let id: UUID + @State private var confirmsRemoval = false + + private var server: Server? { servers.servers.first { $0.id == id } } + private var isBusy: Bool { servers.busyServerIDs.contains(id) } + + var body: some View { + List { + if let server { + if let error = servers.errorMessages[id] { + Text(error).font(.footnote).foregroundStyle(.red) + } + Section("Address") { + if server.key.isEmpty { + Text(server.address.absoluteString).font(.caption) + .lineLimit(1) + Text("Registration needed").foregroundStyle(.secondary) + } else { + // Zero-width spaces let the URL wrap anywhere instead of being hyphenated. + Text((server.addressAndKey.absoluteString + "/").map(String.init).joined(separator: "\u{200B}")) + .font(.caption.monospaced()) + Button("Send Test") { + Task { await servers.sendTestPush(serverID: id) } + } + .disabled(isBusy) + } + } + NavigationLink { + EncryptionView() + } label: { + Label("Encryption", systemImage: "key.fill") + } + Button("Remove server", role: .destructive) { + confirmsRemoval = true + } + .confirmationDialog("Remove this server from the watch?", isPresented: $confirmsRemoval) { + Button("Remove server", role: .destructive) { + Task { await servers.removeServer(serverID: id) } + } + } + } + } + .navigationTitle(server?.address.host() ?? "Server") + .containerBackground(Color.black, for: .tabView) + } +} diff --git a/BarkWatch/View/WatchMarkdownView.swift b/BarkWatch/View/WatchMarkdownView.swift new file mode 100644 index 00000000..94cfcb0b --- /dev/null +++ b/BarkWatch/View/WatchMarkdownView.swift @@ -0,0 +1,135 @@ +// +// WatchMarkdownView.swift +// Bark +// +// Created by Jian Qin on 9/30/26. +// + + +import SwiftUI +import UIKit + +// Render the phone parser's attributed text, retaining paragraph indentation that +// SwiftUI Text does not carry over from NSParagraphStyle. Used by both the app and +// the notification interface. +struct WatchMarkdownView: View { + let source: String + /// Handles tapped web links. When nil, links go to the environment's `openURL`, + /// which the app routes to a browser session. The notification interface can't + /// open a browser, so it passes its own handler. + var openLink: ((URL) -> Void)? + @ScaledMetric(relativeTo: .body) private var fontSize = 14 + @Environment(\.openURL) private var openURL + + var body: some View { + let text = WatchMarkdownParser.parse(source, configuration: .init(baseFont: .systemFont(ofSize: fontSize))) + VStack(alignment: .leading, spacing: 0) { + ForEach(WatchMarkdownParagraph.paragraphs(from: text)) { paragraph in + HStack(alignment: .firstTextBaseline, spacing: 0) { + if let marker = paragraph.marker { + Text(marker) + .fixedSize(horizontal: true, vertical: false) + .frame(minWidth: paragraph.markerWidth, alignment: .leading) + } + Text(paragraph.text) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(paragraph.isCode ? 6 : 0) + .background(paragraph.isCode ? Color.white.opacity(0.12) : .clear, + in: RoundedRectangle(cornerRadius: 6)) + .padding(.leading, paragraph.indent) + .padding(.top, paragraph.spaceBefore) + .accessibilityElement(children: .combine) + } + } + .tint(.cyan) + // Only web links are allowed; anything else is discarded. + .environment(\.openURL, OpenURLAction { [openURL, openLink] url in + guard WatchNotification.validatedWebURL(url.absoluteString) != nil else { return .discarded } + if let openLink { openLink(url) } else { openURL(url) } + return .handled + }) + } +} + +struct WatchMarkdownParagraph: Identifiable { + let id: Int + var text: AttributedString + let marker: AttributedString? + let markerWidth: CGFloat + let indent: CGFloat + let spaceBefore: CGFloat + let codeBlock: Int? + var isCode: Bool { codeBlock != nil } + + static func paragraphs(from source: NSAttributedString) -> [Self] { + let safeText = NSMutableAttributedString(attributedString: source) + source.enumerateAttribute(.link, in: NSRange(location: 0, length: source.length)) { value, range, _ in + guard let value else { return } + let address = (value as? URL)?.absoluteString ?? value as? String + if let url = WatchNotification.validatedWebURL(address) { + safeText.addAttribute(.link, value: url, range: range) + } else { + safeText.removeAttribute(.link, range: range) + } + } + var result: [Self] = [] + var offset = 0 + var blankSpace: CGFloat = 0 + let string = safeText.string as NSString + while offset < safeText.length { + var blockRange = NSRange() + let codeBlock = safeText.attribute(.markdownCodeBlock, at: offset, effectiveRange: &blockRange) as? Int + let newline = string.range(of: "\n", range: NSRange(location: offset, length: safeText.length - offset)) + var range = codeBlock != nil ? blockRange : NSRange(location: offset, + length: (newline.location == NSNotFound ? safeText.length : newline.location) - offset) + offset = NSMaxRange(range) + (codeBlock == nil && newline.location != NSNotFound ? 1 : 0) + if codeBlock != nil, string.substring(with: range).hasSuffix("\n") { range.length -= 1 } + if range.length == 0 { + if !result.isEmpty { blankSpace += 10 } + continue + } + let slice = safeText.attributedSubstring(from: range) + let style = slice.attribute(.paragraphStyle, at: 0, effectiveRange: nil) as? NSParagraphStyle + var markerRange = NSRange() + let hasMarker = slice.attribute(.markdownListMarker, at: 0, effectiveRange: &markerRange) as? Bool == true + let markerLength = hasMarker ? markerRange.length : 0 + let indent = hasMarker ? style?.firstLineHeadIndent ?? 0 : style?.headIndent ?? 0 + let markerWidth = hasMarker ? max(0, (style?.headIndent ?? 0) - indent) : 0 + let marker = hasMarker ? AttributedString(slice.attributedSubstring(from: markerRange)) : nil + let text = AttributedString(slice.attributedSubstring(from: + NSRange(location: markerLength, length: slice.length - markerLength))) + result.append(Self(id: range.location, text: text, marker: marker, markerWidth: markerWidth, + indent: indent, spaceBefore: blankSpace + (style?.paragraphSpacingBefore ?? 0), + codeBlock: codeBlock)) + blankSpace = 0 + } + return result + } +} + +#Preview("Phone Markdown on watch") { + ScrollView { + WatchMarkdownView(source: """ + # Training + **Strong**, *emphasis*, ~~removed~~ and `code`. + + > A quoted paragraph. + + 3. First item + 4. Second item + - Nested item + + - [x] Completed + - [ ] Pending + + ```swift + let distance = 42 + print(distance) + ``` + + [Bark](https://bark.day.app) and ![Photo](https://example.com/photo.png) + """).padding() + } +} diff --git a/Podfile b/Podfile index e732289a..9c7b3337 100644 --- a/Podfile +++ b/Podfile @@ -53,6 +53,20 @@ target 'NotificationContentExtension' do pod 'Kingfisher' end +target 'BarkWatch' do + platform :watchos, '10.0' + pod 'CryptoSwift' + + target 'BarkWatchTests' do + inherit! :search_paths + end +end + +target 'BarkWatchNotificationService' do + platform :watchos, '10.0' + pod 'CryptoSwift' +end + post_install do |installer| installer.pods_project.targets.each do |target| @@ -60,6 +74,9 @@ post_install do |installer| if config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'].to_f < 15.0 config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0' end + if config.build_settings['WATCHOS_DEPLOYMENT_TARGET'] && config.build_settings['WATCHOS_DEPLOYMENT_TARGET'].to_f < 10.0 + config.build_settings['WATCHOS_DEPLOYMENT_TARGET'] = '10.0' + end config.build_settings['CODE_SIGNING_ALLOWED'] = 'NO' end end diff --git a/Podfile.lock b/Podfile.lock index bd35a40c..76a9a895 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -169,6 +169,6 @@ SPEC CHECKSUMS: SwiftyJSON: f5b1bf1cd8dd53cd25887ac0eabcfd92301c6a5a SwiftyStoreKit: 6b9c08810269f030586dac1fae8e75871a82e84a -PODFILE CHECKSUM: 2079c3d2b70039c644c100185cbcbcefd1520d12 +PODFILE CHECKSUM: 1e9a646c5ef7ec48fb54351c54aa8e859d3111ec -COCOAPODS: 1.16.2 +COCOAPODS: 1.17.0 diff --git a/docs/_media/BarkWatch-LastMessageView.png b/docs/_media/BarkWatch-LastMessageView.png new file mode 100644 index 00000000..7e580c73 Binary files /dev/null and b/docs/_media/BarkWatch-LastMessageView.png differ diff --git a/fastlane/Matchfile b/fastlane/Matchfile index 841583fd..034eabc1 100644 --- a/fastlane/Matchfile +++ b/fastlane/Matchfile @@ -4,7 +4,14 @@ storage_mode("git") type("appstore") # The default type, can be: appstore, adhoc, enterprise or development -app_identifier(["me.fin.bark", "me.fin.bark.NotificationContent", "me.fin.bark.NotificationServiceExtension", "me.fin.bark.Widget"]) +app_identifier([ + "me.fin.bark", + "me.fin.bark.NotificationContent", + "me.fin.bark.NotificationServiceExtension", + "me.fin.bark.Widget", + "me.fin.BarkWatch", + "me.fin.BarkWatch.NotificationService" +]) # username("user@fastlane.tools") # Your Apple Developer Portal username # For all available options run `fastlane match --help`