From 80c8c0fc13804c77eedcc1c3735a482671f02661 Mon Sep 17 00:00:00 2001 From: sebersta Date: Wed, 30 Sep 2026 20:56:05 -0700 Subject: [PATCH 1/9] new watchOS prototype --- .gitignore | 3 + Bark.xcodeproj/project.pbxproj | 1490 +++++++++++------ .../xcshareddata/swiftpm/Package.resolved | 33 + .../xcshareddata/xcschemes/BarkWatch.xcscheme | 86 + .../BarkWatchComplicationExtension.xcscheme | 117 ++ .../BarkWatchNotificationService.xcscheme | 92 + .../xcshareddata/swiftpm/Package.resolved | 15 +- BarkWatch/App/BarkWatchApp.swift | 23 + BarkWatch/App/WatchMessage.swift | 262 +++ .../AppIcon.appiconset/Contents.json | 14 + .../AppIcon.appiconset/bark.png | Bin 0 -> 17404 bytes BarkWatch/Assets.xcassets/Contents.json | 6 + BarkWatch/BarkWatch.entitlements | 20 + .../BarkWatchComplication/AppIntent.swift | 31 + .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 13 + .../Assets.xcassets/Contents.json | 6 + .../WidgetBackground.colorset/Contents.json | 11 + .../BarkWatchComplication.swift | 50 + .../BarkWatchComplicationBundle.swift | 16 + .../ComplicationView.swift | 60 + BarkWatch/BarkWatchComplication/Info.plist | 11 + ...arkWatchComplicationExtension.entitlements | 10 + BarkWatch/BarkWatchCritical.entitlements | 16 + BarkWatch/Common/PhoneCompatibility.swift | 33 + BarkWatch/Common/WatchArchive.swift | 134 ++ BarkWatch/Common/WatchEncryption.swift | 185 ++ BarkWatch/Common/WatchMarkdownParser.swift | 58 + BarkWatch/Common/WatchNotification.swift | 99 ++ BarkWatch/Info.plist | 21 + BarkWatch/MarkdownNotification.apns | 13 + BarkWatch/Model/BarkServer.swift | 218 +++ BarkWatch/NotificationService/Info.plist | 24 + .../NotificationService.entitlements | 14 + BarkWatch/README.md | 29 + BarkWatch/ServerSetup.md | 75 + BarkWatch/TestNotification.apns | 10 + BarkWatch/Tests/NotificationTests.swift | 685 ++++++++ BarkWatch/View/AddServerTabView.swift | 31 + BarkWatch/View/AddServerView.swift | 60 + BarkWatch/View/BigButtonView.swift | 111 ++ BarkWatch/View/ContentView.swift | 102 ++ BarkWatch/View/EncryptionView.swift | 88 + BarkWatch/View/LastMessageView.swift | 165 ++ BarkWatch/View/NotificationMarkdownView.swift | 131 ++ BarkWatch/View/NotificationStatusLabel.swift | 135 ++ BarkWatch/View/ServerTabView.swift | 59 + BarkWatch/View/WatchMarkdownView.swift | 131 ++ BarkWatch/WatchNotificationService.swift | 159 ++ BarkWatch/scripts/build-watch-server.sh | 28 + BarkWatch/scripts/send-watch-tests.py | 130 ++ BarkWatch/scripts/test_send_watch_tests.py | 99 ++ .../scripts/watch-notification-tests.json | 362 ++++ BarkWatch/scripts/watch-server.patch | 177 ++ docs/_media/BarkWatch-LastMessageView.png | Bin 0 -> 74603 bytes 55 files changed, 5416 insertions(+), 546 deletions(-) create mode 100644 Bark.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 Bark.xcodeproj/xcshareddata/xcschemes/BarkWatch.xcscheme create mode 100644 Bark.xcodeproj/xcshareddata/xcschemes/BarkWatchComplicationExtension.xcscheme create mode 100644 Bark.xcodeproj/xcshareddata/xcschemes/BarkWatchNotificationService.xcscheme create mode 100644 BarkWatch/App/BarkWatchApp.swift create mode 100644 BarkWatch/App/WatchMessage.swift create mode 100644 BarkWatch/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 BarkWatch/Assets.xcassets/AppIcon.appiconset/bark.png create mode 100644 BarkWatch/Assets.xcassets/Contents.json create mode 100644 BarkWatch/BarkWatch.entitlements create mode 100644 BarkWatch/BarkWatchComplication/AppIntent.swift create mode 100644 BarkWatch/BarkWatchComplication/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 BarkWatch/BarkWatchComplication/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 BarkWatch/BarkWatchComplication/Assets.xcassets/Contents.json create mode 100644 BarkWatch/BarkWatchComplication/Assets.xcassets/WidgetBackground.colorset/Contents.json create mode 100644 BarkWatch/BarkWatchComplication/BarkWatchComplication.swift create mode 100644 BarkWatch/BarkWatchComplication/BarkWatchComplicationBundle.swift create mode 100644 BarkWatch/BarkWatchComplication/ComplicationView.swift create mode 100644 BarkWatch/BarkWatchComplication/Info.plist create mode 100644 BarkWatch/BarkWatchComplicationExtension.entitlements create mode 100644 BarkWatch/BarkWatchCritical.entitlements create mode 100644 BarkWatch/Common/PhoneCompatibility.swift create mode 100644 BarkWatch/Common/WatchArchive.swift create mode 100644 BarkWatch/Common/WatchEncryption.swift create mode 100644 BarkWatch/Common/WatchMarkdownParser.swift create mode 100644 BarkWatch/Common/WatchNotification.swift create mode 100644 BarkWatch/Info.plist create mode 100644 BarkWatch/MarkdownNotification.apns create mode 100644 BarkWatch/Model/BarkServer.swift create mode 100644 BarkWatch/NotificationService/Info.plist create mode 100644 BarkWatch/NotificationService/NotificationService.entitlements create mode 100644 BarkWatch/README.md create mode 100644 BarkWatch/ServerSetup.md create mode 100644 BarkWatch/TestNotification.apns create mode 100644 BarkWatch/Tests/NotificationTests.swift create mode 100644 BarkWatch/View/AddServerTabView.swift create mode 100644 BarkWatch/View/AddServerView.swift create mode 100644 BarkWatch/View/BigButtonView.swift create mode 100644 BarkWatch/View/ContentView.swift create mode 100644 BarkWatch/View/EncryptionView.swift create mode 100644 BarkWatch/View/LastMessageView.swift create mode 100644 BarkWatch/View/NotificationMarkdownView.swift create mode 100644 BarkWatch/View/NotificationStatusLabel.swift create mode 100644 BarkWatch/View/ServerTabView.swift create mode 100644 BarkWatch/View/WatchMarkdownView.swift create mode 100644 BarkWatch/WatchNotificationService.swift create mode 100755 BarkWatch/scripts/build-watch-server.sh create mode 100755 BarkWatch/scripts/send-watch-tests.py create mode 100644 BarkWatch/scripts/test_send_watch_tests.py create mode 100644 BarkWatch/scripts/watch-notification-tests.json create mode 100644 BarkWatch/scripts/watch-server.patch create mode 100644 docs/_media/BarkWatch-LastMessageView.png diff --git a/.gitignore b/.gitignore index e8f44b64..e653fb71 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ Pods/ ._.* xcuserdata buildServer.json +*.p8 +BarkWatchServer/ +.codex-backups/ diff --git a/Bark.xcodeproj/project.pbxproj b/Bark.xcodeproj/project.pbxproj index d03435d0..c40db5c7 100644 --- a/Bark.xcodeproj/project.pbxproj +++ b/Bark.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 54; + objectVersion = 71; objects = { /* Begin PBXBuildFile section */ @@ -58,6 +58,7 @@ 0632052B250B6DD4001561EC /* bell.caf in Resources */ = {isa = PBXBuildFile; fileRef = 0632050C250B6DD4001561EC /* bell.caf */; }; 0632052C250B6DD4001561EC /* horn.caf in Resources */ = {isa = PBXBuildFile; fileRef = 0632050D250B6DD4001561EC /* horn.caf */; }; 0632052D250B6DD4001561EC /* newsflash.caf in Resources */ = {isa = PBXBuildFile; fileRef = 0632050E250B6DD4001561EC /* newsflash.caf */; }; + 000000000000000000000029 /* NotificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000000000000000000000028 /* NotificationTests.swift */; }; 0632CE1F20EC9098003FDF46 /* UserNotifications.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0683487020510FB20024B6DA /* UserNotifications.framework */; }; 0632CE2020EC9098003FDF46 /* UserNotificationsUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0683487220510FB20024B6DA /* UserNotificationsUI.framework */; }; 0632CE2320EC9098003FDF46 /* NotificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0632CE2220EC9098003FDF46 /* NotificationViewController.swift */; }; @@ -105,36 +106,9 @@ 06697FDB2F70ED9400C4680E /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 06697FDA2F70ED9400C4680E /* WidgetKit.framework */; }; 06697FDD2F70ED9400C4680E /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 06697FDC2F70ED9400C4680E /* SwiftUI.framework */; }; 06697FEE2F70ED9600C4680E /* WidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 06697FD92F70ED9400C4680E /* WidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - 06697FF02F70ED9600C4680E /* WidgetHistorySnapshotStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06697FED2F70ED9600C4680E /* WidgetHistorySnapshotStore.swift */; }; - 06697FF42F70ED9600C4680E /* WidgetHistorySnapshotSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06697FF52F70ED9600C4680E /* WidgetHistorySnapshotSupport.swift */; }; 06697FF82F71ED9600C4680E /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 06697FDA2F70ED9400C4680E /* WidgetKit.framework */; }; - 06697FF92F71ED9600C4680E /* WidgetHistorySnapshotStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06697FED2F70ED9600C4680E /* WidgetHistorySnapshotStore.swift */; }; - 066B10012F73F00100C4680E /* WidgetHistoryModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 066B10002F73F00100C4680E /* WidgetHistoryModels.swift */; }; - 066B10022F73F00200C4680E /* WidgetHistoryModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 066B10002F73F00100C4680E /* WidgetHistoryModels.swift */; }; - 066B10032F73F00300C4680E /* WidgetHistoryModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 066B10002F73F00100C4680E /* WidgetHistoryModels.swift */; }; - 066DF4822D2D31A60092B04E /* MessageDeleteTimeRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 066DF4812D2D31A30092B04E /* MessageDeleteTimeRange.swift */; }; - 066E0C8C2BB6AC9A00873838 /* AddSoundCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 066E0C8B2BB6AC9A00873838 /* AddSoundCell.swift */; }; - 0672CB06256903F700570C9D /* MessageListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0672CB05256903F700570C9D /* MessageListViewModel.swift */; }; - 06787C392A710568008ABDD7 /* GesturePassTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06787C382A710568008ABDD7 /* GesturePassTextView.swift */; }; 067AFB1C2E5D8BE300AE78E7 /* UNNotificationContent+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 067AFB1B2E5D8BE300AE78E7 /* UNNotificationContent+Extension.swift */; }; 067AFB1D2E5D8BED00AE78E7 /* UNNotificationContent+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 067AFB1B2E5D8BE300AE78E7 /* UNNotificationContent+Extension.swift */; }; - 067B2EB525693E38008B6BE1 /* MessageSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 067B2EB425693E38008B6BE1 /* MessageSection.swift */; }; - 06802E5320ECC40C00767047 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0661A549204FDA4100965E4E /* Assets.xcassets */; }; - 06840DBB272298FB001B3193 /* BKColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06840DBA272298FB001B3193 /* BKColor.swift */; }; - 0687F2A82CCB791A00B2A52F /* UIFont+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0687F2A72CCB791A00B2A52F /* UIFont+Extension.swift */; }; - 0687F2A92CCB791A00B2A52F /* UIFont+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0687F2A72CCB791A00B2A52F /* UIFont+Extension.swift */; }; - 0687F2AA2CCB7FA500B2A52F /* UIFont+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0687F2A72CCB791A00B2A52F /* UIFont+Extension.swift */; }; - 06885EB6247FB9880004A303 /* MessageSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06885EB5247FB9880004A303 /* MessageSettingsViewController.swift */; }; - 0689CF4C2C7484A7007203A6 /* BarkTabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0689CF4B2C7484A7007203A6 /* BarkTabBarController.swift */; }; - 068A4B962D2E11CD00982449 /* MessageDeleteTimeRangeTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 068A4B952D2E11CD00982449 /* MessageDeleteTimeRangeTest.swift */; }; - 068EC15827ED99C900D5D11E /* ServerListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 068EC15727ED99C900D5D11E /* ServerListViewController.swift */; }; - 068EC15A27ED99E700D5D11E /* ServerListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 068EC15927ED99E700D5D11E /* ServerListViewModel.swift */; }; - 068F66B3247BD84C00DAD25A /* MessageListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 068F66B2247BD84C00DAD25A /* MessageListViewController.swift */; }; - 069332222E6A8E3100F9387F /* String+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0603706A20E20A7C00F4CA05 /* String+Extension.swift */; }; - 069332232E6A8E3100F9387F /* String+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0603706A20E20A7C00F4CA05 /* String+Extension.swift */; }; - 0699473D2D223094008D5E40 /* CustomTapTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0699473C2D223094008D5E40 /* CustomTapTextView.swift */; }; - 069C6CA42ED03C72007244BB /* MarkdownProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 069C6CA32ED03C72007244BB /* MarkdownProcessor.swift */; }; - 069C6CA52ED03E10007244BB /* MarkdownParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06C777CF2ECEFF210032A044 /* MarkdownParser.swift */; }; 069C6CA72ED03E27007244BB /* Markdown in Frameworks */ = {isa = PBXBuildFile; productRef = 069C6CA62ED03E27007244BB /* Markdown */; }; 069C6CA92ED03F07007244BB /* BKColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06840DBA272298FB001B3193 /* BKColor.swift */; }; 06A100022FEE0001000000A1 /* GlassView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06A100012FEE0001000000A1 /* GlassView.swift */; }; @@ -146,107 +120,35 @@ 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 */; }; 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 */; }; - 06BBB88725650C6C0076F63E /* ArchiveSettingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06C5953224811505006B98F3 /* ArchiveSettingManager.swift */; }; - 06BBB89125650CCF0076F63E /* ArchiveSettingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06C5953224811505006B98F3 /* ArchiveSettingManager.swift */; }; - 06BBB896256518760076F63E /* NewServerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06BBB895256518760076F63E /* NewServerViewModel.swift */; }; - 06BBB8B72567AC140076F63E /* MessageSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06BBB8B62567AC140076F63E /* MessageSettingsViewModel.swift */; }; - 06BBB8BC2567B3AD0076F63E /* ArchiveSettingCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06BBB8BB2567B3AD0076F63E /* ArchiveSettingCellViewModel.swift */; }; - 06BBB8C12567B3EF0076F63E /* BaseTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06BBB8C02567B3EF0076F63E /* BaseTableViewCell.swift */; }; - 06BBB8C92567B6730076F63E /* Operators.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06BBB8C82567B6730076F63E /* Operators.swift */; }; - 06BBB8CE2567B8E60076F63E /* silence.caf in Resources */ = {isa = PBXBuildFile; fileRef = 06BBB8CD2567B8E60076F63E /* silence.caf */; }; - 06BCAE562CDB19260092867A /* GroupMuteSettingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06BCAE552CDB19260092867A /* GroupMuteSettingManager.swift */; }; - 06BCAE572CDB19420092867A /* GroupMuteSettingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06BCAE552CDB19260092867A /* GroupMuteSettingManager.swift */; }; - 06BCAE582CDB19420092867A /* GroupMuteSettingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06BCAE552CDB19260092867A /* GroupMuteSettingManager.swift */; }; - 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 */; }; - 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 */; }; - 06C5953124811392006B98F3 /* ArchiveSettingCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06C5953024811392006B98F3 /* ArchiveSettingCell.swift */; }; - 06C595362481160F006B98F3 /* BKLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06C595352481160F006B98F3 /* BKLabel.swift */; }; - 06C777D02ECEFF210032A044 /* MarkdownParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06C777CF2ECEFF210032A044 /* MarkdownParser.swift */; }; 06C777D32ECF05990032A044 /* Markdown in Frameworks */ = {isa = PBXBuildFile; productRef = 06C777D22ECF05990032A044 /* Markdown */; }; 06CF784721C7A50300A052D7 /* NotificationServiceExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 06CF784021C7A50300A052D7 /* NotificationServiceExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - 06CF784C21C7A51200A052D7 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06CF784B21C7A51200A052D7 /* NotificationService.swift */; }; - 06D69E202C1159E200161A35 /* glass.caf in Resources */ = {isa = PBXBuildFile; fileRef = 06320500250B6DD3001561EC /* glass.caf */; }; - 06D69E212C1159E200161A35 /* sherwoodforest.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204F5250B6DD2001561EC /* sherwoodforest.caf */; }; - 06D69E222C1159E200161A35 /* ladder.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204F9250B6DD2001561EC /* ladder.caf */; }; - 06D69E232C1159E200161A35 /* chime.caf in Resources */ = {isa = PBXBuildFile; fileRef = 06320509250B6DD4001561EC /* chime.caf */; }; - 06D69E242C1159E200161A35 /* anticipate.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204FE250B6DD2001561EC /* anticipate.caf */; }; - 06D69E252C1159E200161A35 /* update.caf in Resources */ = {isa = PBXBuildFile; fileRef = 06320504250B6DD3001561EC /* update.caf */; }; - 06D69E262C1159E200161A35 /* suspense.caf in Resources */ = {isa = PBXBuildFile; fileRef = 06320506250B6DD3001561EC /* suspense.caf */; }; - 06D69E272C1159E200161A35 /* newmail.caf in Resources */ = {isa = PBXBuildFile; fileRef = 06320503250B6DD3001561EC /* newmail.caf */; }; - 06D69E282C1159E200161A35 /* noir.caf in Resources */ = {isa = PBXBuildFile; fileRef = 06320508250B6DD3001561EC /* noir.caf */; }; - 06D69E292C1159E200161A35 /* birdsong.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204FC250B6DD2001561EC /* birdsong.caf */; }; - 06D69E2A2C1159E200161A35 /* minuet.caf in Resources */ = {isa = PBXBuildFile; fileRef = 06320505250B6DD3001561EC /* minuet.caf */; }; - 06D69E2B2C1159E200161A35 /* shake.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204F2250B6DD1001561EC /* shake.caf */; }; - 06D69E2C2C1159E200161A35 /* newsflash.caf in Resources */ = {isa = PBXBuildFile; fileRef = 0632050E250B6DD4001561EC /* newsflash.caf */; }; - 06D69E2D2C1159E200161A35 /* paymentsuccess.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204F1250B6DD1001561EC /* paymentsuccess.caf */; }; - 06D69E2E2C1159E200161A35 /* descent.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204F8250B6DD2001561EC /* descent.caf */; }; - 06D69E2F2C1159E200161A35 /* mailsent.caf in Resources */ = {isa = PBXBuildFile; fileRef = 06320507250B6DD3001561EC /* mailsent.caf */; }; - 06D69E302C1159E200161A35 /* tiptoes.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204FA250B6DD2001561EC /* tiptoes.caf */; }; - 06D69E312C1159E200161A35 /* telegraph.caf in Resources */ = {isa = PBXBuildFile; fileRef = 06320501250B6DD3001561EC /* telegraph.caf */; }; - 06D69E322C1159E200161A35 /* healthnotification.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204F6250B6DD2001561EC /* healthnotification.caf */; }; - 06D69E332C1159E200161A35 /* typewriters.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204FD250B6DD2001561EC /* typewriters.caf */; }; - 06D69E342C1159E200161A35 /* bell.caf in Resources */ = {isa = PBXBuildFile; fileRef = 0632050C250B6DD4001561EC /* bell.caf */; }; - 06D69E352C1159E200161A35 /* bloom.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204F4250B6DD1001561EC /* bloom.caf */; }; - 06D69E362C1159E200161A35 /* spell.caf in Resources */ = {isa = PBXBuildFile; fileRef = 0632050A250B6DD4001561EC /* spell.caf */; }; - 06D69E372C1159E200161A35 /* choo.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204FF250B6DD3001561EC /* choo.caf */; }; - 06D69E382C1159E200161A35 /* multiwayinvitation.caf in Resources */ = {isa = PBXBuildFile; fileRef = 06320502250B6DD3001561EC /* multiwayinvitation.caf */; }; - 06D69E392C1159E200161A35 /* horn.caf in Resources */ = {isa = PBXBuildFile; fileRef = 0632050D250B6DD4001561EC /* horn.caf */; }; - 06D69E3A2C1159E200161A35 /* electronic.caf in Resources */ = {isa = PBXBuildFile; fileRef = 0632050B250B6DD4001561EC /* electronic.caf */; }; - 06D69E3B2C1159E200161A35 /* calypso.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204F7250B6DD2001561EC /* calypso.caf */; }; - 06D69E3C2C1159E200161A35 /* gotosleep.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204F0250B6DD1001561EC /* gotosleep.caf */; }; - 06D69E3D2C1159E200161A35 /* silence.caf in Resources */ = {isa = PBXBuildFile; fileRef = 06BBB8CD2567B8E60076F63E /* silence.caf */; }; - 06D69E3E2C1159E200161A35 /* alarm.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204F3250B6DD1001561EC /* alarm.caf */; }; - 06D69E3F2C1159E200161A35 /* fanfare.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204FB250B6DD2001561EC /* fanfare.caf */; }; - 06D69E412C11983E00161A35 /* CallProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06D69E402C11983E00161A35 /* CallProcessor.swift */; }; - 06E62C112D670F62004DC82B /* PushToCurrentIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06E62C102D670F5D004DC82B /* PushToCurrentIntent.swift */; }; - 06E944682C06E40600AC86AB /* NotificationContentProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06E944672C06E40600AC86AB /* NotificationContentProcessor.swift */; }; - 06E9446A2C06E4A200AC86AB /* CiphertextProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06E944692C06E4A200AC86AB /* CiphertextProcessor.swift */; }; - 06E9446D2C06FEC900AC86AB /* LevelProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06E9446C2C06FEC900AC86AB /* LevelProcessor.swift */; }; - 06E9446F2C06FF1E00AC86AB /* BadgeProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06E9446E2C06FF1E00AC86AB /* BadgeProcessor.swift */; }; - 06E944712C06FF4C00AC86AB /* AutoCopyProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06E944702C06FF4C00AC86AB /* AutoCopyProcessor.swift */; }; - 06E944732C06FF9200AC86AB /* ArchiveProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06E944722C06FF9200AC86AB /* ArchiveProcessor.swift */; }; - 06E944752C07012E00AC86AB /* RealmConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06E944742C07012E00AC86AB /* RealmConfiguration.swift */; }; - 06E944762C07013000AC86AB /* RealmConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06E944742C07012E00AC86AB /* RealmConfiguration.swift */; }; - 06E944782C0701F300AC86AB /* IconProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06E944772C0701F300AC86AB /* IconProcessor.swift */; }; - 06E9447A2C0704E500AC86AB /* ImageDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06E944792C0704E500AC86AB /* ImageDownloader.swift */; }; - 06E9447C2C07052F00AC86AB /* ImageProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06E9447B2C07052F00AC86AB /* ImageProcessor.swift */; }; - 06EE1FD326843E9300586708 /* BarkTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06EE1FD226843E9300586708 /* BarkTests.swift */; }; - 06EEF333291CCFF400CA228A /* CryptoSettingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06EEF332291CCFF400CA228A /* CryptoSettingController.swift */; }; - 06EEF335291CD00000CA228A /* CryptoSettingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06EEF334291CD00000CA228A /* CryptoSettingViewModel.swift */; }; - 06EF49152D682A99008B91D2 /* PushResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06EF49142D682A96008B91D2 /* PushResponse.swift */; }; - 06EF49172D682AC4008B91D2 /* OptionsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06EF49162D682AC3008B91D2 /* OptionsProvider.swift */; }; - 06EF49192D682B3B008B91D2 /* PushToOtherIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06EF49182D682B34008B91D2 /* PushToOtherIntent.swift */; }; - 06F08EA429B098DD006AB9CA /* CryptoSettingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06F08EA329B098DD006AB9CA /* CryptoSettingManager.swift */; }; - 06F08EA529B1DDA7006AB9CA /* Algorithm.swift in Sources */ = {isa = PBXBuildFile; fileRef = 061894C629A75BEA00E001C2 /* Algorithm.swift */; }; - 06F08EA729B1DDFE006AB9CA /* Error+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06F08EA629B1DDFE006AB9CA /* Error+Extension.swift */; }; - 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 */; }; - 19BE8EBF2D03514A009BF080 /* ImageDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06E944792C0704E500AC86AB /* ImageDownloader.swift */; }; - 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 */; }; 3428272069AFAFE2C683FEB0 /* libPods-Bark.a in Frameworks */ = {isa = PBXBuildFile; fileRef = CCC722470308049D180876C7 /* libPods-Bark.a */; }; + 3DBD074B306B95A100A86B41 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3DBD074A306B95A100A86B41 /* WidgetKit.framework */; }; + 3DBD074D306B95A100A86B41 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3DBD074C306B95A100A86B41 /* SwiftUI.framework */; }; + 3DBD075A306B95A300A86B41 /* BarkWatchComplicationExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 3DBD0748306B95A100A86B41 /* BarkWatchComplicationExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 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, ); }; }; + B20000000000000000000004 /* CryptoSwift in Frameworks */ = {isa = PBXBuildFile; productRef = B20000000000000000000021 /* CryptoSwift */; }; + B20000000000000000000005 /* CryptoSwift in Frameworks */ = {isa = PBXBuildFile; productRef = B20000000000000000000022 /* CryptoSwift */; }; + 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 */; }; /* 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 +177,20 @@ remoteGlobalIDString = 0661A53E204FDA4100965E4E; remoteInfo = Bark; }; + 3DBD0758306B95A300A86B41 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 0661A537204FDA4100965E4E /* Project object */; + proxyType = 1; + remoteGlobalIDString = 3DBD0747306B95A100A86B41; + remoteInfo = BarkWatchComplicationExtension; + }; + A10000000000000000000011 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 0661A537204FDA4100965E4E /* Project object */; + proxyType = 1; + remoteGlobalIDString = A10000000000000000000010; + remoteInfo = BarkWatchNotificationService; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -291,6 +207,18 @@ name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; }; + A10000000000000000000008 /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 3DBD075A306B95A300A86B41 /* BarkWatchComplicationExtension.appex in Embed App Extensions */, + A10000000000000000000007 /* BarkWatchNotificationService.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ @@ -345,33 +273,16 @@ 0632050C250B6DD4001561EC /* bell.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = bell.caf; sourceTree = ""; }; 0632050D250B6DD4001561EC /* horn.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = horn.caf; sourceTree = ""; }; 0632050E250B6DD4001561EC /* newsflash.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = newsflash.caf; sourceTree = ""; }; + 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.json; path = TestNotification.apns; sourceTree = ""; }; + 000000000000000000000034 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; 0632CE1E20EC9098003FDF46 /* NotificationContentExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationContentExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 0632CE2220EC9098003FDF46 /* NotificationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationViewController.swift; sourceTree = ""; }; 0632CE2520EC9098003FDF46 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = ""; }; 0632CE2720EC9098003FDF46 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 0633E809256A091B00ED0680 /* MJRefresh+Rx.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MJRefresh+Rx.swift"; sourceTree = ""; }; - 0635A8042CE47DFE0027E00F /* SettingSectionHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingSectionHeader.swift; sourceTree = ""; }; - 0635A8062CE4883A0027E00F /* DonateCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DonateCell.swift; sourceTree = ""; }; - 0637FA7720E0926D00E80174 /* BarkTargetType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarkTargetType.swift; sourceTree = ""; }; - 0637FA7920E092B300E80174 /* Observable+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Observable+Extension.swift"; sourceTree = ""; }; - 0637FA7B20E0930E00E80174 /* BarkApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarkApi.swift; sourceTree = ""; }; - 0637FA7D20E0969800E80174 /* Client.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Client.swift; sourceTree = ""; }; - 0637FA7F20E0981E00E80174 /* BarkSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarkSettings.swift; sourceTree = ""; }; - 0637FA8120E09C4B00E80174 /* BarkNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarkNavigationController.swift; sourceTree = ""; }; - 0637FA8520E0AB6600E80174 /* UIColor+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+Extension.swift"; sourceTree = ""; }; - 0637FA8920E0D58800E80174 /* NewServerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewServerViewController.swift; sourceTree = ""; }; - 0637FA8B20E0D7A700E80174 /* BaseViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseViewController.swift; sourceTree = ""; }; - 0642B55927EB13F100453D91 /* MutableTextCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MutableTextCell.swift; sourceTree = ""; }; - 0642B55B27EB149900453D91 /* MutableTextCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MutableTextCellViewModel.swift; sourceTree = ""; }; - 0647DB672CE604AA00102066 /* MessageSettingFooter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageSettingFooter.swift; sourceTree = ""; }; - 0653677729B727A60038BDB8 /* CryptoSettingRelay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoSettingRelay.swift; sourceTree = ""; }; - 065752C02CB6844A001426D8 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; - 065AE76A2987777F00323230 /* ArchiveSettingRelay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSettingRelay.swift; sourceTree = ""; }; - 065BE43F2563D649002A8CA4 /* SoundsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundsViewModel.swift; sourceTree = ""; }; - 065BE4452563D7E5002A8CA4 /* ViewModelType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelType.swift; sourceTree = ""; }; - 065BE44A2563D8E1002A8CA4 /* Reusable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Reusable.swift; sourceTree = ""; }; - 065BE44F2563D939002A8CA4 /* SoundCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundCellViewModel.swift; sourceTree = ""; }; - 065BE4542565055F002A8CA4 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = ""; }; 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 = ""; }; @@ -420,71 +331,28 @@ 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 = ""; }; 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 = ""; }; - 06BBB895256518760076F63E /* NewServerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewServerViewModel.swift; sourceTree = ""; }; - 06BBB8B62567AC140076F63E /* MessageSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageSettingsViewModel.swift; sourceTree = ""; }; - 06BBB8BB2567B3AD0076F63E /* ArchiveSettingCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSettingCellViewModel.swift; sourceTree = ""; }; - 06BBB8C02567B3EF0076F63E /* BaseTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseTableViewCell.swift; sourceTree = ""; }; - 06BBB8C82567B6730076F63E /* Operators.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Operators.swift; sourceTree = ""; }; - 06BBB8CD2567B8E60076F63E /* silence.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = silence.caf; sourceTree = ""; }; - 06BCAE552CDB19260092867A /* GroupMuteSettingManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMuteSettingManager.swift; sourceTree = ""; }; 06BCAE592CDB19590092867A /* NotificationContentExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NotificationContentExtension.entitlements; sourceTree = ""; }; - 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 = ""; }; - 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 = ""; }; - 06C5953024811392006B98F3 /* ArchiveSettingCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSettingCell.swift; sourceTree = ""; }; - 06C5953224811505006B98F3 /* ArchiveSettingManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSettingManager.swift; sourceTree = ""; }; - 06C595352481160F006B98F3 /* BKLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BKLabel.swift; sourceTree = ""; }; - 06C777CF2ECEFF210032A044 /* MarkdownParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownParser.swift; sourceTree = ""; }; 06CF784021C7A50300A052D7 /* NotificationServiceExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationServiceExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; - 06CF784421C7A50300A052D7 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 06CF784B21C7A51200A052D7 /* NotificationService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; - 06D69E402C11983E00161A35 /* CallProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallProcessor.swift; sourceTree = ""; }; - 06E62C102D670F5D004DC82B /* PushToCurrentIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushToCurrentIntent.swift; sourceTree = ""; }; - 06E944672C06E40600AC86AB /* NotificationContentProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationContentProcessor.swift; sourceTree = ""; }; - 06E944692C06E4A200AC86AB /* CiphertextProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CiphertextProcessor.swift; sourceTree = ""; }; - 06E9446C2C06FEC900AC86AB /* LevelProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LevelProcessor.swift; sourceTree = ""; }; - 06E9446E2C06FF1E00AC86AB /* BadgeProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeProcessor.swift; sourceTree = ""; }; - 06E944702C06FF4C00AC86AB /* AutoCopyProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCopyProcessor.swift; sourceTree = ""; }; - 06E944722C06FF9200AC86AB /* ArchiveProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveProcessor.swift; sourceTree = ""; }; - 06E944742C07012E00AC86AB /* RealmConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RealmConfiguration.swift; sourceTree = ""; }; - 06E944772C0701F300AC86AB /* IconProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconProcessor.swift; sourceTree = ""; }; - 06E944792C0704E500AC86AB /* ImageDownloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDownloader.swift; sourceTree = ""; }; - 06E9447B2C07052F00AC86AB /* ImageProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageProcessor.swift; sourceTree = ""; }; 06EE1FD026843E9300586708 /* BarkTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BarkTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 06EE1FD226843E9300586708 /* BarkTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarkTests.swift; sourceTree = ""; }; - 06EE1FD426843E9300586708 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 06EEF332291CCFF400CA228A /* CryptoSettingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoSettingController.swift; sourceTree = ""; }; - 06EEF334291CD00000CA228A /* CryptoSettingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoSettingViewModel.swift; sourceTree = ""; }; - 06EF49142D682A96008B91D2 /* PushResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushResponse.swift; sourceTree = ""; }; - 06EF49162D682AC3008B91D2 /* OptionsProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptionsProvider.swift; sourceTree = ""; }; - 06EF49182D682B34008B91D2 /* PushToOtherIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushToOtherIntent.swift; sourceTree = ""; }; - 06F08EA329B098DD006AB9CA /* CryptoSettingManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoSettingManager.swift; sourceTree = ""; }; - 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 = ""; }; - 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 = ""; }; + 3DBD0748306B95A100A86B41 /* BarkWatchComplicationExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = BarkWatchComplicationExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 3DBD074A306B95A100A86B41 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; + 3DBD074C306B95A100A86B41 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; + 3DBD075F306B969400A86B41 /* BarkWatchComplicationExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = BarkWatchComplicationExtension.entitlements; sourceTree = ""; }; 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 = ""; }; 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; }; 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.json; 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 = ""; }; @@ -499,13 +367,179 @@ ); target = 06697FD82F70ED9400C4680E /* WidgetExtension */; }; + 3D099DDE306DBCFC00A96591 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + WatchArchive.swift, + ); + target = 3DBD0747306B95A100A86B41 /* BarkWatchComplicationExtension */; + }; + 3DBD075B306B95A300A86B41 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 3DBD0747306B95A100A86B41 /* BarkWatchComplicationExtension */; + }; + 3DE24150306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + GroupMuteSettingManager.swift, + "String+Extension.swift", + "UIFont+Extension.swift", + ); + target = 0632CE1D20EC9098003FDF46 /* NotificationContentExtension */; + }; + 3DE24151306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + ArchiveSettingManager.swift, + BKColor.swift, + CryptoSettingManager.swift, + "Error+Extension.swift", + GroupMuteSettingManager.swift, + MarkdownParser.swift, + RealmConfiguration.swift, + SharedDefines.swift, + "String+Extension.swift", + "UIFont+Extension.swift", + WidgetHistoryModels.swift, + WidgetHistorySnapshotStore.swift, + WidgetHistorySnapshotSupport.swift, + ); + target = 06CF783F21C7A50300A052D7 /* NotificationServiceExtension */; + }; + 3DE24152306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Processor/ImageDownloader.swift, + ); + target = 0632CE1D20EC9098003FDF46 /* NotificationContentExtension */; + }; + 3DE24153306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 06CF783F21C7A50300A052D7 /* NotificationServiceExtension */; + }; + 3DE24154306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + WidgetHistoryModels.swift, + ); + target = 06697FD82F70ED9400C4680E /* WidgetExtension */; + }; + 3DE24155306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + MarkdownParser.swift, + "UIFont+Extension.swift", + ); + target = 000000000000000000000004 /* BarkWatch */; + }; + 3DE24156306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + MarkdownParser.swift, + "UIFont+Extension.swift", + ); + target = A10000000000000000000010 /* BarkWatchNotificationService */; + }; + 3DE24157306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Algorithm.swift, + Message.swift, + ); + target = 06CF783F21C7A50300A052D7 /* NotificationServiceExtension */; + }; + 3DE24158306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Algorithm.swift, + ); + target = 000000000000000000000004 /* BarkWatch */; + }; + 3DE24159306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Algorithm.swift, + ); + target = A10000000000000000000010 /* BarkWatchNotificationService */; + }; + 3DE2415A306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 0661A53E204FDA4100965E4E /* Bark */; + }; + 3DE2415B306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Assets.xcassets, + Localizable.xcstrings, + ); + target = 0632CE1D20EC9098003FDF46 /* NotificationContentExtension */; + }; + 3DE2415C306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Localizable.xcstrings, + ); + target = 06CF783F21C7A50300A052D7 /* NotificationServiceExtension */; + }; + 3DE2415D306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Localizable.xcstrings, + ); + target = 06697FD82F70ED9400C4680E /* WidgetExtension */; + }; + 3DE2415E306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 06EE1FCF26843E9300586708 /* BarkTests */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* 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; exceptions = (3D099DDE306DBCFC00A96591 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Common; sourceTree = ""; }; + 3D2E504F306D002800D53E4F /* View */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = View; sourceTree = ""; }; + 3DBD074E306B95A100A86B41 /* BarkWatchComplication */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (3DBD075B306B95A300A86B41 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = BarkWatchComplication; sourceTree = ""; }; + 3DE23FE2306DE84F00066C57 /* Sounds */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Sounds; sourceTree = ""; }; + 3DE24039306DE84F00066C57 /* Controller */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Controller; sourceTree = ""; }; + 3DE24060306DE84F00066C57 /* NotificationServiceExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (3DE24152306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 3DE24153306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = NotificationServiceExtension; sourceTree = ""; }; + 3DE2409E306DE84F00066C57 /* View */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = View; sourceTree = ""; }; + 3DE240E9306DE84F00066C57 /* Common */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (3DE24150306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 3DE24151306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 3DE24154306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 3DE24155306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 3DE24156306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Common; sourceTree = ""; }; + 3DE2411F306DE84F00066C57 /* BarkTests */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (3DE2415E306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = BarkTests; sourceTree = ""; }; + 3DE24130306DE84F00066C57 /* Bark */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (3DE2415A306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 3DE2415B306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 3DE2415C306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 3DE2415D306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Bark; sourceTree = ""; }; + 3DE24145306DE84F00066C57 /* Model */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (3DE24157306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 3DE24158306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 3DE24159306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Model; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + 00000000000000000000003D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + B20000000000000000000004 /* CryptoSwift in Frameworks */, + B20000000000000000000051 /* Markdown in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 000000000000000000000047 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 0632CE1B20EC9098003FDF46 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -553,129 +587,49 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 3DBD0745306B95A100A86B41 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 3DBD074D306B95A100A86B41 /* SwiftUI.framework in Frameworks */, + 3DBD074B306B95A100A86B41 /* WidgetKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + B20000000000000000000013 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + B20000000000000000000005 /* CryptoSwift in Frameworks */, + B20000000000000000000052 /* Markdown in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 0604F7DB20620D2700B32F09 /* Controller */ = { - isa = PBXGroup; - children = ( - 0637FA8B20E0D7A700E80174 /* BaseViewController.swift */, - 0637FA8120E09C4B00E80174 /* BarkNavigationController.swift */, - 0603706C20E23EC000F4CA05 /* BarkSFSafariViewController.swift */, - 0661A544204FDA4100965E4E /* HomeViewController.swift */, - 065BE4542565055F002A8CA4 /* HomeViewModel.swift */, - 0637FA8920E0D58800E80174 /* NewServerViewController.swift */, - 06BBB895256518760076F63E /* NewServerViewModel.swift */, - 068F66B2247BD84C00DAD25A /* MessageListViewController.swift */, - 0672CB05256903F700570C9D /* MessageListViewModel.swift */, - 06885EB5247FB9880004A303 /* MessageSettingsViewController.swift */, - 06BBB8B62567AC140076F63E /* MessageSettingsViewModel.swift */, - 060481ED250F404500BC9799 /* SoundsViewController.swift */, - 065BE43F2563D649002A8CA4 /* SoundsViewModel.swift */, - 06F11E7627D9D5FB00F00298 /* QRScannerViewController.swift */, - 06C10A032FB20001000000D1 /* PushQRCodeViewController.swift */, - 068EC15727ED99C900D5D11E /* ServerListViewController.swift */, - 068EC15927ED99E700D5D11E /* ServerListViewModel.swift */, - 06EEF332291CCFF400CA228A /* CryptoSettingController.swift */, - 06EEF334291CD00000CA228A /* CryptoSettingViewModel.swift */, - 1E73F99D2C282822002BF649 /* SectionViewController-iPad.swift */, - 1EFB545E2C32514000B8E51B /* SectionViewModel-iPad.swift */, - 1EFB545C2C314A6800B8E51B /* BarkSplitViewController.swift */, - 0689CF4B2C7484A7007203A6 /* BarkTabBarController.swift */, - ); - path = Controller; - sourceTree = ""; - }; - 0604F7DC20620D3400B32F09 /* View */ = { - isa = PBXGroup; - children = ( - 06A100092FEE0001000000A1 /* Home */, - 066890092D19495400E106F2 /* MessageList */, - 06A100012FEE0001000000A1 /* GlassView.swift */, - 06BBB8C02567B3EF0076F63E /* BaseTableViewCell.swift */, - 06C5952C2480E3F8006B98F3 /* LabelCell.swift */, - 06C5953024811392006B98F3 /* ArchiveSettingCell.swift */, - 06BBB8BB2567B3AD0076F63E /* ArchiveSettingCellViewModel.swift */, - 060481EF250F51CA00BC9799 /* SoundCell.swift */, - 065BE44F2563D939002A8CA4 /* SoundCellViewModel.swift */, - 066E0C8B2BB6AC9A00873838 /* AddSoundCell.swift */, - 062B98C2251B2762004562E7 /* BKButton.swift */, - 06C595352481160F006B98F3 /* BKLabel.swift */, - 062B98C7251B27AE004562E7 /* UINavigationItem+Extension.swift */, - 06C2CF222685B88D0034B127 /* TextCell.swift */, - 06C2CF242685BDB80034B127 /* SpacerCell.swift */, - 0642B55927EB13F100453D91 /* MutableTextCell.swift */, - 0642B55B27EB149900453D91 /* MutableTextCellViewModel.swift */, - 06172FD927F6DAEF002333A4 /* ServerListTableViewCell.swift */, - 06172FDB27F6DB06002333A4 /* ServerListTableViewCellViewModel.swift */, - 0627DABA298B6EA2002F3F69 /* DropBoxView.swift */, - 0627DABC2990D615002F3F69 /* BorderTextField.swift */, - 0608F06D2994D115006B8029 /* BKDropDownCell.swift */, - 0608F0712994D269006B8029 /* BKDropDownCell.xib */, - 061894C429962EB900E001C2 /* GradientButton.swift */, - 06F08EAE29B5D9FF006AB9CA /* HUD.swift */, - 06787C382A710568008ABDD7 /* GesturePassTextView.swift */, - 0635A8042CE47DFE0027E00F /* SettingSectionHeader.swift */, - 0635A8062CE4883A0027E00F /* DonateCell.swift */, - 0647DB672CE604AA00102066 /* MessageSettingFooter.swift */, - 06BE84032E6EAE7100E6F856 /* InsetView.swift */, - ); - path = View; - sourceTree = ""; - }; - 0604F7DD20620D3800B32F09 /* Model */ = { - isa = PBXGroup; - children = ( - 066DF4812D2D31A30092B04E /* MessageDeleteTimeRange.swift */, - 06B1158E247BB1FB006D91FB /* Message.swift */, - 067B2EB425693E38008B6BE1 /* MessageSection.swift */, - 061E35852D1E5028009A2D6F /* MessageItemModel.swift */, - 06BD4DA92901352E003364DB /* Object+Dictionary.swift */, - 061894C629A75BEA00E001C2 /* Algorithm.swift */, - ); - path = Model; - sourceTree = ""; - }; - 063204EF250B6DC2001561EC /* Sounds */ = { + 000000000000000000000002 /* BarkWatch */ = { isa = PBXGroup; children = ( - 06BBB8CD2567B8E60076F63E /* silence.caf */, - 063204F3250B6DD1001561EC /* alarm.caf */, - 063204FE250B6DD2001561EC /* anticipate.caf */, - 0632050C250B6DD4001561EC /* bell.caf */, - 063204FC250B6DD2001561EC /* birdsong.caf */, - 063204F4250B6DD1001561EC /* bloom.caf */, - 063204F7250B6DD2001561EC /* calypso.caf */, - 06320509250B6DD4001561EC /* chime.caf */, - 063204FF250B6DD3001561EC /* choo.caf */, - 063204F8250B6DD2001561EC /* descent.caf */, - 0632050B250B6DD4001561EC /* electronic.caf */, - 063204FB250B6DD2001561EC /* fanfare.caf */, - 06320500250B6DD3001561EC /* glass.caf */, - 063204F0250B6DD1001561EC /* gotosleep.caf */, - 063204F6250B6DD2001561EC /* healthnotification.caf */, - 0632050D250B6DD4001561EC /* horn.caf */, - 063204F9250B6DD2001561EC /* ladder.caf */, - 06320507250B6DD3001561EC /* mailsent.caf */, - 06320505250B6DD3001561EC /* minuet.caf */, - 06320502250B6DD3001561EC /* multiwayinvitation.caf */, - 06320503250B6DD3001561EC /* newmail.caf */, - 0632050E250B6DD4001561EC /* newsflash.caf */, - 06320508250B6DD3001561EC /* noir.caf */, - 063204F1250B6DD1001561EC /* paymentsuccess.caf */, - 063204F2250B6DD1001561EC /* shake.caf */, - 063204F5250B6DD2001561EC /* sherwoodforest.caf */, - 0632050A250B6DD4001561EC /* spell.caf */, - 06320506250B6DD3001561EC /* suspense.caf */, - 06320501250B6DD3001561EC /* telegraph.caf */, - 063204FA250B6DD2001561EC /* tiptoes.caf */, - 063204FD250B6DD2001561EC /* typewriters.caf */, - 06320504250B6DD3001561EC /* update.caf */, - ); - path = Sounds; + 3D099DD0306DBCF400A96591 /* App */, + 3D099DCC306DBCEE00A96591 /* Model */, + 3D2E504F306D002800D53E4F /* View */, + 3D099DD6306DBCFB00A96591 /* Common */, + 3DBD074E306B95A100A86B41 /* BarkWatchComplication */, + D40000000000000000000005 /* Tests */, + C30000000000000000000001 /* Assets.xcassets */, + 000000000000000000000032 /* BarkWatch.entitlements */, + 3DBD075F306B969400A86B41 /* BarkWatchComplicationExtension.entitlements */, + 000000000000000000000033 /* TestNotification.apns */, + B20000000000000000000055 /* MarkdownNotification.apns */, + 000000000000000000000034 /* README.md */, + 3DBD0749306B95A100A86B41 /* Frameworks */, + A10000000000000000000002 /* WatchNotificationService.swift */, + ); + path = BarkWatch; sourceTree = ""; }; - 0632CE2120EC9098003FDF46 /* NotificationContentExtension */ = { + 0632CE2120EC9098003FDF46 /* notificationContentExtension */ = { isa = PBXGroup; children = ( 06BCAE592CDB19590092867A /* NotificationContentExtension.entitlements */, @@ -716,7 +670,6 @@ 06BCAE552CDB19260092867A /* GroupMuteSettingManager.swift */, 06C777CF2ECEFF210032A044 /* MarkdownParser.swift */, 06A100182FEE0001000000A1 /* CodeHighlighter.swift */, - 06C10A012FB20001000000D1 /* QRCodeGenerator.swift */, 066B10002F73F00100C4680E /* WidgetHistoryModels.swift */, 06697FED2F70ED9600C4680E /* WidgetHistorySnapshotStore.swift */, 06697FF52F70ED9600C4680E /* WidgetHistorySnapshotSupport.swift */, @@ -737,16 +690,17 @@ 0661A536204FDA4100965E4E = { isa = PBXGroup; children = ( - 063204EF250B6DC2001561EC /* Sounds */, - 065BE4442563D7AA002A8CA4 /* Common */, - 0604F7DD20620D3800B32F09 /* Model */, - 0604F7DC20620D3400B32F09 /* View */, - 0604F7DB20620D2700B32F09 /* Controller */, - 0661A541204FDA4100965E4E /* Bark */, - 0632CE2120EC9098003FDF46 /* NotificationContentExtension */, - 06CF784121C7A50300A052D7 /* NotificationServiceExtension */, - 06EE1FD126843E9300586708 /* BarkTests */, + 3DE23FE2306DE84F00066C57 /* Sounds */, + 3DE240E9306DE84F00066C57 /* Common */, + 3DE24145306DE84F00066C57 /* Model */, + 3DE2409E306DE84F00066C57 /* View */, + 3DE24039306DE84F00066C57 /* Controller */, + 3DE24130306DE84F00066C57 /* Bark */, + 0632CE2120EC9098003FDF46 /* notificationContentExtension */, + 3DE24060306DE84F00066C57 /* NotificationServiceExtension */, + 3DE2411F306DE84F00066C57 /* BarkTests */, 06697FDE2F70ED9400C4680E /* Widget */, + 000000000000000000000002 /* BarkWatch */, 0661A540204FDA4100965E4E /* Products */, 99BD309BDB7F62B5DC0CECE1 /* Frameworks */, E9062CB15F1B70C3719578FB /* Pods */, @@ -761,105 +715,21 @@ 06CF784021C7A50300A052D7 /* NotificationServiceExtension.appex */, 06EE1FD026843E9300586708 /* BarkTests.xctest */, 06697FD92F70ED9400C4680E /* WidgetExtension.appex */, + 000000000000000000000006 /* BarkWatch.app */, + 000000000000000000000007 /* BarkWatchTests.xctest */, + A10000000000000000000003 /* BarkWatchNotificationService.appex */, + 3DBD0748306B95A100A86B41 /* BarkWatchComplicationExtension.appex */, ); name = Products; sourceTree = ""; }; - 0661A541204FDA4100965E4E /* Bark */ = { - isa = PBXGroup; - children = ( - 06EF49132D682A82008B91D2 /* Intents */, - 0683486A2050F1310024B6DA /* Bark.entitlements */, - 0661A542204FDA4100965E4E /* AppDelegate.swift */, - 06A200012FED000100000001 /* SceneDelegate.swift */, - 06F808902EF4056C0007352A /* AppDelegate+Realm.swift */, - 0661A549204FDA4100965E4E /* Assets.xcassets */, - 0661A54B204FDA4100965E4E /* LaunchScreen.storyboard */, - 0661A54E204FDA4100965E4E /* Info.plist */, - 065752C02CB6844A001426D8 /* Localizable.xcstrings */, - ); - path = Bark; - sourceTree = ""; - }; - 066890092D19495400E106F2 /* MessageList */ = { - isa = PBXGroup; - children = ( - 0699473C2D223094008D5E40 /* CustomTapTextView.swift */, - 066890072D1946D500E106F2 /* MessageItemView.swift */, - 0667D191247D162C005DE2ED /* MessageTableViewCell.swift */, - 0668900A2D19525400E106F2 /* ShowLessAndClearView.swift */, - 0668900C2D19582400E106F2 /* MessageGroupHeaderView.swift */, - 0668900E2F751A1000E106F2 /* MessageListSkeletonView.swift */, - 061C17072D1BDA4B00891D66 /* MessageGroupMoreView.swift */, - ); - path = MessageList; - sourceTree = ""; - }; - 06A100092FEE0001000000A1 /* Home */ = { - isa = PBXGroup; - children = ( - 06A100032FEE0001000000A1 /* HomeGlassButton.swift */, - 06A1001A2FEE0001000000A1 /* HomeFlatButton.swift */, - 06A100202FEE0001000000A1 /* HomeSplitButton.swift */, - 06A100052FEE0001000000A1 /* HomeExampleSegmentedControl.swift */, - 06A100072FEE0001000000A1 /* HomeSettingRow.swift */, - 06A100102FEE0001000000A1 /* HomePermissionCard.swift */, - 06A100122FEE0001000000A1 /* HomeExampleCard.swift */, - 06A100142FEE0001000000A1 /* HomeSettingsCard.swift */, - 06A100162FEE0001000000A1 /* HomeDocumentsCard.swift */, - ); - path = Home; - sourceTree = ""; - }; - 06CF784121C7A50300A052D7 /* NotificationServiceExtension */ = { - isa = PBXGroup; - children = ( - 06E9446B2C06E4A800AC86AB /* Processor */, - 06B11590247BBC15006D91FB /* NotificationServiceExtension.entitlements */, - 06CF784B21C7A51200A052D7 /* NotificationService.swift */, - 06CF784421C7A50300A052D7 /* Info.plist */, - ); - path = NotificationServiceExtension; - sourceTree = ""; - }; - 06E9446B2C06E4A800AC86AB /* Processor */ = { - isa = PBXGroup; - children = ( - 06E944672C06E40600AC86AB /* NotificationContentProcessor.swift */, - 06E944692C06E4A200AC86AB /* CiphertextProcessor.swift */, - 06E9446C2C06FEC900AC86AB /* LevelProcessor.swift */, - 06E9446E2C06FF1E00AC86AB /* BadgeProcessor.swift */, - 06E944702C06FF4C00AC86AB /* AutoCopyProcessor.swift */, - 06E944722C06FF9200AC86AB /* ArchiveProcessor.swift */, - 06E944772C0701F300AC86AB /* IconProcessor.swift */, - 06E9447B2C07052F00AC86AB /* ImageProcessor.swift */, - 06D69E402C11983E00161A35 /* CallProcessor.swift */, - 06E944792C0704E500AC86AB /* ImageDownloader.swift */, - 06BCAE5A2CDB25120092867A /* MuteProcessor.swift */, - 069C6CA32ED03C72007244BB /* MarkdownProcessor.swift */, - ); - path = Processor; - sourceTree = ""; - }; - 06EE1FD126843E9300586708 /* BarkTests */ = { - isa = PBXGroup; - children = ( - 06EE1FD226843E9300586708 /* BarkTests.swift */, - 06EE1FD426843E9300586708 /* Info.plist */, - 068A4B952D2E11CD00982449 /* MessageDeleteTimeRangeTest.swift */, - ); - path = BarkTests; - sourceTree = ""; - }; - 06EF49132D682A82008B91D2 /* Intents */ = { + 3DBD0749306B95A100A86B41 /* Frameworks */ = { isa = PBXGroup; children = ( - 06EF49162D682AC3008B91D2 /* OptionsProvider.swift */, - 06EF49142D682A96008B91D2 /* PushResponse.swift */, - 06E62C102D670F5D004DC82B /* PushToCurrentIntent.swift */, - 06EF49182D682B34008B91D2 /* PushToOtherIntent.swift */, + 3DBD074A306B95A100A86B41 /* WidgetKit.framework */, + 3DBD074C306B95A100A86B41 /* SwiftUI.framework */, ); - path = Intents; + name = Frameworks; sourceTree = ""; }; 99BD309BDB7F62B5DC0CECE1 /* Frameworks */ = { @@ -879,6 +749,14 @@ name = Frameworks; sourceTree = ""; }; + D40000000000000000000005 /* Tests */ = { + isa = PBXGroup; + children = ( + 000000000000000000000028 /* NotificationTests.swift */, + ); + path = Tests; + sourceTree = ""; + }; E9062CB15F1B70C3719578FB /* Pods */ = { isa = PBXGroup; children = ( @@ -897,6 +775,54 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 000000000000000000000004 /* BarkWatch */ = { + isa = PBXNativeTarget; + buildConfigurationList = 000000000000000000000066 /* Build configuration list for PBXNativeTarget "BarkWatch" */; + buildPhases = ( + 00000000000000000000003C /* Sources */, + 00000000000000000000003D /* Frameworks */, + 00000000000000000000003E /* Resources */, + A10000000000000000000008 /* Embed App Extensions */, + ); + buildRules = ( + ); + dependencies = ( + A10000000000000000000009 /* PBXTargetDependency */, + 3DBD0759306B95A300A86B41 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 3D099DCC306DBCEE00A96591 /* Model */, + 3D099DD0306DBCF400A96591 /* App */, + 3D099DD6306DBCFB00A96591 /* Common */, + 3D2E504F306D002800D53E4F /* View */, + ); + name = BarkWatch; + packageProductDependencies = ( + B20000000000000000000021 /* CryptoSwift */, + 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 = ( + 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" */; @@ -934,6 +860,14 @@ 0632CE2920EC9098003FDF46 /* PBXTargetDependency */, 06CF784621C7A50300A052D7 /* PBXTargetDependency */, ); + fileSystemSynchronizedGroups = ( + 3DE23FE2306DE84F00066C57 /* Sounds */, + 3DE24039306DE84F00066C57 /* Controller */, + 3DE2409E306DE84F00066C57 /* View */, + 3DE240E9306DE84F00066C57 /* Common */, + 3DE24130306DE84F00066C57 /* Bark */, + 3DE24145306DE84F00066C57 /* Model */, + ); name = Bark; productName = Bark; productReference = 0661A53F204FDA4100965E4E /* Bark.app */; @@ -975,6 +909,10 @@ ); dependencies = ( ); + fileSystemSynchronizedGroups = ( + 3DE23FE2306DE84F00066C57 /* Sounds */, + 3DE24060306DE84F00066C57 /* NotificationServiceExtension */, + ); name = NotificationServiceExtension; productName = NotificationServiceExtension; productReference = 06CF784021C7A50300A052D7 /* NotificationServiceExtension.appex */; @@ -994,11 +932,59 @@ dependencies = ( 06EE1FD626843E9300586708 /* PBXTargetDependency */, ); + fileSystemSynchronizedGroups = ( + 3DE2411F306DE84F00066C57 /* BarkTests */, + ); name = BarkTests; productName = BarkTests; productReference = 06EE1FD026843E9300586708 /* BarkTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; + 3DBD0747306B95A100A86B41 /* BarkWatchComplicationExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 3DBD075C306B95A300A86B41 /* Build configuration list for PBXNativeTarget "BarkWatchComplicationExtension" */; + buildPhases = ( + 3DBD0744306B95A100A86B41 /* Sources */, + 3DBD0745306B95A100A86B41 /* Frameworks */, + 3DBD0746306B95A100A86B41 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 3DBD074E306B95A100A86B41 /* BarkWatchComplication */, + ); + name = BarkWatchComplicationExtension; + packageProductDependencies = ( + ); + productName = BarkWatchComplicationExtension; + productReference = 3DBD0748306B95A100A86B41 /* BarkWatchComplicationExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; + A10000000000000000000010 /* BarkWatchNotificationService */ = { + isa = PBXNativeTarget; + buildConfigurationList = A10000000000000000000015 /* Build configuration list for PBXNativeTarget "BarkWatchNotificationService" */; + buildPhases = ( + A10000000000000000000012 /* Sources */, + B20000000000000000000013 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 3D099DD6306DBCFB00A96591 /* Common */, + ); + name = BarkWatchNotificationService; + packageProductDependencies = ( + B20000000000000000000022 /* CryptoSwift */, + B20000000000000000000054 /* Markdown */, + ); + productName = BarkWatchNotificationService; + productReference = A10000000000000000000003 /* BarkWatchNotificationService.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -1010,13 +996,24 @@ 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; }; 0661A53E204FDA4100965E4E = { CreatedOnToolsVersion = 9.2; - ProvisioningStyle = Manual; SystemCapabilities = { com.apple.Push = { enabled = 1; @@ -1035,10 +1032,13 @@ ProvisioningStyle = Automatic; TestTargetID = 0661A53E204FDA4100965E4E; }; + 3DBD0747306B95A100A86B41 = { + CreatedOnToolsVersion = 27.0; + }; }; }; buildConfigurationList = 0661A53A204FDA4100965E4E /* Build configuration list for PBXProject "Bark" */; - compatibilityVersion = "Xcode 8.0"; + compatibilityVersion = "Xcode 16.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( @@ -1052,6 +1052,7 @@ mainGroup = 0661A536204FDA4100965E4E; packageReferences = ( 06C777D12ECF05990032A044 /* XCRemoteSwiftPackageReference "swift-markdown" */, + B20000000000000000000020 /* XCRemoteSwiftPackageReference "CryptoSwift" */, ); productRefGroup = 0661A540204FDA4100965E4E /* Products */; projectDirPath = ""; @@ -1062,17 +1063,34 @@ 06CF783F21C7A50300A052D7 /* NotificationServiceExtension */, 06EE1FCF26843E9300586708 /* BarkTests */, 06697FD82F70ED9400C4680E /* WidgetExtension */, + 000000000000000000000004 /* BarkWatch */, + 000000000000000000000005 /* BarkWatchTests */, + A10000000000000000000010 /* BarkWatchNotificationService */, + 3DBD0747306B95A100A86B41 /* BarkWatchComplicationExtension */, ); }; /* 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; files = ( - 06802E5320ECC40C00767047 /* Assets.xcassets in Resources */, - 065752C22CB6844A001426D8 /* Localizable.xcstrings in Resources */, 0632CE2620EC9098003FDF46 /* MainInterface.storyboard in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1081,42 +1099,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 06320525250B6DD4001561EC /* suspense.caf in Resources */, - 06320529250B6DD4001561EC /* spell.caf in Resources */, - 065752C12CB6844A001426D8 /* Localizable.xcstrings in Resources */, - 0632051D250B6DD4001561EC /* anticipate.caf in Resources */, - 06320521250B6DD4001561EC /* multiwayinvitation.caf in Resources */, - 06BBB8CE2567B8E60076F63E /* silence.caf in Resources */, - 06320528250B6DD4001561EC /* chime.caf in Resources */, - 06320510250B6DD4001561EC /* paymentsuccess.caf in Resources */, - 06320527250B6DD4001561EC /* noir.caf in Resources */, - 06320520250B6DD4001561EC /* telegraph.caf in Resources */, - 06320518250B6DD4001561EC /* ladder.caf in Resources */, - 06320526250B6DD4001561EC /* mailsent.caf in Resources */, - 06320523250B6DD4001561EC /* update.caf in Resources */, - 06320515250B6DD4001561EC /* healthnotification.caf in Resources */, - 06320511250B6DD4001561EC /* shake.caf in Resources */, - 06320513250B6DD4001561EC /* bloom.caf in Resources */, - 0632050F250B6DD4001561EC /* gotosleep.caf in Resources */, - 06320512250B6DD4001561EC /* alarm.caf in Resources */, - 06320522250B6DD4001561EC /* newmail.caf in Resources */, - 0661A54D204FDA4100965E4E /* LaunchScreen.storyboard in Resources */, - 0632051E250B6DD4001561EC /* choo.caf in Resources */, - 06320516250B6DD4001561EC /* calypso.caf in Resources */, - 0608F0722994D269006B8029 /* BKDropDownCell.xib in Resources */, - 0632052D250B6DD4001561EC /* newsflash.caf in Resources */, - 06320514250B6DD4001561EC /* sherwoodforest.caf in Resources */, - 0632051A250B6DD4001561EC /* fanfare.caf in Resources */, - 06320517250B6DD4001561EC /* descent.caf in Resources */, - 0632051B250B6DD4001561EC /* birdsong.caf in Resources */, - 0632051F250B6DD4001561EC /* glass.caf in Resources */, - 06320524250B6DD4001561EC /* minuet.caf in Resources */, - 0661A54A204FDA4100965E4E /* Assets.xcassets in Resources */, - 06320519250B6DD4001561EC /* tiptoes.caf in Resources */, - 0632052A250B6DD4001561EC /* electronic.caf in Resources */, - 0632052C250B6DD4001561EC /* horn.caf in Resources */, - 0632051C250B6DD4001561EC /* typewriters.caf in Resources */, - 0632052B250B6DD4001561EC /* bell.caf in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1124,7 +1106,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 063AE3EB2F74E97C00048718 /* Localizable.xcstrings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1132,39 +1113,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 06D69E202C1159E200161A35 /* glass.caf in Resources */, - 06D69E212C1159E200161A35 /* sherwoodforest.caf in Resources */, - 06D69E222C1159E200161A35 /* ladder.caf in Resources */, - 06D69E232C1159E200161A35 /* chime.caf in Resources */, - 06D69E242C1159E200161A35 /* anticipate.caf in Resources */, - 06D69E252C1159E200161A35 /* update.caf in Resources */, - 06D69E262C1159E200161A35 /* suspense.caf in Resources */, - 06D69E272C1159E200161A35 /* newmail.caf in Resources */, - 06D69E282C1159E200161A35 /* noir.caf in Resources */, - 06D69E292C1159E200161A35 /* birdsong.caf in Resources */, - 06D69E2A2C1159E200161A35 /* minuet.caf in Resources */, - 06D69E2B2C1159E200161A35 /* shake.caf in Resources */, - 06D69E2C2C1159E200161A35 /* newsflash.caf in Resources */, - 06D69E2D2C1159E200161A35 /* paymentsuccess.caf in Resources */, - 06D69E2E2C1159E200161A35 /* descent.caf in Resources */, - 06D69E2F2C1159E200161A35 /* mailsent.caf in Resources */, - 06D69E302C1159E200161A35 /* tiptoes.caf in Resources */, - 065752C32CB6844A001426D8 /* Localizable.xcstrings in Resources */, - 06D69E312C1159E200161A35 /* telegraph.caf in Resources */, - 06D69E322C1159E200161A35 /* healthnotification.caf in Resources */, - 06D69E332C1159E200161A35 /* typewriters.caf in Resources */, - 06D69E342C1159E200161A35 /* bell.caf in Resources */, - 06D69E352C1159E200161A35 /* bloom.caf in Resources */, - 06D69E362C1159E200161A35 /* spell.caf in Resources */, - 06D69E372C1159E200161A35 /* choo.caf in Resources */, - 06D69E382C1159E200161A35 /* multiwayinvitation.caf in Resources */, - 06D69E392C1159E200161A35 /* horn.caf in Resources */, - 06D69E3A2C1159E200161A35 /* electronic.caf in Resources */, - 06D69E3B2C1159E200161A35 /* calypso.caf in Resources */, - 06D69E3C2C1159E200161A35 /* gotosleep.caf in Resources */, - 06D69E3D2C1159E200161A35 /* silence.caf in Resources */, - 06D69E3E2C1159E200161A35 /* alarm.caf in Resources */, - 06D69E3F2C1159E200161A35 /* fanfare.caf in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1175,6 +1123,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 3DBD0746306B95A100A86B41 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -1377,24 +1332,35 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ - 0632CE1A20EC9098003FDF46 /* Sources */ = { + 00000000000000000000003C /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 19BE8EBF2D03514A009BF080 /* ImageDownloader.swift in Sources */, - 067AFB1C2E5D8BE300AE78E7 /* UNNotificationContent+Extension.swift in Sources */, - 0687F2AA2CCB7FA500B2A52F /* UIFont+Extension.swift in Sources */, - 0632CE2320EC9098003FDF46 /* NotificationViewController.swift in Sources */, - 069332222E6A8E3100F9387F /* String+Extension.swift in Sources */, - 06BCAE572CDB19420092867A /* GroupMuteSettingManager.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; - 0661A53B204FDA4100965E4E /* Sources */ = { + 000000000000000000000046 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 062218542F741C91003E2DA0 /* MessageImageCleaner.swift in Sources */, + 000000000000000000000029 /* NotificationTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 0632CE1A20EC9098003FDF46 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 067AFB1C2E5D8BE300AE78E7 /* UNNotificationContent+Extension.swift in Sources */, + 0632CE2320EC9098003FDF46 /* NotificationViewController.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 0661A53B204FDA4100965E4E /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 062218542F741C91003E2DA0 /* MessageImageCleaner.swift in Sources */, 062218562F741C91003E2DA0 /* MessageDeletionCoordinator.swift in Sources */, 0635A8072CE4883A0027E00F /* DonateCell.swift in Sources */, 06840DBB272298FB001B3193 /* BKColor.swift in Sources */, @@ -1457,7 +1423,6 @@ 06885EB6247FB9880004A303 /* MessageSettingsViewController.swift in Sources */, 0635A8052CE47DFE0027E00F /* SettingSectionHeader.swift in Sources */, 06F11E7727D9D5FB00F00298 /* QRScannerViewController.swift in Sources */, - 06C10A042FB20001000000D1 /* PushQRCodeViewController.swift in Sources */, 0608F06E2994D115006B8029 /* BKDropDownCell.swift in Sources */, 06C5952D2480E3F8006B98F3 /* LabelCell.swift in Sources */, 06787C392A710568008ABDD7 /* GesturePassTextView.swift in Sources */, @@ -1473,7 +1438,6 @@ 06A100112FEE0001000000A1 /* HomePermissionCard.swift in Sources */, 06A100132FEE0001000000A1 /* HomeExampleCard.swift in Sources */, 06A100192FEE0001000000A1 /* CodeHighlighter.swift in Sources */, - 06C10A022FB20001000000D1 /* QRCodeGenerator.swift in Sources */, 06A100152FEE0001000000A1 /* HomeSettingsCard.swift in Sources */, 06A100172FEE0001000000A1 /* HomeDocumentsCard.swift in Sources */, 061E35862D1E5028009A2D6F /* MessageItemModel.swift in Sources */, @@ -1512,7 +1476,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 066B10032F73F00300C4680E /* WidgetHistoryModels.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1520,35 +1483,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 066B10022F73F00200C4680E /* WidgetHistoryModels.swift in Sources */, - 06697FF92F71ED9600C4680E /* WidgetHistorySnapshotStore.swift in Sources */, - 063AE3F82F7511AF00048718 /* WidgetHistorySnapshotSupport.swift in Sources */, - 069C6CA92ED03F07007244BB /* BKColor.swift in Sources */, - 06E944752C07012E00AC86AB /* RealmConfiguration.swift in Sources */, - 06E944732C06FF9200AC86AB /* ArchiveProcessor.swift in Sources */, - 06E9447A2C0704E500AC86AB /* ImageDownloader.swift in Sources */, - 06CF784C21C7A51200A052D7 /* NotificationService.swift in Sources */, - 06FB04052C53575400F3A213 /* SharedDefines.swift in Sources */, - 069C6CA42ED03C72007244BB /* MarkdownProcessor.swift in Sources */, 067AFB1D2E5D8BED00AE78E7 /* UNNotificationContent+Extension.swift in Sources */, - 0653677629B719BC0038BDB8 /* CryptoSettingManager.swift in Sources */, - 06E9446F2C06FF1E00AC86AB /* BadgeProcessor.swift in Sources */, - 069C6CA52ED03E10007244BB /* MarkdownParser.swift in Sources */, - 06BCAE582CDB19420092867A /* GroupMuteSettingManager.swift in Sources */, - 06BCAE5B2CDB25120092867A /* MuteProcessor.swift in Sources */, - 06E9446D2C06FEC900AC86AB /* LevelProcessor.swift in Sources */, - 06E944682C06E40600AC86AB /* NotificationContentProcessor.swift in Sources */, - 06F08EA529B1DDA7006AB9CA /* Algorithm.swift in Sources */, - 06E944782C0701F300AC86AB /* IconProcessor.swift in Sources */, - 06BBB89125650CCF0076F63E /* ArchiveSettingManager.swift in Sources */, - 069332232E6A8E3100F9387F /* String+Extension.swift in Sources */, - 06D69E412C11983E00161A35 /* CallProcessor.swift in Sources */, - 06B11591247BC132006D91FB /* Message.swift in Sources */, - 06E9447C2C07052F00AC86AB /* ImageProcessor.swift in Sources */, - 06E9446A2C06E4A200AC86AB /* CiphertextProcessor.swift in Sources */, - 06E944712C06FF4C00AC86AB /* AutoCopyProcessor.swift in Sources */, - 0687F2A92CCB791A00B2A52F /* UIFont+Extension.swift in Sources */, - 06F08EA829B1DE0A006AB9CA /* Error+Extension.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1556,14 +1491,32 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 068A4B962D2E11CD00982449 /* MessageDeleteTimeRangeTest.swift in Sources */, - 06EE1FD326843E9300586708 /* BarkTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3DBD0744306B95A100A86B41 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A10000000000000000000012 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A10000000000000000000006 /* WatchNotificationService.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 +1537,16 @@ target = 0661A53E204FDA4100965E4E /* Bark */; targetProxy = 06EE1FD526843E9300586708 /* PBXContainerItemProxy */; }; + 3DBD0759306B95A300A86B41 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 3DBD0747306B95A100A86B41 /* BarkWatchComplicationExtension */; + targetProxy = 3DBD0758306B95A300A86B41 /* PBXContainerItemProxy */; + }; + A10000000000000000000009 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = A10000000000000000000010 /* BarkWatchNotificationService */; + targetProxy = A10000000000000000000011 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -1595,27 +1558,161 @@ name = MainInterface.storyboard; sourceTree = ""; }; - 0661A54B204FDA4100965E4E /* LaunchScreen.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 0661A54C204FDA4100965E4E /* Base */, - ); - name = LaunchScreen.storyboard; - sourceTree = ""; - }; /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ + 000000000000000000000064 /* Debug */ = { + isa = XCBuildConfiguration; + 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_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 8HVQYZSB2F; + 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 = com.sebersta.BarkWatchPrototype; + PRODUCT_NAME = "$(TARGET_NAME)"; + 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; + 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_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 8HVQYZSB2F; + 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 = com.sebersta.BarkWatchPrototype; + PRODUCT_NAME = "$(TARGET_NAME)"; + 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; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 8HVQYZSB2F; + 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 = com.sebersta.BarkWatchPrototypeTests; + 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; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 8HVQYZSB2F; + 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 = com.sebersta.BarkWatchPrototypeTests; + 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 */; buildSettings = { CLANG_ENABLE_OBJC_WEAK = YES; - CODE_SIGN_ENTITLEMENTS = NotificationContentExtension/NotificationContentExtension.entitlements; + CODE_SIGN_ENTITLEMENTS = notificationContentExtension/NotificationContentExtension.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; - INFOPLIST_FILE = NotificationContentExtension/Info.plist; + INFOPLIST_FILE = notificationContentExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -1637,11 +1734,11 @@ baseConfigurationReference = 4A9C3B74281C8607B364CED7 /* Pods-NotificationContentExtension.release.xcconfig */; buildSettings = { CLANG_ENABLE_OBJC_WEAK = YES; - CODE_SIGN_ENTITLEMENTS = NotificationContentExtension/NotificationContentExtension.entitlements; + CODE_SIGN_ENTITLEMENTS = notificationContentExtension/NotificationContentExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; - INFOPLIST_FILE = NotificationContentExtension/Info.plist; + INFOPLIST_FILE = notificationContentExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -1784,10 +1881,10 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = Bark/Bark.entitlements; - CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; - "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 5U8LBRXG3A; + DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = Bark/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( @@ -1797,8 +1894,7 @@ MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = me.fin.bark; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "match Development me.fin.bark"; - "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match Development me.fin.bark"; + PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SWIFT_VERSION = 5.0; @@ -2037,9 +2133,271 @@ }; name = Release; }; + 3DBD075D306B95A300A86B41 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_ENTITLEMENTS = BarkWatch/BarkWatchComplicationExtension.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = 8HVQYZSB2F; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = BarkWatch/BarkWatchComplication/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = BarkWatchComplication; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + "@executable_path/../../../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 0.1; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.sebersta.BarkWatchPrototype.BarkWatchComplication; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SDKROOT = watchos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Debug; + }; + 3DBD075E306B95A300A86B41 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_ENTITLEMENTS = BarkWatch/BarkWatchComplicationExtension.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = 8HVQYZSB2F; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = BarkWatch/BarkWatchComplication/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = BarkWatchComplication; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + "@executable_path/../../../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 0.1; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.sebersta.BarkWatchPrototype.BarkWatchComplication; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SDKROOT = watchos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 4; + VALIDATE_PRODUCT = YES; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Release; + }; + A10000000000000000000013 /* Debug */ = { + isa = XCBuildConfiguration; + 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_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 8HVQYZSB2F; + 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 = com.sebersta.BarkWatchPrototype.NotificationService; + PRODUCT_NAME = "$(TARGET_NAME)"; + 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; + 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_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 8HVQYZSB2F; + 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 = com.sebersta.BarkWatchPrototype.NotificationService; + PRODUCT_NAME = "$(TARGET_NAME)"; + 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 +2452,24 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 3DBD075C306B95A300A86B41 /* Build configuration list for PBXNativeTarget "BarkWatchComplicationExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 3DBD075D306B95A300A86B41 /* Debug */, + 3DBD075E306B95A300A86B41 /* Release */, + ); + 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 */ @@ -2101,8 +2477,16 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/swiftlang/swift-markdown"; requirement = { - kind = upToNextMajorVersion; - minimumVersion = 0.7.3; + kind = exactVersion; + version = 0.7.3; + }; + }; + B20000000000000000000020 /* XCRemoteSwiftPackageReference "CryptoSwift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/krzyzanowskim/CryptoSwift.git"; + requirement = { + kind = exactVersion; + version = 1.8.4; }; }; /* End XCRemoteSwiftPackageReference section */ @@ -2118,6 +2502,26 @@ package = 06C777D12ECF05990032A044 /* XCRemoteSwiftPackageReference "swift-markdown" */; productName = Markdown; }; + B20000000000000000000021 /* CryptoSwift */ = { + isa = XCSwiftPackageProductDependency; + package = B20000000000000000000020 /* XCRemoteSwiftPackageReference "CryptoSwift" */; + productName = CryptoSwift; + }; + B20000000000000000000022 /* CryptoSwift */ = { + isa = XCSwiftPackageProductDependency; + package = B20000000000000000000020 /* XCRemoteSwiftPackageReference "CryptoSwift" */; + productName = CryptoSwift; + }; + 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..baf728b5 --- /dev/null +++ b/Bark.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,33 @@ +{ + "originHash" : "5d07a6647be4084549bffaef298591fe3fcedaac7b1cc22bbe36a80b31c29756", + "pins" : [ + { + "identity" : "cryptoswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/krzyzanowskim/CryptoSwift.git", + "state" : { + "revision" : "729e01bc9b9dab466ac85f21fb9ee2bc1c61b258", + "version" : "1.8.4" + } + }, + { + "identity" : "swift-cmark", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-cmark.git", + "state" : { + "revision" : "08ddb528923cc1a6527e02b7a1aee9e516ca749a", + "version" : "0.9.0" + } + }, + { + "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/BarkWatchComplicationExtension.xcscheme b/Bark.xcodeproj/xcshareddata/xcschemes/BarkWatchComplicationExtension.xcscheme new file mode 100644 index 00000000..9a0c5413 --- /dev/null +++ b/Bark.xcodeproj/xcshareddata/xcschemes/BarkWatchComplicationExtension.xcscheme @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Bark.xcodeproj/xcshareddata/xcschemes/BarkWatchNotificationService.xcscheme b/Bark.xcodeproj/xcshareddata/xcschemes/BarkWatchNotificationService.xcscheme new file mode 100644 index 00000000..a7a8418c --- /dev/null +++ b/Bark.xcodeproj/xcshareddata/xcschemes/BarkWatchNotificationService.xcscheme @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Bark.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bark.xcworkspace/xcshareddata/swiftpm/Package.resolved index 6bd7bbc8..baf728b5 100644 --- a/Bark.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bark.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,13 +1,22 @@ { - "originHash" : "bb354f5ee26f97a80bea96da70628d71c97b25e01e4e845afe4f5b1d0b6cf6e2", + "originHash" : "5d07a6647be4084549bffaef298591fe3fcedaac7b1cc22bbe36a80b31c29756", "pins" : [ + { + "identity" : "cryptoswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/krzyzanowskim/CryptoSwift.git", + "state" : { + "revision" : "729e01bc9b9dab466ac85f21fb9ee2bc1c61b258", + "version" : "1.8.4" + } + }, { "identity" : "swift-cmark", "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-cmark.git", "state" : { - "revision" : "5d9bdaa4228b381639fff09403e39a04926e2dbe", - "version" : "0.7.1" + "revision" : "08ddb528923cc1a6527e02b7a1aee9e516ca749a", + "version" : "0.9.0" } }, { 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..f84dc73f --- /dev/null +++ b/BarkWatch/App/WatchMessage.swift @@ -0,0 +1,262 @@ +// +// WatchMessage.swift +// Bark +// +// Created by Jian Qin on 9/30/26. +// + + +import Observation +import os +import UserNotifications +import WatchKit +import WidgetKit + +struct WatchMessage: Equatable, Sendable { + let title: String + let subtitle: String + let body: String + let markdown: String? + let group: String + let level: String + 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 = content.interruptionLevel.displayName + 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: BarkServers + 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 (encrypted, `isArchive=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: BarkServers = BarkServers(), archive: WatchArchive = .shared) { + self.servers = servers + lastMessage = (try? archive.latest(group: nil)).flatMap(WatchMessage.init(archived:)) + } + + /// Shows the archived message a complication was displaying, falling back to + /// the newest one if it has since expired. Returns whether a message is shown. + func showArchivedMessage(id: String?, archive: WatchArchive = .shared) -> Bool { + let archived = id.flatMap { try? archive.message(id: $0) } ?? (try? archive.latest(group: nil)) + if let archived { lastMessage = WatchMessage(archived: archived) } + return lastMessage != nil + } + + 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 + #if DEBUG + print("Bark watch link: received message, requested link: \(requestedNotificationURL?.absoluteString ?? "none")") + #endif + if let requestedNotificationURL { + pendingBrowserURL = requestedNotificationURL + self.requestedNotificationURL = nil + } + } + + func refreshAndRegister() async { + let settings = await UNUserNotificationCenter.current().notificationSettings() + authorizationStatus = settings.authorizationStatus + criticalAlertSetting = settings.criticalAlertSetting + #if DEBUG + print("Bark watch critical alerts: \(settings.criticalAlertSetting == .enabled ? "enabled" : settings.criticalAlertSetting == .disabled ? "disabled" : "not supported by this signed app")") + #endif + guard hasNotificationPermission else { + registrationState = .idle + return + } + switch registrationState { + case .idle, .failed: + registrationState = .registering + WKApplication.shared().registerForRemoteNotifications() + case .registered(let deviceToken): + await servers.registerAll(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: "com.sebersta.BarkWatchPrototype", 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.registerAll(deviceToken: tokenString) } + #if DEBUG + // Deliberately copyable in Xcode for manual push testing; never logged in release builds. + print("Bark watch device token: \(tokenString)") + print("Bark watch push topic: \(Bundle.main.bundleIdentifier ?? "unknown")") + #endif + } + + 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 { + if try archive.remove(id: identifier) { + WidgetCenter.shared.reloadTimelines(ofKind: WatchArchive.widgetKind) + } + 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 0000000000000000000000000000000000000000..d7f5201f193efd3e69e217ed5e2acf2703a951da GIT binary patch literal 17404 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 literal 0 HcmV?d00001 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..e8e12995 --- /dev/null +++ b/BarkWatch/BarkWatch.entitlements @@ -0,0 +1,20 @@ + + + + + aps-environment + development + com.apple.developer.usernotifications.communication + + com.apple.developer.usernotifications.time-sensitive + + com.apple.security.application-groups + + group.com.sebersta.BarkWatchPrototype + + keychain-access-groups + + $(AppIdentifierPrefix)com.sebersta.BarkWatchPrototype.encryption + + + diff --git a/BarkWatch/BarkWatchComplication/AppIntent.swift b/BarkWatch/BarkWatchComplication/AppIntent.swift new file mode 100644 index 00000000..a04fbb87 --- /dev/null +++ b/BarkWatch/BarkWatchComplication/AppIntent.swift @@ -0,0 +1,31 @@ +import AppIntents + +struct ArchiveGroup: AppEntity { + static var typeDisplayRepresentation: TypeDisplayRepresentation { "Notification group" } + static var defaultQuery: ArchiveGroupQuery { ArchiveGroupQuery() } + let id: String + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation(title: "\(name)") + } + var name: String { id == "all" ? "All groups" : id == "group:" ? "Ungrouped" : String(id.dropFirst(6)) } + var filter: String? { id == "all" ? nil : String(id.dropFirst(6)) } + static let all = ArchiveGroup(id: "all") +} + +struct ArchiveGroupQuery: EntityQuery { + func entities(for identifiers: [String]) async throws -> [ArchiveGroup] { + identifiers.filter { $0 == "all" || $0.hasPrefix("group:") }.map { ArchiveGroup(id: $0) } + } + func suggestedEntities() async throws -> [ArchiveGroup] { + let groups = Set(try WatchArchive.shared.messages().map(\.group)).sorted() + return [.all] + groups.map { ArchiveGroup(id: "group:" + $0) } + } + func defaultResult() async -> ArchiveGroup? { .all } +} + +struct ConfigurationAppIntent: WidgetConfigurationIntent { + static var title: LocalizedStringResource { "Latest notification" } + static var description: IntentDescription { "Show the newest archived notification from a group." } + + @Parameter(title: "Group") var group: ArchiveGroup? +} diff --git a/BarkWatch/BarkWatchComplication/Assets.xcassets/AccentColor.colorset/Contents.json b/BarkWatch/BarkWatchComplication/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/BarkWatch/BarkWatchComplication/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/BarkWatch/BarkWatchComplication/Assets.xcassets/AppIcon.appiconset/Contents.json b/BarkWatch/BarkWatchComplication/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..49c81cd8 --- /dev/null +++ b/BarkWatch/BarkWatchComplication/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "watchos", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/BarkWatch/BarkWatchComplication/Assets.xcassets/Contents.json b/BarkWatch/BarkWatchComplication/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/BarkWatch/BarkWatchComplication/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/BarkWatch/BarkWatchComplication/Assets.xcassets/WidgetBackground.colorset/Contents.json b/BarkWatch/BarkWatchComplication/Assets.xcassets/WidgetBackground.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/BarkWatch/BarkWatchComplication/Assets.xcassets/WidgetBackground.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/BarkWatch/BarkWatchComplication/BarkWatchComplication.swift b/BarkWatch/BarkWatchComplication/BarkWatchComplication.swift new file mode 100644 index 00000000..6746e1c3 --- /dev/null +++ b/BarkWatch/BarkWatchComplication/BarkWatchComplication.swift @@ -0,0 +1,50 @@ +import WidgetKit +import SwiftUI + + +struct Provider: AppIntentTimelineProvider { + func placeholder(in context: Context) -> ArchiveEntry { .preview } + + func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> ArchiveEntry { + context.isPreview ? .preview : entry(for: configuration) + } + + func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline { + let current = entry(for: configuration) + let refresh = min(current.expires ?? .distantFuture, current.date.addingTimeInterval(15 * 60)) + return Timeline(entries: [current], policy: .after(refresh)) + } + + func recommendations() -> [AppIntentRecommendation] { + [AppIntentRecommendation(intent: ConfigurationAppIntent(), description: "Latest notification")] + } + + private func entry(for configuration: ConfigurationAppIntent) -> ArchiveEntry { + let date = Date() + do { + let message = try WatchArchive.shared.latest(group: configuration.group?.filter, at: date) + return ArchiveEntry(date: date, title: message?.title ?? "Bark", + body: message?.body ?? "No archived notifications", expires: message?.expires, + messageID: message?.id, level: message?.level) + } catch { + return ArchiveEntry(date: date, title: "Bark", body: "Archive unavailable", expires: nil, messageID: nil, + level: nil) + } + } +} + + +struct BarkWatchComplication: Widget { + let kind = WatchArchive.widgetKind + + var body: some WidgetConfiguration { + AppIntentConfiguration(kind: kind, intent: ConfigurationAppIntent.self, provider: Provider()) { entry in + BarkWatchComplicationEntryView(entry: entry) + .widgetURL(WatchArchive.messageURL(id: entry.messageID)) + } + .configurationDisplayName("Latest notification") + .description("The newest archived notification in your selected group.") + .supportedFamilies([.accessoryRectangular]) + } +} + diff --git a/BarkWatch/BarkWatchComplication/BarkWatchComplicationBundle.swift b/BarkWatch/BarkWatchComplication/BarkWatchComplicationBundle.swift new file mode 100644 index 00000000..08a3ab22 --- /dev/null +++ b/BarkWatch/BarkWatchComplication/BarkWatchComplicationBundle.swift @@ -0,0 +1,16 @@ +// +// BarkWatchComplicationBundle.swift +// BarkWatchComplication +// +// Created by Jian Qin on 9/28/26. +// + +import WidgetKit +import SwiftUI + +@main +struct BarkWatchComplicationBundle: WidgetBundle { + var body: some Widget { + BarkWatchComplication() + } +} diff --git a/BarkWatch/BarkWatchComplication/ComplicationView.swift b/BarkWatch/BarkWatchComplication/ComplicationView.swift new file mode 100644 index 00000000..df4087ff --- /dev/null +++ b/BarkWatch/BarkWatchComplication/ComplicationView.swift @@ -0,0 +1,60 @@ +import SwiftUI +import WidgetKit + +struct BarkWatchComplicationEntryView: View { + let entry: ArchiveEntry + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // When one of title/body is empty, the other gets all 3 lines. + if !entry.title.isEmpty { + Text(verbatim: entry.title) + .lineLimit(entry.body.isEmpty ? 3 : 1, reservesSpace: true) + .font(.body.weight(.semibold)) + } + if !entry.body.isEmpty { + Text(verbatim: entry.body) + .lineLimit(entry.title.isEmpty ? 3 : 2, reservesSpace: true) + .font(.callout) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .privacySensitive() + .containerBackground(entry.backgroundColor.gradient, for: .widget) + } +} + +struct ArchiveEntry: TimelineEntry { + let date: Date + let title: String + let body: String + let expires: Date? + let messageID: String? + let level: String? + + /// Background follows the interruption level, matching the tint of `LastMessageView`. + var backgroundColor: Color { + switch level { + case "Critical": .red + case "Time sensitive": .orange + default: .black + } + } + + static let preview = ArchiveEntry( + date: .now, + title: "Build finished", + body: "All checks passed. Ready for testing on your watch.", + expires: nil, + messageID: nil, + level: "Critical" + ) +} + + +#Preview(as: .accessoryRectangular) { + BarkWatchComplication() +} timeline: { + ArchiveEntry.preview +} diff --git a/BarkWatch/BarkWatchComplication/Info.plist b/BarkWatch/BarkWatchComplication/Info.plist new file mode 100644 index 00000000..0f118fb7 --- /dev/null +++ b/BarkWatch/BarkWatchComplication/Info.plist @@ -0,0 +1,11 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/BarkWatch/BarkWatchComplicationExtension.entitlements b/BarkWatch/BarkWatchComplicationExtension.entitlements new file mode 100644 index 00000000..b60b3ecb --- /dev/null +++ b/BarkWatch/BarkWatchComplicationExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.sebersta.BarkWatchPrototype + + + diff --git a/BarkWatch/BarkWatchCritical.entitlements b/BarkWatch/BarkWatchCritical.entitlements new file mode 100644 index 00000000..a736da53 --- /dev/null +++ b/BarkWatch/BarkWatchCritical.entitlements @@ -0,0 +1,16 @@ + + + + + keychain-access-groups + $(AppIdentifierPrefix)com.sebersta.BarkWatchPrototype.encryption + com.apple.developer.usernotifications.communication + + aps-environment + development + com.apple.developer.usernotifications.time-sensitive + + com.apple.developer.usernotifications.critical-alerts + + + diff --git a/BarkWatch/Common/PhoneCompatibility.swift b/BarkWatch/Common/PhoneCompatibility.swift new file mode 100644 index 00000000..7b39142e --- /dev/null +++ b/BarkWatch/Common/PhoneCompatibility.swift @@ -0,0 +1,33 @@ +import Foundation +import UIKit + +// Dependencies expected by the unchanged phone sources, scoped to watch targets. +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 + } +} + +extension String: @retroactive Error {} + +extension String { + var localized: String { + NSLocalizedString(self, value: self == "image" ? "Image" : self, comment: "") + } +} + +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..a681922a --- /dev/null +++ b/BarkWatch/Common/WatchArchive.swift @@ -0,0 +1,134 @@ +import Foundation +import WidgetKit +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: String? + 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. + 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", + info["ciphertext"] == nil 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 = content.interruptionLevel.displayName + url = text("url") + image = text("image") + usedCriticalAlertFallback = info[Self.criticalAlertFallbackKey] as? Bool + } + + // Defined here rather than in WatchNotification because the complication + // target compiles this file without it. + static let criticalAlertFallbackKey = "bark_watch_critical_fallback" +} + +nonisolated extension UNNotificationInterruptionLevel { + var displayName: String { + switch self { + case .passive: "Passive" + case .timeSensitive: "Time sensitive" + case .critical: "Critical" + default: "Active" + } + } +} + +nonisolated struct WatchArchive: Sendable { + static let widgetKind = "BarkWatchComplication" + /// Opened by tapping the complication; `id` names the archived message it shows. + static let messageURLHost = "message" + + static func messageURL(id: String?) -> URL { + var components = URLComponents() + components.scheme = "bark-watch" + components.host = messageURLHost + components.queryItems = id.map { [URLQueryItem(name: "id", value: $0)] } + return components.url ?? URL(string: "bark-watch://message")! + } + static let shared = WatchArchive(directory: FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: "group.com.sebersta.BarkWatchPrototype")) + 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 } + // ponytail: retain 100 recent notifications, matching the phone widget snapshot. + 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 } + } + + func message(id: String, at date: Date = .now) throws -> WatchArchivedMessage? { + try messages(at: date).first { $0.id == id } + } +} diff --git a/BarkWatch/Common/WatchEncryption.swift b/BarkWatch/Common/WatchEncryption.swift new file mode 100644 index 00000000..a2dae539 --- /dev/null +++ b/BarkWatch/Common/WatchEncryption.swift @@ -0,0 +1,185 @@ +// +// WatchEncryption.swift — watch storage and notification presentation +// Bark +// +// Created by huangfeng on 2023/2/23. +// Copyright © 2023 Fin. All rights reserved. +// + +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 + } + + 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: fields.mode == "GCM" ? 12 : fields.mode == "CBC" ? 16 : 0) + 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. +extension AESCryptoModel { + init(watchFields fields: CryptoSettingFields) throws { + try WatchEncryption.validate(fields) + let key = fields.key! + let vectorLength = fields.mode == "CBC" ? 16 : fields.mode == "GCM" ? 12 : 0 + 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..19a1ced5 --- /dev/null +++ b/BarkWatch/Common/WatchMarkdownParser.swift @@ -0,0 +1,58 @@ +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. +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. + 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..c2f7b141 --- /dev/null +++ b/BarkWatch/Common/WatchNotification.swift @@ -0,0 +1,99 @@ +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)) + } + + static func prepare(_ content: UNMutableNotificationContent, criticalAlertsEnabled: Bool = false) { + let userInfo = content.userInfo + if let markdown = stringValue("markdown", in: userInfo), !markdown.isEmpty { + content.body = markdownSummary(markdown) + content.categoryIdentifier = markdownCategoryIdentifier + var aps = userInfo["aps"] as? [String: Any] ?? [:] + var alert = aps["alert"] as? [String: Any] ?? [:] + alert["body"] = content.body + aps["alert"] = alert + content.userInfo["aps"] = aps + } + if let group = stringValue("group", in: userInfo) { content.threadIdentifier = group } + 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 + } + 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/Info.plist b/BarkWatch/Info.plist new file mode 100644 index 00000000..e67809be --- /dev/null +++ b/BarkWatch/Info.plist @@ -0,0 +1,21 @@ + + + + + UIBackgroundModes + + remote-notification + + BarkKeychainAccessGroup + $(AppIdentifierPrefix)com.sebersta.BarkWatchPrototype.encryption + 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/BarkServer.swift b/BarkWatch/Model/BarkServer.swift new file mode 100644 index 00000000..98e56e26 --- /dev/null +++ b/BarkWatch/Model/BarkServer.swift @@ -0,0 +1,218 @@ +// +// BarkServer.swift +// Bark +// +// Created by Jian Qin on 9/30/26. +// + + +import Foundation +import Observation + +struct BarkServer: Codable, Identifiable, Equatable { + let id: UUID + let address: URL + var deviceKey = "" + + 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, address + case deviceKey = "key" + } + + var pushURL: URL { address.appendingPathComponent(deviceKey) } + + func makeRegistrationRequest(deviceToken: String) throws -> URLRequest { + guard var components = URLComponents(url: address.appendingPathComponent("register"), resolvingAgainstBaseURL: false) else { + throw BarkServerError("Invalid server address.") + } + components.queryItems = [URLQueryItem(name: "devicetoken", value: deviceToken), URLQueryItem(name: "key", value: deviceKey)] + // A literal plus is interpreted as a space by form-style query parsers. + components.percentEncodedQuery = components.percentEncodedQuery?.replacingOccurrences(of: "+", with: "%2B") + guard let registrationURL = components.url else { throw BarkServerError("Invalid registration address.") } + return URLRequest(url: registrationURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 20) + } + + func makeTestPushRequest(sound: String = "default", critical: Bool = false, volume: Int = 5, encryption: CryptoSettingFields? = nil) throws -> URLRequest { + var request = URLRequest(url: address.appendingPathComponent("push"), timeoutInterval: 20) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + // 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://github.com/sebersta", + "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"] = deviceKey + // 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" + request.httpBody = try JSONEncoder().encode(payload) + return request + } +} + +struct BarkServerError: LocalizedError { + let errorDescription: String? + init(_ message: String) { errorDescription = message } +} + +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 BarkServers { + private(set) var servers: [BarkServer] = [] + 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([BarkServer].self, from: data) } + catch { storageError = "Saved servers could not be loaded: \(error.localizedDescription)" } + } + } + + @discardableResult + func add(address: String) throws -> UUID { + if let storageError { throw BarkServerError(storageError) } + let server = try BarkServer(address: address) + guard !servers.contains(where: { $0.address == server.address }) else { + throw BarkServerError("This server is already saved.") + } + servers.append(server) + save() + return server.id + } + + func remove(serverID: UUID) { + servers.removeAll { $0.id == serverID } + statusMessages[serverID] = nil + errorMessages[serverID] = nil + save() + } + + func registerAll(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 (data, response) = try await sendRequest(server.makeRegistrationRequest(deviceToken: tokenToRegister)) + 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 let index = servers.firstIndex(where: { $0.id == serverID }) else { return } + servers[index].deviceKey = registeredKey + save() + statusMessages[serverID] = "Key registered" + #if DEBUG + print("Bark watch server: \(server.address.absoluteString)") + print("Bark watch device key: \(registeredKey)") + #endif + } 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.deviceKey.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 save() { + 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..2213b951 --- /dev/null +++ b/BarkWatch/NotificationService/Info.plist @@ -0,0 +1,24 @@ + + + + + BarkKeychainAccessGroup + $(AppIdentifierPrefix)com.sebersta.BarkWatchPrototype.encryption + 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..c9a1ef38 --- /dev/null +++ b/BarkWatch/NotificationService/NotificationService.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.application-groups + + group.com.sebersta.BarkWatchPrototype + + keychain-access-groups + + $(AppIdentifierPrefix)com.sebersta.BarkWatchPrototype.encryption + + + diff --git a/BarkWatch/README.md b/BarkWatch/README.md new file mode 100644 index 00000000..4df1bbac --- /dev/null +++ b/BarkWatch/README.md @@ -0,0 +1,29 @@ +# BarkWatch + +A standalone watch application prototype for watchOS 10 or later. Communication with the phone app is not yet implemented. BarkWatch shares the unchanged phone sources `Model/Algorithm.swift`, `Common/MarkdownParser.swift`, and `Common/UIFont+Extension.swift`. Watch-specific adaptations live in `BarkWatch/Common`. + +**BarkWatch cannot use the official Bark server because its push configuration targets the official phone app.** Use a custom Bark server with your Apple signing credentials, the watch's bundle identifier (`com.sebersta.BarkWatchPrototype` in this checkout), and the matching development or production push environment. See [ServerSetup.md](ServerSetup.md) for server modification, configuration, and launch instructions. + +## Request Parameters +| Parameter | Watch app | +| --- | --- | +| `title`, `subtitle`, `body` | Supported | +| `markdown` | Supported | +| `ciphertext`, `iv` | Same encryption model; configure the watch's own key. | +| `level=active`, `passive`, `timeSensitive` | Supported | +| `level=critical`, `volume` | Implemented, not tested. | +| `sound` | Custom sound unavailable. `silent` supported. | +| `image`, `icon` | Supported | +| `url` | Explicit Open Link button. | +| `group` | Thread metadata and complication filtering; no group muting. | +| `id` | Supported | +| `isArchive`, `ttl` | Supported | +| `badge`, `call` | Unavailable | +| `autocopy`, `automaticallycopy`, `copy` | Unavailable | +| `action` | Unavailable | +| `delete` | Supported | + +Opening web links requires an extra handoff on the watch. The notification interface cannot present the browser directly: tapping **Open link** or a Markdown link passes the selected address and launches the app. BarkWatch waits until it is active before presenting the page. BarkWatch uses `WebAuthenticationSession` to display web pages. The app requests an ephemeral session so cookies are not shared with other sessions. Only links using `http` or `https` are accepted; application links and addresses containing embedded login credentials are rejected. + +## Widget +BarkWatch support a rectangular widget that displays the latest notification. The background color of the complication changes depends on notification `level`. diff --git a/BarkWatch/ServerSetup.md b/BarkWatch/ServerSetup.md new file mode 100644 index 00000000..cca455c5 --- /dev/null +++ b/BarkWatch/ServerSetup.md @@ -0,0 +1,75 @@ +# BarkWatch server setup + +With Git and Go 1.26 or later installed, run the supplied [build script](scripts/build-watch-server.sh) from the Bark repository root. It downloads a pinned upstream server revision, applies [watch-server.patch](scripts/watch-server.patch), and builds `build/watch-server/bark-server`. The generated source, executable, and server data stay under the ignored `build/` directory. + +```sh +./BarkWatch/scripts/build-watch-server.sh +``` + +Obtain an Apple push authentication key (`.p8`), its key identifier, and your team identifier from the developer account used to sign BarkWatch. The key must authorize the watch application and the chosen push environment. Replace the example key path and identifiers below with your own values, then run these commands from the Bark repository root: + +```sh +export BARK_APNS_KEY_FILE='/absolute/path/to/AuthKey_YOUR_KEY_ID.p8' +export BARK_APNS_KEY_ID='YOUR_KEY_ID' +export BARK_APNS_TEAM_ID='YOUR_TEAM_ID' +export BARK_APNS_TOPIC='com.sebersta.BarkWatchPrototype' +export BARK_APNS_ENVIRONMENT='development' +./build/watch-server/bark-server --addr 0.0.0.0:8080 --data ./build/watch-server/data +``` + +`BARK_APNS_TOPIC` must match the **BarkWatch application** bundle identifier in Xcode. If you changed it, update the value above. The key identifier belongs to the `.p8` key; the team identifier belongs to the watch's signing account. Use `development` for this checkout's development-signed watch build and `production` for a build signed with the production push entitlement. The environment selects Apple's sandbox or production endpoint; it does not change how the app is signed. + +To make the changes in your own upstream checkout instead, add `"os"` to the imports in `apns/apns.go` and replace the existing constants for `topic`, `keyID`, and `teamID` with: + +```go +const PayloadMaximum = 4096 + +var ( + topic = os.Getenv("BARK_APNS_TOPIC") + keyID = os.Getenv("BARK_APNS_KEY_ID") + teamID = os.Getenv("BARK_APNS_TEAM_ID") +) +``` + +Inside `ReCreateAPNS`, replace the existing `token.AuthKeyFromBytes(...)` declaration with the following block, keeping the existing error check immediately after it: + +```go +if topic == "" || keyID == "" || teamID == "" { + logger.Fatal("BARK_APNS_TOPIC, BARK_APNS_KEY_ID and BARK_APNS_TEAM_ID are required") +} +host := apns2.HostDevelopment +switch os.Getenv("BARK_APNS_ENVIRONMENT") { +case "development": +case "production": + host = apns2.HostProduction +default: + logger.Fatal("BARK_APNS_ENVIRONMENT must be development or production") +} +authKey, err := token.AuthKeyFromFile(os.Getenv("BARK_APNS_KEY_FILE")) +``` + +In the same function, change the `apns2.Client` field from `Host: apns2.HostProduction` to `Host: host`. Its existing `Token` fields already reference `authKey`, `keyID`, and `teamID`. + +In `route_push.go`, find the `case "sound"` branch and change its condition to `if val == "default" || strings.HasSuffix(val, ".caf")`. This preserves the watch's system sound name instead of turning it into `default.caf`. + +For silent deletion, find `Push` in `apns/apns.go`. Add `priority := apns2.PriorityHigh` beside `pushType := apns2.PushTypeAlert`, set `priority = apns2.PriorityLow` inside the existing `if msg.IsDelete()` branch, and add `Priority: priority` to the `apns2.Notification` sent by `client.Push`. The existing deletion branch already sets the background push type and `content-available`, and omits alert text and sound. Keep the existing collapse identifier and custom payload handling. If your Go toolchain rejects `fmt.Errorf(resp.Reason)`, change it to `fmt.Errorf("%s", resp.Reason)`. + +Build your manually modified checkout with `go build -o bark-server .`, export the five `BARK_APNS_...` values shown above, and launch it with `./bark-server --addr 0.0.0.0:8080 --data ./data`. Restart an already running server after rebuilding or changing its configuration. + +On the watch, add `http://YOUR_MAC_LOCAL_ADDRESS:8080`, replacing the address with the Mac's reachable local network address; `0.0.0.0` is only the server's listening address. Wait for registration, send a test notification, and confirm it arrives on the watch. This configuration targets the watch; serving differently signed phone and watch apps together requires separate server instances or routing by app identity. + +The [notification test sender](scripts/send-watch-tests.py) covers text, Markdown, grouping, alert levels, sound, images, icons, links, replacement, archive expiration, and silent deletion. Preview all test expectations without sending anything: + +```sh +python3 BarkWatch/scripts/send-watch-tests.py --dry-run +``` + +To send selected tests, supply your server's base address and the registered key displayed by the watch. `W03` checks Markdown; `W31` automatically includes the alert and first deletion needed to test repeated silent deletion. Requests are 15 seconds apart by default; use `--interval 20` to change the delay. Each run uses fresh identifiers, so replacement and deletion affect only that run's test notifications. Omit `--only` to send every case. + +```sh +python3 BarkWatch/scripts/send-watch-tests.py \ + --server http://YOUR_MAC_LOCAL_ADDRESS:8080 \ + --key YOUR_WATCH_DEVICE_KEY --only W03 W31 +``` + +You can supply `BARK_SERVER_URL` and `BARK_DEVICE_KEY` as environment variables instead. Server acceptance does not confirm delivery or correct rendering; check the notification, latest-message view, and complication on the watch. Run the sender's local automated check with `python3 BarkWatch/scripts/test_send_watch_tests.py`; it uses a temporary local server and never contacts Apple. 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..37f28299 --- /dev/null +++ b/BarkWatch/Tests/NotificationTests.swift @@ -0,0 +1,685 @@ +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.prepare(content) + #expect(content.interruptionLevel == .passive) + #expect(content.sound == nil) + #expect(WatchNotification.presentationOptions(for: content) == [.list]) + content.userInfo = ["level": "critical", "aps": ["sound": "default"]] + WatchNotification.prepare(content) + #expect(content.interruptionLevel == .active) + #expect(WatchMessage(content: content).usedCriticalAlertFallback) + WatchNotification.prepare(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.prepare(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.prepare(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.prepare(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.prepare(content, criticalAlertsEnabled: true) + #expect(content.sound == nil) + #expect(WatchNotification.presentationOptions(for: content) == [.banner, .list]) + } +} + +@MainActor +@Test func notificationStatusReturnsWhenPermissionOrRegistrationIsLost() throws { + let suite = "BarkWatchTests.\(UUID())" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let state = NotificationState(servers: BarkServers(defaults: defaults)) + state.authorizationStatus = .authorized + state.registrationState = .registered("test-token") + #expect(state.needsNotificationSetup) + let id = try state.servers.add(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) + state.servers.remove(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 = BarkServers(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.add(address: address) } + } + let id = try store.add(address: " https://BARK.EXAMPLE/proxy/ ") + #expect(throws: BarkServerError.self) { try store.add(address: "https://bark.example/proxy") } + #expect(try BarkServer(address: "http://192.168.1.2:8080/").address.absoluteString == "http://192.168.1.2:8080") + await store.register(serverID: id) + #expect(store.servers.first?.deviceKey == "") + await store.registerAll(deviceToken: "first-token") + #expect(store.servers.first?.deviceKey == "watch-key") + #expect(store.errorMessages[id] == nil) + #expect(store.busyServerIDs.isEmpty) + await store.registerAll(deviceToken: "rotated-token") + #expect(store.servers.first?.deviceKey == "watch-key") + + var server = try #require(store.servers.first) + let restored = BarkServers(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.deviceKey + ]) + #expect(try JSONDecoder().decode(BarkServer.self, from: legacyData) == server) + let encodedServer = try #require(JSONSerialization.jsonObject(with: JSONEncoder().encode(server)) as? [String: Any]) + #expect(encodedServer["key"] as? String == server.deviceKey) + #expect(encodedServer["deviceKey"] == nil) + server.deviceKey = "a+b&c=d" + let request = try server.makeRegistrationRequest(deviceToken: "token+with&symbols") + 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.deviceKey) + #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.deviceKey) + #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.add(address: "https://\(host)") + await store.register(serverID: failedID) + #expect(store.errorMessages[failedID] != nil) + #expect(store.servers.first(where: { $0.id == failedID })?.deviceKey == "") + #expect(!store.busyServerIDs.contains(failedID)) + store.remove(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) + } + store.remove(serverID: id) + #expect(BarkServers(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 + let store = BarkServers(defaults: defaults) { request in + requestCount += 1 + signalRequestStarted.yield(()) + var iterator = resumeRequest.makeAsyncIterator() + await iterator.next() + return try await barkResponse(for: request) + } + let serverID = try store.add(address: "https://bark.example") + var startedRequests = requestStarted.makeAsyncIterator() + let registration = Task { await store.registerAll(deviceToken: "first-token") } + await startedRequests.next() + // A concurrent token refresh must not send a duplicate request while this one is busy. + await store.registerAll(deviceToken: "rotated-token") + #expect(requestCount == 1) + signalResumeRequest.yield(()) + await startedRequests.next() + signalResumeRequest.yield(()) + await registration.value + #expect(requestCount == 2) + #expect(store.servers.first?.deviceKey == "watch-key") + #expect(store.errorMessages[serverID] == nil) + #expect(store.busyServerIDs.isEmpty) + + let refresh = Task { await store.register(serverID: serverID) } + await startedRequests.next() + store.remove(serverID: serverID) + signalResumeRequest.yield(()) + await refresh.value + #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 BarkServer(address: "https://bark.example") + server.deviceKey = "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.deviceKey) + #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 complicationArchivePersistsFiltersReplacesAndExpires() 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"], ["ciphertext": "encrypted"]] { + 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 lastMessageIsRestoredFromArchiveAndComplicationTap() 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: BarkServers(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: BarkServers(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 == "Time sensitive") + #expect(restored.url == URL(string: "https://day.app")) + #expect(restored.image == nil) + + #expect(state.showArchivedMessage(id: "older", archive: archive)) + #expect(state.lastMessage?.title == "Older") + #expect(state.showArchivedMessage(id: "expired", archive: archive)) + #expect(state.lastMessage?.title == "Newer") + let url = WatchArchive.messageURL(id: "id with spaces&more") + #expect(url.host() == WatchArchive.messageURLHost) + #expect(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?.first?.value == "id with spaces&more") +} + +@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..f0c1d493 --- /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 `NotificationStatusLabel`. +struct AddServerTabView: View { + let servers: BarkServers + 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..d386a342 --- /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: BarkServers + 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.add(address: address) + isAdding = true + Task { + await servers.register(serverID: id) + isAdding = false + + if let registrationError = servers.errorMessages[id] { + error = registrationError + servers.remove(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..b5cefd4e --- /dev/null +++ b/BarkWatch/View/BigButtonView.swift @@ -0,0 +1,111 @@ +// +// 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") + } + } +} + +//#Preview("With message") { +// BigButtonView( +// caption: "Notifications", +// title: "Disabled", +// tint: .red, +// message: Text("Delete the App,\nor redownload it.") +// ) { +// EmptyView() +// } +//} +// +//#Preview("WatchMessage and button") { +// BigButtonView( +// caption: "Servers", +// title: "2 saved", +// message: Text("Servers could not be saved.").font(.footnote).foregroundStyle(.red) +// ) { +// Button("Add") {} +// } +//} + diff --git a/BarkWatch/View/ContentView.swift b/BarkWatch/View/ContentView.swift new file mode 100644 index 00000000..38a2bd68 --- /dev/null +++ b/BarkWatch/View/ContentView.swift @@ -0,0 +1,102 @@ +// +// ContentView.swift +// BarkWatch +// +// Created by Jian Qin on 9/30/26. +// + +import AuthenticationServices +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 { + NotificationStatusLabel(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 + } + // Tapping the complication shows the message it displays. + .onOpenURL { url in + guard url.host() == WatchArchive.messageURLHost else { return } + let id = URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems?.first { $0.name == "id" }?.value + if state.showArchivedMessage(id: id) { selection = .lastMessage } + } + } + + /// 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() { + #if DEBUG + print("Bark watch link: pending \(state.pendingBrowserURL?.absoluteString ?? "none"), scene phase \(scenePhase)") + #endif + guard let url = state.pendingBrowserURL, scenePhase == .active else { return } + state.pendingBrowserURL = nil + selection = .lastMessage + Task { @MainActor in + // Let the launch transition finish before presenting over it. +// try? await Task.sleep(for: .milliseconds(500)) + await webAuthenticationSession.browse(url) + } + } +} + +//#Preview("Enabled") { +// let state = NotificationState() +// state.authorizationStatus = .authorized +// state.registrationState = .registered("preview") +// return ContentView(state: state) +//} +// +//#Preview("Permission needed") { +// let state = NotificationState() +// state.authorizationStatus = .notDetermined +// return ContentView(state: state) +//} +// +//#Preview("Large text") { +// let state = NotificationState() +// state.authorizationStatus = .denied +// return ContentView(state: state).environment(\.dynamicTypeSize, .accessibility3) +//} 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..7307fdcc --- /dev/null +++ b/BarkWatch/View/LastMessageView.swift @@ -0,0 +1,165 @@ +import AuthenticationServices +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 "Time sensitive": .orange + default: .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("Lastest 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, 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 { + #if DEBUG + print("Bark watch link: opening browser session for \(url)") + #endif + 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. + #if DEBUG + print("Bark watch link: browser session ended: \(error)") + #endif + } + } +} + +/// 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 +// content.userInfo = ["url": "https://day.app"] + + 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..708f9627 --- /dev/null +++ b/BarkWatch/View/NotificationMarkdownView.swift @@ -0,0 +1,131 @@ +// +// NotificationMarkdownView.swift +// Bark +// +// Created by Jian Qin on 9/30/26. +// + + +import SwiftUI +import UIKit +import UserNotifications +import WatchKit + +/// Markdown rendering for the notification interface (long look), kept separate from +/// the in-app `WatchMarkdownView` so it can be styled for the notification platter +/// without affecting the app. Shares parsing through `WatchMarkdownParagraph`. +struct NotificationMarkdownView: View { + let source: String + /// The notification interface can't open a browser, so links go to the controller. + var openLink: (URL) -> Void = { _ in } + @ScaledMetric(relativeTo: .body) private var fontSize = 14 + + 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) + // Paragraphs already contain only web links; anything else is discarded. + .environment(\.openURL, OpenURLAction { url in + guard WatchNotification.validatedWebURL(url.absoluteString) != nil else { return .discarded } + openLink(url) + return .handled + }) + } +} + +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 + #if DEBUG + print("Bark watch link: notification requested \(url), delegate found: \(delegate != nil)") + #endif + 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 { + NotificationMarkdownView(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/NotificationStatusLabel.swift b/BarkWatch/View/NotificationStatusLabel.swift new file mode 100644 index 00000000..cdaee000 --- /dev/null +++ b/BarkWatch/View/NotificationStatusLabel.swift @@ -0,0 +1,135 @@ +// +// NotificationStatusLabel.swift +// Bark +// +// Created by Jian Qin on 9/30/26. +// + + +import SwiftUI + +struct NotificationStatusLabel: 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..795c0dd7 --- /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: BarkServers + let id: UUID + @State private var confirmsRemoval = false + + private var server: BarkServer? { 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.deviceKey.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.pushURL.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) { + servers.remove(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..87fc1500 --- /dev/null +++ b/BarkWatch/View/WatchMarkdownView.swift @@ -0,0 +1,131 @@ +// +// 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. +struct WatchMarkdownView: View { + let source: String + @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; they go to the enclosing handler so the app + // can open them in a browser session instead of the unsupported system action. + .environment(\.openURL, OpenURLAction { [openURL] url in + guard WatchNotification.validatedWebURL(url.absoluteString) != nil else { return .discarded } + 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/BarkWatch/WatchNotificationService.swift b/BarkWatch/WatchNotificationService.swift new file mode 100644 index 00000000..8143b026 --- /dev/null +++ b/BarkWatch/WatchNotificationService.swift @@ -0,0 +1,159 @@ +import Foundation +import Intents +import os +import UniformTypeIdentifiers +import UserNotifications +import WidgetKit + +final class NotificationService: UNNotificationServiceExtension { + private let lock = NSLock() + private var handler: ((UNNotificationContent) -> Void)? + private var content: UNMutableNotificationContent? + private var download: URLSessionDownloadTask? + private let logger = Logger(subsystem: "com.sebersta.BarkWatchPrototype", category: "NotificationIcon") + + override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { + guard let content = request.content.mutableCopy() as? UNMutableNotificationContent else { + contentHandler(request.content) + return + } + lock.lock() + self.content = content + handler = contentHandler + let decrypted = WatchEncryption.prepare(content) { try WatchCryptoStore.load() } + lock.unlock() + guard decrypted else { + finish() + return + } + UNUserNotificationCenter.current().getNotificationSettings { [weak self] settings in + self?.process(criticalAlertsEnabled: settings.criticalAlertSetting == .enabled, + identifier: request.identifier) + } + } + + private func process(criticalAlertsEnabled: Bool, identifier: String) { + lock.lock() + guard handler != nil, let content else { + lock.unlock() + return + } + WatchNotification.prepare(content, criticalAlertsEnabled: criticalAlertsEnabled) + // Archived after preparing so the level and markdown summary match what is shown. + let archived = WatchArchivedMessage(content: content, identifier: identifier) + lock.unlock() + if let archived { + do { + try WatchArchive.shared.save(archived) + WidgetCenter.shared.reloadTimelines(ofKind: WatchArchive.widgetKind) + } catch { + logger.error("Could not archive notification: \(error.localizedDescription)") + } + } + downloadImage(WatchNotification.stringValue("image", in: content.userInfo)) { [weak self] file in + guard let self else { return } + let attachment = file.flatMap { try? UNNotificationAttachment(identifier: "image", url: $0, options: nil) } + if attachment == nil, let file { try? FileManager.default.removeItem(at: file) } + self.lock.lock() + if self.handler != nil, let attachment { self.content?.attachments = [attachment] } + self.lock.unlock() + self.processIcon() + } + } + + private func processIcon() { + lock.lock() + let snapshot = handler == nil ? nil : content?.mutableCopy() as? UNMutableNotificationContent + lock.unlock() + guard let snapshot else { return } + guard WatchNotification.stringValue("icon", in: snapshot.userInfo) != nil else { + finish() + return + } + downloadImage(WatchNotification.stringValue("icon", in: snapshot.userInfo)) { [weak self] file in + defer { if let file { try? FileManager.default.removeItem(at: file) } } + guard let self else { return } + guard let file, let data = try? Data(contentsOf: file), + let intent = WatchNotification.iconIntent(for: snapshot, imageData: data) else { + self.logger.error("Icon download or image validation failed; delivering original notification") + self.finish() + return + } + let interaction = INInteraction(intent: intent, response: nil) + interaction.direction = .incoming + interaction.donate { [weak self] error in + guard let self else { return } + do { + if let error { throw error } + guard let updated = try snapshot.updating(from: intent).mutableCopy() as? UNMutableNotificationContent else { + self.finish() + return + } + // Preserve Bark's text and options while retaining the system's avatar metadata. + updated.title = snapshot.title + updated.subtitle = snapshot.subtitle + updated.body = snapshot.body + updated.userInfo.merge(snapshot.userInfo) { _, original in original } + updated.threadIdentifier = snapshot.threadIdentifier + updated.categoryIdentifier = snapshot.categoryIdentifier + updated.sound = snapshot.sound + updated.interruptionLevel = snapshot.interruptionLevel + updated.attachments = snapshot.attachments + updated.badge = snapshot.badge + self.logger.info("Notification avatar applied") + self.finish(updated: updated) + } catch { + self.logger.error("Notification avatar failed: \(error.localizedDescription)") + self.finish() + } + } + } + } + + private func downloadImage(_ address: String?, completion: @escaping (URL?) -> Void) { + guard let url = WatchNotification.validatedWebURL(address) else { + completion(nil) + return + } + let task = URLSession.shared.downloadTask(with: URLRequest(url: url, timeoutInterval: 8)) { [weak self] file, response, _ in + var imageFile: URL? + if let file, let response = response as? HTTPURLResponse, response.statusCode == 200, + let mime = response.mimeType, ["image/png", "image/jpeg", "image/gif"].contains(mime), + let type = UTType(mimeType: mime), let suffix = type.preferredFilenameExtension, + let size = try? file.resourceValues(forKeys: [.fileSizeKey]).fileSize, size <= 5 * 1024 * 1024 { + let destination = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString).appendingPathExtension(suffix) + do { + try FileManager.default.moveItem(at: file, to: destination) + imageFile = destination + } catch { + try? FileManager.default.removeItem(at: destination) + } + } + guard let self else { return } + self.lock.lock() + let isActive = self.handler != nil + self.lock.unlock() + if isActive { completion(imageFile) } + else if let imageFile { try? FileManager.default.removeItem(at: imageFile) } + } + lock.lock() + download = task + let shouldStart = handler != nil + lock.unlock() + if shouldStart { task.resume() } else { task.cancel() } + } + + override func serviceExtensionTimeWillExpire() { finish() } + + private func finish(updated: UNMutableNotificationContent? = nil) { + lock.lock() + let callback = handler + let result = updated ?? content + handler = nil + let task = download + download = nil + lock.unlock() + task?.cancel() + if let callback, let result { callback(result) } + } +} diff --git a/BarkWatch/scripts/build-watch-server.sh b/BarkWatch/scripts/build-watch-server.sh new file mode 100755 index 00000000..16f5e05a --- /dev/null +++ b/BarkWatch/scripts/build-watch-server.sh @@ -0,0 +1,28 @@ +#!/bin/sh +set -eu + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +repo_root=$(CDPATH= cd -- "$script_dir/../.." && pwd) +build_dir="$repo_root/build/watch-server" +source_dir="$build_dir/source" +revision=5ea5486d8e4fe2fff0b4b170a89b2457e808c9c3 + +if [ ! -d "$source_dir/.git" ]; then + mkdir -p "$source_dir" + git -C "$source_dir" init -q +fi +if ! git -C "$source_dir" rev-parse --verify HEAD >/dev/null 2>&1; then + git -C "$source_dir" fetch --depth 1 https://github.com/Finb/bark-server.git "$revision" + git -C "$source_dir" checkout --detach FETCH_HEAD +fi +test "$(git -C "$source_dir" rev-parse HEAD)" = "$revision" + +if git -C "$source_dir" apply --check "$script_dir/watch-server.patch" 2>/dev/null; then + git -C "$source_dir" apply "$script_dir/watch-server.patch" +else + git -C "$source_dir" apply --reverse --check "$script_dir/watch-server.patch" +fi + +cd "$source_dir" +go build -trimpath -o "$build_dir/bark-server" . +printf 'Built %s\n' "$build_dir/bark-server" diff --git a/BarkWatch/scripts/send-watch-tests.py b/BarkWatch/scripts/send-watch-tests.py new file mode 100755 index 00000000..b0f24182 --- /dev/null +++ b/BarkWatch/scripts/send-watch-tests.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Send watch notification checks. Server acceptance does not confirm watch delivery.""" + +import argparse +import json +import math +import os +from pathlib import Path +import sys +import time +from urllib.error import HTTPError, URLError +from urllib.parse import urlsplit +from urllib.request import Request, urlopen +from uuid import uuid4 + + +def interval_seconds(value): + seconds = float(value) + if not math.isfinite(seconds) or seconds < 0: + raise argparse.ArgumentTypeError("Interval must be finite and nonnegative.") + return seconds + + +def select_cases(cases, identifiers): + available = {case["test"] for case in cases} + selected = set(identifiers) if identifiers is not None else available + unknown = selected - available + if unknown: + raise ValueError("Unknown tests: " + ", ".join(sorted(unknown))) + while True: + required = selected | { + dependency + for case in cases if case["test"] in selected + for dependency in case.get("requires", []) + } + if required == selected: + return [case for case in cases if case["test"] in selected] + if required - available: + raise ValueError("Test data contains an unknown prerequisite.") + selected = required + + +def build_payload(case, run_id, key): + fields = case["payload"] + defaults = {} if fields.get("delete") == "1" else { + "sound": "default", "group": "Watch Option Tests", "level": "active" + } + payload = {**defaults, **fields, "device_key": key} + # Restrict replacement and deletion to notifications created by this run. + payload["id"] = f"watch-test-{run_id}-{fields.get('id', case['test'])}" + return payload + + +def send_push(server, payload): + request = Request( + server.rstrip("/") + "/push", + data=json.dumps(payload, ensure_ascii=False).encode(), + headers={"Content-Type": "application/json"}, + ) + try: + with urlopen(request, timeout=30) as response: + result = json.load(response) + status = response.status + except HTTPError as error: + raise RuntimeError(f"Server returned HTTP {error.code}.") from None + except (URLError, TimeoutError): + raise RuntimeError("Could not reach the server.") from None + except (ValueError, UnicodeError): + raise RuntimeError("Server returned an invalid JSON response.") from None + if status != 200 or not isinstance(result, dict) or result.get("code") != 200: + raise RuntimeError("Server rejected the push request.") + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--server", default=os.environ.get("BARK_SERVER_URL"), help="Server base address, or BARK_SERVER_URL.") + parser.add_argument("--key", default=os.environ.get("BARK_DEVICE_KEY"), help="Registered watch key, or BARK_DEVICE_KEY.") + parser.add_argument("--only", nargs="+", help="Test identifiers, such as W03 W30. Prerequisites are included automatically.") + parser.add_argument("--interval", type=interval_seconds, default=15, help="Seconds between requests; default: 15.") + parser.add_argument("--dry-run", action="store_true", help="Show test expectations without sending anything or requiring a key.") + args = parser.parse_args(argv) + data_path = Path(__file__).with_name("watch-notification-tests.json") + try: + cases = json.loads(data_path.read_text(encoding="utf-8")) + except (OSError, ValueError) as error: + parser.error(f"Cannot read {data_path.name}: {error}") + try: + cases = select_cases(cases, args.only) + except ValueError as error: + parser.error(str(error)) + if args.dry_run: + for case in cases: + print(case["test"] + ": " + case["expected"]) + return 0 + + server = (args.server or "").strip() + try: + url = urlsplit(server) + port = url.port + valid = url.scheme in ("http", "https") and url.hostname and not ( + url.username is not None or url.password is not None or url.query or url.fragment + ) and (port is None or port > 0) + except ValueError: + valid = False + if not valid: + parser.error("Provide --server with an http or https server base address, without credentials, query, or fragment.") + key = (args.key or "").strip() + if not key: + parser.error("Provide --key or set BARK_DEVICE_KEY to the registered watch key.") + + run_id = uuid4().hex[:12] + print(f"Sending {len(cases)} requests, {args.interval:g} seconds apart. Confirm results on the watch.", flush=True) + try: + for index, case in enumerate(cases): + if index: + time.sleep(args.interval) + print(case["test"] + " expected: " + case["expected"], flush=True) + send_push(server, build_payload(case, run_id, key)) + print(case["test"] + ": server accepted; watch result unconfirmed", flush=True) + except RuntimeError as error: + print(f"Stopped at {case['test']}: {error}", file=sys.stderr) + return 1 + except KeyboardInterrupt: + print("\nStopped; previously accepted requests may still arrive.") + return 130 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/BarkWatch/scripts/test_send_watch_tests.py b/BarkWatch/scripts/test_send_watch_tests.py new file mode 100644 index 00000000..f194a5a3 --- /dev/null +++ b/BarkWatch/scripts/test_send_watch_tests.py @@ -0,0 +1,99 @@ +"""Check the sender against a local server; never send to Apple or a real watch.""" + +from http.server import BaseHTTPRequestHandler, HTTPServer +import importlib.util +import json +from pathlib import Path +import subprocess +import sys +from tempfile import TemporaryDirectory +from threading import Thread + + +def check(): + path = Path(__file__).with_name("send-watch-tests.py") + spec = importlib.util.spec_from_file_location("watch_sender", path) + sender = importlib.util.module_from_spec(spec) + spec.loader.exec_module(sender) + with TemporaryDirectory() as directory: + copied_script = Path(directory) / path.name + copied_script.write_text(path.read_text(), encoding="utf-8") + for data in (None, "/s\n{}"): + if data is not None: + copied_script.with_name("watch-notification-tests.json").write_text(data, encoding="utf-8") + result = subprocess.run([sys.executable, str(copied_script), "--dry-run"], capture_output=True, text=True, timeout=10) + assert result.returncode == 2 + assert "Cannot read watch-notification-tests.json" in result.stderr + assert "Traceback" not in result.stderr + cases = json.loads(path.with_name("watch-notification-tests.json").read_text()) + identifiers = [case["test"] for case in cases] + assert len(identifiers) == len(set(identifiers)) + for case in cases: + assert all(identifiers.index(required) < identifiers.index(case["test"]) for required in case.get("requires", [])) + assert len(sender.build_payload(case, "123456789abc", "synthetic-key")["id"].encode()) <= 64 + + requests = [] + + class Handler(BaseHTTPRequestHandler): + def do_POST(self): + payload = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + requests.append(payload) + if self.path == "/http-error/push": + self.send_response(500) + self.end_headers() + return + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + if self.path == "/invalid-json/push": + self.wfile.write(b"invalid JSON") + else: + self.wfile.write(json.dumps({"code": 400 if self.path == "/rejected/push" else 200}).encode()) + + def log_message(self, *_): + pass + + with HTTPServer(("127.0.0.1", 0), Handler) as server: + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + base = f"http://127.0.0.1:{server.server_port}" + try: + def run(*args): + return subprocess.run([sys.executable, str(path), *args], capture_output=True, text=True, timeout=10) + + preview = run("--dry-run", "--only", "W31") + assert preview.returncode == 0, preview.stderr + assert all(identifier in preview.stdout for identifier in ("W29", "W30", "W31")) + assert not requests + assert run("--dry-run", "--only", "UNKNOWN").returncode == 2 + assert run("--dry-run", "--interval", "nan").returncode == 2 + assert run("--dry-run", "--interval", "-1").returncode == 2 + assert run("--server", "https://user:password@example.com", "--key", "synthetic-key").returncode == 2 + + result = run("--server", base, "--key", "synthetic-key", "--only", "W31", "--interval", "0") + assert result.returncode == 0, result.stderr + assert len(requests) == 3 + assert requests[0]["title"] == "Server Alert" + assert requests[0]["subtitle"] == "Production" + assert requests[0]["body"] == "CPU usage exceeded 90% on web-01." + assert requests[0]["level"] == "critical" + assert len({payload["id"] for payload in requests}) == 1 + assert all(set(payload) == {"device_key", "id", "delete"} for payload in requests[1:]) + assert "synthetic-key" not in result.stdout + result.stderr + previous_id = requests[0]["id"] + assert run("--server", base, "--key", "synthetic-key", "--only", "W29", "--interval", "0").returncode == 0 + assert requests[-1]["id"] != previous_id + + for route in ("http-error", "invalid-json", "rejected"): + result = run("--server", base + "/" + route, "--key", "synthetic-key", "--only", "W01", "W02", "--interval", "0") + assert result.returncode == 1 + assert "W02 expected" not in result.stdout + assert "synthetic-key" not in result.stdout + result.stderr + finally: + server.shutdown() + thread.join(timeout=5) + print("Passed: prerequisites, unique identifiers, silent payloads, dry runs, input validation, and server error handling.") + + +if __name__ == "__main__": + check() diff --git a/BarkWatch/scripts/watch-notification-tests.json b/BarkWatch/scripts/watch-notification-tests.json new file mode 100644 index 00000000..09ca749d --- /dev/null +++ b/BarkWatch/scripts/watch-notification-tests.json @@ -0,0 +1,362 @@ +[ + { + "test": "W01", + "expected": "Title, subtitle, two body lines and emoji are readable.", + "payload": { + "title": "[W01] Text structure", + "subtitle": "Subtitle: watch test", + "body": "First line: café 🚴\nSecond line: 12345" + } + }, + { + "test": "W02", + "expected": "A body-only notification arrives without an invented title.", + "payload": { + "body": "[W02] Body only. No title or subtitle was sent." + } + }, + { + "test": "W03", + "expected": "The brief alert contains plain text. Opening the notification or Last message shows styled Markdown. FALLBACK means the extension did not process it.", + "payload": { + "title": "[W03] Markdown", + "body": "FALLBACK: extension did not run", + "markdown": "**Bold**, *italic*, and `code`\nSecond line preserved." + } + }, + { + "test": "W04", + "expected": "First notification in Watch Group A.", + "payload": { + "title": "[W04] Group A, first", + "body": "Keep this in Notification Center for W05.", + "group": "Watch Group A" + } + }, + { + "test": "W05", + "expected": "Groups with W04 in Notification Center, subject to the watch's grouping setting.", + "payload": { + "title": "[W05] Group A, second", + "body": "This belongs with W04.", + "group": "Watch Group A" + }, + "requires": [ + "W04" + ] + }, + { + "test": "W06", + "expected": "Uses a different notification group from W04 and W05.", + "payload": { + "title": "[W06] Group B", + "body": "This belongs to Watch Group B.", + "group": "Watch Group B" + }, + "requires": [ + "W04", + "W05" + ] + }, + { + "test": "W07", + "expected": "Appears in Notification Center without actively alerting. Open Notification Center to find it.", + "payload": { + "title": "[W07] Passive", + "body": "Quiet delivery: find me in Notification Center.", + "level": "passive" + } + }, + { + "test": "W08", + "expected": "Normal visible notification with no requested alert sound. Watch haptics and screen behavior remain system-controlled.", + "payload": { + "title": "[W08] Silent sound", + "body": "An active notification with sound disabled.", + "level": "active", + "sound": "silent" + } + }, + { + "test": "W09", + "expected": "Time-sensitive classification. Focus delivery depends on your time-sensitive notification setting.", + "payload": { + "title": "[W09] Time sensitive", + "body": "Time-sensitive delivery requested.", + "level": "timeSensitive" + } + }, + { + "test": "W10", + "expected": "Critical alert when permission is enabled; otherwise a normal alert with a fallback explanation in Last message.", + "payload": { + "title": "[W10] Critical permission check", + "body": "Critical when authorized; otherwise a normal alert.", + "level": "critical", + "volume": 10 + } + }, + { + "test": "W11", + "expected": "One normal system alert; no custom ringtone, repeating call, badge, or automatic copying.", + "payload": { + "title": "[W11] Unsupported options", + "body": "System sound fallback. No repeated ringing or icon badge.", + "sound": "minuet", + "call": "1", + "badge": 7, + "autoCopy": "1", + "copy": "WATCH-TEST" + } + }, + { + "test": "W12", + "expected": "An Apple logo image appears in the expanded notification if watchOS presents the attachment, and in Last message after opening it.", + "payload": { + "title": "[W12] Image", + "body": "Expand this notification to check the image.", + "image": "https://www.apple.com/ac/structured-data/images/knowledge_graph_logo.png" + } + }, + { + "test": "W13", + "expected": "Open the notification, then tap Open link in Last message. The app presents a web authentication session; close it with Cancel.", + "payload": { + "title": "[W13] Web link", + "body": "Open this notification, then tap Open link.", + "url": "https://example.com" + } + }, + { + "test": "W14", + "expected": "Initial replacement test. Leave this notification in Notification Center for W15.", + "payload": { + "id": "watch-options-replacement", + "title": "[W14] Progress 25%", + "body": "Leave me in Notification Center. W15 uses the same identifier.", + "group": "Watch Replacement" + } + }, + { + "test": "W15", + "expected": "Replaces W14 rather than leaving both progress notifications in Notification Center.", + "payload": { + "id": "watch-options-replacement", + "title": "[W15] Progress 100%", + "body": "Updated using the same identifier as W14.", + "group": "Watch Replacement" + }, + "requires": [ + "W14" + ] + }, + { + "test": "W16", + "expected": "The system default notification sound plays when normal watch sound settings allow it.", + "payload": { + "title": "[W16] System sound", + "body": "Normal active notification with the system default sound.", + "level": "active", + "sound": "default" + } + }, + { + "test": "W17", + "expected": "Visible notification with no requested alert sound.", + "payload": { + "title": "[W17] Silent sound", + "body": "The sound option is silent.", + "level": "active", + "sound": "silent" + } + }, + { + "test": "W18", + "expected": "The same system sound as W16; system is an alias, not a different tone.", + "payload": { + "title": "[W18] System sound alias", + "body": "System sound selected with sound=system.", + "level": "active", + "sound": "system" + } + }, + { + "test": "W19", + "expected": "Critical system alert at 30 percent volume only if critical permission is enabled. Otherwise a normal alert and a fallback explanation.", + "payload": { + "title": "[W19] Critical, volume 3", + "body": "Critical system sound, volume 3 of 10. Without permission this is a normal alert.", + "level": "critical", + "sound": "default", + "volume": 3 + } + }, + { + "test": "W20", + "expected": "Critical system alert at 80 percent volume only if critical permission is enabled. Otherwise a normal alert and a fallback explanation.", + "payload": { + "title": "[W20] Critical, volume 8", + "body": "Critical system sound, volume 8 of 10. Without permission this is a normal alert.", + "level": "critical", + "sound": "default", + "volume": 8 + } + }, + { + "test": "W21", + "expected": "Baseline notification with the ordinary application icon. Compare with W22.", + "payload": { + "title": "[W21] Standard icon", + "subtitle": "Icon comparison", + "body": "No custom icon was requested." + } + }, + { + "test": "W22", + "expected": "An Apple logo appears as the notification avatar. Title, subtitle, and body remain readable. Confirm on the notification itself.", + "payload": { + "title": "[W22] Custom icon", + "subtitle": "Icon comparison", + "body": "Look for the Apple logo avatar, not an image attachment.", + "icon": "https://www.apple.com/ac/structured-data/images/knowledge_graph_logo.png" + }, + "requires": [ + "W21" + ] + }, + { + "test": "W23", + "expected": "The notification still arrives with the ordinary application icon because the icon URL is invalid.", + "payload": { + "title": "[W23] Invalid icon fallback", + "body": "Text must arrive even when the icon cannot be loaded.", + "icon": "file:///invalid-icon.png" + } + }, + { + "test": "W24", + "expected": "The Apple logo avatar and an image attachment both appear. Title and subtitle remain intact.", + "payload": { + "title": "[W24] Icon and image", + "subtitle": "Both fields together", + "body": "Check the avatar and the expanded image separately.", + "icon": "https://www.apple.com/ac/structured-data/images/knowledge_graph_logo.png", + "image": "https://www.apple.com/ac/structured-data/images/knowledge_graph_logo.png" + } + }, + { + "test": "W25", + "payload": { + "title": "[W25] Archive expires in 30 seconds", + "body": "Archive expiration does not remove this alert from Notification Center.", + "ttl": "30", + "isArchive": "1" + }, + "expected": "Run this case alone to check expiration: it can appear on the complication, then the archive expires after 30 seconds. System complication refresh may be delayed." + }, + { + "test": "W26", + "payload": { + "title": "W26 Training", + "body": "First body line\nSecond body line", + "group": "Training", + "isArchive": "1" + }, + "expected": "Rectangular complication shows W26 Training above the two body lines; Training appears in the group picker." + }, + { + "test": "W27", + "payload": { + "title": "W27 Builds", + "body": "Build completed.\nReady for testing.", + "group": "Builds", + "isArchive": "1" + }, + "expected": "All groups and Builds show W27; selecting Training still shows W26.", + "requires": [ + "W26" + ] + }, + { + "test": "W28", + "payload": { + "title": "W28 Not archived", + "body": "This must not replace the complication.", + "group": "Builds", + "isArchive": "0" + }, + "expected": "Notification arrives, but All groups and Builds still show W27.", + "requires": [ + "W27" + ] + }, + { + "test": "W29", + "payload": { + "id": "silent-deletion", + "title": "Server Alert", + "subtitle": "Production", + "body": "CPU usage exceeded 90% on web-01.", + "group": "Monitoring", + "level": "critical" + }, + "expected": "The Server Alert arrives. Leave it in Notification Center for W30. Critical delivery requires entitlement and permission; otherwise it falls back to active." + }, + { + "test": "W30", + "requires": [ + "W29" + ], + "payload": { + "id": "silent-deletion", + "delete": "1" + }, + "expected": "W29 disappears from Notification Center and the archive without a new banner or sound. The latest-message card clears if it shows W29. Background delivery may be delayed." + }, + { + "test": "W31", + "requires": [ + "W30" + ], + "payload": { + "id": "silent-deletion", + "delete": "1" + }, + "expected": "Repeating W30 has no visible effect and does not affect other notifications." + }, + { + "test": "W32", + "payload": { + "id": "unknown-deletion", + "delete": "1" + }, + "expected": "Deleting an identifier that this run has never created has no visible effect." + }, + { + "test": "W33", + "payload": { + "title": "[W33] Full Markdown", + "body": "FALLBACK: extension did not run", + "markdown": "# Heading\n\n**Bold**, *italic*, ~~strikethrough~~, and `inline code`.\n\n> A quotation.\n\n1. Ordered item\n2. Second item\n - Nested bullet\n\n```swift\nlet temperature = 42\nprint(temperature)\n```\n\n| Service | Status |\n| --- | --- |\n| web-01 | Healthy |\n\n[Bark](https://bark.day.app)" + }, + "expected": "Check headings, emphasis, lists, quotation, code, table text, and the tappable web link in the expanded notification and Last message." + }, + { + "test": "W34", + "payload": { + "title": "[W34] Rejected application link", + "body": "This address must not produce an Open link button.", + "url": "file:///invalid-link" + }, + "expected": "Text is delivered; the invalid non-web address is not offered as an Open link button." + }, + { + "test": "W35", + "payload": { + "title": "[W35] Decryption failure", + "body": "This fallback must be sanitized.", + "ciphertext": "not-valid-base64!" + }, + "expected": "Decryption Failed appears. Outer body and links are not shown, and the failed encrypted message is not archived." + } +] diff --git a/BarkWatch/scripts/watch-server.patch b/BarkWatch/scripts/watch-server.patch new file mode 100644 index 00000000..b6832113 --- /dev/null +++ b/BarkWatch/scripts/watch-server.patch @@ -0,0 +1,177 @@ +diff --git a/apns/apns.go b/apns/apns.go +index 6d845b4..22c3383 100644 +--- a/apns/apns.go ++++ b/apns/apns.go +@@ -5,6 +5,7 @@ import ( + "crypto/x509" + "fmt" + "net/http" ++ "os" + "runtime" + "strings" + "time" +@@ -38,11 +39,12 @@ func (p PushMessage) IsDelete() bool { + return val == "1" || val == 1 || val == 1.0 + } + +-const ( +- topic = "me.fin.bark" +- keyID = "LH4T9V5U4R" +- teamID = "5U8LBRXG3A" +- PayloadMaximum = 4096 ++const PayloadMaximum = 4096 ++ ++var ( ++ topic = os.Getenv("BARK_APNS_TOPIC") ++ keyID = os.Getenv("BARK_APNS_KEY_ID") ++ teamID = os.Getenv("BARK_APNS_TEAM_ID") + ) + + var clients = make(chan *apns2.Client, 1) +@@ -57,7 +59,18 @@ func ReCreateAPNS(maxClientCount int) error { + return fmt.Errorf("invalid number of clients") + } + +- authKey, err := token.AuthKeyFromBytes([]byte(apnsPrivateKey)) ++ if topic == "" || keyID == "" || teamID == "" { ++ logger.Fatal("BARK_APNS_TOPIC, BARK_APNS_KEY_ID and BARK_APNS_TEAM_ID are required") ++ } ++ host := apns2.HostDevelopment ++ switch os.Getenv("BARK_APNS_ENVIRONMENT") { ++ case "development": ++ case "production": ++ host = apns2.HostProduction ++ default: ++ logger.Fatal("BARK_APNS_ENVIRONMENT must be development or production") ++ } ++ authKey, err := token.AuthKeyFromFile(os.Getenv("BARK_APNS_KEY_FILE")) + if err != nil { + logger.Fatalf("failed to create APNS auth key: %v", err) + } +@@ -94,7 +107,7 @@ func ReCreateAPNS(maxClientCount int) error { + }, + Timeout: apns2.HTTPClientTimeout, + }, +- Host: apns2.HostProduction, ++ Host: host, + } + logger.Infof("create apns client: %d", i) + clients <- client +@@ -107,10 +120,12 @@ func ReCreateAPNS(maxClientCount int) error { + func Push(msg *PushMessage) (code int, err error) { + pl := payload.NewPayload().MutableContent() + pushType := apns2.PushTypeAlert ++ priority := apns2.PriorityHigh + if msg.IsDelete() { + // Silent push notification + pl = pl.ContentAvailable() + pushType = apns2.PushTypeBackground ++ priority = apns2.PriorityLow + } else { + // Regular push notification + pl = pl.AlertTitle(msg.Title). +@@ -139,12 +154,13 @@ func Push(msg *PushMessage) (code int, err error) { + Payload: pl, + Expiration: time.Now().Add(24 * time.Hour), + PushType: pushType, ++ Priority: priority, + }) + if err != nil { + return 500, err + } + if resp.StatusCode != 200 { +- return resp.StatusCode, fmt.Errorf(resp.Reason) ++ return resp.StatusCode, fmt.Errorf("%s", resp.Reason) + } + return 200, nil + } +diff --git a/route_push.go b/route_push.go +index 8da18f4..c699a6b 100644 +--- a/route_push.go ++++ b/route_push.go +@@ -230,7 +230,7 @@ func push(params map[string]interface{}) (int, error) { + msg.Body = val + case "sound": + // Compatible with old parameters +- if strings.HasSuffix(val, ".caf") { ++ if val == "default" || strings.HasSuffix(val, ".caf") { + msg.Sound = val + } else { + msg.Sound = val + ".caf" +diff --git a/router.go b/router.go +index 56f0730..5d90fbd 100644 +--- a/router.go ++++ b/router.go +@@ -69,7 +69,7 @@ func registerRouteWithWeight(name string, weight int, f func(router fiber.Router + func routerSetup(router fiber.Router) { + routerOnce.Do(func() { + router.Use(fiberlogger.New(fiberlogger.Config{ +- Format: "${time} INFO ${ip} -> [${status}] ${method} ${latency} ${route} => ${url} ${body}\n", ++ Format: "${time} INFO ${ip} -> [${status}] ${method} ${latency} ${route}\n", + TimeFormat: "2006-01-02 15:04:05", + Output: os.Stdout, + })) +diff --git a/apns/watch_delete_test.go b/apns/watch_delete_test.go +new file mode 100644 +index 0000000..32dc2bf +--- /dev/null ++++ b/apns/watch_delete_test.go +@@ -0,0 +1,58 @@ ++package apns ++ ++import ( ++ "encoding/json" ++ "net/http" ++ "net/http/httptest" ++ "testing" ++ ++ "github.com/sideshow/apns2" ++) ++ ++func TestWatchDeletionPush(t *testing.T) { ++ var headers http.Header ++ var body map[string]interface{} ++ server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ++ headers = r.Header.Clone() ++ if err := json.NewDecoder(r.Body).Decode(&body); err != nil { ++ t.Error(err) ++ } ++ w.WriteHeader(http.StatusOK) ++ })) ++ defer server.Close() ++ originalClients := clients ++ clients = make(chan *apns2.Client, 1) ++ clients <- &apns2.Client{Host: server.URL, HTTPClient: server.Client()} ++ defer func() { clients = originalClients }() ++ ++ for _, test := range []struct { ++ name, pushType, priority string ++ deletion bool ++ }{ ++ {"delete", "background", "5", true}, ++ {"alert", "alert", "10", false}, ++ } { ++ t.Run(test.name, func(t *testing.T) { ++ params := map[string]interface{}{"id": "watch-delete-test"} ++ if test.deletion { ++ params["delete"] = "1" ++ } ++ code, err := Push(&PushMessage{Id: "watch-delete-test", DeviceToken: "synthetic", Title: "Test", Body: "Body", Sound: "default", ExtParams: params}) ++ if err != nil || code != http.StatusOK { ++ t.Fatalf("Push: code=%d, error=%v", code, err) ++ } ++ if headers.Get("apns-push-type") != test.pushType || headers.Get("apns-priority") != test.priority || headers.Get("apns-collapse-id") != "watch-delete-test" { ++ t.Fatalf("Unexpected push headers: %v", headers) ++ } ++ aps := body["aps"].(map[string]interface{}) ++ if test.deletion { ++ if aps["content-available"] != float64(1) || aps["alert"] != nil || aps["sound"] != nil || body["delete"] != "1" || body["id"] != "watch-delete-test" { ++ t.Fatalf("Unexpected deletion payload: %v", body) ++ } ++ } else if aps["alert"] == nil || aps["content-available"] != nil { ++ t.Fatalf("Unexpected alert payload: %v", body) ++ } ++ body = nil ++ }) ++ } ++} diff --git a/docs/_media/BarkWatch-LastMessageView.png b/docs/_media/BarkWatch-LastMessageView.png new file mode 100644 index 0000000000000000000000000000000000000000..7e580c73797a6ef34d1598c8818c802d1147c61b GIT binary patch literal 74603 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�akzA_6?n#9u)YyJ99YiFV_t}ceP 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
E!b zXKfpR!?SK~5KD$0xtlRlZ)AZ;MzoUYQky*)2~4ZwfpVu}Kee%F9hL8_u%ZCkTZpihOD)$_@;mQL$Py<|ezzmM z6y1fOV}I|sui&P_;M@4356Cr|&?#eLOB*#Xg}uLmN`}B-DW|LlVy;mjyDU{qhqpk| z>XAyhFucqOSj!A`2s>@P9ww&!nj8VfYTH@U=qIQNqN#Y2Mgioe{Rkj;OeB@wyUMqX z71zjp(Wly2U{Jz|`v161i+><}EaCj+=S7qrv>Qs*8ml6l^t({Qgp(Q}=HSpQi}a3m zbm;b@Yh-6=E9vtM{BDH03=sfWA5Lm25Ng z=&dC|37a>j)m)kEc;FHIZT8oJaM6mQVFk%+_CZJPxy|AMFc(-vWr;@#JRsBtHoVQF zQq7-7idi?(nYq=mg7Hq-wAGUBcl8NG$=v?_pgS~em{c?kWDF2578s8ijwPOA5;sU4 zJQz^U4gEV_!6)J$5aMvp*uf~$L$Pb-Gyf%FEC7_q%EkG-=nN2*jF9qS!m;%CSj9t{KM+-Xk%CcX-jN2gnA&*CxnE+@4_N2jrUs&0OTP0a)Ge;VE&SZ9bT9gH< z@lcwfZAuT{3N`^ITEg@%m?%ItgAFhy_$DRP13|^ATb~AKhHc3$(%bc&JLsR)qZm%; zx5=LJ5@Uq!%vxsz0;Y9MOF05TBoTxh{|nX+_?rso^Wuzko+gZ%#a#3!TJ{Tv=;2Sz z`GKts1jd1nghALdmT-M|m5@_>qIiQ#1KND?EI<3|R#$2AaZIvPT%kZiPa(9wn98X2 z#mldPA_0_mbqX31TPA)cMpbiy4LiGCZqV zP!BoQv%WPjPDGJ5g znj-Sz3EnR;=IQ{0kAdvZlvaKq8VEhA;XM$qP)A2i+w*!ewju=OuDrT)j5$l|)K#o9 z5A-Y3L|fubVHd?tV4gDX2oT;uw0`H&dU&Lo&i8IA--ZXk&xpVr0t9#HXI6ObDe|J0 zv)Ho?JY-Fe)ShIg%D!UEEM^yTQp)Kzoz=`5t~9^iC`rQL$D5G5fmZ}P;h#YlYr>hN z!crtIX1W)286|QVNTMUfrc_I8?!1*w+aTSeX;)hhZbLI?^K$vjEo@@+e~Hn@f6yF$ zLJ0EWah^?b>pe|A6xg7o5D+(uvEy^y2Y~t6AagMA!D1sjr0T0Ux1Ue5j290GJ72v~ z-A(CSW1fo|jk1ah8bIq1x;ToC8rw~3soQ^jYFC)M49#RLr|Y&YaRsblpz2?YmcIN1 zYU7Rq&r&h=p5r?S`DRr08jI5UvE|HR+F?jh>irbIejsx>0sgcT>B_5QmQNhU2 zYt&ZBX#V04MA$s=*X_xnZ{`Frp0Q4n(n_bdq(aGyvs$dL^3n>H-d+Q-TBBR;n9C7@ z#8q*CSr!&}8ZFgd>IU!V-!C!$GAsA^+_`A*QpBB95E-dR1BFCDqfxsh^j=vv+LJDj z^bQYu;#)^VPP=IDJ>-p-0+lDQyVp}~mHICA+A<$6{SUH!)s4i)q+;>3f5ojNNYmOT zU`lNne(u*1yrSR%nSt9)o!RJj1PCOCxQY1$k*holfXXA25vWYXpi2JZuMzflHyIhe zkq)64NGZ~3*-UZyp>IBu)N02*T}LpRXD|=Nw_k5nzA>{l1~`;7&OPVu?1*?E{4eMXgL*!wOZl^{6q zJI5zbJ?)Gs2m)I=n`LWtJ>&=FQOMv=KBQ~n0> z8=DyW&mWHHfHa^Lq@POp!tMk|OW8dTlA}M}OfbLEA>Bqdnf&veM=_`V*g7TOKjz-2 zFxc+>!^DUs(lET_XN<89T+*uVqW&+h3}3&GU|Nd=AuoXHm+a>=5E>JpY8cA|0Y4P5 z%|`(7@5Uj=HLa`|H2AVY=V6o5W+G_i-CX(YkHvNJ7tkkWcZ?h)_!txMIKlHG>6d_W+#{E6;e=r>;dJHqY(aeu%~M6iqn z0^w(LW@W=HM*s}4AkSiZcJ!RuBD-17@*)pCKb(+-00-vcN3pv9b6|FYFmjNMDpIyq z)H3Pb0;} Date: Sat, 10 Oct 2026 15:07:46 -0700 Subject: [PATCH 2/9] - code cleanup - better documentation - no feature changes --- .gitignore | 2 - Bark.xcodeproj/project.pbxproj | 819 ++++++++++++++---- .../xcshareddata/swiftpm/Package.resolved | 4 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- BarkWatch/App/WatchMessage.swift | 40 +- BarkWatch/BarkWatch.xcconfig | 9 + .../BarkWatchComplication/AppIntent.swift | 7 + .../BarkWatchComplication.swift | 7 + .../ComplicationView.swift | 17 +- BarkWatch/Common/BarkApi.swift | 83 ++ BarkWatch/Common/PhoneCompatibility.swift | 19 +- BarkWatch/Common/WatchArchive.swift | 41 +- BarkWatch/Common/WatchConstants.swift | 25 + BarkWatch/Common/WatchEncryption.swift | 23 +- BarkWatch/Common/WatchMarkdownParser.swift | 11 + BarkWatch/Common/WatchNotification.swift | 49 +- .../Common/WatchNotificationDelivery.swift | 252 ++++++ .../{BarkServer.swift => ServerManager.swift} | 89 +- BarkWatch/README.md | 31 +- BarkWatch/ServerSetup.md | 75 -- BarkWatch/Tests/NotificationTests.swift | 109 ++- BarkWatch/View/AddServerTabView.swift | 4 +- BarkWatch/View/AddServerView.swift | 6 +- BarkWatch/View/BigButtonView.swift | 22 - BarkWatch/View/ContentView.swift | 28 +- BarkWatch/View/LastMessageView.swift | 27 +- BarkWatch/View/NotificationMarkdownView.swift | 50 +- ...abel.swift => NotificationStatusTab.swift} | 8 +- BarkWatch/View/ServerTabView.swift | 10 +- BarkWatch/View/WatchMarkdownView.swift | 14 +- BarkWatch/WatchNotificationService.swift | 163 +--- BarkWatch/scripts/build-watch-server.sh | 28 - BarkWatch/scripts/send-watch-tests.py | 130 --- BarkWatch/scripts/test_send_watch_tests.py | 99 --- .../scripts/watch-notification-tests.json | 362 -------- BarkWatch/scripts/watch-server.patch | 177 ---- 36 files changed, 1371 insertions(+), 1473 deletions(-) create mode 100644 BarkWatch/BarkWatch.xcconfig create mode 100644 BarkWatch/Common/BarkApi.swift create mode 100644 BarkWatch/Common/WatchConstants.swift create mode 100644 BarkWatch/Common/WatchNotificationDelivery.swift rename BarkWatch/Model/{BarkServer.swift => ServerManager.swift} (74%) delete mode 100644 BarkWatch/ServerSetup.md rename BarkWatch/View/{NotificationStatusLabel.swift => NotificationStatusTab.swift} (98%) delete mode 100755 BarkWatch/scripts/build-watch-server.sh delete mode 100755 BarkWatch/scripts/send-watch-tests.py delete mode 100644 BarkWatch/scripts/test_send_watch_tests.py delete mode 100644 BarkWatch/scripts/watch-notification-tests.json delete mode 100644 BarkWatch/scripts/watch-server.patch diff --git a/.gitignore b/.gitignore index e653fb71..ab849551 100644 --- a/.gitignore +++ b/.gitignore @@ -12,5 +12,3 @@ Pods/ xcuserdata buildServer.json *.p8 -BarkWatchServer/ -.codex-backups/ diff --git a/Bark.xcodeproj/project.pbxproj b/Bark.xcodeproj/project.pbxproj index c40db5c7..7b6d657f 100644 --- a/Bark.xcodeproj/project.pbxproj +++ b/Bark.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 71; + objectVersion = 70; objects = { /* Begin PBXBuildFile section */ @@ -59,6 +59,55 @@ 0632052C250B6DD4001561EC /* horn.caf in Resources */ = {isa = PBXBuildFile; fileRef = 0632050D250B6DD4001561EC /* horn.caf */; }; 0632052D250B6DD4001561EC /* newsflash.caf in Resources */ = {isa = PBXBuildFile; fileRef = 0632050E250B6DD4001561EC /* newsflash.caf */; }; 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 */; }; + 060481EE250F404500BC9799 /* SoundsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 060481ED250F404500BC9799 /* SoundsViewController.swift */; }; + 060481F0250F51CA00BC9799 /* SoundCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 060481EF250F51CA00BC9799 /* SoundCell.swift */; }; + 0604F7DF20620D4900B32F09 /* ServerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0604F7DE20620D4900B32F09 /* ServerManager.swift */; }; + 0608F06E2994D115006B8029 /* BKDropDownCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0608F06D2994D115006B8029 /* BKDropDownCell.swift */; }; + 0608F0722994D269006B8029 /* BKDropDownCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 0608F0712994D269006B8029 /* BKDropDownCell.xib */; }; + 06172FDA27F6DAEF002333A4 /* ServerListTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06172FD927F6DAEF002333A4 /* ServerListTableViewCell.swift */; }; + 06172FDC27F6DB06002333A4 /* ServerListTableViewCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06172FDB27F6DB06002333A4 /* ServerListTableViewCellViewModel.swift */; }; + 061894C529962EB900E001C2 /* GradientButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 061894C429962EB900E001C2 /* GradientButton.swift */; }; + 061894C729A75BEA00E001C2 /* Algorithm.swift in Sources */ = {isa = PBXBuildFile; fileRef = 061894C629A75BEA00E001C2 /* Algorithm.swift */; }; + 061C17082D1BDA4B00891D66 /* MessageGroupMoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 061C17072D1BDA4B00891D66 /* MessageGroupMoreView.swift */; }; + 061E35862D1E5028009A2D6F /* MessageItemModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 061E35852D1E5028009A2D6F /* MessageItemModel.swift */; }; + 0627DABB298B6EA2002F3F69 /* DropBoxView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0627DABA298B6EA2002F3F69 /* DropBoxView.swift */; }; + 0627DABD2990D615002F3F69 /* BorderTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0627DABC2990D615002F3F69 /* BorderTextField.swift */; }; + 062B98C3251B2762004562E7 /* BKButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 062B98C2251B2762004562E7 /* BKButton.swift */; }; + 062B98C8251B27AE004562E7 /* UINavigationItem+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 062B98C7251B27AE004562E7 /* UINavigationItem+Extension.swift */; }; + 0632050F250B6DD4001561EC /* gotosleep.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204F0250B6DD1001561EC /* gotosleep.caf */; }; + 06320510250B6DD4001561EC /* paymentsuccess.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204F1250B6DD1001561EC /* paymentsuccess.caf */; }; + 06320511250B6DD4001561EC /* shake.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204F2250B6DD1001561EC /* shake.caf */; }; + 06320512250B6DD4001561EC /* alarm.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204F3250B6DD1001561EC /* alarm.caf */; }; + 06320513250B6DD4001561EC /* bloom.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204F4250B6DD1001561EC /* bloom.caf */; }; + 06320514250B6DD4001561EC /* sherwoodforest.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204F5250B6DD2001561EC /* sherwoodforest.caf */; }; + 06320515250B6DD4001561EC /* healthnotification.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204F6250B6DD2001561EC /* healthnotification.caf */; }; + 06320516250B6DD4001561EC /* calypso.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204F7250B6DD2001561EC /* calypso.caf */; }; + 06320517250B6DD4001561EC /* descent.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204F8250B6DD2001561EC /* descent.caf */; }; + 06320518250B6DD4001561EC /* ladder.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204F9250B6DD2001561EC /* ladder.caf */; }; + 06320519250B6DD4001561EC /* tiptoes.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204FA250B6DD2001561EC /* tiptoes.caf */; }; + 0632051A250B6DD4001561EC /* fanfare.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204FB250B6DD2001561EC /* fanfare.caf */; }; + 0632051B250B6DD4001561EC /* birdsong.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204FC250B6DD2001561EC /* birdsong.caf */; }; + 0632051C250B6DD4001561EC /* typewriters.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204FD250B6DD2001561EC /* typewriters.caf */; }; + 0632051D250B6DD4001561EC /* anticipate.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204FE250B6DD2001561EC /* anticipate.caf */; }; + 0632051E250B6DD4001561EC /* choo.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204FF250B6DD3001561EC /* choo.caf */; }; + 0632051F250B6DD4001561EC /* glass.caf in Resources */ = {isa = PBXBuildFile; fileRef = 06320500250B6DD3001561EC /* glass.caf */; }; + 06320520250B6DD4001561EC /* telegraph.caf in Resources */ = {isa = PBXBuildFile; fileRef = 06320501250B6DD3001561EC /* telegraph.caf */; }; + 06320521250B6DD4001561EC /* multiwayinvitation.caf in Resources */ = {isa = PBXBuildFile; fileRef = 06320502250B6DD3001561EC /* multiwayinvitation.caf */; }; + 06320522250B6DD4001561EC /* newmail.caf in Resources */ = {isa = PBXBuildFile; fileRef = 06320503250B6DD3001561EC /* newmail.caf */; }; + 06320523250B6DD4001561EC /* update.caf in Resources */ = {isa = PBXBuildFile; fileRef = 06320504250B6DD3001561EC /* update.caf */; }; + 06320524250B6DD4001561EC /* minuet.caf in Resources */ = {isa = PBXBuildFile; fileRef = 06320505250B6DD3001561EC /* minuet.caf */; }; + 06320525250B6DD4001561EC /* suspense.caf in Resources */ = {isa = PBXBuildFile; fileRef = 06320506250B6DD3001561EC /* suspense.caf */; }; + 06320526250B6DD4001561EC /* mailsent.caf in Resources */ = {isa = PBXBuildFile; fileRef = 06320507250B6DD3001561EC /* mailsent.caf */; }; + 06320527250B6DD4001561EC /* noir.caf in Resources */ = {isa = PBXBuildFile; fileRef = 06320508250B6DD3001561EC /* noir.caf */; }; + 06320528250B6DD4001561EC /* chime.caf in Resources */ = {isa = PBXBuildFile; fileRef = 06320509250B6DD4001561EC /* chime.caf */; }; + 06320529250B6DD4001561EC /* spell.caf in Resources */ = {isa = PBXBuildFile; fileRef = 0632050A250B6DD4001561EC /* spell.caf */; }; + 0632052A250B6DD4001561EC /* electronic.caf in Resources */ = {isa = PBXBuildFile; fileRef = 0632050B250B6DD4001561EC /* electronic.caf */; }; + 0632052B250B6DD4001561EC /* bell.caf in Resources */ = {isa = PBXBuildFile; fileRef = 0632050C250B6DD4001561EC /* bell.caf */; }; + 0632052C250B6DD4001561EC /* horn.caf in Resources */ = {isa = PBXBuildFile; fileRef = 0632050D250B6DD4001561EC /* horn.caf */; }; + 0632052D250B6DD4001561EC /* newsflash.caf in Resources */ = {isa = PBXBuildFile; fileRef = 0632050E250B6DD4001561EC /* newsflash.caf */; }; 0632CE1F20EC9098003FDF46 /* UserNotifications.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0683487020510FB20024B6DA /* UserNotifications.framework */; }; 0632CE2020EC9098003FDF46 /* UserNotificationsUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0683487220510FB20024B6DA /* UserNotificationsUI.framework */; }; 0632CE2320EC9098003FDF46 /* NotificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0632CE2220EC9098003FDF46 /* NotificationViewController.swift */; }; @@ -106,9 +155,36 @@ 06697FDB2F70ED9400C4680E /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 06697FDA2F70ED9400C4680E /* WidgetKit.framework */; }; 06697FDD2F70ED9400C4680E /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 06697FDC2F70ED9400C4680E /* SwiftUI.framework */; }; 06697FEE2F70ED9600C4680E /* WidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 06697FD92F70ED9400C4680E /* WidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 06697FF02F70ED9600C4680E /* WidgetHistorySnapshotStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06697FED2F70ED9600C4680E /* WidgetHistorySnapshotStore.swift */; }; + 06697FF42F70ED9600C4680E /* WidgetHistorySnapshotSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06697FF52F70ED9600C4680E /* WidgetHistorySnapshotSupport.swift */; }; 06697FF82F71ED9600C4680E /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 06697FDA2F70ED9400C4680E /* WidgetKit.framework */; }; + 06697FF92F71ED9600C4680E /* WidgetHistorySnapshotStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06697FED2F70ED9600C4680E /* WidgetHistorySnapshotStore.swift */; }; + 066B10012F73F00100C4680E /* WidgetHistoryModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 066B10002F73F00100C4680E /* WidgetHistoryModels.swift */; }; + 066B10022F73F00200C4680E /* WidgetHistoryModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 066B10002F73F00100C4680E /* WidgetHistoryModels.swift */; }; + 066B10032F73F00300C4680E /* WidgetHistoryModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 066B10002F73F00100C4680E /* WidgetHistoryModels.swift */; }; + 066DF4822D2D31A60092B04E /* MessageDeleteTimeRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 066DF4812D2D31A30092B04E /* MessageDeleteTimeRange.swift */; }; + 066E0C8C2BB6AC9A00873838 /* AddSoundCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 066E0C8B2BB6AC9A00873838 /* AddSoundCell.swift */; }; + 0672CB06256903F700570C9D /* MessageListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0672CB05256903F700570C9D /* MessageListViewModel.swift */; }; + 06787C392A710568008ABDD7 /* GesturePassTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06787C382A710568008ABDD7 /* GesturePassTextView.swift */; }; 067AFB1C2E5D8BE300AE78E7 /* UNNotificationContent+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 067AFB1B2E5D8BE300AE78E7 /* UNNotificationContent+Extension.swift */; }; 067AFB1D2E5D8BED00AE78E7 /* UNNotificationContent+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 067AFB1B2E5D8BE300AE78E7 /* UNNotificationContent+Extension.swift */; }; + 067B2EB525693E38008B6BE1 /* MessageSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 067B2EB425693E38008B6BE1 /* MessageSection.swift */; }; + 06802E5320ECC40C00767047 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0661A549204FDA4100965E4E /* Assets.xcassets */; }; + 06840DBB272298FB001B3193 /* BKColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06840DBA272298FB001B3193 /* BKColor.swift */; }; + 0687F2A82CCB791A00B2A52F /* UIFont+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0687F2A72CCB791A00B2A52F /* UIFont+Extension.swift */; }; + 0687F2A92CCB791A00B2A52F /* UIFont+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0687F2A72CCB791A00B2A52F /* UIFont+Extension.swift */; }; + 0687F2AA2CCB7FA500B2A52F /* UIFont+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0687F2A72CCB791A00B2A52F /* UIFont+Extension.swift */; }; + 06885EB6247FB9880004A303 /* MessageSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06885EB5247FB9880004A303 /* MessageSettingsViewController.swift */; }; + 0689CF4C2C7484A7007203A6 /* BarkTabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0689CF4B2C7484A7007203A6 /* BarkTabBarController.swift */; }; + 068A4B962D2E11CD00982449 /* MessageDeleteTimeRangeTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 068A4B952D2E11CD00982449 /* MessageDeleteTimeRangeTest.swift */; }; + 068EC15827ED99C900D5D11E /* ServerListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 068EC15727ED99C900D5D11E /* ServerListViewController.swift */; }; + 068EC15A27ED99E700D5D11E /* ServerListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 068EC15927ED99E700D5D11E /* ServerListViewModel.swift */; }; + 068F66B3247BD84C00DAD25A /* MessageListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 068F66B2247BD84C00DAD25A /* MessageListViewController.swift */; }; + 069332222E6A8E3100F9387F /* String+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0603706A20E20A7C00F4CA05 /* String+Extension.swift */; }; + 069332232E6A8E3100F9387F /* String+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0603706A20E20A7C00F4CA05 /* String+Extension.swift */; }; + 0699473D2D223094008D5E40 /* CustomTapTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0699473C2D223094008D5E40 /* CustomTapTextView.swift */; }; + 069C6CA42ED03C72007244BB /* MarkdownProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 069C6CA32ED03C72007244BB /* MarkdownProcessor.swift */; }; + 069C6CA52ED03E10007244BB /* MarkdownParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06C777CF2ECEFF210032A044 /* MarkdownParser.swift */; }; 069C6CA72ED03E27007244BB /* Markdown in Frameworks */ = {isa = PBXBuildFile; productRef = 069C6CA62ED03E27007244BB /* Markdown */; }; 069C6CA92ED03F07007244BB /* BKColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06840DBA272298FB001B3193 /* BKColor.swift */; }; 06A100022FEE0001000000A1 /* GlassView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06A100012FEE0001000000A1 /* GlassView.swift */; }; @@ -123,8 +199,95 @@ 06A1001B2FEE0001000000A1 /* HomeFlatButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06A1001A2FEE0001000000A1 /* HomeFlatButton.swift */; }; 06A100212FEE0001000000A1 /* HomeSplitButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06A100202FEE0001000000A1 /* HomeSplitButton.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 */; }; + 06BBB88725650C6C0076F63E /* ArchiveSettingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06C5953224811505006B98F3 /* ArchiveSettingManager.swift */; }; + 06BBB89125650CCF0076F63E /* ArchiveSettingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06C5953224811505006B98F3 /* ArchiveSettingManager.swift */; }; + 06BBB896256518760076F63E /* NewServerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06BBB895256518760076F63E /* NewServerViewModel.swift */; }; + 06BBB8B72567AC140076F63E /* MessageSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06BBB8B62567AC140076F63E /* MessageSettingsViewModel.swift */; }; + 06BBB8BC2567B3AD0076F63E /* ArchiveSettingCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06BBB8BB2567B3AD0076F63E /* ArchiveSettingCellViewModel.swift */; }; + 06BBB8C12567B3EF0076F63E /* BaseTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06BBB8C02567B3EF0076F63E /* BaseTableViewCell.swift */; }; + 06BBB8C92567B6730076F63E /* Operators.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06BBB8C82567B6730076F63E /* Operators.swift */; }; + 06BBB8CE2567B8E60076F63E /* silence.caf in Resources */ = {isa = PBXBuildFile; fileRef = 06BBB8CD2567B8E60076F63E /* silence.caf */; }; + 06BCAE562CDB19260092867A /* GroupMuteSettingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06BCAE552CDB19260092867A /* GroupMuteSettingManager.swift */; }; + 06BCAE572CDB19420092867A /* GroupMuteSettingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06BCAE552CDB19260092867A /* GroupMuteSettingManager.swift */; }; + 06BCAE582CDB19420092867A /* GroupMuteSettingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06BCAE552CDB19260092867A /* GroupMuteSettingManager.swift */; }; + 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 */; }; + 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 */; }; + 06C5953124811392006B98F3 /* ArchiveSettingCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06C5953024811392006B98F3 /* ArchiveSettingCell.swift */; }; + 06C595362481160F006B98F3 /* BKLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06C595352481160F006B98F3 /* BKLabel.swift */; }; + 06C777D02ECEFF210032A044 /* MarkdownParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06C777CF2ECEFF210032A044 /* MarkdownParser.swift */; }; 06C777D32ECF05990032A044 /* Markdown in Frameworks */ = {isa = PBXBuildFile; productRef = 06C777D22ECF05990032A044 /* Markdown */; }; 06CF784721C7A50300A052D7 /* NotificationServiceExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 06CF784021C7A50300A052D7 /* NotificationServiceExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 06CF784C21C7A51200A052D7 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06CF784B21C7A51200A052D7 /* NotificationService.swift */; }; + 06D69E202C1159E200161A35 /* glass.caf in Resources */ = {isa = PBXBuildFile; fileRef = 06320500250B6DD3001561EC /* glass.caf */; }; + 06D69E212C1159E200161A35 /* sherwoodforest.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204F5250B6DD2001561EC /* sherwoodforest.caf */; }; + 06D69E222C1159E200161A35 /* ladder.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204F9250B6DD2001561EC /* ladder.caf */; }; + 06D69E232C1159E200161A35 /* chime.caf in Resources */ = {isa = PBXBuildFile; fileRef = 06320509250B6DD4001561EC /* chime.caf */; }; + 06D69E242C1159E200161A35 /* anticipate.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204FE250B6DD2001561EC /* anticipate.caf */; }; + 06D69E252C1159E200161A35 /* update.caf in Resources */ = {isa = PBXBuildFile; fileRef = 06320504250B6DD3001561EC /* update.caf */; }; + 06D69E262C1159E200161A35 /* suspense.caf in Resources */ = {isa = PBXBuildFile; fileRef = 06320506250B6DD3001561EC /* suspense.caf */; }; + 06D69E272C1159E200161A35 /* newmail.caf in Resources */ = {isa = PBXBuildFile; fileRef = 06320503250B6DD3001561EC /* newmail.caf */; }; + 06D69E282C1159E200161A35 /* noir.caf in Resources */ = {isa = PBXBuildFile; fileRef = 06320508250B6DD3001561EC /* noir.caf */; }; + 06D69E292C1159E200161A35 /* birdsong.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204FC250B6DD2001561EC /* birdsong.caf */; }; + 06D69E2A2C1159E200161A35 /* minuet.caf in Resources */ = {isa = PBXBuildFile; fileRef = 06320505250B6DD3001561EC /* minuet.caf */; }; + 06D69E2B2C1159E200161A35 /* shake.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204F2250B6DD1001561EC /* shake.caf */; }; + 06D69E2C2C1159E200161A35 /* newsflash.caf in Resources */ = {isa = PBXBuildFile; fileRef = 0632050E250B6DD4001561EC /* newsflash.caf */; }; + 06D69E2D2C1159E200161A35 /* paymentsuccess.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204F1250B6DD1001561EC /* paymentsuccess.caf */; }; + 06D69E2E2C1159E200161A35 /* descent.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204F8250B6DD2001561EC /* descent.caf */; }; + 06D69E2F2C1159E200161A35 /* mailsent.caf in Resources */ = {isa = PBXBuildFile; fileRef = 06320507250B6DD3001561EC /* mailsent.caf */; }; + 06D69E302C1159E200161A35 /* tiptoes.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204FA250B6DD2001561EC /* tiptoes.caf */; }; + 06D69E312C1159E200161A35 /* telegraph.caf in Resources */ = {isa = PBXBuildFile; fileRef = 06320501250B6DD3001561EC /* telegraph.caf */; }; + 06D69E322C1159E200161A35 /* healthnotification.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204F6250B6DD2001561EC /* healthnotification.caf */; }; + 06D69E332C1159E200161A35 /* typewriters.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204FD250B6DD2001561EC /* typewriters.caf */; }; + 06D69E342C1159E200161A35 /* bell.caf in Resources */ = {isa = PBXBuildFile; fileRef = 0632050C250B6DD4001561EC /* bell.caf */; }; + 06D69E352C1159E200161A35 /* bloom.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204F4250B6DD1001561EC /* bloom.caf */; }; + 06D69E362C1159E200161A35 /* spell.caf in Resources */ = {isa = PBXBuildFile; fileRef = 0632050A250B6DD4001561EC /* spell.caf */; }; + 06D69E372C1159E200161A35 /* choo.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204FF250B6DD3001561EC /* choo.caf */; }; + 06D69E382C1159E200161A35 /* multiwayinvitation.caf in Resources */ = {isa = PBXBuildFile; fileRef = 06320502250B6DD3001561EC /* multiwayinvitation.caf */; }; + 06D69E392C1159E200161A35 /* horn.caf in Resources */ = {isa = PBXBuildFile; fileRef = 0632050D250B6DD4001561EC /* horn.caf */; }; + 06D69E3A2C1159E200161A35 /* electronic.caf in Resources */ = {isa = PBXBuildFile; fileRef = 0632050B250B6DD4001561EC /* electronic.caf */; }; + 06D69E3B2C1159E200161A35 /* calypso.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204F7250B6DD2001561EC /* calypso.caf */; }; + 06D69E3C2C1159E200161A35 /* gotosleep.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204F0250B6DD1001561EC /* gotosleep.caf */; }; + 06D69E3D2C1159E200161A35 /* silence.caf in Resources */ = {isa = PBXBuildFile; fileRef = 06BBB8CD2567B8E60076F63E /* silence.caf */; }; + 06D69E3E2C1159E200161A35 /* alarm.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204F3250B6DD1001561EC /* alarm.caf */; }; + 06D69E3F2C1159E200161A35 /* fanfare.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204FB250B6DD2001561EC /* fanfare.caf */; }; + 06D69E412C11983E00161A35 /* CallProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06D69E402C11983E00161A35 /* CallProcessor.swift */; }; + 06E62C112D670F62004DC82B /* PushToCurrentIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06E62C102D670F5D004DC82B /* PushToCurrentIntent.swift */; }; + 06E944682C06E40600AC86AB /* NotificationContentProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06E944672C06E40600AC86AB /* NotificationContentProcessor.swift */; }; + 06E9446A2C06E4A200AC86AB /* CiphertextProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06E944692C06E4A200AC86AB /* CiphertextProcessor.swift */; }; + 06E9446D2C06FEC900AC86AB /* LevelProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06E9446C2C06FEC900AC86AB /* LevelProcessor.swift */; }; + 06E9446F2C06FF1E00AC86AB /* BadgeProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06E9446E2C06FF1E00AC86AB /* BadgeProcessor.swift */; }; + 06E944712C06FF4C00AC86AB /* AutoCopyProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06E944702C06FF4C00AC86AB /* AutoCopyProcessor.swift */; }; + 06E944732C06FF9200AC86AB /* ArchiveProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06E944722C06FF9200AC86AB /* ArchiveProcessor.swift */; }; + 06E944752C07012E00AC86AB /* RealmConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06E944742C07012E00AC86AB /* RealmConfiguration.swift */; }; + 06E944762C07013000AC86AB /* RealmConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06E944742C07012E00AC86AB /* RealmConfiguration.swift */; }; + 06E944782C0701F300AC86AB /* IconProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06E944772C0701F300AC86AB /* IconProcessor.swift */; }; + 06E9447A2C0704E500AC86AB /* ImageDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06E944792C0704E500AC86AB /* ImageDownloader.swift */; }; + 06E9447C2C07052F00AC86AB /* ImageProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06E9447B2C07052F00AC86AB /* ImageProcessor.swift */; }; + 06EE1FD326843E9300586708 /* BarkTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06EE1FD226843E9300586708 /* BarkTests.swift */; }; + 06EEF333291CCFF400CA228A /* CryptoSettingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06EEF332291CCFF400CA228A /* CryptoSettingController.swift */; }; + 06EEF335291CD00000CA228A /* CryptoSettingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06EEF334291CD00000CA228A /* CryptoSettingViewModel.swift */; }; + 06EF49152D682A99008B91D2 /* PushResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06EF49142D682A96008B91D2 /* PushResponse.swift */; }; + 06EF49172D682AC4008B91D2 /* OptionsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06EF49162D682AC3008B91D2 /* OptionsProvider.swift */; }; + 06EF49192D682B3B008B91D2 /* PushToOtherIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06EF49182D682B34008B91D2 /* PushToOtherIntent.swift */; }; + 06F08EA429B098DD006AB9CA /* CryptoSettingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06F08EA329B098DD006AB9CA /* CryptoSettingManager.swift */; }; + 06F08EA529B1DDA7006AB9CA /* Algorithm.swift in Sources */ = {isa = PBXBuildFile; fileRef = 061894C629A75BEA00E001C2 /* Algorithm.swift */; }; + 06F08EA729B1DDFE006AB9CA /* Error+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06F08EA629B1DDFE006AB9CA /* Error+Extension.swift */; }; + 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 */; }; + 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 */; }; + 19BE8EBF2D03514A009BF080 /* ImageDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06E944792C0704E500AC86AB /* ImageDownloader.swift */; }; + 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 */; }; 3428272069AFAFE2C683FEB0 /* libPods-Bark.a in Frameworks */ = {isa = PBXBuildFile; fileRef = CCC722470308049D180876C7 /* libPods-Bark.a */; }; 3DBD074B306B95A100A86B41 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3DBD074A306B95A100A86B41 /* WidgetKit.framework */; }; 3DBD074D306B95A100A86B41 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3DBD074C306B95A100A86B41 /* SwiftUI.framework */; }; @@ -139,6 +302,12 @@ 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 */; }; + 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 */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -279,10 +448,82 @@ 000000000000000000000032 /* BarkWatch.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = BarkWatch.entitlements; sourceTree = ""; }; 000000000000000000000033 /* TestNotification.apns */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = TestNotification.apns; sourceTree = ""; }; 000000000000000000000034 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; + 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 = ""; }; + 060481ED250F404500BC9799 /* SoundsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundsViewController.swift; sourceTree = ""; }; + 060481EF250F51CA00BC9799 /* SoundCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundCell.swift; sourceTree = ""; }; + 0604F7DE20620D4900B32F09 /* ServerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerManager.swift; sourceTree = ""; }; + 0608F06D2994D115006B8029 /* BKDropDownCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BKDropDownCell.swift; sourceTree = ""; }; + 0608F0712994D269006B8029 /* BKDropDownCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = BKDropDownCell.xib; sourceTree = ""; }; + 06172FD927F6DAEF002333A4 /* ServerListTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListTableViewCell.swift; sourceTree = ""; }; + 06172FDB27F6DB06002333A4 /* ServerListTableViewCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListTableViewCellViewModel.swift; sourceTree = ""; }; + 061894C429962EB900E001C2 /* GradientButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GradientButton.swift; sourceTree = ""; }; + 061894C629A75BEA00E001C2 /* Algorithm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Algorithm.swift; sourceTree = ""; }; + 061C17072D1BDA4B00891D66 /* MessageGroupMoreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageGroupMoreView.swift; sourceTree = ""; }; + 061E35852D1E5028009A2D6F /* MessageItemModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageItemModel.swift; sourceTree = ""; }; + 0627DABA298B6EA2002F3F69 /* DropBoxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DropBoxView.swift; sourceTree = ""; }; + 0627DABC2990D615002F3F69 /* BorderTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BorderTextField.swift; sourceTree = ""; }; + 062B98C2251B2762004562E7 /* BKButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BKButton.swift; sourceTree = ""; }; + 062B98C7251B27AE004562E7 /* UINavigationItem+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UINavigationItem+Extension.swift"; sourceTree = ""; }; + 063204F0250B6DD1001561EC /* gotosleep.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = gotosleep.caf; sourceTree = ""; }; + 063204F1250B6DD1001561EC /* paymentsuccess.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = paymentsuccess.caf; sourceTree = ""; }; + 063204F2250B6DD1001561EC /* shake.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = shake.caf; sourceTree = ""; }; + 063204F3250B6DD1001561EC /* alarm.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = alarm.caf; sourceTree = ""; }; + 063204F4250B6DD1001561EC /* bloom.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = bloom.caf; sourceTree = ""; }; + 063204F5250B6DD2001561EC /* sherwoodforest.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = sherwoodforest.caf; sourceTree = ""; }; + 063204F6250B6DD2001561EC /* healthnotification.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = healthnotification.caf; sourceTree = ""; }; + 063204F7250B6DD2001561EC /* calypso.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = calypso.caf; sourceTree = ""; }; + 063204F8250B6DD2001561EC /* descent.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = descent.caf; sourceTree = ""; }; + 063204F9250B6DD2001561EC /* ladder.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = ladder.caf; sourceTree = ""; }; + 063204FA250B6DD2001561EC /* tiptoes.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = tiptoes.caf; sourceTree = ""; }; + 063204FB250B6DD2001561EC /* fanfare.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = fanfare.caf; sourceTree = ""; }; + 063204FC250B6DD2001561EC /* birdsong.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = birdsong.caf; sourceTree = ""; }; + 063204FD250B6DD2001561EC /* typewriters.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = typewriters.caf; sourceTree = ""; }; + 063204FE250B6DD2001561EC /* anticipate.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = anticipate.caf; sourceTree = ""; }; + 063204FF250B6DD3001561EC /* choo.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = choo.caf; sourceTree = ""; }; + 06320500250B6DD3001561EC /* glass.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = glass.caf; sourceTree = ""; }; + 06320501250B6DD3001561EC /* telegraph.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = telegraph.caf; sourceTree = ""; }; + 06320502250B6DD3001561EC /* multiwayinvitation.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = multiwayinvitation.caf; sourceTree = ""; }; + 06320503250B6DD3001561EC /* newmail.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = newmail.caf; sourceTree = ""; }; + 06320504250B6DD3001561EC /* update.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = update.caf; sourceTree = ""; }; + 06320505250B6DD3001561EC /* minuet.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = minuet.caf; sourceTree = ""; }; + 06320506250B6DD3001561EC /* suspense.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = suspense.caf; sourceTree = ""; }; + 06320507250B6DD3001561EC /* mailsent.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = mailsent.caf; sourceTree = ""; }; + 06320508250B6DD3001561EC /* noir.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = noir.caf; sourceTree = ""; }; + 06320509250B6DD4001561EC /* chime.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = chime.caf; sourceTree = ""; }; + 0632050A250B6DD4001561EC /* spell.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = spell.caf; sourceTree = ""; }; + 0632050B250B6DD4001561EC /* electronic.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = electronic.caf; sourceTree = ""; }; + 0632050C250B6DD4001561EC /* bell.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = bell.caf; sourceTree = ""; }; + 0632050D250B6DD4001561EC /* horn.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = horn.caf; sourceTree = ""; }; + 0632050E250B6DD4001561EC /* newsflash.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = newsflash.caf; sourceTree = ""; }; 0632CE1E20EC9098003FDF46 /* NotificationContentExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationContentExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 0632CE2220EC9098003FDF46 /* NotificationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationViewController.swift; sourceTree = ""; }; 0632CE2520EC9098003FDF46 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = ""; }; 0632CE2720EC9098003FDF46 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 0633E809256A091B00ED0680 /* MJRefresh+Rx.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MJRefresh+Rx.swift"; sourceTree = ""; }; + 0635A8042CE47DFE0027E00F /* SettingSectionHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingSectionHeader.swift; sourceTree = ""; }; + 0635A8062CE4883A0027E00F /* DonateCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DonateCell.swift; sourceTree = ""; }; + 0637FA7720E0926D00E80174 /* BarkTargetType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarkTargetType.swift; sourceTree = ""; }; + 0637FA7920E092B300E80174 /* Observable+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Observable+Extension.swift"; sourceTree = ""; }; + 0637FA7B20E0930E00E80174 /* BarkApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarkApi.swift; sourceTree = ""; }; + 0637FA7D20E0969800E80174 /* Client.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Client.swift; sourceTree = ""; }; + 0637FA7F20E0981E00E80174 /* BarkSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarkSettings.swift; sourceTree = ""; }; + 0637FA8120E09C4B00E80174 /* BarkNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarkNavigationController.swift; sourceTree = ""; }; + 0637FA8520E0AB6600E80174 /* UIColor+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+Extension.swift"; sourceTree = ""; }; + 0637FA8920E0D58800E80174 /* NewServerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewServerViewController.swift; sourceTree = ""; }; + 0637FA8B20E0D7A700E80174 /* BaseViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseViewController.swift; sourceTree = ""; }; + 0642B55927EB13F100453D91 /* MutableTextCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MutableTextCell.swift; sourceTree = ""; }; + 0642B55B27EB149900453D91 /* MutableTextCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MutableTextCellViewModel.swift; sourceTree = ""; }; + 0647DB672CE604AA00102066 /* MessageSettingFooter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageSettingFooter.swift; sourceTree = ""; }; + 0653677729B727A60038BDB8 /* CryptoSettingRelay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoSettingRelay.swift; sourceTree = ""; }; + 065752C02CB6844A001426D8 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; + 065AE76A2987777F00323230 /* ArchiveSettingRelay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSettingRelay.swift; sourceTree = ""; }; + 065BE43F2563D649002A8CA4 /* SoundsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundsViewModel.swift; sourceTree = ""; }; + 065BE4452563D7E5002A8CA4 /* ViewModelType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelType.swift; sourceTree = ""; }; + 065BE44A2563D8E1002A8CA4 /* Reusable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Reusable.swift; sourceTree = ""; }; + 065BE44F2563D939002A8CA4 /* SoundCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundCellViewModel.swift; sourceTree = ""; }; + 065BE4542565055F002A8CA4 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = ""; }; 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 = ""; }; @@ -334,11 +575,61 @@ 06A1001A2FEE0001000000A1 /* HomeFlatButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeFlatButton.swift; sourceTree = ""; }; 06A100202FEE0001000000A1 /* HomeSplitButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeSplitButton.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 = ""; }; + 06BBB895256518760076F63E /* NewServerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewServerViewModel.swift; sourceTree = ""; }; + 06BBB8B62567AC140076F63E /* MessageSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageSettingsViewModel.swift; sourceTree = ""; }; + 06BBB8BB2567B3AD0076F63E /* ArchiveSettingCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSettingCellViewModel.swift; sourceTree = ""; }; + 06BBB8C02567B3EF0076F63E /* BaseTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseTableViewCell.swift; sourceTree = ""; }; + 06BBB8C82567B6730076F63E /* Operators.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Operators.swift; sourceTree = ""; }; + 06BBB8CD2567B8E60076F63E /* silence.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = silence.caf; sourceTree = ""; }; + 06BCAE552CDB19260092867A /* GroupMuteSettingManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMuteSettingManager.swift; sourceTree = ""; }; 06BCAE592CDB19590092867A /* NotificationContentExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NotificationContentExtension.entitlements; sourceTree = ""; }; + 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 = ""; }; + 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 = ""; }; + 06C5953024811392006B98F3 /* ArchiveSettingCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSettingCell.swift; sourceTree = ""; }; + 06C5953224811505006B98F3 /* ArchiveSettingManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSettingManager.swift; sourceTree = ""; }; + 06C595352481160F006B98F3 /* BKLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BKLabel.swift; sourceTree = ""; }; + 06C777CF2ECEFF210032A044 /* MarkdownParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownParser.swift; sourceTree = ""; }; 06CF784021C7A50300A052D7 /* NotificationServiceExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationServiceExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 06CF784421C7A50300A052D7 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 06CF784B21C7A51200A052D7 /* NotificationService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; + 06D69E402C11983E00161A35 /* CallProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallProcessor.swift; sourceTree = ""; }; + 06E62C102D670F5D004DC82B /* PushToCurrentIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushToCurrentIntent.swift; sourceTree = ""; }; + 06E944672C06E40600AC86AB /* NotificationContentProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationContentProcessor.swift; sourceTree = ""; }; + 06E944692C06E4A200AC86AB /* CiphertextProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CiphertextProcessor.swift; sourceTree = ""; }; + 06E9446C2C06FEC900AC86AB /* LevelProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LevelProcessor.swift; sourceTree = ""; }; + 06E9446E2C06FF1E00AC86AB /* BadgeProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeProcessor.swift; sourceTree = ""; }; + 06E944702C06FF4C00AC86AB /* AutoCopyProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCopyProcessor.swift; sourceTree = ""; }; + 06E944722C06FF9200AC86AB /* ArchiveProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveProcessor.swift; sourceTree = ""; }; + 06E944742C07012E00AC86AB /* RealmConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RealmConfiguration.swift; sourceTree = ""; }; + 06E944772C0701F300AC86AB /* IconProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconProcessor.swift; sourceTree = ""; }; + 06E944792C0704E500AC86AB /* ImageDownloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDownloader.swift; sourceTree = ""; }; + 06E9447B2C07052F00AC86AB /* ImageProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageProcessor.swift; sourceTree = ""; }; 06EE1FD026843E9300586708 /* BarkTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BarkTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 06EE1FD226843E9300586708 /* BarkTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarkTests.swift; sourceTree = ""; }; + 06EE1FD426843E9300586708 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 06EEF332291CCFF400CA228A /* CryptoSettingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoSettingController.swift; sourceTree = ""; }; + 06EEF334291CD00000CA228A /* CryptoSettingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoSettingViewModel.swift; sourceTree = ""; }; + 06EF49142D682A96008B91D2 /* PushResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushResponse.swift; sourceTree = ""; }; + 06EF49162D682AC3008B91D2 /* OptionsProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptionsProvider.swift; sourceTree = ""; }; + 06EF49182D682B34008B91D2 /* PushToOtherIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushToOtherIntent.swift; sourceTree = ""; }; + 06F08EA329B098DD006AB9CA /* CryptoSettingManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoSettingManager.swift; sourceTree = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 3D23D570307AEB510087F74C /* BarkWatch.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = BarkWatch.xcconfig; sourceTree = ""; }; 3DBD0748306B95A100A86B41 /* BarkWatchComplicationExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = BarkWatchComplicationExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 3DBD074A306B95A100A86B41 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; 3DBD074C306B95A100A86B41 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; @@ -371,6 +662,7 @@ isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( WatchArchive.swift, + WatchConstants.swift, ); target = 3DBD0747306B95A100A86B41 /* BarkWatchComplicationExtension */; }; @@ -381,129 +673,6 @@ ); target = 3DBD0747306B95A100A86B41 /* BarkWatchComplicationExtension */; }; - 3DE24150306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - GroupMuteSettingManager.swift, - "String+Extension.swift", - "UIFont+Extension.swift", - ); - target = 0632CE1D20EC9098003FDF46 /* NotificationContentExtension */; - }; - 3DE24151306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - ArchiveSettingManager.swift, - BKColor.swift, - CryptoSettingManager.swift, - "Error+Extension.swift", - GroupMuteSettingManager.swift, - MarkdownParser.swift, - RealmConfiguration.swift, - SharedDefines.swift, - "String+Extension.swift", - "UIFont+Extension.swift", - WidgetHistoryModels.swift, - WidgetHistorySnapshotStore.swift, - WidgetHistorySnapshotSupport.swift, - ); - target = 06CF783F21C7A50300A052D7 /* NotificationServiceExtension */; - }; - 3DE24152306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - Processor/ImageDownloader.swift, - ); - target = 0632CE1D20EC9098003FDF46 /* NotificationContentExtension */; - }; - 3DE24153306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - Info.plist, - ); - target = 06CF783F21C7A50300A052D7 /* NotificationServiceExtension */; - }; - 3DE24154306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - WidgetHistoryModels.swift, - ); - target = 06697FD82F70ED9400C4680E /* WidgetExtension */; - }; - 3DE24155306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - MarkdownParser.swift, - "UIFont+Extension.swift", - ); - target = 000000000000000000000004 /* BarkWatch */; - }; - 3DE24156306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - MarkdownParser.swift, - "UIFont+Extension.swift", - ); - target = A10000000000000000000010 /* BarkWatchNotificationService */; - }; - 3DE24157306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - Algorithm.swift, - Message.swift, - ); - target = 06CF783F21C7A50300A052D7 /* NotificationServiceExtension */; - }; - 3DE24158306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - Algorithm.swift, - ); - target = 000000000000000000000004 /* BarkWatch */; - }; - 3DE24159306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - Algorithm.swift, - ); - target = A10000000000000000000010 /* BarkWatchNotificationService */; - }; - 3DE2415A306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - Info.plist, - ); - target = 0661A53E204FDA4100965E4E /* Bark */; - }; - 3DE2415B306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - Assets.xcassets, - Localizable.xcstrings, - ); - target = 0632CE1D20EC9098003FDF46 /* NotificationContentExtension */; - }; - 3DE2415C306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - Localizable.xcstrings, - ); - target = 06CF783F21C7A50300A052D7 /* NotificationServiceExtension */; - }; - 3DE2415D306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - Localizable.xcstrings, - ); - target = 06697FD82F70ED9400C4680E /* WidgetExtension */; - }; - 3DE2415E306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - Info.plist, - ); - target = 06EE1FCF26843E9300586708 /* BarkTests */; - }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -513,14 +682,6 @@ 3D099DD6306DBCFB00A96591 /* Common */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (3D099DDE306DBCFC00A96591 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Common; sourceTree = ""; }; 3D2E504F306D002800D53E4F /* View */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = View; sourceTree = ""; }; 3DBD074E306B95A100A86B41 /* BarkWatchComplication */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (3DBD075B306B95A300A86B41 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = BarkWatchComplication; sourceTree = ""; }; - 3DE23FE2306DE84F00066C57 /* Sounds */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Sounds; sourceTree = ""; }; - 3DE24039306DE84F00066C57 /* Controller */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Controller; sourceTree = ""; }; - 3DE24060306DE84F00066C57 /* NotificationServiceExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (3DE24152306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 3DE24153306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = NotificationServiceExtension; sourceTree = ""; }; - 3DE2409E306DE84F00066C57 /* View */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = View; sourceTree = ""; }; - 3DE240E9306DE84F00066C57 /* Common */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (3DE24150306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 3DE24151306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 3DE24154306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 3DE24155306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 3DE24156306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Common; sourceTree = ""; }; - 3DE2411F306DE84F00066C57 /* BarkTests */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (3DE2415E306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = BarkTests; sourceTree = ""; }; - 3DE24130306DE84F00066C57 /* Bark */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (3DE2415A306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 3DE2415B306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 3DE2415C306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 3DE2415D306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Bark; sourceTree = ""; }; - 3DE24145306DE84F00066C57 /* Model */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (3DE24157306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 3DE24158306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 3DE24159306DE85000066C57 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Model; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -625,11 +786,130 @@ 000000000000000000000034 /* README.md */, 3DBD0749306B95A100A86B41 /* Frameworks */, A10000000000000000000002 /* WatchNotificationService.swift */, + 3D23D570307AEB510087F74C /* BarkWatch.xcconfig */, ); path = BarkWatch; sourceTree = ""; }; - 0632CE2120EC9098003FDF46 /* notificationContentExtension */ = { + 0604F7DB20620D2700B32F09 /* Controller */ = { + isa = PBXGroup; + children = ( + 0637FA8B20E0D7A700E80174 /* BaseViewController.swift */, + 0637FA8120E09C4B00E80174 /* BarkNavigationController.swift */, + 0603706C20E23EC000F4CA05 /* BarkSFSafariViewController.swift */, + 0661A544204FDA4100965E4E /* HomeViewController.swift */, + 065BE4542565055F002A8CA4 /* HomeViewModel.swift */, + 0637FA8920E0D58800E80174 /* NewServerViewController.swift */, + 06BBB895256518760076F63E /* NewServerViewModel.swift */, + 068F66B2247BD84C00DAD25A /* MessageListViewController.swift */, + 0672CB05256903F700570C9D /* MessageListViewModel.swift */, + 06885EB5247FB9880004A303 /* MessageSettingsViewController.swift */, + 06BBB8B62567AC140076F63E /* MessageSettingsViewModel.swift */, + 060481ED250F404500BC9799 /* SoundsViewController.swift */, + 065BE43F2563D649002A8CA4 /* SoundsViewModel.swift */, + 06F11E7627D9D5FB00F00298 /* QRScannerViewController.swift */, + 068EC15727ED99C900D5D11E /* ServerListViewController.swift */, + 068EC15927ED99E700D5D11E /* ServerListViewModel.swift */, + 06EEF332291CCFF400CA228A /* CryptoSettingController.swift */, + 06EEF334291CD00000CA228A /* CryptoSettingViewModel.swift */, + 1E73F99D2C282822002BF649 /* SectionViewController-iPad.swift */, + 1EFB545E2C32514000B8E51B /* SectionViewModel-iPad.swift */, + 1EFB545C2C314A6800B8E51B /* BarkSplitViewController.swift */, + 0689CF4B2C7484A7007203A6 /* BarkTabBarController.swift */, + ); + path = Controller; + sourceTree = ""; + }; + 0604F7DC20620D3400B32F09 /* View */ = { + isa = PBXGroup; + children = ( + 06A100092FEE0001000000A1 /* Home */, + 066890092D19495400E106F2 /* MessageList */, + 06A100012FEE0001000000A1 /* GlassView.swift */, + 06BBB8C02567B3EF0076F63E /* BaseTableViewCell.swift */, + 06C5952C2480E3F8006B98F3 /* LabelCell.swift */, + 06C5953024811392006B98F3 /* ArchiveSettingCell.swift */, + 06BBB8BB2567B3AD0076F63E /* ArchiveSettingCellViewModel.swift */, + 060481EF250F51CA00BC9799 /* SoundCell.swift */, + 065BE44F2563D939002A8CA4 /* SoundCellViewModel.swift */, + 066E0C8B2BB6AC9A00873838 /* AddSoundCell.swift */, + 062B98C2251B2762004562E7 /* BKButton.swift */, + 06C595352481160F006B98F3 /* BKLabel.swift */, + 062B98C7251B27AE004562E7 /* UINavigationItem+Extension.swift */, + 06C2CF222685B88D0034B127 /* TextCell.swift */, + 06C2CF242685BDB80034B127 /* SpacerCell.swift */, + 0642B55927EB13F100453D91 /* MutableTextCell.swift */, + 0642B55B27EB149900453D91 /* MutableTextCellViewModel.swift */, + 06172FD927F6DAEF002333A4 /* ServerListTableViewCell.swift */, + 06172FDB27F6DB06002333A4 /* ServerListTableViewCellViewModel.swift */, + 0627DABA298B6EA2002F3F69 /* DropBoxView.swift */, + 0627DABC2990D615002F3F69 /* BorderTextField.swift */, + 0608F06D2994D115006B8029 /* BKDropDownCell.swift */, + 0608F0712994D269006B8029 /* BKDropDownCell.xib */, + 061894C429962EB900E001C2 /* GradientButton.swift */, + 06F08EAE29B5D9FF006AB9CA /* HUD.swift */, + 06787C382A710568008ABDD7 /* GesturePassTextView.swift */, + 0635A8042CE47DFE0027E00F /* SettingSectionHeader.swift */, + 0635A8062CE4883A0027E00F /* DonateCell.swift */, + 0647DB672CE604AA00102066 /* MessageSettingFooter.swift */, + 06BE84032E6EAE7100E6F856 /* InsetView.swift */, + ); + path = View; + sourceTree = ""; + }; + 0604F7DD20620D3800B32F09 /* Model */ = { + isa = PBXGroup; + children = ( + 066DF4812D2D31A30092B04E /* MessageDeleteTimeRange.swift */, + 06B1158E247BB1FB006D91FB /* Message.swift */, + 067B2EB425693E38008B6BE1 /* MessageSection.swift */, + 061E35852D1E5028009A2D6F /* MessageItemModel.swift */, + 06BD4DA92901352E003364DB /* Object+Dictionary.swift */, + 061894C629A75BEA00E001C2 /* Algorithm.swift */, + ); + path = Model; + sourceTree = ""; + }; + 063204EF250B6DC2001561EC /* Sounds */ = { + isa = PBXGroup; + children = ( + 06BBB8CD2567B8E60076F63E /* silence.caf */, + 063204F3250B6DD1001561EC /* alarm.caf */, + 063204FE250B6DD2001561EC /* anticipate.caf */, + 0632050C250B6DD4001561EC /* bell.caf */, + 063204FC250B6DD2001561EC /* birdsong.caf */, + 063204F4250B6DD1001561EC /* bloom.caf */, + 063204F7250B6DD2001561EC /* calypso.caf */, + 06320509250B6DD4001561EC /* chime.caf */, + 063204FF250B6DD3001561EC /* choo.caf */, + 063204F8250B6DD2001561EC /* descent.caf */, + 0632050B250B6DD4001561EC /* electronic.caf */, + 063204FB250B6DD2001561EC /* fanfare.caf */, + 06320500250B6DD3001561EC /* glass.caf */, + 063204F0250B6DD1001561EC /* gotosleep.caf */, + 063204F6250B6DD2001561EC /* healthnotification.caf */, + 0632050D250B6DD4001561EC /* horn.caf */, + 063204F9250B6DD2001561EC /* ladder.caf */, + 06320507250B6DD3001561EC /* mailsent.caf */, + 06320505250B6DD3001561EC /* minuet.caf */, + 06320502250B6DD3001561EC /* multiwayinvitation.caf */, + 06320503250B6DD3001561EC /* newmail.caf */, + 0632050E250B6DD4001561EC /* newsflash.caf */, + 06320508250B6DD3001561EC /* noir.caf */, + 063204F1250B6DD1001561EC /* paymentsuccess.caf */, + 063204F2250B6DD1001561EC /* shake.caf */, + 063204F5250B6DD2001561EC /* sherwoodforest.caf */, + 0632050A250B6DD4001561EC /* spell.caf */, + 06320506250B6DD3001561EC /* suspense.caf */, + 06320501250B6DD3001561EC /* telegraph.caf */, + 063204FA250B6DD2001561EC /* tiptoes.caf */, + 063204FD250B6DD2001561EC /* typewriters.caf */, + 06320504250B6DD3001561EC /* update.caf */, + ); + path = Sounds; + sourceTree = ""; + }; + 0632CE2120EC9098003FDF46 /* NotificationContentExtension */ = { isa = PBXGroup; children = ( 06BCAE592CDB19590092867A /* NotificationContentExtension.entitlements */, @@ -690,15 +970,15 @@ 0661A536204FDA4100965E4E = { isa = PBXGroup; children = ( - 3DE23FE2306DE84F00066C57 /* Sounds */, - 3DE240E9306DE84F00066C57 /* Common */, - 3DE24145306DE84F00066C57 /* Model */, - 3DE2409E306DE84F00066C57 /* View */, - 3DE24039306DE84F00066C57 /* Controller */, - 3DE24130306DE84F00066C57 /* Bark */, - 0632CE2120EC9098003FDF46 /* notificationContentExtension */, - 3DE24060306DE84F00066C57 /* NotificationServiceExtension */, - 3DE2411F306DE84F00066C57 /* BarkTests */, + 063204EF250B6DC2001561EC /* Sounds */, + 065BE4442563D7AA002A8CA4 /* Common */, + 0604F7DD20620D3800B32F09 /* Model */, + 0604F7DC20620D3400B32F09 /* View */, + 0604F7DB20620D2700B32F09 /* Controller */, + 0661A541204FDA4100965E4E /* Bark */, + 0632CE2120EC9098003FDF46 /* NotificationContentExtension */, + 06CF784121C7A50300A052D7 /* NotificationServiceExtension */, + 06EE1FD126843E9300586708 /* BarkTests */, 06697FDE2F70ED9400C4680E /* Widget */, 000000000000000000000002 /* BarkWatch */, 0661A540204FDA4100965E4E /* Products */, @@ -723,6 +1003,103 @@ name = Products; sourceTree = ""; }; + 0661A541204FDA4100965E4E /* Bark */ = { + isa = PBXGroup; + children = ( + 06EF49132D682A82008B91D2 /* Intents */, + 0683486A2050F1310024B6DA /* Bark.entitlements */, + 0661A542204FDA4100965E4E /* AppDelegate.swift */, + 06A200012FED000100000001 /* SceneDelegate.swift */, + 06F808902EF4056C0007352A /* AppDelegate+Realm.swift */, + 0661A549204FDA4100965E4E /* Assets.xcassets */, + 0661A54B204FDA4100965E4E /* LaunchScreen.storyboard */, + 0661A54E204FDA4100965E4E /* Info.plist */, + 065752C02CB6844A001426D8 /* Localizable.xcstrings */, + ); + path = Bark; + sourceTree = ""; + }; + 066890092D19495400E106F2 /* MessageList */ = { + isa = PBXGroup; + children = ( + 0699473C2D223094008D5E40 /* CustomTapTextView.swift */, + 066890072D1946D500E106F2 /* MessageItemView.swift */, + 0667D191247D162C005DE2ED /* MessageTableViewCell.swift */, + 0668900A2D19525400E106F2 /* ShowLessAndClearView.swift */, + 0668900C2D19582400E106F2 /* MessageGroupHeaderView.swift */, + 0668900E2F751A1000E106F2 /* MessageListSkeletonView.swift */, + 061C17072D1BDA4B00891D66 /* MessageGroupMoreView.swift */, + ); + path = MessageList; + sourceTree = ""; + }; + 06A100092FEE0001000000A1 /* Home */ = { + isa = PBXGroup; + children = ( + 06A100032FEE0001000000A1 /* HomeGlassButton.swift */, + 06A1001A2FEE0001000000A1 /* HomeFlatButton.swift */, + 06A100202FEE0001000000A1 /* HomeSplitButton.swift */, + 06A100052FEE0001000000A1 /* HomeExampleSegmentedControl.swift */, + 06A100072FEE0001000000A1 /* HomeSettingRow.swift */, + 06A100102FEE0001000000A1 /* HomePermissionCard.swift */, + 06A100122FEE0001000000A1 /* HomeExampleCard.swift */, + 06A100142FEE0001000000A1 /* HomeSettingsCard.swift */, + 06A100162FEE0001000000A1 /* HomeDocumentsCard.swift */, + ); + path = Home; + sourceTree = ""; + }; + 06CF784121C7A50300A052D7 /* NotificationServiceExtension */ = { + isa = PBXGroup; + children = ( + 06E9446B2C06E4A800AC86AB /* Processor */, + 06B11590247BBC15006D91FB /* NotificationServiceExtension.entitlements */, + 06CF784B21C7A51200A052D7 /* NotificationService.swift */, + 06CF784421C7A50300A052D7 /* Info.plist */, + ); + path = NotificationServiceExtension; + sourceTree = ""; + }; + 06E9446B2C06E4A800AC86AB /* Processor */ = { + isa = PBXGroup; + children = ( + 06E944672C06E40600AC86AB /* NotificationContentProcessor.swift */, + 06E944692C06E4A200AC86AB /* CiphertextProcessor.swift */, + 06E9446C2C06FEC900AC86AB /* LevelProcessor.swift */, + 06E9446E2C06FF1E00AC86AB /* BadgeProcessor.swift */, + 06E944702C06FF4C00AC86AB /* AutoCopyProcessor.swift */, + 06E944722C06FF9200AC86AB /* ArchiveProcessor.swift */, + 06E944772C0701F300AC86AB /* IconProcessor.swift */, + 06E9447B2C07052F00AC86AB /* ImageProcessor.swift */, + 06D69E402C11983E00161A35 /* CallProcessor.swift */, + 06E944792C0704E500AC86AB /* ImageDownloader.swift */, + 06BCAE5A2CDB25120092867A /* MuteProcessor.swift */, + 069C6CA32ED03C72007244BB /* MarkdownProcessor.swift */, + ); + path = Processor; + sourceTree = ""; + }; + 06EE1FD126843E9300586708 /* BarkTests */ = { + isa = PBXGroup; + children = ( + 06EE1FD226843E9300586708 /* BarkTests.swift */, + 06EE1FD426843E9300586708 /* Info.plist */, + 068A4B952D2E11CD00982449 /* MessageDeleteTimeRangeTest.swift */, + ); + path = BarkTests; + sourceTree = ""; + }; + 06EF49132D682A82008B91D2 /* Intents */ = { + isa = PBXGroup; + children = ( + 06EF49162D682AC3008B91D2 /* OptionsProvider.swift */, + 06EF49142D682A96008B91D2 /* PushResponse.swift */, + 06E62C102D670F5D004DC82B /* PushToCurrentIntent.swift */, + 06EF49182D682B34008B91D2 /* PushToOtherIntent.swift */, + ); + path = Intents; + sourceTree = ""; + }; 3DBD0749306B95A100A86B41 /* Frameworks */ = { isa = PBXGroup; children = ( @@ -860,14 +1237,6 @@ 0632CE2920EC9098003FDF46 /* PBXTargetDependency */, 06CF784621C7A50300A052D7 /* PBXTargetDependency */, ); - fileSystemSynchronizedGroups = ( - 3DE23FE2306DE84F00066C57 /* Sounds */, - 3DE24039306DE84F00066C57 /* Controller */, - 3DE2409E306DE84F00066C57 /* View */, - 3DE240E9306DE84F00066C57 /* Common */, - 3DE24130306DE84F00066C57 /* Bark */, - 3DE24145306DE84F00066C57 /* Model */, - ); name = Bark; productName = Bark; productReference = 0661A53F204FDA4100965E4E /* Bark.app */; @@ -909,10 +1278,6 @@ ); dependencies = ( ); - fileSystemSynchronizedGroups = ( - 3DE23FE2306DE84F00066C57 /* Sounds */, - 3DE24060306DE84F00066C57 /* NotificationServiceExtension */, - ); name = NotificationServiceExtension; productName = NotificationServiceExtension; productReference = 06CF784021C7A50300A052D7 /* NotificationServiceExtension.appex */; @@ -932,9 +1297,6 @@ dependencies = ( 06EE1FD626843E9300586708 /* PBXTargetDependency */, ); - fileSystemSynchronizedGroups = ( - 3DE2411F306DE84F00066C57 /* BarkTests */, - ); name = BarkTests; productName = BarkTests; productReference = 06EE1FD026843E9300586708 /* BarkTests.xctest */; @@ -1014,6 +1376,7 @@ }; 0661A53E204FDA4100965E4E = { CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; SystemCapabilities = { com.apple.Push = { enabled = 1; @@ -1038,7 +1401,7 @@ }; }; buildConfigurationList = 0661A53A204FDA4100965E4E /* Build configuration list for PBXProject "Bark" */; - compatibilityVersion = "Xcode 16.0"; + compatibilityVersion = "Xcode 8.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( @@ -1091,6 +1454,8 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 06802E5320ECC40C00767047 /* Assets.xcassets in Resources */, + 065752C22CB6844A001426D8 /* Localizable.xcstrings in Resources */, 0632CE2620EC9098003FDF46 /* MainInterface.storyboard in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1099,6 +1464,42 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 06320525250B6DD4001561EC /* suspense.caf in Resources */, + 06320529250B6DD4001561EC /* spell.caf in Resources */, + 065752C12CB6844A001426D8 /* Localizable.xcstrings in Resources */, + 0632051D250B6DD4001561EC /* anticipate.caf in Resources */, + 06320521250B6DD4001561EC /* multiwayinvitation.caf in Resources */, + 06BBB8CE2567B8E60076F63E /* silence.caf in Resources */, + 06320528250B6DD4001561EC /* chime.caf in Resources */, + 06320510250B6DD4001561EC /* paymentsuccess.caf in Resources */, + 06320527250B6DD4001561EC /* noir.caf in Resources */, + 06320520250B6DD4001561EC /* telegraph.caf in Resources */, + 06320518250B6DD4001561EC /* ladder.caf in Resources */, + 06320526250B6DD4001561EC /* mailsent.caf in Resources */, + 06320523250B6DD4001561EC /* update.caf in Resources */, + 06320515250B6DD4001561EC /* healthnotification.caf in Resources */, + 06320511250B6DD4001561EC /* shake.caf in Resources */, + 06320513250B6DD4001561EC /* bloom.caf in Resources */, + 0632050F250B6DD4001561EC /* gotosleep.caf in Resources */, + 06320512250B6DD4001561EC /* alarm.caf in Resources */, + 06320522250B6DD4001561EC /* newmail.caf in Resources */, + 0661A54D204FDA4100965E4E /* LaunchScreen.storyboard in Resources */, + 0632051E250B6DD4001561EC /* choo.caf in Resources */, + 06320516250B6DD4001561EC /* calypso.caf in Resources */, + 0608F0722994D269006B8029 /* BKDropDownCell.xib in Resources */, + 0632052D250B6DD4001561EC /* newsflash.caf in Resources */, + 06320514250B6DD4001561EC /* sherwoodforest.caf in Resources */, + 0632051A250B6DD4001561EC /* fanfare.caf in Resources */, + 06320517250B6DD4001561EC /* descent.caf in Resources */, + 0632051B250B6DD4001561EC /* birdsong.caf in Resources */, + 0632051F250B6DD4001561EC /* glass.caf in Resources */, + 06320524250B6DD4001561EC /* minuet.caf in Resources */, + 0661A54A204FDA4100965E4E /* Assets.xcassets in Resources */, + 06320519250B6DD4001561EC /* tiptoes.caf in Resources */, + 0632052A250B6DD4001561EC /* electronic.caf in Resources */, + 0632052C250B6DD4001561EC /* horn.caf in Resources */, + 0632051C250B6DD4001561EC /* typewriters.caf in Resources */, + 0632052B250B6DD4001561EC /* bell.caf in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1106,6 +1507,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 063AE3EB2F74E97C00048718 /* Localizable.xcstrings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1113,6 +1515,39 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 06D69E202C1159E200161A35 /* glass.caf in Resources */, + 06D69E212C1159E200161A35 /* sherwoodforest.caf in Resources */, + 06D69E222C1159E200161A35 /* ladder.caf in Resources */, + 06D69E232C1159E200161A35 /* chime.caf in Resources */, + 06D69E242C1159E200161A35 /* anticipate.caf in Resources */, + 06D69E252C1159E200161A35 /* update.caf in Resources */, + 06D69E262C1159E200161A35 /* suspense.caf in Resources */, + 06D69E272C1159E200161A35 /* newmail.caf in Resources */, + 06D69E282C1159E200161A35 /* noir.caf in Resources */, + 06D69E292C1159E200161A35 /* birdsong.caf in Resources */, + 06D69E2A2C1159E200161A35 /* minuet.caf in Resources */, + 06D69E2B2C1159E200161A35 /* shake.caf in Resources */, + 06D69E2C2C1159E200161A35 /* newsflash.caf in Resources */, + 06D69E2D2C1159E200161A35 /* paymentsuccess.caf in Resources */, + 06D69E2E2C1159E200161A35 /* descent.caf in Resources */, + 06D69E2F2C1159E200161A35 /* mailsent.caf in Resources */, + 06D69E302C1159E200161A35 /* tiptoes.caf in Resources */, + 065752C32CB6844A001426D8 /* Localizable.xcstrings in Resources */, + 06D69E312C1159E200161A35 /* telegraph.caf in Resources */, + 06D69E322C1159E200161A35 /* healthnotification.caf in Resources */, + 06D69E332C1159E200161A35 /* typewriters.caf in Resources */, + 06D69E342C1159E200161A35 /* bell.caf in Resources */, + 06D69E352C1159E200161A35 /* bloom.caf in Resources */, + 06D69E362C1159E200161A35 /* spell.caf in Resources */, + 06D69E372C1159E200161A35 /* choo.caf in Resources */, + 06D69E382C1159E200161A35 /* multiwayinvitation.caf in Resources */, + 06D69E392C1159E200161A35 /* horn.caf in Resources */, + 06D69E3A2C1159E200161A35 /* electronic.caf in Resources */, + 06D69E3B2C1159E200161A35 /* calypso.caf in Resources */, + 06D69E3C2C1159E200161A35 /* gotosleep.caf in Resources */, + 06D69E3D2C1159E200161A35 /* silence.caf in Resources */, + 06D69E3E2C1159E200161A35 /* alarm.caf in Resources */, + 06D69E3F2C1159E200161A35 /* fanfare.caf in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1336,6 +1771,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + E10000000000000000000000 /* Algorithm.swift in Sources */, + E10000000000000000000001 /* MarkdownParser.swift in Sources */, + E10000000000000000000002 /* UIFont+Extension.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1351,8 +1789,12 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 19BE8EBF2D03514A009BF080 /* ImageDownloader.swift in Sources */, 067AFB1C2E5D8BE300AE78E7 /* UNNotificationContent+Extension.swift in Sources */, + 0687F2AA2CCB7FA500B2A52F /* UIFont+Extension.swift in Sources */, 0632CE2320EC9098003FDF46 /* NotificationViewController.swift in Sources */, + 069332222E6A8E3100F9387F /* String+Extension.swift in Sources */, + 06BCAE572CDB19420092867A /* GroupMuteSettingManager.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1476,6 +1918,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 066B10032F73F00300C4680E /* WidgetHistoryModels.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1483,7 +1926,35 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 066B10022F73F00200C4680E /* WidgetHistoryModels.swift in Sources */, + 06697FF92F71ED9600C4680E /* WidgetHistorySnapshotStore.swift in Sources */, + 063AE3F82F7511AF00048718 /* WidgetHistorySnapshotSupport.swift in Sources */, + 069C6CA92ED03F07007244BB /* BKColor.swift in Sources */, + 06E944752C07012E00AC86AB /* RealmConfiguration.swift in Sources */, + 06E944732C06FF9200AC86AB /* ArchiveProcessor.swift in Sources */, + 06E9447A2C0704E500AC86AB /* ImageDownloader.swift in Sources */, + 06CF784C21C7A51200A052D7 /* NotificationService.swift in Sources */, + 06FB04052C53575400F3A213 /* SharedDefines.swift in Sources */, + 069C6CA42ED03C72007244BB /* MarkdownProcessor.swift in Sources */, 067AFB1D2E5D8BED00AE78E7 /* UNNotificationContent+Extension.swift in Sources */, + 0653677629B719BC0038BDB8 /* CryptoSettingManager.swift in Sources */, + 06E9446F2C06FF1E00AC86AB /* BadgeProcessor.swift in Sources */, + 069C6CA52ED03E10007244BB /* MarkdownParser.swift in Sources */, + 06BCAE582CDB19420092867A /* GroupMuteSettingManager.swift in Sources */, + 06BCAE5B2CDB25120092867A /* MuteProcessor.swift in Sources */, + 06E9446D2C06FEC900AC86AB /* LevelProcessor.swift in Sources */, + 06E944682C06E40600AC86AB /* NotificationContentProcessor.swift in Sources */, + 06F08EA529B1DDA7006AB9CA /* Algorithm.swift in Sources */, + 06E944782C0701F300AC86AB /* IconProcessor.swift in Sources */, + 06BBB89125650CCF0076F63E /* ArchiveSettingManager.swift in Sources */, + 069332232E6A8E3100F9387F /* String+Extension.swift in Sources */, + 06D69E412C11983E00161A35 /* CallProcessor.swift in Sources */, + 06B11591247BC132006D91FB /* Message.swift in Sources */, + 06E9447C2C07052F00AC86AB /* ImageProcessor.swift in Sources */, + 06E9446A2C06E4A200AC86AB /* CiphertextProcessor.swift in Sources */, + 06E944712C06FF4C00AC86AB /* AutoCopyProcessor.swift in Sources */, + 0687F2A92CCB791A00B2A52F /* UIFont+Extension.swift in Sources */, + 06F08EA829B1DE0A006AB9CA /* Error+Extension.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1491,6 +1962,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 068A4B962D2E11CD00982449 /* MessageDeleteTimeRangeTest.swift in Sources */, + 06EE1FD326843E9300586708 /* BarkTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1505,6 +1978,9 @@ 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 */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1558,6 +2034,14 @@ name = MainInterface.storyboard; sourceTree = ""; }; + 0661A54B204FDA4100965E4E /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 0661A54C204FDA4100965E4E /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ @@ -1708,11 +2192,11 @@ baseConfigurationReference = EF45D2197673129A8128ADB7 /* Pods-NotificationContentExtension.debug.xcconfig */; buildSettings = { CLANG_ENABLE_OBJC_WEAK = YES; - CODE_SIGN_ENTITLEMENTS = notificationContentExtension/NotificationContentExtension.entitlements; + CODE_SIGN_ENTITLEMENTS = NotificationContentExtension/NotificationContentExtension.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; - INFOPLIST_FILE = notificationContentExtension/Info.plist; + INFOPLIST_FILE = NotificationContentExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -1734,11 +2218,11 @@ baseConfigurationReference = 4A9C3B74281C8607B364CED7 /* Pods-NotificationContentExtension.release.xcconfig */; buildSettings = { CLANG_ENABLE_OBJC_WEAK = YES; - CODE_SIGN_ENTITLEMENTS = notificationContentExtension/NotificationContentExtension.entitlements; + CODE_SIGN_ENTITLEMENTS = NotificationContentExtension/NotificationContentExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; - INFOPLIST_FILE = notificationContentExtension/Info.plist; + INFOPLIST_FILE = NotificationContentExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -1881,10 +2365,10 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = Bark/Bark.entitlements; - CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 5U8LBRXG3A; INFOPLIST_FILE = Bark/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( @@ -1894,7 +2378,8 @@ MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = me.fin.bark; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ""; + PROVISIONING_PROFILE_SPECIFIER = "match Development me.fin.bark"; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match Development me.fin.bark"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SWIFT_VERSION = 5.0; @@ -2477,8 +2962,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/swiftlang/swift-markdown"; requirement = { - kind = exactVersion; - version = 0.7.3; + kind = upToNextMajorVersion; + minimumVersion = 0.7.3; }; }; B20000000000000000000020 /* XCRemoteSwiftPackageReference "CryptoSwift" */ = { diff --git a/Bark.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bark.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index baf728b5..0cb299fb 100644 --- a/Bark.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bark.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-cmark.git", "state" : { - "revision" : "08ddb528923cc1a6527e02b7a1aee9e516ca749a", - "version" : "0.9.0" + "revision" : "5d9bdaa4228b381639fff09403e39a04926e2dbe", + "version" : "0.7.1" } }, { diff --git a/Bark.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bark.xcworkspace/xcshareddata/swiftpm/Package.resolved index baf728b5..0cb299fb 100644 --- a/Bark.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bark.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-cmark.git", "state" : { - "revision" : "08ddb528923cc1a6527e02b7a1aee9e516ca749a", - "version" : "0.9.0" + "revision" : "5d9bdaa4228b381639fff09403e39a04926e2dbe", + "version" : "0.7.1" } }, { diff --git a/BarkWatch/App/WatchMessage.swift b/BarkWatch/App/WatchMessage.swift index f84dc73f..b7eadd51 100644 --- a/BarkWatch/App/WatchMessage.swift +++ b/BarkWatch/App/WatchMessage.swift @@ -18,7 +18,7 @@ struct WatchMessage: Equatable, Sendable { let body: String let markdown: String? let group: String - let level: String + let level: WatchNotificationLevel let url: URL? let image: URL? let identifier: String? @@ -30,7 +30,7 @@ struct WatchMessage: Equatable, Sendable { body = content.body markdown = WatchNotification.stringValue("markdown", in: content.userInfo) group = content.threadIdentifier - level = content.interruptionLevel.displayName + 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) @@ -43,7 +43,7 @@ struct WatchMessage: Equatable, Sendable { body = message.body markdown = message.markdown group = message.group - level = message.level ?? "Active" + level = message.level ?? .active url = WatchNotification.validatedWebURL(message.url) image = WatchNotification.validatedWebURL(message.image) identifier = message.id @@ -54,7 +54,7 @@ struct WatchMessage: Equatable, Sendable { @Observable @MainActor final class NotificationState { - let servers: BarkServers + let servers: ServerManager enum PushRegistrationState { case idle case registering @@ -68,7 +68,7 @@ final class NotificationState { var permissionError: String? var criticalAlertSetting: UNNotificationSetting? /// Most recently presented or opened notification. Restored from `WatchArchive` - /// at launch; messages the archive skips (encrypted, `isArchive=0`) last only + /// 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. @@ -78,7 +78,7 @@ final class NotificationState { /// A link the app should open once it's active; set together with `lastMessage`. var pendingBrowserURL: URL? - init(servers: BarkServers = BarkServers(), archive: WatchArchive = .shared) { + init(servers: ServerManager = ServerManager(), archive: WatchArchive = .shared) { self.servers = servers lastMessage = (try? archive.latest(group: nil)).flatMap(WatchMessage.init(archived:)) } @@ -109,9 +109,7 @@ final class NotificationState { func receive(_ message: WatchMessage) { lastMessage = message - #if DEBUG - print("Bark watch link: received message, requested link: \(requestedNotificationURL?.absoluteString ?? "none")") - #endif + Logger.links.debug("Received message, requested link: \(self.requestedNotificationURL?.absoluteString ?? "none")") if let requestedNotificationURL { pendingBrowserURL = requestedNotificationURL self.requestedNotificationURL = nil @@ -122,9 +120,12 @@ final class NotificationState { let settings = await UNUserNotificationCenter.current().notificationSettings() authorizationStatus = settings.authorizationStatus criticalAlertSetting = settings.criticalAlertSetting - #if DEBUG - print("Bark watch critical alerts: \(settings.criticalAlertSetting == .enabled ? "enabled" : settings.criticalAlertSetting == .disabled ? "disabled" : "not supported by this signed app")") - #endif + 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 @@ -134,7 +135,7 @@ final class NotificationState { registrationState = .registering WKApplication.shared().registerForRemoteNotifications() case .registered(let deviceToken): - await servers.registerAll(deviceToken: deviceToken) + await servers.syncAllServers(deviceToken: deviceToken) case .registering: break } @@ -166,7 +167,7 @@ final class NotificationState { @MainActor final class NotificationDelegate: NSObject, WKApplicationDelegate, UNUserNotificationCenterDelegate { let state = NotificationState() - private let logger = Logger(subsystem: "com.sebersta.BarkWatchPrototype", category: "NotificationDeletion") + 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? @@ -193,12 +194,11 @@ final class NotificationDelegate: NSObject, WKApplicationDelegate, UNUserNotific func didRegisterForRemoteNotifications(withDeviceToken deviceToken: Data) { let tokenString = deviceToken.map { String(format: "%02x", $0) }.joined() state.registrationState = .registered(tokenString) - Task { await state.servers.registerAll(deviceToken: tokenString) } - #if DEBUG - // Deliberately copyable in Xcode for manual push testing; never logged in release builds. - print("Bark watch device token: \(tokenString)") - print("Bark watch push topic: \(Bundle.main.bundleIdentifier ?? "unknown")") - #endif + 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) { diff --git a/BarkWatch/BarkWatch.xcconfig b/BarkWatch/BarkWatch.xcconfig new file mode 100644 index 00000000..35b85733 --- /dev/null +++ b/BarkWatch/BarkWatch.xcconfig @@ -0,0 +1,9 @@ +// Signing identity for all BarkWatch targets (app, notification service, +// complication, tests). Change these two values to build under another team. +// +// Every bundle identifier, the app group (group.) and the keychain access +// group (.encryption) are derived from BARK_WATCH_BUNDLE_ID in the +// project, entitlements and Info.plist files, so nothing else needs editing. + +BARK_WATCH_DEVELOPMENT_TEAM = 8HVQYZSB2F +BARK_WATCH_BUNDLE_ID = com.sebersta.BarkWatchPrototype diff --git a/BarkWatch/BarkWatchComplication/AppIntent.swift b/BarkWatch/BarkWatchComplication/AppIntent.swift index a04fbb87..a8358b32 100644 --- a/BarkWatch/BarkWatchComplication/AppIntent.swift +++ b/BarkWatch/BarkWatchComplication/AppIntent.swift @@ -1,3 +1,10 @@ +// +// AppIntent.swift +// BarkWatchComplication +// +// Created by Jian Qin on 9/28/26. +// + import AppIntents struct ArchiveGroup: AppEntity { diff --git a/BarkWatch/BarkWatchComplication/BarkWatchComplication.swift b/BarkWatch/BarkWatchComplication/BarkWatchComplication.swift index 6746e1c3..81ee05d6 100644 --- a/BarkWatch/BarkWatchComplication/BarkWatchComplication.swift +++ b/BarkWatch/BarkWatchComplication/BarkWatchComplication.swift @@ -1,3 +1,10 @@ +// +// BarkWatchComplication.swift +// BarkWatchComplication +// +// Created by Jian Qin on 9/30/26. +// + import WidgetKit import SwiftUI diff --git a/BarkWatch/BarkWatchComplication/ComplicationView.swift b/BarkWatch/BarkWatchComplication/ComplicationView.swift index df4087ff..88b11656 100644 --- a/BarkWatch/BarkWatchComplication/ComplicationView.swift +++ b/BarkWatch/BarkWatchComplication/ComplicationView.swift @@ -1,3 +1,10 @@ +// +// ComplicationView.swift +// BarkWatchComplication +// +// Created by Jian Qin on 9/30/26. +// + import SwiftUI import WidgetKit @@ -31,14 +38,14 @@ struct ArchiveEntry: TimelineEntry { let body: String let expires: Date? let messageID: String? - let level: String? + let level: WatchNotificationLevel? /// Background follows the interruption level, matching the tint of `LastMessageView`. var backgroundColor: Color { switch level { - case "Critical": .red - case "Time sensitive": .orange - default: .black + case .critical: .red + case .timeSensitive: .orange + case .active, .passive, nil: .black } } @@ -48,7 +55,7 @@ struct ArchiveEntry: TimelineEntry { body: "All checks passed. Ready for testing on your watch.", expires: nil, messageID: nil, - level: "Critical" + level: .critical ) } 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 index 7b39142e..97636293 100644 --- a/BarkWatch/Common/PhoneCompatibility.swift +++ b/BarkWatch/Common/PhoneCompatibility.swift @@ -1,7 +1,20 @@ +// +// PhoneCompatibility.swift +// Bark +// +// Created by Jian Qin on 9/30/26. +// + import Foundation import UIKit -// Dependencies expected by the unchanged phone sources, scoped to watch targets. +// 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 @@ -13,14 +26,18 @@ enum BKColor { } } +// Mirrors Common/Error+Extension.swift so Algorithm.swift's `throw "…"` compiles. extension String: @retroactive Error {} +// 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] } diff --git a/BarkWatch/Common/WatchArchive.swift b/BarkWatch/Common/WatchArchive.swift index a681922a..2bea3a75 100644 --- a/BarkWatch/Common/WatchArchive.swift +++ b/BarkWatch/Common/WatchArchive.swift @@ -1,3 +1,10 @@ +// +// WatchArchive.swift +// Bark +// +// Created by Jian Qin on 9/30/26. +// + import Foundation import WidgetKit import Darwin @@ -15,21 +22,21 @@ nonisolated struct WatchArchivedMessage: Codable, Equatable, Sendable { // before these fields existed still decode. let subtitle: String? let markdown: String? - let level: 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. + /// 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", - info["ciphertext"] == nil else { return nil } + 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") ?? "" @@ -41,7 +48,7 @@ nonisolated struct WatchArchivedMessage: Codable, Equatable, Sendable { expires = ttl.map { date.addingTimeInterval($0) } subtitle = content.subtitle markdown = text("markdown") - level = content.interruptionLevel.displayName + level = WatchNotificationLevel(content.interruptionLevel) url = text("url") image = text("image") usedCriticalAlertFallback = info[Self.criticalAlertFallbackKey] as? Bool @@ -52,13 +59,20 @@ nonisolated struct WatchArchivedMessage: Codable, Equatable, Sendable { static let criticalAlertFallbackKey = "bark_watch_critical_fallback" } -nonisolated extension UNNotificationInterruptionLevel { - var displayName: String { - switch self { - case .passive: "Passive" - case .timeSensitive: "Time sensitive" - case .critical: "Critical" - default: "Active" +/// The interruption level a message was delivered with, as shown in the app and +/// complication. 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 } } } @@ -76,7 +90,7 @@ nonisolated struct WatchArchive: Sendable { return components.url ?? URL(string: "bark-watch://message")! } static let shared = WatchArchive(directory: FileManager.default.containerURL( - forSecurityApplicationGroupIdentifier: "group.com.sebersta.BarkWatchPrototype")) + forSecurityApplicationGroupIdentifier: WatchConstants.appGroupIdentifier)) let directory: URL? func messages(at date: Date = .now) throws -> [WatchArchivedMessage] { @@ -117,7 +131,6 @@ nonisolated struct WatchArchive: Sendable { var items = try messages() guard update(&items) else { return false } items.sort { $0.date > $1.date } - // ponytail: retain 100 recent notifications, matching the phone widget snapshot. let data = try JSONEncoder().encode(Array(items.prefix(100))) try data.write(to: directory.appendingPathComponent("recent_messages.json"), options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) diff --git a/BarkWatch/Common/WatchConstants.swift b/BarkWatch/Common/WatchConstants.swift new file mode 100644 index 00000000..5bc1b55b --- /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. +/// +/// These must match the `.entitlements` files and the `BarkKeychainAccessGroup` +/// Info.plist entries; update them together when the prototype bundle +/// identifier changes. +nonisolated enum WatchConstants { + static let bundleIdentifier = "com.sebersta.BarkWatchPrototype" + static let appGroupIdentifier = "group.\(bundleIdentifier)" +} + +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 index a2dae539..1c08f0b3 100644 --- a/BarkWatch/Common/WatchEncryption.swift +++ b/BarkWatch/Common/WatchEncryption.swift @@ -2,8 +2,10 @@ // WatchEncryption.swift — watch storage and notification presentation // Bark // -// Created by huangfeng on 2023/2/23. -// Copyright © 2023 Fin. All rights reserved. +// 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 @@ -97,6 +99,15 @@ nonisolated enum WatchEncryption { 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) @@ -109,7 +120,7 @@ nonisolated enum WatchEncryption { static func encrypt(_ payload: [String: String], fields: CryptoSettingFields) throws -> [String: String] { var fields = fields - fields.iv = try randomText(length: fields.mode == "GCM" ? 12 : fields.mode == "CBC" ? 16 : 0) + 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] @@ -163,11 +174,15 @@ nonisolated enum WatchEncryption { } // 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 = fields.mode == "CBC" ? 16 : fields.mode == "GCM" ? 12 : 0 + 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.") } diff --git a/BarkWatch/Common/WatchMarkdownParser.swift b/BarkWatch/Common/WatchMarkdownParser.swift index 19a1ced5..2f871d6a 100644 --- a/BarkWatch/Common/WatchMarkdownParser.swift +++ b/BarkWatch/Common/WatchMarkdownParser.swift @@ -1,3 +1,10 @@ +// +// WatchMarkdownParser.swift +// Bark +// +// Created by Jian Qin on 9/30/26. +// + import Markdown import UIKit @@ -7,6 +14,8 @@ extension NSAttributedString.Key { } // 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)) @@ -21,6 +30,8 @@ enum WatchMarkdownParser { 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, diff --git a/BarkWatch/Common/WatchNotification.swift b/BarkWatch/Common/WatchNotification.swift index c2f7b141..4b73bc96 100644 --- a/BarkWatch/Common/WatchNotification.swift +++ b/BarkWatch/Common/WatchNotification.swift @@ -1,3 +1,10 @@ +// +// WatchNotification.swift +// Bark +// +// Created by Jian Qin on 9/30/26. +// + import Foundation import ImageIO import Intents @@ -51,18 +58,36 @@ nonisolated enum WatchNotification { 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) { - let userInfo = content.userInfo - if let markdown = stringValue("markdown", in: userInfo), !markdown.isEmpty { - content.body = markdownSummary(markdown) - content.categoryIdentifier = markdownCategoryIdentifier - var aps = userInfo["aps"] as? [String: Any] ?? [:] - var alert = aps["alert"] as? [String: Any] ?? [:] - alert["body"] = content.body - aps["alert"] = alert - content.userInfo["aps"] = aps + 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 } - if let group = stringValue("group", in: 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 @@ -87,6 +112,10 @@ nonisolated enum WatchNotification { // 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 } diff --git a/BarkWatch/Common/WatchNotificationDelivery.swift b/BarkWatch/Common/WatchNotificationDelivery.swift new file mode 100644 index 00000000..3961c668 --- /dev/null +++ b/BarkWatch/Common/WatchNotificationDelivery.swift @@ -0,0 +1,252 @@ +// +// WatchNotificationDelivery.swift +// Bark +// +// Created by Jian Qin on 10/9/26. +// + +import Foundation +import Intents +import os +import UniformTypeIdentifiers +import UserNotifications +import WidgetKit + +/// 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() + } + var reloadComplications: () -> Void = { + WidgetCenter.shared.reloadTimelines(ofKind: WatchArchive.widgetKind) + } + } + + 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) + dependencies.reloadComplications() + } 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/Model/BarkServer.swift b/BarkWatch/Model/ServerManager.swift similarity index 74% rename from BarkWatch/Model/BarkServer.swift rename to BarkWatch/Model/ServerManager.swift index 98e56e26..7070eab9 100644 --- a/BarkWatch/Model/BarkServer.swift +++ b/BarkWatch/Model/ServerManager.swift @@ -1,5 +1,5 @@ // -// BarkServer.swift +// ServerManager.swift // Bark // // Created by Jian Qin on 9/30/26. @@ -8,11 +8,12 @@ import Foundation import Observation +import os -struct BarkServer: Codable, Identifiable, Equatable { +struct Server: Codable, Identifiable, Equatable { let id: UUID let address: URL - var deviceKey = "" + var key = "" init(address: String) throws { let trimmedAddress = address.trimmingCharacters(in: .whitespacesAndNewlines) @@ -32,27 +33,17 @@ struct BarkServer: Codable, Identifiable, Equatable { } private enum CodingKeys: String, CodingKey { - case id, address - case deviceKey = "key" + case id + case address + case key } - var pushURL: URL { address.appendingPathComponent(deviceKey) } - - func makeRegistrationRequest(deviceToken: String) throws -> URLRequest { - guard var components = URLComponents(url: address.appendingPathComponent("register"), resolvingAgainstBaseURL: false) else { - throw BarkServerError("Invalid server address.") - } - components.queryItems = [URLQueryItem(name: "devicetoken", value: deviceToken), URLQueryItem(name: "key", value: deviceKey)] - // A literal plus is interpreted as a space by form-style query parsers. - components.percentEncodedQuery = components.percentEncodedQuery?.replacingOccurrences(of: "+", with: "%2B") - guard let registrationURL = components.url else { throw BarkServerError("Invalid registration address.") } - return URLRequest(url: registrationURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 20) + 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 { - var request = URLRequest(url: address.appendingPathComponent("push"), timeoutInterval: 20) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") // Exercise grouping, alert level, Markdown, links, images, and the notification icon. var payload = [ "title": encryption == nil ? "Bark Watch test" : "Bark Watch encrypted test", @@ -60,27 +51,21 @@ struct BarkServer: Codable, Identifiable, Equatable { "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://github.com/sebersta", + "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"] = deviceKey + 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" - request.httpBody = try JSONEncoder().encode(payload) - return request + return try BarkApi.push(address: address, parameters: payload).asURLRequest() } } -struct BarkServerError: LocalizedError { - let errorDescription: String? - init(_ message: String) { errorDescription = message } -} - struct BarkResponse: Decodable { struct RegistrationData: Decodable { let key: String? } let code: Int @@ -105,8 +90,8 @@ struct BarkResponse: Decodable { @Observable @MainActor -final class BarkServers { - private(set) var servers: [BarkServer] = [] +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 = [] @@ -122,31 +107,44 @@ final class BarkServers { self.userDefaults = defaults self.sendRequest = send if let data = defaults.data(forKey: "barkWatchServers") { - do { servers = try JSONDecoder().decode([BarkServer].self, from: data) } + do { servers = try JSONDecoder().decode([Server].self, from: data) } catch { storageError = "Saved servers could not be loaded: \(error.localizedDescription)" } } } @discardableResult - func add(address: String) throws -> UUID { + func addServer(address: String) throws -> UUID { if let storageError { throw BarkServerError(storageError) } - let server = try BarkServer(address: address) + 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) - save() + saveServers() return server.id } - func remove(serverID: UUID) { + 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 - save() + 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 registerAll(deviceToken: String) async { + func syncAllServers(deviceToken: String) async { self.deviceToken = deviceToken for serverID in servers.map(\.id) { await register(serverID: serverID) } } @@ -165,19 +163,16 @@ final class BarkServers { statusMessages[serverID] = "Registering key…" errorMessages[serverID] = nil do { - let (data, response) = try await sendRequest(server.makeRegistrationRequest(deviceToken: tokenToRegister)) + 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 let index = servers.firstIndex(where: { $0.id == serverID }) else { return } - servers[index].deviceKey = registeredKey - save() + guard servers.contains(where: { $0.id == serverID }) else { return } + updateServerKey(serverID: serverID, key: registeredKey) statusMessages[serverID] = "Key registered" - #if DEBUG - print("Bark watch server: \(server.address.absoluteString)") - print("Bark watch device key: \(registeredKey)") - #endif + Logger.registration.debug("Registered \(server.address.absoluteString) with device key \(registeredKey)") } catch { guard servers.contains(where: { $0.id == serverID }) else { return } statusMessages[serverID] = nil @@ -188,7 +183,7 @@ final class BarkServers { } 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.deviceKey.isEmpty else { return } + 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…" @@ -211,7 +206,7 @@ final class BarkServers { if tokenBeforeSending != deviceToken { await register(serverID: serverID) } } - private func save() { + 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/README.md b/BarkWatch/README.md index 4df1bbac..770ff920 100644 --- a/BarkWatch/README.md +++ b/BarkWatch/README.md @@ -1,10 +1,31 @@ -# BarkWatch -A standalone watch application prototype for watchOS 10 or later. Communication with the phone app is not yet implemented. BarkWatch shares the unchanged phone sources `Model/Algorithm.swift`, `Common/MarkdownParser.swift`, and `Common/UIFont+Extension.swift`. Watch-specific adaptations live in `BarkWatch/Common`. +BarkWatch -**BarkWatch cannot use the official Bark server because its push configuration targets the official phone app.** Use a custom Bark server with your Apple signing credentials, the watch's bundle identifier (`com.sebersta.BarkWatchPrototype` in this checkout), and the matching development or production push environment. See [ServerSetup.md](ServerSetup.md) for server modification, configuration, and launch instructions. +A standalone watch application prototype for watchOS 10 or later. Communication with the phone app is not yet implemented. BarkWatch shares the unchanged iOS sources `Model/Algorithm.swift`, `Common/MarkdownParser.swift`, and `Common/UIFont+Extension.swift`. Watch-specific adaptations live in `BarkWatch/Common`. -## Request Parameters +**BarkWatch cannot use the official Bark server because its push configuration targets the official phone app.** Use a custom Bark server with your Apple signing credentials, the watch's bundle identifier (`com.sebersta.BarkWatchPrototype` in this checkout). + +## Shared iOS Code +The watch targets compile three iOS files. Change to these files can break the watch build. + +| iOS file | Watch relies on | +| --- | --- | +| `Model/Algorithm.swift` | `Algorithm` (`modes`, `keyLength`), `CryptoSettingFields`, and all four stored properties of `AESCryptoModel` (`key`, `mode`, `padding`, `aes`), which `AESCryptoModel(watchFields:)` assigns | +| `Common/MarkdownParser.swift` | `MarkdownParser.Configuration`; `WatchMarkdownParser` identifies code blocks by monospaced font plus `codeBlockTextColor` plus a background color, and task-list checkboxes as text attachments | +| `Common/UIFont+Extension.swift` | `UIFont.preferredFont(ofSize:weight:)` used by `MarkdownParser` | + +### PhoneCompatibility.swift +`Algorithm.swift` and `MarkdownParser.swift` use code from three other iOS files. The watch App does not compile those files, so `BarkWatch/Common/PhoneCompatibility.swift` contains copies of just the parts that are used: + +| iOS file | Why BarkWatch needs a copy | +| --- | --- | +| `Common/BKColor.swift` | `MarkdownParser` uses four `BKColor` colors as defaults. The iOS app loads them from its asset catalog, which the watch does not have, so the watch defines them as fixed colors manually. | +| `Common/Error+Extension.swift` | `Algorithm.swift` throws plain strings (`throw "Invalid Mode"`). This only compiles because of `extension String: Error {}`, which the watch copies. | +| `Common/String+Extension.swift` | Both files call `.localized`. The watch has its own `.localized` without the iOS app's string catalog. | + +The tests encryptedNotificationsMatchPhoneWireFormat and markdownUsesPhoneParserForFormattingAndNotificationFallback in BarkWatch/Tests/NotificationTests.swift check that the watch's encryption and Markdown output still match the iOS. + +## Request Parameters Availability | Parameter | Watch app | | --- | --- | | `title`, `subtitle`, `body` | Supported | @@ -26,4 +47,4 @@ A standalone watch application prototype for watchOS 10 or later. Communication Opening web links requires an extra handoff on the watch. The notification interface cannot present the browser directly: tapping **Open link** or a Markdown link passes the selected address and launches the app. BarkWatch waits until it is active before presenting the page. BarkWatch uses `WebAuthenticationSession` to display web pages. The app requests an ephemeral session so cookies are not shared with other sessions. Only links using `http` or `https` are accepted; application links and addresses containing embedded login credentials are rejected. ## Widget -BarkWatch support a rectangular widget that displays the latest notification. The background color of the complication changes depends on notification `level`. +BarkWatch supports a rectangular widget that displays the latest notification. The background color of the complication changes depending on the notification `level`. diff --git a/BarkWatch/ServerSetup.md b/BarkWatch/ServerSetup.md deleted file mode 100644 index cca455c5..00000000 --- a/BarkWatch/ServerSetup.md +++ /dev/null @@ -1,75 +0,0 @@ -# BarkWatch server setup - -With Git and Go 1.26 or later installed, run the supplied [build script](scripts/build-watch-server.sh) from the Bark repository root. It downloads a pinned upstream server revision, applies [watch-server.patch](scripts/watch-server.patch), and builds `build/watch-server/bark-server`. The generated source, executable, and server data stay under the ignored `build/` directory. - -```sh -./BarkWatch/scripts/build-watch-server.sh -``` - -Obtain an Apple push authentication key (`.p8`), its key identifier, and your team identifier from the developer account used to sign BarkWatch. The key must authorize the watch application and the chosen push environment. Replace the example key path and identifiers below with your own values, then run these commands from the Bark repository root: - -```sh -export BARK_APNS_KEY_FILE='/absolute/path/to/AuthKey_YOUR_KEY_ID.p8' -export BARK_APNS_KEY_ID='YOUR_KEY_ID' -export BARK_APNS_TEAM_ID='YOUR_TEAM_ID' -export BARK_APNS_TOPIC='com.sebersta.BarkWatchPrototype' -export BARK_APNS_ENVIRONMENT='development' -./build/watch-server/bark-server --addr 0.0.0.0:8080 --data ./build/watch-server/data -``` - -`BARK_APNS_TOPIC` must match the **BarkWatch application** bundle identifier in Xcode. If you changed it, update the value above. The key identifier belongs to the `.p8` key; the team identifier belongs to the watch's signing account. Use `development` for this checkout's development-signed watch build and `production` for a build signed with the production push entitlement. The environment selects Apple's sandbox or production endpoint; it does not change how the app is signed. - -To make the changes in your own upstream checkout instead, add `"os"` to the imports in `apns/apns.go` and replace the existing constants for `topic`, `keyID`, and `teamID` with: - -```go -const PayloadMaximum = 4096 - -var ( - topic = os.Getenv("BARK_APNS_TOPIC") - keyID = os.Getenv("BARK_APNS_KEY_ID") - teamID = os.Getenv("BARK_APNS_TEAM_ID") -) -``` - -Inside `ReCreateAPNS`, replace the existing `token.AuthKeyFromBytes(...)` declaration with the following block, keeping the existing error check immediately after it: - -```go -if topic == "" || keyID == "" || teamID == "" { - logger.Fatal("BARK_APNS_TOPIC, BARK_APNS_KEY_ID and BARK_APNS_TEAM_ID are required") -} -host := apns2.HostDevelopment -switch os.Getenv("BARK_APNS_ENVIRONMENT") { -case "development": -case "production": - host = apns2.HostProduction -default: - logger.Fatal("BARK_APNS_ENVIRONMENT must be development or production") -} -authKey, err := token.AuthKeyFromFile(os.Getenv("BARK_APNS_KEY_FILE")) -``` - -In the same function, change the `apns2.Client` field from `Host: apns2.HostProduction` to `Host: host`. Its existing `Token` fields already reference `authKey`, `keyID`, and `teamID`. - -In `route_push.go`, find the `case "sound"` branch and change its condition to `if val == "default" || strings.HasSuffix(val, ".caf")`. This preserves the watch's system sound name instead of turning it into `default.caf`. - -For silent deletion, find `Push` in `apns/apns.go`. Add `priority := apns2.PriorityHigh` beside `pushType := apns2.PushTypeAlert`, set `priority = apns2.PriorityLow` inside the existing `if msg.IsDelete()` branch, and add `Priority: priority` to the `apns2.Notification` sent by `client.Push`. The existing deletion branch already sets the background push type and `content-available`, and omits alert text and sound. Keep the existing collapse identifier and custom payload handling. If your Go toolchain rejects `fmt.Errorf(resp.Reason)`, change it to `fmt.Errorf("%s", resp.Reason)`. - -Build your manually modified checkout with `go build -o bark-server .`, export the five `BARK_APNS_...` values shown above, and launch it with `./bark-server --addr 0.0.0.0:8080 --data ./data`. Restart an already running server after rebuilding or changing its configuration. - -On the watch, add `http://YOUR_MAC_LOCAL_ADDRESS:8080`, replacing the address with the Mac's reachable local network address; `0.0.0.0` is only the server's listening address. Wait for registration, send a test notification, and confirm it arrives on the watch. This configuration targets the watch; serving differently signed phone and watch apps together requires separate server instances or routing by app identity. - -The [notification test sender](scripts/send-watch-tests.py) covers text, Markdown, grouping, alert levels, sound, images, icons, links, replacement, archive expiration, and silent deletion. Preview all test expectations without sending anything: - -```sh -python3 BarkWatch/scripts/send-watch-tests.py --dry-run -``` - -To send selected tests, supply your server's base address and the registered key displayed by the watch. `W03` checks Markdown; `W31` automatically includes the alert and first deletion needed to test repeated silent deletion. Requests are 15 seconds apart by default; use `--interval 20` to change the delay. Each run uses fresh identifiers, so replacement and deletion affect only that run's test notifications. Omit `--only` to send every case. - -```sh -python3 BarkWatch/scripts/send-watch-tests.py \ - --server http://YOUR_MAC_LOCAL_ADDRESS:8080 \ - --key YOUR_WATCH_DEVICE_KEY --only W03 W31 -``` - -You can supply `BARK_SERVER_URL` and `BARK_DEVICE_KEY` as environment variables instead. Server acceptance does not confirm delivery or correct rendering; check the notification, latest-message view, and complication on the watch. Run the sender's local automated check with `python3 BarkWatch/scripts/test_send_watch_tests.py`; it uses a temporary local server and never contacts Apple. diff --git a/BarkWatch/Tests/NotificationTests.swift b/BarkWatch/Tests/NotificationTests.swift index 37f28299..10faa4aa 100644 --- a/BarkWatch/Tests/NotificationTests.swift +++ b/BarkWatch/Tests/NotificationTests.swift @@ -1,3 +1,10 @@ +// +// NotificationTests.swift +// Bark +// +// Created by Jian Qin on 9/30/26. +// + import Testing import Intents import UserNotifications @@ -94,15 +101,15 @@ import WatchKit #expect(WatchNotification.presentationOptions(for: content).contains(.sound)) content.userInfo = ["level": "passive", "aps": ["sound": "default"]] - WatchNotification.prepare(content) + 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.prepare(content) + WatchNotification.applyLevel(content) #expect(content.interruptionLevel == .active) #expect(WatchMessage(content: content).usedCriticalAlertFallback) - WatchNotification.prepare(content, criticalAlertsEnabled: true) + WatchNotification.applyLevel(content, criticalAlertsEnabled: true) #expect(content.interruptionLevel == .critical) #expect(content.sound != nil) #expect(!WatchMessage(content: content).usedCriticalAlertFallback) @@ -114,11 +121,11 @@ import WatchKit #expect(WatchNotification.criticalSoundVolume(["volume": "nan"]) == 0.5) #expect(WatchNotification.criticalSoundVolume(["volume": "inf"]) == 0.5) content.userInfo = ["level": "critical", "aps": ["sound": "silent"]] - WatchNotification.prepare(content, criticalAlertsEnabled: true) + 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.prepare(content) + WatchNotification.applyLevel(content) #expect(content.sound == nil) #expect(!WatchNotification.presentationOptions(for: content).contains(.sound)) let message = WatchMessage(content: content) @@ -130,7 +137,7 @@ import WatchKit } #expect(WatchNotification.stringValue("badge", in: ["badge": 3]) == "3") content.userInfo = ["level": "active", "aps": ["sound": "system.caf"]] - WatchNotification.prepare(content) + WatchNotification.applyLevel(content) #expect(content.sound != nil) } @@ -140,22 +147,22 @@ func silentSoundDisablesAlertSound(soundName: String) { let content = UNMutableNotificationContent() content.sound = .default content.userInfo = ["level": level, "aps": ["sound": soundName]] - WatchNotification.prepare(content, criticalAlertsEnabled: true) + WatchNotification.applyLevel(content, criticalAlertsEnabled: true) #expect(content.sound == nil) #expect(WatchNotification.presentationOptions(for: content) == [.banner, .list]) } } @MainActor -@Test func notificationStatusReturnsWhenPermissionOrRegistrationIsLost() throws { +@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: BarkServers(defaults: defaults)) + let state = NotificationState(servers: ServerManager(defaults: defaults)) state.authorizationStatus = .authorized state.registrationState = .registered("test-token") #expect(state.needsNotificationSetup) - let id = try state.servers.add(address: "https://bark.example") + let id = try state.servers.addServer(address: "https://bark.example") #expect(!state.needsNotificationSetup) state.authorizationStatus = .denied #expect(state.needsNotificationSetup) @@ -168,7 +175,7 @@ func silentSoundDisablesAlertSound(soundName: String) { state.authorizationStatus = .authorized state.registrationState = .registered("test-token") #expect(!state.needsNotificationSetup) - state.servers.remove(serverID: id) + await state.servers.removeServer(serverID: id) #expect(state.needsNotificationSetup) } @@ -202,42 +209,42 @@ func silentSoundDisablesAlertSound(soundName: String) { let suite = "BarkWatchTests.\(UUID())" let defaults = try #require(UserDefaults(suiteName: suite)) defer { defaults.removePersistentDomain(forName: suite) } - let store = BarkServers(defaults: defaults, send: barkResponse) + 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.add(address: address) } + #expect(throws: BarkServerError.self) { try store.addServer(address: address) } } - let id = try store.add(address: " https://BARK.EXAMPLE/proxy/ ") - #expect(throws: BarkServerError.self) { try store.add(address: "https://bark.example/proxy") } - #expect(try BarkServer(address: "http://192.168.1.2:8080/").address.absoluteString == "http://192.168.1.2:8080") + 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?.deviceKey == "") - await store.registerAll(deviceToken: "first-token") - #expect(store.servers.first?.deviceKey == "watch-key") + #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.registerAll(deviceToken: "rotated-token") - #expect(store.servers.first?.deviceKey == "watch-key") + await store.syncAllServers(deviceToken: "rotated-token") + #expect(store.servers.first?.key == "watch-key") var server = try #require(store.servers.first) - let restored = BarkServers(defaults: defaults, send: barkResponse) + 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.deviceKey + "id": server.id.uuidString, "address": server.address.absoluteString, "key": server.key ]) - #expect(try JSONDecoder().decode(BarkServer.self, from: legacyData) == server) + #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.deviceKey) + #expect(encodedServer["key"] as? String == server.key) #expect(encodedServer["deviceKey"] == nil) - server.deviceKey = "a+b&c=d" - let request = try server.makeRegistrationRequest(deviceToken: "token+with&symbols") + 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.deviceKey) + #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) @@ -246,7 +253,7 @@ func silentSoundDisablesAlertSound(soundName: String) { #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.deviceKey) + #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)) @@ -260,19 +267,19 @@ func silentSoundDisablesAlertSound(soundName: String) { #expect(store.errorMessages[id] == nil) for host in ["rejected.example", "missing-key.example", "malformed.example", "offline.example"] { - let failedID = try store.add(address: "https://\(host)") + 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 })?.deviceKey == "") + #expect(store.servers.first(where: { $0.id == failedID })?.key == "") #expect(!store.busyServerIDs.contains(failedID)) - store.remove(serverID: 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) } - store.remove(serverID: id) - #expect(BarkServers(defaults: defaults).servers.isEmpty) + await store.removeServer(serverID: id) + #expect(ServerManager(defaults: defaults).servers.isEmpty) } @MainActor @@ -287,34 +294,42 @@ func silentSoundDisablesAlertSound(soundName: String) { signalResumeRequest.finish() } var requestCount = 0 - let store = BarkServers(defaults: defaults) { request in + 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.add(address: "https://bark.example") + let serverID = try store.addServer(address: "https://bark.example") var startedRequests = requestStarted.makeAsyncIterator() - let registration = Task { await store.registerAll(deviceToken: "first-token") } + 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.registerAll(deviceToken: "rotated-token") + 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?.deviceKey == "watch-key") + #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() - store.remove(serverID: serverID) + 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) @@ -454,14 +469,14 @@ private func barkResponse(for request: URLRequest) async throws -> (Data, URLRes @MainActor @Test func encryptedTestRequestContainsNoPlaintextAndUsesFreshVectors() throws { - var server = try BarkServer(address: "https://bark.example") - server.deviceKey = "watch-key" + 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.deviceKey) + #expect(payload["device_key"] == server.key) #expect(payload["title"] == nil && payload["body"] == nil && payload["sound"] == nil) #expect(payload["key"] == nil) let content = UNMutableNotificationContent() @@ -517,7 +532,7 @@ private func barkResponse(for request: URLRequest) async throws -> (Data, URLRes #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"], ["ciphertext": "encrypted"]] { + 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) } @@ -535,7 +550,7 @@ private func barkResponse(for request: URLRequest) async throws -> (Data, URLRes defer { try? FileManager.default.removeItem(at: directory) } let archive = WatchArchive(directory: directory) let defaults = try #require(UserDefaults(suiteName: UUID().uuidString)) - #expect(NotificationState(servers: BarkServers(defaults: defaults), archive: archive).lastMessage == nil) + #expect(NotificationState(servers: ServerManager(defaults: defaults), archive: archive).lastMessage == nil) let content = UNMutableNotificationContent() content.title = "Older" @@ -547,14 +562,14 @@ private func barkResponse(for request: URLRequest) async throws -> (Data, URLRes content.title = "Newer" try archive.save(#require(WatchArchivedMessage(content: content, identifier: "newer", date: .now.addingTimeInterval(1)))) - let state = NotificationState(servers: BarkServers(defaults: defaults), archive: archive) + 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 == "Time sensitive") + #expect(restored.level == .timeSensitive) #expect(restored.url == URL(string: "https://day.app")) #expect(restored.image == nil) diff --git a/BarkWatch/View/AddServerTabView.swift b/BarkWatch/View/AddServerTabView.swift index f0c1d493..cfac31aa 100644 --- a/BarkWatch/View/AddServerTabView.swift +++ b/BarkWatch/View/AddServerTabView.swift @@ -7,9 +7,9 @@ import SwiftUI -/// Trailing tab for adding a server, laid out like `NotificationStatusLabel`. +/// Trailing tab for adding a server, laid out like `NotificationStatusTab`. struct AddServerTabView: View { - let servers: BarkServers + let servers: ServerManager let onAdd: (UUID) -> Void var body: some View { diff --git a/BarkWatch/View/AddServerView.swift b/BarkWatch/View/AddServerView.swift index d386a342..ee433a34 100644 --- a/BarkWatch/View/AddServerView.swift +++ b/BarkWatch/View/AddServerView.swift @@ -9,7 +9,7 @@ import SwiftUI struct AddServerView: View { - let servers: BarkServers + let servers: ServerManager var onAdd: (UUID) -> Void = { _ in } @Environment(\.dismiss) private var dismiss @State private var address = "https://" @@ -39,7 +39,7 @@ struct AddServerView: View { error = nil do { - let id = try servers.add(address: address) + let id = try servers.addServer(address: address) isAdding = true Task { await servers.register(serverID: id) @@ -47,7 +47,7 @@ struct AddServerView: View { if let registrationError = servers.errorMessages[id] { error = registrationError - servers.remove(serverID: id) + await servers.removeServer(serverID: id) } else { onAdd(id) dismiss() diff --git a/BarkWatch/View/BigButtonView.swift b/BarkWatch/View/BigButtonView.swift index b5cefd4e..632282fa 100644 --- a/BarkWatch/View/BigButtonView.swift +++ b/BarkWatch/View/BigButtonView.swift @@ -87,25 +87,3 @@ private struct BigButtonStyle: ButtonStyle { } } } - -//#Preview("With message") { -// BigButtonView( -// caption: "Notifications", -// title: "Disabled", -// tint: .red, -// message: Text("Delete the App,\nor redownload it.") -// ) { -// EmptyView() -// } -//} -// -//#Preview("WatchMessage and button") { -// BigButtonView( -// caption: "Servers", -// title: "2 saved", -// message: Text("Servers could not be saved.").font(.footnote).foregroundStyle(.red) -// ) { -// Button("Add") {} -// } -//} - diff --git a/BarkWatch/View/ContentView.swift b/BarkWatch/View/ContentView.swift index 38a2bd68..f505ff18 100644 --- a/BarkWatch/View/ContentView.swift +++ b/BarkWatch/View/ContentView.swift @@ -6,6 +6,7 @@ // import AuthenticationServices +import os import SwiftUI struct ContentView: View { @@ -22,7 +23,7 @@ struct ContentView: View { NavigationStack { TabView(selection: $selection) { if state.needsNotificationSetup { - NotificationStatusLabel(state: state) { selection = .server($0) } + NotificationStatusTab(state: state) { selection = .server($0) } .tag(Tab.status) } @@ -68,35 +69,12 @@ struct ContentView: View { /// active: presenting the browser while the app is still launching from the /// notification leaves it stuck and unresponsive. private func openPendingLink() { - #if DEBUG - print("Bark watch link: pending \(state.pendingBrowserURL?.absoluteString ?? "none"), scene phase \(scenePhase)") - #endif + 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 - // Let the launch transition finish before presenting over it. -// try? await Task.sleep(for: .milliseconds(500)) await webAuthenticationSession.browse(url) } } } - -//#Preview("Enabled") { -// let state = NotificationState() -// state.authorizationStatus = .authorized -// state.registrationState = .registered("preview") -// return ContentView(state: state) -//} -// -//#Preview("Permission needed") { -// let state = NotificationState() -// state.authorizationStatus = .notDetermined -// return ContentView(state: state) -//} -// -//#Preview("Large text") { -// let state = NotificationState() -// state.authorizationStatus = .denied -// return ContentView(state: state).environment(\.dynamicTypeSize, .accessibility3) -//} diff --git a/BarkWatch/View/LastMessageView.swift b/BarkWatch/View/LastMessageView.swift index 7307fdcc..aa762889 100644 --- a/BarkWatch/View/LastMessageView.swift +++ b/BarkWatch/View/LastMessageView.swift @@ -1,4 +1,12 @@ +// +// LastMessageView.swift +// Bark +// +// Created by Jian Qin on 9/30/26. +// + import AuthenticationServices +import os import SwiftUI import UserNotifications import WatchKit @@ -12,9 +20,9 @@ struct LastMessageView: View { /// Tint follows the interruption level, like the status card follows its state. private var tint: Color { switch message.level { - case "Critical": .red - case "Time sensitive": .orange - default: .secondary + case .critical: .red + case .timeSensitive: .orange + case .active, .passive: .secondary } } @@ -54,7 +62,7 @@ struct LastMessageView: View { private var header: some View { VStack(alignment: .leading, spacing: 2) { - Text("Lastest Notification") + Text("Latest Notification") .font(.caption.weight(.semibold)) .foregroundStyle(.tertiary) .minimumScaleFactor(0.8) @@ -91,7 +99,7 @@ struct LastMessageView: View { if !message.group.isEmpty { Label(message.group, systemImage: "folder") } - Label(message.level, systemImage: "bell") + Label(message.level.rawValue, systemImage: "bell") } .font(.footnote) .foregroundStyle(.secondary) @@ -104,9 +112,7 @@ extension WebAuthenticationSession { /// 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 { - #if DEBUG - print("Bark watch link: opening browser session for \(url)") - #endif + Logger.links.debug("Opening browser session for \(url)") do { // Ephemeral: don't share cookies with other sessions. _ = try await authenticate( @@ -116,9 +122,7 @@ extension WebAuthenticationSession { ) } catch { // Cancel is the normal way to close the page; anything else is worth seeing. - #if DEBUG - print("Bark watch link: browser session ended: \(error)") - #endif + Logger.links.debug("Browser session ended: \(error)") } } } @@ -156,7 +160,6 @@ private struct LinkButtonStyle: ButtonStyle { content.body = "CPU usage exceeded 90% on web-01." content.threadIdentifier = "Monitoring" content.interruptionLevel = .critical -// content.userInfo = ["url": "https://day.app"] return TabView { LastMessageView(message: WatchMessage(content: content)) diff --git a/BarkWatch/View/NotificationMarkdownView.swift b/BarkWatch/View/NotificationMarkdownView.swift index 708f9627..28c12eae 100644 --- a/BarkWatch/View/NotificationMarkdownView.swift +++ b/BarkWatch/View/NotificationMarkdownView.swift @@ -5,53 +5,11 @@ // Created by Jian Qin on 9/30/26. // - +import os import SwiftUI -import UIKit import UserNotifications import WatchKit -/// Markdown rendering for the notification interface (long look), kept separate from -/// the in-app `WatchMarkdownView` so it can be styled for the notification platter -/// without affecting the app. Shares parsing through `WatchMarkdownParagraph`. -struct NotificationMarkdownView: View { - let source: String - /// The notification interface can't open a browser, so links go to the controller. - var openLink: (URL) -> Void = { _ in } - @ScaledMetric(relativeTo: .body) private var fontSize = 14 - - 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) - // Paragraphs already contain only web links; anything else is discarded. - .environment(\.openURL, OpenURLAction { url in - guard WatchNotification.validatedWebURL(url.absoluteString) != nil else { return .discarded } - openLink(url) - return .handled - }) - } -} - final class MarkdownNotificationController: WKUserNotificationHostingController { private var content: UNNotificationContent? @@ -64,9 +22,7 @@ final class MarkdownNotificationController: WKUserNotificationHostingController< private func open(_ url: URL) { let delegate = NotificationDelegate.current delegate?.state.requestedNotificationURL = url - #if DEBUG - print("Bark watch link: notification requested \(url), delegate found: \(delegate != nil)") - #endif + Logger.links.debug("Notification requested \(url), delegate found: \(delegate != nil)") performNotificationDefaultAction() } @@ -86,7 +42,7 @@ struct MarkdownNotificationView: View { if !content.subtitle.isEmpty { Text(content.subtitle).font(.subheadline) } if let source = WatchNotification.stringValue("markdown", in: content.userInfo), !source.isEmpty, content.userInfo["ciphertext"] == nil { - NotificationMarkdownView(source: source, openLink: openLink) + WatchMarkdownView(source: source, openLink: openLink) } else { Text(content.body) } diff --git a/BarkWatch/View/NotificationStatusLabel.swift b/BarkWatch/View/NotificationStatusTab.swift similarity index 98% rename from BarkWatch/View/NotificationStatusLabel.swift rename to BarkWatch/View/NotificationStatusTab.swift index cdaee000..e77f8a9c 100644 --- a/BarkWatch/View/NotificationStatusLabel.swift +++ b/BarkWatch/View/NotificationStatusTab.swift @@ -1,5 +1,5 @@ // -// NotificationStatusLabel.swift +// NotificationStatusTab.swift // Bark // // Created by Jian Qin on 9/30/26. @@ -7,8 +7,8 @@ import SwiftUI - -struct NotificationStatusLabel: View { + +struct NotificationStatusTab: View { let state: NotificationState var onAddServer: (UUID) -> Void = { _ in } @State private var isSyncing = false @@ -112,7 +112,7 @@ struct NotificationStatusLabel: View { state.registrationState = .registering return ContentView(state: state) } -// + #Preview("Enabled") { let state = NotificationState() state.authorizationStatus = .authorized diff --git a/BarkWatch/View/ServerTabView.swift b/BarkWatch/View/ServerTabView.swift index 795c0dd7..94008e90 100644 --- a/BarkWatch/View/ServerTabView.swift +++ b/BarkWatch/View/ServerTabView.swift @@ -10,11 +10,11 @@ import SwiftUI /// One tab per saved server: address, key, encryption, and test delivery. struct ServerTabView: View { - let servers: BarkServers + let servers: ServerManager let id: UUID @State private var confirmsRemoval = false - private var server: BarkServer? { servers.servers.first { $0.id == id } } + private var server: Server? { servers.servers.first { $0.id == id } } private var isBusy: Bool { servers.busyServerIDs.contains(id) } var body: some View { @@ -24,13 +24,13 @@ struct ServerTabView: View { Text(error).font(.footnote).foregroundStyle(.red) } Section("Address") { - if server.deviceKey.isEmpty { + 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.pushURL.absoluteString + "/").map(String.init).joined(separator: "\u{200B}")) + Text((server.addressAndKey.absoluteString + "/").map(String.init).joined(separator: "\u{200B}")) .font(.caption.monospaced()) Button("Send Test") { Task { await servers.sendTestPush(serverID: id) } @@ -48,7 +48,7 @@ struct ServerTabView: View { } .confirmationDialog("Remove this server from the watch?", isPresented: $confirmsRemoval) { Button("Remove server", role: .destructive) { - servers.remove(serverID: id) + Task { await servers.removeServer(serverID: id) } } } } diff --git a/BarkWatch/View/WatchMarkdownView.swift b/BarkWatch/View/WatchMarkdownView.swift index 87fc1500..94cfcb0b 100644 --- a/BarkWatch/View/WatchMarkdownView.swift +++ b/BarkWatch/View/WatchMarkdownView.swift @@ -10,9 +10,14 @@ import SwiftUI import UIKit // Render the phone parser's attributed text, retaining paragraph indentation that -// SwiftUI Text does not carry over from NSParagraphStyle. +// 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 @@ -39,11 +44,10 @@ struct WatchMarkdownView: View { } } .tint(.cyan) - // Only web links are allowed; they go to the enclosing handler so the app - // can open them in a browser session instead of the unsupported system action. - .environment(\.openURL, OpenURLAction { [openURL] url in + // 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 } - openURL(url) + if let openLink { openLink(url) } else { openURL(url) } return .handled }) } diff --git a/BarkWatch/WatchNotificationService.swift b/BarkWatch/WatchNotificationService.swift index 8143b026..cbf7e0d2 100644 --- a/BarkWatch/WatchNotificationService.swift +++ b/BarkWatch/WatchNotificationService.swift @@ -1,159 +1,20 @@ -import Foundation -import Intents -import os -import UniformTypeIdentifiers +// +// WatchNotificationService.swift +// Bark +// +// Created by Jian Qin on 9/30/26. +// + import UserNotifications -import WidgetKit +/// 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 lock = NSLock() - private var handler: ((UNNotificationContent) -> Void)? - private var content: UNMutableNotificationContent? - private var download: URLSessionDownloadTask? - private let logger = Logger(subsystem: "com.sebersta.BarkWatchPrototype", category: "NotificationIcon") + private let delivery = WatchNotificationDelivery() override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { - guard let content = request.content.mutableCopy() as? UNMutableNotificationContent else { - contentHandler(request.content) - return - } - lock.lock() - self.content = content - handler = contentHandler - let decrypted = WatchEncryption.prepare(content) { try WatchCryptoStore.load() } - lock.unlock() - guard decrypted else { - finish() - return - } - UNUserNotificationCenter.current().getNotificationSettings { [weak self] settings in - self?.process(criticalAlertsEnabled: settings.criticalAlertSetting == .enabled, - identifier: request.identifier) - } - } - - private func process(criticalAlertsEnabled: Bool, identifier: String) { - lock.lock() - guard handler != nil, let content else { - lock.unlock() - return - } - WatchNotification.prepare(content, criticalAlertsEnabled: criticalAlertsEnabled) - // Archived after preparing so the level and markdown summary match what is shown. - let archived = WatchArchivedMessage(content: content, identifier: identifier) - lock.unlock() - if let archived { - do { - try WatchArchive.shared.save(archived) - WidgetCenter.shared.reloadTimelines(ofKind: WatchArchive.widgetKind) - } catch { - logger.error("Could not archive notification: \(error.localizedDescription)") - } - } - downloadImage(WatchNotification.stringValue("image", in: content.userInfo)) { [weak self] file in - guard let self else { return } - let attachment = file.flatMap { try? UNNotificationAttachment(identifier: "image", url: $0, options: nil) } - if attachment == nil, let file { try? FileManager.default.removeItem(at: file) } - self.lock.lock() - if self.handler != nil, let attachment { self.content?.attachments = [attachment] } - self.lock.unlock() - self.processIcon() - } - } - - private func processIcon() { - lock.lock() - let snapshot = handler == nil ? nil : content?.mutableCopy() as? UNMutableNotificationContent - lock.unlock() - guard let snapshot else { return } - guard WatchNotification.stringValue("icon", in: snapshot.userInfo) != nil else { - finish() - return - } - downloadImage(WatchNotification.stringValue("icon", in: snapshot.userInfo)) { [weak self] file in - defer { if let file { try? FileManager.default.removeItem(at: file) } } - guard let self else { return } - guard let file, let data = try? Data(contentsOf: file), - let intent = WatchNotification.iconIntent(for: snapshot, imageData: data) else { - self.logger.error("Icon download or image validation failed; delivering original notification") - self.finish() - return - } - let interaction = INInteraction(intent: intent, response: nil) - interaction.direction = .incoming - interaction.donate { [weak self] error in - guard let self else { return } - do { - if let error { throw error } - guard let updated = try snapshot.updating(from: intent).mutableCopy() as? UNMutableNotificationContent else { - self.finish() - return - } - // Preserve Bark's text and options while retaining the system's avatar metadata. - updated.title = snapshot.title - updated.subtitle = snapshot.subtitle - updated.body = snapshot.body - updated.userInfo.merge(snapshot.userInfo) { _, original in original } - updated.threadIdentifier = snapshot.threadIdentifier - updated.categoryIdentifier = snapshot.categoryIdentifier - updated.sound = snapshot.sound - updated.interruptionLevel = snapshot.interruptionLevel - updated.attachments = snapshot.attachments - updated.badge = snapshot.badge - self.logger.info("Notification avatar applied") - self.finish(updated: updated) - } catch { - self.logger.error("Notification avatar failed: \(error.localizedDescription)") - self.finish() - } - } - } + delivery.didReceive(request, withContentHandler: contentHandler) } - private func downloadImage(_ address: String?, completion: @escaping (URL?) -> Void) { - guard let url = WatchNotification.validatedWebURL(address) else { - completion(nil) - return - } - let task = URLSession.shared.downloadTask(with: URLRequest(url: url, timeoutInterval: 8)) { [weak self] file, response, _ in - var imageFile: URL? - if let file, let response = response as? HTTPURLResponse, response.statusCode == 200, - let mime = response.mimeType, ["image/png", "image/jpeg", "image/gif"].contains(mime), - let type = UTType(mimeType: mime), let suffix = type.preferredFilenameExtension, - let size = try? file.resourceValues(forKeys: [.fileSizeKey]).fileSize, size <= 5 * 1024 * 1024 { - let destination = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString).appendingPathExtension(suffix) - do { - try FileManager.default.moveItem(at: file, to: destination) - imageFile = destination - } catch { - try? FileManager.default.removeItem(at: destination) - } - } - guard let self else { return } - self.lock.lock() - let isActive = self.handler != nil - self.lock.unlock() - if isActive { completion(imageFile) } - else if let imageFile { try? FileManager.default.removeItem(at: imageFile) } - } - lock.lock() - download = task - let shouldStart = handler != nil - lock.unlock() - if shouldStart { task.resume() } else { task.cancel() } - } - - override func serviceExtensionTimeWillExpire() { finish() } - - private func finish(updated: UNMutableNotificationContent? = nil) { - lock.lock() - let callback = handler - let result = updated ?? content - handler = nil - let task = download - download = nil - lock.unlock() - task?.cancel() - if let callback, let result { callback(result) } - } + override func serviceExtensionTimeWillExpire() { delivery.serviceExtensionTimeWillExpire() } } diff --git a/BarkWatch/scripts/build-watch-server.sh b/BarkWatch/scripts/build-watch-server.sh deleted file mode 100755 index 16f5e05a..00000000 --- a/BarkWatch/scripts/build-watch-server.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/bin/sh -set -eu - -script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -repo_root=$(CDPATH= cd -- "$script_dir/../.." && pwd) -build_dir="$repo_root/build/watch-server" -source_dir="$build_dir/source" -revision=5ea5486d8e4fe2fff0b4b170a89b2457e808c9c3 - -if [ ! -d "$source_dir/.git" ]; then - mkdir -p "$source_dir" - git -C "$source_dir" init -q -fi -if ! git -C "$source_dir" rev-parse --verify HEAD >/dev/null 2>&1; then - git -C "$source_dir" fetch --depth 1 https://github.com/Finb/bark-server.git "$revision" - git -C "$source_dir" checkout --detach FETCH_HEAD -fi -test "$(git -C "$source_dir" rev-parse HEAD)" = "$revision" - -if git -C "$source_dir" apply --check "$script_dir/watch-server.patch" 2>/dev/null; then - git -C "$source_dir" apply "$script_dir/watch-server.patch" -else - git -C "$source_dir" apply --reverse --check "$script_dir/watch-server.patch" -fi - -cd "$source_dir" -go build -trimpath -o "$build_dir/bark-server" . -printf 'Built %s\n' "$build_dir/bark-server" diff --git a/BarkWatch/scripts/send-watch-tests.py b/BarkWatch/scripts/send-watch-tests.py deleted file mode 100755 index b0f24182..00000000 --- a/BarkWatch/scripts/send-watch-tests.py +++ /dev/null @@ -1,130 +0,0 @@ -#!/usr/bin/env python3 -"""Send watch notification checks. Server acceptance does not confirm watch delivery.""" - -import argparse -import json -import math -import os -from pathlib import Path -import sys -import time -from urllib.error import HTTPError, URLError -from urllib.parse import urlsplit -from urllib.request import Request, urlopen -from uuid import uuid4 - - -def interval_seconds(value): - seconds = float(value) - if not math.isfinite(seconds) or seconds < 0: - raise argparse.ArgumentTypeError("Interval must be finite and nonnegative.") - return seconds - - -def select_cases(cases, identifiers): - available = {case["test"] for case in cases} - selected = set(identifiers) if identifiers is not None else available - unknown = selected - available - if unknown: - raise ValueError("Unknown tests: " + ", ".join(sorted(unknown))) - while True: - required = selected | { - dependency - for case in cases if case["test"] in selected - for dependency in case.get("requires", []) - } - if required == selected: - return [case for case in cases if case["test"] in selected] - if required - available: - raise ValueError("Test data contains an unknown prerequisite.") - selected = required - - -def build_payload(case, run_id, key): - fields = case["payload"] - defaults = {} if fields.get("delete") == "1" else { - "sound": "default", "group": "Watch Option Tests", "level": "active" - } - payload = {**defaults, **fields, "device_key": key} - # Restrict replacement and deletion to notifications created by this run. - payload["id"] = f"watch-test-{run_id}-{fields.get('id', case['test'])}" - return payload - - -def send_push(server, payload): - request = Request( - server.rstrip("/") + "/push", - data=json.dumps(payload, ensure_ascii=False).encode(), - headers={"Content-Type": "application/json"}, - ) - try: - with urlopen(request, timeout=30) as response: - result = json.load(response) - status = response.status - except HTTPError as error: - raise RuntimeError(f"Server returned HTTP {error.code}.") from None - except (URLError, TimeoutError): - raise RuntimeError("Could not reach the server.") from None - except (ValueError, UnicodeError): - raise RuntimeError("Server returned an invalid JSON response.") from None - if status != 200 or not isinstance(result, dict) or result.get("code") != 200: - raise RuntimeError("Server rejected the push request.") - - -def main(argv=None): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--server", default=os.environ.get("BARK_SERVER_URL"), help="Server base address, or BARK_SERVER_URL.") - parser.add_argument("--key", default=os.environ.get("BARK_DEVICE_KEY"), help="Registered watch key, or BARK_DEVICE_KEY.") - parser.add_argument("--only", nargs="+", help="Test identifiers, such as W03 W30. Prerequisites are included automatically.") - parser.add_argument("--interval", type=interval_seconds, default=15, help="Seconds between requests; default: 15.") - parser.add_argument("--dry-run", action="store_true", help="Show test expectations without sending anything or requiring a key.") - args = parser.parse_args(argv) - data_path = Path(__file__).with_name("watch-notification-tests.json") - try: - cases = json.loads(data_path.read_text(encoding="utf-8")) - except (OSError, ValueError) as error: - parser.error(f"Cannot read {data_path.name}: {error}") - try: - cases = select_cases(cases, args.only) - except ValueError as error: - parser.error(str(error)) - if args.dry_run: - for case in cases: - print(case["test"] + ": " + case["expected"]) - return 0 - - server = (args.server or "").strip() - try: - url = urlsplit(server) - port = url.port - valid = url.scheme in ("http", "https") and url.hostname and not ( - url.username is not None or url.password is not None or url.query or url.fragment - ) and (port is None or port > 0) - except ValueError: - valid = False - if not valid: - parser.error("Provide --server with an http or https server base address, without credentials, query, or fragment.") - key = (args.key or "").strip() - if not key: - parser.error("Provide --key or set BARK_DEVICE_KEY to the registered watch key.") - - run_id = uuid4().hex[:12] - print(f"Sending {len(cases)} requests, {args.interval:g} seconds apart. Confirm results on the watch.", flush=True) - try: - for index, case in enumerate(cases): - if index: - time.sleep(args.interval) - print(case["test"] + " expected: " + case["expected"], flush=True) - send_push(server, build_payload(case, run_id, key)) - print(case["test"] + ": server accepted; watch result unconfirmed", flush=True) - except RuntimeError as error: - print(f"Stopped at {case['test']}: {error}", file=sys.stderr) - return 1 - except KeyboardInterrupt: - print("\nStopped; previously accepted requests may still arrive.") - return 130 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/BarkWatch/scripts/test_send_watch_tests.py b/BarkWatch/scripts/test_send_watch_tests.py deleted file mode 100644 index f194a5a3..00000000 --- a/BarkWatch/scripts/test_send_watch_tests.py +++ /dev/null @@ -1,99 +0,0 @@ -"""Check the sender against a local server; never send to Apple or a real watch.""" - -from http.server import BaseHTTPRequestHandler, HTTPServer -import importlib.util -import json -from pathlib import Path -import subprocess -import sys -from tempfile import TemporaryDirectory -from threading import Thread - - -def check(): - path = Path(__file__).with_name("send-watch-tests.py") - spec = importlib.util.spec_from_file_location("watch_sender", path) - sender = importlib.util.module_from_spec(spec) - spec.loader.exec_module(sender) - with TemporaryDirectory() as directory: - copied_script = Path(directory) / path.name - copied_script.write_text(path.read_text(), encoding="utf-8") - for data in (None, "/s\n{}"): - if data is not None: - copied_script.with_name("watch-notification-tests.json").write_text(data, encoding="utf-8") - result = subprocess.run([sys.executable, str(copied_script), "--dry-run"], capture_output=True, text=True, timeout=10) - assert result.returncode == 2 - assert "Cannot read watch-notification-tests.json" in result.stderr - assert "Traceback" not in result.stderr - cases = json.loads(path.with_name("watch-notification-tests.json").read_text()) - identifiers = [case["test"] for case in cases] - assert len(identifiers) == len(set(identifiers)) - for case in cases: - assert all(identifiers.index(required) < identifiers.index(case["test"]) for required in case.get("requires", [])) - assert len(sender.build_payload(case, "123456789abc", "synthetic-key")["id"].encode()) <= 64 - - requests = [] - - class Handler(BaseHTTPRequestHandler): - def do_POST(self): - payload = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) - requests.append(payload) - if self.path == "/http-error/push": - self.send_response(500) - self.end_headers() - return - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.end_headers() - if self.path == "/invalid-json/push": - self.wfile.write(b"invalid JSON") - else: - self.wfile.write(json.dumps({"code": 400 if self.path == "/rejected/push" else 200}).encode()) - - def log_message(self, *_): - pass - - with HTTPServer(("127.0.0.1", 0), Handler) as server: - thread = Thread(target=server.serve_forever, daemon=True) - thread.start() - base = f"http://127.0.0.1:{server.server_port}" - try: - def run(*args): - return subprocess.run([sys.executable, str(path), *args], capture_output=True, text=True, timeout=10) - - preview = run("--dry-run", "--only", "W31") - assert preview.returncode == 0, preview.stderr - assert all(identifier in preview.stdout for identifier in ("W29", "W30", "W31")) - assert not requests - assert run("--dry-run", "--only", "UNKNOWN").returncode == 2 - assert run("--dry-run", "--interval", "nan").returncode == 2 - assert run("--dry-run", "--interval", "-1").returncode == 2 - assert run("--server", "https://user:password@example.com", "--key", "synthetic-key").returncode == 2 - - result = run("--server", base, "--key", "synthetic-key", "--only", "W31", "--interval", "0") - assert result.returncode == 0, result.stderr - assert len(requests) == 3 - assert requests[0]["title"] == "Server Alert" - assert requests[0]["subtitle"] == "Production" - assert requests[0]["body"] == "CPU usage exceeded 90% on web-01." - assert requests[0]["level"] == "critical" - assert len({payload["id"] for payload in requests}) == 1 - assert all(set(payload) == {"device_key", "id", "delete"} for payload in requests[1:]) - assert "synthetic-key" not in result.stdout + result.stderr - previous_id = requests[0]["id"] - assert run("--server", base, "--key", "synthetic-key", "--only", "W29", "--interval", "0").returncode == 0 - assert requests[-1]["id"] != previous_id - - for route in ("http-error", "invalid-json", "rejected"): - result = run("--server", base + "/" + route, "--key", "synthetic-key", "--only", "W01", "W02", "--interval", "0") - assert result.returncode == 1 - assert "W02 expected" not in result.stdout - assert "synthetic-key" not in result.stdout + result.stderr - finally: - server.shutdown() - thread.join(timeout=5) - print("Passed: prerequisites, unique identifiers, silent payloads, dry runs, input validation, and server error handling.") - - -if __name__ == "__main__": - check() diff --git a/BarkWatch/scripts/watch-notification-tests.json b/BarkWatch/scripts/watch-notification-tests.json deleted file mode 100644 index 09ca749d..00000000 --- a/BarkWatch/scripts/watch-notification-tests.json +++ /dev/null @@ -1,362 +0,0 @@ -[ - { - "test": "W01", - "expected": "Title, subtitle, two body lines and emoji are readable.", - "payload": { - "title": "[W01] Text structure", - "subtitle": "Subtitle: watch test", - "body": "First line: café 🚴\nSecond line: 12345" - } - }, - { - "test": "W02", - "expected": "A body-only notification arrives without an invented title.", - "payload": { - "body": "[W02] Body only. No title or subtitle was sent." - } - }, - { - "test": "W03", - "expected": "The brief alert contains plain text. Opening the notification or Last message shows styled Markdown. FALLBACK means the extension did not process it.", - "payload": { - "title": "[W03] Markdown", - "body": "FALLBACK: extension did not run", - "markdown": "**Bold**, *italic*, and `code`\nSecond line preserved." - } - }, - { - "test": "W04", - "expected": "First notification in Watch Group A.", - "payload": { - "title": "[W04] Group A, first", - "body": "Keep this in Notification Center for W05.", - "group": "Watch Group A" - } - }, - { - "test": "W05", - "expected": "Groups with W04 in Notification Center, subject to the watch's grouping setting.", - "payload": { - "title": "[W05] Group A, second", - "body": "This belongs with W04.", - "group": "Watch Group A" - }, - "requires": [ - "W04" - ] - }, - { - "test": "W06", - "expected": "Uses a different notification group from W04 and W05.", - "payload": { - "title": "[W06] Group B", - "body": "This belongs to Watch Group B.", - "group": "Watch Group B" - }, - "requires": [ - "W04", - "W05" - ] - }, - { - "test": "W07", - "expected": "Appears in Notification Center without actively alerting. Open Notification Center to find it.", - "payload": { - "title": "[W07] Passive", - "body": "Quiet delivery: find me in Notification Center.", - "level": "passive" - } - }, - { - "test": "W08", - "expected": "Normal visible notification with no requested alert sound. Watch haptics and screen behavior remain system-controlled.", - "payload": { - "title": "[W08] Silent sound", - "body": "An active notification with sound disabled.", - "level": "active", - "sound": "silent" - } - }, - { - "test": "W09", - "expected": "Time-sensitive classification. Focus delivery depends on your time-sensitive notification setting.", - "payload": { - "title": "[W09] Time sensitive", - "body": "Time-sensitive delivery requested.", - "level": "timeSensitive" - } - }, - { - "test": "W10", - "expected": "Critical alert when permission is enabled; otherwise a normal alert with a fallback explanation in Last message.", - "payload": { - "title": "[W10] Critical permission check", - "body": "Critical when authorized; otherwise a normal alert.", - "level": "critical", - "volume": 10 - } - }, - { - "test": "W11", - "expected": "One normal system alert; no custom ringtone, repeating call, badge, or automatic copying.", - "payload": { - "title": "[W11] Unsupported options", - "body": "System sound fallback. No repeated ringing or icon badge.", - "sound": "minuet", - "call": "1", - "badge": 7, - "autoCopy": "1", - "copy": "WATCH-TEST" - } - }, - { - "test": "W12", - "expected": "An Apple logo image appears in the expanded notification if watchOS presents the attachment, and in Last message after opening it.", - "payload": { - "title": "[W12] Image", - "body": "Expand this notification to check the image.", - "image": "https://www.apple.com/ac/structured-data/images/knowledge_graph_logo.png" - } - }, - { - "test": "W13", - "expected": "Open the notification, then tap Open link in Last message. The app presents a web authentication session; close it with Cancel.", - "payload": { - "title": "[W13] Web link", - "body": "Open this notification, then tap Open link.", - "url": "https://example.com" - } - }, - { - "test": "W14", - "expected": "Initial replacement test. Leave this notification in Notification Center for W15.", - "payload": { - "id": "watch-options-replacement", - "title": "[W14] Progress 25%", - "body": "Leave me in Notification Center. W15 uses the same identifier.", - "group": "Watch Replacement" - } - }, - { - "test": "W15", - "expected": "Replaces W14 rather than leaving both progress notifications in Notification Center.", - "payload": { - "id": "watch-options-replacement", - "title": "[W15] Progress 100%", - "body": "Updated using the same identifier as W14.", - "group": "Watch Replacement" - }, - "requires": [ - "W14" - ] - }, - { - "test": "W16", - "expected": "The system default notification sound plays when normal watch sound settings allow it.", - "payload": { - "title": "[W16] System sound", - "body": "Normal active notification with the system default sound.", - "level": "active", - "sound": "default" - } - }, - { - "test": "W17", - "expected": "Visible notification with no requested alert sound.", - "payload": { - "title": "[W17] Silent sound", - "body": "The sound option is silent.", - "level": "active", - "sound": "silent" - } - }, - { - "test": "W18", - "expected": "The same system sound as W16; system is an alias, not a different tone.", - "payload": { - "title": "[W18] System sound alias", - "body": "System sound selected with sound=system.", - "level": "active", - "sound": "system" - } - }, - { - "test": "W19", - "expected": "Critical system alert at 30 percent volume only if critical permission is enabled. Otherwise a normal alert and a fallback explanation.", - "payload": { - "title": "[W19] Critical, volume 3", - "body": "Critical system sound, volume 3 of 10. Without permission this is a normal alert.", - "level": "critical", - "sound": "default", - "volume": 3 - } - }, - { - "test": "W20", - "expected": "Critical system alert at 80 percent volume only if critical permission is enabled. Otherwise a normal alert and a fallback explanation.", - "payload": { - "title": "[W20] Critical, volume 8", - "body": "Critical system sound, volume 8 of 10. Without permission this is a normal alert.", - "level": "critical", - "sound": "default", - "volume": 8 - } - }, - { - "test": "W21", - "expected": "Baseline notification with the ordinary application icon. Compare with W22.", - "payload": { - "title": "[W21] Standard icon", - "subtitle": "Icon comparison", - "body": "No custom icon was requested." - } - }, - { - "test": "W22", - "expected": "An Apple logo appears as the notification avatar. Title, subtitle, and body remain readable. Confirm on the notification itself.", - "payload": { - "title": "[W22] Custom icon", - "subtitle": "Icon comparison", - "body": "Look for the Apple logo avatar, not an image attachment.", - "icon": "https://www.apple.com/ac/structured-data/images/knowledge_graph_logo.png" - }, - "requires": [ - "W21" - ] - }, - { - "test": "W23", - "expected": "The notification still arrives with the ordinary application icon because the icon URL is invalid.", - "payload": { - "title": "[W23] Invalid icon fallback", - "body": "Text must arrive even when the icon cannot be loaded.", - "icon": "file:///invalid-icon.png" - } - }, - { - "test": "W24", - "expected": "The Apple logo avatar and an image attachment both appear. Title and subtitle remain intact.", - "payload": { - "title": "[W24] Icon and image", - "subtitle": "Both fields together", - "body": "Check the avatar and the expanded image separately.", - "icon": "https://www.apple.com/ac/structured-data/images/knowledge_graph_logo.png", - "image": "https://www.apple.com/ac/structured-data/images/knowledge_graph_logo.png" - } - }, - { - "test": "W25", - "payload": { - "title": "[W25] Archive expires in 30 seconds", - "body": "Archive expiration does not remove this alert from Notification Center.", - "ttl": "30", - "isArchive": "1" - }, - "expected": "Run this case alone to check expiration: it can appear on the complication, then the archive expires after 30 seconds. System complication refresh may be delayed." - }, - { - "test": "W26", - "payload": { - "title": "W26 Training", - "body": "First body line\nSecond body line", - "group": "Training", - "isArchive": "1" - }, - "expected": "Rectangular complication shows W26 Training above the two body lines; Training appears in the group picker." - }, - { - "test": "W27", - "payload": { - "title": "W27 Builds", - "body": "Build completed.\nReady for testing.", - "group": "Builds", - "isArchive": "1" - }, - "expected": "All groups and Builds show W27; selecting Training still shows W26.", - "requires": [ - "W26" - ] - }, - { - "test": "W28", - "payload": { - "title": "W28 Not archived", - "body": "This must not replace the complication.", - "group": "Builds", - "isArchive": "0" - }, - "expected": "Notification arrives, but All groups and Builds still show W27.", - "requires": [ - "W27" - ] - }, - { - "test": "W29", - "payload": { - "id": "silent-deletion", - "title": "Server Alert", - "subtitle": "Production", - "body": "CPU usage exceeded 90% on web-01.", - "group": "Monitoring", - "level": "critical" - }, - "expected": "The Server Alert arrives. Leave it in Notification Center for W30. Critical delivery requires entitlement and permission; otherwise it falls back to active." - }, - { - "test": "W30", - "requires": [ - "W29" - ], - "payload": { - "id": "silent-deletion", - "delete": "1" - }, - "expected": "W29 disappears from Notification Center and the archive without a new banner or sound. The latest-message card clears if it shows W29. Background delivery may be delayed." - }, - { - "test": "W31", - "requires": [ - "W30" - ], - "payload": { - "id": "silent-deletion", - "delete": "1" - }, - "expected": "Repeating W30 has no visible effect and does not affect other notifications." - }, - { - "test": "W32", - "payload": { - "id": "unknown-deletion", - "delete": "1" - }, - "expected": "Deleting an identifier that this run has never created has no visible effect." - }, - { - "test": "W33", - "payload": { - "title": "[W33] Full Markdown", - "body": "FALLBACK: extension did not run", - "markdown": "# Heading\n\n**Bold**, *italic*, ~~strikethrough~~, and `inline code`.\n\n> A quotation.\n\n1. Ordered item\n2. Second item\n - Nested bullet\n\n```swift\nlet temperature = 42\nprint(temperature)\n```\n\n| Service | Status |\n| --- | --- |\n| web-01 | Healthy |\n\n[Bark](https://bark.day.app)" - }, - "expected": "Check headings, emphasis, lists, quotation, code, table text, and the tappable web link in the expanded notification and Last message." - }, - { - "test": "W34", - "payload": { - "title": "[W34] Rejected application link", - "body": "This address must not produce an Open link button.", - "url": "file:///invalid-link" - }, - "expected": "Text is delivered; the invalid non-web address is not offered as an Open link button." - }, - { - "test": "W35", - "payload": { - "title": "[W35] Decryption failure", - "body": "This fallback must be sanitized.", - "ciphertext": "not-valid-base64!" - }, - "expected": "Decryption Failed appears. Outer body and links are not shown, and the failed encrypted message is not archived." - } -] diff --git a/BarkWatch/scripts/watch-server.patch b/BarkWatch/scripts/watch-server.patch deleted file mode 100644 index b6832113..00000000 --- a/BarkWatch/scripts/watch-server.patch +++ /dev/null @@ -1,177 +0,0 @@ -diff --git a/apns/apns.go b/apns/apns.go -index 6d845b4..22c3383 100644 ---- a/apns/apns.go -+++ b/apns/apns.go -@@ -5,6 +5,7 @@ import ( - "crypto/x509" - "fmt" - "net/http" -+ "os" - "runtime" - "strings" - "time" -@@ -38,11 +39,12 @@ func (p PushMessage) IsDelete() bool { - return val == "1" || val == 1 || val == 1.0 - } - --const ( -- topic = "me.fin.bark" -- keyID = "LH4T9V5U4R" -- teamID = "5U8LBRXG3A" -- PayloadMaximum = 4096 -+const PayloadMaximum = 4096 -+ -+var ( -+ topic = os.Getenv("BARK_APNS_TOPIC") -+ keyID = os.Getenv("BARK_APNS_KEY_ID") -+ teamID = os.Getenv("BARK_APNS_TEAM_ID") - ) - - var clients = make(chan *apns2.Client, 1) -@@ -57,7 +59,18 @@ func ReCreateAPNS(maxClientCount int) error { - return fmt.Errorf("invalid number of clients") - } - -- authKey, err := token.AuthKeyFromBytes([]byte(apnsPrivateKey)) -+ if topic == "" || keyID == "" || teamID == "" { -+ logger.Fatal("BARK_APNS_TOPIC, BARK_APNS_KEY_ID and BARK_APNS_TEAM_ID are required") -+ } -+ host := apns2.HostDevelopment -+ switch os.Getenv("BARK_APNS_ENVIRONMENT") { -+ case "development": -+ case "production": -+ host = apns2.HostProduction -+ default: -+ logger.Fatal("BARK_APNS_ENVIRONMENT must be development or production") -+ } -+ authKey, err := token.AuthKeyFromFile(os.Getenv("BARK_APNS_KEY_FILE")) - if err != nil { - logger.Fatalf("failed to create APNS auth key: %v", err) - } -@@ -94,7 +107,7 @@ func ReCreateAPNS(maxClientCount int) error { - }, - Timeout: apns2.HTTPClientTimeout, - }, -- Host: apns2.HostProduction, -+ Host: host, - } - logger.Infof("create apns client: %d", i) - clients <- client -@@ -107,10 +120,12 @@ func ReCreateAPNS(maxClientCount int) error { - func Push(msg *PushMessage) (code int, err error) { - pl := payload.NewPayload().MutableContent() - pushType := apns2.PushTypeAlert -+ priority := apns2.PriorityHigh - if msg.IsDelete() { - // Silent push notification - pl = pl.ContentAvailable() - pushType = apns2.PushTypeBackground -+ priority = apns2.PriorityLow - } else { - // Regular push notification - pl = pl.AlertTitle(msg.Title). -@@ -139,12 +154,13 @@ func Push(msg *PushMessage) (code int, err error) { - Payload: pl, - Expiration: time.Now().Add(24 * time.Hour), - PushType: pushType, -+ Priority: priority, - }) - if err != nil { - return 500, err - } - if resp.StatusCode != 200 { -- return resp.StatusCode, fmt.Errorf(resp.Reason) -+ return resp.StatusCode, fmt.Errorf("%s", resp.Reason) - } - return 200, nil - } -diff --git a/route_push.go b/route_push.go -index 8da18f4..c699a6b 100644 ---- a/route_push.go -+++ b/route_push.go -@@ -230,7 +230,7 @@ func push(params map[string]interface{}) (int, error) { - msg.Body = val - case "sound": - // Compatible with old parameters -- if strings.HasSuffix(val, ".caf") { -+ if val == "default" || strings.HasSuffix(val, ".caf") { - msg.Sound = val - } else { - msg.Sound = val + ".caf" -diff --git a/router.go b/router.go -index 56f0730..5d90fbd 100644 ---- a/router.go -+++ b/router.go -@@ -69,7 +69,7 @@ func registerRouteWithWeight(name string, weight int, f func(router fiber.Router - func routerSetup(router fiber.Router) { - routerOnce.Do(func() { - router.Use(fiberlogger.New(fiberlogger.Config{ -- Format: "${time} INFO ${ip} -> [${status}] ${method} ${latency} ${route} => ${url} ${body}\n", -+ Format: "${time} INFO ${ip} -> [${status}] ${method} ${latency} ${route}\n", - TimeFormat: "2006-01-02 15:04:05", - Output: os.Stdout, - })) -diff --git a/apns/watch_delete_test.go b/apns/watch_delete_test.go -new file mode 100644 -index 0000000..32dc2bf ---- /dev/null -+++ b/apns/watch_delete_test.go -@@ -0,0 +1,58 @@ -+package apns -+ -+import ( -+ "encoding/json" -+ "net/http" -+ "net/http/httptest" -+ "testing" -+ -+ "github.com/sideshow/apns2" -+) -+ -+func TestWatchDeletionPush(t *testing.T) { -+ var headers http.Header -+ var body map[string]interface{} -+ server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { -+ headers = r.Header.Clone() -+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil { -+ t.Error(err) -+ } -+ w.WriteHeader(http.StatusOK) -+ })) -+ defer server.Close() -+ originalClients := clients -+ clients = make(chan *apns2.Client, 1) -+ clients <- &apns2.Client{Host: server.URL, HTTPClient: server.Client()} -+ defer func() { clients = originalClients }() -+ -+ for _, test := range []struct { -+ name, pushType, priority string -+ deletion bool -+ }{ -+ {"delete", "background", "5", true}, -+ {"alert", "alert", "10", false}, -+ } { -+ t.Run(test.name, func(t *testing.T) { -+ params := map[string]interface{}{"id": "watch-delete-test"} -+ if test.deletion { -+ params["delete"] = "1" -+ } -+ code, err := Push(&PushMessage{Id: "watch-delete-test", DeviceToken: "synthetic", Title: "Test", Body: "Body", Sound: "default", ExtParams: params}) -+ if err != nil || code != http.StatusOK { -+ t.Fatalf("Push: code=%d, error=%v", code, err) -+ } -+ if headers.Get("apns-push-type") != test.pushType || headers.Get("apns-priority") != test.priority || headers.Get("apns-collapse-id") != "watch-delete-test" { -+ t.Fatalf("Unexpected push headers: %v", headers) -+ } -+ aps := body["aps"].(map[string]interface{}) -+ if test.deletion { -+ if aps["content-available"] != float64(1) || aps["alert"] != nil || aps["sound"] != nil || body["delete"] != "1" || body["id"] != "watch-delete-test" { -+ t.Fatalf("Unexpected deletion payload: %v", body) -+ } -+ } else if aps["alert"] == nil || aps["content-available"] != nil { -+ t.Fatalf("Unexpected alert payload: %v", body) -+ } -+ body = nil -+ }) -+ } -+} From ddd644213dbf607a88a06e781b6ac890e6a6d74d Mon Sep 17 00:00:00 2001 From: sebersta Date: Sat, 10 Oct 2026 18:41:39 -0700 Subject: [PATCH 3/9] Add CryptoSwift by pod istead of SPM --- Bark.xcodeproj/project.pbxproj | 170 ++++++++++++++---- .../xcshareddata/swiftpm/Package.resolved | 11 +- Podfile | 17 ++ Podfile.lock | 4 +- 4 files changed, 159 insertions(+), 43 deletions(-) diff --git a/Bark.xcodeproj/project.pbxproj b/Bark.xcodeproj/project.pbxproj index 7b6d657f..7d50dd7e 100644 --- a/Bark.xcodeproj/project.pbxproj +++ b/Bark.xcodeproj/project.pbxproj @@ -288,6 +288,7 @@ 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 */; }; 3DBD074B306B95A100A86B41 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3DBD074A306B95A100A86B41 /* WidgetKit.framework */; }; 3DBD074D306B95A100A86B41 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3DBD074C306B95A100A86B41 /* SwiftUI.framework */; }; @@ -296,18 +297,18 @@ 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, ); }; }; - B20000000000000000000004 /* CryptoSwift in Frameworks */ = {isa = PBXBuildFile; productRef = B20000000000000000000021 /* CryptoSwift */; }; - B20000000000000000000005 /* CryptoSwift in Frameworks */ = {isa = PBXBuildFile; productRef = B20000000000000000000022 /* CryptoSwift */; }; 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 */ @@ -448,6 +449,7 @@ 000000000000000000000032 /* BarkWatch.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = BarkWatch.entitlements; sourceTree = ""; }; 000000000000000000000033 /* TestNotification.apns */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = TestNotification.apns; sourceTree = ""; }; 000000000000000000000034 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; 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 = ""; }; @@ -626,9 +628,14 @@ 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; }; 3D23D570307AEB510087F74C /* BarkWatch.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = BarkWatch.xcconfig; sourceTree = ""; }; 3DBD0748306B95A100A86B41 /* BarkWatchComplicationExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = BarkWatchComplicationExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 3DBD074A306B95A100A86B41 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; @@ -636,6 +643,8 @@ 3DBD075F306B969400A86B41 /* BarkWatchComplicationExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = BarkWatchComplicationExtension.entitlements; sourceTree = ""; }; 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 = ""; }; @@ -647,6 +656,7 @@ 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 */ @@ -689,8 +699,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - B20000000000000000000004 /* CryptoSwift in Frameworks */, B20000000000000000000051 /* Markdown in Frameworks */, + CFBE41F3462A6D4F0E1A4B12 /* libPods-BarkWatch.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -698,6 +708,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 2FEAABEDC1A6FF1648620312 /* libPods-BarkWatchTests.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -761,8 +772,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - B20000000000000000000005 /* CryptoSwift in Frameworks */, B20000000000000000000052 /* Markdown in Frameworks */, + E68AEB0E47866777F8755476 /* libPods-BarkWatchNotificationService.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1122,6 +1133,9 @@ 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 = ""; @@ -1145,6 +1159,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 = ""; @@ -1156,10 +1176,12 @@ 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 = ( ); @@ -1175,7 +1197,6 @@ ); name = BarkWatch; packageProductDependencies = ( - B20000000000000000000021 /* CryptoSwift */, B20000000000000000000053 /* Markdown */, ); productName = BarkWatch; @@ -1186,6 +1207,7 @@ isa = PBXNativeTarget; buildConfigurationList = 000000000000000000000070 /* Build configuration list for PBXNativeTarget "BarkWatchTests" */; buildPhases = ( + F9620B9B24668A0183324C97 /* [CP] Check Pods Manifest.lock */, 000000000000000000000046 /* Sources */, 000000000000000000000047 /* Frameworks */, 000000000000000000000048 /* Resources */, @@ -1258,8 +1280,6 @@ 06697FDE2F70ED9400C4680E /* Widget */, ); name = WidgetExtension; - packageProductDependencies = ( - ); productName = WidgetExtension; productReference = 06697FD92F70ED9400C4680E /* WidgetExtension.appex */; productType = "com.apple.product-type.app-extension"; @@ -1318,8 +1338,6 @@ 3DBD074E306B95A100A86B41 /* BarkWatchComplication */, ); name = BarkWatchComplicationExtension; - packageProductDependencies = ( - ); productName = BarkWatchComplicationExtension; productReference = 3DBD0748306B95A100A86B41 /* BarkWatchComplicationExtension.appex */; productType = "com.apple.product-type.app-extension"; @@ -1328,8 +1346,10 @@ 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 = ( ); @@ -1340,7 +1360,6 @@ ); name = BarkWatchNotificationService; packageProductDependencies = ( - B20000000000000000000022 /* CryptoSwift */, B20000000000000000000054 /* Markdown */, ); productName = BarkWatchNotificationService; @@ -1415,7 +1434,6 @@ mainGroup = 0661A536204FDA4100965E4E; packageReferences = ( 06C777D12ECF05990032A044 /* XCRemoteSwiftPackageReference "swift-markdown" */, - B20000000000000000000020 /* XCRemoteSwiftPackageReference "CryptoSwift" */, ); productRefGroup = 0661A540204FDA4100965E4E /* Products */; projectDirPath = ""; @@ -1638,7 +1656,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", @@ -1694,6 +1712,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; @@ -1701,7 +1741,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", @@ -1742,6 +1782,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; @@ -2047,6 +2167,7 @@ /* 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; @@ -2087,6 +2208,7 @@ }; 000000000000000000000065 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 2EB8EE587788EAA2C88A9C1A /* Pods-BarkWatch.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; BARK_WATCH_ENTITLEMENTS = BarkWatch/BarkWatch.entitlements; @@ -2125,6 +2247,7 @@ }; 00000000000000000000006E /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = F877138E6E8864F7E43BC3EC /* Pods-BarkWatchTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ENABLE_MODULES = YES; @@ -2158,6 +2281,7 @@ }; 00000000000000000000006F /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 62DF9BBF3FDB3BE69663075F /* Pods-BarkWatchTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ENABLE_MODULES = YES; @@ -2796,6 +2920,7 @@ }; A10000000000000000000013 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 178D8742EC982295ED31D74C /* Pods-BarkWatchNotificationService.debug.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; APPLICATION_EXTENSION_API_ONLY = YES; @@ -2831,6 +2956,7 @@ }; A10000000000000000000014 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 2E2E32D47067F72822D1BE12 /* Pods-BarkWatchNotificationService.release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; APPLICATION_EXTENSION_API_ONLY = YES; @@ -2966,14 +3092,6 @@ minimumVersion = 0.7.3; }; }; - B20000000000000000000020 /* XCRemoteSwiftPackageReference "CryptoSwift" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/krzyzanowskim/CryptoSwift.git"; - requirement = { - kind = exactVersion; - version = 1.8.4; - }; - }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -2987,16 +3105,6 @@ package = 06C777D12ECF05990032A044 /* XCRemoteSwiftPackageReference "swift-markdown" */; productName = Markdown; }; - B20000000000000000000021 /* CryptoSwift */ = { - isa = XCSwiftPackageProductDependency; - package = B20000000000000000000020 /* XCRemoteSwiftPackageReference "CryptoSwift" */; - productName = CryptoSwift; - }; - B20000000000000000000022 /* CryptoSwift */ = { - isa = XCSwiftPackageProductDependency; - package = B20000000000000000000020 /* XCRemoteSwiftPackageReference "CryptoSwift" */; - productName = CryptoSwift; - }; B20000000000000000000053 /* Markdown */ = { isa = XCSwiftPackageProductDependency; package = 06C777D12ECF05990032A044 /* XCRemoteSwiftPackageReference "swift-markdown" */; diff --git a/Bark.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bark.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0cb299fb..6bd7bbc8 100644 --- a/Bark.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bark.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,15 +1,6 @@ { - "originHash" : "5d07a6647be4084549bffaef298591fe3fcedaac7b1cc22bbe36a80b31c29756", + "originHash" : "bb354f5ee26f97a80bea96da70628d71c97b25e01e4e845afe4f5b1d0b6cf6e2", "pins" : [ - { - "identity" : "cryptoswift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/krzyzanowskim/CryptoSwift.git", - "state" : { - "revision" : "729e01bc9b9dab466ac85f21fb9ee2bc1c61b258", - "version" : "1.8.4" - } - }, { "identity" : "swift-cmark", "kind" : "remoteSourceControl", 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 From ba7242723e5575307cf4bc9a4dbcdd3f86abfc88 Mon Sep 17 00:00:00 2001 From: Fin Date: Thu, 15 Oct 2026 14:53:32 +0800 Subject: [PATCH 4/9] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8A=A5=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Bark.xcodeproj/project.pbxproj | 120 +++------------------- BarkWatch/Common/PhoneCompatibility.swift | 3 - BarkWatch/README.md | 8 +- 3 files changed, 21 insertions(+), 110 deletions(-) diff --git a/Bark.xcodeproj/project.pbxproj b/Bark.xcodeproj/project.pbxproj index 7d50dd7e..7fdeaf20 100644 --- a/Bark.xcodeproj/project.pbxproj +++ b/Bark.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ 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 */; }; @@ -58,56 +59,6 @@ 0632052B250B6DD4001561EC /* bell.caf in Resources */ = {isa = PBXBuildFile; fileRef = 0632050C250B6DD4001561EC /* bell.caf */; }; 0632052C250B6DD4001561EC /* horn.caf in Resources */ = {isa = PBXBuildFile; fileRef = 0632050D250B6DD4001561EC /* horn.caf */; }; 0632052D250B6DD4001561EC /* newsflash.caf in Resources */ = {isa = PBXBuildFile; fileRef = 0632050E250B6DD4001561EC /* newsflash.caf */; }; - 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 */; }; - 060481EE250F404500BC9799 /* SoundsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 060481ED250F404500BC9799 /* SoundsViewController.swift */; }; - 060481F0250F51CA00BC9799 /* SoundCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 060481EF250F51CA00BC9799 /* SoundCell.swift */; }; - 0604F7DF20620D4900B32F09 /* ServerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0604F7DE20620D4900B32F09 /* ServerManager.swift */; }; - 0608F06E2994D115006B8029 /* BKDropDownCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0608F06D2994D115006B8029 /* BKDropDownCell.swift */; }; - 0608F0722994D269006B8029 /* BKDropDownCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 0608F0712994D269006B8029 /* BKDropDownCell.xib */; }; - 06172FDA27F6DAEF002333A4 /* ServerListTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06172FD927F6DAEF002333A4 /* ServerListTableViewCell.swift */; }; - 06172FDC27F6DB06002333A4 /* ServerListTableViewCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06172FDB27F6DB06002333A4 /* ServerListTableViewCellViewModel.swift */; }; - 061894C529962EB900E001C2 /* GradientButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 061894C429962EB900E001C2 /* GradientButton.swift */; }; - 061894C729A75BEA00E001C2 /* Algorithm.swift in Sources */ = {isa = PBXBuildFile; fileRef = 061894C629A75BEA00E001C2 /* Algorithm.swift */; }; - 061C17082D1BDA4B00891D66 /* MessageGroupMoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 061C17072D1BDA4B00891D66 /* MessageGroupMoreView.swift */; }; - 061E35862D1E5028009A2D6F /* MessageItemModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 061E35852D1E5028009A2D6F /* MessageItemModel.swift */; }; - 0627DABB298B6EA2002F3F69 /* DropBoxView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0627DABA298B6EA2002F3F69 /* DropBoxView.swift */; }; - 0627DABD2990D615002F3F69 /* BorderTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0627DABC2990D615002F3F69 /* BorderTextField.swift */; }; - 062B98C3251B2762004562E7 /* BKButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 062B98C2251B2762004562E7 /* BKButton.swift */; }; - 062B98C8251B27AE004562E7 /* UINavigationItem+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 062B98C7251B27AE004562E7 /* UINavigationItem+Extension.swift */; }; - 0632050F250B6DD4001561EC /* gotosleep.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204F0250B6DD1001561EC /* gotosleep.caf */; }; - 06320510250B6DD4001561EC /* paymentsuccess.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204F1250B6DD1001561EC /* paymentsuccess.caf */; }; - 06320511250B6DD4001561EC /* shake.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204F2250B6DD1001561EC /* shake.caf */; }; - 06320512250B6DD4001561EC /* alarm.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204F3250B6DD1001561EC /* alarm.caf */; }; - 06320513250B6DD4001561EC /* bloom.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204F4250B6DD1001561EC /* bloom.caf */; }; - 06320514250B6DD4001561EC /* sherwoodforest.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204F5250B6DD2001561EC /* sherwoodforest.caf */; }; - 06320515250B6DD4001561EC /* healthnotification.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204F6250B6DD2001561EC /* healthnotification.caf */; }; - 06320516250B6DD4001561EC /* calypso.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204F7250B6DD2001561EC /* calypso.caf */; }; - 06320517250B6DD4001561EC /* descent.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204F8250B6DD2001561EC /* descent.caf */; }; - 06320518250B6DD4001561EC /* ladder.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204F9250B6DD2001561EC /* ladder.caf */; }; - 06320519250B6DD4001561EC /* tiptoes.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204FA250B6DD2001561EC /* tiptoes.caf */; }; - 0632051A250B6DD4001561EC /* fanfare.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204FB250B6DD2001561EC /* fanfare.caf */; }; - 0632051B250B6DD4001561EC /* birdsong.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204FC250B6DD2001561EC /* birdsong.caf */; }; - 0632051C250B6DD4001561EC /* typewriters.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204FD250B6DD2001561EC /* typewriters.caf */; }; - 0632051D250B6DD4001561EC /* anticipate.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204FE250B6DD2001561EC /* anticipate.caf */; }; - 0632051E250B6DD4001561EC /* choo.caf in Resources */ = {isa = PBXBuildFile; fileRef = 063204FF250B6DD3001561EC /* choo.caf */; }; - 0632051F250B6DD4001561EC /* glass.caf in Resources */ = {isa = PBXBuildFile; fileRef = 06320500250B6DD3001561EC /* glass.caf */; }; - 06320520250B6DD4001561EC /* telegraph.caf in Resources */ = {isa = PBXBuildFile; fileRef = 06320501250B6DD3001561EC /* telegraph.caf */; }; - 06320521250B6DD4001561EC /* multiwayinvitation.caf in Resources */ = {isa = PBXBuildFile; fileRef = 06320502250B6DD3001561EC /* multiwayinvitation.caf */; }; - 06320522250B6DD4001561EC /* newmail.caf in Resources */ = {isa = PBXBuildFile; fileRef = 06320503250B6DD3001561EC /* newmail.caf */; }; - 06320523250B6DD4001561EC /* update.caf in Resources */ = {isa = PBXBuildFile; fileRef = 06320504250B6DD3001561EC /* update.caf */; }; - 06320524250B6DD4001561EC /* minuet.caf in Resources */ = {isa = PBXBuildFile; fileRef = 06320505250B6DD3001561EC /* minuet.caf */; }; - 06320525250B6DD4001561EC /* suspense.caf in Resources */ = {isa = PBXBuildFile; fileRef = 06320506250B6DD3001561EC /* suspense.caf */; }; - 06320526250B6DD4001561EC /* mailsent.caf in Resources */ = {isa = PBXBuildFile; fileRef = 06320507250B6DD3001561EC /* mailsent.caf */; }; - 06320527250B6DD4001561EC /* noir.caf in Resources */ = {isa = PBXBuildFile; fileRef = 06320508250B6DD3001561EC /* noir.caf */; }; - 06320528250B6DD4001561EC /* chime.caf in Resources */ = {isa = PBXBuildFile; fileRef = 06320509250B6DD4001561EC /* chime.caf */; }; - 06320529250B6DD4001561EC /* spell.caf in Resources */ = {isa = PBXBuildFile; fileRef = 0632050A250B6DD4001561EC /* spell.caf */; }; - 0632052A250B6DD4001561EC /* electronic.caf in Resources */ = {isa = PBXBuildFile; fileRef = 0632050B250B6DD4001561EC /* electronic.caf */; }; - 0632052B250B6DD4001561EC /* bell.caf in Resources */ = {isa = PBXBuildFile; fileRef = 0632050C250B6DD4001561EC /* bell.caf */; }; - 0632052C250B6DD4001561EC /* horn.caf in Resources */ = {isa = PBXBuildFile; fileRef = 0632050D250B6DD4001561EC /* horn.caf */; }; - 0632052D250B6DD4001561EC /* newsflash.caf in Resources */ = {isa = PBXBuildFile; fileRef = 0632050E250B6DD4001561EC /* newsflash.caf */; }; 0632CE1F20EC9098003FDF46 /* UserNotifications.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0683487020510FB20024B6DA /* UserNotifications.framework */; }; 0632CE2020EC9098003FDF46 /* UserNotificationsUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0683487220510FB20024B6DA /* UserNotificationsUI.framework */; }; 0632CE2320EC9098003FDF46 /* NotificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0632CE2220EC9098003FDF46 /* NotificationViewController.swift */; }; @@ -132,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 */; }; @@ -142,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 */; }; @@ -196,8 +148,10 @@ 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 */; }; @@ -281,6 +235,7 @@ 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 */; }; @@ -392,57 +347,6 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 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 = ""; }; - 060481ED250F404500BC9799 /* SoundsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundsViewController.swift; sourceTree = ""; }; - 060481EF250F51CA00BC9799 /* SoundCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundCell.swift; sourceTree = ""; }; - 0604F7DE20620D4900B32F09 /* ServerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerManager.swift; sourceTree = ""; }; - 0608F06D2994D115006B8029 /* BKDropDownCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BKDropDownCell.swift; sourceTree = ""; }; - 0608F0712994D269006B8029 /* BKDropDownCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = BKDropDownCell.xib; sourceTree = ""; }; - 06172FD927F6DAEF002333A4 /* ServerListTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListTableViewCell.swift; sourceTree = ""; }; - 06172FDB27F6DB06002333A4 /* ServerListTableViewCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListTableViewCellViewModel.swift; sourceTree = ""; }; - 061894C429962EB900E001C2 /* GradientButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GradientButton.swift; sourceTree = ""; }; - 061894C629A75BEA00E001C2 /* Algorithm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Algorithm.swift; sourceTree = ""; }; - 061C17072D1BDA4B00891D66 /* MessageGroupMoreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageGroupMoreView.swift; sourceTree = ""; }; - 061E35852D1E5028009A2D6F /* MessageItemModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageItemModel.swift; sourceTree = ""; }; - 062218512F741C91003E2DA0 /* MessageImageCleaner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageImageCleaner.swift; sourceTree = ""; }; - 062218552F741C91003E2DA0 /* MessageDeletionCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDeletionCoordinator.swift; sourceTree = ""; }; - 0627DABA298B6EA2002F3F69 /* DropBoxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DropBoxView.swift; sourceTree = ""; }; - 0627DABC2990D615002F3F69 /* BorderTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BorderTextField.swift; sourceTree = ""; }; - 062B98C2251B2762004562E7 /* BKButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BKButton.swift; sourceTree = ""; }; - 062B98C7251B27AE004562E7 /* UINavigationItem+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UINavigationItem+Extension.swift"; sourceTree = ""; }; - 063204F0250B6DD1001561EC /* gotosleep.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = gotosleep.caf; sourceTree = ""; }; - 063204F1250B6DD1001561EC /* paymentsuccess.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = paymentsuccess.caf; sourceTree = ""; }; - 063204F2250B6DD1001561EC /* shake.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = shake.caf; sourceTree = ""; }; - 063204F3250B6DD1001561EC /* alarm.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = alarm.caf; sourceTree = ""; }; - 063204F4250B6DD1001561EC /* bloom.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = bloom.caf; sourceTree = ""; }; - 063204F5250B6DD2001561EC /* sherwoodforest.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = sherwoodforest.caf; sourceTree = ""; }; - 063204F6250B6DD2001561EC /* healthnotification.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = healthnotification.caf; sourceTree = ""; }; - 063204F7250B6DD2001561EC /* calypso.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = calypso.caf; sourceTree = ""; }; - 063204F8250B6DD2001561EC /* descent.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = descent.caf; sourceTree = ""; }; - 063204F9250B6DD2001561EC /* ladder.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = ladder.caf; sourceTree = ""; }; - 063204FA250B6DD2001561EC /* tiptoes.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = tiptoes.caf; sourceTree = ""; }; - 063204FB250B6DD2001561EC /* fanfare.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = fanfare.caf; sourceTree = ""; }; - 063204FC250B6DD2001561EC /* birdsong.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = birdsong.caf; sourceTree = ""; }; - 063204FD250B6DD2001561EC /* typewriters.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = typewriters.caf; sourceTree = ""; }; - 063204FE250B6DD2001561EC /* anticipate.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = anticipate.caf; sourceTree = ""; }; - 063204FF250B6DD3001561EC /* choo.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = choo.caf; sourceTree = ""; }; - 06320500250B6DD3001561EC /* glass.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = glass.caf; sourceTree = ""; }; - 06320501250B6DD3001561EC /* telegraph.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = telegraph.caf; sourceTree = ""; }; - 06320502250B6DD3001561EC /* multiwayinvitation.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = multiwayinvitation.caf; sourceTree = ""; }; - 06320503250B6DD3001561EC /* newmail.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = newmail.caf; sourceTree = ""; }; - 06320504250B6DD3001561EC /* update.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = update.caf; sourceTree = ""; }; - 06320505250B6DD3001561EC /* minuet.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = minuet.caf; sourceTree = ""; }; - 06320506250B6DD3001561EC /* suspense.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = suspense.caf; sourceTree = ""; }; - 06320507250B6DD3001561EC /* mailsent.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = mailsent.caf; sourceTree = ""; }; - 06320508250B6DD3001561EC /* noir.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = noir.caf; sourceTree = ""; }; - 06320509250B6DD4001561EC /* chime.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = chime.caf; sourceTree = ""; }; - 0632050A250B6DD4001561EC /* spell.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = spell.caf; sourceTree = ""; }; - 0632050B250B6DD4001561EC /* electronic.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = electronic.caf; sourceTree = ""; }; - 0632050C250B6DD4001561EC /* bell.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = bell.caf; sourceTree = ""; }; - 0632050D250B6DD4001561EC /* horn.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = horn.caf; sourceTree = ""; }; - 0632050E250B6DD4001561EC /* newsflash.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = newsflash.caf; sourceTree = ""; }; 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 = ""; }; @@ -464,6 +368,8 @@ 061894C629A75BEA00E001C2 /* Algorithm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Algorithm.swift; sourceTree = ""; }; 061C17072D1BDA4B00891D66 /* MessageGroupMoreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageGroupMoreView.swift; sourceTree = ""; }; 061E35852D1E5028009A2D6F /* MessageItemModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageItemModel.swift; sourceTree = ""; }; + 062218512F741C91003E2DA0 /* MessageImageCleaner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageImageCleaner.swift; sourceTree = ""; }; + 062218552F741C91003E2DA0 /* MessageDeletionCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDeletionCoordinator.swift; sourceTree = ""; }; 0627DABA298B6EA2002F3F69 /* DropBoxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DropBoxView.swift; sourceTree = ""; }; 0627DABC2990D615002F3F69 /* BorderTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BorderTextField.swift; sourceTree = ""; }; 062B98C2251B2762004562E7 /* BKButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BKButton.swift; sourceTree = ""; }; @@ -529,7 +435,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 = ""; }; @@ -574,8 +479,10 @@ 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 = ""; }; @@ -624,6 +531,7 @@ 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 = ""; }; @@ -819,6 +727,7 @@ 060481ED250F404500BC9799 /* SoundsViewController.swift */, 065BE43F2563D649002A8CA4 /* SoundsViewModel.swift */, 06F11E7627D9D5FB00F00298 /* QRScannerViewController.swift */, + 06C10A032FB20001000000D1 /* PushQRCodeViewController.swift */, 068EC15727ED99C900D5D11E /* ServerListViewController.swift */, 068EC15927ED99E700D5D11E /* ServerListViewModel.swift */, 06EEF332291CCFF400CA228A /* CryptoSettingController.swift */, @@ -961,6 +870,7 @@ 06BCAE552CDB19260092867A /* GroupMuteSettingManager.swift */, 06C777CF2ECEFF210032A044 /* MarkdownParser.swift */, 06A100182FEE0001000000A1 /* CodeHighlighter.swift */, + 06C10A012FB20001000000D1 /* QRCodeGenerator.swift */, 066B10002F73F00100C4680E /* WidgetHistoryModels.swift */, 06697FED2F70ED9600C4680E /* WidgetHistorySnapshotStore.swift */, 06697FF52F70ED9600C4680E /* WidgetHistorySnapshotSupport.swift */, @@ -1894,6 +1804,7 @@ E10000000000000000000000 /* Algorithm.swift in Sources */, E10000000000000000000001 /* MarkdownParser.swift in Sources */, E10000000000000000000002 /* UIFont+Extension.swift in Sources */, + 065632CB3080A88400A0C5A4 /* Error+Extension.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1985,6 +1896,7 @@ 06885EB6247FB9880004A303 /* MessageSettingsViewController.swift in Sources */, 0635A8052CE47DFE0027E00F /* SettingSectionHeader.swift in Sources */, 06F11E7727D9D5FB00F00298 /* QRScannerViewController.swift in Sources */, + 06C10A042FB20001000000D1 /* PushQRCodeViewController.swift in Sources */, 0608F06E2994D115006B8029 /* BKDropDownCell.swift in Sources */, 06C5952D2480E3F8006B98F3 /* LabelCell.swift in Sources */, 06787C392A710568008ABDD7 /* GesturePassTextView.swift in Sources */, @@ -2000,6 +1912,7 @@ 06A100112FEE0001000000A1 /* HomePermissionCard.swift in Sources */, 06A100132FEE0001000000A1 /* HomeExampleCard.swift in Sources */, 06A100192FEE0001000000A1 /* CodeHighlighter.swift in Sources */, + 06C10A022FB20001000000D1 /* QRCodeGenerator.swift in Sources */, 06A100152FEE0001000000A1 /* HomeSettingsCard.swift in Sources */, 06A100172FEE0001000000A1 /* HomeDocumentsCard.swift in Sources */, 061E35862D1E5028009A2D6F /* MessageItemModel.swift in Sources */, @@ -2102,6 +2015,7 @@ E10000000000000000000011 /* MarkdownParser.swift in Sources */, E10000000000000000000012 /* UIFont+Extension.swift in Sources */, A10000000000000000000006 /* WatchNotificationService.swift in Sources */, + 065632CC3080A88400A0C5A4 /* Error+Extension.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/BarkWatch/Common/PhoneCompatibility.swift b/BarkWatch/Common/PhoneCompatibility.swift index 97636293..34317090 100644 --- a/BarkWatch/Common/PhoneCompatibility.swift +++ b/BarkWatch/Common/PhoneCompatibility.swift @@ -26,9 +26,6 @@ enum BKColor { } } -// Mirrors Common/Error+Extension.swift so Algorithm.swift's `throw "…"` compiles. -extension String: @retroactive Error {} - // 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 { diff --git a/BarkWatch/README.md b/BarkWatch/README.md index 770ff920..9af24f47 100644 --- a/BarkWatch/README.md +++ b/BarkWatch/README.md @@ -1,26 +1,26 @@ BarkWatch -A standalone watch application prototype for watchOS 10 or later. Communication with the phone app is not yet implemented. BarkWatch shares the unchanged iOS sources `Model/Algorithm.swift`, `Common/MarkdownParser.swift`, and `Common/UIFont+Extension.swift`. Watch-specific adaptations live in `BarkWatch/Common`. +A standalone watch application prototype for watchOS 10 or later. Communication with the phone app is not yet implemented. BarkWatch shares the unchanged iOS sources `Model/Algorithm.swift`, `Common/MarkdownParser.swift`, `Common/UIFont+Extension.swift`, and `Common/Error+Extension.swift`. Watch-specific adaptations live in `BarkWatch/Common`. **BarkWatch cannot use the official Bark server because its push configuration targets the official phone app.** Use a custom Bark server with your Apple signing credentials, the watch's bundle identifier (`com.sebersta.BarkWatchPrototype` in this checkout). ## Shared iOS Code -The watch targets compile three iOS files. Change to these files can break the watch build. +The watch targets compile four iOS files. Change to these files can break the watch build. | iOS file | Watch relies on | | --- | --- | | `Model/Algorithm.swift` | `Algorithm` (`modes`, `keyLength`), `CryptoSettingFields`, and all four stored properties of `AESCryptoModel` (`key`, `mode`, `padding`, `aes`), which `AESCryptoModel(watchFields:)` assigns | | `Common/MarkdownParser.swift` | `MarkdownParser.Configuration`; `WatchMarkdownParser` identifies code blocks by monospaced font plus `codeBlockTextColor` plus a background color, and task-list checkboxes as text attachments | | `Common/UIFont+Extension.swift` | `UIFont.preferredFont(ofSize:weight:)` used by `MarkdownParser` | +| `Common/Error+Extension.swift` | The `String: Error` conformance, which makes `Algorithm.swift`'s plain-string throws (`throw "Invalid Mode"`) compile, and `Error.rawString()`, which formats decryption errors | ### PhoneCompatibility.swift -`Algorithm.swift` and `MarkdownParser.swift` use code from three other iOS files. The watch App does not compile those files, so `BarkWatch/Common/PhoneCompatibility.swift` contains copies of just the parts that are used: +`Algorithm.swift` and `MarkdownParser.swift` use code from two other iOS files. The watch App does not compile those files, so `BarkWatch/Common/PhoneCompatibility.swift` contains copies of just the parts that are used: | iOS file | Why BarkWatch needs a copy | | --- | --- | | `Common/BKColor.swift` | `MarkdownParser` uses four `BKColor` colors as defaults. The iOS app loads them from its asset catalog, which the watch does not have, so the watch defines them as fixed colors manually. | -| `Common/Error+Extension.swift` | `Algorithm.swift` throws plain strings (`throw "Invalid Mode"`). This only compiles because of `extension String: Error {}`, which the watch copies. | | `Common/String+Extension.swift` | Both files call `.localized`. The watch has its own `.localized` without the iOS app's string catalog. | The tests encryptedNotificationsMatchPhoneWireFormat and markdownUsesPhoneParserForFormattingAndNotificationFallback in BarkWatch/Tests/NotificationTests.swift check that the watch's encryption and Markdown output still match the iOS. From 029ae68d783691d699a77bba4471f921cabfccc6 Mon Sep 17 00:00:00 2001 From: Fin Date: Mon, 19 Oct 2026 15:32:50 +0800 Subject: [PATCH 5/9] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E5=B0=8F=E7=BB=84?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Bark.xcodeproj/project.pbxproj | 292 +----------------- .../xcshareddata/swiftpm/Package.resolved | 11 +- .../BarkWatchComplicationExtension.xcscheme | 117 ------- BarkWatch/App/WatchMessage.swift | 13 +- BarkWatch/BarkWatch.xcconfig | 2 +- .../BarkWatchComplication/AppIntent.swift | 38 --- .../AccentColor.colorset/Contents.json | 11 - .../AppIcon.appiconset/Contents.json | 13 - .../Assets.xcassets/Contents.json | 6 - .../WidgetBackground.colorset/Contents.json | 11 - .../BarkWatchComplication.swift | 57 ---- .../BarkWatchComplicationBundle.swift | 16 - .../ComplicationView.swift | 67 ---- BarkWatch/BarkWatchComplication/Info.plist | 11 - ...arkWatchComplicationExtension.entitlements | 10 - BarkWatch/Common/WatchArchive.swift | 22 +- .../Common/WatchNotificationDelivery.swift | 5 - BarkWatch/README.md | 5 +- BarkWatch/Tests/NotificationTests.swift | 12 +- BarkWatch/View/ContentView.swift | 7 - 20 files changed, 13 insertions(+), 713 deletions(-) delete mode 100644 Bark.xcodeproj/xcshareddata/xcschemes/BarkWatchComplicationExtension.xcscheme delete mode 100644 BarkWatch/BarkWatchComplication/AppIntent.swift delete mode 100644 BarkWatch/BarkWatchComplication/Assets.xcassets/AccentColor.colorset/Contents.json delete mode 100644 BarkWatch/BarkWatchComplication/Assets.xcassets/AppIcon.appiconset/Contents.json delete mode 100644 BarkWatch/BarkWatchComplication/Assets.xcassets/Contents.json delete mode 100644 BarkWatch/BarkWatchComplication/Assets.xcassets/WidgetBackground.colorset/Contents.json delete mode 100644 BarkWatch/BarkWatchComplication/BarkWatchComplication.swift delete mode 100644 BarkWatch/BarkWatchComplication/BarkWatchComplicationBundle.swift delete mode 100644 BarkWatch/BarkWatchComplication/ComplicationView.swift delete mode 100644 BarkWatch/BarkWatchComplication/Info.plist delete mode 100644 BarkWatch/BarkWatchComplicationExtension.entitlements diff --git a/Bark.xcodeproj/project.pbxproj b/Bark.xcodeproj/project.pbxproj index 7fdeaf20..e350a6ed 100644 --- a/Bark.xcodeproj/project.pbxproj +++ b/Bark.xcodeproj/project.pbxproj @@ -148,7 +148,6 @@ 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 */; }; @@ -169,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 */; }; @@ -235,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 */; }; @@ -245,9 +245,6 @@ 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 */; }; - 3DBD074B306B95A100A86B41 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3DBD074A306B95A100A86B41 /* WidgetKit.framework */; }; - 3DBD074D306B95A100A86B41 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3DBD074C306B95A100A86B41 /* SwiftUI.framework */; }; - 3DBD075A306B95A300A86B41 /* BarkWatchComplicationExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 3DBD0748306B95A100A86B41 /* BarkWatchComplicationExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 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 */; }; @@ -302,13 +299,6 @@ remoteGlobalIDString = 0661A53E204FDA4100965E4E; remoteInfo = Bark; }; - 3DBD0758306B95A300A86B41 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 0661A537204FDA4100965E4E /* Project object */; - proxyType = 1; - remoteGlobalIDString = 3DBD0747306B95A100A86B41; - remoteInfo = BarkWatchComplicationExtension; - }; A10000000000000000000011 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 0661A537204FDA4100965E4E /* Project object */; @@ -338,7 +328,6 @@ dstPath = ""; dstSubfolderSpec = 13; files = ( - 3DBD075A306B95A300A86B41 /* BarkWatchComplicationExtension.appex in Embed App Extensions */, A10000000000000000000007 /* BarkWatchNotificationService.appex in Embed App Extensions */, ); name = "Embed App Extensions"; @@ -479,7 +468,6 @@ 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 = ""; }; @@ -497,6 +485,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 = ""; }; @@ -531,7 +521,6 @@ 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 = ""; }; @@ -545,10 +534,6 @@ 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; }; 3D23D570307AEB510087F74C /* BarkWatch.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = BarkWatch.xcconfig; sourceTree = ""; }; - 3DBD0748306B95A100A86B41 /* BarkWatchComplicationExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = BarkWatchComplicationExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; - 3DBD074A306B95A100A86B41 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; - 3DBD074C306B95A100A86B41 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; - 3DBD075F306B969400A86B41 /* BarkWatchComplicationExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = BarkWatchComplicationExtension.entitlements; sourceTree = ""; }; 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 = ""; }; @@ -576,30 +561,14 @@ ); target = 06697FD82F70ED9400C4680E /* WidgetExtension */; }; - 3D099DDE306DBCFC00A96591 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - WatchArchive.swift, - WatchConstants.swift, - ); - target = 3DBD0747306B95A100A86B41 /* BarkWatchComplicationExtension */; - }; - 3DBD075B306B95A300A86B41 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - Info.plist, - ); - target = 3DBD0747306B95A100A86B41 /* BarkWatchComplicationExtension */; - }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* 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; exceptions = (3D099DDE306DBCFC00A96591 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Common; sourceTree = ""; }; + 3D099DD6306DBCFB00A96591 /* Common */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Common; sourceTree = ""; }; 3D2E504F306D002800D53E4F /* View */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = View; sourceTree = ""; }; - 3DBD074E306B95A100A86B41 /* BarkWatchComplication */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (3DBD075B306B95A300A86B41 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = BarkWatchComplication; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -667,15 +636,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 3DBD0745306B95A100A86B41 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 3DBD074D306B95A100A86B41 /* SwiftUI.framework in Frameworks */, - 3DBD074B306B95A100A86B41 /* WidgetKit.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; B20000000000000000000013 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -695,15 +655,12 @@ 3D099DCC306DBCEE00A96591 /* Model */, 3D2E504F306D002800D53E4F /* View */, 3D099DD6306DBCFB00A96591 /* Common */, - 3DBD074E306B95A100A86B41 /* BarkWatchComplication */, D40000000000000000000005 /* Tests */, C30000000000000000000001 /* Assets.xcassets */, 000000000000000000000032 /* BarkWatch.entitlements */, - 3DBD075F306B969400A86B41 /* BarkWatchComplicationExtension.entitlements */, 000000000000000000000033 /* TestNotification.apns */, B20000000000000000000055 /* MarkdownNotification.apns */, 000000000000000000000034 /* README.md */, - 3DBD0749306B95A100A86B41 /* Frameworks */, A10000000000000000000002 /* WatchNotificationService.swift */, 3D23D570307AEB510087F74C /* BarkWatch.xcconfig */, ); @@ -919,7 +876,6 @@ 000000000000000000000006 /* BarkWatch.app */, 000000000000000000000007 /* BarkWatchTests.xctest */, A10000000000000000000003 /* BarkWatchNotificationService.appex */, - 3DBD0748306B95A100A86B41 /* BarkWatchComplicationExtension.appex */, ); name = Products; sourceTree = ""; @@ -1021,15 +977,6 @@ path = Intents; sourceTree = ""; }; - 3DBD0749306B95A100A86B41 /* Frameworks */ = { - isa = PBXGroup; - children = ( - 3DBD074A306B95A100A86B41 /* WidgetKit.framework */, - 3DBD074C306B95A100A86B41 /* SwiftUI.framework */, - ); - name = Frameworks; - sourceTree = ""; - }; 99BD309BDB7F62B5DC0CECE1 /* Frameworks */ = { isa = PBXGroup; children = ( @@ -1097,7 +1044,6 @@ ); dependencies = ( A10000000000000000000009 /* PBXTargetDependency */, - 3DBD0759306B95A300A86B41 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 3D099DCC306DBCEE00A96591 /* Model */, @@ -1232,26 +1178,6 @@ productReference = 06EE1FD026843E9300586708 /* BarkTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; - 3DBD0747306B95A100A86B41 /* BarkWatchComplicationExtension */ = { - isa = PBXNativeTarget; - buildConfigurationList = 3DBD075C306B95A300A86B41 /* Build configuration list for PBXNativeTarget "BarkWatchComplicationExtension" */; - buildPhases = ( - 3DBD0744306B95A100A86B41 /* Sources */, - 3DBD0745306B95A100A86B41 /* Frameworks */, - 3DBD0746306B95A100A86B41 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - fileSystemSynchronizedGroups = ( - 3DBD074E306B95A100A86B41 /* BarkWatchComplication */, - ); - name = BarkWatchComplicationExtension; - productName = BarkWatchComplicationExtension; - productReference = 3DBD0748306B95A100A86B41 /* BarkWatchComplicationExtension.appex */; - productType = "com.apple.product-type.app-extension"; - }; A10000000000000000000010 /* BarkWatchNotificationService */ = { isa = PBXNativeTarget; buildConfigurationList = A10000000000000000000015 /* Build configuration list for PBXNativeTarget "BarkWatchNotificationService" */; @@ -1324,9 +1250,6 @@ ProvisioningStyle = Automatic; TestTargetID = 0661A53E204FDA4100965E4E; }; - 3DBD0747306B95A100A86B41 = { - CreatedOnToolsVersion = 27.0; - }; }; }; buildConfigurationList = 0661A53A204FDA4100965E4E /* Build configuration list for PBXProject "Bark" */; @@ -1357,7 +1280,6 @@ 000000000000000000000004 /* BarkWatch */, 000000000000000000000005 /* BarkWatchTests */, A10000000000000000000010 /* BarkWatchNotificationService */, - 3DBD0747306B95A100A86B41 /* BarkWatchComplicationExtension */, ); }; /* End PBXProject section */ @@ -1486,13 +1408,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 3DBD0746306B95A100A86B41 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -2000,13 +1915,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 3DBD0744306B95A100A86B41 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; A10000000000000000000012 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -2047,11 +1955,6 @@ target = 0661A53E204FDA4100965E4E /* Bark */; targetProxy = 06EE1FD526843E9300586708 /* PBXContainerItemProxy */; }; - 3DBD0759306B95A300A86B41 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 3DBD0747306B95A100A86B41 /* BarkWatchComplicationExtension */; - targetProxy = 3DBD0758306B95A300A86B41 /* PBXContainerItemProxy */; - }; A10000000000000000000009 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = A10000000000000000000010 /* BarkWatchNotificationService */; @@ -2656,182 +2559,6 @@ }; name = Release; }; - 3DBD075D306B95A300A86B41 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - CODE_SIGN_ENTITLEMENTS = BarkWatch/BarkWatchComplicationExtension.entitlements; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; - COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 1; - DEBUG_INFORMATION_FORMAT = dwarf; - DEVELOPMENT_TEAM = 8HVQYZSB2F; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = BarkWatch/BarkWatchComplication/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = BarkWatchComplication; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@executable_path/../../Frameworks", - "@executable_path/../../../../Frameworks", - ); - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 0.1; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; - MTL_FAST_MATH = YES; - ONLY_ACTIVE_ARCH = YES; - PRODUCT_BUNDLE_IDENTIFIER = com.sebersta.BarkWatchPrototype.BarkWatchComplication; - PRODUCT_NAME = "$(TARGET_NAME)"; - REGISTER_APP_GROUPS = YES; - SDKROOT = watchos; - SKIP_INSTALL = YES; - STRING_CATALOG_GENERATE_SYMBOLS = YES; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; - SWIFT_APPROACHABLE_CONCURRENCY = YES; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 6.0; - TARGETED_DEVICE_FAMILY = 4; - WATCHOS_DEPLOYMENT_TARGET = 10.0; - }; - name = Debug; - }; - 3DBD075E306B95A300A86B41 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - CODE_SIGN_ENTITLEMENTS = BarkWatch/BarkWatchComplicationExtension.entitlements; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; - COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 1; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEVELOPMENT_TEAM = 8HVQYZSB2F; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = BarkWatch/BarkWatchComplication/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = BarkWatchComplication; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@executable_path/../../Frameworks", - "@executable_path/../../../../Frameworks", - ); - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 0.1; - MTL_ENABLE_DEBUG_INFO = NO; - MTL_FAST_MATH = YES; - ONLY_ACTIVE_ARCH = YES; - PRODUCT_BUNDLE_IDENTIFIER = com.sebersta.BarkWatchPrototype.BarkWatchComplication; - PRODUCT_NAME = "$(TARGET_NAME)"; - REGISTER_APP_GROUPS = YES; - SDKROOT = watchos; - SKIP_INSTALL = YES; - STRING_CATALOG_GENERATE_SYMBOLS = YES; - SWIFT_APPROACHABLE_CONCURRENCY = YES; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 6.0; - TARGETED_DEVICE_FAMILY = 4; - VALIDATE_PRODUCT = YES; - WATCHOS_DEPLOYMENT_TARGET = 10.0; - }; - name = Release; - }; A10000000000000000000013 /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = 178D8742EC982295ED31D74C /* Pods-BarkWatchNotificationService.debug.xcconfig */; @@ -2977,15 +2704,6 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 3DBD075C306B95A300A86B41 /* Build configuration list for PBXNativeTarget "BarkWatchComplicationExtension" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 3DBD075D306B95A300A86B41 /* Debug */, - 3DBD075E306B95A300A86B41 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; A10000000000000000000015 /* Build configuration list for PBXNativeTarget "BarkWatchNotificationService" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/Bark.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bark.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0cb299fb..6bd7bbc8 100644 --- a/Bark.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bark.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,15 +1,6 @@ { - "originHash" : "5d07a6647be4084549bffaef298591fe3fcedaac7b1cc22bbe36a80b31c29756", + "originHash" : "bb354f5ee26f97a80bea96da70628d71c97b25e01e4e845afe4f5b1d0b6cf6e2", "pins" : [ - { - "identity" : "cryptoswift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/krzyzanowskim/CryptoSwift.git", - "state" : { - "revision" : "729e01bc9b9dab466ac85f21fb9ee2bc1c61b258", - "version" : "1.8.4" - } - }, { "identity" : "swift-cmark", "kind" : "remoteSourceControl", diff --git a/Bark.xcodeproj/xcshareddata/xcschemes/BarkWatchComplicationExtension.xcscheme b/Bark.xcodeproj/xcshareddata/xcschemes/BarkWatchComplicationExtension.xcscheme deleted file mode 100644 index 9a0c5413..00000000 --- a/Bark.xcodeproj/xcshareddata/xcschemes/BarkWatchComplicationExtension.xcscheme +++ /dev/null @@ -1,117 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/BarkWatch/App/WatchMessage.swift b/BarkWatch/App/WatchMessage.swift index b7eadd51..0b884aa5 100644 --- a/BarkWatch/App/WatchMessage.swift +++ b/BarkWatch/App/WatchMessage.swift @@ -10,7 +10,6 @@ import Observation import os import UserNotifications import WatchKit -import WidgetKit struct WatchMessage: Equatable, Sendable { let title: String @@ -83,14 +82,6 @@ final class NotificationState { lastMessage = (try? archive.latest(group: nil)).flatMap(WatchMessage.init(archived:)) } - /// Shows the archived message a complication was displaying, falling back to - /// the newest one if it has since expired. Returns whether a message is shown. - func showArchivedMessage(id: String?, archive: WatchArchive = .shared) -> Bool { - let archived = id.flatMap { try? archive.message(id: $0) } ?? (try? archive.latest(group: nil)) - if let archived { lastMessage = WatchMessage(archived: archived) } - return lastMessage != nil - } - var hasNotificationPermission: Bool { authorizationStatus == .authorized || authorizationStatus == .provisional } @@ -222,9 +213,7 @@ final class NotificationDelegate: NSObject, WKApplicationDelegate, UNUserNotific UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [identifier]) state.removeMessage(withIdentifier: identifier) do { - if try archive.remove(id: identifier) { - WidgetCenter.shared.reloadTimelines(ofKind: WatchArchive.widgetKind) - } + try archive.remove(id: identifier) return .newData } catch { logger.error("Could not delete archived notification: \(error.localizedDescription)") diff --git a/BarkWatch/BarkWatch.xcconfig b/BarkWatch/BarkWatch.xcconfig index 35b85733..6c75c1eb 100644 --- a/BarkWatch/BarkWatch.xcconfig +++ b/BarkWatch/BarkWatch.xcconfig @@ -1,5 +1,5 @@ // Signing identity for all BarkWatch targets (app, notification service, -// complication, tests). Change these two values to build under another team. +// tests). Change these two values to build under another team. // // Every bundle identifier, the app group (group.) and the keychain access // group (.encryption) are derived from BARK_WATCH_BUNDLE_ID in the diff --git a/BarkWatch/BarkWatchComplication/AppIntent.swift b/BarkWatch/BarkWatchComplication/AppIntent.swift deleted file mode 100644 index a8358b32..00000000 --- a/BarkWatch/BarkWatchComplication/AppIntent.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// AppIntent.swift -// BarkWatchComplication -// -// Created by Jian Qin on 9/28/26. -// - -import AppIntents - -struct ArchiveGroup: AppEntity { - static var typeDisplayRepresentation: TypeDisplayRepresentation { "Notification group" } - static var defaultQuery: ArchiveGroupQuery { ArchiveGroupQuery() } - let id: String - var displayRepresentation: DisplayRepresentation { - DisplayRepresentation(title: "\(name)") - } - var name: String { id == "all" ? "All groups" : id == "group:" ? "Ungrouped" : String(id.dropFirst(6)) } - var filter: String? { id == "all" ? nil : String(id.dropFirst(6)) } - static let all = ArchiveGroup(id: "all") -} - -struct ArchiveGroupQuery: EntityQuery { - func entities(for identifiers: [String]) async throws -> [ArchiveGroup] { - identifiers.filter { $0 == "all" || $0.hasPrefix("group:") }.map { ArchiveGroup(id: $0) } - } - func suggestedEntities() async throws -> [ArchiveGroup] { - let groups = Set(try WatchArchive.shared.messages().map(\.group)).sorted() - return [.all] + groups.map { ArchiveGroup(id: "group:" + $0) } - } - func defaultResult() async -> ArchiveGroup? { .all } -} - -struct ConfigurationAppIntent: WidgetConfigurationIntent { - static var title: LocalizedStringResource { "Latest notification" } - static var description: IntentDescription { "Show the newest archived notification from a group." } - - @Parameter(title: "Group") var group: ArchiveGroup? -} diff --git a/BarkWatch/BarkWatchComplication/Assets.xcassets/AccentColor.colorset/Contents.json b/BarkWatch/BarkWatchComplication/Assets.xcassets/AccentColor.colorset/Contents.json deleted file mode 100644 index eb878970..00000000 --- a/BarkWatch/BarkWatchComplication/Assets.xcassets/AccentColor.colorset/Contents.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "colors" : [ - { - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/BarkWatch/BarkWatchComplication/Assets.xcassets/AppIcon.appiconset/Contents.json b/BarkWatch/BarkWatchComplication/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index 49c81cd8..00000000 --- a/BarkWatch/BarkWatchComplication/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "platform" : "watchos", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/BarkWatch/BarkWatchComplication/Assets.xcassets/Contents.json b/BarkWatch/BarkWatchComplication/Assets.xcassets/Contents.json deleted file mode 100644 index 73c00596..00000000 --- a/BarkWatch/BarkWatchComplication/Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/BarkWatch/BarkWatchComplication/Assets.xcassets/WidgetBackground.colorset/Contents.json b/BarkWatch/BarkWatchComplication/Assets.xcassets/WidgetBackground.colorset/Contents.json deleted file mode 100644 index eb878970..00000000 --- a/BarkWatch/BarkWatchComplication/Assets.xcassets/WidgetBackground.colorset/Contents.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "colors" : [ - { - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/BarkWatch/BarkWatchComplication/BarkWatchComplication.swift b/BarkWatch/BarkWatchComplication/BarkWatchComplication.swift deleted file mode 100644 index 81ee05d6..00000000 --- a/BarkWatch/BarkWatchComplication/BarkWatchComplication.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// BarkWatchComplication.swift -// BarkWatchComplication -// -// Created by Jian Qin on 9/30/26. -// - -import WidgetKit -import SwiftUI - - -struct Provider: AppIntentTimelineProvider { - func placeholder(in context: Context) -> ArchiveEntry { .preview } - - func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> ArchiveEntry { - context.isPreview ? .preview : entry(for: configuration) - } - - func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline { - let current = entry(for: configuration) - let refresh = min(current.expires ?? .distantFuture, current.date.addingTimeInterval(15 * 60)) - return Timeline(entries: [current], policy: .after(refresh)) - } - - func recommendations() -> [AppIntentRecommendation] { - [AppIntentRecommendation(intent: ConfigurationAppIntent(), description: "Latest notification")] - } - - private func entry(for configuration: ConfigurationAppIntent) -> ArchiveEntry { - let date = Date() - do { - let message = try WatchArchive.shared.latest(group: configuration.group?.filter, at: date) - return ArchiveEntry(date: date, title: message?.title ?? "Bark", - body: message?.body ?? "No archived notifications", expires: message?.expires, - messageID: message?.id, level: message?.level) - } catch { - return ArchiveEntry(date: date, title: "Bark", body: "Archive unavailable", expires: nil, messageID: nil, - level: nil) - } - } -} - - -struct BarkWatchComplication: Widget { - let kind = WatchArchive.widgetKind - - var body: some WidgetConfiguration { - AppIntentConfiguration(kind: kind, intent: ConfigurationAppIntent.self, provider: Provider()) { entry in - BarkWatchComplicationEntryView(entry: entry) - .widgetURL(WatchArchive.messageURL(id: entry.messageID)) - } - .configurationDisplayName("Latest notification") - .description("The newest archived notification in your selected group.") - .supportedFamilies([.accessoryRectangular]) - } -} - diff --git a/BarkWatch/BarkWatchComplication/BarkWatchComplicationBundle.swift b/BarkWatch/BarkWatchComplication/BarkWatchComplicationBundle.swift deleted file mode 100644 index 08a3ab22..00000000 --- a/BarkWatch/BarkWatchComplication/BarkWatchComplicationBundle.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// BarkWatchComplicationBundle.swift -// BarkWatchComplication -// -// Created by Jian Qin on 9/28/26. -// - -import WidgetKit -import SwiftUI - -@main -struct BarkWatchComplicationBundle: WidgetBundle { - var body: some Widget { - BarkWatchComplication() - } -} diff --git a/BarkWatch/BarkWatchComplication/ComplicationView.swift b/BarkWatch/BarkWatchComplication/ComplicationView.swift deleted file mode 100644 index 88b11656..00000000 --- a/BarkWatch/BarkWatchComplication/ComplicationView.swift +++ /dev/null @@ -1,67 +0,0 @@ -// -// ComplicationView.swift -// BarkWatchComplication -// -// Created by Jian Qin on 9/30/26. -// - -import SwiftUI -import WidgetKit - -struct BarkWatchComplicationEntryView: View { - let entry: ArchiveEntry - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - // When one of title/body is empty, the other gets all 3 lines. - if !entry.title.isEmpty { - Text(verbatim: entry.title) - .lineLimit(entry.body.isEmpty ? 3 : 1, reservesSpace: true) - .font(.body.weight(.semibold)) - } - if !entry.body.isEmpty { - Text(verbatim: entry.body) - .lineLimit(entry.title.isEmpty ? 3 : 2, reservesSpace: true) - .font(.callout) - .foregroundStyle(.secondary) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .privacySensitive() - .containerBackground(entry.backgroundColor.gradient, for: .widget) - } -} - -struct ArchiveEntry: TimelineEntry { - let date: Date - let title: String - let body: String - let expires: Date? - let messageID: String? - let level: WatchNotificationLevel? - - /// Background follows the interruption level, matching the tint of `LastMessageView`. - var backgroundColor: Color { - switch level { - case .critical: .red - case .timeSensitive: .orange - case .active, .passive, nil: .black - } - } - - static let preview = ArchiveEntry( - date: .now, - title: "Build finished", - body: "All checks passed. Ready for testing on your watch.", - expires: nil, - messageID: nil, - level: .critical - ) -} - - -#Preview(as: .accessoryRectangular) { - BarkWatchComplication() -} timeline: { - ArchiveEntry.preview -} diff --git a/BarkWatch/BarkWatchComplication/Info.plist b/BarkWatch/BarkWatchComplication/Info.plist deleted file mode 100644 index 0f118fb7..00000000 --- a/BarkWatch/BarkWatchComplication/Info.plist +++ /dev/null @@ -1,11 +0,0 @@ - - - - - NSExtension - - NSExtensionPointIdentifier - com.apple.widgetkit-extension - - - diff --git a/BarkWatch/BarkWatchComplicationExtension.entitlements b/BarkWatch/BarkWatchComplicationExtension.entitlements deleted file mode 100644 index b60b3ecb..00000000 --- a/BarkWatch/BarkWatchComplicationExtension.entitlements +++ /dev/null @@ -1,10 +0,0 @@ - - - - - com.apple.security.application-groups - - group.com.sebersta.BarkWatchPrototype - - - diff --git a/BarkWatch/Common/WatchArchive.swift b/BarkWatch/Common/WatchArchive.swift index 2bea3a75..f72fb2cf 100644 --- a/BarkWatch/Common/WatchArchive.swift +++ b/BarkWatch/Common/WatchArchive.swift @@ -6,7 +6,6 @@ // import Foundation -import WidgetKit import Darwin import UserNotifications @@ -54,13 +53,11 @@ nonisolated struct WatchArchivedMessage: Codable, Equatable, Sendable { usedCriticalAlertFallback = info[Self.criticalAlertFallbackKey] as? Bool } - // Defined here rather than in WatchNotification because the complication - // target compiles this file without it. static let criticalAlertFallbackKey = "bark_watch_critical_fallback" } -/// The interruption level a message was delivered with, as shown in the app and -/// complication. The raw values are stored in the archive, so don't change them. +/// 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" @@ -78,17 +75,6 @@ nonisolated enum WatchNotificationLevel: String, Codable, Sendable { } nonisolated struct WatchArchive: Sendable { - static let widgetKind = "BarkWatchComplication" - /// Opened by tapping the complication; `id` names the archived message it shows. - static let messageURLHost = "message" - - static func messageURL(id: String?) -> URL { - var components = URLComponents() - components.scheme = "bark-watch" - components.host = messageURLHost - components.queryItems = id.map { [URLQueryItem(name: "id", value: $0)] } - return components.url ?? URL(string: "bark-watch://message")! - } static let shared = WatchArchive(directory: FileManager.default.containerURL( forSecurityApplicationGroupIdentifier: WatchConstants.appGroupIdentifier)) let directory: URL? @@ -140,8 +126,4 @@ nonisolated struct WatchArchive: Sendable { func latest(group: String?, at date: Date = .now) throws -> WatchArchivedMessage? { try messages(at: date).first { group == nil || $0.group == group } } - - func message(id: String, at date: Date = .now) throws -> WatchArchivedMessage? { - try messages(at: date).first { $0.id == id } - } } diff --git a/BarkWatch/Common/WatchNotificationDelivery.swift b/BarkWatch/Common/WatchNotificationDelivery.swift index 3961c668..1d71b82a 100644 --- a/BarkWatch/Common/WatchNotificationDelivery.swift +++ b/BarkWatch/Common/WatchNotificationDelivery.swift @@ -10,7 +10,6 @@ import Intents import os import UniformTypeIdentifiers import UserNotifications -import WidgetKit /// 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. @@ -32,9 +31,6 @@ nonisolated final class WatchNotificationDelivery: @unchecked Sendable { var donate: (INInteraction) async throws -> Void = { interaction in try await interaction.donate() } - var reloadComplications: () -> Void = { - WidgetCenter.shared.reloadTimelines(ofKind: WatchArchive.widgetKind) - } } private let dependencies: Dependencies @@ -126,7 +122,6 @@ nonisolated final class WatchNotificationDelivery: @unchecked Sendable { } do { try dependencies.archive.save(message) - dependencies.reloadComplications() } catch { logger.error("Could not archive notification: \(error.localizedDescription)") } diff --git a/BarkWatch/README.md b/BarkWatch/README.md index 9af24f47..98681dfe 100644 --- a/BarkWatch/README.md +++ b/BarkWatch/README.md @@ -36,7 +36,7 @@ The tests encryptedNotificationsMatchPhoneWireFormat and markdownUsesPhoneParser | `sound` | Custom sound unavailable. `silent` supported. | | `image`, `icon` | Supported | | `url` | Explicit Open Link button. | -| `group` | Thread metadata and complication filtering; no group muting. | +| `group` | Thread metadata; no group muting. | | `id` | Supported | | `isArchive`, `ttl` | Supported | | `badge`, `call` | Unavailable | @@ -45,6 +45,3 @@ The tests encryptedNotificationsMatchPhoneWireFormat and markdownUsesPhoneParser | `delete` | Supported | Opening web links requires an extra handoff on the watch. The notification interface cannot present the browser directly: tapping **Open link** or a Markdown link passes the selected address and launches the app. BarkWatch waits until it is active before presenting the page. BarkWatch uses `WebAuthenticationSession` to display web pages. The app requests an ephemeral session so cookies are not shared with other sessions. Only links using `http` or `https` are accepted; application links and addresses containing embedded login credentials are rejected. - -## Widget -BarkWatch supports a rectangular widget that displays the latest notification. The background color of the complication changes depending on the notification `level`. diff --git a/BarkWatch/Tests/NotificationTests.swift b/BarkWatch/Tests/NotificationTests.swift index 10faa4aa..82a13c95 100644 --- a/BarkWatch/Tests/NotificationTests.swift +++ b/BarkWatch/Tests/NotificationTests.swift @@ -510,7 +510,7 @@ private func barkResponse(for request: URLRequest) async throws -> (Data, URLRes #expect(try WatchCryptoStore.load(account: account) == nil) } -@Test func complicationArchivePersistsFiltersReplacesAndExpires() throws { +@Test func archivePersistsFiltersReplacesAndExpires() throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let archive = WatchArchive(directory: directory) @@ -545,7 +545,7 @@ private func barkResponse(for request: URLRequest) async throws -> (Data, URLRes } @MainActor -@Test func lastMessageIsRestoredFromArchiveAndComplicationTap() throws { +@Test func lastMessageIsRestoredFromArchive() throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let archive = WatchArchive(directory: directory) @@ -572,14 +572,6 @@ private func barkResponse(for request: URLRequest) async throws -> (Data, URLRes #expect(restored.level == .timeSensitive) #expect(restored.url == URL(string: "https://day.app")) #expect(restored.image == nil) - - #expect(state.showArchivedMessage(id: "older", archive: archive)) - #expect(state.lastMessage?.title == "Older") - #expect(state.showArchivedMessage(id: "expired", archive: archive)) - #expect(state.lastMessage?.title == "Newer") - let url = WatchArchive.messageURL(id: "id with spaces&more") - #expect(url.host() == WatchArchive.messageURLHost) - #expect(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?.first?.value == "id with spaces&more") } @MainActor diff --git a/BarkWatch/View/ContentView.swift b/BarkWatch/View/ContentView.swift index f505ff18..7bdee937 100644 --- a/BarkWatch/View/ContentView.swift +++ b/BarkWatch/View/ContentView.swift @@ -56,13 +56,6 @@ struct ContentView: View { selection = state.needsNotificationSetup ? .status : state.servers.servers.first.map { .server($0.id) } ?? .add } - // Tapping the complication shows the message it displays. - .onOpenURL { url in - guard url.host() == WatchArchive.messageURLHost else { return } - let id = URLComponents(url: url, resolvingAgainstBaseURL: false)? - .queryItems?.first { $0.name == "id" }?.value - if state.showArchivedMessage(id: id) { selection = .lastMessage } - } } /// Opens a link tapped in the notification interface. Waits for the app to be From 30fb71ad9ad1768bfa247a0eaf7e98971a6f48a1 Mon Sep 17 00:00:00 2001 From: Fin Date: Mon, 19 Oct 2026 16:27:15 +0800 Subject: [PATCH 6/9] =?UTF-8?q?=E4=BF=AE=E6=94=B9=20Bundle=20Identifier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Bark.xcodeproj/project.pbxproj | 36 +++++++------- .../BarkWatchNotificationService.xcscheme | 2 + BarkWatch/BarkWatch.entitlements | 6 ++- BarkWatch/BarkWatch.xcconfig | 9 ---- BarkWatch/BarkWatchCritical.entitlements | 16 ------- BarkWatch/Common/WatchConstants.swift | 10 ++-- BarkWatch/Info.plist | 2 +- BarkWatch/NotificationService/Info.plist | 2 +- .../NotificationService.entitlements | 4 +- BarkWatch/README.md | 47 ------------------- 10 files changed, 31 insertions(+), 103 deletions(-) delete mode 100644 BarkWatch/BarkWatch.xcconfig delete mode 100644 BarkWatch/BarkWatchCritical.entitlements delete mode 100644 BarkWatch/README.md diff --git a/Bark.xcodeproj/project.pbxproj b/Bark.xcodeproj/project.pbxproj index e350a6ed..3ff62d6a 100644 --- a/Bark.xcodeproj/project.pbxproj +++ b/Bark.xcodeproj/project.pbxproj @@ -340,8 +340,7 @@ 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.json; path = TestNotification.apns; sourceTree = ""; }; - 000000000000000000000034 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; 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 = ""; }; @@ -533,7 +532,6 @@ 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; }; - 3D23D570307AEB510087F74C /* BarkWatch.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = BarkWatch.xcconfig; sourceTree = ""; }; 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 = ""; }; @@ -543,7 +541,7 @@ 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; }; 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.json; path = MarkdownNotification.apns; 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; }; @@ -660,9 +658,7 @@ 000000000000000000000032 /* BarkWatch.entitlements */, 000000000000000000000033 /* TestNotification.apns */, B20000000000000000000055 /* MarkdownNotification.apns */, - 000000000000000000000034 /* README.md */, A10000000000000000000002 /* WatchNotificationService.swift */, - 3D23D570307AEB510087F74C /* BarkWatch.xcconfig */, ); path = BarkWatch; sourceTree = ""; @@ -856,9 +852,9 @@ 0661A541204FDA4100965E4E /* Bark */, 0632CE2120EC9098003FDF46 /* NotificationContentExtension */, 06CF784121C7A50300A052D7 /* NotificationServiceExtension */, - 06EE1FD126843E9300586708 /* BarkTests */, - 06697FDE2F70ED9400C4680E /* Widget */, 000000000000000000000002 /* BarkWatch */, + 06697FDE2F70ED9400C4680E /* Widget */, + 06EE1FD126843E9300586708 /* BarkTests */, 0661A540204FDA4100965E4E /* Products */, 99BD309BDB7F62B5DC0CECE1 /* Frameworks */, E9062CB15F1B70C3719578FB /* Pods */, @@ -1993,7 +1989,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 8HVQYZSB2F; + DEVELOPMENT_TEAM = 5U8LBRXG3A; ENABLE_TESTABILITY = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BarkWatch/Info.plist; @@ -2008,7 +2004,7 @@ ); MARKETING_VERSION = 0.1; ONLY_ACTIVE_ARCH = YES; - PRODUCT_BUNDLE_IDENTIFIER = com.sebersta.BarkWatchPrototype; + PRODUCT_BUNDLE_IDENTIFIER = me.fin.BarkWatch; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; SDKROOT = watchos; @@ -2034,7 +2030,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 8HVQYZSB2F; + DEVELOPMENT_TEAM = 5U8LBRXG3A; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BarkWatch/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = BarkWatch; @@ -2048,7 +2044,7 @@ ); MARKETING_VERSION = 0.1; ONLY_ACTIVE_ARCH = YES; - PRODUCT_BUNDLE_IDENTIFIER = com.sebersta.BarkWatchPrototype; + PRODUCT_BUNDLE_IDENTIFIER = me.fin.BarkWatch; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; SDKROOT = watchos; @@ -2071,7 +2067,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 8HVQYZSB2F; + DEVELOPMENT_TEAM = 5U8LBRXG3A; ENABLE_TESTABILITY = YES; GENERATE_INFOPLIST_FILE = YES; LD_RUNPATH_SEARCH_PATHS = ( @@ -2081,7 +2077,7 @@ ); MARKETING_VERSION = 0.1; ONLY_ACTIVE_ARCH = YES; - PRODUCT_BUNDLE_IDENTIFIER = com.sebersta.BarkWatchPrototypeTests; + PRODUCT_BUNDLE_IDENTIFIER = me.fin.BarkWatchTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; SUPPORTED_PLATFORMS = "watchos watchsimulator"; @@ -2105,7 +2101,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 8HVQYZSB2F; + DEVELOPMENT_TEAM = 5U8LBRXG3A; GENERATE_INFOPLIST_FILE = YES; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -2114,7 +2110,7 @@ ); MARKETING_VERSION = 0.1; ONLY_ACTIVE_ARCH = YES; - PRODUCT_BUNDLE_IDENTIFIER = com.sebersta.BarkWatchPrototypeTests; + PRODUCT_BUNDLE_IDENTIFIER = me.fin.BarkWatchTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; SUPPORTED_PLATFORMS = "watchos watchsimulator"; @@ -2569,7 +2565,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 8HVQYZSB2F; + DEVELOPMENT_TEAM = 5U8LBRXG3A; ENABLE_TESTABILITY = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BarkWatch/NotificationService/Info.plist; @@ -2581,7 +2577,7 @@ ); MARKETING_VERSION = 0.1; ONLY_ACTIVE_ARCH = YES; - PRODUCT_BUNDLE_IDENTIFIER = com.sebersta.BarkWatchPrototype.NotificationService; + PRODUCT_BUNDLE_IDENTIFIER = me.fin.BarkWatch.NotificationService; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; SDKROOT = watchos; @@ -2605,7 +2601,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 8HVQYZSB2F; + DEVELOPMENT_TEAM = 5U8LBRXG3A; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BarkWatch/NotificationService/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Bark Watch Notifications"; @@ -2616,7 +2612,7 @@ ); MARKETING_VERSION = 0.1; ONLY_ACTIVE_ARCH = YES; - PRODUCT_BUNDLE_IDENTIFIER = com.sebersta.BarkWatchPrototype.NotificationService; + PRODUCT_BUNDLE_IDENTIFIER = me.fin.BarkWatch.NotificationService; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; SDKROOT = watchos; diff --git a/Bark.xcodeproj/xcshareddata/xcschemes/BarkWatchNotificationService.xcscheme b/Bark.xcodeproj/xcshareddata/xcschemes/BarkWatchNotificationService.xcscheme index a7a8418c..6786612e 100644 --- a/Bark.xcodeproj/xcshareddata/xcschemes/BarkWatchNotificationService.xcscheme +++ b/Bark.xcodeproj/xcshareddata/xcschemes/BarkWatchNotificationService.xcscheme @@ -18,6 +18,7 @@ BuildableIdentifier = "primary" BlueprintIdentifier = "A10000000000000000000010" BuildableName = "BarkWatchNotificationService.appex" + BlueprintName = "BarkWatchNotificationService" ReferencedContainer = "container:Bark.xcodeproj"> @@ -31,6 +32,7 @@ BuildableIdentifier = "primary" BlueprintIdentifier = "000000000000000000000004" BuildableName = "BarkWatch.app" + BlueprintName = "BarkWatch" ReferencedContainer = "container:Bark.xcodeproj"> diff --git a/BarkWatch/BarkWatch.entitlements b/BarkWatch/BarkWatch.entitlements index e8e12995..2a4cccc6 100644 --- a/BarkWatch/BarkWatch.entitlements +++ b/BarkWatch/BarkWatch.entitlements @@ -8,13 +8,15 @@ com.apple.developer.usernotifications.time-sensitive + com.apple.developer.usernotifications.critical-alerts + com.apple.security.application-groups - group.com.sebersta.BarkWatchPrototype + group.bark keychain-access-groups - $(AppIdentifierPrefix)com.sebersta.BarkWatchPrototype.encryption + $(AppIdentifierPrefix)me.fin.bark.shared diff --git a/BarkWatch/BarkWatch.xcconfig b/BarkWatch/BarkWatch.xcconfig deleted file mode 100644 index 6c75c1eb..00000000 --- a/BarkWatch/BarkWatch.xcconfig +++ /dev/null @@ -1,9 +0,0 @@ -// Signing identity for all BarkWatch targets (app, notification service, -// tests). Change these two values to build under another team. -// -// Every bundle identifier, the app group (group.) and the keychain access -// group (.encryption) are derived from BARK_WATCH_BUNDLE_ID in the -// project, entitlements and Info.plist files, so nothing else needs editing. - -BARK_WATCH_DEVELOPMENT_TEAM = 8HVQYZSB2F -BARK_WATCH_BUNDLE_ID = com.sebersta.BarkWatchPrototype diff --git a/BarkWatch/BarkWatchCritical.entitlements b/BarkWatch/BarkWatchCritical.entitlements deleted file mode 100644 index a736da53..00000000 --- a/BarkWatch/BarkWatchCritical.entitlements +++ /dev/null @@ -1,16 +0,0 @@ - - - - - keychain-access-groups - $(AppIdentifierPrefix)com.sebersta.BarkWatchPrototype.encryption - com.apple.developer.usernotifications.communication - - aps-environment - development - com.apple.developer.usernotifications.time-sensitive - - com.apple.developer.usernotifications.critical-alerts - - - diff --git a/BarkWatch/Common/WatchConstants.swift b/BarkWatch/Common/WatchConstants.swift index 5bc1b55b..56122cd2 100644 --- a/BarkWatch/Common/WatchConstants.swift +++ b/BarkWatch/Common/WatchConstants.swift @@ -9,12 +9,12 @@ import os /// Identifiers shared by the watch app and its extensions. /// -/// These must match the `.entitlements` files and the `BarkKeychainAccessGroup` -/// Info.plist entries; update them together when the prototype bundle -/// identifier changes. +/// 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 = "com.sebersta.BarkWatchPrototype" - static let appGroupIdentifier = "group.\(bundleIdentifier)" + static let bundleIdentifier = "me.fin.BarkWatch" + static let appGroupIdentifier = "group.bark" } nonisolated extension Logger { diff --git a/BarkWatch/Info.plist b/BarkWatch/Info.plist index e67809be..1d486f4a 100644 --- a/BarkWatch/Info.plist +++ b/BarkWatch/Info.plist @@ -7,7 +7,7 @@ remote-notification BarkKeychainAccessGroup - $(AppIdentifierPrefix)com.sebersta.BarkWatchPrototype.encryption + $(AppIdentifierPrefix)me.fin.bark.shared NSUserActivityTypes INSendMessageIntent diff --git a/BarkWatch/NotificationService/Info.plist b/BarkWatch/NotificationService/Info.plist index 2213b951..73e8f39e 100644 --- a/BarkWatch/NotificationService/Info.plist +++ b/BarkWatch/NotificationService/Info.plist @@ -3,7 +3,7 @@ BarkKeychainAccessGroup - $(AppIdentifierPrefix)com.sebersta.BarkWatchPrototype.encryption + $(AppIdentifierPrefix)me.fin.bark.shared NSUserActivityTypes INSendMessageIntent diff --git a/BarkWatch/NotificationService/NotificationService.entitlements b/BarkWatch/NotificationService/NotificationService.entitlements index c9a1ef38..28393494 100644 --- a/BarkWatch/NotificationService/NotificationService.entitlements +++ b/BarkWatch/NotificationService/NotificationService.entitlements @@ -4,11 +4,11 @@ com.apple.security.application-groups - group.com.sebersta.BarkWatchPrototype + group.bark keychain-access-groups - $(AppIdentifierPrefix)com.sebersta.BarkWatchPrototype.encryption + $(AppIdentifierPrefix)me.fin.bark.shared diff --git a/BarkWatch/README.md b/BarkWatch/README.md deleted file mode 100644 index 98681dfe..00000000 --- a/BarkWatch/README.md +++ /dev/null @@ -1,47 +0,0 @@ - -BarkWatch - -A standalone watch application prototype for watchOS 10 or later. Communication with the phone app is not yet implemented. BarkWatch shares the unchanged iOS sources `Model/Algorithm.swift`, `Common/MarkdownParser.swift`, `Common/UIFont+Extension.swift`, and `Common/Error+Extension.swift`. Watch-specific adaptations live in `BarkWatch/Common`. - -**BarkWatch cannot use the official Bark server because its push configuration targets the official phone app.** Use a custom Bark server with your Apple signing credentials, the watch's bundle identifier (`com.sebersta.BarkWatchPrototype` in this checkout). - -## Shared iOS Code -The watch targets compile four iOS files. Change to these files can break the watch build. - -| iOS file | Watch relies on | -| --- | --- | -| `Model/Algorithm.swift` | `Algorithm` (`modes`, `keyLength`), `CryptoSettingFields`, and all four stored properties of `AESCryptoModel` (`key`, `mode`, `padding`, `aes`), which `AESCryptoModel(watchFields:)` assigns | -| `Common/MarkdownParser.swift` | `MarkdownParser.Configuration`; `WatchMarkdownParser` identifies code blocks by monospaced font plus `codeBlockTextColor` plus a background color, and task-list checkboxes as text attachments | -| `Common/UIFont+Extension.swift` | `UIFont.preferredFont(ofSize:weight:)` used by `MarkdownParser` | -| `Common/Error+Extension.swift` | The `String: Error` conformance, which makes `Algorithm.swift`'s plain-string throws (`throw "Invalid Mode"`) compile, and `Error.rawString()`, which formats decryption errors | - -### PhoneCompatibility.swift -`Algorithm.swift` and `MarkdownParser.swift` use code from two other iOS files. The watch App does not compile those files, so `BarkWatch/Common/PhoneCompatibility.swift` contains copies of just the parts that are used: - -| iOS file | Why BarkWatch needs a copy | -| --- | --- | -| `Common/BKColor.swift` | `MarkdownParser` uses four `BKColor` colors as defaults. The iOS app loads them from its asset catalog, which the watch does not have, so the watch defines them as fixed colors manually. | -| `Common/String+Extension.swift` | Both files call `.localized`. The watch has its own `.localized` without the iOS app's string catalog. | - -The tests encryptedNotificationsMatchPhoneWireFormat and markdownUsesPhoneParserForFormattingAndNotificationFallback in BarkWatch/Tests/NotificationTests.swift check that the watch's encryption and Markdown output still match the iOS. - -## Request Parameters Availability -| Parameter | Watch app | -| --- | --- | -| `title`, `subtitle`, `body` | Supported | -| `markdown` | Supported | -| `ciphertext`, `iv` | Same encryption model; configure the watch's own key. | -| `level=active`, `passive`, `timeSensitive` | Supported | -| `level=critical`, `volume` | Implemented, not tested. | -| `sound` | Custom sound unavailable. `silent` supported. | -| `image`, `icon` | Supported | -| `url` | Explicit Open Link button. | -| `group` | Thread metadata; no group muting. | -| `id` | Supported | -| `isArchive`, `ttl` | Supported | -| `badge`, `call` | Unavailable | -| `autocopy`, `automaticallycopy`, `copy` | Unavailable | -| `action` | Unavailable | -| `delete` | Supported | - -Opening web links requires an extra handoff on the watch. The notification interface cannot present the browser directly: tapping **Open link** or a Markdown link passes the selected address and launches the app. BarkWatch waits until it is active before presenting the page. BarkWatch uses `WebAuthenticationSession` to display web pages. The app requests an ephemeral session so cookies are not shared with other sessions. Only links using `http` or `https` are accepted; application links and addresses containing embedded login credentials are rejected. From 2badfa9c84a5265ac9fcbc49ee326d52d849b841 Mon Sep 17 00:00:00 2001 From: Fin Date: Mon, 19 Oct 2026 16:47:00 +0800 Subject: [PATCH 7/9] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20BarkWatch/Notification?= =?UTF-8?q?Service?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Bark.xcodeproj/project.pbxproj | 14 +++++++++++++- .../WatchNotificationService.swift | 0 2 files changed, 13 insertions(+), 1 deletion(-) rename BarkWatch/{ => NotificationService}/WatchNotificationService.swift (100%) diff --git a/Bark.xcodeproj/project.pbxproj b/Bark.xcodeproj/project.pbxproj index 3ff62d6a..772cf2e3 100644 --- a/Bark.xcodeproj/project.pbxproj +++ b/Bark.xcodeproj/project.pbxproj @@ -540,6 +540,8 @@ 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; }; @@ -658,11 +660,21 @@ 000000000000000000000032 /* BarkWatch.entitlements */, 000000000000000000000033 /* TestNotification.apns */, B20000000000000000000055 /* MarkdownNotification.apns */, - A10000000000000000000002 /* WatchNotificationService.swift */, + A10000000000000000000020 /* NotificationService */, ); path = BarkWatch; sourceTree = ""; }; + A10000000000000000000020 /* NotificationService */ = { + isa = PBXGroup; + children = ( + A10000000000000000000002 /* WatchNotificationService.swift */, + A10000000000000000000021 /* Info.plist */, + A10000000000000000000022 /* NotificationService.entitlements */, + ); + path = NotificationService; + sourceTree = ""; + }; 0604F7DB20620D2700B32F09 /* Controller */ = { isa = PBXGroup; children = ( diff --git a/BarkWatch/WatchNotificationService.swift b/BarkWatch/NotificationService/WatchNotificationService.swift similarity index 100% rename from BarkWatch/WatchNotificationService.swift rename to BarkWatch/NotificationService/WatchNotificationService.swift From 64850652e884dec3a8776a33eef534b99e9bddf2 Mon Sep 17 00:00:00 2001 From: Fin Date: Wed, 28 Oct 2026 15:21:16 +0800 Subject: [PATCH 8/9] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=20Match=20=E7=AD=BE?= =?UTF-8?q?=E5=90=8D=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Bark.xcodeproj/project.pbxproj | 52 ++++++++++++++++++++++------------ fastlane/Matchfile | 9 +++++- 2 files changed, 42 insertions(+), 19 deletions(-) diff --git a/Bark.xcodeproj/project.pbxproj b/Bark.xcodeproj/project.pbxproj index 772cf2e3..e2c9b210 100644 --- a/Bark.xcodeproj/project.pbxproj +++ b/Bark.xcodeproj/project.pbxproj @@ -665,16 +665,6 @@ path = BarkWatch; sourceTree = ""; }; - A10000000000000000000020 /* NotificationService */ = { - isa = PBXGroup; - children = ( - A10000000000000000000002 /* WatchNotificationService.swift */, - A10000000000000000000021 /* Info.plist */, - A10000000000000000000022 /* NotificationService.entitlements */, - ); - path = NotificationService; - sourceTree = ""; - }; 0604F7DB20620D2700B32F09 /* Controller */ = { isa = PBXGroup; children = ( @@ -1005,6 +995,16 @@ 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 = ( @@ -1999,9 +1999,11 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "$(BARK_WATCH_ENTITLEMENTS)"; CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + "CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 5U8LBRXG3A; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=watchos*]" = 5U8LBRXG3A; ENABLE_TESTABILITY = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BarkWatch/Info.plist; @@ -2018,6 +2020,8 @@ 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"; @@ -2040,9 +2044,11 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "$(BARK_WATCH_ENTITLEMENTS)"; CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + "CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 5U8LBRXG3A; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=watchos*]" = 5U8LBRXG3A; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BarkWatch/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = BarkWatch; @@ -2058,6 +2064,8 @@ 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"; @@ -2575,9 +2583,11 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = BarkWatch/NotificationService/NotificationService.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + "CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 5U8LBRXG3A; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=watchos*]" = 5U8LBRXG3A; ENABLE_TESTABILITY = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BarkWatch/NotificationService/Info.plist; @@ -2591,6 +2601,8 @@ 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; @@ -2611,9 +2623,11 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = BarkWatch/NotificationService/NotificationService.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + "CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 5U8LBRXG3A; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=watchos*]" = 5U8LBRXG3A; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BarkWatch/NotificationService/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Bark Watch Notifications"; @@ -2626,6 +2640,8 @@ 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; 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` From 0a8f757c075878866052fa1a0426223f69e596fd Mon Sep 17 00:00:00 2001 From: Fin Date: Thu, 5 Nov 2026 17:39:29 +0800 Subject: [PATCH 9/9] =?UTF-8?q?1.=20=E5=B0=86=20BarkWatch=20=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=20Independent=20App=E3=80=82=202.=20=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=20SwiftData=20=E4=BF=9D=E5=AD=98=E6=95=B0=E6=8D=AE=20?= =?UTF-8?q?3.=20=E9=87=8D=E6=9E=84=E9=A1=B5=E9=9D=A2=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 1 + Bark.xcodeproj/project.pbxproj | 203 +- .../BarkWatchNotificationService.xcscheme | 10 +- Bark/AppDelegate.swift | 2 + Bark/Localizable.xcstrings | 2497 +++++++++-------- BarkWatch/App/AppDelegate.swift | 41 + BarkWatch/App/BarkWatchApp.swift | 20 +- BarkWatch/App/PhoneSyncManager.swift | 50 + BarkWatch/App/WatchMessage.swift | 251 -- BarkWatch/BarkWatch.entitlements | 6 - BarkWatch/Common/BarkApi.swift | 83 - BarkWatch/Common/Localized.swift | 11 + BarkWatch/Common/MarkdownSupport.swift | 40 + BarkWatch/Common/MessageStore.swift | 189 ++ BarkWatch/Common/PhoneCompatibility.swift | 47 - BarkWatch/Common/WatchArchive.swift | 129 - BarkWatch/Common/WatchConstants.swift | 25 - BarkWatch/Common/WatchEncryption.swift | 200 -- BarkWatch/Common/WatchGroup.swift | 14 + BarkWatch/Common/WatchMarkdownParser.swift | 69 - BarkWatch/Common/WatchMessage.swift | 39 + BarkWatch/Common/WatchNotification.swift | 128 - .../Common/WatchNotificationDelivery.swift | 247 -- BarkWatch/Info.plist | 10 - BarkWatch/MarkdownNotification.apns | 13 - BarkWatch/Model/AppModel.swift | 57 + BarkWatch/Model/MessageList.swift | 62 + BarkWatch/Model/ServerManager.swift | 213 -- BarkWatch/Model/ServerStore.swift | 161 ++ BarkWatch/NotificationService/Info.plist | 6 - .../NotificationPipeline.swift | 172 ++ .../NotificationService.entitlements | 4 - .../NotificationService.swift | 60 + .../WatchNotificationService.swift | 20 - BarkWatch/TestNotification.apns | 10 - BarkWatch/Tests/BarkWatchTests.swift | 7 + BarkWatch/Tests/NotificationTests.swift | 692 ----- BarkWatch/View/AddServerTabView.swift | 31 - BarkWatch/View/AddServerView.swift | 60 - BarkWatch/View/BigButtonView.swift | 89 - BarkWatch/View/ContentView.swift | 73 - BarkWatch/View/EncryptionView.swift | 198 +- BarkWatch/View/HistoryView.swift | 263 ++ BarkWatch/View/LastMessageView.swift | 168 -- BarkWatch/View/NotificationMarkdownView.swift | 87 - BarkWatch/View/NotificationStatusTab.swift | 135 - BarkWatch/View/NotificationView.swift | 97 + BarkWatch/View/PermissionView.swift | 77 + BarkWatch/View/RootTabView.swift | 67 + BarkWatch/View/ServerListView.swift | 159 ++ BarkWatch/View/ServerTabView.swift | 59 - BarkWatch/View/ServerView.swift | 95 + BarkWatch/View/WatchMarkdownView.swift | 135 - Common/BarkSettings.swift | 4 +- Common/CryptoSettingManager.swift | 2 +- Common/Date+Extension.swift | 23 - Common/Date+Shared.swift | 29 + Common/MarkdownParser.swift | 5 + Common/Moya/BarkApi.swift | 9 +- Common/Moya/BarkTargetType.swift | 24 +- Common/Moya/Observable+Extension.swift | 4 +- Common/ServerManager.swift | 55 +- Common/WatchSyncManager.swift | 57 + Common/WatchSyncProtocol.swift | 6 + Model/MessageDeleteTimeRange.swift | 2 +- Podfile | 5 + Podfile.lock | 2 +- fastlane/Matchfile | 4 +- 68 files changed, 3417 insertions(+), 4396 deletions(-) create mode 100644 BarkWatch/App/AppDelegate.swift create mode 100644 BarkWatch/App/PhoneSyncManager.swift delete mode 100644 BarkWatch/App/WatchMessage.swift delete mode 100644 BarkWatch/Common/BarkApi.swift create mode 100644 BarkWatch/Common/Localized.swift create mode 100644 BarkWatch/Common/MarkdownSupport.swift create mode 100644 BarkWatch/Common/MessageStore.swift delete mode 100644 BarkWatch/Common/PhoneCompatibility.swift delete mode 100644 BarkWatch/Common/WatchArchive.swift delete mode 100644 BarkWatch/Common/WatchConstants.swift delete mode 100644 BarkWatch/Common/WatchEncryption.swift create mode 100644 BarkWatch/Common/WatchGroup.swift delete mode 100644 BarkWatch/Common/WatchMarkdownParser.swift create mode 100644 BarkWatch/Common/WatchMessage.swift delete mode 100644 BarkWatch/Common/WatchNotification.swift delete mode 100644 BarkWatch/Common/WatchNotificationDelivery.swift delete mode 100644 BarkWatch/MarkdownNotification.apns create mode 100644 BarkWatch/Model/AppModel.swift create mode 100644 BarkWatch/Model/MessageList.swift delete mode 100644 BarkWatch/Model/ServerManager.swift create mode 100644 BarkWatch/Model/ServerStore.swift create mode 100644 BarkWatch/NotificationService/NotificationPipeline.swift create mode 100644 BarkWatch/NotificationService/NotificationService.swift delete mode 100644 BarkWatch/NotificationService/WatchNotificationService.swift delete mode 100644 BarkWatch/TestNotification.apns create mode 100644 BarkWatch/Tests/BarkWatchTests.swift delete mode 100644 BarkWatch/Tests/NotificationTests.swift delete mode 100644 BarkWatch/View/AddServerTabView.swift delete mode 100644 BarkWatch/View/AddServerView.swift delete mode 100644 BarkWatch/View/BigButtonView.swift delete mode 100644 BarkWatch/View/ContentView.swift create mode 100644 BarkWatch/View/HistoryView.swift delete mode 100644 BarkWatch/View/LastMessageView.swift delete mode 100644 BarkWatch/View/NotificationMarkdownView.swift delete mode 100644 BarkWatch/View/NotificationStatusTab.swift create mode 100644 BarkWatch/View/NotificationView.swift create mode 100644 BarkWatch/View/PermissionView.swift create mode 100644 BarkWatch/View/RootTabView.swift create mode 100644 BarkWatch/View/ServerListView.swift delete mode 100644 BarkWatch/View/ServerTabView.swift create mode 100644 BarkWatch/View/ServerView.swift delete mode 100644 BarkWatch/View/WatchMarkdownView.swift create mode 100644 Common/Date+Shared.swift create mode 100644 Common/WatchSyncManager.swift create mode 100644 Common/WatchSyncProtocol.swift diff --git a/AGENTS.md b/AGENTS.md index 9ffc2ffa..80b1fb9d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -137,6 +137,7 @@ Order matters - processors run sequentially in `NotificationService.swift`: - Localization is managed via `Localizable.xcstrings` - Custom notification sounds are in `Sounds/` directory - Code signing is disabled for Pods (see Podfile post_install) +- **Do not use simulators for testing.** If simulator testing is absolutely necessary, you must obtain my approval beforehand. ## Testing diff --git a/Bark.xcodeproj/project.pbxproj b/Bark.xcodeproj/project.pbxproj index e2c9b210..1db19e83 100644 --- a/Bark.xcodeproj/project.pbxproj +++ b/Bark.xcodeproj/project.pbxproj @@ -7,7 +7,6 @@ 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 */; }; @@ -100,6 +99,8 @@ 0661A54D204FDA4100965E4E /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 0661A54B204FDA4100965E4E /* LaunchScreen.storyboard */; }; 0667D192247D162C005DE2ED /* MessageTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0667D191247D162C005DE2ED /* MessageTableViewCell.swift */; }; 0667D194247D1BA0005DE2ED /* Date+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0667D193247D1BA0005DE2ED /* Date+Extension.swift */; }; + 06D47E582F8A1C0000BEEF01 /* Date+Shared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06D47E572F8A1C0000BEEF01 /* Date+Shared.swift */; }; + A20000000000000000000022 /* Date+Shared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06D47E572F8A1C0000BEEF01 /* Date+Shared.swift */; }; 066890082D1946D500E106F2 /* MessageItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 066890072D1946D500E106F2 /* MessageItemView.swift */; }; 0668900B2D19525400E106F2 /* ShowLessAndClearView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0668900A2D19525400E106F2 /* ShowLessAndClearView.swift */; }; 0668900D2D19582400E106F2 /* MessageGroupHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0668900C2D19582400E106F2 /* MessageGroupHeaderView.swift */; }; @@ -247,20 +248,34 @@ 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 */; }; + A10000000000000000000006 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000002 /* NotificationService.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 */; }; + A20000000000000000000002 /* WatchSyncProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000001 /* WatchSyncProtocol.swift */; }; + A20000000000000000000003 /* WatchSyncProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000001 /* WatchSyncProtocol.swift */; }; + A20000000000000000000005 /* WatchSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000004 /* WatchSyncManager.swift */; }; + A20000000000000000000011 /* BarkWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 000000000000000000000006 /* BarkWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + A20000000000000000000021 /* MessageDeleteTimeRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 066DF4812D2D31A30092B04E /* MessageDeleteTimeRange.swift */; }; 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 */; }; + F10000000000000000000001 /* ServerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0604F7DE20620D4900B32F09 /* ServerManager.swift */; }; + F10000000000000000000002 /* BarkApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0637FA7B20E0930E00E80174 /* BarkApi.swift */; }; + F10000000000000000000003 /* BarkTargetType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0637FA7720E0926D00E80174 /* BarkTargetType.swift */; }; + F10000000000000000000004 /* Observable+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0637FA7920E092B300E80174 /* Observable+Extension.swift */; }; + F10000000000000000000005 /* BarkSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0637FA7F20E0981E00E80174 /* BarkSettings.swift */; }; + F30000000000000000000001 /* CryptoSettingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06F08EA329B098DD006AB9CA /* CryptoSettingManager.swift */; }; + F30000000000000000000002 /* CryptoSettingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06F08EA329B098DD006AB9CA /* CryptoSettingManager.swift */; }; + F30000000000000000000004 /* NotificationPipeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = F30000000000000000000003 /* NotificationPipeline.swift */; }; + F30000000000000000000005 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 065752C02CB6844A001426D8 /* Localizable.xcstrings */; }; + F40000000000000000000003 /* Markdown in Frameworks */ = {isa = PBXBuildFile; productRef = F40000000000000000000001 /* Markdown */; }; + F40000000000000000000004 /* Markdown in Frameworks */ = {isa = PBXBuildFile; productRef = F40000000000000000000002 /* Markdown */; }; + F40000000000000000000005 /* MarkdownParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06C777CF2ECEFF210032A044 /* MarkdownParser.swift */; }; + F40000000000000000000006 /* MarkdownParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06C777CF2ECEFF210032A044 /* MarkdownParser.swift */; }; + F40000000000000000000007 /* UIFont+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0687F2A72CCB791A00B2A52F /* UIFont+Extension.swift */; }; + F40000000000000000000008 /* UIFont+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0687F2A72CCB791A00B2A52F /* UIFont+Extension.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -306,6 +321,13 @@ remoteGlobalIDString = A10000000000000000000010; remoteInfo = BarkWatchNotificationService; }; + A20000000000000000000013 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 0661A537204FDA4100965E4E /* Project object */; + proxyType = 1; + remoteGlobalIDString = 000000000000000000000004; + remoteInfo = BarkWatch; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -333,14 +355,23 @@ name = "Embed App Extensions"; runOnlyForDeploymentPostprocessing = 0; }; + A20000000000000000000010 /* Embed Watch Content */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; + dstSubfolderSpec = 16; + files = ( + A20000000000000000000011 /* BarkWatch.app in Embed Watch Content */, + ); + name = "Embed Watch Content"; + 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 = ""; }; @@ -429,6 +460,7 @@ 0661A54E204FDA4100965E4E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 0667D191247D162C005DE2ED /* MessageTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageTableViewCell.swift; sourceTree = ""; }; 0667D193247D1BA0005DE2ED /* Date+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extension.swift"; sourceTree = ""; }; + 06D47E572F8A1C0000BEEF01 /* Date+Shared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Shared.swift"; sourceTree = ""; }; 066890072D1946D500E106F2 /* MessageItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageItemView.swift; sourceTree = ""; }; 0668900A2D19525400E106F2 /* ShowLessAndClearView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShowLessAndClearView.swift; sourceTree = ""; }; 0668900C2D19582400E106F2 /* MessageGroupHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageGroupHeaderView.swift; sourceTree = ""; }; @@ -538,17 +570,19 @@ 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 = ""; }; + A10000000000000000000002 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.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 = ""; }; + A20000000000000000000001 /* WatchSyncProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchSyncProtocol.swift; sourceTree = ""; }; + A20000000000000000000004 /* WatchSyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchSyncManager.swift; 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 = ""; }; + F30000000000000000000003 /* NotificationPipeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPipeline.swift; 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 */ @@ -569,6 +603,7 @@ 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 = ""; }; + F50000000000000000000001 /* Tests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Tests; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -576,8 +611,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - B20000000000000000000051 /* Markdown in Frameworks */, CFBE41F3462A6D4F0E1A4B12 /* libPods-BarkWatch.a in Frameworks */, + F40000000000000000000003 /* Markdown in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -640,8 +675,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - B20000000000000000000052 /* Markdown in Frameworks */, E68AEB0E47866777F8755476 /* libPods-BarkWatchNotificationService.a in Frameworks */, + F40000000000000000000004 /* Markdown in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -655,11 +690,9 @@ 3D099DCC306DBCEE00A96591 /* Model */, 3D2E504F306D002800D53E4F /* View */, 3D099DD6306DBCFB00A96591 /* Common */, - D40000000000000000000005 /* Tests */, + F50000000000000000000001 /* Tests */, C30000000000000000000001 /* Assets.xcassets */, 000000000000000000000032 /* BarkWatch.entitlements */, - 000000000000000000000033 /* TestNotification.apns */, - B20000000000000000000055 /* MarkdownNotification.apns */, A10000000000000000000020 /* NotificationService */, ); path = BarkWatch; @@ -810,6 +843,7 @@ 0604F7DE20620D4900B32F09 /* ServerManager.swift */, 0637FA7D20E0969800E80174 /* Client.swift */, 0667D193247D1BA0005DE2ED /* Date+Extension.swift */, + 06D47E572F8A1C0000BEEF01 /* Date+Shared.swift */, 06C5953224811505006B98F3 /* ArchiveSettingManager.swift */, 06BBB8C82567B6730076F63E /* Operators.swift */, 0633E809256A091B00ED0680 /* MJRefresh+Rx.swift */, @@ -817,6 +851,8 @@ 065AE76A2987777F00323230 /* ArchiveSettingRelay.swift */, 06F08EA329B098DD006AB9CA /* CryptoSettingManager.swift */, 0653677729B727A60038BDB8 /* CryptoSettingRelay.swift */, + A20000000000000000000001 /* WatchSyncProtocol.swift */, + A20000000000000000000004 /* WatchSyncManager.swift */, 06F08EA629B1DDFE006AB9CA /* Error+Extension.swift */, 06E944742C07012E00AC86AB /* RealmConfiguration.swift */, 062218512F741C91003E2DA0 /* MessageImageCleaner.swift */, @@ -998,21 +1034,14 @@ A10000000000000000000020 /* NotificationService */ = { isa = PBXGroup; children = ( - A10000000000000000000002 /* WatchNotificationService.swift */, + A10000000000000000000002 /* NotificationService.swift */, + F30000000000000000000003 /* NotificationPipeline.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 = ( @@ -1061,7 +1090,7 @@ ); name = BarkWatch; packageProductDependencies = ( - B20000000000000000000053 /* Markdown */, + F40000000000000000000001 /* Markdown */, ); productName = BarkWatch; productReference = 000000000000000000000006 /* BarkWatch.app */; @@ -1081,6 +1110,9 @@ dependencies = ( 000000000000000000000051 /* PBXTargetDependency */, ); + fileSystemSynchronizedGroups = ( + F50000000000000000000001 /* Tests */, + ); name = BarkWatchTests; productName = BarkWatchTests; productReference = 000000000000000000000007 /* BarkWatchTests.xctest */; @@ -1114,6 +1146,7 @@ 0661A53C204FDA4100965E4E /* Frameworks */, 0661A53D204FDA4100965E4E /* Resources */, 0632CE2E20EC9098003FDF46 /* Embed Foundation Extensions */, + A20000000000000000000010 /* Embed Watch Content */, 330C2B4092D535691BE03E9D /* [CP] Copy Pods Resources */, ); buildRules = ( @@ -1122,6 +1155,7 @@ 062E6DA02F73B6A00080657A /* PBXTargetDependency */, 0632CE2920EC9098003FDF46 /* PBXTargetDependency */, 06CF784621C7A50300A052D7 /* PBXTargetDependency */, + A20000000000000000000012 /* PBXTargetDependency */, ); name = Bark; productName = Bark; @@ -1204,7 +1238,7 @@ ); name = BarkWatchNotificationService; packageProductDependencies = ( - B20000000000000000000054 /* Markdown */, + F40000000000000000000002 /* Markdown */, ); productName = BarkWatchNotificationService; productReference = A10000000000000000000003 /* BarkWatchNotificationService.appex */; @@ -1298,6 +1332,7 @@ buildActionMask = 2147483647; files = ( C30000000000000000000002 /* Assets.xcassets in Resources */, + F30000000000000000000005 /* Localizable.xcstrings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1488,7 +1523,7 @@ ); inputPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Bark/Pods-Bark-resources.sh", - "${PODS_CONFIGURATION_BUILD_DIR}/Alamofire/Alamofire.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/Alamofire-iOS/Alamofire.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/CryptoSwift-iOS/CryptoSwift.bundle", "${PODS_ROOT}/DropDown/DropDown/resources/DropDownCell.xib", "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardCore/IQKeyboardCore.bundle", @@ -1502,16 +1537,16 @@ "${PODS_CONFIGURATION_BUILD_DIR}/Material/com.cosmicmind.material.icons.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/Material/com.cosmicmind.material.fonts.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/MercariQRScanner/QRScannerAssets.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/ObjectMapper/Privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/ObjectMapper-iOS/Privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/Realm/realm_objc_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/RealmSwift/realm_swift_privacy.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/RxCocoa/RxCocoa_Privacy.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/RxRelay/RxRelay_Privacy.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/RxSwift/RxSwift_Privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/RxCocoa-iOS/RxCocoa_Privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/RxRelay-iOS/RxRelay_Privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/RxSwift-iOS/RxSwift_Privacy.bundle", "${PODS_ROOT}/SVProgressHUD/SVProgressHUD/SVProgressHUD.bundle", "${PODS_ROOT}/SVProgressHUD/SVProgressHUD/PrivacyInfo.xcprivacy", "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit/SnapKit_Privacy.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/SwiftyJSON/SwiftyJSON.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/SwiftyJSON-iOS/SwiftyJSON.bundle", ); name = "[CP] Copy Pods Resources"; outputPaths = ( @@ -1578,7 +1613,7 @@ "${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", - "${PODS_CONFIGURATION_BUILD_DIR}/SwiftyJSON/SwiftyJSON.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/SwiftyJSON-iOS/SwiftyJSON.bundle", ); name = "[CP] Copy Pods Resources"; outputPaths = ( @@ -1622,11 +1657,23 @@ ); inputPaths = ( "${PODS_ROOT}/Target Support Files/Pods-BarkWatch/Pods-BarkWatch-resources.sh", + "${PODS_CONFIGURATION_BUILD_DIR}/Alamofire-watchOS/Alamofire.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/CryptoSwift-watchOS/CryptoSwift.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/ObjectMapper-watchOS/Privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/RxCocoa-watchOS/RxCocoa_Privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/RxRelay-watchOS/RxRelay_Privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/RxSwift-watchOS/RxSwift_Privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/SwiftyJSON-watchOS/SwiftyJSON.bundle", ); name = "[CP] Copy Pods Resources"; outputPaths = ( + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Alamofire.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/CryptoSwift.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RxCocoa_Privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RxRelay_Privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RxSwift_Privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SwiftyJSON.bundle", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; @@ -1724,10 +1771,19 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + F10000000000000000000001 /* ServerManager.swift in Sources */, + F10000000000000000000002 /* BarkApi.swift in Sources */, + F10000000000000000000003 /* BarkTargetType.swift in Sources */, + F10000000000000000000004 /* Observable+Extension.swift in Sources */, + F10000000000000000000005 /* BarkSettings.swift in Sources */, E10000000000000000000000 /* Algorithm.swift in Sources */, - E10000000000000000000001 /* MarkdownParser.swift in Sources */, - E10000000000000000000002 /* UIFont+Extension.swift in Sources */, 065632CB3080A88400A0C5A4 /* Error+Extension.swift in Sources */, + F30000000000000000000001 /* CryptoSettingManager.swift in Sources */, + A20000000000000000000003 /* WatchSyncProtocol.swift in Sources */, + F40000000000000000000005 /* MarkdownParser.swift in Sources */, + F40000000000000000000007 /* UIFont+Extension.swift in Sources */, + A20000000000000000000021 /* MessageDeleteTimeRange.swift in Sources */, + A20000000000000000000022 /* Date+Shared.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1735,7 +1791,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 000000000000000000000029 /* NotificationTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1779,6 +1834,8 @@ 06EEF333291CCFF400CA228A /* CryptoSettingController.swift in Sources */, 0603706D20E23EC000F4CA05 /* BarkSFSafariViewController.swift in Sources */, 06F08EA429B098DD006AB9CA /* CryptoSettingManager.swift in Sources */, + A20000000000000000000002 /* WatchSyncProtocol.swift in Sources */, + A20000000000000000000005 /* WatchSyncManager.swift in Sources */, 0668900B2D19525400E106F2 /* ShowLessAndClearView.swift in Sources */, 06BD4DAA2901352E003364DB /* Object+Dictionary.swift in Sources */, 06C5953124811392006B98F3 /* ArchiveSettingCell.swift in Sources */, @@ -1809,6 +1866,7 @@ 0637FA7A20E092B300E80174 /* Observable+Extension.swift in Sources */, 060481F0250F51CA00BC9799 /* SoundCell.swift in Sources */, 0667D194247D1BA0005DE2ED /* Date+Extension.swift in Sources */, + 06D47E582F8A1C0000BEEF01 /* Date+Shared.swift in Sources */, 0604F7DF20620D4900B32F09 /* ServerManager.swift in Sources */, 0667D192247D162C005DE2ED /* MessageTableViewCell.swift in Sources */, 06E944762C07013000AC86AB /* RealmConfiguration.swift in Sources */, @@ -1928,10 +1986,12 @@ buildActionMask = 2147483647; files = ( E10000000000000000000010 /* Algorithm.swift in Sources */, - E10000000000000000000011 /* MarkdownParser.swift in Sources */, - E10000000000000000000012 /* UIFont+Extension.swift in Sources */, - A10000000000000000000006 /* WatchNotificationService.swift in Sources */, + A10000000000000000000006 /* NotificationService.swift in Sources */, 065632CC3080A88400A0C5A4 /* Error+Extension.swift in Sources */, + F30000000000000000000002 /* CryptoSettingManager.swift in Sources */, + F30000000000000000000004 /* NotificationPipeline.swift in Sources */, + F40000000000000000000006 /* MarkdownParser.swift in Sources */, + F40000000000000000000008 /* UIFont+Extension.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1968,6 +2028,11 @@ target = A10000000000000000000010 /* BarkWatchNotificationService */; targetProxy = A10000000000000000000011 /* PBXContainerItemProxy */; }; + A20000000000000000000012 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 000000000000000000000004 /* BarkWatch */; + targetProxy = A20000000000000000000013 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -2007,23 +2072,25 @@ ENABLE_TESTABILITY = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BarkWatch/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = BarkWatch; + INFOPLIST_KEY_CFBundleDisplayName = Bark; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_WKApplication = YES; - INFOPLIST_KEY_WKWatchOnly = YES; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = me.fin.bark; + INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = YES; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 0.1; + MARKETING_VERSION = 1.0.0; ONLY_ACTIVE_ARCH = YES; - PRODUCT_BUNDLE_IDENTIFIER = me.fin.BarkWatch; + PRODUCT_BUNDLE_IDENTIFIER = me.fin.bark.watch; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=watchos*]" = "match Development me.fin.BarkWatch"; + "PROVISIONING_PROFILE_SPECIFIER[sdk=watchos*]" = "match Development me.fin.bark.watch"; REGISTER_APP_GROUPS = YES; SDKROOT = watchos; + SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "watchos watchsimulator"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; SWIFT_APPROACHABLE_CONCURRENCY = YES; @@ -2031,7 +2098,7 @@ SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = 4; - WATCHOS_DEPLOYMENT_TARGET = 10.0; + WATCHOS_DEPLOYMENT_TARGET = 11.0; }; name = Debug; }; @@ -2051,30 +2118,32 @@ "DEVELOPMENT_TEAM[sdk=watchos*]" = 5U8LBRXG3A; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BarkWatch/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = BarkWatch; + INFOPLIST_KEY_CFBundleDisplayName = Bark; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_WKApplication = YES; - INFOPLIST_KEY_WKWatchOnly = YES; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = me.fin.bark; + INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = YES; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 0.1; + MARKETING_VERSION = 1.0.0; ONLY_ACTIVE_ARCH = YES; - PRODUCT_BUNDLE_IDENTIFIER = me.fin.BarkWatch; + PRODUCT_BUNDLE_IDENTIFIER = me.fin.bark.watch; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=watchos*]" = "match AppStore me.fin.BarkWatch"; + "PROVISIONING_PROFILE_SPECIFIER[sdk=watchos*]" = "match AppStore me.fin.bark.watch"; REGISTER_APP_GROUPS = YES; SDKROOT = watchos; + SKIP_INSTALL = YES; 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; + WATCHOS_DEPLOYMENT_TARGET = 11.0; }; name = Release; }; @@ -2095,7 +2164,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 0.1; + MARKETING_VERSION = 1.0.0; ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = me.fin.BarkWatchTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -2108,7 +2177,7 @@ 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; + WATCHOS_DEPLOYMENT_TARGET = 11.0; }; name = Debug; }; @@ -2128,7 +2197,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 0.1; + MARKETING_VERSION = 1.0.0; ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = me.fin.BarkWatchTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -2140,7 +2209,7 @@ 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; + WATCHOS_DEPLOYMENT_TARGET = 11.0; }; name = Release; }; @@ -2597,12 +2666,12 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 0.1; + MARKETING_VERSION = 1.0.0; ONLY_ACTIVE_ARCH = YES; - PRODUCT_BUNDLE_IDENTIFIER = me.fin.BarkWatch.NotificationService; + PRODUCT_BUNDLE_IDENTIFIER = me.fin.bark.watch.NotificationService; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=watchos*]" = "match Development me.fin.BarkWatch.NotificationService"; + "PROVISIONING_PROFILE_SPECIFIER[sdk=watchos*]" = "match Development me.fin.bark.watch.NotificationService"; REGISTER_APP_GROUPS = YES; SDKROOT = watchos; SKIP_INSTALL = YES; @@ -2611,7 +2680,7 @@ SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 4; - WATCHOS_DEPLOYMENT_TARGET = 10.0; + WATCHOS_DEPLOYMENT_TARGET = 11.0; }; name = Debug; }; @@ -2636,12 +2705,12 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 0.1; + MARKETING_VERSION = 1.0.0; ONLY_ACTIVE_ARCH = YES; - PRODUCT_BUNDLE_IDENTIFIER = me.fin.BarkWatch.NotificationService; + PRODUCT_BUNDLE_IDENTIFIER = me.fin.bark.watch.NotificationService; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=watchos*]" = "match AppStore me.fin.BarkWatch.NotificationService"; + "PROVISIONING_PROFILE_SPECIFIER[sdk=watchos*]" = "match AppStore me.fin.bark.watch.NotificationService"; REGISTER_APP_GROUPS = YES; SDKROOT = watchos; SKIP_INSTALL = YES; @@ -2649,7 +2718,7 @@ SWIFT_COMPILATION_MODE = wholemodule; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 4; - WATCHOS_DEPLOYMENT_TARGET = 10.0; + WATCHOS_DEPLOYMENT_TARGET = 11.0; }; name = Release; }; @@ -2761,12 +2830,12 @@ package = 06C777D12ECF05990032A044 /* XCRemoteSwiftPackageReference "swift-markdown" */; productName = Markdown; }; - B20000000000000000000053 /* Markdown */ = { + F40000000000000000000001 /* Markdown */ = { isa = XCSwiftPackageProductDependency; package = 06C777D12ECF05990032A044 /* XCRemoteSwiftPackageReference "swift-markdown" */; productName = Markdown; }; - B20000000000000000000054 /* Markdown */ = { + F40000000000000000000002 /* Markdown */ = { isa = XCSwiftPackageProductDependency; package = 06C777D12ECF05990032A044 /* XCRemoteSwiftPackageReference "swift-markdown" */; productName = Markdown; diff --git a/Bark.xcodeproj/xcshareddata/xcschemes/BarkWatchNotificationService.xcscheme b/Bark.xcodeproj/xcshareddata/xcschemes/BarkWatchNotificationService.xcscheme index 6786612e..d650ad87 100644 --- a/Bark.xcodeproj/xcshareddata/xcschemes/BarkWatchNotificationService.xcscheme +++ b/Bark.xcodeproj/xcshareddata/xcschemes/BarkWatchNotificationService.xcscheme @@ -60,13 +60,21 @@ queueDebuggingEnabled = "No"> + + + + - + Bool { // 必须在应用一开始就配置,否则应用可能提前在配置之前试用了 Realm() ,则会创建两个独立数据库。 setupRealm() + + WatchSyncManager.shared.activate() SVProgressHUD.setMinimumDismissTimeInterval(1.5) diff --git a/Bark/Localizable.xcstrings b/Bark/Localizable.xcstrings index a29eacf7..a02217f3 100644 --- a/Bark/Localizable.xcstrings +++ b/Bark/Localizable.xcstrings @@ -10,6 +10,12 @@ "value" : "Server added successfully." } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Server aggiunto con successo." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -27,12 +33,6 @@ "state" : "translated", "value" : "添加成功!" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Server aggiunto con successo." - } } } }, @@ -45,6 +45,12 @@ "value" : "Add a Server" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aggiungi un server" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -62,12 +68,6 @@ "state" : "translated", "value" : "添加私有服务器" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Aggiungi un server" - } } } }, @@ -80,6 +80,12 @@ "value" : "Algorithm" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Algoritmo" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -97,12 +103,6 @@ "state" : "translated", "value" : "算法" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Algoritmo" - } } } }, @@ -115,6 +115,12 @@ "value" : "All" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tutti" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -132,12 +138,6 @@ "state" : "translated", "value" : "全部" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tutti" - } } } }, @@ -150,6 +150,12 @@ "value" : "All time" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Da sempre" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -167,12 +173,6 @@ "state" : "translated", "value" : "所有时间" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Da sempre" - } } } }, @@ -185,6 +185,12 @@ "value" : "Archive notifications by default if the 'isArchive' parameter is unset." } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Archivia le notifiche di default se il parametro 'isArchive' non è impostato." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -202,12 +208,6 @@ "state" : "translated", "value" : "当推送请求URL没有指定 isArchive 参数时,将按照此设置来决定是否保存通知消息" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Archivia le notifiche di default se il parametro 'isArchive' non è impostato." - } } } }, @@ -220,6 +220,12 @@ "value" : "Badge Count" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conteggio badge" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -237,12 +243,6 @@ "state" : "translated", "value" : "设置角标" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Conteggio badge" - } } } }, @@ -255,6 +255,12 @@ "value" : "If you get a 'Decryption Failed' prompt, try adding '-w 0' after the base64 command." } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Se ricevi l'errore 'Decrittografia fallita', prova ad aggiungere '-w 0' dopo il comando base64." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -272,12 +278,6 @@ "state" : "translated", "value" : "如果提示 Decryption Failed, 尝试在 base64 命令后面 加上 -w 0" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Se ricevi l'errore 'Decrittografia fallita', prova ad aggiungere '-w 0' dopo il comando base64." - } } } }, @@ -290,6 +290,12 @@ "value" : "Before a month" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oltre un mese fa" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -307,12 +313,6 @@ "state" : "translated", "value" : "一个月之前" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Oltre un mese fa" - } } } }, @@ -325,6 +325,12 @@ "value" : "Before an hour" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oltre un'ora fa" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -342,12 +348,6 @@ "state" : "translated", "value" : "一小时之前" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Oltre un'ora fa" - } } } }, @@ -360,6 +360,12 @@ "value" : "Before today" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Prima di oggi" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -377,12 +383,6 @@ "state" : "translated", "value" : "今天之前" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Prima di oggi" - } } } }, @@ -395,6 +395,12 @@ "value" : "Before yesterday" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Prima di ieri" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -412,12 +418,6 @@ "state" : "translated", "value" : "昨天之前" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Prima di ieri" - } } } }, @@ -430,6 +430,12 @@ "value" : "Bark uses GitHub Actions to build and publish itself to the App Store. You can verify the ‘run_id’ in the URL to ensure Bark was built from its original open-source codebase." } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bark usa GitHub Actions per compilarsi e pubblicarsi sull'App Store. Puoi verificare il 'run_id' nell'URL per assicurarti che Bark sia stato compilato dal codice sorgente open-source originale." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -447,12 +453,6 @@ "state" : "translated", "value" : "Bark 由 GitHub Actions 构建后上传到 App Store,此信息可帮助你确认当前版本是由开源代码所构建,未经任何人(包含作者)修改。" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bark usa GitHub Actions per compilarsi e pubblicarsi sull'App Store. Puoi verificare il 'run_id' nell'URL per assicurarti che Bark sia stato compilato dal codice sorgente open-source originale." - } } } }, @@ -465,6 +465,12 @@ "value" : "Cancel" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Annulla" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -482,12 +488,6 @@ "state" : "translated", "value" : "取消" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Annulla" - } } } }, @@ -500,6 +500,12 @@ "value" : "URL encoding the ciphertext, there may be special characters." } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Codifica il testo cifrato in URL encoding, potrebbe contenere caratteri speciali." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -517,12 +523,6 @@ "state" : "translated", "value" : "密文可能有特殊字符,所以记得 URL 编码一下。" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Codifica il testo cifrato in URL encoding, potrebbe contenere caratteri speciali." - } } } }, @@ -535,6 +535,12 @@ "value" : "Clear" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Svuota" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -552,12 +558,6 @@ "state" : "translated", "value" : "清除" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Svuota" - } } } }, @@ -570,6 +570,12 @@ "value" : "Clear from:" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancella da:" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -587,12 +593,6 @@ "state" : "translated", "value" : "清除以下时间段的历史消息:" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cancella da:" - } } } }, @@ -605,6 +605,12 @@ "value" : "Close" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chiudi" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -622,12 +628,6 @@ "state" : "translated", "value" : "关闭" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Chiudi" - } } } }, @@ -640,6 +640,12 @@ "value" : "Confirm" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conferma" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -657,12 +663,6 @@ "state" : "translated", "value" : "确定" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Conferma" - } } } }, @@ -675,6 +675,12 @@ "value" : "Are you sure you want to delete this server?" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sei sicuro di voler eliminare questo server?" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -692,12 +698,6 @@ "state" : "translated", "value" : "确定删除服务器吗?" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sei sicuro di voler eliminare questo server?" - } } } }, @@ -710,6 +710,12 @@ "value" : "The console will print" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "La console stamperà" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -727,12 +733,6 @@ "state" : "translated", "value" : "控制台将打印" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "La console stamperà" - } } } }, @@ -745,28 +745,28 @@ "value" : "Continuous support" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "継続的なサポート" + "value" : "Supporto continuativo" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "sürekli destek" + "value" : "継続的なサポート" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "持续支持" + "value" : "sürekli destek" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Supporto continuativo" + "value" : "持续支持" } } } @@ -780,6 +780,12 @@ "value" : "Copied!" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copiato!" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -797,12 +803,6 @@ "state" : "translated", "value" : "复制成功" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Copiato!" - } } } }, @@ -815,6 +815,12 @@ "value" : "Copy" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copia" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -832,12 +838,6 @@ "state" : "translated", "value" : "复制" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Copia" - } } } }, @@ -850,6 +850,12 @@ "value" : "Copy Address and Key" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copia indirizzo e chiave" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -867,12 +873,6 @@ "state" : "translated", "value" : "复制地址和Key" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Copia indirizzo e chiave" - } } } }, @@ -885,6 +885,12 @@ "value" : "Copy the Content" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copia il contenuto" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -902,12 +908,6 @@ "state" : "translated", "value" : "复制内容" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Copia il contenuto" - } } } }, @@ -920,6 +920,12 @@ "value" : "Copy Send Script Example" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copia lo script di esempio" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -937,12 +943,6 @@ "state" : "translated", "value" : "复制发送脚本示例" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Copia lo script di esempio" - } } } }, @@ -955,6 +955,12 @@ "value" : "Copy Key" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copia chiave" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -972,12 +978,6 @@ "state" : "translated", "value" : "复制Key" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Copia chiave" - } } } }, @@ -990,6 +990,12 @@ "value" : "Critical Alert" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avviso critico" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1007,12 +1013,6 @@ "state" : "translated", "value" : "重要警告" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Avviso critico" - } } } }, @@ -1025,6 +1025,12 @@ "value" : "Critical Alerts Disabled" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avvisi critici disabilitati" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1042,12 +1048,6 @@ "state" : "translated", "value" : "重要警告权限未开启" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Avvisi critici disabilitati" - } } } }, @@ -1060,6 +1060,12 @@ "value" : "Critical alerts will be delivered as normal notifications" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gli avvisi critici verranno consegnati come notifiche normali" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1077,12 +1083,6 @@ "state" : "translated", "value" : "重要警告推送将降级为普通推送" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Gli avvisi critici verranno consegnati come notifiche normali" - } } } }, @@ -1095,6 +1095,12 @@ "value" : "Body Text" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Testo del messaggio" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1112,12 +1118,6 @@ "state" : "translated", "value" : "这里改成你自己的推送内容" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Testo del messaggio" - } } } }, @@ -1130,6 +1130,12 @@ "value" : "Title" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Titolo" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1147,12 +1153,6 @@ "state" : "translated", "value" : "推送标题" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Titolo" - } } } }, @@ -1165,6 +1165,12 @@ "value" : "Custom Sounds" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suoni personalizzati" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1182,12 +1188,6 @@ "state" : "translated", "value" : "自定义铃声" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Suoni personalizzati" - } } } }, @@ -1200,6 +1200,12 @@ "value" : "Default" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Default" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1217,12 +1223,6 @@ "state" : "translated", "value" : "默认" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Default" - } } } }, @@ -1235,6 +1235,12 @@ "value" : "Archive by Default" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Archivia di default" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1252,12 +1258,6 @@ "state" : "translated", "value" : "默认保存" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Archivia di default" - } } } }, @@ -1270,6 +1270,12 @@ "value" : "Default Sounds" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suoni predefiniti" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1287,12 +1293,6 @@ "state" : "translated", "value" : "默认铃声" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Suoni predefiniti" - } } } }, @@ -1305,6 +1305,12 @@ "value" : "Server has been deleted successfully." } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Server eliminato con successo." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1322,12 +1328,6 @@ "state" : "translated", "value" : "删除成功!" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Server eliminato con successo." - } } } }, @@ -1340,6 +1340,12 @@ "value" : "You must have at least one server configured." } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Devi avere almeno un server configurato." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1357,12 +1363,6 @@ "state" : "translated", "value" : "不能删除,服务器至少需保留一个" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Devi avere almeno un server configurato." - } } } }, @@ -1375,6 +1375,12 @@ "value" : "Delete Server" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elimina server" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1392,12 +1398,6 @@ "state" : "translated", "value" : "删除服务器" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Elimina server" - } } } }, @@ -1410,6 +1410,12 @@ "value" : "Deploy a self-hosted Bark server" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installa un server Bark self-hosted" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1427,12 +1433,6 @@ "state" : "translated", "value" : "查看服务端部署教程" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Installa un server Bark self-hosted" - } } } }, @@ -1445,28 +1445,28 @@ "value" : "https://bark.day.app/#/en-us/deploy" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", "value" : "https://bark.day.app/#/en-us/deploy" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", "value" : "https://bark.day.app/#/en-us/deploy" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "https://bark.day.app/#/deploy" + "value" : "https://bark.day.app/#/en-us/deploy" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "https://bark.day.app/#/en-us/deploy" + "value" : "https://bark.day.app/#/deploy" } } } @@ -1480,28 +1480,28 @@ "value" : "Device not registered. Push service is unavailable." } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "デバイスが登録されていないため、プッシュサービスを利用できません" + "value" : "Dispositivo non registrato. Servizio push non disponibile." } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Cihaz kaydedilmedi, gönderim hizmeti kullanılamıyor" + "value" : "デバイスが登録されていないため、プッシュサービスを利用できません" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "设备未注册成功,推送服务暂不可用" + "value" : "Cihaz kaydedilmedi, gönderim hizmeti kullanılamıyor" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Dispositivo non registrato. Servizio push non disponibile." + "value" : "设备未注册成功,推送服务暂不可用" } } } @@ -1515,6 +1515,12 @@ "value" : "Documentation" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Documentazione" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1532,12 +1538,6 @@ "state" : "translated", "value" : "使用文档" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Documentazione" - } } } }, @@ -1550,6 +1550,12 @@ "value" : "Docs & Examples" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Documenti ed esempi" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1567,12 +1573,6 @@ "state" : "translated", "value" : "文档与示例" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Documenti ed esempi" - } } } }, @@ -1585,6 +1585,12 @@ "value" : "View push parameters and FAQs" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vedi i parametri push e le FAQ" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1602,12 +1608,6 @@ "state" : "translated", "value" : "查看推送参数和常见问题" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vedi i parametri push e le FAQ" - } } } }, @@ -1620,28 +1620,28 @@ "value" : "https://bark.day.app/#/en-us/?id=bark" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", "value" : "https://bark.day.app/#/en-us/?id=bark" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", "value" : "https://bark.day.app/#/en-us/?id=bark" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "https://bark.day.app/#/?id=bark" + "value" : "https://bark.day.app/#/en-us/?id=bark" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "https://bark.day.app/#/en-us/?id=bark" + "value" : "https://bark.day.app/#/?id=bark" } } } @@ -1655,6 +1655,12 @@ "value" : "Donate" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dona" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1672,12 +1678,6 @@ "state" : "translated", "value" : "捐赠" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dona" - } } } }, @@ -1690,25 +1690,25 @@ "value" : "🤝" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", "value" : "🤝" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", "value" : "🤝" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", "value" : "🤝" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "🤝" @@ -1725,6 +1725,12 @@ "value" : "Done" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fatto" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1742,12 +1748,6 @@ "state" : "translated", "value" : "完成" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Fatto" - } } } }, @@ -1760,6 +1760,12 @@ "value" : "Encryption Settings" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impostazioni crittografia" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1777,12 +1783,6 @@ "state" : "translated", "value" : "加密设置" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Impostazioni crittografia" - } } } }, @@ -1795,28 +1795,28 @@ "value" : "https://bark.day.app/#/en-us/encryption" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", "value" : "https://bark.day.app/#/en-us/encryption" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", "value" : "https://bark.day.app/#/en-us/encryption" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "https://bark.day.app/#/encryption" + "value" : "https://bark.day.app/#/en-us/encryption" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "https://bark.day.app/#/en-us/encryption" + "value" : "https://bark.day.app/#/encryption" } } } @@ -1830,6 +1830,12 @@ "value" : "Please enter %d-bytes Iv" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inserisci l'IV da %d byte" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1847,12 +1853,6 @@ "state" : "translated", "value" : "请输入%d位Iv" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Inserisci l'IV da %d byte" - } } } }, @@ -1865,6 +1865,12 @@ "value" : "Please enter %d-bytes Key" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inserisci la chiave da %d byte" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1882,2563 +1888,2644 @@ "state" : "translated", "value" : "请输入%d位Key" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Inserisci la chiave da %d byte" - } } } }, - "export" : { + "expired" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Export" + "value" : "Expired" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "エクスポート" + "value" : "Scaduto" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Dışa Aktar" + "value" : "期限切れ" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "导出" + "value" : "Suresi doldu" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Esporta" + "value" : "已过期" } } } }, - "faq" : { + "expiryTimeDay" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "FAQs" + "value" : "Expires in %1$d days" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "よくある質問" + "value" : "Scade tra %1$d giorni" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "SSS" + "value" : "%1$d日後に期限切れ" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "常见问题" + "value" : "%1$d gun icinde suresi dolacak" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "FAQ" + "value" : "%1$d天后过期" } } } }, - "faqUrl" : { + "expiryTimeHour" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "https://bark.day.app/#/en-us/faq" + "value" : "Expires in %1$d hours" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "https://bark.day.app/#/en-us/faq" + "value" : "Scade tra %1$d ore" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "https://bark.day.app/#/en-us/faq" + "value" : "%1$d時間後に期限切れ" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "https://bark.day.app/#/faq" + "value" : "%1$d saat icinde suresi dolacak" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "https://bark.day.app/#/en-us/faq" + "value" : "%1$d小时后过期" } } } }, - "goToSettings" : { + "expiryTimeMinute" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Enable" + "value" : "Expires in %1$d minutes" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "有効化" + "value" : "Scade tra %1$d minuti" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Etkinleştir" + "value" : "%1$d分後に期限切れ" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "去开启" + "value" : "%1$d dakika icinde suresi dolacak" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Abilita" + "value" : "%1$d分钟后过期" } } } }, - "group" : { + "expiryTimeMonth" : { + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Group" + "value" : "Expires in %1$d months" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "グループ" + "value" : "Scade tra %1$d mesi" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "grup" + "value" : "%1$dか月後に期限切れ" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "分组" + "value" : "%1$d ay icinde suresi dolacak" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Gruppo" + "value" : "%1$d月后过期" } } } }, - "groupMuted" : { + "expiryTimeSecond" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Group \"%@\" muted!" + "value" : "Expires in %1$d seconds" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scade tra %1$d secondi" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "グループ「%@」をミュートしました!" + "value" : "%1$d秒後に期限切れ" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "\"%@\" grubunun sesi kapatıldı!" + "value" : "%1$d saniye icinde suresi dolacak" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "分组 \"%@\" 已静音" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Gruppo \"%@\" silenziato!" + "value" : "%1$d秒后过期" } } } }, - "historyMessage" : { + "expiryTimeYear" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Message History" + "value" : "Expires in %1$d years" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "メッセージ履歴" + "value" : "Scade tra %1$d anni" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Mesaj Geçmişi" + "value" : "%1$d年後に期限切れ" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "历史消息" + "value" : "%1$d yil icinde suresi dolacak" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Cronologia messaggi" + "value" : "%1$d年后过期" } } } }, - "image" : { + "export" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Image" + "value" : "Export" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "画像" + "value" : "Esporta" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "resim" + "value" : "エクスポート" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "图片" + "value" : "Dışa Aktar" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Immagine" + "value" : "导出" } } } }, - "import" : { + "faq" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Import" + "value" : "FAQs" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "インポート" + "value" : "FAQ" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "İçe Aktar" + "value" : "よくある質問" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "导入" + "value" : "SSS" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Importa" + "value" : "常见问题" } } } }, - "info" : { + "faqUrl" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Info" + "value" : "https://bark.day.app/#/en-us/faq" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "情報" + "value" : "https://bark.day.app/#/en-us/faq" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Bilgi" + "value" : "https://bark.day.app/#/en-us/faq" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "信息" + "value" : "https://bark.day.app/#/en-us/faq" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Info" + "value" : "https://bark.day.app/#/faq" } } } }, - "InvalidServer" : { + "goToSettings" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Server address is invalid." + "value" : "Enable" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "サーバーアドレスが無効です。" + "value" : "Abilita" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Sunucu adresi geçersiz." + "value" : "有効化" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "填写的服务器无效,请重试!" + "value" : "Etkinleştir" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Indirizzo del server non valido." + "value" : "去开启" } } } }, - "InvalidURL" : { - "extractionState" : "manual", + "group" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "URL is invalid." + "value" : "Group" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "URLが無効です。" + "value" : "Gruppo" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "URL geçersiz." + "value" : "グループ" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "输入的URL好像不对劲!" + "value" : "grup" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "URL non valido." + "value" : "分组" } } } }, - "items" : { + "groupMuted" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "messages" + "value" : "Group \"%@\" muted!" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "メッセージ" + "value" : "Gruppo \"%@\" silenziato!" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "mesajlar" + "value" : "グループ「%@」をミュートしました!" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "条消息" + "value" : "\"%@\" grubunun sesi kapatıldı!" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "messaggi" + "value" : "分组 \"%@\" 已静音" } } } }, - "ivComment" : { + "historyMessage" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Generate a random IV and send it via the iv parameter." + "value" : "Message History" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "IVはランダムに生成し、ivパラメータで送信してください。" + "value" : "Cronologia messaggi" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "IV'yi rastgele oluşturun ve iv parametresiyle birlikte gönderin." + "value" : "メッセージ履歴" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "IV 请随机生成,并放进 iv 参数一起发送。" + "value" : "Mesaj Geçmişi" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Genera un IV casuale e invialo tramite il parametro iv." + "value" : "历史消息" } } } }, - "keyComment" : { + "image" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Must be %d bit long" + "value" : "Image" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "%dビット長でなければなりません" + "value" : "Immagine" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "%d bit uzunluğunda olmalıdır" + "value" : "画像" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "必须%d位" + "value" : "resim" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Deve essere lunga %d bit" + "value" : "图片" } } } }, - "lastHour" : { + "import" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "The last hour" + "value" : "Import" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "過去1時間" + "value" : "Importa" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Son bir saat" + "value" : "インポート" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "最近一小时" + "value" : "İçe Aktar" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Ultima ora" + "value" : "导入" } } } }, - "lastMonth" : { + "info" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "The last month" + "value" : "Info" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "過去1ヶ月" + "value" : "Info" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Son Ay" + "value" : "情報" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "最近一个月" + "value" : "Bilgi" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Ultimo mese" + "value" : "信息" } } } }, - "mode" : { + "InvalidServer" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Mode" + "value" : "Server address is invalid." } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "モード" + "value" : "Indirizzo del server non valido." } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Mod" + "value" : "サーバーアドレスが無効です。" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "模式" + "value" : "Sunucu adresi geçersiz." } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Modalità" + "value" : "填写的服务器无效,请重试!" } } } }, - "more" : { + "InvalidURL" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "More" + "value" : "URL is invalid." } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "詳細" + "value" : "URL non valido." } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Daha" + "value" : "URLが無効です。" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "更多" + "value" : "URL geçersiz." } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Altro" + "value" : "输入的URL好像不对劲!" } } } }, - "MoreActions" : { + "items" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "More Actions" + "value" : "messages" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "その他のアクション" + "value" : "messaggi" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Daha Fazla İşlem" + "value" : "メッセージ" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "更多操作" + "value" : "mesajlar" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Altre azioni" + "value" : "条消息" } } } }, - "muteGroup1Hour" : { + "ivComment" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Mute this group for 1 Hour" + "value" : "Generate a random IV and send it via the iv parameter." } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "このグループを1時間ミュート" + "value" : "Genera un IV casuale e invialo tramite il parametro iv." } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "bu grubu 1 saatliğine sessize alın" + "value" : "IVはランダムに生成し、ivパラメータで送信してください。" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "1小时内不再提醒此分组" + "value" : "IV'yi rastgele oluşturun ve iv parametresiyle birlikte gönderin." } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Silenzia questo gruppo per 1 ora" + "value" : "IV 请随机生成,并放进 iv 参数一起发送。" } } } }, - "noPermission" : { + "keyComment" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "No permission" + "value" : "Must be %d bit long" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "権限がありません" + "value" : "Deve essere lunga %d bit" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "İzin yok" + "value" : "%dビット長でなければなりません" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "没有权限" + "value" : "%d bit uzunluğunda olmalıdır" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Nessun permesso" + "value" : "必须%d位" } } } }, - "notificationIcon" : { + "lastHour" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Icon" + "value" : "The last hour" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "アイコン" + "value" : "Ultima ora" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "İkon" + "value" : "過去1時間" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "自定义推送图标(需iOS15或以上)" + "value" : "Son bir saat" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Icona" + "value" : "最近一小时" } } } }, - "notificationPermissionAlertMessage" : { + "lastMonth" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Enable notification permission before sending a test." + "value" : "The last month" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "テスト送信の前に通知を許可してください。" + "value" : "Ultimo mese" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Test göndermeden önce bildirim iznini etkinleştirin." + "value" : "過去1ヶ月" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "发送测试前请先开启通知权限。" + "value" : "Son Ay" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Abilita il permesso alle notifiche prima di inviare un test." + "value" : "最近一个月" } } } }, - "notificationPermissionOff" : { + "mode" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Notifications Disabled" + "value" : "Mode" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "通知が許可されていません" + "value" : "Modalità" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Bildirim izni kapalı" + "value" : "モード" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "通知权限未开启" + "value" : "Mod" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Notifiche disabilitate" + "value" : "模式" } } } }, - "notificationPermissionOffDetail" : { + "more" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Enable it to receive push notifications" + "value" : "More" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "プッシュ通知を受け取るには有効にしてください" + "value" : "Altro" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Bildirim almak için etkinleştirin" + "value" : "詳細" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "需开启以接收消息推送" + "value" : "Daha" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Abilitale per ricevere notifiche push" + "value" : "更多" } } } }, - "notificationPermissionOn" : { + "MoreActions" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Notifications Enabled" + "value" : "More Actions" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "通知が許可されています" + "value" : "Altre azioni" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Bildirim izni açık" + "value" : "その他のアクション" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "通知权限已开启" + "value" : "Daha Fazla İşlem" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Notifiche abilitate" + "value" : "更多操作" } } } }, - "notificationPermissionOnDetail" : { + "muteGroup1Hour" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "You can now receive push notifications" + "value" : "Mute this group for 1 Hour" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "プッシュ通知を正常に受信できます" + "value" : "Silenzia questo gruppo per 1 ora" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Artık push bildirimlerini alabilirsiniz" + "value" : "このグループを1時間ミュート" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "现在可以正常接收消息推送" + "value" : "bu grubu 1 saatliğine sessize alın" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Ora puoi ricevere notifiche push" + "value" : "1小时内不再提醒此分组" } } } }, - "notificationSound" : { + "noPermission" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Alert Sound" + "value" : "No permission" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "アラートサウンド" + "value" : "Nessun permesso" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Uyarı Sesi" + "value" : "権限がありません" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "推送铃声" + "value" : "İzin yok" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Suono di notifica" + "value" : "没有权限" } } } }, - "oneTimeDonation" : { + "notificationIcon" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "One-time donation" + "value" : "Icon" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "一度きりの寄付" + "value" : "Icona" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "tek seferlik bağış" + "value" : "アイコン" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "一次性捐赠" + "value" : "İkon" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Donazione una tantum" + "value" : "自定义推送图标(需iOS15或以上)" } } } }, - "opensslEncodingComment" : { + "notificationPermissionAlertMessage" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "openssl requires Hex encoding of manual keys and IVs, not ASCII encoding." + "value" : "Enable notification permission before sending a test." } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "opensslでは、手動キーとIVをASCIIエンコードではなくHexエンコードする必要があります。" + "value" : "Abilita il permesso alle notifiche prima di inviare un test." } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "openssl, manuel anahtarların ve IV'lerin ASCII kodlamasını değil, Hex kodlamasını gerektirir." + "value" : "テスト送信の前に通知を許可してください。" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "OpenSSL 要求输入的 Key 和 IV 需使用十六进制编码。" + "value" : "Test göndermeden önce bildirim iznini etkinleştirin." } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "openssl richiede la codifica Hex per chiavi e IV manuali, non la codifica ASCII." + "value" : "发送测试前请先开启通知权限。" } } } }, - "other" : { + "notificationPermissionOff" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Other" + "value" : "Notifications Disabled" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "その他" + "value" : "Notifiche disabilitate" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Diğer" + "value" : "通知が許可されていません" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "其他" + "value" : "Bildirim izni kapalı" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Altro" + "value" : "通知权限未开启" } } } }, - "paramsUrl" : { + "notificationPermissionOffDetail" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "https://bark.day.app/#/en-us/params" + "value" : "Enable it to receive push notifications" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "https://bark.day.app/#/en-us/params" + "value" : "Abilitale per ricevere notifiche push" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "https://bark.day.app/#/en-us/params" + "value" : "プッシュ通知を受け取るには有効にしてください" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "https://bark.day.app/#/params" + "value" : "Bildirim almak için etkinleştirin" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "https://bark.day.app/#/en-us/params" + "value" : "需开启以接收消息推送" } } } }, - "privacyPolicy" : { + "notificationPermissionOn" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Privacy Policy" + "value" : "Notifications Enabled" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "プライバシーポリシー" + "value" : "Notifiche abilitate" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "gizlilik politikası" + "value" : "通知が許可されています" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "隐私政策" + "value" : "Bildirim izni açık" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Privacy" + "value" : "通知权限已开启" } } } }, - "pushParameters" : { + "notificationPermissionOnDetail" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Parameters" + "value" : "You can now receive push notifications" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "パラメータ" + "value" : "Ora puoi ricevere notifiche push" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Parametreler" + "value" : "プッシュ通知を正常に受信できます" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "推送参数" + "value" : "Artık push bildirimlerini alabilirsiniz" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Parametri" + "value" : "现在可以正常接收消息推送" } } } }, - "removeMessage" : { + "notificationSound" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Remove" + "value" : "Alert Sound" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "削除" + "value" : "Suono di notifica" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Sil" + "value" : "アラートサウンド" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "删除" + "value" : "Uyarı Sesi" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Rimuovi" + "value" : "推送铃声" } } } }, - "removeNotice" : { + "oneTimeDonation" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "This will be permanently removed and cannot be undone." + "value" : "One-time donation" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "これは永久に削除され、元に戻せません。" + "value" : "Donazione una tantum" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Bu kalıcı olarak kaldırılacak ve geri alınamaz." + "value" : "一度きりの寄付" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "将永久删除并且无法恢复。" + "value" : "tek seferlik bağış" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Verrà eliminato definitivamente e non potrà essere recuperato." + "value" : "一次性捐赠" } } } }, - "resetFailed" : { + "opensslEncodingComment" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Reset Failed" + "value" : "openssl requires Hex encoding of manual keys and IVs, not ASCII encoding." } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "リセット失敗" + "value" : "openssl richiede la codifica Hex per chiavi e IV manuali, non la codifica ASCII." } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Sıfırlama Başarısız Oldu" + "value" : "opensslでは、手動キーとIVをASCIIエンコードではなくHexエンコードする必要があります。" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "重置失败" + "value" : "openssl, manuel anahtarların ve IV'lerin ASCII kodlamasını değil, Hex kodlamasını gerektirir." } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Ripristino fallito" + "value" : "OpenSSL 要求输入的 Key 和 IV 需使用十六进制编码。" } } } }, - "resetFailed2" : { + "other" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Bark could not get a DeviceToken from the server." + "value" : "Other" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "BarkはサーバーからDeviceTokenを取得できませんでした。" + "value" : "Altro" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Bark sunucudan bir DeviceToken alamadı." + "value" : "その他" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "重置失败,没有获取到 DeviceToken" + "value" : "Diğer" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Bark non è riuscito a ottenere un DeviceToken dal server." + "value" : "其他" } } } }, - "resetKey" : { + "paramsUrl" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Reset or Restore Key" + "value" : "https://bark.day.app/#/en-us/params" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "https://bark.day.app/#/en-us/params" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "キーをリセットまたは復元" + "value" : "https://bark.day.app/#/en-us/params" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "Anahtarı Sıfırla veya Geri Yükle" + "value" : "https://bark.day.app/#/en-us/params" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "重置或还原Key" + "value" : "https://bark.day.app/#/params" + } + } + } + }, + "permissionGuide" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open the Watch app on your iPhone to enable notifications" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Reimposta o ripristina la chiave" + "value" : "请在 iPhone 的 Watch App 中开启通知" } } } }, - "resetKeyDesc" : { + "privacyPolicy" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Leave blank to reset. Enter an old key to restore." + "value" : "Privacy Policy" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "空白のままにしてリセットします。古いキーを入力して復元します。" + "value" : "Privacy" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Sıfırlamak için boş bırakın. Geri yüklemek için eski bir anahtar girin." + "value" : "プライバシーポリシー" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "重置直接点确定,还原请先输入旧的Key" + "value" : "gizlilik politikası" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Lascia vuoto per reimpostare. Inserisci una vecchia chiave per ripristinarla." + "value" : "隐私政策" } } } }, - "resetKeyPlaceholder" : { + "pushParameters" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Do not enter or enter the old Key" + "value" : "Parameters" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "古いキーを入力しないか、空白のままにします" + "value" : "Parametri" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Fazladan girmeyin veya T ve OL anahtarını girmeyin" + "value" : "パラメータ" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "不输入或输入旧的Key" + "value" : "Parametreler" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Non inserire nulla o inserisci la vecchia chiave" + "value" : "推送参数" } } } }, - "restoreSubscription" : { + "removeMessage" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Restore Subscription" + "value" : "Remove" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "サブスクリプションを復元" + "value" : "Rimuovi" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "aboneliği geri yükle" + "value" : "削除" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "恢复订阅" + "value" : "Sil" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Ripristina abbonamento" + "value" : "删除" } } } }, - "ringtone" : { + "removeNotice" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Ringtone" + "value" : "This will be permanently removed and cannot be undone." } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "着信音" + "value" : "Verrà eliminato definitivamente e non potrà essere recuperato." } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "zil sesi" + "value" : "これは永久に削除され、元に戻せません。" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "持续响铃" + "value" : "Bu kalıcı olarak kaldırılacak ve geri alınamaz." } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Suoneria" + "value" : "将永久删除并且无法恢复。" } } } }, - "ringtoneVolume" : { + "resetFailed" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Ringtone Volume for Critical Alerts" + "value" : "Reset Failed" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "重要な警告の着信音量" + "value" : "Ripristino fallito" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Önemli Uyarılar için Zil Sesi Seviyesi" + "value" : "リセット失敗" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "重要警告铃声音量" + "value" : "Sıfırlama Başarısız Oldu" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Volume suoneria per avvisi critici" + "value" : "重置失败" } } } }, - "save" : { + "resetFailed2" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Save" + "value" : "Bark could not get a DeviceToken from the server." } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "保存" + "value" : "Bark non è riuscito a ottenere un DeviceToken dal server." } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Kaydet" + "value" : "BarkはサーバーからDeviceTokenを取得できませんでした。" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "保存" + "value" : "Bark sunucudan bir DeviceToken alamadı." } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Salva" + "value" : "重置失败,没有获取到 DeviceToken" } } } }, - "saveSuccess" : { + "resetKey" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Saved to your photos!" + "value" : "Reset or Restore Key" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "アルバムに保存したよ!" + "value" : "Reimposta o ripristina la chiave" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Albüme kaydedildi!" + "value" : "キーをリセットまたは復元" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "已保存到相册!" + "value" : "Anahtarı Sıfırla veya Geri Yükle" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Salvato nelle tue foto!" + "value" : "重置或还原Key" } } } }, - "sendPushNotification" : { + "resetKeyDesc" : { + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Send notification to this device" + "value" : "Leave blank to reset. Enter an old key to restore." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lascia vuoto per reimpostare. Inserisci una vecchia chiave per ripristinarla." } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "このデバイスにプッシュ通知を送信" + "value" : "空白のままにしてリセットします。古いキーを入力して復元します。" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "Bu cihaza bir bildirim gönder" + "value" : "Sıfırlamak için boş bırakın. Geri yüklemek için eski bir anahtar girin." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "发送推送到此设备" + "value" : "重置直接点确定,还原请先输入旧的Key" + } + } + } + }, + "resetKeyPlaceholder" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Do not enter or enter the old Key" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Invia notifica a questo dispositivo" + "value" : "Non inserire nulla o inserisci la vecchia chiave" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "古いキーを入力しないか、空白のままにします" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fazladan girmeyin veya T ve OL anahtarını girmeyin" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "不输入或输入旧的Key" } } } }, - "sendPushNotificationToOther" : { + "restoreSubscription" : { + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Send notification to other device" + "value" : "Restore Subscription" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ripristina abbonamento" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "他のデバイスにプッシュ通知を送信する" + "value" : "サブスクリプションを復元" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "Diğer cihazlara bildirim gönder" + "value" : "aboneliği geri yükle" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "发送推送到其他设备" + "value" : "恢复订阅" + } + } + } + }, + "ringtone" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ringtone" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Invia notifica a un altro dispositivo" + "value" : "Suoneria" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "着信音" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "zil sesi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "持续响铃" } } } }, - "sendTest" : { + "ringtoneVolume" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Send Test" + "value" : "Ringtone Volume for Critical Alerts" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "テスト送信" + "value" : "Volume suoneria per avvisi critici" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Test Gönder" + "value" : "重要な警告の着信音量" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "发送测试" + "value" : "Önemli Uyarılar için Zil Sesi Seviyesi" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Invia test" + "value" : "重要警告铃声音量" } } } }, - "ServerAddress" : { + "save" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Server Address" + "value" : "Save" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "サーバーアドレス" + "value" : "Salva" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Sunucu Adresi" + "value" : "保存" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "服务器地址" + "value" : "Kaydet" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Indirizzo del server" + "value" : "保存" } } } }, - "ServerError" : { + "saveSuccess" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Server Error" + "value" : "Saved to your photos!" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "サーバーエラー" + "value" : "Salvato nelle tue foto!" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Sunucu Hatası" + "value" : "アルバムに保存したよ!" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "服务器错误" + "value" : "Albüme kaydedildi!" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Errore del server" + "value" : "已保存到相册!" } } } }, - "ServerExample" : { - "extractionState" : "manual", + "sendPushNotification" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Enter the server address, e.g., https://api.day.app" + "value" : "Send notification to this device" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "サーバーアドレスを入力してください。例: https://api.day.app" + "value" : "Invia notifica a questo dispositivo" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Sunucu adresini girin, örneğin, https://api.day.app" + "value" : "このデバイスにプッシュ通知を送信" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "输入服务器地址,例如: https://api.day.app" + "value" : "Bu cihaza bir bildirim gönder" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Inserisci l'indirizzo del server, es. https://api.day.app" + "value" : "发送推送到此设备" } } } }, - "serverList" : { - "extractionState" : "manual", + "sendPushNotificationToOther" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Server List" + "value" : "Send notification to other device" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "サーバーリスト" + "value" : "Invia notifica a un altro dispositivo" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Sunucu Listesi" + "value" : "他のデバイスにプッシュ通知を送信する" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "服务器列表" + "value" : "Diğer cihazlara bildirim gönder" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Lista server" + "value" : "发送推送到其他设备" } } } }, - "service" : { + "sendTest" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Service" + "value" : "Send Test" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "サービス" + "value" : "Invia test" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Hizmet" + "value" : "テスト送信" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "服务器" + "value" : "Test Gönder" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Servizio" + "value" : "发送测试" } } } }, - "setAsDefaultServer" : { + "ServerAddress" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Set As Default Server" + "value" : "Server Address" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "デフォルトサーバーとして設定" + "value" : "Indirizzo del server" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "varsayılan sunucu olarak ayarla" + "value" : "サーバーアドレス" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "设为预览服务器" + "value" : "Sunucu Adresi" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Imposta come server predefinito" + "value" : "服务器地址" } } } }, - "setServerName" : { + "ServerError" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Set Server Name" + "value" : "Server Error" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "サーバー名を設定" + "value" : "Errore del server" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Sunucu Adını Ayarla" + "value" : "サーバーエラー" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "设置服务器名称" + "value" : "Sunucu Hatası" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Imposta nome server" + "value" : "服务器错误" } } } }, - "setSuccessfully" : { + "ServerExample" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Successfully set" + "value" : "Enter the server address, e.g., https://api.day.app" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "正常に設定されました" + "value" : "Inserisci l'indirizzo del server, es. https://api.day.app" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "başarıyla ayarlandı" + "value" : "サーバーアドレスを入力してください。例: https://api.day.app" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "设置成功!" + "value" : "Sunucu adresini girin, örneğin, https://api.day.app" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Impostato con successo" + "value" : "输入服务器地址,例如: https://api.day.app" } } } }, - "settings" : { + "serverList" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Settings" + "value" : "Server List" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "設定" + "value" : "Lista server" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Ayarlar" + "value" : "サーバーリスト" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "设置" + "value" : "Sunucu Listesi" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Impostazioni" + "value" : "服务器列表" } } } }, - "showLess" : { + "service" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Show less" + "value" : "Service" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "詳細を表示しない" + "value" : "Servizio" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Katlamak" + "value" : "サービス" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "折叠" + "value" : "Hizmet" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Mostra meno" + "value" : "服务器" } } } }, - "showQRCode" : { + "setAsDefaultServer" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Show QR Code" + "value" : "Set As Default Server" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "QRコードを表示" + "value" : "Imposta come server predefinito" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "QR Kodunu Göster" + "value" : "デフォルトサーバーとして設定" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "显示二维码" + "value" : "varsayılan sunucu olarak ayarla" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Mostra QR Code" + "value" : "设为预览服务器" } } } }, - "sourceCode" : { + "setServerName" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Source Code" + "value" : "Set Server Name" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "ソースコード" + "value" : "Imposta nome server" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "kaynak kodu" + "value" : "サーバー名を設定" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "开源代码" + "value" : "Sunucu Adını Ayarla" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Codice sorgente" + "value" : "设置服务器名称" } } } }, - "successfulDonation" : { + "setSuccessfully" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Donation" + "value" : "Successfully set" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impostato con successo" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "寄付" + "value" : "正常に設定されました" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "bağış" + "value" : "başarıyla ayarlandı" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "成功捐赠" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Donazione" + "value" : "设置成功!" } } } }, - "testPushFailed" : { + "settings" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Failed to send test notification: %@" + "value" : "Settings" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "テスト通知の送信に失敗しました: %@" + "value" : "Impostazioni" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Test bildirimi gönderilemedi: %@" + "value" : "設定" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "测试推送发送失败: %@" + "value" : "Ayarlar" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Invio notifica di test fallito: %@" + "value" : "设置" } } } }, - "testPushSent" : { + "showLess" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Test notification sent" + "value" : "Show less" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "テスト通知を送信しました" + "value" : "Mostra meno" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Test bildirimi gönderildi" + "value" : "詳細を表示しない" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "测试推送已发送" + "value" : "Katlamak" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Notifica di test inviata" + "value" : "折叠" } } } }, - "thankYouSupport" : { + "showQRCode" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Thank you for your kind support" + "value" : "Show QR Code" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "ご支援ありがとうございます" + "value" : "Mostra QR Code" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "nazik desteğiniz için teşekkür ederim" + "value" : "QRコードを表示" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "谢谢你的慷慨支持" + "value" : "QR Kodunu Göster" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Grazie per il tuo supporto" + "value" : "显示二维码" } } } }, - "expired" : { + "sourceCode" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Expired" + "value" : "Source Code" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "期限切れ" + "value" : "Codice sorgente" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Suresi doldu" + "value" : "ソースコード" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "已过期" + "value" : "kaynak kodu" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Scaduto" + "value" : "开源代码" } } } }, - "expiryTimeDay" : { + "successfulDonation" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Expires in %1$d days" + "value" : "Donation" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "%1$d日後に期限切れ" + "value" : "Donazione" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "%1$d gun icinde suresi dolacak" + "value" : "寄付" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "%1$d天后过期" + "value" : "bağış" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Scade tra %1$d giorni" + "value" : "成功捐赠" } } } }, - "expiryTimeHour" : { + "syncFromIPhone" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Expires in %1$d hours" + "value" : "Sync from iPhone" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "%1$d時間後に期限切れ" + "value" : "Sincronizza da iPhone" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "%1$d saat icinde suresi dolacak" + "value" : "iPhone から同期" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "%1$d小时后过期" + "value" : "iPhone'dan Senkronize Et" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Scade tra %1$d ore" + "value" : "从 iPhone 同步" } } } }, - "expiryTimeMinute" : { + "syncFromIPhoneFailed" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Expires in %1$d minutes" + "value" : "Sync failed. Open Bark on your iPhone and try again." } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "%1$d分後に期限切れ" + "value" : "Sincronizzazione non riuscita. Apri Bark sul tuo iPhone e riprova." } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "%1$d dakika icinde suresi dolacak" + "value" : "同期に失敗しました。iPhone で Bark を開いて再試行してください。" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "%1$d分钟后过期" + "value" : "Senkronizasyon başarısız. iPhone'unuzda Bark'ı açıp tekrar deneyin." } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Scade tra %1$d minuti" + "value" : "同步失败,请打开 iPhone 上的 Bark 后重试。" } } } }, - "expiryTimeMonth" : { + "testPushFailed" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Expires in %1$d months" + "value" : "Failed to send test notification: %@" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "%1$dか月後に期限切れ" + "value" : "Invio notifica di test fallito: %@" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "%1$d ay icinde suresi dolacak" + "value" : "テスト通知の送信に失敗しました: %@" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "%1$d月后过期" + "value" : "Test bildirimi gönderilemedi: %@" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Scade tra %1$d mesi" + "value" : "测试推送发送失败: %@" } } } }, - "expiryTimeSecond" : { + "testPushSent" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Expires in %1$d seconds" + "value" : "Test notification sent" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "%1$d秒後に期限切れ" + "value" : "Notifica di test inviata" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "%1$d saniye icinde suresi dolacak" + "value" : "テスト通知を送信しました" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "%1$d秒后过期" + "value" : "Test bildirimi gönderildi" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Scade tra %1$d secondi" + "value" : "测试推送已发送" } } } }, - "expiryTimeYear" : { + "thankYouSupport" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Expires in %1$d years" + "value" : "Thank you for your kind support" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "%1$d年後に期限切れ" + "value" : "Grazie per il tuo supporto" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "%1$d yil icinde suresi dolacak" + "value" : "ご支援ありがとうございます" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "%1$d年后过期" + "value" : "nazik desteğiniz için teşekkür ederim" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Scade tra %1$d anni" + "value" : "谢谢你的慷慨支持" } } } @@ -4452,6 +4539,12 @@ "value" : "%1$d hour ago" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$d ora fa" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -4469,12 +4562,6 @@ "state" : "translated", "value" : "%1$d小时前" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "%1$d ora fa" - } } } }, @@ -4487,6 +4574,12 @@ "value" : "Just now" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adesso" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -4504,12 +4597,6 @@ "state" : "translated", "value" : "刚刚" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Adesso" - } } } }, @@ -4522,6 +4609,12 @@ "value" : "%1$d minute ago" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$d minuto fa" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -4539,12 +4632,6 @@ "state" : "translated", "value" : "%1$d分钟前" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "%1$d minuto fa" - } } } }, @@ -4557,6 +4644,12 @@ "value" : "%1$d hour %2$d minutes ago" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$d ore %2$d minuti fa" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -4574,12 +4667,6 @@ "state" : "translated", "value" : "%1$d小时%2$d分钟前" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "%1$d ore %2$d minuti fa" - } } } }, @@ -4589,7 +4676,13 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Today" + "value" : "Today" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oggi" } }, "ja" : { @@ -4609,12 +4702,6 @@ "state" : "translated", "value" : "今天" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Oggi" - } } } }, @@ -4627,6 +4714,12 @@ "value" : "Today and yesterday" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oggi e ieri" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -4644,80 +4737,74 @@ "state" : "translated", "value" : "昨天和今天" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Oggi e ieri" - } } } }, - "ttl" : { + "toggle" : { + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "TTL (seconds)" + "value" : "Toggle" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "TTL(秒)" + "value" : "Attiva/disattiva" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "TTL (saniye)" + "value" : "表示切替" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "有效期(秒)" + "value" : "Görünümü Değiştir" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "TTL (secondi)" + "value" : "切换" } } } }, - "toggle" : { - "extractionState" : "manual", + "ttl" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Toggle" + "value" : "TTL (seconds)" } }, - "ja" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "表示切替" + "value" : "TTL (secondi)" } }, - "tr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Görünümü Değiştir" + "value" : "TTL(秒)" } }, - "zh-Hans" : { + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "切换" + "value" : "TTL (saniye)" } }, - "it" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Attiva/disattiva" + "value" : "有效期(秒)" } } } @@ -4731,6 +4818,12 @@ "value" : "Unknown Status" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stato sconosciuto" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -4748,12 +4841,6 @@ "state" : "translated", "value" : "未知" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Stato sconosciuto" - } } } }, @@ -4766,6 +4853,12 @@ "value" : "Upload Sound" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carica suono" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -4783,12 +4876,6 @@ "state" : "translated", "value" : "上传铃声" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Carica suono" - } } } }, @@ -4801,6 +4888,12 @@ "value" : "Please convert the sound file to caf format, and make sure it's no longer than 30 seconds." } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Converti il file audio in formato caf e assicurati che non superi i 30 secondi." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -4818,12 +4911,6 @@ "state" : "translated", "value" : "请先将铃声转换成 caf 格式,时长不超过 30 秒。" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Converti il file audio in formato caf e assicurati che non superi i 30 secondi." - } } } }, @@ -4836,6 +4923,12 @@ "value" : "convert the sound file to caf format" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "converti il file audio in formato caf" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -4853,12 +4946,6 @@ "state" : "translated", "value" : "转换成 caf 格式" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "converti il file audio in formato caf" - } } } }, @@ -4871,6 +4958,12 @@ "value" : "Usage Examples" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Esempi d'uso" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -4888,12 +4981,6 @@ "state" : "translated", "value" : "使用示例" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Esempi d'uso" - } } } }, @@ -4906,6 +4993,12 @@ "value" : "User Agreement" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Termini di servizio" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -4923,12 +5016,6 @@ "state" : "translated", "value" : "用户协议" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Termini di servizio" - } } } }, @@ -4941,6 +5028,12 @@ "value" : "Version" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Versione" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -4958,12 +5051,6 @@ "state" : "translated", "value" : "版本" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Versione" - } } } }, @@ -4976,6 +5063,12 @@ "value" : "View all %d messages" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vedi tutti i %d messaggi" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -4993,12 +5086,6 @@ "state" : "translated", "value" : "查看全部%d条消息" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vedi tutti i %d messaggi" - } } } }, @@ -5010,6 +5097,12 @@ "value" : "Choose which message group this widget should show." } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scegli quale gruppo di messaggi mostrare in questo widget." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -5027,12 +5120,6 @@ "state" : "translated", "value" : "选择此小组件应显示的消息分组" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Scegli quale gruppo di messaggi mostrare in questo widget." - } } } }, @@ -5045,6 +5132,12 @@ "value" : "Recent Messages" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Messaggi recenti" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -5062,12 +5155,6 @@ "state" : "translated", "value" : "最近消息" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Messaggi recenti" - } } } }, @@ -5080,6 +5167,12 @@ "value" : "See the latest items from Bark history at a glance." } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vedi a colpo d'occhio gli ultimi elementi della cronologia di Bark." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -5097,12 +5190,6 @@ "state" : "translated", "value" : "快速查看 Bark 历史中的最新消息。" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vedi a colpo d'occhio gli ultimi elementi della cronologia di Bark." - } } } }, @@ -5115,6 +5202,12 @@ "value" : "New notifications will appear here when they arrive" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Le nuove notifiche appariranno qui quando arriveranno" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -5132,12 +5225,6 @@ "state" : "translated", "value" : "当收到新推送时会显示在这里" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Le nuove notifiche appariranno qui quando arriveranno" - } } } }, @@ -5150,6 +5237,12 @@ "value" : "No New Messages" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nessun nuovo messaggio" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -5167,12 +5260,6 @@ "state" : "translated", "value" : "无新消息" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nessun nuovo messaggio" - } } } }, @@ -5185,6 +5272,12 @@ "value" : "Deployment finished successfully" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Distribuzione completata con successo" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -5202,12 +5295,6 @@ "state" : "translated", "value" : "部署已成功完成" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Distribuzione completata con successo" - } } } }, @@ -5220,6 +5307,12 @@ "value" : "Server" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Server" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -5237,12 +5330,6 @@ "state" : "translated", "value" : "服务器" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Server" - } } } }, @@ -5255,6 +5342,12 @@ "value" : "Build completed" } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Build completata" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -5272,15 +5365,9 @@ "state" : "translated", "value" : "构建完成" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Build completata" - } } } } }, "version" : "1.0" -} +} \ No newline at end of file diff --git a/BarkWatch/App/AppDelegate.swift b/BarkWatch/App/AppDelegate.swift new file mode 100644 index 00000000..244c7029 --- /dev/null +++ b/BarkWatch/App/AppDelegate.swift @@ -0,0 +1,41 @@ +import Foundation +import UserNotifications +import WatchKit + +@MainActor +final class AppDelegate: NSObject, WKApplicationDelegate, UNUserNotificationCenterDelegate { + let model = AppModel() + + func applicationDidFinishLaunching() { + PhoneSyncManager.shared.activate() + + let center = UNUserNotificationCenter.current() + center.delegate = self + center.setNotificationCategories([ + UNNotificationCategory( + identifier: WatchNotificationCategory.markdown, + actions: [], + intentIdentifiers: [], + options: [] + ) + ]) + Task { await model.start() } + } + + func applicationDidBecomeActive() { + Task { await model.refresh() } + } + + func didRegisterForRemoteNotifications(withDeviceToken deviceToken: Data) { + let token = deviceToken.map { String(format: "%02x", $0) }.joined() + model.didRegister(deviceToken: token) + } + + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification + ) async -> UNNotificationPresentationOptions { + await MainActor.run { model.messages.reload() } + return [.banner, .sound, .list] + } +} diff --git a/BarkWatch/App/BarkWatchApp.swift b/BarkWatch/App/BarkWatchApp.swift index e660db52..6f25a67f 100644 --- a/BarkWatch/App/BarkWatchApp.swift +++ b/BarkWatch/App/BarkWatchApp.swift @@ -1,23 +1,19 @@ -// -// 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 + @WKApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate var body: some Scene { WindowGroup { - ContentView(state: delegate.state) + RootTabView() + .environment(appDelegate.model) } - WKNotificationScene(controller: MarkdownNotificationController.self, - category: WatchNotification.markdownCategoryIdentifier) + + WKNotificationScene( + controller: NotificationController.self, + category: WatchNotificationCategory.markdown + ) } } diff --git a/BarkWatch/App/PhoneSyncManager.swift b/BarkWatch/App/PhoneSyncManager.swift new file mode 100644 index 00000000..fdd85df4 --- /dev/null +++ b/BarkWatch/App/PhoneSyncManager.swift @@ -0,0 +1,50 @@ +import Foundation +import WatchConnectivity + +final class PhoneSyncManager: NSObject, WCSessionDelegate, @unchecked Sendable { + static let shared = PhoneSyncManager() + + private enum Key { + static let type = "type" + static let payload = "payload" + } + + enum SyncError: Error { + case unavailable + } + + func activate() { + guard WCSession.isSupported() else { return } + WCSession.default.delegate = self + WCSession.default.activate() + } + + func requestData(for request: WatchSyncRequest) async throws -> Data? { + let session = WCSession.default + guard session.activationState == .activated else { + throw SyncError.unavailable + } + + return try await withCheckedThrowingContinuation { continuation in + session.sendMessage( + [Key.type: request.rawValue], + replyHandler: { reply in + guard reply[Key.type] as? String == request.rawValue else { + continuation.resume(throwing: SyncError.unavailable) + return + } + continuation.resume(returning: reply[Key.payload] as? Data) + }, + errorHandler: { _ in + continuation.resume(throwing: SyncError.unavailable) + } + ) + } + } + + func session( + _ session: WCSession, + activationDidCompleteWith activationState: WCSessionActivationState, + error: Error? + ) {} +} diff --git a/BarkWatch/App/WatchMessage.swift b/BarkWatch/App/WatchMessage.swift deleted file mode 100644 index 0b884aa5..00000000 --- a/BarkWatch/App/WatchMessage.swift +++ /dev/null @@ -1,251 +0,0 @@ -// -// 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/BarkWatch.entitlements b/BarkWatch/BarkWatch.entitlements index 2a4cccc6..b30ae735 100644 --- a/BarkWatch/BarkWatch.entitlements +++ b/BarkWatch/BarkWatch.entitlements @@ -4,8 +4,6 @@ aps-environment development - com.apple.developer.usernotifications.communication - com.apple.developer.usernotifications.time-sensitive com.apple.developer.usernotifications.critical-alerts @@ -14,9 +12,5 @@ group.bark - keychain-access-groups - - $(AppIdentifierPrefix)me.fin.bark.shared - diff --git a/BarkWatch/Common/BarkApi.swift b/BarkWatch/Common/BarkApi.swift deleted file mode 100644 index 77d68f83..00000000 --- a/BarkWatch/Common/BarkApi.swift +++ /dev/null @@ -1,83 +0,0 @@ -// -// 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/Localized.swift b/BarkWatch/Common/Localized.swift new file mode 100644 index 00000000..c2c3628e --- /dev/null +++ b/BarkWatch/Common/Localized.swift @@ -0,0 +1,11 @@ +import Foundation + +extension String { + var localized: String { + NSLocalizedString(self, comment: "") + } + + func localized(with arguments: CVarArg...) -> String { + String(format: NSLocalizedString(self, comment: ""), arguments: arguments) + } +} diff --git a/BarkWatch/Common/MarkdownSupport.swift b/BarkWatch/Common/MarkdownSupport.swift new file mode 100644 index 00000000..e39b39cb --- /dev/null +++ b/BarkWatch/Common/MarkdownSupport.swift @@ -0,0 +1,40 @@ +import UIKit + +enum WatchNotificationCategory { + static let markdown = "myNotificationCategory" +} + +/// MarkdownParser 的默认配色来自 iPhone 的资源目录,watch 包内没有这些颜色资源, +/// 这里给默认参数提供等效的系统颜色。 +enum BKColor { + enum grey { + static let base = UIColor.lightGray + static let darken4 = UIColor.white + static let lighten4 = UIColor.darkGray + } + + enum blue { + static let base = UIColor.cyan + } +} + +enum MarkdownText { + static var configuration: MarkdownParser.Configuration { + MarkdownParser.Configuration( + baseFont: UIFont.preferredFont(ofSize: 15), + baseColor: .white, + linkColor: .cyan, + codeTextColor: .cyan, + codeBackgroundColor: UIColor.white.withAlphaComponent(0.12), + codeBlockTextColor: .white, + quoteColor: .lightGray + ) + } + static func attributed(_ source: String) -> NSAttributedString { + MarkdownParser(configuration: configuration).parse(source) + } + + static func plain(_ source: String) -> String { + attributed(source).string + } +} diff --git a/BarkWatch/Common/MessageStore.swift b/BarkWatch/Common/MessageStore.swift new file mode 100644 index 00000000..5357e7fa --- /dev/null +++ b/BarkWatch/Common/MessageStore.swift @@ -0,0 +1,189 @@ +import Foundation +import SwiftData + +enum MessageFilter: Hashable { + case all + case group(String) +} + +@MainActor +final class MessageStore { + static let shared = MessageStore() + + static let pageSize = 50 + + struct PageCursor { + let createDate: Date + let id: String + } + + private let context: ModelContext? + + private init() { + guard let groupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.bark") else { + context = nil + return + } + let directory = groupURL.appendingPathComponent("BarkWatch", isDirectory: true) + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let configuration = ModelConfiguration(url: directory.appendingPathComponent("Messages.store")) + guard let container = try? ModelContainer( + for: WatchMessage.self, + WatchGroup.self, + configurations: configuration + ) else { + context = nil + return + } + context = ModelContext(container) + } + + func save(_ message: WatchMessage) { + guard let context else { return } + let id = message.id + var descriptor = FetchDescriptor(predicate: #Predicate { $0.id == id }) + descriptor.fetchLimit = 1 + if (try? context.fetch(descriptor).first) != nil { + return + } + context.insert(message) + upsertGroup(message.group, date: message.createDate, in: context) + try? context.save() + } + + func fetchPage( + filter: MessageFilter, + before cursor: PageCursor?, + limit: Int = MessageStore.pageSize + ) -> (items: [WatchMessage], hasMore: Bool) { + guard let context else { return ([], false) } + + var descriptor = FetchDescriptor( + predicate: pagePredicate(filter: filter, before: cursor), + sortBy: [ + SortDescriptor(\.createDate, order: .reverse), + SortDescriptor(\.id, order: .reverse), + ] + ) + descriptor.fetchLimit = limit + 1 + + let fetched = (try? context.fetch(descriptor)) ?? [] + let items = Array(fetched.prefix(limit)) + return (items, fetched.count > limit) + } + + func fetchGroups() -> [WatchGroup] { + guard let context else { return [] } + let descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.latestDate, order: .reverse)] + ) + return (try? context.fetch(descriptor)) ?? [] + } + + @discardableResult + func pruneExpired(now: Date) -> Int { + deleteMessages( + predicate: #Predicate { message in + message.expireDate != nil && message.expireDate! <= now + }, + batchSize: 500 + ) + } + + @discardableResult + func deleteMessages(filter: MessageFilter, startDate: Date, endDate: Date) -> Int { + let predicate: Predicate + switch filter { + case .all: + predicate = #Predicate { message in + message.createDate >= startDate && message.createDate <= endDate + } + case .group(let key): + predicate = #Predicate { message in + message.group == key && + message.createDate >= startDate && message.createDate <= endDate + } + } + return deleteMessages(predicate: predicate) + } + + @discardableResult + func deleteMessages(predicate: Predicate, batchSize: Int? = nil) -> Int { + guard let context else { return 0 } + + var deleted = 0 + while true { + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = batchSize + guard let batch = try? context.fetch(descriptor), !batch.isEmpty else { break } + for message in batch { + context.delete(message) + } + deleted += batch.count + try? context.save() + if batchSize == nil { break } + } + + if deleted > 0 { + reconcileGroups(in: context) + } + return deleted + } + + private func upsertGroup(_ key: String, date: Date, in context: ModelContext) { + var descriptor = FetchDescriptor(predicate: #Predicate { $0.key == key }) + descriptor.fetchLimit = 1 + if let group = (try? context.fetch(descriptor))?.first { + if group.latestDate < date { + group.latestDate = date + } + } else { + context.insert(WatchGroup(key: key, latestDate: date)) + } + } + + private func reconcileGroups(in context: ModelContext) { + let groups = (try? context.fetch(FetchDescriptor())) ?? [] + for group in groups { + let key = group.key + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.group == key } + ) + descriptor.sortBy = [SortDescriptor(\.createDate, order: .reverse)] + descriptor.fetchLimit = 1 + + if let latest = (try? context.fetch(descriptor))?.first { + group.latestDate = latest.createDate + } else { + context.delete(group) + } + } + try? context.save() + } + + private func pagePredicate(filter: MessageFilter, before cursor: PageCursor?) -> Predicate? { + switch (filter, cursor) { + case (.all, nil): + return nil + case let (.all, cursor?): + return cursorPredicate(cursor) + case let (.group(key), nil): + return #Predicate { message in message.group == key } + case let (.group(key), cursor?): + let date = cursor.createDate + let id = cursor.id + return #Predicate { message in + message.group == key && + (message.createDate < date || (message.createDate == date && message.id < id)) + } + } + } + + private func cursorPredicate(_ cursor: PageCursor) -> Predicate { + let date = cursor.createDate + let id = cursor.id + return #Predicate { message in + message.createDate < date || (message.createDate == date && message.id < id) + } + } +} diff --git a/BarkWatch/Common/PhoneCompatibility.swift b/BarkWatch/Common/PhoneCompatibility.swift deleted file mode 100644 index 34317090..00000000 --- a/BarkWatch/Common/PhoneCompatibility.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// 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 deleted file mode 100644 index f72fb2cf..00000000 --- a/BarkWatch/Common/WatchArchive.swift +++ /dev/null @@ -1,129 +0,0 @@ -// -// 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 deleted file mode 100644 index 56122cd2..00000000 --- a/BarkWatch/Common/WatchConstants.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// 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 deleted file mode 100644 index 1c08f0b3..00000000 --- a/BarkWatch/Common/WatchEncryption.swift +++ /dev/null @@ -1,200 +0,0 @@ -// -// 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/WatchGroup.swift b/BarkWatch/Common/WatchGroup.swift new file mode 100644 index 00000000..d7dd3163 --- /dev/null +++ b/BarkWatch/Common/WatchGroup.swift @@ -0,0 +1,14 @@ +import Foundation +import SwiftData + +@Model +final class WatchGroup { + @Attribute(.unique) var key: String + + var latestDate: Date + + init(key: String, latestDate: Date) { + self.key = key + self.latestDate = latestDate + } +} diff --git a/BarkWatch/Common/WatchMarkdownParser.swift b/BarkWatch/Common/WatchMarkdownParser.swift deleted file mode 100644 index 2f871d6a..00000000 --- a/BarkWatch/Common/WatchMarkdownParser.swift +++ /dev/null @@ -1,69 +0,0 @@ -// -// 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/WatchMessage.swift b/BarkWatch/Common/WatchMessage.swift new file mode 100644 index 00000000..02f52fc3 --- /dev/null +++ b/BarkWatch/Common/WatchMessage.swift @@ -0,0 +1,39 @@ +import Foundation +import SwiftData + +@Model +final class WatchMessage { + #Index([\.id], [\.createDate], [\.group, \.createDate]) + + var id: String + var title: String? + var subtitle: String? + var body: String? + var url: String? + var image: String? + var group: String + var createDate: Date + var expireDate: Date? + + init( + id: String, + title: String?, + subtitle: String?, + body: String?, + url: String?, + image: String?, + group: String, + createDate: Date, + expireDate: Date? + ) { + self.id = id + self.title = title + self.subtitle = subtitle + self.body = body + self.url = url + self.image = image + self.group = group + self.createDate = createDate + self.expireDate = expireDate + } +} diff --git a/BarkWatch/Common/WatchNotification.swift b/BarkWatch/Common/WatchNotification.swift deleted file mode 100644 index 4b73bc96..00000000 --- a/BarkWatch/Common/WatchNotification.swift +++ /dev/null @@ -1,128 +0,0 @@ -// -// 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 deleted file mode 100644 index 1d71b82a..00000000 --- a/BarkWatch/Common/WatchNotificationDelivery.swift +++ /dev/null @@ -1,247 +0,0 @@ -// -// 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 index 1d486f4a..cf5e9dd6 100644 --- a/BarkWatch/Info.plist +++ b/BarkWatch/Info.plist @@ -2,16 +2,6 @@ - UIBackgroundModes - - remote-notification - - BarkKeychainAccessGroup - $(AppIdentifierPrefix)me.fin.bark.shared - NSUserActivityTypes - - INSendMessageIntent - NSAppTransportSecurity NSAllowsArbitraryLoads diff --git a/BarkWatch/MarkdownNotification.apns b/BarkWatch/MarkdownNotification.apns deleted file mode 100644 index eb8a4f61..00000000 --- a/BarkWatch/MarkdownNotification.apns +++ /dev/null @@ -1,13 +0,0 @@ -{ - "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/AppModel.swift b/BarkWatch/Model/AppModel.swift new file mode 100644 index 00000000..c1801bfc --- /dev/null +++ b/BarkWatch/Model/AppModel.swift @@ -0,0 +1,57 @@ +import Foundation +import Observation +import UserNotifications +import WatchKit + +@MainActor +@Observable +final class AppModel { + var authorizationStatus: UNAuthorizationStatus = .notDetermined + var criticalAlertSetting: UNNotificationSetting = .notSupported + var permissionChecked = false + var deviceToken: String? + + let servers = ServerStore() + let messages = MessageList() + + var notificationsAllowed: Bool { + authorizationStatus == .authorized || authorizationStatus == .provisional + } + + var needsPermission: Bool { + !notificationsAllowed || criticalAlertSetting == .disabled + } + + func start() async { + await refresh() + } + + func refresh() async { + let (status, critical) = await Self.loadPermission() + authorizationStatus = status + criticalAlertSetting = critical + permissionChecked = true + + if notificationsAllowed, deviceToken == nil { + WKApplication.shared().registerForRemoteNotifications() + } + + servers.reload() + messages.reload() + } + + func requestAuthorization() async { + _ = try? await UNUserNotificationCenter.current() + .requestAuthorization(options: [.alert, .sound, .criticalAlert]) + } + + func didRegister(deviceToken: String) { + self.deviceToken = deviceToken + servers.sync(deviceToken: deviceToken) + } + + nonisolated private static func loadPermission() async -> (UNAuthorizationStatus, UNNotificationSetting) { + let settings = await UNUserNotificationCenter.current().notificationSettings() + return (settings.authorizationStatus, settings.criticalAlertSetting) + } +} diff --git a/BarkWatch/Model/MessageList.swift b/BarkWatch/Model/MessageList.swift new file mode 100644 index 00000000..7e6dc608 --- /dev/null +++ b/BarkWatch/Model/MessageList.swift @@ -0,0 +1,62 @@ +import Foundation +import Observation + +@MainActor +@Observable +final class MessageList { + private(set) var items: [WatchMessage] = [] + private(set) var groupFilters: [MessageFilter] = [.all] + private(set) var hasMore = false + private(set) var filter: MessageFilter = .all + + private var cursor: MessageStore.PageCursor? + private var isLoadingMore = false + + func reload() { + let store = MessageStore.shared + store.pruneExpired(now: Date()) + + groupFilters = [.all] + store.fetchGroups().map { .group($0.key) } + + if !groupFilters.contains(filter) { + filter = .all + } + + loadFirstPage(from: store) + } + + func select(_ newFilter: MessageFilter) { + guard newFilter != filter else { return } + filter = newFilter + loadFirstPage(from: .shared) + } + + @discardableResult + func clear(_ range: MessageDeleteTimeRange) -> Int { + let deleted = MessageStore.shared.deleteMessages( + filter: filter, + startDate: range.startDate, + endDate: range.endDate + ) + reload() + return deleted + } + + func loadMore() { + guard hasMore, !isLoadingMore, let cursor else { return } + isLoadingMore = true + defer { isLoadingMore = false } + + let page = MessageStore.shared.fetchPage(filter: filter, before: cursor) + items.append(contentsOf: page.items) + self.cursor = page.items.last.map { MessageStore.PageCursor(createDate: $0.createDate, id: $0.id) } + hasMore = page.hasMore + } + + private func loadFirstPage(from store: MessageStore) { + let page = store.fetchPage(filter: filter, before: nil) + items = page.items + cursor = page.items.last.map { MessageStore.PageCursor(createDate: $0.createDate, id: $0.id) } + hasMore = page.hasMore + } +} diff --git a/BarkWatch/Model/ServerManager.swift b/BarkWatch/Model/ServerManager.swift deleted file mode 100644 index 7070eab9..00000000 --- a/BarkWatch/Model/ServerManager.swift +++ /dev/null @@ -1,213 +0,0 @@ -// -// 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/Model/ServerStore.swift b/BarkWatch/Model/ServerStore.swift new file mode 100644 index 00000000..55a0d60c --- /dev/null +++ b/BarkWatch/Model/ServerStore.swift @@ -0,0 +1,161 @@ +import Foundation +import Observation +import RxSwift + +@MainActor +@Observable +final class ServerStore { + enum TestResult { + case sent + case failed(String) + } + + private let manager = ServerManager.shared + private let disposeBag = DisposeBag() + + private(set) var servers: [Server] = [] + private(set) var currentServer: Server? + private(set) var isSending = false + private(set) var testResult: TestResult? + private(set) var isSyncingServers = false + var syncServersFailed = false + + init() { + reload() + manager.currentServerUpdateRelay + .subscribe(onNext: { [weak self] _ in + Task { @MainActor in self?.reload() } + }) + .disposed(by: disposeBag) + } + + func reload() { + servers = manager.servers + currentServer = manager.currentServer + } + + func sync(deviceToken: String) { + manager.syncAllServers(deviceToken: deviceToken) + } + + func syncServers(deviceToken: String?) async { + guard !isSyncingServers else { return } + isSyncingServers = true + defer { isSyncingServers = false } + + do { + guard let data = try await PhoneSyncManager.shared.requestData(for: .serverList), + let incoming = try? JSONDecoder().decode([String].self, from: data) + else { + syncServersFailed = true + return + } + importServers(incoming, deviceToken: deviceToken) + } catch { + syncServersFailed = true + } + } + + func importServers(_ incoming: [String], deviceToken: String?) { + guard !incoming.isEmpty else { return } + + let existing = manager.servers + var knownAddresses = Set(existing.map(\.cleanAddress)) + for address in incoming where !knownAddresses.contains(address) { + knownAddresses.insert(address) + manager.addServer(server: Server(address: address, key: "")) + } + + reload() + + if let deviceToken { + manager.syncAllServers(deviceToken: deviceToken) + } + } + + func setCurrent(_ server: Server) { + manager.setCurrentServer(serverId: server.id) + reload() + } + + @discardableResult + func addServer(address: String, deviceToken: String?) -> Bool { + let trimmed = address.trimmingCharacters(in: .whitespacesAndNewlines) + guard let url = URL(string: trimmed), + let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https", + let host = url.host, + !host.isEmpty + else { + return false + } + + let cleanAddress = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + + let server = Server(address: cleanAddress, key: "") + manager.addServer(server: server) + manager.setCurrentServer(serverId: server.id) + reload() + + if let deviceToken { + manager.syncAllServers(deviceToken: deviceToken) + } + return true + } + + func remove(_ server: Server, deviceToken: String?) { + guard manager.servers.count > 1 else { return } + manager.removeServer(server: server) + reload() + + if let deviceToken { + manager.syncAllServers(deviceToken: deviceToken) + } + } + + func resetTestResult() { + testResult = nil + } + + func sendTest(server: Server) async { + guard !server.key.isEmpty else { + testResult = .failed("deviceNotRegistered".localized) + return + } + guard let baseURL = URL(string: server.cleanAddress) else { + testResult = .failed("InvalidServer".localized) + return + } + + var request = URLRequest(url: baseURL.appendingPathComponent("push")) + request.httpMethod = "POST" + request.timeoutInterval = 15 + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try? JSONSerialization.data(withJSONObject: [ + "device_key": server.key, + "title": "title", + "body": "body", + "group": "example" + ]) + + isSending = true + testResult = nil + defer { isSending = false } + + do { + let (data, response) = try await URLSession.shared.data(for: request) + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 + let json = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + let code = json?["code"] as? Int + + if (200..<300).contains(statusCode), code == 200 { + testResult = .sent + } else { + let message = json?["message"] as? String ?? "HTTP \(statusCode)" + testResult = .failed("testPushFailed".localized(with: message)) + } + } catch { + testResult = .failed("testPushFailed".localized(with: error.localizedDescription)) + } + } +} diff --git a/BarkWatch/NotificationService/Info.plist b/BarkWatch/NotificationService/Info.plist index 73e8f39e..671648dd 100644 --- a/BarkWatch/NotificationService/Info.plist +++ b/BarkWatch/NotificationService/Info.plist @@ -2,12 +2,6 @@ - BarkKeychainAccessGroup - $(AppIdentifierPrefix)me.fin.bark.shared - NSUserActivityTypes - - INSendMessageIntent - NSExtension NSExtensionPointIdentifier diff --git a/BarkWatch/NotificationService/NotificationPipeline.swift b/BarkWatch/NotificationService/NotificationPipeline.swift new file mode 100644 index 00000000..e417a99a --- /dev/null +++ b/BarkWatch/NotificationService/NotificationPipeline.swift @@ -0,0 +1,172 @@ +import Foundation +import UniformTypeIdentifiers +import UserNotifications + +enum NotificationPipeline { + /// 解密、markdown、分组、级别、归档。图片下载单独执行,避免拖慢归档。 + static func prepare( + _ content: UNMutableNotificationContent, + identifier: String + ) async -> UNMutableNotificationContent { + var userInfo = content.userInfo + + if let ciphertext = userInfo["ciphertext"] as? String { + do { + userInfo = try decrypt(ciphertext, iv: stringValue(userInfo["iv"])) + content.userInfo = userInfo + content.title = stringValue(userInfo["title"]) ?? "" + content.subtitle = stringValue(userInfo["subtitle"]) ?? "" + content.body = stringValue(userInfo["body"]) ?? "" + } catch { + content.title = "Decryption Failed" + content.subtitle = "" + content.body = error.rawString() + return content + } + } + + if let markdown = stringValue(userInfo["markdown"]), !markdown.isEmpty { + content.body = MarkdownText.plain(markdown) + content.categoryIdentifier = WatchNotificationCategory.markdown + } + + if let group = stringValue(userInfo["group"]), !group.isEmpty { + content.threadIdentifier = group + } + + await applyLevel(to: content) + await archive(content, identifier: identifier) + return content + } + + static func attachImage(to content: UNMutableNotificationContent) async -> UNMutableNotificationContent { + guard let image = stringValue(content.userInfo["image"]), + let url = URL(string: image), + let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https" + else { + return content + } + + var request = URLRequest(url: url) + request.timeoutInterval = 5 + + guard let (data, response) = try? await URLSession.shared.data(for: request), + let http = response as? HTTPURLResponse, + (200..<300).contains(http.statusCode), + !data.isEmpty, + data.count <= 5 * 1024 * 1024 + else { + return content + } + + let fileExtension = http.mimeType.flatMap { UTType(mimeType: $0)?.preferredFilenameExtension } + ?? (url.pathExtension.isEmpty ? "png" : url.pathExtension) + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension(fileExtension) + + guard (try? data.write(to: fileURL)) != nil, + let attachment = try? UNNotificationAttachment(identifier: "image", url: fileURL, options: nil) + else { + return content + } + + content.attachments = [attachment] + return content + } +} + +private extension NotificationPipeline { + static func applyLevel(to content: UNMutableNotificationContent) async { + guard let level = stringValue(content.userInfo["level"]) else { return } + + if level == "critical", await criticalAlertsEnabled() { + var volume: Float = 0.5 + if let value = stringValue(content.userInfo["volume"]), let parsed = Float(value) { + volume = min(max(parsed / 10, 0), 1) + } + content.interruptionLevel = .critical + content.sound = .defaultCriticalSound(withAudioVolume: volume) + return + } + + switch level { + case "passive": + content.interruptionLevel = .passive + case "timeSensitive", "timesensitive", "critical": + content.interruptionLevel = .timeSensitive + default: + content.interruptionLevel = .active + } + } + + static func criticalAlertsEnabled() async -> Bool { + let settings = await UNUserNotificationCenter.current().notificationSettings() + return settings.criticalAlertSetting == .enabled + } + + static func archive(_ content: UNMutableNotificationContent, identifier: String) async { + let userInfo = content.userInfo + + if stringValue(userInfo["isarchive"]) == "0" { + return + } + + var expireDate: Date? + if let ttl = stringValue(userInfo["ttl"]), let seconds = Int(ttl) { + guard seconds > 0 else { return } + expireDate = Date().addingTimeInterval(TimeInterval(seconds)) + } + + let id = stringValue(userInfo["id"]) ?? identifier + let group = stringValue(userInfo["group"]).flatMap { $0.isEmpty ? nil : $0 } + ?? (content.threadIdentifier.isEmpty ? nil : content.threadIdentifier) + ?? "" + + let message = WatchMessage( + id: id, + title: content.title.isEmpty ? nil : content.title, + subtitle: content.subtitle.isEmpty ? nil : content.subtitle, + body: content.body.isEmpty ? nil : content.body, + url: stringValue(userInfo["url"]), + image: stringValue(userInfo["image"]), + group: group, + createDate: Date(), + expireDate: expireDate + ) + await MessageStore.shared.save(message) + } + + static func decrypt(_ ciphertext: String, iv: String?) throws -> [AnyHashable: Any] { + guard var fields = CryptoSettingManager.shared.fields else { + throw "No encryption key set" + } + if let iv, !iv.isEmpty { + fields.iv = iv + } + + let text = try AESCryptoModel(cryptoFields: fields).decrypt(ciphertext: ciphertext) + guard let data = text.data(using: .utf8), + let json = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + else { + throw "JSON parsing failed" + } + + var result: [AnyHashable: Any] = [:] + for (key, value) in json { + result[key.lowercased()] = stringValue(value) ?? value + } + return result + } + + static func stringValue(_ value: Any?) -> String? { + if let string = value as? String { + return string + } + if let number = value as? NSNumber { + return number.stringValue + } + return nil + } +} diff --git a/BarkWatch/NotificationService/NotificationService.entitlements b/BarkWatch/NotificationService/NotificationService.entitlements index 28393494..59eb47c0 100644 --- a/BarkWatch/NotificationService/NotificationService.entitlements +++ b/BarkWatch/NotificationService/NotificationService.entitlements @@ -6,9 +6,5 @@ group.bark - keychain-access-groups - - $(AppIdentifierPrefix)me.fin.bark.shared - diff --git a/BarkWatch/NotificationService/NotificationService.swift b/BarkWatch/NotificationService/NotificationService.swift new file mode 100644 index 00000000..057e766f --- /dev/null +++ b/BarkWatch/NotificationService/NotificationService.swift @@ -0,0 +1,60 @@ +import UserNotifications + +final class NotificationService: UNNotificationServiceExtension { + private let lock = NSLock() + private var contentHandler: ((UNNotificationContent) -> Void)? + private var bestAttemptContent: UNMutableNotificationContent? + private var delivered = false + + override func didReceive( + _ request: UNNotificationRequest, + withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void + ) { + self.contentHandler = contentHandler + + guard let content = request.content.mutableCopy() as? UNMutableNotificationContent else { + deliver(request.content) + return + } + bestAttemptContent = content + + Task { + let prepared = await NotificationPipeline.prepare(content, identifier: request.identifier) + publish(prepared) + + let withImage = await NotificationPipeline.attachImage(to: prepared) + publish(withImage) + + deliver(withImage) + } + } + + override func serviceExtensionTimeWillExpire() { + lock.lock() + let content = bestAttemptContent + lock.unlock() + + if let content { + deliver(content) + } + } + + private func publish(_ content: UNMutableNotificationContent) { + lock.lock() + bestAttemptContent = content + lock.unlock() + } + + private func deliver(_ content: UNNotificationContent) { + lock.lock() + guard !delivered, let handler = contentHandler else { + lock.unlock() + return + } + delivered = true + contentHandler = nil + lock.unlock() + + handler(content) + } +} diff --git a/BarkWatch/NotificationService/WatchNotificationService.swift b/BarkWatch/NotificationService/WatchNotificationService.swift deleted file mode 100644 index cbf7e0d2..00000000 --- a/BarkWatch/NotificationService/WatchNotificationService.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// 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 deleted file mode 100644 index 3d39decf..00000000 --- a/BarkWatch/TestNotification.apns +++ /dev/null @@ -1,10 +0,0 @@ -{ - "aps": { - "alert": { - "title": "Bark Watch", - "body": "Test notification from the simulator." - }, - "mutable-content": 1, - "sound": "default" - } -} diff --git a/BarkWatch/Tests/BarkWatchTests.swift b/BarkWatch/Tests/BarkWatchTests.swift new file mode 100644 index 00000000..da6f6832 --- /dev/null +++ b/BarkWatch/Tests/BarkWatchTests.swift @@ -0,0 +1,7 @@ +import Testing + +struct BarkWatchTests { + @Test func example() async throws { + // Add assertions here to verify your tests produce the correct results. + } +} diff --git a/BarkWatch/Tests/NotificationTests.swift b/BarkWatch/Tests/NotificationTests.swift deleted file mode 100644 index 82a13c95..00000000 --- a/BarkWatch/Tests/NotificationTests.swift +++ /dev/null @@ -1,692 +0,0 @@ -// -// 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 deleted file mode 100644 index cfac31aa..00000000 --- a/BarkWatch/View/AddServerTabView.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// 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 deleted file mode 100644 index ee433a34..00000000 --- a/BarkWatch/View/AddServerView.swift +++ /dev/null @@ -1,60 +0,0 @@ -// -// 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 deleted file mode 100644 index 632282fa..00000000 --- a/BarkWatch/View/BigButtonView.swift +++ /dev/null @@ -1,89 +0,0 @@ -// -// 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 deleted file mode 100644 index 7bdee937..00000000 --- a/BarkWatch/View/ContentView.swift +++ /dev/null @@ -1,73 +0,0 @@ -// -// 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 index 30df0357..7940fb0d 100644 --- a/BarkWatch/View/EncryptionView.swift +++ b/BarkWatch/View/EncryptionView.swift @@ -1,88 +1,162 @@ -// -// EncryptionView.swift -// Bark -// -// Created by Jian Qin on 9/30/26. -// - - import SwiftUI struct EncryptionView: View { - @State private var algorithm = Algorithm.aes256.rawValue + @Environment(\.dismiss) private var dismiss + + private let algorithms: [Algorithm] = [.aes128, .aes192, .aes256] + private let paddingLabel = "Padding" + + @State private var algorithm: Algorithm = .aes256 @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"] } + @State private var errorMessage: String? + @State private var isSyncing = false + @State private var syncFailed = false var body: some View { Form { - Picker("Algorithm", selection: $algorithm) { - ForEach(Algorithm.allCases, id: \.rawValue) { Text($0.rawValue).tag($0.rawValue) } + Picker("algorithm", selection: $algorithm) { + ForEach(algorithms, id: \.self) { algorithm in + Text(verbatim: algorithm.rawValue) + } } - Picker("Mode", selection: $mode) { - ForEach(Algorithm(rawValue: algorithm)?.modes ?? [], id: \.self) { Text($0).tag($0) } + .onChange(of: algorithm) { _, newValue in + if !newValue.modes.contains(mode) { + mode = newValue.modes[0] + padding = padding(for: mode) + } } - Picker("Padding", selection: $padding) { - ForEach(paddings, id: \.self) { Text($0).tag($0) } + + Picker("mode", selection: $mode) { + ForEach(algorithm.modes, id: \.self) { mode in + Text(verbatim: mode) + } } + .onChange(of: mode) { _, newValue in + padding = padding(for: newValue) + } + + LabeledContent(paddingLabel, value: padding) + + TextField("enterKey".localized(with: algorithm.keyLength), text: $key) + .font(.system(.body, design: .monospaced)) + .autocorrectionDisabled() + + if let errorMessage { + Text(verbatim: errorMessage) + .font(.caption2) + .foregroundStyle(.red) + } + 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) + Button(action: sync) { + HStack(spacing: 4) { + Label("syncFromIPhone", systemImage: "arrow.down.circle") + .lineLimit(1) + .minimumScaleFactor(0.7) + Spacer(minLength: 0) + if isSyncing { + ProgressView() + } } - } 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) } + .disabled(isSyncing) } } - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .navigationTitle("Encryption") - .onChange(of: mode) { - // GCM only supports noPadding. - if !paddings.contains(padding) { padding = "noPadding" } + .navigationTitle("encryptionSettings") + .alert("syncFromIPhoneFailed", isPresented: $syncFailed) { + Button("confirm", role: .cancel) {} } - .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 ?? "" + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button(action: save) { + Image(systemName: "checkmark") } - } catch { self.error = error.localizedDescription } + .disabled(!isValid) + } } + .onAppear(perform: load) + } + + private var isValid: Bool { + key.isEmpty || key.count == algorithm.keyLength + } + + private func padding(for mode: String) -> String { + mode == "GCM" ? "noPadding" : "pkcs7" + } + + private func load() { + guard let fields = CryptoSettingManager.shared.fields, + let storedAlgorithm = Algorithm(rawValue: fields.algorithm) + else { + return + } + + algorithm = storedAlgorithm + mode = storedAlgorithm.modes.contains(fields.mode) ? fields.mode : storedAlgorithm.modes[0] + padding = padding(for: mode) + key = fields.key ?? "" } - /// 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 + if key.isEmpty { + CryptoSettingManager.shared.fields = nil + dismiss() + return + } + + let fields = CryptoSettingFields( + algorithm: algorithm.rawValue, + mode: mode, + padding: padding, + key: key, + iv: currentIv + ) + do { - try WatchCryptoStore.save(fields) - saved = fields - } catch { self.error = error.localizedDescription } + try AESCryptoModel.validate(cryptoFields: fields) + CryptoSettingManager.shared.fields = fields + dismiss() + } catch { + errorMessage = error.rawString() + } + } + + private var currentIv: String? { + guard let iv = CryptoSettingManager.shared.fields?.iv else { + return nil + } + let expected = ["CBC": 16, "GCM": 12][mode] ?? 0 + return iv.count == expected ? iv : nil + } + + private func sync() { + guard !isSyncing else { return } + isSyncing = true + errorMessage = nil + + Task { + defer { isSyncing = false } + + do { + guard let data = try await PhoneSyncManager.shared.requestData(for: .cryptoSetting), + let fields = try? JSONDecoder().decode(CryptoSettingFields.self, from: data), + let storedAlgorithm = Algorithm(rawValue: fields.algorithm) + else { + syncFailed = true + return + } + + algorithm = storedAlgorithm + mode = storedAlgorithm.modes.contains(fields.mode) ? fields.mode : storedAlgorithm.modes[0] + padding = fields.padding + key = fields.key ?? "" + CryptoSettingManager.shared.fields = fields + } catch { + syncFailed = true + } + } } } diff --git a/BarkWatch/View/HistoryView.swift b/BarkWatch/View/HistoryView.swift new file mode 100644 index 00000000..bdc541a4 --- /dev/null +++ b/BarkWatch/View/HistoryView.swift @@ -0,0 +1,263 @@ +import SwiftUI + +struct HistoryView: View { + @Environment(AppModel.self) private var model + @State private var showingFilter = false + @State private var showingClearOptions = false + @State private var pendingRange: MessageDeleteTimeRange? + @State private var showingClearConfirmation = false + + var body: some View { + List { + ForEach(model.messages.items, id: \.id) { message in + NavigationLink { + MessageDetailView(message: message) + } label: { + row(for: message) + } + } + + if model.messages.hasMore { + ProgressView() + .onAppear { + model.messages.loadMore() + } + } + } + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { + showingFilter = true + } label: { + Image(systemName: filterIcon) + } + .accessibilityLabel("group") + } + ToolbarItem(placement: .topBarTrailing) { + Button { + showingClearOptions = true + } label: { + Image(systemName: "trash") + } + .accessibilityLabel("clear".localized) + } + } + .sheet(isPresented: $showingClearOptions, onDismiss: { + if pendingRange != nil { + showingClearConfirmation = true + } + }) { + ClearRangeListView { range in + pendingRange = range + showingClearOptions = false + } + } + .confirmationDialog( + Text(verbatim: "\(groupTitle): \(model.messages.filter.title)"), + isPresented: $showingFilter, + titleVisibility: .visible + ) { + ForEach(model.messages.groupFilters, id: \.self) { filter in + Button { + model.messages.select(filter) + } label: { + Text(verbatim: filter.title) + } + } + } + .alert( + Text(verbatim: clearTitle), + isPresented: $showingClearConfirmation, + presenting: pendingRange + ) { range in + Button(role: .destructive) { + model.messages.clear(range) + pendingRange = nil + } label: { + Text(verbatim: "clear".localized) + } + Button(role: .cancel) { + pendingRange = nil + } label: { + Text(verbatim: "Cancel".localized) + } + } + .onAppear { + model.messages.reload() + } + } + + private var clearTitle: String { + guard let pendingRange else { return "" } + return "\("clearFrom".localized)\n\(pendingRange.string)" + } + + private var groupTitle: String { + "group".localized + } + + private var filterIcon: String { + switch model.messages.filter { + case .all: + return "line.3.horizontal.decrease.circle" + case .group: + return "line.3.horizontal.decrease.circle.fill" + } + } + + private func row(for message: WatchMessage) -> some View { + let title = message.title.flatMap { $0.isEmpty ? nil : $0 } + let body = message.body.flatMap { $0.isEmpty ? nil : $0 } + + return VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + if model.messages.filter == .all, !message.group.isEmpty { + Text(verbatim: message.group) + .lineLimit(1) + } + Spacer(minLength: 0) + Text(message.createDate, style: .relative) + } + .font(.caption2) + .foregroundStyle(.secondary) + + if let title { + Text(verbatim: title) + .font(.headline) + .lineLimit(1) + } + + if let body { + Text(verbatim: body) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + } +} + +private struct ClearRangeListView: View { + let onSelect: (MessageDeleteTimeRange) -> Void + + @Environment(\.dismiss) private var dismiss + + private let primaryRanges: [MessageDeleteTimeRange] = [ + .lastHour, .today, .todayAndYesterday, .lastMonth, .allTime + ] + private let moreRanges: [MessageDeleteTimeRange] = [ + .beforeOneHour, .beforeToday, .beforeYesterday, .beforeOneMonth + ] + + var body: some View { + NavigationStack { + List { + Section { + ForEach(primaryRanges, id: \.self) { range in + Button { + onSelect(range) + } label: { + Text(verbatim: range.string) + } + } + } header: { + Text(verbatim: "clearFrom".localized) + } + + Section { + ForEach(moreRanges, id: \.self) { range in + Button { + onSelect(range) + } label: { + Text(verbatim: range.string) + } + } + } header: { + Text(verbatim: "more".localized) + } + } + .navigationTitle(Text(verbatim: "clear".localized)) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button { + dismiss() + } label: { + Text(verbatim: "Cancel".localized) + } + } + } + } + } +} + +private struct MessageDetailView: View { + let message: WatchMessage + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 8) { + if let title = message.title, !title.isEmpty { + Text(verbatim: title) + .font(.headline) + } + + if let subtitle = message.subtitle, !subtitle.isEmpty { + Text(verbatim: subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + HStack(spacing: 4) { + Text(message.createDate, style: .date) + Text(message.createDate, style: .time) + } + .font(.caption2) + .foregroundStyle(.secondary) + + if !message.group.isEmpty { + Label(message.group, systemImage: "folder") + .font(.caption2) + .foregroundStyle(.secondary) + } + + if let body = message.body, !body.isEmpty { + Text(verbatim: body) + .font(.body) + } + + if let image = message.image, let url = URL(string: image) { + AsyncImage(url: url) { phase in + if let image = phase.image { + image + .resizable() + .scaledToFit() + .clipShape(RoundedRectangle(cornerRadius: 8)) + } else if phase.error == nil { + ProgressView() + } + } + } + + if let link = message.url, let url = URL(string: link) { + Link(destination: url) { + Label(url.host ?? link, systemImage: "link") + .lineLimit(1) + } + .font(.footnote) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} + +private extension MessageFilter { + var title: String { + switch self { + case .all: + return "all".localized + case .group(let group): + return group.isEmpty ? "default".localized : group + } + } +} diff --git a/BarkWatch/View/LastMessageView.swift b/BarkWatch/View/LastMessageView.swift deleted file mode 100644 index aa762889..00000000 --- a/BarkWatch/View/LastMessageView.swift +++ /dev/null @@ -1,168 +0,0 @@ -// -// 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 deleted file mode 100644 index 28c12eae..00000000 --- a/BarkWatch/View/NotificationMarkdownView.swift +++ /dev/null @@ -1,87 +0,0 @@ -// -// 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 deleted file mode 100644 index e77f8a9c..00000000 --- a/BarkWatch/View/NotificationStatusTab.swift +++ /dev/null @@ -1,135 +0,0 @@ -// -// 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/NotificationView.swift b/BarkWatch/View/NotificationView.swift new file mode 100644 index 00000000..a13fde62 --- /dev/null +++ b/BarkWatch/View/NotificationView.swift @@ -0,0 +1,97 @@ +import CoreText +import SwiftUI +import UserNotifications +import WatchKit + +final class NotificationController: WKUserNotificationHostingController { + private var content: UNNotificationContent? + private var markdown: AttributedString? + + override var body: NotificationView { + NotificationView(content: content, markdown: markdown) + } + + override class var isInteractive: Bool { true } + + override func didReceive(_ notification: UNNotification) { + let content = notification.request.content + self.content = content + + if let source = content.userInfo["markdown"] as? String, !source.isEmpty { + markdown = Self.attributedString(from: MarkdownText.attributed(source)) + } + } + + private static func attributedString(from text: NSAttributedString) -> AttributedString { + var result = AttributedString() + + text.enumerateAttributes(in: NSRange(location: 0, length: text.length)) { attributes, range, _ in + var run = AttributedString(text.attributedSubstring(from: range).string) + + if let font = attributes[.font] as? UIFont { + run.font = Font(font as CTFont) + } + if let color = attributes[.foregroundColor] as? UIColor { + run.foregroundColor = Color(uiColor: color) + } + if let background = attributes[.backgroundColor] as? UIColor { + run.backgroundColor = Color(uiColor: background) + } + if attributes[.underlineStyle] != nil { + run.underlineStyle = .single + } + if attributes[.strikethroughStyle] != nil { + run.strikethroughStyle = .single + } + + result.append(run) + } + + return result + } +} + +struct NotificationView: View { + let content: UNNotificationContent? + let markdown: AttributedString? + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 6) { + if let content { + if !content.title.isEmpty { + Text(verbatim: content.title) + .font(.headline) + } + + if !content.subtitle.isEmpty { + Text(verbatim: content.subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + if let markdown { + Text(markdown) + } else if !content.body.isEmpty { + Text(verbatim: content.body) + } + + if let image = content.userInfo["image"] as? String, + let url = URL(string: image) { + AsyncImage(url: url) { phase in + if let image = phase.image { + image + .resizable() + .scaledToFit() + .clipShape(RoundedRectangle(cornerRadius: 6)) + } else if phase.error == nil { + ProgressView() + } + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} diff --git a/BarkWatch/View/PermissionView.swift b/BarkWatch/View/PermissionView.swift new file mode 100644 index 00000000..9a50fb6c --- /dev/null +++ b/BarkWatch/View/PermissionView.swift @@ -0,0 +1,77 @@ +import SwiftUI + +struct PermissionView: View { + @Environment(AppModel.self) private var model + var onGranted: () -> Void + + @State private var requesting = false + + var body: some View { + ScrollView { + VStack(spacing: 10) { + Image(systemName: iconName) + .font(.system(size: 32)) + .foregroundStyle(.orange) + + Text(title) + .font(.headline) + .multilineTextAlignment(.center) + + Text(detail) + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + if canRequest { + Button(action: request) { + Text("goToSettings") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(.orange) + .disabled(requesting) + } else { + Text("permissionGuide") + .font(.caption2) + .foregroundStyle(.orange) + .multilineTextAlignment(.center) + } + } + .padding(.horizontal, 6) + .padding(.vertical, 8) + } + } + + private var notificationsMissing: Bool { + !model.notificationsAllowed + } + + private var canRequest: Bool { + model.authorizationStatus == .notDetermined + } + + private var title: LocalizedStringKey { + notificationsMissing ? "notificationPermissionOff" : "criticalAlertPermissionOff" + } + + private var detail: LocalizedStringKey { + notificationsMissing ? "notificationPermissionOffDetail" : "criticalAlertPermissionOffDetail" + } + + private var iconName: String { + notificationsMissing ? "bell.badge.fill" : "exclamationmark.triangle.fill" + } + + private func request() { + requesting = true + Task { + await model.requestAuthorization() + await model.refresh() + requesting = false + + if !model.needsPermission { + onGranted() + } + } + } +} diff --git a/BarkWatch/View/RootTabView.swift b/BarkWatch/View/RootTabView.swift new file mode 100644 index 00000000..fcdccb29 --- /dev/null +++ b/BarkWatch/View/RootTabView.swift @@ -0,0 +1,67 @@ +import SwiftUI + +enum WatchTab: String { + case permission + case servers + case history +} + +struct RootTabView: View { + @Environment(AppModel.self) private var model + @AppStorage("BarkWatch.selectedTab") private var storedTab = WatchTab.servers.rawValue + @State private var selection: WatchTab = .servers + + var body: some View { + Group { + if model.permissionChecked { + NavigationStack { + TabView(selection: $selection) { + if model.needsPermission { + PermissionView { + selection = .servers + } + .tag(WatchTab.permission) + } + + ServerView() + .tag(WatchTab.servers) + + HistoryView() + .tag(WatchTab.history) + } + .tabViewStyle(.verticalPage) + } + } else { + ProgressView() + } + } + .task { restoreSelection() } + .onChange(of: model.permissionChecked) { _, _ in + restoreSelection() + } + .onChange(of: model.needsPermission) { _, needsPermission in + if needsPermission { + selection = .permission + } else if selection == .permission { + selection = .servers + } + } + .onChange(of: selection) { _, newValue in + if newValue != .permission { + storedTab = newValue.rawValue + } + } + } + + private func restoreSelection() { + guard model.permissionChecked else { return } + + if model.needsPermission { + selection = .permission + } else if selection == .permission { + selection = .servers + } else if let saved = WatchTab(rawValue: storedTab), saved != .permission { + selection = saved + } + } +} diff --git a/BarkWatch/View/ServerListView.swift b/BarkWatch/View/ServerListView.swift new file mode 100644 index 00000000..53d7bfe6 --- /dev/null +++ b/BarkWatch/View/ServerListView.swift @@ -0,0 +1,159 @@ +import SwiftUI + +struct ServerListView: View { + @Environment(AppModel.self) private var model + @State private var addingServer = false + + var body: some View { + List { + ForEach(model.servers.servers, id: \.id) { server in + NavigationLink { + ServerDetailView(server: server) + } label: { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Text(verbatim: server.displayName) + .font(.headline) + .lineLimit(1) + if server.id == model.servers.currentServer?.id { + Image(systemName: "checkmark") + .font(.caption2) + .foregroundStyle(.tint) + } + } + Text(verbatim: server.cleanAddress) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } + + Section { + Button { + Task { await model.servers.syncServers(deviceToken: model.deviceToken) } + } label: { + HStack(spacing: 4) { + Label("syncFromIPhone", systemImage: "arrow.down.circle") + .lineLimit(1) + .minimumScaleFactor(0.7) + Spacer(minLength: 0) + if model.servers.isSyncingServers { + ProgressView() + } + } + } + .disabled(model.servers.isSyncingServers) + } + } + .navigationTitle("serverList") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + addingServer = true + } label: { + Image(systemName: "plus") + } + } + } + .alert( + "syncFromIPhoneFailed", + isPresented: Binding( + get: { model.servers.syncServersFailed }, + set: { model.servers.syncServersFailed = $0 } + ) + ) { + Button("confirm", role: .cancel) {} + } + .sheet(isPresented: $addingServer) { + AddServerView() + } + .onAppear { + model.servers.reload() + } + } +} + +private struct ServerDetailView: View { + let server: Server + + @Environment(AppModel.self) private var model + @Environment(\.dismiss) private var dismiss + + @State private var confirmingDelete = false + + private var isCurrent: Bool { + server.id == model.servers.currentServer?.id + } + + var body: some View { + List { + Section { + Text(verbatim: server.cleanAddress) + .font(.footnote) + Text(verbatim: server.key.isEmpty ? "deviceNotRegistered".localized : server.key) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(server.key.isEmpty ? .secondary : .primary) + } + + if !isCurrent { + Button("setAsDefaultServer") { + model.servers.setCurrent(server) + dismiss() + } + } + + Button("deleteServer", role: .destructive) { + confirmingDelete = true + } + .disabled(model.servers.servers.count <= 1) + } + .navigationTitle(server.displayName) + .confirmationDialog("confirmDeleteServer", isPresented: $confirmingDelete) { + Button("deleteServer", role: .destructive) { + model.servers.remove(server, deviceToken: model.deviceToken) + dismiss() + } + Button("Cancel", role: .cancel) {} + } + } +} + +private struct AddServerView: View { + @Environment(AppModel.self) private var model + @Environment(\.dismiss) private var dismiss + + private let addressPlaceholder = "https://" + + @State private var address = "https://" + @State private var failed = false + + var body: some View { + NavigationStack { + Form { + TextField(addressPlaceholder, text: $address) + + if failed { + Text("InvalidServer") + .font(.caption2) + .foregroundStyle(.red) + } + + Button("AddServer") { + if model.servers.addServer(address: address, deviceToken: model.deviceToken) { + dismiss() + } else { + failed = true + } + } + .disabled(address.count <= 8) + } + .navigationTitle("AddServer") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + } + } +} diff --git a/BarkWatch/View/ServerTabView.swift b/BarkWatch/View/ServerTabView.swift deleted file mode 100644 index 94008e90..00000000 --- a/BarkWatch/View/ServerTabView.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// 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/ServerView.swift b/BarkWatch/View/ServerView.swift new file mode 100644 index 00000000..82f3da7d --- /dev/null +++ b/BarkWatch/View/ServerView.swift @@ -0,0 +1,95 @@ +import SwiftUI + +struct ServerView: View { + @Environment(AppModel.self) private var model + + private var server: Server? { + model.servers.currentServer + } + + private var hasKey: Bool { + server?.key.isEmpty == false + } + + var body: some View { + List { + Section { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Image(systemName: hasKey ? "checkmark.seal.fill" : "exclamationmark.triangle.fill") + .font(.subheadline) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(hasKey ? Color.green : Color.orange) + + Text(verbatim: server?.displayName ?? defaultServer) + .font(.headline) + .lineLimit(1) + } + + Text(verbatim: server?.cleanAddress ?? defaultServer) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + + Text(verbatim: hasKey ? (server?.key ?? "") : "deviceNotRegistered".localized) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(hasKey ? Color.primary : Color.orange) + .lineLimit(1) + .minimumScaleFactor(0.5) + } + .padding(.vertical, 4) + + Button(action: sendTest) { + HStack(spacing: 4) { + Label("sendTest", systemImage: "paperplane.fill") + Spacer(minLength: 0) + if model.servers.isSending { + ProgressView() + } + } + } + .disabled(!hasKey || model.servers.isSending) + + if let result = model.servers.testResult { + switch result { + case .sent: + Text("testPushSent") + .font(.caption2) + .foregroundStyle(.green) + case .failed(let message): + Text(verbatim: message) + .font(.caption2) + .foregroundStyle(.red) + } + } + } + + Section { + NavigationLink { + ServerListView() + } label: { + Label("serverList", systemImage: "server.rack") + .lineLimit(1) + .minimumScaleFactor(0.5) + } + + NavigationLink { + EncryptionView() + } label: { + Label("encryptionSettings", systemImage: "key.fill") + .lineLimit(1) + .minimumScaleFactor(0.5) + } + } + } + .onAppear { + model.servers.reload() + model.servers.resetTestResult() + } + } + + private func sendTest() { + guard let server else { return } + Task { await model.servers.sendTest(server: server) } + } +} diff --git a/BarkWatch/View/WatchMarkdownView.swift b/BarkWatch/View/WatchMarkdownView.swift deleted file mode 100644 index 94cfcb0b..00000000 --- a/BarkWatch/View/WatchMarkdownView.swift +++ /dev/null @@ -1,135 +0,0 @@ -// -// 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/Common/BarkSettings.swift b/Common/BarkSettings.swift index 26ad1450..3b37366d 100644 --- a/Common/BarkSettings.swift +++ b/Common/BarkSettings.swift @@ -7,7 +7,7 @@ // import DefaultsKit -import UIKit +import Foundation enum BarkSettingKey: String { /// 存放key , 1.2.6 版本`之后`不再使用 @@ -22,7 +22,7 @@ enum BarkSettingKey: String { case selectedViewControllerIndex = "me.fin.bark.selectedViewControllerIndex" } -class BarkSettings { +class BarkSettings: @unchecked Sendable { static let shared = BarkSettings() private init() {} diff --git a/Common/CryptoSettingManager.swift b/Common/CryptoSettingManager.swift index 93604f06..b562e4f2 100644 --- a/Common/CryptoSettingManager.swift +++ b/Common/CryptoSettingManager.swift @@ -9,7 +9,7 @@ import Foundation class CryptoSettingManager: NSObject { - static let shared = CryptoSettingManager() + nonisolated(unsafe) static let shared = CryptoSettingManager() let defaults = UserDefaults(suiteName: "group.bark") var fields: CryptoSettingFields? { get { diff --git a/Common/Date+Extension.swift b/Common/Date+Extension.swift index a20f705a..219fec2a 100644 --- a/Common/Date+Extension.swift +++ b/Common/Date+Extension.swift @@ -81,26 +81,3 @@ extension Date { return dayAfter.month != month } } - -extension Date { - static var yesterday: Date { return Date().dayBefore } - static var tomorrow: Date { return Date().dayAfter } - static var lastHour: Date { return Calendar.current.date(byAdding: .hour, value: -1, to: Date())! } - static var lastMonth: Date { return Calendar.current.date(byAdding: .month, value: -1, to: Date())! } - - var dayBefore: Date { - return Calendar.current.date(byAdding: .day, value: -1, to: startOfDay)! - } - - var dayAfter: Date { - return Calendar.current.date(byAdding: .day, value: 1, to: startOfDay)! - } - - var startOfDay: Date { - return Calendar.current.date(bySettingHour: 0, minute: 0, second: 0, of: self)! - } - - var endOfDay: Date { - return Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: self)! - } -} diff --git a/Common/Date+Shared.swift b/Common/Date+Shared.swift new file mode 100644 index 00000000..90dd843e --- /dev/null +++ b/Common/Date+Shared.swift @@ -0,0 +1,29 @@ +// +// Date+Shared.swift +// Bark +// + +import Foundation + +extension Date { + static var yesterday: Date { return Date().dayBefore } + static var tomorrow: Date { return Date().dayAfter } + static var lastHour: Date { return Calendar.current.date(byAdding: .hour, value: -1, to: Date())! } + static var lastMonth: Date { return Calendar.current.date(byAdding: .month, value: -1, to: Date())! } + + var dayBefore: Date { + return Calendar.current.date(byAdding: .day, value: -1, to: startOfDay)! + } + + var dayAfter: Date { + return Calendar.current.date(byAdding: .day, value: 1, to: startOfDay)! + } + + var startOfDay: Date { + return Calendar.current.date(bySettingHour: 0, minute: 0, second: 0, of: self)! + } + + var endOfDay: Date { + return Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: self)! + } +} diff --git a/Common/MarkdownParser.swift b/Common/MarkdownParser.swift index 38375486..9d86724b 100644 --- a/Common/MarkdownParser.swift +++ b/Common/MarkdownParser.swift @@ -296,6 +296,7 @@ private struct AttributedStringWalker: MarkupWalker { attributedString.append(NSAttributedString(string: "\(prefix)", attributes: attributes)) if let checkbox = listItem.checkbox { + #if os(iOS) let imageName = checkbox == .checked ? "checkmark.square" : "square" let font = attributes[.font] as? UIFont ?? configuration.baseFont let color = attributes[.foregroundColor] as? UIColor ?? configuration.baseColor @@ -313,6 +314,10 @@ private struct AttributedStringWalker: MarkupWalker { let text = checkbox == .checked ? "☑ " : "☐ " attributedString.append(NSAttributedString(string: text, attributes: attributes)) } + #else + let text = checkbox == .checked ? "☑ " : "☐ " + attributedString.append(NSAttributedString(string: text, attributes: attributes)) + #endif } for child in listItem.children { diff --git a/Common/Moya/BarkApi.swift b/Common/Moya/BarkApi.swift index 871e5f5a..f0ee9e34 100644 --- a/Common/Moya/BarkApi.swift +++ b/Common/Moya/BarkApi.swift @@ -6,7 +6,7 @@ // Copyright © 2018 Fin. All rights reserved. // -import UIKit +import Foundation enum BarkApi { case ping(baseURL: String?) @@ -31,7 +31,12 @@ extension BarkApi: BarkTargetType { var parameters: [String: Any]? { switch self { case let .register(_, key, devicetoken): - var params = ["devicetoken": devicetoken] + #if os(watchOS) + let deviceType = "watchos" + #else + let deviceType = "ios" + #endif + var params: [String: Any] = ["devicetoken": devicetoken, "device_type": deviceType] if let key = key { params["key"] = key } diff --git a/Common/Moya/BarkTargetType.swift b/Common/Moya/BarkTargetType.swift index 51785c8f..c46f3977 100644 --- a/Common/Moya/BarkTargetType.swift +++ b/Common/Moya/BarkTargetType.swift @@ -8,10 +8,23 @@ import Moya import RxSwift -import UIKit +import Foundation // 保存全局Providers -private var retainProviders: [String: Any] = [:] +private final class ProviderCache: @unchecked Sendable { + static let shared = ProviderCache() + private let lock = NSLock() + private var providers: [String: Any] = [:] + + func provider(for key: String, create: () -> T) -> T { + lock.lock() + defer { lock.unlock() } + if let provider = providers[key] as? T { return provider } + let provider = create() + providers[key] = provider + return provider + } +} protocol BarkTargetType: TargetType { var parameters: [String: Any]? { get } @@ -57,12 +70,7 @@ extension BarkTargetType { /// 实现此协议的类,将自动获得用该类实例化的 provider 对象 static var provider: RxSwift.Reactive> { let key = "\(Self.self)" - if let provider = retainProviders[key] as? RxSwift.Reactive> { - return provider - } - let provider = Self.weakProvider - retainProviders[key] = provider - return provider + return ProviderCache.shared.provider(for: key) { Self.weakProvider } } /// 不被全局持有的 Provider ,使用时,需要持有它,否则将立即释放,请求随即终止 diff --git a/Common/Moya/Observable+Extension.swift b/Common/Moya/Observable+Extension.swift index 6bb95da8..826d8e3b 100644 --- a/Common/Moya/Observable+Extension.swift +++ b/Common/Moya/Observable+Extension.swift @@ -6,13 +6,11 @@ // Copyright © 2018 Fin. All rights reserved. // -import UIKit - +import Foundation import Moya import ObjectMapper import RxSwift import SwiftyJSON -import UIKit extension Observable where Element: Moya.Response { /// 过滤 HTTP 错误,例如超时,请求失败等 diff --git a/Common/ServerManager.swift b/Common/ServerManager.swift index 0a242dcb..8ba0f4d4 100644 --- a/Common/ServerManager.swift +++ b/Common/ServerManager.swift @@ -6,10 +6,9 @@ // Copyright © 2018年 Fin. All rights reserved. // +import Foundation import RxCocoa import RxSwift -import SwiftUI -import UIKit let defaultServer = "https://api.day.app" @@ -63,8 +62,9 @@ class Server: Codable { } class ServerManager: NSObject { - static let shared = ServerManager() - override private init() { + // Callers and registration callbacks must use the main thread when mutating state. + nonisolated(unsafe) static let shared = ServerManager() + override init() { var servers: [Server] = [] if let savedServers: [Server] = Settings[.servers] { servers = savedServers @@ -160,26 +160,48 @@ class ServerManager: NSObject { } var dispose: Disposable? - /// 同步所有 server - func syncAllServers() { - guard let token = Client.shared.deviceToken.value, token.count > 0 else { + + func register(server: Server, deviceToken: String) -> Observable> { + BarkApi.provider.request( + .register(address: server.address, key: server.key, devicetoken: deviceToken) + ) + .filterResponseError() + .map { result in + switch result { + case .success(let json): + guard let key = json["data", "key"].string, + !key.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + return .failure(.Error(info: "服务器未返回设备 key")) + } + return .success(key) + case .failure(let error): + return .failure(error) + } + } + } + + func syncAllServers(deviceToken: String? = nil) { + let token: String? + + #if os(iOS) + token = deviceToken ?? Client.shared.deviceToken.value + #elseif os(watchOS) + token = deviceToken + #endif + + guard let token, token.count > 0 else { return } dispose?.dispose() let apis = servers.map { server in - BarkApi.provider.request( - .register( - address: server.address, - key: server.key, - devicetoken: token - )) - .filterResponseError() + register(server: server, deviceToken: token) .map { result -> (Server, String?) in switch result { - case .success(let json): - return (server, json["data", "key"].rawString()) + case .success(let key): + return (server, key) case .failure: return (server, nil) } @@ -190,6 +212,7 @@ class ServerManager: NSObject { dispose = Observable .merge(apis) + .observe(on: MainScheduler.instance) .subscribe { result in // 更新所有的 server key if let key = result.1 { diff --git a/Common/WatchSyncManager.swift b/Common/WatchSyncManager.swift new file mode 100644 index 00000000..03a0114a --- /dev/null +++ b/Common/WatchSyncManager.swift @@ -0,0 +1,57 @@ +import Foundation +import WatchConnectivity + +final class WatchSyncManager: NSObject { + static let shared = WatchSyncManager() + + private enum Key { + static let type = "type" + static let payload = "payload" + } + + func activate() { + guard WCSession.isSupported() else { return } + WCSession.default.delegate = self + WCSession.default.activate() + } + + private func payload(for request: WatchSyncRequest) -> Data? { + switch request { + case .cryptoSetting: + return CryptoSettingManager.shared.fields.flatMap { try? JSONEncoder().encode($0) } + case .serverList: + return try? JSONEncoder().encode(ServerManager.shared.servers.map(\.cleanAddress)) + } + } +} + +extension WatchSyncManager: WCSessionDelegate { + func session( + _ session: WCSession, + activationDidCompleteWith activationState: WCSessionActivationState, + error: Error? + ) {} + + func sessionDidBecomeInactive(_ session: WCSession) {} + + func sessionDidDeactivate(_ session: WCSession) { + session.activate() + } + + func session( + _ session: WCSession, + didReceiveMessage message: [String: Any], + replyHandler: @escaping ([String: Any]) -> Void + ) { + guard let rawValue = message[Key.type] as? String, + let request = WatchSyncRequest(rawValue: rawValue) + else { + replyHandler([:]) + return + } + + var reply: [String: Any] = [Key.type: request.rawValue] + reply[Key.payload] = payload(for: request) + replyHandler(reply) + } +} diff --git a/Common/WatchSyncProtocol.swift b/Common/WatchSyncProtocol.swift new file mode 100644 index 00000000..d1b3c5c8 --- /dev/null +++ b/Common/WatchSyncProtocol.swift @@ -0,0 +1,6 @@ +import Foundation + +enum WatchSyncRequest: String { + case cryptoSetting + case serverList +} diff --git a/Model/MessageDeleteTimeRange.swift b/Model/MessageDeleteTimeRange.swift index 48262dcc..2693451f 100644 --- a/Model/MessageDeleteTimeRange.swift +++ b/Model/MessageDeleteTimeRange.swift @@ -8,7 +8,7 @@ import Foundation -enum MessageDeleteTimeRange { +enum MessageDeleteTimeRange: Hashable { /// 最近一小时 case lastHour /// 今天 diff --git a/Podfile b/Podfile index 9c7b3337..a5fdfa01 100644 --- a/Podfile +++ b/Podfile @@ -56,6 +56,11 @@ end target 'BarkWatch' do platform :watchos, '10.0' pod 'CryptoSwift' + pod 'Moya/RxSwift' + pod 'RxCocoa' + pod 'DefaultsKit' + pod 'SwiftyJSON' + pod 'ObjectMapper' target 'BarkWatchTests' do inherit! :search_paths diff --git a/Podfile.lock b/Podfile.lock index 76a9a895..e56931bd 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -169,6 +169,6 @@ SPEC CHECKSUMS: SwiftyJSON: f5b1bf1cd8dd53cd25887ac0eabcfd92301c6a5a SwiftyStoreKit: 6b9c08810269f030586dac1fae8e75871a82e84a -PODFILE CHECKSUM: 1e9a646c5ef7ec48fb54351c54aa8e859d3111ec +PODFILE CHECKSUM: 7bc8435580fcb800bf0a4b6f3345f1393a161e39 COCOAPODS: 1.17.0 diff --git a/fastlane/Matchfile b/fastlane/Matchfile index 034eabc1..6630f5cd 100644 --- a/fastlane/Matchfile +++ b/fastlane/Matchfile @@ -9,8 +9,8 @@ app_identifier([ "me.fin.bark.NotificationContent", "me.fin.bark.NotificationServiceExtension", "me.fin.bark.Widget", - "me.fin.BarkWatch", - "me.fin.BarkWatch.NotificationService" + "me.fin.bark.watch", + "me.fin.bark.watch.NotificationService" ]) # username("user@fastlane.tools") # Your Apple Developer Portal username