51 Commits

Author SHA1 Message Date
relikd
b270f30f3c Version 1.0.0 (26) 2020-07-28 15:20:30 +02:00
relikd
03177cee0b Invalidate restart reminder when VPN is running 2020-07-28 15:19:47 +02:00
relikd
9ee094dc20 Version 1.0.0 (25) 2020-07-28 14:50:59 +02:00
relikd
b1d49c6765 Persist logs by renaming table (hopefully reduces lock time) 2020-07-27 21:16:14 +02:00
relikd
b774e2152c Don't perform notification open action if modal window is open 2020-07-27 19:23:16 +02:00
relikd
e398ac8bcd Let notification open domain 2020-07-27 19:06:44 +02:00
relikd
01523b250f Proper VPN simulator with notifications, etc. 2020-07-27 17:50:15 +02:00
relikd
a2b0f311d5 First version with app notifications 2020-07-26 22:32:11 +02:00
relikd
88a52fb92c Version 1.0.0 (24) 2020-07-02 14:12:26 +02:00
relikd
723f1665a7 fittingSize() 2020-07-02 12:26:34 +02:00
relikd
4f92d3d58d Co-Occurrence on domain level 2020-07-02 12:26:07 +02:00
relikd
05d06a4f31 Update readme 2020-07-01 13:31:29 +02:00
relikd
f9ab545e0f Version 1.0.0 (23) 2020-07-01 12:30:22 +02:00
relikd
b10d4c8b36 Show database file size in settings 2020-07-01 11:47:15 +02:00
relikd
5a3ca024f8 Vacuum before export 2020-07-01 10:57:50 +02:00
relikd
92216c0c03 CustomAlert refactoring. Using proper UIPresentationController with adaptive margins 2020-07-01 00:53:25 +02:00
relikd
9ece3474c6 Limit CustomAlert to screen size & cut padding in half if necessary 2020-06-29 00:34:15 +02:00
relikd
6dcc2086e6 Auto-delete logs finished + custom App-to-VPN messages 2020-06-28 23:55:08 +02:00
relikd
08483711e2 Remove two unimportant and verbose error logs 2020-06-28 21:17:37 +02:00
relikd
0e100006d3 Moving extensions around 2020-06-28 17:04:48 +02:00
relikd
710c617862 Move VPN manager logic into its own controller 2020-06-28 16:31:11 +02:00
relikd
3ed25c92cd Render assets as template image 2020-06-28 14:34:16 +02:00
relikd
f7644e6048 Rename Pref -> Prefs 2020-06-28 14:33:36 +02:00
relikd
80afa6aff1 Privacy: Auto-delete logs (no functionality yet) 2020-06-28 14:20:31 +02:00
relikd
43de81929f Alerts with custom views 2020-06-28 01:06:06 +02:00
relikd
e315e71d07 Storyboard constantly trying to replace floats with rounding error 2020-06-27 16:27:48 +02:00
relikd
416eb34799 Reverse context analysis sort order (oldest first) 2020-06-27 16:20:20 +02:00
relikd
b7b13f51b2 Recordings: Toggle between raw logs and summary 2020-06-27 16:13:58 +02:00
relikd
2312187670 DB readText -> col_text 2020-06-27 00:54:50 +02:00
relikd
c7d0dc7c5f UIColor.sysFg -> UIColor.sysLabel 2020-06-27 00:50:47 +02:00
relikd
895cabee80 Context analysis: +/-5min raw logs 2020-06-27 00:40:29 +02:00
relikd
d96ced48c9 Version 1.0.0 (22) 2020-06-26 21:36:51 +02:00
relikd
0b6dbfd888 Co-Occurrence tutorial sheet + small bugfixes 2020-06-26 20:26:30 +02:00
relikd
96656438c6 Context analysis: Co-Occurrence 2020-06-24 13:09:11 +02:00
relikd
4b32df5683 Fix layout constraint warning on iOS 10 2020-06-21 16:20:20 +02:00
relikd
0758bd7dec Fix iOS 9 finish editing of cell 2020-06-21 16:16:39 +02:00
relikd
171dabd83a Search integrated in table view header 2020-06-21 16:13:58 +02:00
relikd
6182a99ebd Exclude TLD when searching host 2020-06-20 13:56:11 +02:00
relikd
8bfedda3ab Version 1.0.0 (21) 2020-06-20 13:28:38 +02:00
relikd
26f6ea1a9a Fix crash when sort and filter change at the same time.
Fix edit table cell during reload
2020-06-20 12:56:56 +02:00
relikd
778f377e42 Disabling prepared statement for now 2020-06-20 01:03:51 +02:00
relikd
f284365469 Fix update of 'last modified' cell if removing latest entries 2020-06-19 17:01:10 +02:00
relikd
5dfb7d4ba4 Remove safeSetRange 2020-06-19 15:40:34 +02:00
relikd
bb9c3a3034 Use nil instead of 0 and -1 2020-06-19 14:24:03 +02:00
relikd
8cf872a4b0 Todos 2020-06-17 01:49:46 +02:00
relikd
e813230824 Fix: re-insert at same position if last row 2020-06-17 01:36:39 +02:00
relikd
e8bfde9243 Fix: Search bar animation table height 2020-06-17 01:26:49 +02:00
relikd
e947ad6d4d Refactoring II.
- Filter by date range
- SyncUpdate tasks run fully asynchronous in background
- Move tableView manipulations into FilterPipelineDelegate
- Move SyncUpdate notification into SyncUpdateDelegate
- Fix: sync cache before persisting a recording
- Restructuring GroupedDomainDataSource
- Performance: db logs queries use rowids instead of timestamps
- Add 'now' button to DatePickerAlert
2020-06-17 00:27:22 +02:00
relikd
0a53898797 DatePickerAlert + DateFormat 2020-06-11 01:32:50 +02:00
relikd
946acc2460 Sort order 2020-06-08 23:38:09 +02:00
relikd
e13b3df2c4 Swap filter and search button button 2020-06-07 12:02:18 +02:00
95 changed files with 5400 additions and 1403 deletions

View File

@@ -7,23 +7,64 @@
objects = {
/* Begin PBXBuildFile section */
5404AEEB24A90717003B2F54 /* PrefsShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54E67E4524A8B0FE0025D261 /* PrefsShared.swift */; };
5404AEED24A95F3F003B2F54 /* SlideInAnimation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5404AEEC24A95F3F003B2F54 /* SlideInAnimation.swift */; };
5404AEEF24ACC089003B2F54 /* VCAnalysisBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5404AEEE24ACC089003B2F54 /* VCAnalysisBar.swift */; };
540E6780242D2CF100871BBE /* VCRecordings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 540E677F242D2CF100871BBE /* VCRecordings.swift */; };
540E67822433483D00871BBE /* VCEditRecording.swift in Sources */ = {isa = PBXBuildFile; fileRef = 540E67812433483D00871BBE /* VCEditRecording.swift */; };
540E67842433FAFE00871BBE /* TVCPreviousRecords.swift in Sources */ = {isa = PBXBuildFile; fileRef = 540E67832433FAFE00871BBE /* TVCPreviousRecords.swift */; };
541075CE24C9D43A00D6F1BF /* UNNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 541075CD24C9D43A00D6F1BF /* UNNotification.swift */; };
541075CF24C9D43A00D6F1BF /* UNNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 541075CD24C9D43A00D6F1BF /* UNNotification.swift */; };
541075D124CDBA0000D6F1BF /* ThrottledBatchQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 541075D024CDBA0000D6F1BF /* ThrottledBatchQueue.swift */; };
541075D224CDBA0000D6F1BF /* ThrottledBatchQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 541075D024CDBA0000D6F1BF /* ThrottledBatchQueue.swift */; };
541075D524CE286200D6F1BF /* CachedConnectionAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 541075D424CE286200D6F1BF /* CachedConnectionAlert.swift */; };
541075D624CE286200D6F1BF /* CachedConnectionAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 541075D424CE286200D6F1BF /* CachedConnectionAlert.swift */; };
541075D924CE2C7200D6F1BF /* GlassVPNHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 541075D824CE2C7200D6F1BF /* GlassVPNHook.swift */; };
541075DA24CE2C7200D6F1BF /* GlassVPNHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 541075D824CE2C7200D6F1BF /* GlassVPNHook.swift */; };
5412F8EE24571B8200A63D7A /* VCDateFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5412F8ED24571B8100A63D7A /* VCDateFilter.swift */; };
5412FCC224C628FA000DE429 /* TVCReminderAlerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5412FCBF24C628F9000DE429 /* TVCReminderAlerts.swift */; };
5412FCC324C628FA000DE429 /* TVCChooseAlertTone.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5412FCC024C628F9000DE429 /* TVCChooseAlertTone.swift */; };
5412FCC424C628FA000DE429 /* TVCConnectionAlerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5412FCC124C628FA000DE429 /* TVCConnectionAlerts.swift */; };
541A957623E602DF00C09C19 /* LaunchIcon.png in Resources */ = {isa = PBXBuildFile; fileRef = 541A957523E602DF00C09C19 /* LaunchIcon.png */; };
541AC5D82399498A00A769D7 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 541AC5D72399498A00A769D7 /* AppDelegate.swift */; };
541AC5DD2399498A00A769D7 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 541AC5DB2399498A00A769D7 /* Main.storyboard */; };
541AC5DF2399498B00A769D7 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 541AC5DE2399498B00A769D7 /* Assets.xcassets */; };
541AC5E22399498B00A769D7 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 541AC5E02399498B00A769D7 /* LaunchScreen.storyboard */; };
541DCA6124A6B0F6005F1A4B /* Color.swift in Sources */ = {isa = PBXBuildFile; fileRef = 541DCA6024A6B0F6005F1A4B /* Color.swift */; };
541FC47624A12D01009154D8 /* IBViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 541FC47524A12D01009154D8 /* IBViews.swift */; };
541FC47824A1453F009154D8 /* VCCoOccurrence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 541FC47724A1453F009154D8 /* VCCoOccurrence.swift */; };
541FC9872497D81C00962623 /* TheGreatDestroyer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 541FC9862497D81C00962623 /* TheGreatDestroyer.swift */; };
542E2A982404973F001462DC /* TBCMain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 542E2A972404973F001462DC /* TBCMain.swift */; };
542E2A9A24051556001462DC /* TVCSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 542E2A9924051556001462DC /* TVCSettings.swift */; };
543078AA24B5E12500278F2D /* snap2.caf in Resources */ = {isa = PBXBuildFile; fileRef = 5430789F24B5E12200278F2D /* snap2.caf */; };
543078AB24B5E12500278F2D /* snap2.caf in Resources */ = {isa = PBXBuildFile; fileRef = 5430789F24B5E12200278F2D /* snap2.caf */; };
543078AC24B5E12500278F2D /* typewriter2.caf in Resources */ = {isa = PBXBuildFile; fileRef = 543078A024B5E12200278F2D /* typewriter2.caf */; };
543078AD24B5E12500278F2D /* typewriter2.caf in Resources */ = {isa = PBXBuildFile; fileRef = 543078A024B5E12200278F2D /* typewriter2.caf */; };
543078AE24B5E12500278F2D /* wood1.caf in Resources */ = {isa = PBXBuildFile; fileRef = 543078A124B5E12300278F2D /* wood1.caf */; };
543078AF24B5E12500278F2D /* wood1.caf in Resources */ = {isa = PBXBuildFile; fileRef = 543078A124B5E12300278F2D /* wood1.caf */; };
543078B024B5E12500278F2D /* plop2.caf in Resources */ = {isa = PBXBuildFile; fileRef = 543078A224B5E12300278F2D /* plop2.caf */; };
543078B124B5E12500278F2D /* plop2.caf in Resources */ = {isa = PBXBuildFile; fileRef = 543078A224B5E12300278F2D /* plop2.caf */; };
543078B224B5E12500278F2D /* plop1.caf in Resources */ = {isa = PBXBuildFile; fileRef = 543078A324B5E12300278F2D /* plop1.caf */; };
543078B324B5E12500278F2D /* plop1.caf in Resources */ = {isa = PBXBuildFile; fileRef = 543078A324B5E12300278F2D /* plop1.caf */; };
543078B424B5E12500278F2D /* snap1.caf in Resources */ = {isa = PBXBuildFile; fileRef = 543078A424B5E12300278F2D /* snap1.caf */; };
543078B524B5E12500278F2D /* snap1.caf in Resources */ = {isa = PBXBuildFile; fileRef = 543078A424B5E12300278F2D /* snap1.caf */; };
543078B624B5E12500278F2D /* drum1.caf in Resources */ = {isa = PBXBuildFile; fileRef = 543078A524B5E12300278F2D /* drum1.caf */; };
543078B724B5E12500278F2D /* drum1.caf in Resources */ = {isa = PBXBuildFile; fileRef = 543078A524B5E12300278F2D /* drum1.caf */; };
543078B824B5E12500278F2D /* wood2.caf in Resources */ = {isa = PBXBuildFile; fileRef = 543078A624B5E12400278F2D /* wood2.caf */; };
543078B924B5E12500278F2D /* wood2.caf in Resources */ = {isa = PBXBuildFile; fileRef = 543078A624B5E12400278F2D /* wood2.caf */; };
543078BA24B5E12500278F2D /* typewriter1.caf in Resources */ = {isa = PBXBuildFile; fileRef = 543078A724B5E12400278F2D /* typewriter1.caf */; };
543078BB24B5E12500278F2D /* typewriter1.caf in Resources */ = {isa = PBXBuildFile; fileRef = 543078A724B5E12400278F2D /* typewriter1.caf */; };
543078BC24B5E12500278F2D /* clock.caf in Resources */ = {isa = PBXBuildFile; fileRef = 543078A824B5E12400278F2D /* clock.caf */; };
543078BD24B5E12500278F2D /* clock.caf in Resources */ = {isa = PBXBuildFile; fileRef = 543078A824B5E12400278F2D /* clock.caf */; };
543078BE24B5E12500278F2D /* drum2.caf in Resources */ = {isa = PBXBuildFile; fileRef = 543078A924B5E12500278F2D /* drum2.caf */; };
543078BF24B5E12500278F2D /* drum2.caf in Resources */ = {isa = PBXBuildFile; fileRef = 543078A924B5E12500278F2D /* drum2.caf */; };
543078C324B60F3B00278F2D /* Settings.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 543078C124B60F3B00278F2D /* Settings.storyboard */; };
543078C924B75CEA00278F2D /* PushNotificationAppOnly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 543078C824B75CD100278F2D /* PushNotificationAppOnly.swift */; };
543CDB2023EEE61900B7F323 /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 543CDB1F23EEE61900B7F323 /* PacketTunnelProvider.swift */; };
543CDB2523EEE61900B7F323 /* GlassVPN.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 543CDB1D23EEE61900B7F323 /* GlassVPN.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
54448A2E2486464F00771C96 /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54448A2D2486464F00771C96 /* Array.swift */; };
54448A30248647D900771C96 /* Time.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54448A2F248647D900771C96 /* Time.swift */; };
54448A3224899A4000771C96 /* SearchBarManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54448A3124899A4000771C96 /* SearchBarManager.swift */; };
544C95262407B1C700AB89D0 /* SharedState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 544C95252407B1C700AB89D0 /* SharedState.swift */; };
544F912024A67EC5001D4B00 /* TVCOccurrenceContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 544F911F24A67EC5001D4B00 /* TVCOccurrenceContext.swift */; };
5458EBC0243A3F2200CFEB15 /* TVCRecordingDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5458EBBF243A3F2200CFEB15 /* TVCRecordingDetails.swift */; };
545DDDCF243E6267003B6544 /* TutorialSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 545DDDCE243E6267003B6544 /* TutorialSheet.swift */; };
545DDDD124436983003B6544 /* QuickUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 545DDDD024436983003B6544 /* QuickUI.swift */; };
@@ -36,10 +77,11 @@
54953E6123E0D69A0054345C /* TVCHosts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54953E6023E0D69A0054345C /* TVCHosts.swift */; };
54953E6F23E44CD00054345C /* TVCHostDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54953E6E23E44CD00054345C /* TVCHostDetails.swift */; };
54953E7123E473F10054345C /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = 54953E7023E473F10054345C /* Settings.bundle */; };
549ECD9D24A7AD550097571C /* CustomAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 549ECD9C24A7AD550097571C /* CustomAlert.swift */; };
54B34594240E6343004C53CC /* TVCFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B34593240E6343004C53CC /* TVCFilter.swift */; };
54B34596240F0513004C53CC /* TableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B34595240F0513004C53CC /* TableView.swift */; };
54B345A6241BB982004C53CC /* Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B345A5241BB982004C53CC /* Notifications.swift */; };
54B345A9241BBA0B004C53CC /* Generic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B345A8241BBA0B004C53CC /* Generic.swift */; };
54B345A9241BBA0B004C53CC /* Logging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B345A8241BBA0B004C53CC /* Logging.swift */; };
54B345AB241BBA5B004C53CC /* AlertSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B345AA241BBA5B004C53CC /* AlertSheet.swift */; };
54B345AD241BBB00004C53CC /* DBExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B345AC241BBB00004C53CC /* DBExtensions.swift */; };
54B345B0242264F8004C53CC /* third-level.txt in Resources */ = {isa = PBXBuildFile; fileRef = 54B345AF242264F8004C53CC /* third-level.txt */; };
@@ -125,6 +167,8 @@
54CA02BE2426D4F3003A5E04 /* DDLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54CA02BD2426D4F3003A5E04 /* DDLog.swift */; };
54CA02C32426DCCD003A5E04 /* GCDAsyncSocket.m in Sources */ = {isa = PBXBuildFile; fileRef = 54CA02BF2426DCCC003A5E04 /* GCDAsyncSocket.m */; };
54CA02C42426DCCD003A5E04 /* GCDAsyncUdpSocket.m in Sources */ = {isa = PBXBuildFile; fileRef = 54CA02C02426DCCD003A5E04 /* GCDAsyncUdpSocket.m */; };
54CE8BC424B1ED2100CC1756 /* PushNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54CE8BC324B1ED2100CC1756 /* PushNotification.swift */; };
54CE8BC524B1ED2100CC1756 /* PushNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54CE8BC324B1ED2100CC1756 /* PushNotification.swift */; };
54D8B97A246C9F2000EB2414 /* FilterPipeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D8B979246C9F2000EB2414 /* FilterPipeline.swift */; };
54D8B97C2471A7E000EB2414 /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D8B97B2471A7E000EB2414 /* String.swift */; };
54D8B97E2471B88900EB2414 /* DBCommon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D8B97D2471B88900EB2414 /* DBCommon.swift */; };
@@ -132,9 +176,16 @@
54D8B9832471BD8100EB2414 /* DBAppOnly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D8B9822471BD8100EB2414 /* DBAppOnly.swift */; };
54D8B98624796E9900EB2414 /* GroupedDomainDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D8B98524796E9800EB2414 /* GroupedDomainDataSource.swift */; };
54E540F2247C423200F7C34A /* DomainFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54E540F1247C423200F7C34A /* DomainFilter.swift */; };
54E540F4247D3F2600F7C34A /* TestDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54E540F3247D3F2600F7C34A /* TestDataSource.swift */; };
54E540F4247D3F2600F7C34A /* SimulatorVPN.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54E540F3247D3F2600F7C34A /* SimulatorVPN.swift */; };
54E540F8247DB90F00F7C34A /* RecordingsDB.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54E540F7247DB90F00F7C34A /* RecordingsDB.swift */; };
54E540FA2482414800F7C34A /* SyncUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54E540F92482414800F7C34A /* SyncUpdate.swift */; };
54E67E4624A8B0FE0025D261 /* PrefsShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54E67E4524A8B0FE0025D261 /* PrefsShared.swift */; };
54E67E4924A8B1280025D261 /* Prefs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54E67E4824A8B1280025D261 /* Prefs.swift */; };
54E67E4B24A8C6370025D261 /* GlassVPN.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54E67E4A24A8C6370025D261 /* GlassVPN.swift */; };
54E67E4D24A8E20D0025D261 /* TVCSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 542E2A9924051556001462DC /* TVCSettings.swift */; };
54E67E4F24A8E2910025D261 /* Equatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54E67E4E24A8E2910025D261 /* Equatable.swift */; };
54E67E5124A8E8820025D261 /* View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54E67E5024A8E8820025D261 /* View.swift */; };
54EFA4E82491A16A0022D618 /* Font.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54EFA4E72491A16A0022D618 /* Font.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -162,10 +213,19 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
5404AEEC24A95F3F003B2F54 /* SlideInAnimation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlideInAnimation.swift; sourceTree = "<group>"; };
5404AEEE24ACC089003B2F54 /* VCAnalysisBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VCAnalysisBar.swift; sourceTree = "<group>"; };
540E677F242D2CF100871BBE /* VCRecordings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VCRecordings.swift; sourceTree = "<group>"; };
540E67812433483D00871BBE /* VCEditRecording.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VCEditRecording.swift; sourceTree = "<group>"; };
540E67832433FAFE00871BBE /* TVCPreviousRecords.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVCPreviousRecords.swift; sourceTree = "<group>"; };
541075CD24C9D43A00D6F1BF /* UNNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UNNotification.swift; sourceTree = "<group>"; };
541075D024CDBA0000D6F1BF /* ThrottledBatchQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThrottledBatchQueue.swift; sourceTree = "<group>"; };
541075D424CE286200D6F1BF /* CachedConnectionAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachedConnectionAlert.swift; sourceTree = "<group>"; };
541075D824CE2C7200D6F1BF /* GlassVPNHook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlassVPNHook.swift; sourceTree = "<group>"; };
5412F8ED24571B8100A63D7A /* VCDateFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VCDateFilter.swift; sourceTree = "<group>"; };
5412FCBF24C628F9000DE429 /* TVCReminderAlerts.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TVCReminderAlerts.swift; sourceTree = "<group>"; };
5412FCC024C628F9000DE429 /* TVCChooseAlertTone.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TVCChooseAlertTone.swift; sourceTree = "<group>"; };
5412FCC124C628FA000DE429 /* TVCConnectionAlerts.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TVCConnectionAlerts.swift; sourceTree = "<group>"; };
541A957523E602DF00C09C19 /* LaunchIcon.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = LaunchIcon.png; sourceTree = "<group>"; };
541AC5D42399498A00A769D7 /* AppCheck.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AppCheck.app; sourceTree = BUILT_PRODUCTS_DIR; };
541AC5D72399498A00A769D7 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
@@ -173,8 +233,25 @@
541AC5DE2399498B00A769D7 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
541AC5E12399498B00A769D7 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
541AC5E32399498B00A769D7 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
541DCA6024A6B0F6005F1A4B /* Color.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Color.swift; sourceTree = "<group>"; };
541FC47524A12D01009154D8 /* IBViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IBViews.swift; sourceTree = "<group>"; };
541FC47724A1453F009154D8 /* VCCoOccurrence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VCCoOccurrence.swift; sourceTree = "<group>"; };
541FC9862497D81C00962623 /* TheGreatDestroyer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TheGreatDestroyer.swift; sourceTree = "<group>"; };
542E2A972404973F001462DC /* TBCMain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBCMain.swift; sourceTree = "<group>"; };
542E2A9924051556001462DC /* TVCSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVCSettings.swift; sourceTree = "<group>"; };
5430789F24B5E12200278F2D /* snap2.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = snap2.caf; sourceTree = "<group>"; };
543078A024B5E12200278F2D /* typewriter2.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = typewriter2.caf; sourceTree = "<group>"; };
543078A124B5E12300278F2D /* wood1.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = wood1.caf; sourceTree = "<group>"; };
543078A224B5E12300278F2D /* plop2.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = plop2.caf; sourceTree = "<group>"; };
543078A324B5E12300278F2D /* plop1.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = plop1.caf; sourceTree = "<group>"; };
543078A424B5E12300278F2D /* snap1.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = snap1.caf; sourceTree = "<group>"; };
543078A524B5E12300278F2D /* drum1.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = drum1.caf; sourceTree = "<group>"; };
543078A624B5E12400278F2D /* wood2.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = wood2.caf; sourceTree = "<group>"; };
543078A724B5E12400278F2D /* typewriter1.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = typewriter1.caf; sourceTree = "<group>"; };
543078A824B5E12400278F2D /* clock.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = clock.caf; sourceTree = "<group>"; };
543078A924B5E12500278F2D /* drum2.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = drum2.caf; sourceTree = "<group>"; };
543078C224B60F3B00278F2D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Settings.storyboard; sourceTree = "<group>"; };
543078C824B75CD100278F2D /* PushNotificationAppOnly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationAppOnly.swift; sourceTree = "<group>"; };
543CDB1D23EEE61900B7F323 /* GlassVPN.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = GlassVPN.appex; sourceTree = BUILT_PRODUCTS_DIR; };
543CDB1F23EEE61900B7F323 /* PacketTunnelProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketTunnelProvider.swift; sourceTree = "<group>"; };
543CDB2123EEE61900B7F323 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
@@ -182,7 +259,7 @@
54448A2D2486464F00771C96 /* Array.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Array.swift; sourceTree = "<group>"; };
54448A2F248647D900771C96 /* Time.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Time.swift; sourceTree = "<group>"; };
54448A3124899A4000771C96 /* SearchBarManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBarManager.swift; sourceTree = "<group>"; };
544C95252407B1C700AB89D0 /* SharedState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedState.swift; sourceTree = "<group>"; };
544F911F24A67EC5001D4B00 /* TVCOccurrenceContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVCOccurrenceContext.swift; sourceTree = "<group>"; };
5458EBBF243A3F2200CFEB15 /* TVCRecordingDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVCRecordingDetails.swift; sourceTree = "<group>"; };
545DDDCE243E6267003B6544 /* TutorialSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialSheet.swift; sourceTree = "<group>"; };
545DDDD024436983003B6544 /* QuickUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickUI.swift; sourceTree = "<group>"; };
@@ -193,10 +270,11 @@
54953E6023E0D69A0054345C /* TVCHosts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVCHosts.swift; sourceTree = "<group>"; };
54953E6E23E44CD00054345C /* TVCHostDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVCHostDetails.swift; sourceTree = "<group>"; };
54953E7023E473F10054345C /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = "<group>"; };
549ECD9C24A7AD550097571C /* CustomAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomAlert.swift; sourceTree = "<group>"; };
54B34593240E6343004C53CC /* TVCFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVCFilter.swift; sourceTree = "<group>"; };
54B34595240F0513004C53CC /* TableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableView.swift; sourceTree = "<group>"; };
54B345A5241BB982004C53CC /* Notifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notifications.swift; sourceTree = "<group>"; };
54B345A8241BBA0B004C53CC /* Generic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Generic.swift; sourceTree = "<group>"; };
54B345A8241BBA0B004C53CC /* Logging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logging.swift; sourceTree = "<group>"; };
54B345AA241BBA5B004C53CC /* AlertSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertSheet.swift; sourceTree = "<group>"; };
54B345AC241BBB00004C53CC /* DBExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBExtensions.swift; sourceTree = "<group>"; };
54B345AF242264F8004C53CC /* third-level.txt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "third-level.txt"; sourceTree = "<group>"; };
@@ -286,15 +364,22 @@
54CA02C02426DCCD003A5E04 /* GCDAsyncUdpSocket.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GCDAsyncUdpSocket.m; sourceTree = "<group>"; };
54CA02C12426DCCD003A5E04 /* GCDAsyncSocket.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = GCDAsyncSocket.h; sourceTree = "<group>"; };
54CA02C22426DCCD003A5E04 /* GCDAsyncUdpSocket.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = GCDAsyncUdpSocket.h; sourceTree = "<group>"; };
54CE8BC324B1ED2100CC1756 /* PushNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotification.swift; sourceTree = "<group>"; };
54D8B979246C9F2000EB2414 /* FilterPipeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterPipeline.swift; sourceTree = "<group>"; };
54D8B97B2471A7E000EB2414 /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = "<group>"; };
54D8B97D2471B88900EB2414 /* DBCommon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBCommon.swift; sourceTree = "<group>"; };
54D8B9822471BD8100EB2414 /* DBAppOnly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBAppOnly.swift; sourceTree = "<group>"; };
54D8B98524796E9800EB2414 /* GroupedDomainDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupedDomainDataSource.swift; sourceTree = "<group>"; };
54E540F1247C423200F7C34A /* DomainFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainFilter.swift; sourceTree = "<group>"; };
54E540F3247D3F2600F7C34A /* TestDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestDataSource.swift; sourceTree = "<group>"; };
54E540F3247D3F2600F7C34A /* SimulatorVPN.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimulatorVPN.swift; sourceTree = "<group>"; };
54E540F7247DB90F00F7C34A /* RecordingsDB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingsDB.swift; sourceTree = "<group>"; };
54E540F92482414800F7C34A /* SyncUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncUpdate.swift; sourceTree = "<group>"; };
54E67E4524A8B0FE0025D261 /* PrefsShared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefsShared.swift; sourceTree = "<group>"; };
54E67E4824A8B1280025D261 /* Prefs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Prefs.swift; sourceTree = "<group>"; };
54E67E4A24A8C6370025D261 /* GlassVPN.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlassVPN.swift; sourceTree = "<group>"; };
54E67E4E24A8E2910025D261 /* Equatable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Equatable.swift; sourceTree = "<group>"; };
54E67E5024A8E8820025D261 /* View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = View.swift; sourceTree = "<group>"; };
54EFA4E72491A16A0022D618 /* Font.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Font.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -322,6 +407,8 @@
5412F8ED24571B8100A63D7A /* VCDateFilter.swift */,
54953E6023E0D69A0054345C /* TVCHosts.swift */,
54953E6E23E44CD00054345C /* TVCHostDetails.swift */,
544F911F24A67EC5001D4B00 /* TVCOccurrenceContext.swift */,
541FC47424A12CE9009154D8 /* Analysis */,
);
path = Requests;
sourceTree = "<group>";
@@ -331,6 +418,9 @@
children = (
542E2A9924051556001462DC /* TVCSettings.swift */,
54B34593240E6343004C53CC /* TVCFilter.swift */,
5412FCBF24C628F9000DE429 /* TVCReminderAlerts.swift */,
5412FCC124C628FA000DE429 /* TVCConnectionAlerts.swift */,
5412FCC024C628F9000DE429 /* TVCChooseAlertTone.swift */,
);
path = Settings;
sourceTree = "<group>";
@@ -346,6 +436,17 @@
path = Recordings;
sourceTree = "<group>";
};
541075D324CE284700D6F1BF /* Push Notifications */ = {
isa = PBXGroup;
children = (
541075CD24C9D43A00D6F1BF /* UNNotification.swift */,
54CE8BC324B1ED2100CC1756 /* PushNotification.swift */,
543078C824B75CD100278F2D /* PushNotificationAppOnly.swift */,
541075D424CE286200D6F1BF /* CachedConnectionAlert.swift */,
);
path = "Push Notifications";
sourceTree = "<group>";
};
541AC5CB2399498A00A769D7 = {
isa = PBXGroup;
children = (
@@ -372,8 +473,11 @@
54E540F0247C386500F7C34A /* Data Source */,
54B345A4241BB975004C53CC /* Extensions */,
545DDDD224436A03003B6544 /* Common Classes */,
541075D324CE284700D6F1BF /* Push Notifications */,
548B1F9423D338EC005B047C /* main.entitlements */,
541AC5D72399498A00A769D7 /* AppDelegate.swift */,
54E67E4A24A8C6370025D261 /* GlassVPN.swift */,
541075D824CE2C7200D6F1BF /* GlassVPNHook.swift */,
542E2A972404973F001462DC /* TBCMain.swift */,
540C6454240D5BAE00E948F9 /* Requests */,
540E677E242D2CD200871BBE /* Recordings */,
@@ -381,6 +485,7 @@
54B345B12422E029004C53CC /* unused */,
541AC5E02399498B00A769D7 /* LaunchScreen.storyboard */,
541AC5DB2399498A00A769D7 /* Main.storyboard */,
543078C124B60F3B00278F2D /* Settings.storyboard */,
541AC5DE2399498B00A769D7 /* Assets.xcassets */,
541AC5E32399498B00A769D7 /* Info.plist */,
54953E7023E473F10054345C /* Settings.bundle */,
@@ -388,15 +493,43 @@
path = main;
sourceTree = "<group>";
};
541FC47424A12CE9009154D8 /* Analysis */ = {
isa = PBXGroup;
children = (
5404AEEE24ACC089003B2F54 /* VCAnalysisBar.swift */,
541FC47724A1453F009154D8 /* VCCoOccurrence.swift */,
);
path = Analysis;
sourceTree = "<group>";
};
542E2A9B24051F79001462DC /* media */ = {
isa = PBXGroup;
children = (
5430789E24B5E10E00278F2D /* sounds */,
541A957523E602DF00C09C19 /* LaunchIcon.png */,
54B345AF242264F8004C53CC /* third-level.txt */,
);
path = media;
sourceTree = "<group>";
};
5430789E24B5E10E00278F2D /* sounds */ = {
isa = PBXGroup;
children = (
543078A824B5E12400278F2D /* clock.caf */,
543078A524B5E12300278F2D /* drum1.caf */,
543078A924B5E12500278F2D /* drum2.caf */,
543078A324B5E12300278F2D /* plop1.caf */,
543078A224B5E12300278F2D /* plop2.caf */,
543078A424B5E12300278F2D /* snap1.caf */,
5430789F24B5E12200278F2D /* snap2.caf */,
543078A724B5E12400278F2D /* typewriter1.caf */,
543078A024B5E12200278F2D /* typewriter2.caf */,
543078A124B5E12300278F2D /* wood1.caf */,
543078A624B5E12400278F2D /* wood2.caf */,
);
path = sounds;
sourceTree = "<group>";
};
543CDB1E23EEE61900B7F323 /* GlassVPN */ = {
isa = PBXGroup;
children = (
@@ -414,10 +547,16 @@
545DDDD224436A03003B6544 /* Common Classes */ = {
isa = PBXGroup;
children = (
54E67E4824A8B1280025D261 /* Prefs.swift */,
54E67E4524A8B0FE0025D261 /* PrefsShared.swift */,
545DDDD024436983003B6544 /* QuickUI.swift */,
545DDDCE243E6267003B6544 /* TutorialSheet.swift */,
54D8B979246C9F2000EB2414 /* FilterPipeline.swift */,
54448A3124899A4000771C96 /* SearchBarManager.swift */,
549ECD9C24A7AD550097571C /* CustomAlert.swift */,
541FC47524A12D01009154D8 /* IBViews.swift */,
5404AEEC24A95F3F003B2F54 /* SlideInAnimation.swift */,
541075D024CDBA0000D6F1BF /* ThrottledBatchQueue.swift */,
);
path = "Common Classes";
sourceTree = "<group>";
@@ -429,6 +568,7 @@
54D8B97D2471B88900EB2414 /* DBCommon.swift */,
54D8B9822471BD8100EB2414 /* DBAppOnly.swift */,
54B345AC241BBB00004C53CC /* DBExtensions.swift */,
541FC9862497D81C00962623 /* TheGreatDestroyer.swift */,
);
path = DB;
sourceTree = "<group>";
@@ -436,12 +576,15 @@
54B345A4241BB975004C53CC /* Extensions */ = {
isa = PBXGroup;
children = (
544C95252407B1C700AB89D0 /* SharedState.swift */,
54B345A8241BBA0B004C53CC /* Generic.swift */,
54B345A8241BBA0B004C53CC /* Logging.swift */,
54E67E4E24A8E2910025D261 /* Equatable.swift */,
54B345A5241BB982004C53CC /* Notifications.swift */,
54B345AA241BBA5B004C53CC /* AlertSheet.swift */,
54E67E5024A8E8820025D261 /* View.swift */,
541DCA6024A6B0F6005F1A4B /* Color.swift */,
54448A2F248647D900771C96 /* Time.swift */,
54751E502423955000168273 /* URL.swift */,
54EFA4E72491A16A0022D618 /* Font.swift */,
54448A2D2486464F00771C96 /* Array.swift */,
54D8B97B2471A7E000EB2414 /* String.swift */,
54B34595240F0513004C53CC /* TableView.swift */,
@@ -685,7 +828,7 @@
54E540F0247C386500F7C34A /* Data Source */ = {
isa = PBXGroup;
children = (
54E540F3247D3F2600F7C34A /* TestDataSource.swift */,
54E540F3247D3F2600F7C34A /* SimulatorVPN.swift */,
54E540F92482414800F7C34A /* SyncUpdate.swift */,
54D8B98524796E9800EB2414 /* GroupedDomainDataSource.swift */,
54E540F1247C423200F7C34A /* DomainFilter.swift */,
@@ -788,11 +931,23 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
543078AC24B5E12500278F2D /* typewriter2.caf in Resources */,
54953E7123E473F10054345C /* Settings.bundle in Resources */,
543078B024B5E12500278F2D /* plop2.caf in Resources */,
541AC5E22399498B00A769D7 /* LaunchScreen.storyboard in Resources */,
543078B824B5E12500278F2D /* wood2.caf in Resources */,
543078BE24B5E12500278F2D /* drum2.caf in Resources */,
543078B424B5E12500278F2D /* snap1.caf in Resources */,
541AC5DF2399498B00A769D7 /* Assets.xcassets in Resources */,
543078AE24B5E12500278F2D /* wood1.caf in Resources */,
541AC5DD2399498A00A769D7 /* Main.storyboard in Resources */,
54B345B0242264F8004C53CC /* third-level.txt in Resources */,
543078BA24B5E12500278F2D /* typewriter1.caf in Resources */,
543078B224B5E12500278F2D /* plop1.caf in Resources */,
543078B624B5E12500278F2D /* drum1.caf in Resources */,
543078BC24B5E12500278F2D /* clock.caf in Resources */,
543078C324B60F3B00278F2D /* Settings.storyboard in Resources */,
543078AA24B5E12500278F2D /* snap2.caf in Resources */,
541A957623E602DF00C09C19 /* LaunchIcon.png in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -801,6 +956,17 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
543078AD24B5E12500278F2D /* typewriter2.caf in Resources */,
543078BB24B5E12500278F2D /* typewriter1.caf in Resources */,
543078BF24B5E12500278F2D /* drum2.caf in Resources */,
543078AF24B5E12500278F2D /* wood1.caf in Resources */,
543078B124B5E12500278F2D /* plop2.caf in Resources */,
543078AB24B5E12500278F2D /* snap2.caf in Resources */,
543078B924B5E12500278F2D /* wood2.caf in Resources */,
543078B724B5E12500278F2D /* drum1.caf in Resources */,
543078B524B5E12500278F2D /* snap1.caf in Resources */,
543078B324B5E12500278F2D /* plop1.caf in Resources */,
543078BD24B5E12500278F2D /* clock.caf in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -811,43 +977,65 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
54E67E4924A8B1280025D261 /* Prefs.swift in Sources */,
54E540F8247DB90F00F7C34A /* RecordingsDB.swift in Sources */,
54E540F4247D3F2600F7C34A /* TestDataSource.swift in Sources */,
54E67E4F24A8E2910025D261 /* Equatable.swift in Sources */,
54E540F4247D3F2600F7C34A /* SimulatorVPN.swift in Sources */,
541075D924CE2C7200D6F1BF /* GlassVPNHook.swift in Sources */,
541075D524CE286200D6F1BF /* CachedConnectionAlert.swift in Sources */,
5404AEEF24ACC089003B2F54 /* VCAnalysisBar.swift in Sources */,
545DDDD424466D37003B6544 /* AutoLayout.swift in Sources */,
54B345AD241BBB00004C53CC /* DBExtensions.swift in Sources */,
54E540F2247C423200F7C34A /* DomainFilter.swift in Sources */,
54D8B97E2471B88900EB2414 /* DBCommon.swift in Sources */,
54D8B9832471BD8100EB2414 /* DBAppOnly.swift in Sources */,
5412FCC224C628FA000DE429 /* TVCReminderAlerts.swift in Sources */,
54E67E4D24A8E20D0025D261 /* TVCSettings.swift in Sources */,
540E67842433FAFE00871BBE /* TVCPreviousRecords.swift in Sources */,
54B345A6241BB982004C53CC /* Notifications.swift in Sources */,
54448A2E2486464F00771C96 /* Array.swift in Sources */,
54E67E4B24A8C6370025D261 /* GlassVPN.swift in Sources */,
541FC47824A1453F009154D8 /* VCCoOccurrence.swift in Sources */,
54B345AB241BBA5B004C53CC /* AlertSheet.swift in Sources */,
544C95262407B1C700AB89D0 /* SharedState.swift in Sources */,
541DCA6124A6B0F6005F1A4B /* Color.swift in Sources */,
545DDDCF243E6267003B6544 /* TutorialSheet.swift in Sources */,
54B345A9241BBA0B004C53CC /* Generic.swift in Sources */,
54B345A9241BBA0B004C53CC /* Logging.swift in Sources */,
54B34596240F0513004C53CC /* TableView.swift in Sources */,
540E6780242D2CF100871BBE /* VCRecordings.swift in Sources */,
541075D124CDBA0000D6F1BF /* ThrottledBatchQueue.swift in Sources */,
54953E3323DC752E0054345C /* DBCore.swift in Sources */,
54E67E4624A8B0FE0025D261 /* PrefsShared.swift in Sources */,
5412FCC324C628FA000DE429 /* TVCChooseAlertTone.swift in Sources */,
544F912024A67EC5001D4B00 /* TVCOccurrenceContext.swift in Sources */,
54448A30248647D900771C96 /* Time.swift in Sources */,
54953E6123E0D69A0054345C /* TVCHosts.swift in Sources */,
54751E512423955100168273 /* URL.swift in Sources */,
542E2A9A24051556001462DC /* TVCSettings.swift in Sources */,
54953E5F23DEBE840054345C /* TVCDomains.swift in Sources */,
54C056DB23E9E36E00214A3F /* AppInfoType.swift in Sources */,
54D8B98624796E9900EB2414 /* GroupedDomainDataSource.swift in Sources */,
541075CE24C9D43A00D6F1BF /* UNNotification.swift in Sources */,
54953E6F23E44CD00054345C /* TVCHostDetails.swift in Sources */,
54D8B97C2471A7E000EB2414 /* String.swift in Sources */,
54E67E5124A8E8820025D261 /* View.swift in Sources */,
54C056DD23E9EEF700214A3F /* BundleIcon.swift in Sources */,
542E2A982404973F001462DC /* TBCMain.swift in Sources */,
54448A3224899A4000771C96 /* SearchBarManager.swift in Sources */,
5458EBC0243A3F2200CFEB15 /* TVCRecordingDetails.swift in Sources */,
541FC9872497D81C00962623 /* TheGreatDestroyer.swift in Sources */,
5412F8EE24571B8200A63D7A /* VCDateFilter.swift in Sources */,
5412FCC424C628FA000DE429 /* TVCConnectionAlerts.swift in Sources */,
543078C924B75CEA00278F2D /* PushNotificationAppOnly.swift in Sources */,
545DDDD124436983003B6544 /* QuickUI.swift in Sources */,
541AC5D82399498A00A769D7 /* AppDelegate.swift in Sources */,
541FC47624A12D01009154D8 /* IBViews.swift in Sources */,
540E67822433483D00871BBE /* VCEditRecording.swift in Sources */,
54EFA4E82491A16A0022D618 /* Font.swift in Sources */,
54D8B97A246C9F2000EB2414 /* FilterPipeline.swift in Sources */,
54B34594240E6343004C53CC /* TVCFilter.swift in Sources */,
549ECD9D24A7AD550097571C /* CustomAlert.swift in Sources */,
54CE8BC424B1ED2100CC1756 /* PushNotification.swift in Sources */,
54E540FA2482414800F7C34A /* SyncUpdate.swift in Sources */,
5404AEED24A95F3F003B2F54 /* SlideInAnimation.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -865,6 +1053,7 @@
54CA02722426B2FD003A5E04 /* IPInterval.swift in Sources */,
54CA029A2426B2FD003A5E04 /* Observer.swift in Sources */,
54CA025C2426B2FD003A5E04 /* ConnectSession.swift in Sources */,
5404AEEB24A90717003B2F54 /* PrefsShared.swift in Sources */,
54CA02C42426DCCD003A5E04 /* GCDAsyncUdpSocket.m in Sources */,
54CA02BA2426B2FD003A5E04 /* ProxySocket.swift in Sources */,
54CA025E2426B2FD003A5E04 /* ResponseGeneratorFactory.swift in Sources */,
@@ -888,6 +1077,7 @@
54CA02A82426B2FD003A5E04 /* SOCKS5Adapter.swift in Sources */,
54CA02792426B2FD003A5E04 /* Checksum.swift in Sources */,
54CA02AD2426B2FD003A5E04 /* RejectAdapterFactory.swift in Sources */,
541075D224CDBA0000D6F1BF /* ThrottledBatchQueue.swift in Sources */,
54CA02672426B2FD003A5E04 /* RawTCPSocketProtocol.swift in Sources */,
54CA02602426B2FD003A5E04 /* GCDProxyServer.swift in Sources */,
54CA026B2426B2FD003A5E04 /* GCDTCPSocket.swift in Sources */,
@@ -898,16 +1088,19 @@
54CA02B02426B2FD003A5E04 /* SecureHTTPAdapterFactory.swift in Sources */,
54CA02A62426B2FD003A5E04 /* AdapterSocket.swift in Sources */,
54CA02742426B2FD003A5E04 /* IPMask.swift in Sources */,
541075CF24C9D43A00D6F1BF /* UNNotification.swift in Sources */,
54CA02BB2426B2FD003A5E04 /* SOCKS5ProxySocket.swift in Sources */,
54D8B97F2471B89100EB2414 /* DBCommon.swift in Sources */,
54CA02A42426B2FD003A5E04 /* SecureHTTPAdapter.swift in Sources */,
54CA02942426B2FD003A5E04 /* DNSResolver.swift in Sources */,
54CA025F2426B2FD003A5E04 /* ProxyServer.swift in Sources */,
54CA02842426B2FD003A5E04 /* Rule.swift in Sources */,
54CE8BC524B1ED2100CC1756 /* PushNotification.swift in Sources */,
54CA02B92426B2FD003A5E04 /* DirectProxySocket.swift in Sources */,
54751E522423955100168273 /* URL.swift in Sources */,
54CA02A92426B2FD003A5E04 /* RejectAdapter.swift in Sources */,
54CA02732426B2FD003A5E04 /* IPPool.swift in Sources */,
541075D624CE286200D6F1BF /* CachedConnectionAlert.swift in Sources */,
54CA027E2426B2FD003A5E04 /* DomainListRule.swift in Sources */,
54CA02782426B2FD003A5E04 /* BinaryDataScanner.swift in Sources */,
54CA02B12426B2FD003A5E04 /* ServerAdapterFactory.swift in Sources */,
@@ -938,6 +1131,7 @@
54CA02B32426B2FD003A5E04 /* HTTPAdapterFactory.swift in Sources */,
54CA02702426B2FD003A5E04 /* HTTPStreamScanner.swift in Sources */,
54CA02812426B2FD003A5E04 /* DNSFailRule.swift in Sources */,
541075DA24CE2C7200D6F1BF /* GlassVPNHook.swift in Sources */,
54CA02AC2426B2FD003A5E04 /* AuthenticationServerAdapterFactory.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -969,6 +1163,14 @@
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
543078C124B60F3B00278F2D /* Settings.storyboard */ = {
isa = PBXVariantGroup;
children = (
543078C224B60F3B00278F2D /* Base */,
);
name = Settings.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
@@ -1099,7 +1301,7 @@
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = main/main.entitlements;
CURRENT_PROJECT_VERSION = 19;
CURRENT_PROJECT_VERSION = 26;
INFOPLIST_FILE = main/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -1118,7 +1320,7 @@
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = main/main.entitlements;
CURRENT_PROJECT_VERSION = 19;
CURRENT_PROJECT_VERSION = 26;
INFOPLIST_FILE = main/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -1137,7 +1339,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = GlassVPN/GlassVPN.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 19;
CURRENT_PROJECT_VERSION = 26;
INFOPLIST_FILE = GlassVPN/Info.plist;
MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = "de.uni-bamberg.psi.AppCheck.VPN";
@@ -1155,7 +1357,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = GlassVPN/GlassVPN.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 19;
CURRENT_PROJECT_VERSION = 26;
INFOPLIST_FILE = GlassVPN/Info.plist;
MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = "de.uni-bamberg.psi.AppCheck.VPN";

View File

@@ -1,40 +1,6 @@
import NetworkExtension
fileprivate var db: SQLiteDatabase!
fileprivate var pStmt: OpaquePointer!
fileprivate var filterDomains: [String]!
fileprivate var filterOptions: [(block: Bool, ignore: Bool)]!
// MARK: Backward DNS Binary Tree Lookup
fileprivate func reloadDomainFilter() {
let tmp = db.loadFilters()?.map({
(String($0.reversed()), $1)
}).sorted(by: { $0.0 < $1.0 }) ?? []
filterDomains = tmp.map { $0.0 }
filterOptions = tmp.map { ($1.contains(.blocked), $1.contains(.ignored)) }
}
fileprivate func filterIndex(for domain: String) -> Int {
let reverseDomain = String(domain.reversed())
var lo = 0, hi = filterDomains.count - 1
while lo <= hi {
let mid = (lo + hi)/2
if filterDomains[mid] < reverseDomain {
lo = mid + 1
} else if reverseDomain < filterDomains[mid] {
hi = mid - 1
} else {
return mid
}
}
if lo > 0, reverseDomain.hasPrefix(filterDomains[lo - 1] + ".") {
return lo - 1
}
return -1
}
fileprivate var hook : GlassVPNHook!
// MARK: ObserverFactory
@@ -49,15 +15,8 @@ class LDObserverFactory: ObserverFactory {
override func signal(_ event: ProxySocketEvent) {
switch event {
case .receivedRequest(let session, let socket):
let i = filterIndex(for: session.host)
if i >= 0 {
let (block, ignore) = filterOptions[i]
if !ignore { try? db.logWrite(pStmt, session.host, blocked: block) }
if block { socket.forceDisconnect() }
} else {
// TODO: disable filter during recordings
try? db.logWrite(pStmt, session.host)
}
let kill = hook.processDNSRequest(session.host)
if kill { socket.forceDisconnect() }
default:
break
}
@@ -70,27 +29,63 @@ class LDObserverFactory: ObserverFactory {
class PacketTunnelProvider: NEPacketTunnelProvider {
let proxyServerPort: UInt16 = 9090
let proxyServerAddress = "127.0.0.1"
var proxyServer: GCDHTTPProxyServer!
private let proxyServerPort: UInt16 = 9090
private let proxyServerAddress = "127.0.0.1"
private var proxyServer: GCDHTTPProxyServer!
// MARK: Delegate
override func startTunnel(options: [String : NSObject]?, completionHandler: @escaping (Error?) -> Void) {
DDLogVerbose("startTunnel with with options: \(String(describing: options))")
PrefsShared.registerDefaults()
do {
db = try SQLiteDatabase.open()
db.initCommonScheme()
pStmt = try db.logWritePrepare()
try SQLiteDatabase.open().initCommonScheme()
} catch {
completionHandler(error)
completionHandler(error) // if we cant open db, fail immediately
return
}
reloadDomainFilter()
if proxyServer != nil {
proxyServer.stop()
}
// stop previous if any
if proxyServer != nil { proxyServer.stop() }
proxyServer = nil
// Create proxy
willInitProxy()
self.setTunnelNetworkSettings(createProxy()) { error in
guard error == nil else {
DDLogError("setTunnelNetworkSettings error: \(error!)")
completionHandler(error)
return
}
self.proxyServer = GCDHTTPProxyServer(address: IPAddress(fromString: self.proxyServerAddress), port: Port(port: self.proxyServerPort))
do {
try self.proxyServer.start()
self.didInitProxy()
completionHandler(nil)
} catch let proxyError {
DDLogError("Error starting proxy server \(proxyError)")
completionHandler(proxyError)
}
}
}
override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) {
DDLogVerbose("stopTunnel with reason: \(reason)")
shutdown()
completionHandler()
exit(EXIT_SUCCESS)
}
override func handleAppMessage(_ messageData: Data, completionHandler: ((Data?) -> Void)?) {
hook.handleAppMessage(messageData)
}
// MARK: Helper
private func willInitProxy() {
hook = GlassVPNHook()
}
private func createProxy() -> NEPacketTunnelNetworkSettings {
let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: proxyServerAddress)
settings.mtu = NSNumber(value: 1500)
@@ -107,45 +102,29 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
settings.proxySettings = proxySettings;
RawSocketFactory.TunnelProvider = self
ObserverFactory.currentFactory = LDObserverFactory()
self.setTunnelNetworkSettings(settings) { error in
guard error == nil else {
DDLogError("setTunnelNetworkSettings error: \(String(describing: error))")
completionHandler(error)
return
}
completionHandler(nil)
self.proxyServer = GCDHTTPProxyServer(address: IPAddress(fromString: self.proxyServerAddress), port: Port(port: self.proxyServerPort))
do {
try self.proxyServer.start()
completionHandler(nil)
}
catch let proxyError {
DDLogError("Error starting proxy server \(proxyError)")
completionHandler(proxyError)
}
return settings
}
private func didInitProxy() {
if PrefsShared.RestartReminder.Enabled {
PushNotification.scheduleRestartReminderBadge(on: false)
PushNotification.cancel(.CantStopMeNowReminder)
}
}
override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) {
DDLogVerbose("stopTunnel with reason: \(reason)")
private func shutdown() {
// proxy
DNSServer.currentServer = nil
RawSocketFactory.TunnelProvider = nil
ObserverFactory.currentFactory = nil
proxyServer.stop()
proxyServer = nil
db.prepared(finalize: pStmt)
pStmt = nil
db = nil
filterDomains = nil
filterOptions = nil
completionHandler()
exit(EXIT_SUCCESS)
}
override func handleAppMessage(_ messageData: Data, completionHandler: ((Data?) -> Void)?) {
reloadDomainFilter()
// custom
hook.cleanUp()
hook = nil
if PrefsShared.RestartReminder.Enabled {
PushNotification.scheduleRestartReminderBadge(on: true)
PushNotification.scheduleRestartReminderBanner()
}
}
}

View File

@@ -141,7 +141,14 @@ public class NWTCPSocket: NSObject, RawTCPSocketProtocol {
connection!.readMinimumLength(1, maximumLength: Opt.MAXNWTCPSocketReadDataSize) { data, error in
guard error == nil else {
DDLogError("NWTCPSocket got an error when reading data: \(String(describing: error))")
let e = error! as NSError
let ignore = (
e.domain == "kNWErrorDomainPOSIX" && e.code == POSIXError.ECANCELED.rawValue // Operation canceled
|| e.domain == NSPOSIXErrorDomain && e.code == POSIXError.ENOTCONN.rawValue // Socket is not connected
)
if !ignore {
DDLogError("NWTCPSocket got an error when reading data: \(String(describing: error))")
}
self.queueCall {
self.disconnect()
}

View File

@@ -16,6 +16,8 @@ Your data belongs to you.
Therefore, monitoring and analysis take place on your device only.
The app does not share any data with us or any other third-party unless you choose to.
Join [Testflight beta](https://testflight.apple.com/join/9jjaFeHO)
### How does it work?
@@ -31,19 +33,23 @@ That means, AppCheck does not have to be active in the foreground all the time.
- See history of previous connections
- Block unwanted traffic based on domain names
- Record app specific activity<sup>1</sup>
- Apply logging filters
- Apply logging filters (block or ignore) and display filters (specific range or last x minutes)
- Sort results by time, name, or occurrence count
- Context Analysis
- What other domains occur often at the same time?
- What happened immediately before or after the action?
- Export results for custom analysis
**… and soon:**
- Alert Monitor & reminder
- Occurrence Context Analysis
- Participate in privacy research
<sup>1</sup> Due to technical limitations, recording is not limited to any single application. Remember to force-quit all other applications before starting a recording.
<sup>1</sup> Due to technical limitations, recordings can not be restricted to a single application. Remember to force-quit all other applications before starting a recording.
## Research Project
*information will be added soon*
*information will be added soon*

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 158 KiB

View File

@@ -1,13 +1,9 @@
import UIKit
import NetworkExtension
let VPNConfigBundleIdentifier = "de.uni-bamberg.psi.AppCheck.VPN"
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
var managerVPN: NETunnelProviderManager?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
if UserDefaults.standard.bool(forKey: "kill_db") {
@@ -19,123 +15,21 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
db.initAppOnlyScheme()
}
#if IOS_SIMULATOR
TestDataSource.load()
#endif
Prefs.registerDefaults()
PrefsShared.registerDefaults()
loadVPN { mgr in
self.managerVPN = mgr
self.postVPNState()
}
NSNotification.Name.NEVPNStatusDidChange.observe(call: #selector(vpnStatusChanged(_:)), on: self)
NotifyDNSFilterChanged.observe(call: #selector(didChangeDomainFilter), on: self)
#if IOS_SIMULATOR
SimulatorVPN.load()
#endif
sync.start()
return true
}
@objc private func vpnStatusChanged(_ notification: Notification) {
postRawVPNState((notification.object as? NETunnelProviderSession)?.status ?? .invalid)
}
@objc private func didChangeDomainFilter() {
// Notify VPN extension about changes
if let session = self.managerVPN?.connection as? NETunnelProviderSession,
session.status == .connected {
try? session.sendProviderMessage("filter-update".data(using: .ascii)!, responseHandler: nil)
}
}
func setProxyEnabled(_ newState: Bool) {
guard let mgr = self.managerVPN else {
self.createNewVPN { manager in
self.managerVPN = manager
self.setProxyEnabled(newState)
}
return
}
let state = mgr.isEnabled && (mgr.connection.status == .connected)
if state != newState {
self.updateVPN({ mgr.isEnabled = true }) {
newState ? try? mgr.connection.startVPNTunnel() : mgr.connection.stopVPNTunnel()
}
}
}
// MARK: VPN
private func createNewVPN(_ success: @escaping (_ manager: NETunnelProviderManager) -> Void) {
let mgr = NETunnelProviderManager()
mgr.localizedDescription = "AppCheck Monitor"
let proto = NETunnelProviderProtocol()
proto.providerBundleIdentifier = VPNConfigBundleIdentifier
proto.serverAddress = "127.0.0.1"
mgr.protocolConfiguration = proto
mgr.isEnabled = true
mgr.saveToPreferences { error in
guard error == nil else {
self.postProcessedVPNState(.off)
//ErrorAlert(error!).presentIn(self.window?.rootViewController)
return
}
success(mgr)
}
}
private func loadVPN(_ finally: @escaping (_ manager: NETunnelProviderManager?) -> Void) {
NETunnelProviderManager.loadAllFromPreferences { managers, error in
guard let mgrs = managers, mgrs.count > 0 else {
finally(nil)
return
}
for mgr in mgrs {
if let proto = (mgr.protocolConfiguration as? NETunnelProviderProtocol) {
if proto.providerBundleIdentifier == VPNConfigBundleIdentifier {
finally(mgr)
return
}
}
}
finally(nil)
}
}
private func updateVPN(_ body: @escaping () -> Void, _ onSuccess: @escaping () -> Void) {
self.managerVPN?.loadFromPreferences { error in
guard error == nil else { return }
body()
self.managerVPN?.saveToPreferences { error in
guard error == nil else { return }
onSuccess()
}
}
}
private func postVPNState() {
guard let mgr = self.managerVPN else {
self.postRawVPNState(.invalid)
return
}
mgr.loadFromPreferences { _ in
self.postRawVPNState(mgr.connection.status)
}
}
// MARK: Notifications
private func postRawVPNState(_ origState: NEVPNStatus) {
let state: VPNState
switch origState {
case .connected: state = .on
case .connecting, .disconnecting, .reasserting: state = .inbetween
case .invalid, .disconnected: fallthrough
@unknown default: state = .off
}
postProcessedVPNState(state)
}
private func postProcessedVPNState(_ state: VPNState) {
currentVPNState = state
NotifyVPNStateChanged.post(state)
func applicationDidBecomeActive(_ application: UIApplication) {
TheGreatDestroyer.deleteLogs(olderThan: PrefsShared.AutoDeleteLogsDays)
// FIXME: Does not reflect changes performed by GlassVPN auto-delete while app is open.
// It will update whenever app restarts or becomes active again (only if deleteLogs has something to delete!)
// This is a known issue and tolerated.
}
}

BIN
main/Assets.xcassets/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -19,5 +19,8 @@
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "template"
}
}

View File

@@ -19,5 +19,8 @@
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "template"
}
}

View File

@@ -0,0 +1,26 @@
{
"images" : [
{
"filename" : "img.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "img@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "img@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "template"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 695 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,26 @@
{
"images" : [
{
"filename" : "img.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "img@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "img@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "template"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 409 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

View File

@@ -0,0 +1,26 @@
{
"images" : [
{
"filename" : "img.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "img@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "img@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "template"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 B

View File

@@ -0,0 +1,26 @@
{
"images" : [
{
"filename" : "img.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "img@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "img@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "template"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

View File

@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="16096" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="sfA-EG-18J">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="16097" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="sfA-EG-18J">
<device id="retina4_0" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
@@ -49,19 +49,19 @@
<objects>
<viewController storyboardIdentifier="domainFilter" modalTransitionStyle="crossDissolve" id="r7v-PM-PrR" customClass="VCDateFilter" customModule="AppCheck" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="QBv-5g-BTH">
<rect key="frame" x="0.0" y="0.0" width="320" height="320"/>
<rect key="frame" x="0.0" y="0.0" width="320" height="568"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<navigationBar hidden="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="jAM-LN-evh">
<rect key="frame" x="0.0" y="0.0" width="320" height="44"/>
<items>
<navigationItem title="Placeholder" id="s5o-aw-nIo">
<barButtonItem key="rightBarButtonItem" title="Item" image="filter-clear" id="oMW-R3-3Eh"/>
<barButtonItem key="leftBarButtonItem" title="Item" image="filter-clear" id="oMW-R3-3Eh"/>
</navigationItem>
</items>
</navigationBar>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="pEc-vv-7Ts">
<rect key="frame" x="78.5" y="64" width="233.5" height="217.5"/>
<rect key="frame" x="8" y="64" width="233.5" height="391.5"/>
<subviews>
<segmentedControl opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="left" contentVerticalAlignment="top" segmentControlStyle="plain" selectedSegmentIndex="0" translatesAutoresizingMaskIntoConstraints="NO" id="UNT-qn-2cg">
<rect key="frame" x="8" y="8" width="217.5" height="32"/>
@@ -70,20 +70,20 @@
<segment title="Date Range"/>
</segments>
<connections>
<action selector="didChangeSegment:" destination="r7v-PM-PrR" eventType="valueChanged" id="cxI-lR-J7y"/>
<action selector="didChangeFilterBy:" destination="r7v-PM-PrR" eventType="valueChanged" id="kM6-QE-ZGV"/>
</connections>
</segmentedControl>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Show entries no older than" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="UBq-oH-pKp">
<rect key="frame" x="10" y="55" width="213.5" height="20.5"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleHeadline"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" translatesAutoresizingMaskIntoConstraints="NO" id="gEf-Ra-RyA">
<rect key="frame" x="10" y="83.5" width="213.5" height="124"/>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="15" translatesAutoresizingMaskIntoConstraints="NO" id="gEf-Ra-RyA">
<rect key="frame" x="10" y="47" width="213.5" height="334.5"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Show entries no older than" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="UBq-oH-pKp">
<rect key="frame" x="0.0" y="0.0" width="213.5" height="20.5"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleHeadline"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="ucF-MH-iRP">
<rect key="frame" x="0.0" y="0.0" width="213.5" height="50"/>
<rect key="frame" x="0.0" y="35.5" width="213.5" height="50"/>
<subviews>
<slider opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" value="0.5" minValue="0.0" maxValue="1" translatesAutoresizingMaskIntoConstraints="NO" id="qhe-6d-hGB">
<rect key="frame" x="-2" y="0.0" width="155.5" height="51"/>
@@ -111,8 +111,14 @@
<constraint firstItem="qhe-6d-hGB" firstAttribute="top" secondItem="ucF-MH-iRP" secondAttribute="top" id="eJC-d4-zg0"/>
</constraints>
</view>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Show entries within range" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="rtf-o1-gk6">
<rect key="frame" x="0.0" y="100.5" width="213.5" height="20.5"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleHeadline"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="9As-hA-MKt">
<rect key="frame" x="0.0" y="50" width="213.5" height="74"/>
<rect key="frame" x="0.0" y="136" width="213.5" height="74"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="From:" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="wAd-o2-PHY">
<rect key="frame" x="0.0" y="6.5" width="44" height="20.5"/>
@@ -154,26 +160,59 @@
<constraint firstItem="FVD-kB-91w" firstAttribute="trailing" secondItem="9As-hA-MKt" secondAttribute="trailing" id="a6D-1D-HvF"/>
<constraint firstItem="wAd-o2-PHY" firstAttribute="leading" secondItem="fzL-94-c0l" secondAttribute="leading" id="bM0-gJ-IW5"/>
<constraint firstItem="wAd-o2-PHY" firstAttribute="centerY" secondItem="FVD-kB-91w" secondAttribute="centerY" id="g7F-LP-PQQ"/>
<constraint firstItem="IG3-Wc-UI4" firstAttribute="bottom" secondItem="9As-hA-MKt" secondAttribute="bottom" id="jlK-69-8hl"/>
<constraint firstItem="IG3-Wc-UI4" firstAttribute="bottom" secondItem="9As-hA-MKt" secondAttribute="bottom" priority="750" id="jlK-69-8hl"/>
<constraint firstItem="IG3-Wc-UI4" firstAttribute="leading" secondItem="fzL-94-c0l" secondAttribute="trailing" constant="8" symbolic="YES" id="pcE-Gv-oj7"/>
<constraint firstItem="wAd-o2-PHY" firstAttribute="leading" secondItem="9As-hA-MKt" secondAttribute="leading" id="zgR-pJ-vFs"/>
</constraints>
</view>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Order by" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="9Fe-5F-TVt">
<rect key="frame" x="0.0" y="225" width="213.5" height="20.5"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleHeadline"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="cWy-un-IHC">
<rect key="frame" x="0.0" y="260.5" width="213.5" height="74"/>
<subviews>
<segmentedControl opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="left" contentVerticalAlignment="top" segmentControlStyle="plain" selectedSegmentIndex="0" translatesAutoresizingMaskIntoConstraints="NO" id="UKE-MR-kRJ">
<rect key="frame" x="-2" y="0.0" width="217.5" height="36"/>
<segments>
<segment title="Date"/>
<segment title="Name"/>
<segment title="Count"/>
</segments>
</segmentedControl>
<segmentedControl opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="left" contentVerticalAlignment="top" segmentControlStyle="plain" selectedSegmentIndex="0" translatesAutoresizingMaskIntoConstraints="NO" id="eG2-a4-zm5">
<rect key="frame" x="-2" y="43" width="217.5" height="32"/>
<segments>
<segment title="Ascending"/>
<segment title="Descending"/>
</segments>
</segmentedControl>
</subviews>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<constraints>
<constraint firstItem="eG2-a4-zm5" firstAttribute="top" secondItem="UKE-MR-kRJ" secondAttribute="bottom" constant="8" symbolic="YES" id="6oC-bZ-XdM"/>
<constraint firstItem="eG2-a4-zm5" firstAttribute="leading" secondItem="cWy-un-IHC" secondAttribute="leading" constant="-2" id="7R0-qB-J0u"/>
<constraint firstAttribute="bottom" secondItem="eG2-a4-zm5" secondAttribute="bottom" id="JbN-vA-Rd5"/>
<constraint firstItem="UKE-MR-kRJ" firstAttribute="top" secondItem="cWy-un-IHC" secondAttribute="top" id="L21-Kf-g2d"/>
<constraint firstAttribute="trailing" secondItem="eG2-a4-zm5" secondAttribute="trailing" constant="-2" id="cbD-H9-e1Q"/>
<constraint firstItem="UKE-MR-kRJ" firstAttribute="leading" secondItem="cWy-un-IHC" secondAttribute="leading" constant="-2" id="lKB-g4-asw"/>
<constraint firstAttribute="trailing" secondItem="UKE-MR-kRJ" secondAttribute="trailing" constant="-2" id="xIa-X2-0Lp"/>
</constraints>
</view>
</subviews>
</stackView>
</subviews>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<constraints>
<constraint firstItem="UNT-qn-2cg" firstAttribute="top" secondItem="pEc-vv-7Ts" secondAttribute="top" constant="8" id="Awu-uv-9wF"/>
<constraint firstItem="UBq-oH-pKp" firstAttribute="top" secondItem="UNT-qn-2cg" secondAttribute="bottom" constant="16" id="FDO-1V-ffl"/>
<constraint firstItem="UNT-qn-2cg" firstAttribute="leading" secondItem="pEc-vv-7Ts" secondAttribute="leading" constant="8" id="Icx-YR-5bc"/>
<constraint firstItem="UBq-oH-pKp" firstAttribute="leading" secondItem="pEc-vv-7Ts" secondAttribute="leading" constant="10" id="KRb-xo-A9i"/>
<constraint firstItem="gEf-Ra-RyA" firstAttribute="top" secondItem="UBq-oH-pKp" secondAttribute="bottom" constant="8" symbolic="YES" id="QPi-aa-6ff"/>
<constraint firstItem="gEf-Ra-RyA" firstAttribute="top" secondItem="UNT-qn-2cg" secondAttribute="bottom" constant="8" symbolic="YES" id="QPi-aa-6ff"/>
<constraint firstItem="UNT-qn-2cg" firstAttribute="trailing" secondItem="pEc-vv-7Ts" secondAttribute="trailing" constant="-8" id="Sof-6L-T2D"/>
<constraint firstItem="gEf-Ra-RyA" firstAttribute="bottom" secondItem="pEc-vv-7Ts" secondAttribute="bottom" constant="-10" id="TMx-5J-z2P"/>
<constraint firstItem="gEf-Ra-RyA" firstAttribute="leading" secondItem="UBq-oH-pKp" secondAttribute="leading" id="U6l-7M-bm4"/>
<constraint firstItem="gEf-Ra-RyA" firstAttribute="trailing" secondItem="UBq-oH-pKp" secondAttribute="trailing" id="YKE-TR-fTB"/>
<constraint firstItem="UBq-oH-pKp" firstAttribute="trailing" secondItem="pEc-vv-7Ts" secondAttribute="trailing" constant="-10" id="yZd-eO-85k"/>
<constraint firstItem="gEf-Ra-RyA" firstAttribute="leading" secondItem="pEc-vv-7Ts" secondAttribute="leading" constant="10" id="U6l-7M-bm4"/>
<constraint firstItem="gEf-Ra-RyA" firstAttribute="trailing" secondItem="pEc-vv-7Ts" secondAttribute="trailing" constant="-10" id="YKE-TR-fTB"/>
</constraints>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="number" keyPath="layer.cornerRadius">
@@ -182,7 +221,7 @@
</userDefinedRuntimeAttributes>
</view>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="sAi-8j-0n1" customClass="PopupTriangle" customModule="AppCheck" customModuleProvider="target">
<rect key="frame" x="278" y="46" width="28" height="22"/>
<rect key="frame" x="14" y="46" width="28" height="22"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstAttribute="height" constant="22" id="MaD-aD-U8h"/>
@@ -195,16 +234,16 @@
</userDefinedRuntimeAttributes>
</view>
</subviews>
<color key="backgroundColor" white="0.0" alpha="0.2" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<color key="backgroundColor" white="0.0" alpha="0.20000000000000001" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<gestureRecognizers/>
<constraints>
<constraint firstItem="sAi-8j-0n1" firstAttribute="bottom" secondItem="pEc-vv-7Ts" secondAttribute="top" constant="4" id="DCq-Ps-sQo"/>
<constraint firstItem="pEc-vv-7Ts" firstAttribute="top" secondItem="jAM-LN-evh" secondAttribute="bottom" constant="20" id="EdA-nv-DEa"/>
<constraint firstItem="u0F-hK-vVD" firstAttribute="trailing" secondItem="pEc-vv-7Ts" secondAttribute="trailing" constant="8" id="Iow-GV-Lxy"/>
<constraint firstItem="pEc-vv-7Ts" firstAttribute="leading" secondItem="u0F-hK-vVD" secondAttribute="leading" constant="8" id="Iow-GV-Lxy"/>
<constraint firstItem="jAM-LN-evh" firstAttribute="trailing" secondItem="u0F-hK-vVD" secondAttribute="trailing" id="Lju-K6-G89"/>
<constraint firstItem="jAM-LN-evh" firstAttribute="top" secondItem="u0F-hK-vVD" secondAttribute="top" id="MqW-YU-POp"/>
<constraint firstItem="pEc-vv-7Ts" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="u0F-hK-vVD" secondAttribute="leading" constant="8" id="V9T-2Y-oNy"/>
<constraint firstItem="sAi-8j-0n1" firstAttribute="trailing" secondItem="pEc-vv-7Ts" secondAttribute="trailing" constant="-6" id="cXH-3c-s6t"/>
<constraint firstItem="u0F-hK-vVD" firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="pEc-vv-7Ts" secondAttribute="trailing" constant="8" id="V9T-2Y-oNy"/>
<constraint firstItem="sAi-8j-0n1" firstAttribute="leading" secondItem="pEc-vv-7Ts" secondAttribute="leading" constant="6" id="cXH-3c-s6t"/>
<constraint firstItem="jAM-LN-evh" firstAttribute="leading" secondItem="u0F-hK-vVD" secondAttribute="leading" id="ula-eW-vAq"/>
</constraints>
<viewLayoutGuide key="safeArea" id="u0F-hK-vVD"/>
@@ -213,16 +252,18 @@
</connections>
</view>
<extendedEdge key="edgesForExtendedLayout" bottom="YES"/>
<size key="freeformSize" width="320" height="320"/>
<connections>
<outlet property="buttonRangeEnd" destination="IG3-Wc-UI4" id="wAd-ca-bVQ"/>
<outlet property="buttonRangeStart" destination="FVD-kB-91w" id="HbX-Vl-uBE"/>
<outlet property="durationLabel" destination="ika-su-PZQ" id="1Br-vu-xir"/>
<outlet property="durationSlider" destination="qhe-6d-hGB" id="wph-zX-WIz"/>
<outlet property="durationTitle" destination="UBq-oH-pKp" id="BEd-Lo-a2v"/>
<outlet property="durationView" destination="ucF-MH-iRP" id="TCI-Pp-drf"/>
<outlet property="filterBy" destination="UNT-qn-2cg" id="M1J-n8-LHq"/>
<outlet property="orderbyAsc" destination="eG2-a4-zm5" id="II1-hc-pyZ"/>
<outlet property="orderbyType" destination="UKE-MR-kRJ" id="fK7-dW-MLd"/>
<outlet property="rangeTitle" destination="rtf-o1-gk6" id="2DY-xP-VOg"/>
<outlet property="rangeView" destination="9As-hA-MKt" id="0Mq-Gi-nF6"/>
<outlet property="sectionTitle" destination="UBq-oH-pKp" id="JG9-aM-n6e"/>
<outlet property="segmentControl" destination="UNT-qn-2cg" id="JKs-2W-IRd"/>
</connections>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="xTS-RW-xLN" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
@@ -232,7 +273,7 @@
</connections>
</tapGestureRecognizer>
</objects>
<point key="canvasLocation" x="700" y="-1800"/>
<point key="canvasLocation" x="700" y="-1950"/>
</scene>
<!--Domains-->
<scene sceneID="MN1-aZ-cZt">
@@ -250,7 +291,7 @@
<rect key="frame" x="0.0" y="0.0" width="293" height="57.5"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Title" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="0HB-5f-eB1">
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Title" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.80000000000000004" id="0HB-5f-eB1">
<rect key="frame" x="16" y="9" width="33.5" height="20.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
@@ -277,19 +318,14 @@
</connections>
</tableView>
<navigationItem key="navigationItem" title="Domains" id="nY5-jL-QT9">
<barButtonItem key="leftBarButtonItem" systemItem="search" id="FHY-of-M4V">
<connections>
<action selector="searchButtonTapped:" destination="pdd-aM-sKl" id="HH1-6f-mcM"/>
</connections>
</barButtonItem>
<rightBarButtonItems>
<leftBarButtonItems>
<barButtonItem image="filter-clear" id="FZm-Ld-jJE">
<connections>
<action selector="filterButtonTapped:" destination="pdd-aM-sKl" id="Xyy-LF-eCF"/>
</connections>
</barButtonItem>
<barButtonItem enabled="NO" title="7 days" id="wxA-bC-1pN"/>
</rightBarButtonItems>
</leftBarButtonItems>
</navigationItem>
<connections>
<outlet property="filterButton" destination="FZm-Ld-jJE" id="g96-Q2-cYX"/>
@@ -303,14 +339,21 @@
<!--Hosts-->
<scene sceneID="ZCV-Yx-jjW">
<objects>
<tableViewController id="WcC-nb-Vf5" customClass="TVCHosts" customModule="AppCheck" customModuleProvider="target" sceneMemberID="viewController">
<tableViewController storyboardIdentifier="requestsHosts" id="WcC-nb-Vf5" customClass="TVCHosts" customModule="AppCheck" customModuleProvider="target" sceneMemberID="viewController">
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="prototypes" style="plain" separatorStyle="default" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="28" sectionFooterHeight="28" id="nRF-dc-dC2">
<rect key="frame" x="0.0" y="0.0" width="320" height="568"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<containerView key="tableHeaderView" opaque="NO" contentMode="scaleToFill" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="kh4-PQ-hy6">
<rect key="frame" x="0.0" y="0.0" width="320" height="49"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" flexibleMinY="YES"/>
<connections>
<segue destination="1ba-SA-8sT" kind="embed" id="vf1-07-AS4"/>
</connections>
</containerView>
<prototypes>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" accessoryType="disclosureIndicator" indentationWidth="10" reuseIdentifier="HostCell" textLabel="Rnk-SP-UHm" detailTextLabel="ovQ-lJ-hWJ" style="IBUITableViewCellStyleSubtitle" id="uv0-9B-Zbb">
<rect key="frame" x="0.0" y="28" width="320" height="57.5"/>
<rect key="frame" x="0.0" y="77" width="320" height="57.5"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="uv0-9B-Zbb" id="6vH-Du-gCg">
<rect key="frame" x="0.0" y="0.0" width="293" height="57.5"/>
@@ -342,13 +385,7 @@
<outlet property="delegate" destination="WcC-nb-Vf5" id="sBd-BW-Wg6"/>
</connections>
</tableView>
<navigationItem key="navigationItem" title="Hosts" prompt="com.app.Example" id="TvD-8U-F05">
<barButtonItem key="rightBarButtonItem" systemItem="search" id="cBL-dP-ig1">
<connections>
<action selector="searchButtonTapped:" destination="WcC-nb-Vf5" id="QFl-Me-lc6"/>
</connections>
</barButtonItem>
</navigationItem>
<navigationItem key="navigationItem" title="Hosts" prompt="com.app.Example" id="TvD-8U-F05"/>
</tableViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="Gdi-Xi-JUL" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
@@ -357,20 +394,27 @@
<!--Occurrences-->
<scene sceneID="ws3-sK-l8m">
<objects>
<tableViewController id="h7Z-Qr-pJ5" customClass="TVCHostDetails" customModule="AppCheck" customModuleProvider="target" sceneMemberID="viewController">
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="prototypes" style="plain" separatorStyle="default" allowsSelection="NO" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="28" sectionFooterHeight="28" id="4ms-FO-Fge">
<tableViewController storyboardIdentifier="requestsOccurrences" id="h7Z-Qr-pJ5" customClass="TVCHostDetails" customModule="AppCheck" customModuleProvider="target" sceneMemberID="viewController">
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="prototypes" style="plain" separatorStyle="default" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="28" sectionFooterHeight="28" id="4ms-FO-Fge">
<rect key="frame" x="0.0" y="0.0" width="320" height="568"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<containerView key="tableHeaderView" opaque="NO" contentMode="scaleToFill" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="SxM-2c-aJb">
<rect key="frame" x="0.0" y="0.0" width="320" height="49"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" flexibleMinY="YES"/>
<connections>
<segue destination="1ba-SA-8sT" kind="embed" id="ueN-6L-cP7"/>
</connections>
</containerView>
<prototypes>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" reuseIdentifier="HostDetailCell" textLabel="J2P-mU-Vad" detailTextLabel="eWb-mX-udN" style="IBUITableViewCellStyleValue1" id="ZCA-Dz-i92">
<rect key="frame" x="0.0" y="28" width="320" height="43.5"/>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" accessoryType="disclosureIndicator" indentationWidth="10" reuseIdentifier="HostDetailCell" textLabel="J2P-mU-Vad" detailTextLabel="eWb-mX-udN" style="IBUITableViewCellStyleValue1" id="ZCA-Dz-i92">
<rect key="frame" x="0.0" y="77" width="320" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="ZCA-Dz-i92" id="nxe-48-jAQ">
<rect key="frame" x="0.0" y="0.0" width="320" height="43.5"/>
<rect key="frame" x="0.0" y="0.0" width="293" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Title" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="J2P-mU-Vad">
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Title" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.80000000000000004" id="J2P-mU-Vad">
<rect key="frame" x="16" y="12" width="33.5" height="20.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
@@ -378,7 +422,7 @@
<nil key="highlightedColor"/>
</label>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Detail" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="eWb-mX-udN">
<rect key="frame" x="260" y="12" width="44" height="20.5"/>
<rect key="frame" x="241" y="12" width="44" height="20.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<color key="textColor" systemColor="secondaryLabelColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
@@ -386,6 +430,9 @@
</label>
</subviews>
</tableViewCellContentView>
<connections>
<segue destination="rjy-Di-Cru" kind="push" id="SfC-iY-Ce0"/>
</connections>
</tableViewCell>
</prototypes>
<connections>
@@ -399,6 +446,284 @@
</objects>
<point key="canvasLocation" x="2100" y="-1250"/>
</scene>
<!--Analysis Bar-->
<scene sceneID="1qq-WD-Lqq">
<objects>
<viewController id="1ba-SA-8sT" customClass="VCAnalysisBar" customModule="AppCheck" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="qp6-er-N6U">
<rect key="frame" x="0.0" y="0.0" width="320" height="49"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<tabBar contentMode="scaleToFill" translucent="NO" id="1Jy-zg-CXR">
<rect key="frame" x="0.0" y="0.0" width="320" height="49"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" flexibleMaxY="YES"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<items>
<tabBarItem title="Co-Occurrence" image="intersection" id="KXh-kQ-rAF"/>
</items>
<connections>
<outlet property="delegate" destination="1ba-SA-8sT" id="bRS-kh-dOv"/>
</connections>
</tabBar>
</subviews>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<viewLayoutGuide key="safeArea" id="dtz-KG-P4C"/>
</view>
<connections>
<outlet property="tabBar" destination="1Jy-zg-CXR" id="VTV-xq-Aou"/>
</connections>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="XnK-B9-RSJ" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="1400" y="-1950"/>
</scene>
<!--Co Occurrence-->
<scene sceneID="Gbm-AP-b72">
<objects>
<viewController storyboardIdentifier="IBCoOccurrence" id="W5Q-oz-bFb" customClass="VCCoOccurrence" customModule="AppCheck" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="f34-NO-d8f">
<rect key="frame" x="0.0" y="0.0" width="320" height="568"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<navigationBar contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="rvt-nC-2Zr">
<rect key="frame" x="0.0" y="0.0" width="320" height="44"/>
<items>
<navigationItem title="Co-Occurrence" id="csY-x8-Rpe">
<barButtonItem key="leftBarButtonItem" systemItem="done" id="eg9-p3-Xas">
<connections>
<action selector="didClose:" destination="W5Q-oz-bFb" id="wyw-vo-6xL"/>
</connections>
</barButtonItem>
<barButtonItem key="rightBarButtonItem" id="bTi-7F-CFS">
<button key="customView" opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="infoLight" showsTouchWhenHighlighted="YES" lineBreakMode="middleTruncation" id="kqK-SL-CxZ">
<rect key="frame" x="279" y="10" width="25" height="24"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<connections>
<action selector="showInfoScreen" destination="W5Q-oz-bFb" eventType="touchUpInside" id="TuI-R9-PNr"/>
</connections>
</button>
</barButtonItem>
</navigationItem>
</items>
</navigationBar>
<tableView clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="prototypes" style="plain" separatorStyle="default" allowsSelection="NO" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="28" sectionFooterHeight="28" translatesAutoresizingMaskIntoConstraints="NO" id="PGb-pB-cfO">
<rect key="frame" x="0.0" y="44" width="320" height="524"/>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<segmentedControl key="tableHeaderView" opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="left" contentVerticalAlignment="top" segmentControlStyle="plain" selectedSegmentIndex="0" id="7ye-tU-pdo">
<rect key="frame" x="0.0" y="0.0" width="320" height="32"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<segments>
<segment title="10s"/>
<segment title="30s"/>
</segments>
<connections>
<action selector="didChangeTime:" destination="W5Q-oz-bFb" eventType="valueChanged" id="c5h-JG-S19"/>
</connections>
</segmentedControl>
<prototypes>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" reuseIdentifier="CoOccurrenceCell" rowHeight="72" id="2qH-Bh-644" customClass="CoOccurrenceCell" customModule="AppCheck" customModuleProvider="target">
<rect key="frame" x="0.0" y="60" width="320" height="72"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="2qH-Bh-644" id="Lwk-Uj-viQ">
<rect key="frame" x="0.0" y="0.0" width="320" height="72"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="99." textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="qaw-ql-zIB" customClass="TagLabel" customModule="AppCheck" customModuleProvider="target">
<rect key="frame" x="15" y="39.5" width="32" height="21.5"/>
<color key="backgroundColor" systemColor="secondarySystemBackgroundColor" red="0.94901960780000005" green="0.94901960780000005" blue="0.96862745100000003" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstAttribute="width" secondItem="qaw-ql-zIB" secondAttribute="height" multiplier="3:2" id="VOJ-f5-xhk"/>
</constraints>
<fontDescription key="fontDescription" style="UICTFontTextStyleFootnote"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Title" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.80000000000000004" translatesAutoresizingMaskIntoConstraints="NO" id="zbU-wC-qJG">
<rect key="frame" x="15" y="11" width="290" height="20.5"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" horizontalCompressionResistancePriority="500" insetsLayoutMarginsFromSafeArea="NO" text="Count" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="JWp-6l-HTJ">
<rect key="frame" x="109.5" y="42.5" width="37" height="16"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleFootnote"/>
<color key="textColor" systemColor="secondaryLabelColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="5900" textAlignment="natural" lineBreakMode="clip" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="q5v-FM-iGo" customClass="TagLabel" customModule="AppCheck" customModuleProvider="target">
<rect key="frame" x="150.5" y="39.5" width="42.5" height="21.5"/>
<color key="backgroundColor" systemColor="secondarySystemBackgroundColor" red="0.94901960780000005" green="0.94901960780000005" blue="0.96862745100000003" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleFootnote"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="number" keyPath="padRight">
<real key="value" value="1"/>
</userDefinedRuntimeAttribute>
</userDefinedRuntimeAttributes>
</label>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="10.35s" textAlignment="natural" lineBreakMode="clip" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="zCg-I0-4Tz" customClass="TagLabel" customModule="AppCheck" customModuleProvider="target">
<rect key="frame" x="255.5" y="39.5" width="49.5" height="21.5"/>
<color key="backgroundColor" systemColor="secondarySystemBackgroundColor" red="0.94901960780000005" green="0.94901960780000005" blue="0.96862745100000003" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleFootnote"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="number" keyPath="padRight">
<real key="value" value="1"/>
</userDefinedRuntimeAttribute>
</userDefinedRuntimeAttributes>
</label>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" horizontalHuggingPriority="750" horizontalCompressionResistancePriority="400" insetsLayoutMarginsFromSafeArea="NO" text="Diverge" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="T4X-cn-msT">
<rect key="frame" x="205" y="42.5" width="46.5" height="16"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleFootnote"/>
<color key="textColor" systemColor="secondaryLabelColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
<view opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="9Bb-e5-D3O" customClass="MeterBar" customModule="AppCheck" customModuleProvider="target">
<rect key="frame" x="190" y="39.5" width="3" height="21.5"/>
<constraints>
<constraint firstAttribute="width" constant="3" id="wWb-VG-Kqa"/>
</constraints>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="number" keyPath="percent">
<real key="value" value="1"/>
</userDefinedRuntimeAttribute>
<userDefinedRuntimeAttribute type="color" keyPath="barColor">
<color key="value" systemColor="systemOrangeColor" red="1" green="0.58431372550000005" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</userDefinedRuntimeAttribute>
</userDefinedRuntimeAttributes>
</view>
<view opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="JwY-mq-rYZ" customClass="MeterBar" customModule="AppCheck" customModuleProvider="target">
<rect key="frame" x="302" y="39.5" width="3" height="21.5"/>
<constraints>
<constraint firstAttribute="width" constant="3" id="Tta-m5-vwa"/>
</constraints>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="number" keyPath="percent">
<real key="value" value="1"/>
</userDefinedRuntimeAttribute>
<userDefinedRuntimeAttribute type="color" keyPath="barColor">
<color key="value" systemColor="systemOrangeColor" red="1" green="0.58431372550000005" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</userDefinedRuntimeAttribute>
</userDefinedRuntimeAttributes>
</view>
</subviews>
<constraints>
<constraint firstItem="qaw-ql-zIB" firstAttribute="height" secondItem="q5v-FM-iGo" secondAttribute="height" id="2Ug-qN-ido"/>
<constraint firstItem="zbU-wC-qJG" firstAttribute="leading" secondItem="Lwk-Uj-viQ" secondAttribute="leadingMargin" id="2Zo-jC-08y"/>
<constraint firstItem="JwY-mq-rYZ" firstAttribute="top" secondItem="zCg-I0-4Tz" secondAttribute="top" id="3MU-gk-eUU"/>
<constraint firstItem="T4X-cn-msT" firstAttribute="height" secondItem="zCg-I0-4Tz" secondAttribute="height" multiplier="0.75" id="AwE-JC-MFF"/>
<constraint firstAttribute="bottomMargin" secondItem="q5v-FM-iGo" secondAttribute="bottom" id="B2M-MQ-kAw"/>
<constraint firstItem="9Bb-e5-D3O" firstAttribute="bottom" secondItem="q5v-FM-iGo" secondAttribute="bottom" id="Efb-Ud-lxb"/>
<constraint firstItem="JWp-6l-HTJ" firstAttribute="height" secondItem="q5v-FM-iGo" secondAttribute="height" multiplier="0.75" id="Gfb-up-g1b"/>
<constraint firstItem="JwY-mq-rYZ" firstAttribute="trailing" secondItem="zCg-I0-4Tz" secondAttribute="trailing" id="RlS-DQ-pdh"/>
<constraint firstItem="zCg-I0-4Tz" firstAttribute="leading" secondItem="T4X-cn-msT" secondAttribute="trailing" constant="4" id="VpT-5w-aKh"/>
<constraint firstAttribute="trailingMargin" secondItem="zCg-I0-4Tz" secondAttribute="trailing" id="ai7-PW-ISq"/>
<constraint firstItem="qaw-ql-zIB" firstAttribute="leading" secondItem="Lwk-Uj-viQ" secondAttribute="leadingMargin" id="bGT-hc-lSG"/>
<constraint firstItem="JWp-6l-HTJ" firstAttribute="height" secondItem="T4X-cn-msT" secondAttribute="height" id="cKO-4d-ikl"/>
<constraint firstItem="JWp-6l-HTJ" firstAttribute="centerY" secondItem="q5v-FM-iGo" secondAttribute="centerY" id="dZr-0G-1sp"/>
<constraint firstAttribute="trailingMargin" secondItem="zbU-wC-qJG" secondAttribute="trailing" id="e7x-RS-YWo"/>
<constraint firstItem="JwY-mq-rYZ" firstAttribute="bottom" secondItem="zCg-I0-4Tz" secondAttribute="bottom" id="fAV-yh-H1r"/>
<constraint firstItem="9Bb-e5-D3O" firstAttribute="trailing" secondItem="q5v-FM-iGo" secondAttribute="trailing" id="fFF-y3-qOe"/>
<constraint firstItem="qaw-ql-zIB" firstAttribute="top" secondItem="zbU-wC-qJG" secondAttribute="bottom" constant="8" symbolic="YES" id="fgw-q8-YRD"/>
<constraint firstItem="9Bb-e5-D3O" firstAttribute="top" secondItem="q5v-FM-iGo" secondAttribute="top" id="idg-nm-vIj"/>
<constraint firstItem="T4X-cn-msT" firstAttribute="leading" secondItem="q5v-FM-iGo" secondAttribute="trailing" constant="12" id="kZj-Tn-BQ3"/>
<constraint firstItem="zbU-wC-qJG" firstAttribute="top" secondItem="Lwk-Uj-viQ" secondAttribute="top" constant="11" id="o7o-M0-sA2"/>
<constraint firstItem="q5v-FM-iGo" firstAttribute="top" secondItem="zbU-wC-qJG" secondAttribute="bottom" constant="8" symbolic="YES" id="peW-Pg-5WC"/>
<constraint firstItem="zCg-I0-4Tz" firstAttribute="top" secondItem="zbU-wC-qJG" secondAttribute="bottom" constant="8" symbolic="YES" id="ttp-yA-tsi"/>
<constraint firstItem="T4X-cn-msT" firstAttribute="centerY" secondItem="zCg-I0-4Tz" secondAttribute="centerY" id="tz9-Vr-fB6"/>
<constraint firstItem="JWp-6l-HTJ" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="qaw-ql-zIB" secondAttribute="trailing" constant="8" symbolic="YES" id="xFl-RU-Ynw"/>
<constraint firstItem="q5v-FM-iGo" firstAttribute="leading" secondItem="JWp-6l-HTJ" secondAttribute="trailing" constant="4" id="xHw-Pf-daH"/>
</constraints>
</tableViewCellContentView>
<connections>
<outlet property="avgdiff" destination="zCg-I0-4Tz" id="Jno-Yc-ngL"/>
<outlet property="avgdiffMeter" destination="JwY-mq-rYZ" id="QNx-rP-17Z"/>
<outlet property="count" destination="q5v-FM-iGo" id="AFk-93-mhs"/>
<outlet property="countMeter" destination="9Bb-e5-D3O" id="zqt-dT-ecT"/>
<outlet property="rank" destination="qaw-ql-zIB" id="q6Y-JS-NFU"/>
<outlet property="title" destination="zbU-wC-qJG" id="hgV-L0-blX"/>
</connections>
</tableViewCell>
</prototypes>
<connections>
<outlet property="dataSource" destination="W5Q-oz-bFb" id="7lD-aQ-QhQ"/>
</connections>
</tableView>
</subviews>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<constraints>
<constraint firstItem="PGb-pB-cfO" firstAttribute="top" secondItem="rvt-nC-2Zr" secondAttribute="bottom" id="Edp-lx-Xld"/>
<constraint firstItem="4eZ-5P-8sz" firstAttribute="bottom" secondItem="PGb-pB-cfO" secondAttribute="bottom" id="OAG-HL-4N4"/>
<constraint firstItem="PGb-pB-cfO" firstAttribute="leading" secondItem="4eZ-5P-8sz" secondAttribute="leading" id="V6d-HM-JzJ"/>
<constraint firstItem="4eZ-5P-8sz" firstAttribute="trailing" secondItem="rvt-nC-2Zr" secondAttribute="trailing" id="cmE-iH-06W"/>
<constraint firstItem="rvt-nC-2Zr" firstAttribute="top" secondItem="4eZ-5P-8sz" secondAttribute="top" id="epT-LW-CJV"/>
<constraint firstItem="4eZ-5P-8sz" firstAttribute="trailing" secondItem="PGb-pB-cfO" secondAttribute="trailing" id="j8i-8q-qGS"/>
<constraint firstItem="rvt-nC-2Zr" firstAttribute="leading" secondItem="4eZ-5P-8sz" secondAttribute="leading" id="skN-SN-Wu7"/>
</constraints>
<viewLayoutGuide key="safeArea" id="4eZ-5P-8sz"/>
</view>
<connections>
<outlet property="tableView" destination="PGb-pB-cfO" id="5gT-KC-ce5"/>
<outlet property="timeSegment" destination="7ye-tU-pdo" id="2ys-X4-Jff"/>
</connections>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="yYY-5U-gct" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="2100" y="-1950"/>
</scene>
<!--Occurrence Context-->
<scene sceneID="A1T-7G-agr">
<objects>
<tableViewController id="rjy-Di-Cru" customClass="TVCOccurrenceContext" customModule="AppCheck" customModuleProvider="target" sceneMemberID="viewController">
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="prototypes" style="plain" separatorStyle="default" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="28" sectionFooterHeight="28" id="EfM-yv-85f">
<rect key="frame" x="0.0" y="0.0" width="320" height="568"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<prototypes>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" reuseIdentifier="OccurrenceContextCell" textLabel="xgq-hW-e3R" detailTextLabel="No8-Bf-ptL" style="IBUITableViewCellStyleValue2" id="KQh-Ei-If8">
<rect key="frame" x="0.0" y="28" width="320" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="KQh-Ei-If8" id="i32-u4-1Q8">
<rect key="frame" x="0.0" y="0.0" width="320" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Title" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="xgq-hW-e3R">
<rect key="frame" x="16" y="12" width="91" height="20.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<color key="textColor" systemColor="secondaryLabelColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Subtitle" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.80000000000000004" id="No8-Bf-ptL">
<rect key="frame" x="113" y="12" width="59" height="20.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</tableViewCellContentView>
</tableViewCell>
</prototypes>
<connections>
<outlet property="dataSource" destination="rjy-Di-Cru" id="6CT-Vd-Ixn"/>
<outlet property="delegate" destination="rjy-Di-Cru" id="JtY-um-ZTF"/>
</connections>
</tableView>
<navigationItem key="navigationItem" title="Occurrence Context" id="2mj-It-uND">
<barButtonItem key="rightBarButtonItem" image="jump-to-target" id="TqX-qO-B3s">
<connections>
<action selector="jumpToTsZero" destination="rjy-Di-Cru" id="RS6-IO-hi4"/>
</connections>
</barButtonItem>
</navigationItem>
</tableViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="cYd-oX-akc" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="2800" y="-1250"/>
</scene>
<!--Recordings-->
<scene sceneID="ODR-PD-nTU">
<objects>
@@ -505,7 +830,7 @@
<rect key="frame" x="0.0" y="0.0" width="280" height="57.5"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Title" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="hr0-Xt-5gV">
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Title" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.80000000000000004" id="hr0-Xt-5gV">
<rect key="frame" x="16" y="9" width="33.5" height="20.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
@@ -705,14 +1030,14 @@ Duration: 60:00</string>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<prototypes>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" reuseIdentifier="PreviousRecordDetailCell" textLabel="rN0-kA-Eln" detailTextLabel="xRp-XG-oKf" style="IBUITableViewCellStyleValue1" id="ceT-cF-lLF">
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" reuseIdentifier="RecordDetailCountedCell" textLabel="rN0-kA-Eln" detailTextLabel="xRp-XG-oKf" style="IBUITableViewCellStyleValue1" id="ceT-cF-lLF">
<rect key="frame" x="0.0" y="28" width="320" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="ceT-cF-lLF" id="c5Y-xg-hSL">
<rect key="frame" x="0.0" y="0.0" width="320" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Title" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="rN0-kA-Eln">
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Title" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.80000000000000004" id="rN0-kA-Eln">
<rect key="frame" x="16" y="12" width="33.5" height="20.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
@@ -729,13 +1054,67 @@ Duration: 60:00</string>
</subviews>
</tableViewCellContentView>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" reuseIdentifier="RecordDetailShortCell" textLabel="rIc-r4-6pg" detailTextLabel="0pW-ZC-wmh" style="IBUITableViewCellStyleValue2" id="hzU-cx-nIs">
<rect key="frame" x="0.0" y="71.5" width="320" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="hzU-cx-nIs" id="scX-pQ-E7z">
<rect key="frame" x="0.0" y="0.0" width="320" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Title" textAlignment="right" lineBreakMode="headTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" minimumScaleFactor="0.80000000000000004" id="rIc-r4-6pg">
<rect key="frame" x="16" y="12" width="91" height="20.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<color key="textColor" systemColor="secondaryLabelColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Detail" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.80000000000000004" id="0pW-ZC-wmh">
<rect key="frame" x="113" y="12" width="44" height="20.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</tableViewCellContentView>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" reuseIdentifier="RecordDetailLongCell" textLabel="xDy-8J-JFT" detailTextLabel="kgF-BN-FdV" style="IBUITableViewCellStyleSubtitle" id="Q4T-JJ-fqY">
<rect key="frame" x="0.0" y="115" width="320" height="57.5"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="Q4T-JJ-fqY" id="8hy-Rg-b6Q">
<rect key="frame" x="0.0" y="0.0" width="320" height="57.5"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Title" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.80000000000000004" id="xDy-8J-JFT">
<rect key="frame" x="16" y="9" width="33.5" height="20.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Subtitle" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.80000000000000004" adjustsFontSizeToFit="NO" id="kgF-BN-FdV">
<rect key="frame" x="16" y="32.5" width="44" height="14.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleCaption1"/>
<color key="textColor" systemColor="secondaryLabelColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</tableViewCellContentView>
</tableViewCell>
</prototypes>
<connections>
<outlet property="dataSource" destination="50g-BI-Q6S" id="SFM-IM-FRx"/>
<outlet property="delegate" destination="50g-BI-Q6S" id="LBY-sp-dg0"/>
</connections>
</tableView>
<navigationItem key="navigationItem" title="Logs" id="AXT-fV-keV"/>
<navigationItem key="navigationItem" title="Logs" id="AXT-fV-keV">
<barButtonItem key="rightBarButtonItem" image="line-expand" id="xLc-O7-KVB">
<connections>
<action selector="toggleDisplayStyle:" destination="50g-BI-Q6S" id="3wo-9O-7gV"/>
</connections>
</barButtonItem>
</navigationItem>
</tableViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="lan-I9-b0a" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
@@ -751,7 +1130,7 @@ Duration: 60:00</string>
<autoresizingMask key="autoresizingMask"/>
</navigationBar>
<connections>
<segue destination="qdB-ZO-LHY" kind="relationship" relationship="rootViewController" id="qJW-Jc-O4D"/>
<segue destination="cV2-If-0fV" kind="relationship" relationship="rootViewController" id="vQq-KE-MOO"/>
</connections>
</navigationController>
<placeholder placeholderIdentifier="IBFirstResponder" id="bg9-bR-vlx" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
@@ -759,249 +1138,25 @@ Duration: 60:00</string>
<point key="canvasLocation" x="0.0" y="150"/>
</scene>
<!--Settings-->
<scene sceneID="gEe-ny-NaU">
<scene sceneID="l0f-fL-3tG">
<objects>
<tableViewController id="qdB-ZO-LHY" customClass="TVCSettings" customModule="AppCheck" customModuleProvider="target" sceneMemberID="viewController">
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="static" style="grouped" separatorStyle="default" rowHeight="44" estimatedRowHeight="-1" sectionHeaderHeight="18" sectionFooterHeight="18" id="8kq-PY-wp7">
<rect key="frame" x="0.0" y="0.0" width="320" height="568"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<sections>
<tableViewSection headerTitle="VPN Proxy Settings" id="w58-6X-Jea">
<cells>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="ghM-ze-fvp">
<rect key="frame" x="0.0" y="55.5" width="320" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="ghM-ze-fvp" id="d2v-vz-QIB">
<rect key="frame" x="0.0" y="0.0" width="320" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<switch opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="750" verticalHuggingPriority="750" contentHorizontalAlignment="center" contentVerticalAlignment="center" on="YES" translatesAutoresizingMaskIntoConstraints="NO" id="kmY-ot-lJW">
<rect key="frame" x="255" y="6.5" width="51" height="31"/>
<connections>
<action selector="toggleVPNProxy:" destination="qdB-ZO-LHY" eventType="valueChanged" id="y95-2Z-Uep"/>
</connections>
</switch>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="VPN Proxy enabled" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Qha-4I-go0">
<rect key="frame" x="16" y="12" width="147" height="20"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<constraints>
<constraint firstItem="kmY-ot-lJW" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="Qha-4I-go0" secondAttribute="trailing" constant="8" id="Lnx-hC-xOx"/>
<constraint firstItem="kmY-ot-lJW" firstAttribute="trailing" secondItem="d2v-vz-QIB" secondAttribute="trailingMargin" id="Ylz-D4-hz4"/>
<constraint firstItem="Qha-4I-go0" firstAttribute="centerY" secondItem="d2v-vz-QIB" secondAttribute="centerY" id="dKE-By-qEu"/>
<constraint firstItem="kmY-ot-lJW" firstAttribute="centerY" secondItem="Qha-4I-go0" secondAttribute="centerY" id="dgh-tx-Y8a"/>
<constraint firstItem="Qha-4I-go0" firstAttribute="leading" secondItem="d2v-vz-QIB" secondAttribute="leadingMargin" id="rHx-0D-DPX"/>
</constraints>
</tableViewCellContentView>
</tableViewCell>
</cells>
</tableViewSection>
<tableViewSection headerTitle="Logging Filter" id="EcH-KA-eLE">
<cells>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" accessoryType="detailDisclosureButton" indentationWidth="10" reuseIdentifier="settingsIgnoredCell" textLabel="UdM-Zm-G9p" detailTextLabel="bHb-Tw-nPR" style="IBUITableViewCellStyleValue2" id="fZR-we-Y0k">
<rect key="frame" x="0.0" y="155.5" width="320" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="fZR-we-Y0k" id="eqc-fj-p0d">
<rect key="frame" x="0.0" y="0.0" width="261" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Ignore" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="UdM-Zm-G9p">
<rect key="frame" x="16" y="13" width="91" height="18"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="0 Domains" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="bHb-Tw-nPR">
<rect key="frame" x="113" y="13" width="73" height="18"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/>
<color key="textColor" systemColor="secondaryLabelColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</tableViewCellContentView>
<connections>
<segue destination="q3B-Yi-1bx" kind="push" identifier="segueFilterIgnored" id="EzT-Xq-wka"/>
</connections>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" accessoryType="detailDisclosureButton" indentationWidth="10" reuseIdentifier="settingsBlockedCell" textLabel="fI0-Nt-Ucf" detailTextLabel="CGG-47-cdc" style="IBUITableViewCellStyleValue2" id="3pw-7c-M6R">
<rect key="frame" x="0.0" y="199.5" width="320" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="3pw-7c-M6R" id="Smv-n1-917">
<rect key="frame" x="0.0" y="0.0" width="261" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Block" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="fI0-Nt-Ucf">
<rect key="frame" x="16" y="13" width="91" height="18"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="0 Domains" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="CGG-47-cdc">
<rect key="frame" x="113" y="13" width="73" height="18"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/>
<color key="textColor" systemColor="secondaryLabelColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</tableViewCellContentView>
<connections>
<segue destination="q3B-Yi-1bx" kind="push" identifier="segueFilterBlocked" id="cOY-j0-75m"/>
</connections>
</tableViewCell>
</cells>
</tableViewSection>
<tableViewSection headerTitle="Reset Settings" id="tBs-BI-JqN">
<cells>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="Uii-Jp-53c">
<rect key="frame" x="0.0" y="299.5" width="320" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="Uii-Jp-53c" id="4Fp-Ox-yrk">
<rect key="frame" x="0.0" y="0.0" width="320" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="6B5-l4-Hgz">
<rect key="frame" x="74.5" y="7" width="171" height="30"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/>
<state key="normal" title="Reset Introduction Alerts"/>
<connections>
<action selector="resetTutorialAlerts:" destination="qdB-ZO-LHY" eventType="touchUpInside" id="hw8-as-4PZ"/>
</connections>
</button>
</subviews>
<constraints>
<constraint firstItem="6B5-l4-Hgz" firstAttribute="centerY" secondItem="4Fp-Ox-yrk" secondAttribute="centerY" id="h2Y-P2-Feo"/>
<constraint firstItem="6B5-l4-Hgz" firstAttribute="centerX" secondItem="4Fp-Ox-yrk" secondAttribute="centerX" id="jpA-gA-3jY"/>
</constraints>
</tableViewCellContentView>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="Xgc-6Z-IlH">
<rect key="frame" x="0.0" y="343.5" width="320" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="Xgc-6Z-IlH" id="efR-vn-6MX">
<rect key="frame" x="0.0" y="0.0" width="320" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="sE3-Vh-0lM">
<rect key="frame" x="111.5" y="7" width="97" height="30"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/>
<state key="normal" title="Delete all logs">
<color key="titleColor" systemColor="systemRedColor" red="1" green="0.23137254900000001" blue="0.18823529410000001" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</state>
<connections>
<action selector="clearDatabaseResults:" destination="qdB-ZO-LHY" eventType="touchUpInside" id="adR-Yk-zsB"/>
</connections>
</button>
</subviews>
<constraints>
<constraint firstItem="sE3-Vh-0lM" firstAttribute="centerX" secondItem="efR-vn-6MX" secondAttribute="centerX" id="TvC-jA-Wp5"/>
<constraint firstItem="sE3-Vh-0lM" firstAttribute="centerY" secondItem="efR-vn-6MX" secondAttribute="centerY" id="WoM-cy-cAY"/>
</constraints>
</tableViewCellContentView>
</tableViewCell>
</cells>
</tableViewSection>
<tableViewSection headerTitle="Advanced" id="wLR-T2-Qxm">
<cells>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="Qyy-0U-yhd">
<rect key="frame" x="0.0" y="443.5" width="320" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="Qyy-0U-yhd" id="Mfs-fu-W5k">
<rect key="frame" x="0.0" y="0.0" width="320" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="9Ko-sD-7x0">
<rect key="frame" x="125" y="7" width="70" height="30"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/>
<state key="normal" title="Export DB"/>
<connections>
<action selector="exportDB:" destination="qdB-ZO-LHY" eventType="touchUpInside" id="3gu-WF-3Xa"/>
</connections>
</button>
</subviews>
<constraints>
<constraint firstItem="9Ko-sD-7x0" firstAttribute="centerX" secondItem="Mfs-fu-W5k" secondAttribute="centerX" id="LzG-xg-XTg"/>
<constraint firstItem="9Ko-sD-7x0" firstAttribute="centerY" secondItem="Mfs-fu-W5k" secondAttribute="centerY" id="SXw-dC-2kl"/>
</constraints>
</tableViewCellContentView>
</tableViewCell>
</cells>
</tableViewSection>
</sections>
<connections>
<outlet property="dataSource" destination="qdB-ZO-LHY" id="RH3-xR-dpC"/>
<outlet property="delegate" destination="qdB-ZO-LHY" id="eYf-Xd-2Jq"/>
</connections>
</tableView>
<navigationItem key="navigationItem" title="Settings" id="9Ce-p2-kGX"/>
<connections>
<outlet property="cellDomainsBlocked" destination="3pw-7c-M6R" id="AHT-FE-z0s"/>
<outlet property="cellDomainsIgnored" destination="fZR-we-Y0k" id="Huy-N3-gz7"/>
<outlet property="vpnToggle" destination="kmY-ot-lJW" id="yeS-DE-FfR"/>
</connections>
</tableViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="VNK-Z0-T0a" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
<viewControllerPlaceholder storyboardName="Settings" id="cV2-If-0fV" sceneMemberID="viewController">
<navigationItem key="navigationItem" id="wlO-Ea-6he"/>
</viewControllerPlaceholder>
<placeholder placeholderIdentifier="IBFirstResponder" id="i3W-Ff-rJL" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="700" y="150"/>
</scene>
<!--Domains-->
<scene sceneID="218-uP-X7b">
<objects>
<tableViewController id="q3B-Yi-1bx" customClass="TVCFilter" customModule="AppCheck" customModuleProvider="target" sceneMemberID="viewController">
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="prototypes" style="plain" separatorStyle="default" allowsSelection="NO" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="28" sectionFooterHeight="28" id="GSg-ZZ-F8J">
<rect key="frame" x="0.0" y="0.0" width="320" height="568"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<prototypes>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" reuseIdentifier="DomainFilterCell" textLabel="MrS-rb-RLB" style="IBUITableViewCellStyleDefault" id="EO2-ww-xuz">
<rect key="frame" x="0.0" y="28" width="320" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="EO2-ww-xuz" id="AtR-ce-uYs">
<rect key="frame" x="0.0" y="0.0" width="320" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Title" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="MrS-rb-RLB">
<rect key="frame" x="16" y="0.0" width="288" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</tableViewCellContentView>
</tableViewCell>
</prototypes>
<connections>
<outlet property="dataSource" destination="q3B-Yi-1bx" id="eWw-VO-n1c"/>
<outlet property="delegate" destination="q3B-Yi-1bx" id="02X-f0-d1a"/>
</connections>
</tableView>
<navigationItem key="navigationItem" title="Domains" id="FWA-IG-VIb">
<barButtonItem key="rightBarButtonItem" systemItem="add" id="RFW-bp-wwH">
<connections>
<action selector="addNewFilter" destination="q3B-Yi-1bx" id="JID-eH-y0p"/>
</connections>
</barButtonItem>
</navigationItem>
</tableViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="Xzo-dO-WpK" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="1400" y="150"/>
</scene>
</scenes>
<inferredMetricsTieBreakers>
<segue reference="EzT-Xq-wka"/>
<segue reference="ueN-6L-cP7"/>
</inferredMetricsTieBreakers>
<resources>
<image name="filter-clear" width="20" height="20"/>
<image name="intersection" width="25" height="25"/>
<image name="journal" width="25" height="25"/>
<image name="jump-to-target" width="20" height="20"/>
<image name="line-expand" width="20" height="20"/>
<image name="settings" width="25" height="25"/>
<image name="tag" width="25" height="25"/>
</resources>

View File

@@ -0,0 +1,837 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="16097" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="qdB-ZO-LHY">
<device id="retina4_0" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="16087"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--Settings-->
<scene sceneID="gEe-ny-NaU">
<objects>
<tableViewController id="qdB-ZO-LHY" customClass="TVCSettings" customModule="AppCheck" customModuleProvider="target" sceneMemberID="viewController">
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="static" style="grouped" separatorStyle="default" rowHeight="44" estimatedRowHeight="-1" sectionHeaderHeight="18" sectionFooterHeight="18" id="8kq-PY-wp7">
<rect key="frame" x="0.0" y="0.0" width="320" height="568"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<sections>
<tableViewSection headerTitle="VPN Proxy Settings" id="w58-6X-Jea">
<cells>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" textLabel="3y8-eK-09n" style="IBUITableViewCellStyleDefault" id="ghM-ze-fvp">
<rect key="frame" x="0.0" y="55.5" width="320" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="ghM-ze-fvp" id="d2v-vz-QIB">
<rect key="frame" x="0.0" y="0.0" width="320" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="VPN Proxy Enabled" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="3y8-eK-09n">
<rect key="frame" x="16" y="0.0" width="288" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<switch opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="750" verticalHuggingPriority="750" contentHorizontalAlignment="center" contentVerticalAlignment="center" on="YES" translatesAutoresizingMaskIntoConstraints="NO" id="ZAz-WT-FDb">
<rect key="frame" x="257" y="6" width="49" height="31"/>
<autoresizingMask key="autoresizingMask" flexibleMinX="YES" flexibleMaxY="YES"/>
<connections>
<action selector="toggleVPNProxy:" destination="qdB-ZO-LHY" eventType="valueChanged" id="DNS-71-2ga"/>
</connections>
</switch>
</subviews>
</tableViewCellContentView>
<connections>
<outlet property="accessoryView" destination="ZAz-WT-FDb" id="SX3-lk-I3M"/>
</connections>
</tableViewCell>
</cells>
</tableViewSection>
<tableViewSection headerTitle="Logging Filter" id="EcH-KA-eLE">
<cells>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" accessoryType="detailDisclosureButton" indentationWidth="10" reuseIdentifier="settingsIgnoredCell" textLabel="UdM-Zm-G9p" detailTextLabel="bHb-Tw-nPR" style="IBUITableViewCellStyleValue2" id="fZR-we-Y0k">
<rect key="frame" x="0.0" y="155.5" width="320" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="fZR-we-Y0k" id="eqc-fj-p0d">
<rect key="frame" x="0.0" y="0.0" width="261" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Ignore" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="UdM-Zm-G9p">
<rect key="frame" x="16" y="13" width="91" height="18"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="0 Domains" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="bHb-Tw-nPR">
<rect key="frame" x="113" y="13" width="73" height="18"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/>
<color key="textColor" systemColor="secondaryLabelColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</tableViewCellContentView>
<connections>
<segue destination="q3B-Yi-1bx" kind="push" identifier="segueFilterIgnored" id="EzT-Xq-wka"/>
</connections>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" accessoryType="detailDisclosureButton" indentationWidth="10" reuseIdentifier="settingsBlockedCell" textLabel="fI0-Nt-Ucf" detailTextLabel="CGG-47-cdc" style="IBUITableViewCellStyleValue2" id="3pw-7c-M6R">
<rect key="frame" x="0.0" y="199.5" width="320" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="3pw-7c-M6R" id="Smv-n1-917">
<rect key="frame" x="0.0" y="0.0" width="261" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Block" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="fI0-Nt-Ucf">
<rect key="frame" x="16" y="13" width="91" height="18"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="0 Domains" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="CGG-47-cdc">
<rect key="frame" x="113" y="13" width="73" height="18"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/>
<color key="textColor" systemColor="secondaryLabelColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</tableViewCellContentView>
<connections>
<segue destination="q3B-Yi-1bx" kind="push" identifier="segueFilterBlocked" id="cOY-j0-75m"/>
</connections>
</tableViewCell>
</cells>
</tableViewSection>
<tableViewSection headerTitle="Notification Settings" id="gNL-sO-BEp">
<cells>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" selectionStyle="blue" accessoryType="disclosureIndicator" hidesAccessoryWhenEditing="NO" indentationLevel="1" indentationWidth="0.0" textLabel="pN1-lL-bGz" detailTextLabel="ldE-NT-c2c" style="IBUITableViewCellStyleValue1" id="jZA-aP-aHG">
<rect key="frame" x="0.0" y="299.5" width="320" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="jZA-aP-aHG" id="OYo-TE-SLp">
<rect key="frame" x="0.0" y="0.0" width="293" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Reminders" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="pN1-lL-bGz">
<rect key="frame" x="16" y="12" width="81.5" height="20.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Disabled" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="ldE-NT-c2c">
<rect key="frame" x="218" y="12" width="67" height="20.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<color key="textColor" systemColor="secondaryLabelColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</tableViewCellContentView>
<connections>
<segue destination="JYM-cs-i4H" kind="push" identifier="" id="uOT-Eo-Fm8"/>
</connections>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" selectionStyle="blue" accessoryType="disclosureIndicator" hidesAccessoryWhenEditing="NO" indentationLevel="1" indentationWidth="0.0" textLabel="o7c-vQ-haI" detailTextLabel="VeV-go-DXR" style="IBUITableViewCellStyleValue1" id="OTC-Kt-LFT">
<rect key="frame" x="0.0" y="343.5" width="320" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="OTC-Kt-LFT" id="RLb-Oi-WBg">
<rect key="frame" x="0.0" y="0.0" width="293" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Connection Alerts" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="o7c-vQ-haI">
<rect key="frame" x="16" y="12" width="137.5" height="20.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Disabled" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="VeV-go-DXR">
<rect key="frame" x="218" y="12" width="67" height="20.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<color key="textColor" systemColor="secondaryLabelColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</tableViewCellContentView>
<connections>
<segue destination="D2a-Po-vDU" kind="push" id="6NC-bN-nVR"/>
</connections>
</tableViewCell>
</cells>
</tableViewSection>
<tableViewSection headerTitle="Privacy Settings" id="wLR-T2-Qxm">
<cells>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" accessoryType="disclosureIndicator" indentationWidth="10" textLabel="8gD-At-D8n" detailTextLabel="Yy4-Ip-Wdv" style="IBUITableViewCellStyleValue1" id="Qyy-0U-yhd">
<rect key="frame" x="0.0" y="443.5" width="320" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="Qyy-0U-yhd" id="Mfs-fu-W5k">
<rect key="frame" x="0.0" y="0.0" width="293" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Auto-Delete Logs" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="8gD-At-D8n">
<rect key="frame" x="16" y="12" width="134" height="20.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Never" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="Yy4-Ip-Wdv">
<rect key="frame" x="239.5" y="12" width="45.5" height="20.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<color key="textColor" systemColor="secondaryLabelColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</tableViewCellContentView>
</tableViewCell>
</cells>
</tableViewSection>
<tableViewSection headerTitle="Reset Settings" id="tBs-BI-JqN">
<cells>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="Uii-Jp-53c">
<rect key="frame" x="0.0" y="543.5" width="320" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="Uii-Jp-53c" id="4Fp-Ox-yrk">
<rect key="frame" x="0.0" y="0.0" width="320" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="6B5-l4-Hgz">
<rect key="frame" x="74.5" y="7" width="171" height="30"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/>
<state key="normal" title="Reset Introduction Alerts"/>
<connections>
<action selector="resetTutorialAlerts:" destination="qdB-ZO-LHY" eventType="touchUpInside" id="hw8-as-4PZ"/>
</connections>
</button>
</subviews>
<constraints>
<constraint firstItem="6B5-l4-Hgz" firstAttribute="centerY" secondItem="4Fp-Ox-yrk" secondAttribute="centerY" id="h2Y-P2-Feo"/>
<constraint firstItem="6B5-l4-Hgz" firstAttribute="centerX" secondItem="4Fp-Ox-yrk" secondAttribute="centerX" id="jpA-gA-3jY"/>
</constraints>
</tableViewCellContentView>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="Xgc-6Z-IlH">
<rect key="frame" x="0.0" y="587.5" width="320" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="Xgc-6Z-IlH" id="efR-vn-6MX">
<rect key="frame" x="0.0" y="0.0" width="320" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="sE3-Vh-0lM">
<rect key="frame" x="111.5" y="7" width="97" height="30"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/>
<state key="normal" title="Delete all logs">
<color key="titleColor" systemColor="systemRedColor" red="1" green="0.23137254900000001" blue="0.18823529410000001" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</state>
<connections>
<action selector="clearDatabaseResults" destination="qdB-ZO-LHY" eventType="touchUpInside" id="heU-m1-oJq"/>
</connections>
</button>
</subviews>
<constraints>
<constraint firstItem="sE3-Vh-0lM" firstAttribute="centerX" secondItem="efR-vn-6MX" secondAttribute="centerX" id="TvC-jA-Wp5"/>
<constraint firstItem="sE3-Vh-0lM" firstAttribute="centerY" secondItem="efR-vn-6MX" secondAttribute="centerY" id="WoM-cy-cAY"/>
</constraints>
</tableViewCellContentView>
</tableViewCell>
</cells>
</tableViewSection>
<tableViewSection headerTitle="Advanced Settings" id="Vlg-nm-VB3">
<cells>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="VnR-9B-1zl">
<rect key="frame" x="0.0" y="687.5" width="320" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="VnR-9B-1zl" id="ZTz-vZ-l5p">
<rect key="frame" x="0.0" y="0.0" width="320" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="twS-Ne-dU0">
<rect key="frame" x="125" y="7" width="70" height="30"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/>
<state key="normal" title="Export DB"/>
<connections>
<action selector="exportDB" destination="qdB-ZO-LHY" eventType="touchUpInside" id="FYN-Zz-UK4"/>
</connections>
</button>
</subviews>
<constraints>
<constraint firstItem="twS-Ne-dU0" firstAttribute="centerY" secondItem="ZTz-vZ-l5p" secondAttribute="centerY" id="LgK-8q-r6K"/>
<constraint firstItem="twS-Ne-dU0" firstAttribute="centerX" secondItem="ZTz-vZ-l5p" secondAttribute="centerX" id="ltC-Ba-Bxr"/>
</constraints>
</tableViewCellContentView>
</tableViewCell>
</cells>
</tableViewSection>
</sections>
<connections>
<outlet property="dataSource" destination="qdB-ZO-LHY" id="RH3-xR-dpC"/>
<outlet property="delegate" destination="qdB-ZO-LHY" id="eYf-Xd-2Jq"/>
</connections>
</tableView>
<navigationItem key="navigationItem" title="Settings" id="9Ce-p2-kGX"/>
<simulatedNavigationBarMetrics key="simulatedTopBarMetrics" prompted="NO"/>
<simulatedTabBarMetrics key="simulatedBottomBarMetrics"/>
<connections>
<outlet property="cellDomainsBlocked" destination="3pw-7c-M6R" id="AHT-FE-z0s"/>
<outlet property="cellDomainsIgnored" destination="fZR-we-Y0k" id="Huy-N3-gz7"/>
<outlet property="cellNotificationConnectionAlert" destination="OTC-Kt-LFT" id="XiG-CC-4lC"/>
<outlet property="cellNotificationReminder" destination="jZA-aP-aHG" id="sjo-2s-rqW"/>
<outlet property="cellPrivacyAutoDelete" destination="Qyy-0U-yhd" id="PzN-iv-kFl"/>
<outlet property="vpnToggle" destination="ZAz-WT-FDb" id="lGX-J8-WrU"/>
</connections>
</tableViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="VNK-Z0-T0a" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="-200" y="0.0"/>
</scene>
<!--Domains-->
<scene sceneID="218-uP-X7b">
<objects>
<tableViewController id="q3B-Yi-1bx" customClass="TVCFilter" customModule="AppCheck" customModuleProvider="target" sceneMemberID="viewController">
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="prototypes" style="plain" separatorStyle="default" allowsSelection="NO" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="28" sectionFooterHeight="28" id="GSg-ZZ-F8J">
<rect key="frame" x="0.0" y="0.0" width="320" height="568"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<prototypes>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" reuseIdentifier="DomainFilterCell" textLabel="MrS-rb-RLB" style="IBUITableViewCellStyleDefault" id="EO2-ww-xuz">
<rect key="frame" x="0.0" y="28" width="320" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="EO2-ww-xuz" id="AtR-ce-uYs">
<rect key="frame" x="0.0" y="0.0" width="320" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Title" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.80000000000000004" id="MrS-rb-RLB">
<rect key="frame" x="16" y="0.0" width="288" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</tableViewCellContentView>
</tableViewCell>
</prototypes>
<connections>
<outlet property="dataSource" destination="q3B-Yi-1bx" id="eWw-VO-n1c"/>
<outlet property="delegate" destination="q3B-Yi-1bx" id="02X-f0-d1a"/>
</connections>
</tableView>
<navigationItem key="navigationItem" title="Domains" id="FWA-IG-VIb">
<barButtonItem key="rightBarButtonItem" systemItem="add" id="RFW-bp-wwH">
<connections>
<action selector="addNewFilter" destination="q3B-Yi-1bx" id="JID-eH-y0p"/>
</connections>
</barButtonItem>
</navigationItem>
</tableViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="Xzo-dO-WpK" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="1500" y="0.0"/>
</scene>
<!--Reminders-->
<scene sceneID="fWF-ss-cNz">
<objects>
<tableViewController id="JYM-cs-i4H" customClass="TVCReminderAlerts" customModule="AppCheck" customModuleProvider="target" sceneMemberID="viewController">
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="static" style="grouped" separatorStyle="default" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="18" sectionFooterHeight="18" id="Dop-3B-Uvh">
<rect key="frame" x="0.0" y="0.0" width="320" height="568"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<sections>
<tableViewSection headerTitle="Restart Reminder" id="UOi-fT-8Vh">
<string key="footerTitle">If VPN stops accidentally, show a notification 5 minutes later. It will remind you to re-enable the VPN after system reboot.
if Notification is enabled, show a notification banner once, stating the VPN has stopped.
If App Badge is enabled, display the letter "1" on the homescreen app icon, as long as the VPN is not running.</string>
<cells>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" textLabel="dl8-4J-a0L" style="IBUITableViewCellStyleDefault" id="Z2r-Kz-TCt">
<rect key="frame" x="0.0" y="55.5" width="320" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="Z2r-Kz-TCt" id="rEy-qO-PEN">
<rect key="frame" x="0.0" y="0.0" width="320" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Warn If VPN Stops" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="dl8-4J-a0L">
<rect key="frame" x="16" y="0.0" width="288" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<switch opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="750" verticalHuggingPriority="750" contentHorizontalAlignment="center" contentVerticalAlignment="center" on="YES" id="zaV-mh-eqb">
<rect key="frame" x="252" y="6" width="54" height="31"/>
<autoresizingMask key="autoresizingMask" flexibleMinX="YES" flexibleMaxY="YES"/>
<connections>
<action selector="toggleAllowRestartReminder:" destination="JYM-cs-i4H" eventType="valueChanged" id="F4e-k2-bni"/>
</connections>
</switch>
</subviews>
</tableViewCellContentView>
<connections>
<outlet property="accessoryView" destination="zaV-mh-eqb" id="irZ-hk-KoR"/>
</connections>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" textLabel="XC6-mj-vkg" style="IBUITableViewCellStyleDefault" id="5sU-vh-JDf">
<rect key="frame" x="0.0" y="99" width="320" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="5sU-vh-JDf" id="MDI-fb-989">
<rect key="frame" x="0.0" y="0.0" width="320" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="… with Notification" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="XC6-mj-vkg">
<rect key="frame" x="16" y="0.0" width="288" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<switch opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="750" verticalHuggingPriority="750" contentHorizontalAlignment="center" contentVerticalAlignment="center" on="YES" id="HaE-En-NH3">
<rect key="frame" x="252" y="6" width="54" height="31"/>
<autoresizingMask key="autoresizingMask" flexibleMinX="YES" flexibleMaxY="YES"/>
<connections>
<action selector="toggleAllowRestartNotify:" destination="JYM-cs-i4H" eventType="valueChanged" id="12C-h5-mrR"/>
</connections>
</switch>
</subviews>
</tableViewCellContentView>
<connections>
<outlet property="accessoryView" destination="HaE-En-NH3" id="dld-32-3vc"/>
</connections>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" textLabel="wHg-Wo-szR" style="IBUITableViewCellStyleDefault" id="01e-KG-qDH">
<rect key="frame" x="0.0" y="142.5" width="320" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="01e-KG-qDH" id="XWV-FF-VxR">
<rect key="frame" x="0.0" y="0.0" width="320" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="… with App Badge" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="wHg-Wo-szR">
<rect key="frame" x="16" y="0.0" width="288" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<switch opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="750" verticalHuggingPriority="750" contentHorizontalAlignment="center" contentVerticalAlignment="center" on="YES" id="N2Q-cU-pkd">
<rect key="frame" x="252" y="6" width="54" height="31"/>
<autoresizingMask key="autoresizingMask" flexibleMinX="YES" flexibleMaxY="YES"/>
<connections>
<action selector="toggleAllowRestartBadge:" destination="JYM-cs-i4H" eventType="valueChanged" id="76l-6y-fOu"/>
</connections>
</switch>
</subviews>
</tableViewCellContentView>
<connections>
<outlet property="accessoryView" destination="N2Q-cU-pkd" id="6LN-Zw-4Nf"/>
</connections>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" accessoryType="disclosureIndicator" indentationWidth="10" textLabel="UBT-zI-VNd" detailTextLabel="tj7-1l-bts" style="IBUITableViewCellStyleValue1" id="pAS-8r-oS5">
<rect key="frame" x="0.0" y="186" width="320" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="pAS-8r-oS5" id="iMr-Gb-dhX">
<rect key="frame" x="0.0" y="0.0" width="293" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Sound" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="UBT-zI-VNd">
<rect key="frame" x="16" y="12" width="49.5" height="20.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Detail" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="tj7-1l-bts">
<rect key="frame" x="241" y="12" width="44" height="20.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<color key="textColor" systemColor="secondaryLabelColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</tableViewCellContentView>
<connections>
<segue destination="I37-dZ-c9Q" kind="push" identifier="segueSoundRestartReminder" id="nBh-15-nBq"/>
</connections>
</tableViewCell>
</cells>
</tableViewSection>
<tableViewSection headerTitle="Recording Reminder" footerTitle="Very sporadic reminder. Triggered if your last recording is older than two weeks." id="9Wn-bc-wKX">
<cells>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" textLabel="u55-7Z-Q0Q" style="IBUITableViewCellStyleDefault" id="87B-MT-J9s">
<rect key="frame" x="0.0" y="449" width="320" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="87B-MT-J9s" id="79D-rZ-spg">
<rect key="frame" x="0.0" y="0.0" width="320" height="43.5"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Recording Reminder" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="u55-7Z-Q0Q">
<rect key="frame" x="16" y="0.0" width="288" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<switch opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="750" verticalHuggingPriority="750" contentHorizontalAlignment="center" contentVerticalAlignment="center" on="YES" translatesAutoresizingMaskIntoConstraints="NO" id="mTm-Rm-1RQ">
<rect key="frame" x="255" y="6" width="49" height="31"/>
<autoresizingMask key="autoresizingMask" flexibleMinX="YES" flexibleMaxY="YES"/>
<connections>
<action selector="toggleAllowRecordingReminder:" destination="JYM-cs-i4H" eventType="valueChanged" id="unC-Ur-jPM"/>
</connections>
</switch>
</subviews>
</tableViewCellContentView>
<connections>
<outlet property="accessoryView" destination="mTm-Rm-1RQ" id="MRe-3h-xfa"/>
</connections>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" accessoryType="disclosureIndicator" indentationWidth="10" textLabel="VYC-xF-awf" detailTextLabel="Ywb-pT-l1W" style="IBUITableViewCellStyleValue1" id="UkL-k7-bB7">
<rect key="frame" x="0.0" y="492.5" width="320" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="UkL-k7-bB7" id="fZv-NH-YA3">
<rect key="frame" x="0.0" y="0.0" width="293" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Sound" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="VYC-xF-awf">
<rect key="frame" x="16" y="12" width="49.5" height="20.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Detail" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="Ywb-pT-l1W">
<rect key="frame" x="241" y="12" width="44" height="20.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<color key="textColor" systemColor="secondaryLabelColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</tableViewCellContentView>
<connections>
<segue destination="I37-dZ-c9Q" kind="push" identifier="segueSoundRecordingReminder" id="xKB-i8-J9c"/>
</connections>
</tableViewCell>
</cells>
</tableViewSection>
</sections>
<connections>
<outlet property="dataSource" destination="JYM-cs-i4H" id="1ji-9q-6qB"/>
<outlet property="delegate" destination="JYM-cs-i4H" id="YU7-R1-VjB"/>
</connections>
</tableView>
<navigationItem key="navigationItem" title="Reminders" id="Z9N-kQ-xhI"/>
<connections>
<outlet property="recordingAllow" destination="mTm-Rm-1RQ" id="tqz-Pk-pSi"/>
<outlet property="recordingSound" destination="UkL-k7-bB7" id="Y9s-fL-cQU"/>
<outlet property="restartAllow" destination="zaV-mh-eqb" id="zcQ-1e-i4H"/>
<outlet property="restartAllowBadge" destination="N2Q-cU-pkd" id="RPU-f6-Dv3"/>
<outlet property="restartAllowNotify" destination="HaE-En-NH3" id="1BN-5h-zNR"/>
<outlet property="restartSound" destination="pAS-8r-oS5" id="9Dy-QR-WXq"/>
</connections>
</tableViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="W7H-LK-3wW" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="-200" y="768"/>
</scene>
<!--Sound-->
<scene sceneID="3a5-wn-tdm">
<objects>
<tableViewController id="I37-dZ-c9Q" customClass="TVCChooseAlertTone" customModule="AppCheck" customModuleProvider="target" sceneMemberID="viewController">
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="prototypes" style="grouped" separatorStyle="default" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="18" sectionFooterHeight="18" id="w2R-BE-lM4">
<rect key="frame" x="0.0" y="0.0" width="320" height="568"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<prototypes>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" accessoryType="checkmark" indentationWidth="10" reuseIdentifier="SettingsAlertToneCell" textLabel="O50-Db-5TI" style="IBUITableViewCellStyleDefault" id="38V-eP-HSv">
<rect key="frame" x="0.0" y="55.5" width="320" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="38V-eP-HSv" id="hoG-Cy-sHr">
<rect key="frame" x="0.0" y="0.0" width="280" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Title" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="O50-Db-5TI">
<rect key="frame" x="16" y="0.0" width="256" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</tableViewCellContentView>
</tableViewCell>
</prototypes>
<sections/>
<connections>
<outlet property="dataSource" destination="I37-dZ-c9Q" id="aji-Ci-VQs"/>
<outlet property="delegate" destination="I37-dZ-c9Q" id="YUg-WL-kh8"/>
</connections>
</tableView>
<navigationItem key="navigationItem" title="Sound" id="5hI-rp-d1Z"/>
</tableViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="NXc-3P-uoW" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="1500" y="768"/>
</scene>
<!--Connection Alerts-->
<scene sceneID="JyV-QU-Dw8">
<objects>
<tableViewController id="D2a-Po-vDU" customClass="TVCConnectionAlerts" customModule="AppCheck" customModuleProvider="target" sceneMemberID="viewController">
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="static" style="grouped" separatorStyle="default" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="18" sectionFooterHeight="18" id="u0e-LW-1Qh">
<rect key="frame" x="0.0" y="0.0" width="320" height="568"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<sections>
<tableViewSection headerTitle="Connection Alerts" footerTitle="Get a notification whenever a specific DNS request occurs." id="4OJ-qA-l8L">
<cells>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" textLabel="9g7-rO-sIS" style="IBUITableViewCellStyleDefault" id="8hz-pm-rV5">
<rect key="frame" x="0.0" y="55.5" width="320" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="8hz-pm-rV5" id="fz3-2p-ave">
<rect key="frame" x="0.0" y="0.0" width="320" height="43.5"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Enable Alerts" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="9g7-rO-sIS">
<rect key="frame" x="16" y="0.0" width="288" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<switch opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="750" verticalHuggingPriority="750" contentHorizontalAlignment="center" contentVerticalAlignment="center" on="YES" translatesAutoresizingMaskIntoConstraints="NO" id="who-8G-voz">
<rect key="frame" x="256" y="6" width="48" height="31"/>
<autoresizingMask key="autoresizingMask" flexibleMinX="YES" flexibleMaxY="YES"/>
<connections>
<action selector="toggleShowNotifications:" destination="D2a-Po-vDU" eventType="valueChanged" id="Thg-6R-7wM"/>
</connections>
</switch>
</subviews>
</tableViewCellContentView>
<connections>
<outlet property="accessoryView" destination="who-8G-voz" id="6YE-dG-ThI"/>
</connections>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" accessoryType="disclosureIndicator" indentationWidth="10" textLabel="pck-pT-tnX" detailTextLabel="l8v-5i-Zue" style="IBUITableViewCellStyleValue1" id="laE-pg-nAE">
<rect key="frame" x="0.0" y="99" width="320" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="laE-pg-nAE" id="157-KR-1R5">
<rect key="frame" x="0.0" y="0.0" width="293" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Sound" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="pck-pT-tnX">
<rect key="frame" x="16" y="12" width="49.5" height="20.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Detail" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="l8v-5i-Zue">
<rect key="frame" x="241" y="12" width="44" height="20.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<color key="textColor" systemColor="secondaryLabelColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</tableViewCellContentView>
<connections>
<segue destination="I37-dZ-c9Q" kind="push" id="tUF-Kv-koO"/>
</connections>
</tableViewCell>
</cells>
</tableViewSection>
<tableViewSection headerTitle="Operation Mode" footerTitle="Select whether you'd like to manually include or manually exclude specific domains from notifications." id="9fV-UJ-d1S">
<cells>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" accessoryType="checkmark" indentationWidth="10" textLabel="Lug-Bp-oz0" style="IBUITableViewCellStyleDefault" id="ZMb-xn-r8o">
<rect key="frame" x="0.0" y="234" width="320" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="ZMb-xn-r8o" id="eT6-OU-8eU">
<rect key="frame" x="0.0" y="0.0" width="280" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Notify only selected lists" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="Lug-Bp-oz0">
<rect key="frame" x="16" y="0.0" width="256" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</tableViewCellContentView>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" accessoryType="checkmark" indentationWidth="10" textLabel="FRE-W4-dw2" style="IBUITableViewCellStyleDefault" id="47P-B8-Sul">
<rect key="frame" x="0.0" y="277.5" width="320" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="47P-B8-Sul" id="RbD-bN-NCj">
<rect key="frame" x="0.0" y="0.0" width="280" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Notify all, except selected" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="FRE-W4-dw2">
<rect key="frame" x="16" y="0.0" width="256" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</tableViewCellContentView>
</tableViewCell>
</cells>
</tableViewSection>
<tableViewSection headerTitle="Include" id="Lus-cA-eCF">
<cells>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" accessoryType="checkmark" indentationWidth="10" textLabel="be7-GU-inU" style="IBUITableViewCellStyleDefault" id="2bN-EB-rDk">
<rect key="frame" x="0.0" y="421" width="320" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="2bN-EB-rDk" id="UxC-Sm-W4n">
<rect key="frame" x="0.0" y="0.0" width="280" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Blocked Domains" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="be7-GU-inU">
<rect key="frame" x="16" y="0.0" width="256" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</tableViewCellContentView>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" accessoryType="checkmark" indentationWidth="10" textLabel="fYg-Mq-C4Q" style="IBUITableViewCellStyleDefault" id="UTd-2r-8c7">
<rect key="frame" x="0.0" y="464.5" width="320" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="UTd-2r-8c7" id="Z6Y-qL-4bw">
<rect key="frame" x="0.0" y="0.0" width="280" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Domains of List A" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="fYg-Mq-C4Q">
<rect key="frame" x="16" y="0.0" width="256" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</tableViewCellContentView>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" accessoryType="checkmark" indentationWidth="10" textLabel="f4F-j9-uiy" style="IBUITableViewCellStyleDefault" id="dk0-Vg-0Zl">
<rect key="frame" x="0.0" y="508" width="320" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="dk0-Vg-0Zl" id="69j-ye-ziM">
<rect key="frame" x="0.0" y="0.0" width="280" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Domains of List B" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="f4F-j9-uiy">
<rect key="frame" x="16" y="0.0" width="256" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</tableViewCellContentView>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" accessoryType="checkmark" indentationWidth="10" textLabel="RiJ-Eq-LiA" style="IBUITableViewCellStyleDefault" id="VTi-I6-dXQ">
<rect key="frame" x="0.0" y="551.5" width="320" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="VTi-I6-dXQ" id="PbB-ri-ibd">
<rect key="frame" x="0.0" y="0.0" width="280" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Not in Any List" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="RiJ-Eq-LiA">
<rect key="frame" x="16" y="0.0" width="256" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</tableViewCellContentView>
</tableViewCell>
</cells>
</tableViewSection>
<tableViewSection headerTitle="Custom Lists" footerTitle="" id="5bN-ic-93C">
<cells>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" accessoryType="disclosureIndicator" indentationWidth="10" textLabel="1fx-L3-esi" detailTextLabel="SAD-ad-RUa" style="IBUITableViewCellStyleValue2" id="fzJ-h6-8Ll">
<rect key="frame" x="0.0" y="658.5" width="320" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="fzJ-h6-8Ll" id="u6I-My-k4J">
<rect key="frame" x="0.0" y="0.0" width="293" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="List A" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="1fx-L3-esi">
<rect key="frame" x="16" y="13" width="91" height="18"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="0 Domains" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="SAD-ad-RUa">
<rect key="frame" x="113" y="13" width="73" height="18"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/>
<color key="textColor" systemColor="secondaryLabelColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</tableViewCellContentView>
<connections>
<segue destination="q3B-Yi-1bx" kind="push" identifier="segueFilterListCustomA" id="2ak-aX-wVl"/>
</connections>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" accessoryType="disclosureIndicator" indentationWidth="10" textLabel="bP5-xR-UWP" detailTextLabel="3b0-Aj-9So" style="IBUITableViewCellStyleValue2" id="uTh-Xw-vp2">
<rect key="frame" x="0.0" y="702.5" width="320" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="uTh-Xw-vp2" id="h1L-kH-yQX">
<rect key="frame" x="0.0" y="0.0" width="293" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="List B" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="bP5-xR-UWP">
<rect key="frame" x="16" y="13" width="91" height="18"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="0 Domains" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="3b0-Aj-9So">
<rect key="frame" x="113" y="13" width="73" height="18"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/>
<color key="textColor" systemColor="secondaryLabelColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</tableViewCellContentView>
<connections>
<segue destination="q3B-Yi-1bx" kind="push" identifier="segueFilterListCustomB" id="6wc-d1-VYY"/>
</connections>
</tableViewCell>
</cells>
</tableViewSection>
</sections>
<connections>
<outlet property="dataSource" destination="D2a-Po-vDU" id="4t8-lb-7wO"/>
<outlet property="delegate" destination="D2a-Po-vDU" id="I1e-X5-6xm"/>
</connections>
</tableView>
<navigationItem key="navigationItem" title="Connection Alerts" id="5Re-pU-mt2"/>
<connections>
<outlet property="cellSound" destination="laE-pg-nAE" id="Qd5-mf-wox"/>
<outlet property="listsCustomA" destination="fzJ-h6-8Ll" id="77h-42-70y"/>
<outlet property="listsCustomB" destination="uTh-Xw-vp2" id="6OI-pU-Vff"/>
<outlet property="showNotifications" destination="who-8G-voz" id="cUz-Bg-ftS"/>
</connections>
</tableViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="1z3-Sx-YAL" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="650" y="384"/>
</scene>
</scenes>
<inferredMetricsTieBreakers>
<segue reference="tUF-Kv-koO"/>
<segue reference="6wc-d1-VYY"/>
</inferredMetricsTieBreakers>
</document>

View File

@@ -0,0 +1,237 @@
import UIKit
class CustomAlert<CustomView: UIView>: UIViewController {
private let alertTitle: String?
private let alertDetail: String?
private let customView: CustomView
private var callback: ((CustomView) -> Void)?
/// Default: `[Cancel, Save]`
let buttonsBar: UIStackView = {
let cancel = QuickUI.button("Cancel", target: self, action: #selector(didTapCancel))
let save = QuickUI.button("Save", target: self, action: #selector(didTapSave))
save.titleLabel?.font = save.titleLabel?.font.bold()
let bar = UIStackView(arrangedSubviews: [cancel, save])
bar.axis = .horizontal
bar.distribution = .equalSpacing
return bar
}()
// MARK: - Init
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
init(title: String? = nil, detail: String? = nil, view custom: CustomView) {
alertTitle = title
alertDetail = detail
customView = custom
super.init(nibName: nil, bundle: nil)
}
override var isModalInPresentation: Bool { set{} get{true} }
override var modalPresentationStyle: UIModalPresentationStyle { set{} get{.custom} }
override var transitioningDelegate: UIViewControllerTransitioningDelegate? {
set {} get {
SlideInTransitioningDelegate(for: .bottom, modal: true)
}
}
internal override func loadView() {
let control = UIView()
control.backgroundColor = .sysBackground
view = control
var tmpPrevivous: UIView? = nil
func adaptive(margin: CGFloat, _ fn: () -> NSLayoutConstraint) {
regularConstraints.append(fn() + margin)
compactConstraints.append(fn() + margin/2)
}
func addLabel(_ lbl: UILabel) {
lbl.numberOfLines = 0
control.addSubview(lbl)
lbl.anchor([.leading, .trailing], to: control.layoutMarginsGuide)
if let p = tmpPrevivous {
adaptive(margin: 16) { lbl.topAnchor =&= p.bottomAnchor }
} else {
adaptive(margin: 12) { lbl.topAnchor =&= control.layoutMarginsGuide.topAnchor }
}
tmpPrevivous = lbl
}
// Alert title & description
if let t = alertTitle {
let lbl = QuickUI.label(t, align: .center, style: .subheadline)
lbl.font = lbl.font.bold()
addLabel(lbl)
}
if let d = alertDetail {
addLabel(QuickUI.label(d, align: .center, style: .footnote))
}
// User content
control.addSubview(customView)
customView.anchor([.leading, .trailing], to: control)
if let p = tmpPrevivous {
customView.topAnchor =&= p.bottomAnchor | .defaultHigh
} else {
customView.topAnchor =&= control.layoutMarginsGuide.topAnchor
}
// Action buttons
control.addSubview(buttonsBar)
buttonsBar.anchor([.leading, .trailing], to: control.layoutMarginsGuide, margin: 8)
buttonsBar.topAnchor =&= customView.bottomAnchor | .defaultHigh
adaptive(margin: 12) { control.layoutMarginsGuide.bottomAnchor =&= buttonsBar.bottomAnchor }
adaptToNewTraits(traitCollection)
view.frame.size = view.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
}
// MARK: - Adaptive Traits
private var compactConstraints: [NSLayoutConstraint] = []
private var regularConstraints: [NSLayoutConstraint] = []
private func adaptToNewTraits(_ traits: UITraitCollection) {
let flag = traits.verticalSizeClass == .compact
NSLayoutConstraint.deactivate(flag ? regularConstraints : compactConstraints)
NSLayoutConstraint.activate(flag ? compactConstraints : regularConstraints)
view.setNeedsLayout()
}
override func willTransition(to newCollection: UITraitCollection, with coordinator: UIViewControllerTransitionCoordinator) {
super.willTransition(to: newCollection, with: coordinator)
adaptToNewTraits(newCollection)
}
// MARK: - User Interaction
override var keyCommands: [UIKeyCommand]? {
[UIKeyCommand(input: UIKeyCommand.inputEscape, modifierFlags: [], action: #selector(didTapCancel))]
}
@objc private func didTapCancel() {
callback = nil
dismiss(animated: true)
}
@objc private func didTapSave() {
dismiss(animated: true) {
self.callback?(self.customView)
self.callback = nil
}
}
// MARK: - Present & Dismiss
func present(in viewController: UIViewController, onSuccess: @escaping (CustomView) -> Void) {
callback = onSuccess
viewController.present(self, animated: true)
}
}
// ###################################
// #
// # MARK: - Date Picker Alert
// #
// ###################################
class DatePickerAlert : CustomAlert<UIDatePicker> {
let datePicker = UIDatePicker()
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
init(title: String? = nil, detail: String? = nil, initial date: Date? = nil) {
if let date = date {
datePicker.setDate(date, animated: false)
}
super.init(title: title, detail: detail, view: datePicker)
let now = QuickUI.button("Now", target: self, action: #selector(didTapNow))
now.titleLabel?.font = now.titleLabel?.font.bold()
now.setTitleColor(.sysLabel, for: .normal)
buttonsBar.insertArrangedSubview(now, at: 1)
}
@objc private func didTapNow() {
datePicker.date = Date()
}
func present(in viewController: UIViewController, onSuccess: @escaping (UIDatePicker, Date) -> Void) {
super.present(in: viewController) {
onSuccess($0, $0.date)
}
}
}
// #######################################
// #
// # MARK: - Duration Picker Alert
// #
// #######################################
class DurationPickerAlert: CustomAlert<UIPickerView>, UIPickerViewDataSource, UIPickerViewDelegate {
let pickerView = UIPickerView()
private let dataSource: [[String]]
private let compWidths: [CGFloat]
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
/// - Parameter options: [[List of labels] per component]
/// - Parameter widths: If `nil` set all components to equal width
init(title: String? = nil, detail: String? = nil, options: [[String]], widths: [CGFloat]? = nil) {
assert(widths == nil || widths!.count == options.count, "widths.count != options.count")
dataSource = options
compWidths = widths ?? options.map { _ in 1 / CGFloat(options.count) }
super.init(title: title, detail: detail, view: pickerView)
pickerView.dataSource = self
pickerView.delegate = self
}
func numberOfComponents(in _: UIPickerView) -> Int {
dataSource.count
}
func pickerView(_: UIPickerView, numberOfRowsInComponent c: Int) -> Int {
dataSource[c].count
}
func pickerView(_: UIPickerView, titleForRow r: Int, forComponent c: Int) -> String? {
dataSource[c][r]
}
func pickerView(_ pickerView: UIPickerView, widthForComponent c: Int) -> CGFloat {
compWidths[c] * pickerView.frame.width
}
func present(in viewController: UIViewController, onSuccess: @escaping (UIPickerView, [Int]) -> Void) {
super.present(in: viewController) {
onSuccess($0, $0.selection)
}
}
}
extension UIPickerView {
var selection: [Int] {
get { (0..<numberOfComponents).map { selectedRow(inComponent: $0) } }
set { setSelection(newValue) }
}
/// - Warning: Does not check for boundaries!
func setSelection(_ selection: [Int], animated: Bool = false) {
assert(selection.count == numberOfComponents, "selection.count != components.count")
for (c, i) in selection.enumerated() {
selectRow(i, inComponent: c, animated: animated)
}
}
}

View File

@@ -1,34 +1,27 @@
import UIKit
protocol FilterPipelineDelegate: UITableViewController {
/// Currently only called when a row is moved and the `tableView` is frontmost.
func rowNeedsUpdate(_ row: Int)
protocol FilterPipelineDelegate: AnyObject {
/// Call `reloadData()`
func filterPipelineDidReset()
/// Call `safeDeleteRows()`
func filterPipeline(delete rows: [Int])
/// Call `safeInsertRow()`
func filterPipeline(insert row: Int)
/// Call `safeReloadRow()`
func filterPipeline(update row: Int)
/// Call `safeMoveRow()`
func filterPipeline(move oldRow: Int, to newRow: Int)
}
// MARK: FilterPipeline
// MARK: - FilterPipeline
class FilterPipeline<T> {
typealias DataSourceQuery = () -> [T]
private var sourceQuery: DataSourceQuery!
private(set) fileprivate var dataSource: [T] = []
private var pipeline: [PipelineFilter<T>] = []
private var display: PipelineSorting<T>!
private(set) weak var delegate: FilterPipelineDelegate?
private var cellAnimations: Bool = true
required init(withDelegate: FilterPipelineDelegate) {
delegate = withDelegate
}
/// Set a new `dataSource` query and immediately apply all filters and sorting.
/// - Note: You must call `reload(fromSource:whenDone:)` manually!
/// - Note: Always use `[unowned self]`
func setDataSource(query: @escaping DataSourceQuery) {
sourceQuery = query
}
weak var delegate: FilterPipelineDelegate?
/// - Returns: Number of elements in `projection`
@inline(__always) func displayObjectCount() -> Int { display.projection.count }
@@ -49,20 +42,12 @@ class FilterPipeline<T> {
return (i, dataSource[i])
}
/// Re-query data source and re-built filter and display sorting order.
/// - Note: Will call `reloadData()` before `whenDone` closure is executed. But only if `cellAnimations` are enabled.
/// - Parameter fromSource: If `false` only re-built filter and sort order
func reload(fromSource: Bool, whenDone: @escaping () -> Void) {
DispatchQueue.global().async {
if fromSource {
self.dataSource = self.sourceQuery()
}
self.resetFilters()
DispatchQueue.main.sync {
self.reloadTableCells()
whenDone()
}
}
/// Set new data source and re-built filter and display sorting order.
/// - Note: Will call `filterPipelineDidReset()`
func reset(dataSource: [T]) {
self.dataSource = dataSource
self.resetFilters()
delegate?.filterPipelineDidReset()
}
/// Returns the index set of either the last filter layer, or `dataSource` if no filter is set.
@@ -80,12 +65,14 @@ class FilterPipeline<T> {
/// Add new filter layer. Each layer is applied upon the previous layer. Therefore, each filter
/// can only restrict the display further. A filter cannot introduce previously removed elements.
/// - Note: Will call `reloadData()` if `cellAnimations` are enabled.
/// - Warning: Use `[unowned self]` to prevent retain cycles!
/// - Note: Will call `filterPipelineDidReset()`
/// - Parameters:
/// - identifier: Use this id to find the filter again. For reload and remove operations.
/// - otherId: If `nil` or non-existent the new filter will be appended at the end.
/// - predicate: Return `true` if you want to keep the element.
func addFilter(_ identifier: String, before otherId: String? = nil, _ predicate: @escaping PipelineFilter<T>.Predicate) {
guard indexOfFilter(identifier) == nil else { return }
let newFilter = PipelineFilter(identifier, predicate)
if let other = otherId, let i = indexOfFilter(other) {
pipeline.insert(newFilter, at: i)
@@ -95,37 +82,48 @@ class FilterPipeline<T> {
pipeline.append(newFilter)
display?.apply(moreRestrictive: newFilter.selection)
}
reloadTableCells()
delegate?.filterPipelineDidReset()
}
/// Find and remove filter with given identifier. Will automatically update remaining filters and display sorting.
/// - Note: Will call `reloadData()` if `cellAnimations` are enabled.
/// - Note: Will call `filterPipelineDidReset()`
func removeFilter(withId ident: String) {
guard let i = indexOfFilter(ident) else { return }
pipeline.remove(at: i)
if i == pipeline.count {
// only if we don't reset other layers we can assure `toLessRestrictive`
display?.reset(toLessRestrictive: lastLayerIndices())
display?.apply(lessRestrictive: lastLayerIndices())
} else {
resetFilters(startingAt: i)
}
reloadTableCells()
delegate?.filterPipelineDidReset()
}
/// Start filter evaluation on all entries from previous filter.
/// - Note: Will call `reloadData()` if `cellAnimations` are enabled.
/// - Note: Will call `filterPipelineDidReset()`
func reloadFilter(withId ident: String) {
guard let i = indexOfFilter(ident) else { return }
resetFilters(startingAt: i)
reloadTableCells()
delegate?.filterPipelineDidReset()
}
/// Sets the sort and display order. You should set the `delegate` to automatically update your `tableView`.
/// - Note: Will call `reloadData()` if `cellAnimations` are enabled.
/// - Warning: Use `[unowned self]` to prevent retain cycles!
/// - Note: Will call `filterPipelineDidReset()`
/// - Parameter predicate: Return `true` if first element should be sorted before second element.
func setSorting(_ predicate: @escaping PipelineSorting<T>.Predicate) {
display = .init(predicate, pipe: self)
reloadTableCells()
delegate?.filterPipelineDidReset()
}
/// Will reverse the current display order without resorting. This is faster than setting a new sorting `predicate`.
/// However, the `predicate` must be dynamic and support a sort order flag.
/// - Note: Will call `filterPipelineDidReset()`
/// - Warning: Make sure `predicate` does reflect the change or it will lead to data inconsistency!
func reverseSorting() {
// TODO: use semaphore to prevent concurrent edits
display?.reverseOrder()
delegate?.filterPipelineDidReset()
}
/// Re-built filter and display sorting order.
@@ -164,26 +162,8 @@ class FilterPipeline<T> {
// MARK: data updates
/// Disable individual cell updates (update, move, insert & remove actions)
func pauseCellAnimations(if condition: Bool = true) {
cellAnimations = !condition && delegate?.tableView.isFrontmost ?? false
}
/// Allow individual cell updates (update, move, insert & remove actions) if tableView `isFrontmost`
/// - Parameter reloadTable: If `true` and cell animations are disabled, perform `tableView.reloadData()`
func continueCellAnimations(reloadTable: Bool = true) {
if !cellAnimations {
cellAnimations = true
if reloadTable { delegate?.tableView.reloadData() }
}
}
/// Reload table but only if `cellAnimations` is enabled.
func reloadTableCells() {
if cellAnimations { delegate?.tableView.reloadData() }
}
/// Add new element to the original `dataSource` and immediately apply filter and sorting.
/// - Note: Will call `filterPipeline(insert:)` if not filtered.
/// - Complexity: O((*m*+1) log *n*), where *m* is the number of filters and *n* the number of elements in each filter.
func addNew(_ obj: T) {
let index = dataSource.count
@@ -193,10 +173,11 @@ class FilterPipeline<T> {
}
// survived all filters
let displayIndex = display.insertNew(index)
if cellAnimations { delegate?.tableView.safeInsertRow(displayIndex, with: .left) }
delegate?.filterPipeline(insert: displayIndex)
}
/// Update element at `index` in the original `dataSource` and immediately re-apply filter and sorting.
/// - Note: Will call `filterPipeline(delete:)`, `(insert:)`, `(update:)`, or `(move:)`
/// - Parameters:
/// - obj: Element to be added. Will overwrite previous `dataSource` object.
/// - index: Index in the original `dataSource`
@@ -210,27 +191,23 @@ class FilterPipeline<T> {
let oldPos = display.deleteOld(index)
dataSource[index] = obj
guard status.display else {
if cellAnimations, oldPos != -1 { delegate?.tableView.safeDeleteRows([oldPos]) }
if oldPos != -1 { delegate?.filterPipeline(delete: [oldPos]) }
return
}
let newPos = display.insertNew(index, previousIndex: oldPos)
if cellAnimations {
if oldPos == -1 {
delegate?.tableView.safeInsertRow(newPos, with: .left)
if oldPos == -1 {
delegate?.filterPipeline(insert: newPos)
} else {
if oldPos == newPos {
delegate?.filterPipeline(update: oldPos)
} else {
if oldPos == newPos {
delegate?.tableView.safeReloadRow(oldPos)
} else {
delegate?.tableView.safeMoveRow(oldPos, to: newPos)
if delegate?.tableView.isFrontmost ?? false {
delegate?.rowNeedsUpdate(newPos)
}
}
delegate?.filterPipeline(move: oldPos, to: newPos)
}
}
}
/// Remove elements from the original `dataSource`, from all filters, and from display sorting.
/// - Note: Will call `filterPipeline(delete:)` if `sorted` array is not empty.
/// - Parameter sorted: Indices in the original `dataSource`
/// - Complexity: O(*t*(*m*+*n*) + *m* log *n*), where *t* is the number of filters,
/// *m* the number of elements in each filter / projection, and *n* the length of `sorted` indices.
@@ -243,14 +220,16 @@ class FilterPipeline<T> {
filter.shiftRemove(indices: sorted)
}
let indices = display.shiftRemove(indices: sorted)
if cellAnimations { delegate?.tableView.safeDeleteRows(indices) }
delegate?.filterPipeline(delete: indices)
}
}
// MARK: - Filter
class PipelineFilter<T> {
class PipelineFilter<T>: CustomStringConvertible {
var description: String { "\(Self.self)(id: \(id))" }
typealias Predicate = (T) -> Bool
let id: String
@@ -344,6 +323,7 @@ class PipelineSorting<T> {
/// Create a fresh, already sorted, display order projection.
/// - Parameter predicate: Return `true` if first element should be sorted before second element.
/// - Complexity: O(*n* log *n*), where *n* is the length of the `filter`.
required init(_ predicate: @escaping Predicate, pipe: FilterPipeline<T>) {
comperator = { [unowned pipe] in
predicate(pipe.dataSource[$0], pipe.dataSource[$1])
@@ -351,6 +331,12 @@ class PipelineSorting<T> {
reset(to: pipe.lastLayerIndices())
}
/// - Warning: Make sure `predicate` does reflect the change. Or it will lead to data inconsistency.
/// - Complexity: O(*n*), where *n* is the length of the `filter`.
fileprivate func reverseOrder() {
projection.reverse()
}
/// Replace current `projection` with new filter indices and apply sorting.
/// - Complexity: O(*n* log *n*), where *n* is the length of the `filter`.
fileprivate func reset(to filterIndices: [Int]) {
@@ -367,7 +353,7 @@ class PipelineSorting<T> {
/// After removing a layer of filtering the previous layers are less restrictive and thus contain more indices.
/// Therefore, the difference between both index sets will be inserted into the projection.
/// - Complexity: O(*m* log *n*), where *m* is the difference to the previous layer and *n* is the length of the `projection`.
fileprivate func reset(toLessRestrictive filterIndices: [Int]) {
fileprivate func apply(lessRestrictive filterIndices: [Int]) {
for x in filterIndices.difference(toSubset: projection.sorted(), compare: (<)) {
insertNew(x)
}
@@ -380,8 +366,9 @@ class PipelineSorting<T> {
/// - Returns: Index in the projection
/// - Complexity: O(log *n*), where *n* is the length of the `projection`.
@discardableResult fileprivate func insertNew(_ index: Int, previousIndex prev: Int = -1) -> Int {
if prev >= 0, prev < projection.count {
if (prev == 0 || !comperator(index, projection[prev - 1])), !comperator(projection[prev], index) {
if prev >= 0, prev <= projection.count { // '<=' because previous delete removed one element
if (prev == 0 || !comperator(index, projection[prev - 1])),
(prev == projection.count || !comperator(projection[prev], index)) {
// If element can be inserted at the same position without resorting, do that
projection.insert(index, at: prev)
return prev

View File

@@ -0,0 +1,83 @@
import UIKit
import CoreGraphics
// MARK: White Triangle Popup Arrow
@IBDesignable
class PopupTriangle: UIView {
@IBInspectable var rotation: CGFloat = 0
@IBInspectable var color: UIColor = .black
override func draw(_ rect: CGRect) {
guard let c = UIGraphicsGetCurrentContext() else { return }
let w = rect.width, h = rect.height
switch rotation {
case 90: // right
c.lineFromTo(x1: 0, y1: 0, x2: w, y2: h/2)
c.addLine(to: CGPoint(x: 0, y: h))
case 180: // bottom
c.lineFromTo(x1: w, y1: 0, x2: w/2, y2: h)
c.addLine(to: CGPoint(x: 0, y: 0))
case 270: // left
c.lineFromTo(x1: w, y1: h, x2: 0, y2: h/2)
c.addLine(to: CGPoint(x: w, y: 0))
default: // top
c.lineFromTo(x1: 0, y1: h, x2: w/2, y2: 0)
c.addLine(to: CGPoint(x: w, y: h))
}
c.closePath()
c.setFillColor(color.cgColor)
c.fillPath()
}
}
// MARK: Label as Tag Bubble
@IBDesignable
class TagLabel: UILabel {
private var em: CGFloat { font.pointSize }
@IBInspectable var padTop: CGFloat = 0
@IBInspectable var padLeft: CGFloat = 0
@IBInspectable var padRight: CGFloat = 0
@IBInspectable var padBottom: CGFloat = 0
private var padding: UIEdgeInsets {
.init(top: padTop + em/6, left: padLeft + em/3,
bottom: padBottom + em/6, right: padRight + em/3)
}
override func textRect(forBounds bounds: CGRect, limitedToNumberOfLines numberOfLines: Int) -> CGRect {
let i = padding
let ii = UIEdgeInsets(top: -i.top, left: -i.left, bottom: -i.bottom, right: -i.right)
return super.textRect(forBounds: bounds.inset(by: i),
limitedToNumberOfLines: numberOfLines).inset(by: ii)
}
override func drawText(in rect: CGRect) {
layer.masksToBounds = true
layer.cornerRadius = em/2.5
super.drawText(in: rect.inset(by: padding))
}
}
// MARK: Percentage meter
@IBDesignable
class MeterBar: UIView {
@IBInspectable var percent: CGFloat = 0 { didSet { setNeedsDisplay() } }
@IBInspectable var barColor: UIColor = .sysLink
@IBInspectable var horizontal: Bool = false
private var normPercent: CGFloat { 1 - max(0, min(percent, 1)) }
override func draw(_ rect: CGRect) {
let c = UIGraphicsGetCurrentContext()
c?.setFillColor(barColor.cgColor)
if horizontal {
c?.fill(rect.insetBy(dx: normPercent * (rect.width/2), dy: 0))
} else {
c?.fill(rect.insetBy(dx: 0, dy: normPercent * (rect.height/2)))
}
}
}

View File

@@ -0,0 +1,122 @@
import Foundation
enum Prefs {
private static var suite: UserDefaults { UserDefaults.standard }
private static func Int(_ key: String) -> Int { suite.integer(forKey: key) }
private static func Int(_ key: String, _ val: Int) { suite.set(val, forKey: key) }
private static func Bool(_ key: String) -> Bool { suite.bool(forKey: key) }
private static func Bool(_ key: String, _ val: Bool) { suite.set(val, forKey: key) }
private static func Str(_ key: String) -> String? { suite.string(forKey: key) }
private static func Str(_ key: String, _ val: String?) { suite.set(val, forKey: key) }
private static func Obj(_ key: String) -> Any? { suite.object(forKey: key) }
private static func Obj(_ key: String, _ val: Any?) { suite.set(val, forKey: key) }
static func registerDefaults() {
suite.register(defaults: [
"RecordingReminderEnabled" : true,
"contextAnalyisCoOccurrenceTime" : 5,
])
}
}
// MARK: - Tutorial
extension Prefs {
enum DidShowTutorial {
static var Welcome: Bool {
get { Prefs.Bool("didShowTutorialAppWelcome") }
set { Prefs.Bool("didShowTutorialAppWelcome", newValue) }
}
static var Recordings: Bool {
get { Prefs.Bool("didShowTutorialRecordings") }
set { Prefs.Bool("didShowTutorialRecordings", newValue) }
}
}
}
// MARK: - Date Filter
enum DateFilterKind: Int {
case Off = 0, LastXMin = 1, ABRange = 2;
}
enum DateFilterOrderBy: Int {
case Date = 0, Name = 1, Count = 2;
}
extension Prefs {
enum DateFilter {
static var Kind: DateFilterKind {
get { DateFilterKind(rawValue: Prefs.Int("dateFilterType"))! }
set { Prefs.Int("dateFilterType", newValue.rawValue) }
}
/// Default: `0` (disabled)
static var LastXMin: Int {
get { Prefs.Int("dateFilterLastXMin") }
set { Prefs.Int("dateFilterLastXMin", newValue) }
}
/// Default: `nil` (disabled)
static var RangeA: Timestamp? {
get { Prefs.Obj("dateFilterRangeA") as? Timestamp }
set { Prefs.Obj("dateFilterRangeA", newValue) }
}
/// Default: `nil` (disabled)
static var RangeB: Timestamp? {
get { Prefs.Obj("dateFilterRangeB") as? Timestamp }
set { Prefs.Obj("dateFilterRangeB", newValue) }
}
/// default: `.Date`
static var OrderBy: DateFilterOrderBy {
get { DateFilterOrderBy(rawValue: Prefs.Int("dateFilterOderType"))! }
set { Prefs.Int("dateFilterOderType", newValue.rawValue) }
}
/// default: `false` (Desc)
static var OrderAsc: Bool {
get { Prefs.Bool("dateFilterOderAsc") }
set { Prefs.Bool("dateFilterOderAsc", newValue) }
}
/// - Returns: Timestamp restriction depending on current selected date filter.
/// - `Off` : `(nil, nil)`
/// - `LastXMin` : `(now-LastXMin, nil)`
/// - `ABRange` : `(RangeA, RangeB)`
static func restrictions() -> (type: DateFilterKind, earliest: Timestamp?, latest: Timestamp?) {
let type = Kind
switch type {
case .Off: return (type, nil, nil)
case .LastXMin: return (type, Timestamp.past(minutes: Prefs.DateFilter.LastXMin), nil)
case .ABRange: return (type, Prefs.DateFilter.RangeA, Prefs.DateFilter.RangeB)
}
}
}
}
// MARK: - ContextAnalyis
extension Prefs {
enum ContextAnalyis {
static var CoOccurrenceTime: Int {
get { Prefs.Int("contextAnalyisCoOccurrenceTime") }
set { Prefs.Int("contextAnalyisCoOccurrenceTime", newValue) }
}
}
}
// MARK: - Notifications
extension Prefs {
enum RecordingReminder {
static var Enabled: Bool {
get { Prefs.Bool("RecordingReminderEnabled") }
set { Prefs.Bool("RecordingReminderEnabled", newValue) }
}
static var Sound: String {
get { Prefs.Str("RecordingReminderSound") ?? "#default" }
set { Prefs.Str("RecordingReminderSound", newValue) }
}
}
}

View File

@@ -0,0 +1,82 @@
import Foundation
enum PrefsShared {
private static var suite: UserDefaults { UserDefaults(suiteName: "group.de.uni-bamberg.psi.AppCheck")! }
private static func Int(_ key: String) -> Int { suite.integer(forKey: key) }
private static func Int(_ key: String, _ val: Int) { suite.set(val, forKey: key); suite.synchronize() }
private static func Bool(_ key: String) -> Bool { suite.bool(forKey: key) }
private static func Bool(_ key: String, _ val: Bool) { suite.set(val, forKey: key); suite.synchronize() }
private static func Str(_ key: String) -> String? { suite.string(forKey: key) }
private static func Str(_ key: String, _ val: String?) { suite.set(val, forKey: key); suite.synchronize() }
static func registerDefaults() {
suite.register(defaults: [
"RestartReminderEnabled" : true,
"RestartReminderWithText" : true,
"RestartReminderWithBadge" : true,
"ConnectionAlertsListsElse" : true,
])
}
static var AutoDeleteLogsDays: Int {
get { Int("AutoDeleteLogsDays") }
set { Int("AutoDeleteLogsDays", newValue) }
}
}
// MARK: - Notifications
extension PrefsShared {
enum RestartReminder {
static var Enabled: Bool {
get { PrefsShared.Bool("RestartReminderEnabled") }
set { PrefsShared.Bool("RestartReminderEnabled", newValue) }
}
static var WithText: Bool {
get { PrefsShared.Bool("RestartReminderWithText") }
set { PrefsShared.Bool("RestartReminderWithText", newValue) }
}
static var WithBadge: Bool {
get { PrefsShared.Bool("RestartReminderWithBadge") }
set { PrefsShared.Bool("RestartReminderWithBadge", newValue) }
}
static var Sound: String {
get { PrefsShared.Str("RestartReminderSound") ?? "#default" }
set { PrefsShared.Str("RestartReminderSound", newValue) }
}
}
enum ConnectionAlerts {
static var Enabled: Bool {
get { PrefsShared.Bool("ConnectionAlertsEnabled") }
set { PrefsShared.Bool("ConnectionAlertsEnabled", newValue) }
}
static var Sound: String {
get { PrefsShared.Str("ConnectionAlertsSound") ?? "#default" }
set { PrefsShared.Str("ConnectionAlertsSound", newValue) }
}
static var ExcludeMode: Bool {
get { PrefsShared.Bool("ConnectionAlertsExcludeMode") }
set { PrefsShared.Bool("ConnectionAlertsExcludeMode", newValue) }
}
enum Lists {
static var CustomA: Bool {
get { PrefsShared.Bool("ConnectionAlertsListsCustomA") }
set { PrefsShared.Bool("ConnectionAlertsListsCustomA", newValue) }
}
static var CustomB: Bool {
get { PrefsShared.Bool("ConnectionAlertsListsCustomB") }
set { PrefsShared.Bool("ConnectionAlertsListsCustomB", newValue) }
}
static var Blocked: Bool {
get { PrefsShared.Bool("ConnectionAlertsListsBlocked") }
set { PrefsShared.Bool("ConnectionAlertsListsBlocked", newValue) }
}
static var Else: Bool {
get { PrefsShared.Bool("ConnectionAlertsListsElse") }
set { PrefsShared.Bool("ConnectionAlertsListsElse", newValue) }
}
}
}
}

View File

@@ -2,10 +2,25 @@ import UIKit
struct QuickUI {
static func label(_ str: String, frame: CGRect = CGRect.zero, align: NSTextAlignment = .natural, style: UIFont.TextStyle = .body) -> UILabel {
let x = UILabel(frame: frame)
x.text = str
x.textAlignment = align
x.font = .preferredFont(forTextStyle: style)
x.constrainHuggingCompression(.horizontal, .defaultLow)
x.constrainHuggingCompression(.vertical, .defaultHigh)
x.sizeToFit()
if #available(iOS 10.0, *) {
x.adjustsFontForContentSizeCategory = true
}
return x
}
static func button(_ title: String, target: Any? = nil, action: Selector? = nil) -> UIButton {
let x = UIButton(type: .roundedRect)
x.setTitle(title, for: .normal)
x.titleLabel?.font = .preferredFont(forTextStyle: .body)
x.constrainHuggingCompression(.vertical, .defaultHigh)
x.sizeToFit()
if let a = action { x.addTarget(target, action: a, for: .touchUpInside) }
if #available(iOS 10.0, *) {
@@ -36,35 +51,8 @@ struct QuickUI {
static func text(attributed: NSAttributedString, frame: CGRect = CGRect.zero) -> UITextView {
let txt = self.text("", frame: frame)
txt.attributedText = attributed
txt.textContainerInset = .zero
//txt.textContainer.lineFragmentPadding = 0 // remove left right padding
return txt
}
}
extension NSMutableAttributedString {
static private var def: UIFont = .preferredFont(forTextStyle: .body)
func normal(_ str: String, _ style: UIFont.TextStyle = .body) -> Self { append(str, withFont: .preferredFont(forTextStyle: style)) }
func bold(_ str: String, _ style: UIFont.TextStyle = .body) -> Self { append(str, withFont: UIFont.preferredFont(forTextStyle: style).bold()) }
func italic(_ str: String, _ style: UIFont.TextStyle = .body) -> Self { append(str, withFont: UIFont.preferredFont(forTextStyle: style).italic()) }
func h1(_ str: String) -> Self { normal(str, .title1) }
func h2(_ str: String) -> Self { normal(str, .title2) }
func h3(_ str: String) -> Self { normal(str, .title3) }
private func append(_ str: String, withFont: UIFont) -> Self {
append(NSAttributedString(string: str, attributes: [
.font : withFont,
.foregroundColor : UIColor.sysFg
]))
return self
}
}
extension UIFont {
func withTraits(traits: UIFontDescriptor.SymbolicTraits) -> UIFont {
UIFont(descriptor: fontDescriptor.withSymbolicTraits(traits)!, size: 0) // keep size as is
}
func bold() -> UIFont { withTraits(traits: .traitBold) }
func italic() -> UIFont { withTraits(traits: .traitItalic) }
}

View File

@@ -1,102 +1,55 @@
import UIKit
/// Assigns a `UISearchBar` to the `tableHeaderView` property of a `UITableView`.
class SearchBarManager: NSObject, UISearchBarDelegate {
class SearchBarManager: NSObject, UISearchResultsUpdating {
private weak var tableView: UITableView?
private let searchBar: UISearchBar
private(set) var active: Bool = false
typealias OnChange = (String) -> Void
typealias OnHide = () -> Void
private var onChangeCallback: OnChange!
private var onHideCallback: OnHide?
private(set) var isActive = false
private(set) var term = ""
private lazy var controller: UISearchController = {
let x = UISearchController(searchResultsController: nil)
x.searchBar.autocapitalizationType = .none
x.searchBar.autocorrectionType = .no
x.obscuresBackgroundDuringPresentation = false
x.searchResultsUpdater = self
return x
}()
private weak var tvc: UITableViewController?
private let onChangeCallback: (String) -> Void
/// Prepare `UISearchBar` for user input
/// - Parameter tableView: The `tableHeaderView` property is used for display.
required init(on tableView: UITableView) {
self.tableView = tableView
searchBar = UISearchBar(frame: CGRect.init(x: 0, y: 0, width: 20, height: 10))
searchBar.sizeToFit() // sets height, width is set by table view header
searchBar.showsCancelButton = true
searchBar.autocapitalizationType = .none
searchBar.autocorrectionType = .no
/// - Parameter onChange: Code that will be executed every time the user changes the text (with 0.2s delay)
required init(onChange: @escaping (String) -> Void) {
onChangeCallback = onChange
super.init()
searchBar.delegate = self
UITextField.appearance(whenContainedInInstancesOf: [UISearchBar.self])
.defaultTextAttributes = [.font: UIFont.preferredFont(forTextStyle: .body)]
}
// MARK: Show & Hide
/// Insert search bar in `tableView` and call `reloadData()` after animation.
/// - Parameters:
/// - onHide: Code that will be executed once the search bar is dismissed.
/// - onChange: Code that will be executed every time the user changes the text (with 0.2s delay)
func show(onHide: OnHide? = nil, onChange: @escaping OnChange) {
onChangeCallback = onChange
onHideCallback = onHide
setSearchBarHidden(false)
}
/// Remove search bar from `tableView` and call `reloadData()` after animation.
func hide() {
setSearchBarHidden(true)
}
/// Internal method to insert or remove the `UISearchBar` as `tableHeaderView`
private func setSearchBarHidden(_ flag: Bool) {
active = !flag
searchBar.text = nil
guard let tv = tableView else {
hideAndRelease()
return
}
if active {
tv.scrollToTop(animated: false)
tv.tableHeaderView = searchBar
tv.frame.origin.y = -searchBar.frame.height
UIView.animate(withDuration: 0.3, animations: {
tv.frame.origin.y = 0
}) { _ in
tv.reloadData()
self.searchBar.becomeFirstResponder()
}
/// Assigns the `UISearchBar` to `tableView.tableHeaderView` (iOS 9) or `navigationItem.searchController` (iOS 11).
func fuseWith(tableViewController: UITableViewController?) {
guard tvc !== tableViewController else { return }
tvc = tableViewController
if #available(iOS 11.0, *) {
tvc?.navigationItem.searchController = controller
} else {
searchBar.resignFirstResponder()
UIView.animate(withDuration: 0.3, animations: {
tv.frame.origin.y = -(tv.tableHeaderView?.frame.height ?? 0)
tv.scrollToTop(animated: false) // false to let UIView animate the change
}) { _ in
tv.frame.origin.y = 0
self.hideAndRelease()
tv.reloadData()
let thv = tvc?.tableView.tableHeaderView
guard thv == nil || thv is UISearchBar else {
// Don't overwrite actions bar (co-occurrence, etc.)
// FIXME: find alternative or iOS 9-10 users can't search in hosts
tvc = nil
return
}
controller.loadViewIfNeeded() // Fix: "Attempting to load the view of a view controller while it is deallocating"
tvc?.definesPresentationContext = true // make search bar disappear if user changes scene (eg. select cell)
//tvc?.tableView.backgroundView = UIView() // iOS 11+ bug: bright white background in dark mode
tvc?.tableView.tableHeaderView = controller.searchBar
tvc?.tableView.setContentOffset(.init(x: 0, y: controller.searchBar.frame.height), animated: false)
}
}
/// Call `OnHide` closure (if set), then release strong closure references.
private func hideAndRelease() {
tableView?.tableHeaderView = nil
onHideCallback?()
onHideCallback = nil
onChangeCallback = nil
}
// MARK: Search Bar Delegate
func searchBarCancelButtonClicked(_ _: UISearchBar) {
setSearchBarHidden(true)
}
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
searchBar.resignFirstResponder()
}
func searchBar(_ _: UISearchBar, textDidChange _: String) {
/// Search callback
internal func updateSearchResults(for controller: UISearchController) {
NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(performSearch), object: nil)
perform(#selector(performSearch), with: nil, afterDelay: 0.2)
}
@@ -104,7 +57,8 @@ class SearchBarManager: NSObject, UISearchBarDelegate {
/// Internal callback function for delayed text evaluation.
/// This way we can avoid unnecessary searches while user is typing.
@objc private func performSearch() {
onChangeCallback(searchBar.text ?? "")
tableView?.reloadData()
term = controller.searchBar.text?.lowercased() ?? ""
isActive = term.count > 0
onChangeCallback(term)
}
}

View File

@@ -0,0 +1,182 @@
import UIKit
enum PresentationEdge { case left, top, right, bottom }
// ########################################
// #
// # MARK: - Transitioning Delegate
// #
// ########################################
class SlideInTransitioningDelegate: NSObject, UIViewControllerTransitioningDelegate {
private var edge: PresentationEdge
private var modal: Bool
private var dismissable: Bool
private var shadow: UIColor?
init(for edge: PresentationEdge, modal: Bool, tapAnywhereToDismiss: Bool = false, modalBackgroundColor color: UIColor? = nil) {
self.edge = edge
self.dismissable = tapAnywhereToDismiss
self.shadow = color
self.modal = modal
}
func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? {
StickyPresentationController(presented: presented, presenting: presenting, stickTo: edge, modal: modal, tapAnywhereToDismiss: dismissable, modalBackgroundColor: shadow)
}
func animationController(forPresented _: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
SlideInAnimationController(from: edge, isPresentation: true)
}
func animationController(forDismissed _: UIViewController) -> UIViewControllerAnimatedTransitioning? {
SlideInAnimationController(from: edge, isPresentation: false)
}
}
// ########################################
// #
// # MARK: - Animated Transitioning
// #
// ########################################
private final class SlideInAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
let edge: PresentationEdge
let appear: Bool
init(from edge: PresentationEdge, isPresentation: Bool) {
self.edge = edge
self.appear = isPresentation
super.init()
}
func transitionDuration(using context: UIViewControllerContextTransitioning?) -> TimeInterval {
(context?.isAnimated ?? true) ? 0.3 : 0.0
}
func animateTransition(using context: UIViewControllerContextTransitioning) {
guard let vc = context.viewController(forKey: appear ? .to : .from) else { return }
var to = context.finalFrame(for: vc)
var from = to
switch edge {
case .left: from.origin.x = -to.width
case .right: from.origin.x = context.containerView.frame.width
case .top: from.origin.y = -to.height
case .bottom: from.origin.y = context.containerView.frame.height
}
if appear { context.containerView.addSubview(vc.view) }
else { swap(&from, &to) }
vc.view.frame = from
UIView.animate(withDuration: transitionDuration(using: context), animations: {
vc.view.frame = to
}, completion: { finished in
if !self.appear { vc.view.removeFromSuperview() }
context.completeTransition(finished)
})
}
}
// #########################################
// #
// # MARK: - Presentation Controller
// #
// #########################################
private class StickyPresentationController: UIPresentationController {
private let stickTo: PresentationEdge
private let isModal: Bool
private let bg = UIView()
private var availableSize: CGSize = .zero // save original size when resizing the container
override var shouldPresentInFullscreen: Bool { false }
override var frameOfPresentedViewInContainerView: CGRect { fittedContentFrame() }
required init(presented: UIViewController, presenting: UIViewController?, stickTo edge: PresentationEdge, modal: Bool = true, tapAnywhereToDismiss: Bool = false, modalBackgroundColor bgColor: UIColor? = nil) {
self.stickTo = edge
self.isModal = modal
super.init(presentedViewController: presented, presenting: presenting)
bg.backgroundColor = bgColor ?? .init(white: 0, alpha: 0.5)
if modal, tapAnywhereToDismiss {
bg.addGestureRecognizer(
UITapGestureRecognizer(target: self, action: #selector(didTapBackground))
)
}
}
// MARK: Present
override func presentationTransitionWillBegin() {
availableSize = containerView!.frame.size
guard isModal else { return }
containerView!.insertSubview(bg, at: 0)
bg.alpha = 0.0
if presentedViewController.transitionCoordinator?.animate(alongsideTransition: { _ in
self.bg.alpha = 1.0
}) != true { bg.alpha = 1.0 }
}
@objc func didTapBackground(_ sender: UITapGestureRecognizer) {
presentingViewController.dismiss(animated: true)
}
// MARK: Dismiss
override func dismissalTransitionWillBegin() {
if presentedViewController.transitionCoordinator?.animate(alongsideTransition: { _ in
self.bg.alpha = 0.0
}) != true { bg.alpha = 0.0 }
}
override func dismissalTransitionDidEnd(_ completed: Bool) {
if completed { bg.removeFromSuperview() }
}
// MARK: Update
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
availableSize = size
super.viewWillTransition(to: size, with: coordinator)
}
override func containerViewDidLayoutSubviews() {
super.containerViewDidLayoutSubviews()
bg.frame = containerView!.bounds
if isModal {
presentedView!.frame = fittedContentFrame()
} else {
containerView!.frame = fittedContentFrame()
presentedView!.frame = containerView!.bounds
}
}
/// Calculate `fittedContentSize()` then offset frame to sticky edge respecting *available* container size .
func fittedContentFrame() -> CGRect {
var frame = CGRect(origin: .zero, size: fittedContentSize())
switch stickTo {
case .right: frame.origin.x = availableSize.width - frame.width
case .bottom: frame.origin.y = availableSize.height - frame.height
default: break
}
return frame
}
/// Calculate best fitting size for available container size and presentation sticky edge.
func fittedContentSize() -> CGSize {
guard let target = presentedView else { return availableSize }
let full = availableSize
let preferred = presentedViewController.preferredContentSize
switch stickTo {
case .left, .right:
let fitted = target.fittingSize(fixedHeight: full.height, preferredWidth: preferred.width)
return CGSize(width: min(fitted.width, full.width), height: full.height)
case .top, .bottom:
let fitted = target.fittingSize(fixedWidth: full.width, preferredHeight: preferred.height)
return CGSize(width: full.width, height: min(fitted.height, full.height))
}
}
}

View File

@@ -0,0 +1,31 @@
import Foundation
class ThrottledBatchQueue<T> {
private var cache: [T] = []
private var scheduled: Bool = false
private let queue: DispatchQueue
private let delay: Double
init(_ delay: Double, using queue: DispatchQueue) {
self.queue = queue
self.delay = delay
}
func addDelayed(_ elem: T, afterDelay closure: @escaping ([T]) -> Void) {
queue.sync {
cache.append(elem)
guard !scheduled else {
return
}
scheduled = true
queue.asyncAfter(deadline: .now() + delay) {
let aCopy = self.cache
self.cache.removeAll(keepingCapacity: true)
self.scheduled = false
DispatchQueue.main.async {
closure(aCopy)
}
}
}
}
}

View File

@@ -1,11 +1,18 @@
import UIKit
fileprivate let margin: CGFloat = 20
fileprivate let cornerRadius: CGFloat = 15
fileprivate let uniRect = CGRect(x: 0, y: 0, width: 500, height: 500)
fileprivate var margin: CGFloat { 20 }
fileprivate var sheetInset: CGFloat { cornerRadius/2 }
fileprivate var cornerRadius: CGFloat { 15 }
fileprivate var uniRect: CGRect { CGRect(x: 0, y: 0, width: 500, height: 500) }
class TutorialSheet: UIViewController, UIScrollViewDelegate {
/// Maximum displayable width of a Tutorial Sheet in portrait mode.
public static var verticalWidth: CGFloat {
let s = UIScreen.main.bounds.size
return min(s.width, s.height) - 2 * (margin + sheetInset)
}
public var buttonTitleNext: String = "Next"
public var buttonTitleDone: String = "Close"
@@ -18,7 +25,7 @@ class TutorialSheet: UIViewController, UIScrollViewDelegate {
private let sheetBg: UIView = {
let x = UIView(frame: uniRect)
x.autoresizingMask = [.flexibleWidth, .flexibleHeight]
x.backgroundColor = .sysBg
x.backgroundColor = .sysBackground
x.layer.cornerRadius = cornerRadius
x.layer.shadowColor = UIColor.black.cgColor
x.layer.shadowRadius = 10
@@ -30,8 +37,8 @@ class TutorialSheet: UIViewController, UIScrollViewDelegate {
private let pager: UIPageControl = {
let x = UIPageControl(frame: uniRect)
x.frame.size.height = x.size(forNumberOfPages: 1).height
x.currentPageIndicatorTintColor = UIColor.sysFg.withAlphaComponent(0.5)
x.pageIndicatorTintColor = UIColor.sysFg.withAlphaComponent(0.25)
x.currentPageIndicatorTintColor = UIColor.sysLabel.withAlphaComponent(0.5)
x.pageIndicatorTintColor = UIColor.sysLabel.withAlphaComponent(0.25)
x.numberOfPages = 0
x.hidesForSinglePage = true
x.addTarget(self, action: #selector(pagerDidChange), for: .valueChanged)
@@ -47,7 +54,6 @@ class TutorialSheet: UIViewController, UIScrollViewDelegate {
let content = UIView()
x.addSubview(content)
content.translatesAutoresizingMaskIntoConstraints = false
content.anchor([.left, .right, .top, .bottom], to: x)
content.anchor([.width, .height], to: x) | .defaultLow
return x
@@ -62,7 +68,7 @@ class TutorialSheet: UIViewController, UIScrollViewDelegate {
// MARK: Init
required init?(coder: NSCoder) { super.init(coder: coder) }
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
required init() {
super.init(nibName: nil, bundle: nil)
@@ -98,7 +104,6 @@ class TutorialSheet: UIViewController, UIScrollViewDelegate {
pager.numberOfPages += 1
updateButtonTitle()
let x = UIStackView(frame: pageScroll.bounds)
x.translatesAutoresizingMaskIntoConstraints = false
x.axis = .vertical
x.backgroundColor = UIColor.black
x.isOpaque = true
@@ -107,7 +112,8 @@ class TutorialSheet: UIViewController, UIScrollViewDelegate {
}
let prev = content.subviews.last
content.addSubview(x)
x.anchor([.top, .width, .height], to: pageScroll)
x.anchor([.top, .height], to: pageScroll)
x.widthAnchor =&= sheetBg.widthAnchor - 2 * sheetInset
x.leadingAnchor =&= (prev==nil ? content.leadingAnchor : prev!.trailingAnchor)
lastAnchor?.isActive = false
lastAnchor = (x.trailingAnchor =&= pageScroll.trailingAnchor)
@@ -125,11 +131,9 @@ class TutorialSheet: UIViewController, UIScrollViewDelegate {
sheetBg.addSubview(pageScroll)
sheetBg.addSubview(button)
for x in sheetBg.subviews { x.translatesAutoresizingMaskIntoConstraints = false }
pager.anchor([.top, .left, .right], to: sheetBg)
pageScroll.topAnchor =&= pager.bottomAnchor
pageScroll.anchor([.left, .right, .top, .bottom], to: sheetBg, margin: cornerRadius/2) | .defaultHigh
pageScroll.anchor([.left, .right, .top, .bottom], to: sheetBg, margin: sheetInset) | .defaultHigh
button.topAnchor =&= pageScroll.bottomAnchor
button.anchor([.bottom, .centerX], to: sheetBg)
// button.bottomAnchor =&= sheetBg.bottomAnchor - 30

View File

@@ -33,11 +33,9 @@ extension SQLiteDatabase {
do {
try run(sql: "SELECT 1 FROM req LIMIT 1;") // fails if req doesnt exist
createFunction("domainof") { ($0.first as! String).extractDomain() }
try run(sql: """
BEGIN TRANSACTION;
transaction("""
INSERT INTO heap(ts,fqdn,domain,opt) SELECT ts,domain,domainof(domain),nullif(logOpt,0) FROM req;
DROP TABLE req;
COMMIT;
""")
} catch { /* no need to migrate */ }
}
@@ -54,16 +52,37 @@ extension SQLiteDatabase {
return sqlite3_column_int64($0, 0)
}) ?? 0
}
fileprivate func col_ts(_ stmt: OpaquePointer, _ col: Int32) -> Timestamp {
sqlite3_column_int64(stmt, col)
}
}
struct WhereClauseBuilder: CustomStringConvertible {
class WhereClauseBuilder: CustomStringConvertible {
var description: String = ""
private let prefix: String
private(set) var bindings: [DBBinding] = []
init(prefix p: String = "WHERE") { prefix = "\(p) " }
mutating func and(_ clause: String, _ bind: DBBinding ...) {
/// Append new clause by either prepending `WHERE` prefix or placing `AND` between clauses.
@discardableResult func and(_ clause: String, _ bind: DBBinding ...) -> Self {
description.append((description=="" ? prefix : " AND ") + clause)
bindings.append(contentsOf: bind)
return self
}
/// Restrict to `rowid >= {range}.start AND rowid <= {range}.end`.
/// Omitted if range is `nil` or individually if a value is `0`.
@discardableResult func and(in range: SQLiteRowRange) -> Self {
if range.start != 0 { and("rowid >= ?", BindInt64(range.start)) }
if range.end != 0 { and("rowid <= ?", BindInt64(range.end)) }
return self
}
/// Restrict to `ts >= {min} AND ts < {max}`. Omit one or the other if value is `0`.
@discardableResult func and(min: Timestamp = 0, max: Timestamp = 0) -> Self {
if min != 0 { and("ts >= ?", BindInt64(min)) }
if max != 0 { and("ts < ?", BindInt64(max)) }
return self
}
}
@@ -88,6 +107,7 @@ struct GroupedDomain {
var options: FilterOptions? = nil
}
typealias GroupedTsOccurrence = (ts: Timestamp, total: Int32, blocked: Int32)
typealias DomainTsPair = (domain: String, ts: Timestamp)
extension SQLiteDatabase {
@@ -97,13 +117,12 @@ extension SQLiteDatabase {
/// - Returns: `nil` in case no entries were transmitted.
@discardableResult func dnsLogsPersist() -> SQLiteRowRange? {
guard lastRowId(.cache) > 0 else { return nil }
transaction("ALTER TABLE cache RENAME TO tmp_cache; \(CreateTable.cache)")
let before = lastRowId(.heap) + 1
createFunction("domainof") { ($0.first as! String).extractDomain() }
try? run(sql:"""
BEGIN TRANSACTION;
INSERT INTO heap(ts,fqdn,domain,opt) SELECT ts,dns,domainof(dns),nullif(opt&1,0) FROM cache;
DELETE FROM cache;
COMMIT;
transaction("""
INSERT INTO heap(ts,fqdn,domain,opt) SELECT ts,dns,domainof(dns),nullif(opt&1,0) FROM tmp_cache;
DROP TABLE tmp_cache;
""")
let after = lastRowId(.heap)
return (before > after) ? nil : (before, after)
@@ -119,8 +138,7 @@ extension SQLiteDatabase {
/// - Parameter strict: If `true`, use `fqdn` instead of `domain` column
/// - Returns: Number of changes aka. Number of rows deleted
@discardableResult func dnsLogsDelete(_ domain: String, strict: Bool, since ts: Timestamp = 0) -> Int32 {
var Where = WhereClauseBuilder()
if ts != 0 { Where.and("ts >= ?", BindInt64(ts)) }
let Where = WhereClauseBuilder().and(min: ts)
Where.and("\(strict ? "fqdn" : "domain") = ?", BindText(domain)) // (fqdn = ? OR fqdn LIKE '%.' || ?)
return (try? run(sql: "DELETE FROM heap \(Where);", bind: Where.bindings) { stmt -> Int32 in
try ifStep(stmt, SQLITE_DONE)
@@ -130,33 +148,48 @@ extension SQLiteDatabase {
// MARK: read
/// Select min and max row id with given condition `ts >= ? AND ts < ?`
/// - Returns: `nil` in case no rows are matching the condition
func dnsLogsRowRange(between ts: Timestamp, and ts2: Timestamp) -> SQLiteRowRange? {
try? run(sql:"SELECT min(rowid), max(rowid) FROM heap WHERE ts >= ? AND ts < ?",
bind: [BindInt64(ts), BindInt64(ts2)]) {
/// `SELECT min(ts) FROM heap`
func dnsLogsMinDate() -> Timestamp? {
try? run(sql:"SELECT min(ts) FROM heap") {
try ifStep($0, SQLITE_ROW)
let max = sqlite3_column_int64($0, 1)
return (max == 0) ? nil : (sqlite3_column_int64($0, 0), max)
return col_ts($0, 0)
}
}
/// Select min and max row id with given condition `ts >= ? AND ts < ?`
/// - Parameters:
/// - ts1: Restrict min `rowid` to `ts >= ?`. Pass `0` to omit restriction.
/// - ts2: Restrict max `rowid` to `ts < ?`. Pass `0` to omit restriction.
/// - range: If set, only look at the specified range. Default: `(0,0)`
/// - Returns: `nil` in case no rows are matching the condition
func dnsLogsRowRange(between ts1: Timestamp, and ts2: Timestamp, within range: SQLiteRowRange = (0,0)) -> SQLiteRowRange? {
let Where = WhereClauseBuilder().and(in: range).and(min: ts1, max: ts2)
return try? run(sql:"SELECT min(rowid), max(rowid) FROM heap \(Where);", bind: Where.bindings) {
try ifStep($0, SQLITE_ROW)
let max = col_ts($0, 1)
return (max == 0) ? nil : (col_ts($0, 0), max)
}
}
/// Get raw logs between two timestamps. `ts >= ? AND ts <= ?`
/// - Returns: List sorted by `ts` in descending order (newest entries first).
func dnsLogs(between ts1: Timestamp, and ts2: Timestamp) -> [DomainTsPair]? {
try? run(sql: "SELECT fqdn, ts FROM heap WHERE ts >= ? AND ts <= ? ORDER BY ts DESC, rowid ASC;",
bind: [BindInt64(ts1), BindInt64(ts2)]) {
allRows($0) {
(col_text($0, 0) ?? "", col_ts($0, 1))
}
}
}
/// Group DNS logs by domain, count occurences and number of blocked requests.
/// - Parameters:
/// - range: Whenever possible set range to improve SQL lookup times. `start <= rowid <= end `
/// - ts: Restrict result set `ts >= ?`
/// - ts2: Restrict result set `ts < ?`
/// - matchingDomain: Restrict `(fqdn|domain) = ?`. Which column is used is determined by `parentDomain`.
/// - parentDomain: If `nil` returns `domain` column. Else returns `fqdn` column with restriction on `domain == parentDomain`.
/// - Returns: List of grouped domains with no particular sorting order.
func dnsLogsGrouped(range: SQLiteRowRange? = nil, since ts: Timestamp = 0, upto ts2: Timestamp = 0,
matchingDomain: String? = nil, parentDomain: String? = nil) -> [GroupedDomain]?
{
var Where = WhereClauseBuilder()
if let from = range?.start { Where.and("rowid >= ?", BindInt64(from)) }
if let to = range?.end { Where.and("rowid <= ?", BindInt64(to)) }
if ts != 0 { Where.and("ts >= ?", BindInt64(ts)) }
if ts2 != 0 { Where.and("ts < ?", BindInt64(ts2)) }
func dnsLogsGrouped(range: SQLiteRowRange, matchingDomain: String? = nil, parentDomain: String? = nil) -> [GroupedDomain]? {
let Where = WhereClauseBuilder().and(in: range)
let col: String // fqdn or domain
if let parent = parentDomain { // is subdomain
col = "fqdn"
@@ -169,10 +202,10 @@ extension SQLiteDatabase {
}
return try? run(sql: "SELECT \(col), COUNT(*), COUNT(opt), MAX(ts) FROM heap \(Where) GROUP BY \(col);", bind: Where.bindings) {
allRows($0) {
GroupedDomain(domain: readText($0, 0) ?? "",
GroupedDomain(domain: col_text($0, 0) ?? "",
total: sqlite3_column_int($0, 1),
blocked: sqlite3_column_int($0, 2),
lastModified: sqlite3_column_int64($0, 3))
lastModified: col_ts($0, 3))
}
}
}
@@ -181,19 +214,12 @@ extension SQLiteDatabase {
/// - Parameters:
/// - fqdn: Exact match for domain name `fqdn = ?`
/// - range: Whenever possible set range to improve SQL lookup times. `start <= rowid <= end `
/// - ts: Restrict result set `ts >= ?`
/// - ts2: Restrict result set `ts < ?`
/// - Returns: List sorted by reverse timestamp order (newest first)
func timesForDomain(_ fqdn: String, range: SQLiteRowRange? = nil, since ts: Timestamp = 0, upto ts2: Timestamp = 0) -> [GroupedTsOccurrence]? {
var Where = WhereClauseBuilder()
if let from = range?.start { Where.and("rowid >= ?", BindInt64(from)) }
if let to = range?.end { Where.and("rowid <= ?", BindInt64(to)) }
if ts != 0 { Where.and("ts >= ?", BindInt64(ts)) }
if ts2 != 0 { Where.and("ts < ?", BindInt64(ts2)) }
Where.and("fqdn = ?", BindText(fqdn))
func timesForDomain(_ fqdn: String, range: SQLiteRowRange) -> [GroupedTsOccurrence]? {
let Where = WhereClauseBuilder().and(in: range).and("fqdn = ?", BindText(fqdn))
return try? run(sql: "SELECT ts, COUNT(ts), COUNT(opt) FROM heap \(Where) GROUP BY ts ORDER BY ts DESC;", bind: Where.bindings) {
allRows($0) {
(sqlite3_column_int64($0, 0), sqlite3_column_int($0, 1), sqlite3_column_int($0, 2))
(col_ts($0, 0), sqlite3_column_int($0, 1), sqlite3_column_int($0, 2))
}
}
}
@@ -201,6 +227,72 @@ extension SQLiteDatabase {
// MARK: - Context Analysis
typealias ContextAnalysisResult = (domain: String, count: Int32, avg: Double, rank: Double)
extension SQLiteDatabase {
/// Number of times how often given `fqdn` appears in the database
func dnsLogsCount(fqdn: String) -> Int? {
try? run(sql: "SELECT COUNT(*) FROM heap WHERE fqdn = ?;", bind: [BindText(fqdn)]) {
try ifStep($0, SQLITE_ROW)
return Int(sqlite3_column_int($0, 0))
}
}
/// Get sorted, unique list of `ts` with given `fqdn`.
func dnsLogsUniqTs(_ domain: String, isFQDN flag: Bool) -> [Timestamp]? {
try? run(sql: "SELECT DISTINCT ts FROM heap WHERE \(flag ? "fqdn" : "domain") = ? ORDER BY ts;",
bind: [BindText(domain)]) {
allRows($0) { col_ts($0, 0) }
}
}
/// Find other domains occurring regularly at roughly the same time as `fqdn`.
/// - Warning: `times` list must be **sorted** by time in ascending order.
/// - Parameters:
/// - times: List of `ts` from `dnsLogsUniqTs(fqdn)`
/// - dt: Search for `ts - dt <= X <= ts + dt`
/// - fqdn: Rows matching this domain will be excluded from the result set.
/// - Returns: List of tuples ordered by rank (ASC).
func contextAnalysis(coOccurrence times: [Timestamp], plusMinus dt: Timestamp, exclude domain: String, isFQDN flag: Bool) -> [ContextAnalysisResult]? {
guard times.count > 0 else { return nil }
createFunction("fnDist") {
let x = $0.first as! Timestamp
let i = times.binTreeIndex(of: x, compare: <)!
let dist: Timestamp
switch i {
case 0: dist = times[0] - x
case times.count: dist = x - times[i-1]
default: dist = min(times[i] - x, x - times[i-1])
}
return dist
}
// `avg ^ 2`: prefer results that are closer to `times`
// `_ / count`: prefer results with higher occurrence count
// `time / 2`: Weighting factor (low: prefer close, high: prefer count)
// `time` helpful esp. for smaller spans. `avg^2` will raise faster anyway.
let fnRank = "(avg * avg + (? / 2.0) + 1) / count" // +1 in case time == 0 -> avg^2 == 0
// improve query by excluding entries that are: before the first, or after the last ts
let low = times.first! - dt
let high = times.last! + dt
return try? run(sql: """
SELECT fqdn, count, avg, (\(fnRank)) rank FROM (
SELECT fqdn, COUNT(*) count, AVG(dist) avg FROM (
SELECT fqdn, fnDist(ts) dist FROM heap
WHERE ts BETWEEN ? AND ? AND \(flag ? "fqdn" : "domain") != ? AND dist <= ?
) GROUP BY fqdn
) ORDER BY rank ASC LIMIT 99;
""", bind: [BindInt64(dt), BindInt64(low), BindInt64(high), BindText(domain), BindInt64(dt)]) {
allRows($0) {
(col_text($0, 0) ?? "", sqlite3_column_int($0, 1), sqlite3_column_double($0, 2), sqlite3_column_double($0, 3))
}
}
}
}
// MARK: - Recordings
extension CreateTable {
@@ -270,13 +362,13 @@ extension SQLiteDatabase {
// MARK: read
private func readRecording(_ stmt: OpaquePointer) -> Recording {
let end = sqlite3_column_int64(stmt, 2)
let end = col_ts(stmt, 2)
return Recording(id: sqlite3_column_int64(stmt, 0),
start: sqlite3_column_int64(stmt, 1),
start: col_ts(stmt, 1),
stop: end == 0 ? nil : end,
appId: readText(stmt, 3),
title: readText(stmt, 4),
notes: readText(stmt, 5))
appId: col_text(stmt, 3),
title: col_text(stmt, 4),
notes: col_text(stmt, 5))
}
/// `WHERE stop IS NULL`
@@ -287,6 +379,14 @@ extension SQLiteDatabase {
}
}
/// Get `Timestamp` of last recording.
func recordingLastTimestamp() -> Timestamp? {
try? run(sql: "SELECT stop FROM rec WHERE stop IS NOT NULL ORDER BY rowid DESC LIMIT 1;") {
try ifStep($0, SQLITE_ROW)
return col_ts($0, 0)
}
}
/// `WHERE stop IS NOT NULL`
func recordingGetAll() -> [Recording]? {
try? run(sql: "SELECT * FROM rec WHERE stop IS NOT NULL;") {
@@ -318,8 +418,6 @@ extension CreateTable {
"""}
}
typealias RecordLog = (domain: String, count: Int32)
extension SQLiteDatabase {
// MARK: write
@@ -348,13 +446,24 @@ extension SQLiteDatabase {
}
}
/// Delete one recording log entry with given `recording id`, matching `domain`, and `ts`.
/// - Returns: `true` if row was deleted
func recordingLogsDelete(_ recId: sqlite3_int64, singleEntry ts: Timestamp, domain: String) throws -> Bool {
try run(sql: "DELETE FROM recLog WHERE rid = ? AND ts = ? AND domain = ? LIMIT 1;",
bind: [BindInt64(recId), BindInt64(ts), BindText(domain)]) {
try ifStep($0, SQLITE_DONE)
return numberOfChanges > 0
}
}
// MARK: read
/// List of domains and count occurences for given recording.
func recordingLogsGetGrouped(_ r: Recording) -> [RecordLog]? {
try? run(sql: "SELECT domain, COUNT() FROM recLog WHERE rid = ? GROUP BY domain;",
/// - Returns: List of `(domain, ts)` pairs. Sorted by `ts` in ascending order (oldest first)
func recordingLogsGet(_ r: Recording) -> [DomainTsPair]? {
try? run(sql: "SELECT domain, ts FROM recLog WHERE rid = ? ORDER BY ts ASC, rowid DESC;",
bind: [BindInt64(r.id)]) {
allRows($0) { (readText($0, 0) ?? "", sqlite3_column_int($0, 1)) }
allRows($0) { (col_text($0, 0) ?? "", col_ts($0, 1)) }
}
}
}

View File

@@ -25,16 +25,34 @@ extension CreateTable {
}
extension SQLiteDatabase {
// /// `INSERT INTO cache (dns, opt) VALUES (?, ?);`
// func logWritePrepare() throws -> OpaquePointer {
// try prepare(sql: "INSERT INTO cache (dns, opt) VALUES (?, ?);")
// }
// /// `prep` must exist and be initialized with `logWritePrepare()`
// func logWrite(_ pStmt: OpaquePointer!, _ domain: String, blocked: Bool = false) throws {
// guard let prep = pStmt else {
// return
// }
// try prepared(run: prep, bind: [BindText(domain), BindInt32(blocked ? 1 : 0)])
// }
/// `INSERT INTO cache (dns, opt) VALUES (?, ?);`
func logWritePrepare() throws -> OpaquePointer {
try prepare(sql: "INSERT INTO cache (dns, opt) VALUES (?, ?);")
func logWrite(_ domain: String, blocked: Bool = false) throws {
try self.run(sql: "INSERT INTO cache (dns, opt) VALUES (?, ?);",
bind: [BindText(domain), BindInt32(blocked ? 1 : 0)])
{ try ifStep($0, SQLITE_DONE) }
}
/// `prep` must exist and be initialized with `logWritePrepare()`
func logWrite(_ pStmt: OpaquePointer!, _ domain: String, blocked: Bool = false) throws {
guard let prep = pStmt else {
return
/// `DELETE FROM cache WHERE ts < (now - ? days);`
/// - Parameter days: if `0` or negative, this function does nothing.
/// - Returns: `true` if at least one row was deleted.
@discardableResult func dnsLogsDeleteOlderThan(days: Int) throws -> Bool {
guard days > 0 else { return false }
return try self.run(sql: "DELETE FROM cache WHERE ts < strftime('%s', 'now', ?);",
bind: [BindText("-\(days) days")]) {
try ifStep($0, SQLITE_DONE)
return numberOfChanges > 0
}
try prepared(run: prep, bind: [BindText(domain), BindInt32(blocked ? 1 : 0)])
}
}
@@ -52,11 +70,13 @@ extension CreateTable {
}
struct FilterOptions: OptionSet {
let rawValue: Int32
let rawValue: Int32
static let none = FilterOptions([])
static let blocked = FilterOptions(rawValue: 1 << 0)
static let ignored = FilterOptions(rawValue: 1 << 1)
static let any = FilterOptions(rawValue: 0b11)
static let blocked = FilterOptions(rawValue: 1 << 0)
static let ignored = FilterOptions(rawValue: 1 << 1)
static let customA = FilterOptions(rawValue: 1 << 2)
static let customB = FilterOptions(rawValue: 1 << 3)
static let any = FilterOptions(rawValue: 0b1111)
}
extension SQLiteDatabase {
@@ -65,7 +85,7 @@ extension SQLiteDatabase {
return try? run(sql: "SELECT domain, opt FROM filter \(rv>0 ? "WHERE opt & ?" : "");",
bind: rv>0 ? [BindInt32(rv)] : []) {
allRowsKeyed($0) {
(key: readText($0, 0) ?? "",
(key: col_text($0, 0) ?? "",
value: FilterOptions(rawValue: sqlite3_column_int($0, 1)))
}
}

View File

@@ -13,6 +13,7 @@ enum SQLiteError: Error {
/// `try? SQLiteDatabase.open()`
var AppDB: SQLiteDatabase? { get { try? SQLiteDatabase.open() } }
typealias SQLiteRowID = sqlite3_int64
/// `0` indicates an unbound edge.
typealias SQLiteRowRange = (start: SQLiteRowID, end: SQLiteRowID)
// MARK: - SQLiteDatabase
@@ -34,7 +35,7 @@ class SQLiteDatabase {
}
deinit {
sqlite3_close(dbPointer)
sqlite3_close_v2(dbPointer)
}
static func destroyDatabase(path: String = URL.internalDB().relativePath) {
@@ -46,15 +47,10 @@ class SQLiteDatabase {
static func open(path: String = URL.internalDB().relativePath) throws -> SQLiteDatabase {
var db: OpaquePointer?
//sqlite3_open_v2(path, &db, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_SHAREDCACHE, nil)
if sqlite3_open(path, &db) == SQLITE_OK {
if sqlite3_open_v2(path, &db, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK {
return SQLiteDatabase(dbPointer: db)
} else {
defer {
if db != nil {
sqlite3_close(db)
}
}
defer { sqlite3_close_v2(db) }
if let errorPointer = sqlite3_errmsg(db) {
let message = String(cString: errorPointer)
throw SQLiteError.OpenDatabase(message: message)
@@ -95,15 +91,20 @@ class SQLiteDatabase {
}
}
/// `BEGIN TRANSACTION; \(sql); COMMIT;` on exception rollback.
func transaction(_ sql: String) {
do { try run(sql: "BEGIN TRANSACTION; \(sql); COMMIT;") }
catch { rollback() }
}
func ifStep(_ stmt: OpaquePointer, _ expected: Int32) throws {
guard sqlite3_step(stmt) == expected else {
throw SQLiteError.Step(message: errorMessage)
}
}
func vacuum() {
try? run(sql: "VACUUM;")
}
func vacuum() { NSLog("[SQL] VACUUM"); try? run(sql: "VACUUM;"); }
func rollback() { NSLog("[SQL] ROLLBACK"); try? run(sql: "ROLLBACK;"); }
}
@@ -142,6 +143,7 @@ extension SQLiteDatabase {
if let r = result as? Blob { sqlite3_result_blob(context, r.bytes, Int32(r.bytes.count), nil) }
else if let r = result as? Double { sqlite3_result_double(context, r) }
else if let r = result as? Int64 { sqlite3_result_int64(context, r) }
else if let r = result as? Bool { sqlite3_result_int(context, r ? 1 : 0) }
else if let r = result as? String { sqlite3_result_text(context, r, Int32(r.count), SQLITE_TRANSIENT) }
else if result == nil { sqlite3_result_null(context) }
else { fatalError("unsupported result type: \(String(describing: result))") }
@@ -196,7 +198,7 @@ extension SQLiteDatabase {
var numberOfChanges: Int32 { get { sqlite3_changes(dbPointer) } }
var lastInsertedRow: SQLiteRowID { get { sqlite3_last_insert_rowid(dbPointer) } }
func readText(_ stmt: OpaquePointer, _ col: Int32) -> String? {
func col_text(_ stmt: OpaquePointer, _ col: Int32) -> String? {
let val = sqlite3_column_text(stmt, col)
return (val != nil ? String(cString: val!) : nil)
}
@@ -221,6 +223,7 @@ extension SQLiteDatabase {
func prepare(sql: String) throws -> OpaquePointer {
var pStmt: OpaquePointer?
guard sqlite3_prepare_v2(dbPointer, sql, -1, &pStmt, nil) == SQLITE_OK, let S = pStmt else {
sqlite3_finalize(pStmt)
throw SQLiteError.Prepare(message: errorMessage)
}
return S

View File

@@ -16,8 +16,8 @@ extension GroupedDomain {
extension GroupedDomain {
var detailCellText: String { get {
return blocked > 0
? "\(lastModified.asDateTime())\(blocked)/\(total) blocked"
: "\(lastModified.asDateTime())\(total)"
? "\(DateFormat.seconds(lastModified))\(blocked)/\(total) blocked"
: "\(DateFormat.seconds(lastModified))\(total)"
}
}
}
@@ -35,6 +35,5 @@ extension FilterOptions {
extension Recording {
var fallbackTitle: String { get { "Unnamed Recording #\(id)" } }
var duration: Timestamp? { get { stop == nil ? nil : stop! - start } }
var durationString: String? { get { stop == nil ? nil : TimeFormat.from(duration!) } }
}

View File

@@ -0,0 +1,43 @@
import Foundation
struct TheGreatDestroyer {
/// Callback fired when user performs row edit -> delete action
static func deleteLogs(domain: String, since ts: Timestamp, strict flag: Bool) {
sync.pause()
DispatchQueue.global().async {
defer { sync.continue() }
guard let db = AppDB, db.dnsLogsDelete(domain, strict: flag, since: ts) > 0 else {
return // nothing has changed
}
db.vacuum()
sync.needsReloadDB(domain: domain)
}
}
/// Fired when user taps on Settings -> "Delete All Logs"
static func deleteAllLogs() {
sync.pause()
DispatchQueue.global().async {
defer { sync.continue() }
do {
try AppDB?.dnsLogsDeleteAll()
sync.needsReloadDB()
} catch {}
}
}
/// Fired when user changes Settings -> "Auto-delete logs" and every time the App enters foreground
static func deleteLogs(olderThan days: Int) {
guard days > 0 else { return }
sync.pause()
DispatchQueue.global().async {
defer { sync.continue() }
QLog.Info("Auto-delete logs")
guard let success = try? AppDB?.dnsLogsDeleteOlderThan(days: days), success else {
return // nothing changed
}
sync.needsReloadDB()
}
}
}

View File

@@ -1,9 +1,7 @@
import Foundation
enum DomainFilter {
static private var data: [String: FilterOptions] = {
AppDB?.loadFilters() ?? [:]
}()
static private var data = AppDB?.loadFilters() ?? [:]
/// Get filter with given `domain` name
@inline(__always) static subscript(_ domain: String) -> FilterOptions? {
@@ -12,10 +10,10 @@ enum DomainFilter {
/// Update local memory object by loading values from persistent db.
/// - Note: Will trigger `NotifyDNSFilterChanged` notification.
static func reload() {
data = AppDB?.loadFilters() ?? [:]
NotifyDNSFilterChanged.post()
}
// static func reload() {
// data = AppDB?.loadFilters() ?? [:]
// NotifyDNSFilterChanged.post()
// }
/// Get list of domains (sorted by name) which do contain the given filter
static func list(where matching: FilterOptions) -> [String] {
@@ -23,10 +21,13 @@ enum DomainFilter {
}
/// Get total number of blocked and ignored domains. Shown in settings overview.
static func counts() -> (blocked: Int, ignored: Int) {
data.reduce(into: (0, 0)) {
static func counts() -> (blocked: Int, ignored: Int, listCustomA: Int, listCustomB: Int) {
data.reduce(into: (0, 0, 0, 0)) {
if $1.1.contains(.blocked) { $0.0 += 1 }
if $1.1.contains(.ignored) { $0.1 += 1 } }
if $1.1.contains(.ignored) { $0.1 += 1 }
if $1.1.contains(.customA) { $0.2 += 1 }
if $1.1.contains(.customB) { $0.3 += 1 }
}
}
/// Union `filter` with set.

View File

@@ -1,78 +1,91 @@
import UIKit
protocol GroupedDomainDataSourceDelegate: UITableViewController {
/// Currently only called when a row is moved and the `tableView` is frontmost.
func groupedDomainDataSource(needsUpdate row: Int)
}
// ##########################
// #
// # MARK: DataSource
// #
// ##########################
class GroupedDomainDataSource {
class GroupedDomainDataSource: FilterPipelineDelegate, SyncUpdateDelegate {
private var tsLatest: Timestamp = 0
let parent: String?
private let pipeline = FilterPipeline<GroupedDomain>()
private var currentOrder: DateFilterOrderBy = .Date
private var orderAsc = false
private let parent: String?
let pipeline: FilterPipeline<GroupedDomain>
private lazy var search = SearchBarManager(on: pipeline.delegate!.tableView)
private(set) lazy var search = SearchBarManager { [unowned self] _ in
self.pipeline.reloadFilter(withId: "search")
}
init(withDelegate tvc: FilterPipelineDelegate, parent p: String?) {
parent = p
pipeline = .init(withDelegate: tvc)
pipeline.setDataSource { [unowned self] in self.dataSourceCallback() }
pipeline.setSorting {
$0.lastModified > $1.lastModified
/// Will init `sync.allowPullToRefresh()` on `tableView.refreshControl` as well.
weak var delegate: GroupedDomainDataSourceDelegate? {
willSet { if #available(iOS 10.0, *), newValue !== delegate {
sync.allowPullToRefresh(onTVC: newValue, forObserver: self)
}}}
/// - Note: Will call `tableview.reloadData()`
init(withParent: String?) {
parent = withParent
let len: Int
if let p = withParent, p.first != "#" { len = p.count } else { len = 0 }
pipeline.addFilter("search") { [unowned self] in
!self.search.isActive ||
$0.domain.prefix($0.domain.count - len).lowercased().contains(self.search.term)
}
if #available(iOS 10.0, *) {
tvc.tableView.refreshControl = UIRefreshControl(call: #selector(reloadFromSource), on: self)
}
NotifyLogHistoryReset.observe(call: #selector(reloadFromSource), on: self)
pipeline.delegate = self
resetSortingOrder(force: true)
NotifyDNSFilterChanged.observe(call: #selector(didChangeDomainFilter), on: self)
NotifySyncInsert.observe(call: #selector(syncInsert), on: self)
NotifySyncRemove.observe(call: #selector(syncRemove), on: self)
NotifySortOrderChanged.observe(call: #selector(didChangeSortOrder), on: self)
sync.addObserver(self) // calls syncUpdate(reset:)
}
/// Callback fired only when pipeline resets data source
private func dataSourceCallback() -> [GroupedDomain] {
guard let db = AppDB else { return [] }
let earliest = sync.tsEarliest
tsLatest = earliest
var log = db.dnsLogsGrouped(since: earliest, parentDomain: parent) ?? []
for (i, val) in log.enumerated() {
log[i].options = DomainFilter[val.domain]
tsLatest = max(tsLatest, val.lastModified)
}
return log
/// Callback fired when user changes date filter settings. (`NotifySortOrderChanged` notification)
@objc private func didChangeSortOrder(_ notification: Notification) {
resetSortingOrder()
}
/// Pause recurring background updates to force reload `dataSource`.
/// Callback fired on user action `pull-to-refresh`, or another background task triggered `NotifyLogHistoryReset`.
/// - Parameter sender: May be either `UIRefreshControl` or `Notification`
/// (optional: pass single domain as the notification object).
@objc func reloadFromSource(sender: Any? = nil) {
weak var refreshControl = sender as? UIRefreshControl
let notification = sender as? Notification
sync.pause()
if let affectedDomain = notification?.object as? String {
partiallyReloadFromSource(affectedDomain)
sync.continue()
} else {
pipeline.reload(fromSource: true, whenDone: {
sync.syncNow() // sync outstanding entries in cache
sync.continue()
refreshControl?.endRefreshing()
})
/// Read user defaults and apply new sorting order. Either by setting a new or reversing the current.
/// - Parameter force: If `true` set new sorting even if the type does not differ.
private func resetSortingOrder(force: Bool = false) {
let orderAscChanged = (orderAsc <-? Prefs.DateFilter.OrderAsc)
let orderTypChanged = (currentOrder <-? Prefs.DateFilter.OrderBy)
if orderTypChanged || force {
switch currentOrder {
case .Date:
pipeline.setSorting { [unowned self] in
self.orderAsc ? $0.lastModified < $1.lastModified : $0.lastModified > $1.lastModified
}
case .Name:
pipeline.setSorting { [unowned self] in
self.orderAsc ? $0.domain < $1.domain : $0.domain > $1.domain
}
case .Count:
pipeline.setSorting { [unowned self] in
self.orderAsc ? $0.total < $1.total : $0.total > $1.total
}
}
} else if orderAscChanged {
pipeline.reverseSorting()
}
}
/// Callback fired when user editslist of `blocked` or `ignored` domains in settings. (`NotifyDNSFilterChanged` notification)
/// Callback fired when user edits list of `blocked` or `ignored` domains in settings. (`NotifyDNSFilterChanged` notification)
@objc private func didChangeDomainFilter(_ notification: Notification) {
guard let domain = notification.object as? String else {
reloadFromSource()
return
preconditionFailure("Domain independent filter reset not implemented") // `syncUpdate(reset:)` async!
}
if let (i, obj) = pipeline.dataSourceGet(where: { $0.domain == domain }) {
var y = obj
y.options = DomainFilter[domain]
pipeline.update(y, at: i)
if let x = pipeline.dataSourceGet(where: { $0.domain == domain }) {
var obj = x.object
obj.options = DomainFilter[domain]
pipeline.update(obj, at: x.index)
}
}
@@ -82,129 +95,138 @@ class GroupedDomainDataSource {
@inline(__always) var numberOfRows: Int { get { pipeline.displayObjectCount() } }
@inline(__always) subscript(_ row: Int) -> GroupedDomain { pipeline.displayObject(at: row) }
// MARK: partial updates
/// Callback fired when background sync added new entries to the list. (`NotifySyncInsert` notification)
@objc private func syncInsert(_ notification: Notification) {
sync.pause()
defer { sync.continue() }
let range = notification.object as! SQLiteRowRange
guard let latest = AppDB?.dnsLogsGrouped(range: range, parentDomain: parent) else {
assertionFailure("NotifySyncInsert fired with empty range")
return
}
pipeline.pauseCellAnimations(if: latest.count > 14)
for x in latest {
if let (i, obj) = pipeline.dataSourceGet(where: { $0.domain == x.domain }) {
pipeline.update(obj + x, at: i)
} else {
var y = x
y.options = DomainFilter[x.domain]
pipeline.addNew(y)
}
tsLatest = max(tsLatest, x.lastModified)
}
pipeline.continueCellAnimations(reloadTable: true)
}
/// Callback fired when background sync removed old entries from the list. (`NotifySyncRemove` notification)
@objc private func syncRemove(_ notification: Notification) {
sync.pause()
defer { sync.continue() }
let range = notification.object as! SQLiteRowRange
guard let outdated = AppDB?.dnsLogsGrouped(range: range, parentDomain: parent),
outdated.count > 0 else {
return
}
pipeline.pauseCellAnimations(if: outdated.count > 14)
var listOfDeletes: [Int] = []
for x in outdated {
guard let (i, obj) = pipeline.dataSourceGet(where: { $0.domain == x.domain }) else {
assertionFailure("Try to remove non-existent element")
continue // should never happen
}
if obj.total > x.total {
pipeline.update(obj - x, at: i)
} else {
listOfDeletes.append(i)
}
}
pipeline.remove(indices: listOfDeletes.sorted())
pipeline.continueCellAnimations(reloadTable: true)
}
}
// ################################
// #
// # MARK: - Delete History
// # MARK: - Partial Update
// #
// ################################
extension GroupedDomainDataSource {
/// Callback fired when user performs row edit -> delete action
func deleteHistory(domain: String, since ts: Timestamp) {
let flag = (parent != nil)
DispatchQueue.global().async {
guard let db = AppDB, db.dnsLogsDelete(domain, strict: flag, since: ts) > 0 else {
return // nothing has changed
}
db.vacuum()
NotifyLogHistoryReset.postAsyncMain(domain) // calls partiallyReloadFromSource(:)
func syncUpdate(_: SyncUpdate, reset rows: SQLiteRowRange) {
var logs = AppDB?.dnsLogsGrouped(range: rows, parentDomain: parent) ?? []
for (i, val) in logs.enumerated() {
logs[i].options = DomainFilter[val.domain]
}
DispatchQueue.main.sync {
pipeline.reset(dataSource: logs)
}
}
/// Reload a single data source entry. Callback fired by `reloadFromSource()`
/// Only useful if `affectedFQDN` currently exists in `dataSource`. Can either update or remove entry.
private func partiallyReloadFromSource(_ affectedFQDN: String) {
func syncUpdate(_: SyncUpdate, insert rows: SQLiteRowRange, affects: SyncUpdateEnd) {
guard let latest = AppDB?.dnsLogsGrouped(range: rows, parentDomain: parent) else {
assertionFailure("NotifySyncInsert fired with empty range")
return
}
DispatchQueue.main.sync {
cellAnimationsGroup(if: latest.count > 14)
for x in latest {
if let (i, obj) = pipeline.dataSourceGet(where: { $0.domain == x.domain }) {
pipeline.update(obj + x, at: i)
} else {
var y = x
y.options = DomainFilter[x.domain]
pipeline.addNew(y)
}
}
cellAnimationsCommit()
}
}
func syncUpdate(_ sender: SyncUpdate, remove rows: SQLiteRowRange, affects: SyncUpdateEnd) {
if affects == .Latest {
// TODO: alternatively query last modified from db (last entry _before_ range)
syncUpdate(sender, reset: sender.rows)
return
}
guard let outdated = AppDB?.dnsLogsGrouped(range: rows, parentDomain: parent),
outdated.count > 0 else {
return
}
DispatchQueue.main.sync {
cellAnimationsGroup(if: outdated.count > 14)
var listOfDeletes: [Int] = []
for x in outdated {
guard let (i, obj) = pipeline.dataSourceGet(where: { $0.domain == x.domain }) else {
assertionFailure("Try to remove non-existent element")
continue // should never happen
}
if obj.total > x.total {
pipeline.update(obj - x, at: i)
} else {
listOfDeletes.append(i)
}
}
pipeline.remove(indices: listOfDeletes.sorted())
cellAnimationsCommit()
}
}
func syncUpdate(_ sender: SyncUpdate, partialRemove affectedFQDN: String) {
let affectedParent = affectedFQDN.extractDomain()
guard parent == nil || parent == affectedParent else {
return // does not affect current table
}
let affected = (parent == nil ? affectedParent : affectedFQDN)
guard let old = pipeline.dataSourceGet(where: { $0.domain == affected }) else {
// can only happen if delete sheet is open while background sync removed the element
return
}
if var updated = AppDB?.dnsLogsGrouped(since: sync.tsEarliest, upto: tsLatest,
matchingDomain: affected, parentDomain: parent)?.first {
assert(old.object.domain == updated.domain)
updated.options = DomainFilter[updated.domain]
pipeline.update(updated, at: old.index)
} else {
pipeline.remove(indices: [old.index])
let updated = AppDB?.dnsLogsGrouped(range: sender.rows, matchingDomain: affected, parentDomain: parent)?.first
DispatchQueue.main.sync {
guard let old = pipeline.dataSourceGet(where: { $0.domain == affected }) else {
// can only happen if delete sheet is open while background sync removed the element
return
}
if var updated = updated {
assert(old.object.domain == updated.domain)
updated.options = DomainFilter[updated.domain]
pipeline.update(updated, at: old.index)
} else {
pipeline.remove(indices: [old.index])
}
}
}
}
// ################################
// #################################
// #
// # MARK: - Search
// # MARK: - Cell Animations
// #
// ################################
// #################################
extension GroupedDomainDataSource {
func toggleSearch() {
if search.active { search.hide() }
else {
// Pause animations. Otherwise the `scrollToTop` animation is broken.
// This is due to `addFilter` calling `reloadData()` before `search.show()` can animate it.
pipeline.pauseCellAnimations()
var searchTerm = ""
pipeline.addFilter("search") {
$0.domain.lowercased().contains(searchTerm)
}
search.show(onHide: { [unowned self] in
self.pipeline.removeFilter(withId: "search")
}, onChange: { [unowned self] in
searchTerm = $0.lowercased()
self.pipeline.reloadFilter(withId: "search")
})
pipeline.continueCellAnimations()
/// Sets `pipeline.delegate = nil` to disable individual cell animations (update, insert, delete & move).
private func cellAnimationsGroup(if condition: Bool = true) {
if condition || delegate?.tableView.isFrontmost == false {
pipeline.delegate = nil
}
}
/// No-Op if cell animations are enabled already.
/// Else, set `pipeline.delegate = self` and perform `reloadData()`.
private func cellAnimationsCommit() {
if pipeline.delegate == nil {
pipeline.delegate = self
delegate?.tableView.reloadData()
}
}
// TODO: Collect animations and post them in a single animations block.
// This will require enormous work to translate them into a final set.
func filterPipelineDidReset() { delegate?.tableView.reloadData() }
func filterPipeline(delete rows: [Int]) { delegate?.tableView.safeDeleteRows(rows) }
func filterPipeline(insert row: Int) { delegate?.tableView.safeInsertRow(row, with: .left) }
func filterPipeline(update row: Int) {
guard let tv = delegate?.tableView else { return }
if !tv.isEditing { tv.safeReloadRow(row) }
else if tv.isFrontmost == true {
delegate?.groupedDomainDataSource(needsUpdate: row)
}
}
func filterPipeline(move oldRow: Int, to newRow: Int) {
delegate?.tableView.safeMoveRow(oldRow, to: newRow)
if delegate?.tableView.isFrontmost == true {
delegate?.groupedDomainDataSource(needsUpdate: newRow)
}
}
}
@@ -216,8 +238,8 @@ extension GroupedDomainDataSource {
// #
// ##########################
protocol GroupedDomainEditRow : EditableRows, FilterPipelineDelegate {
var source: GroupedDomainDataSource { get set }
protocol GroupedDomainEditRow : UIViewController, EditableRows {
var source: GroupedDomainDataSource { get }
}
extension GroupedDomainEditRow {
@@ -244,8 +266,10 @@ extension GroupedDomainEditRow {
case .ignore: showFilterSheet(entry, .ignored)
case .block: showFilterSheet(entry, .blocked)
case .delete:
AlertDeleteLogs(entry.domain, latest: entry.lastModified) {
self.source.deleteHistory(domain: entry.domain, since: $0)
let name = entry.domain
let flag = (source.parent != nil)
AlertDeleteLogs(name, latest: entry.lastModified) {
TheGreatDestroyer.deleteLogs(domain: name, since: $0, strict: flag)
}.presentIn(self)
}
return true
@@ -264,7 +288,7 @@ extension GroupedDomainEditRow {
// MARK: Extensions
extension TVCDomains : GroupedDomainEditRow {
override func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
getRowActionsIOS9(indexPath)
getRowActionsIOS9(indexPath, tableView)
}
@available(iOS 11.0, *)
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
@@ -274,7 +298,7 @@ extension TVCDomains : GroupedDomainEditRow {
extension TVCHosts : GroupedDomainEditRow {
override func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
getRowActionsIOS9(indexPath)
getRowActionsIOS9(indexPath, tableView)
}
@available(iOS 11.0, *)
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {

View File

@@ -13,15 +13,19 @@ enum RecordingsDB {
/// Get list of all recordings
static func list() -> [Recording] { AppDB?.recordingGetAll() ?? [] }
/// Get `Timestamp` of latest recording
static func lastTimestamp() -> Timestamp? { AppDB?.recordingLastTimestamp() }
/// Copy log entries from generic `heap` table to recording specific `recLog` table
static func persist(_ r: Recording) {
sync.syncNow() // persist changes in cache before copying recording details
AppDB?.recordingLogsPersist(r)
sync.syncNow { // persist changes in cache before copying recording details
AppDB?.recordingLogsPersist(r)
}
}
/// Get list of domains that occured during the recording
static func details(_ r: Recording) -> [RecordLog] {
AppDB?.recordingLogsGetGrouped(r) ?? []
static func details(_ r: Recording) -> [DomainTsPair] {
AppDB?.recordingLogsGet(r) ?? []
}
/// Update `title`, `appid`, and `notes` and post `NotifyRecordingChanged` notification.
@@ -42,5 +46,11 @@ enum RecordingsDB {
static func deleteDetails(_ r: Recording, domain: String) -> Bool {
((try? AppDB?.recordingLogsDelete(r.id, matchingDomain: domain)) ?? 0) > 0
}
/// Delete individual entries from recording while keeping the recording alive.
/// - Returns: `true` if at least one row is deleted.
static func deleteSingle(_ r: Recording, domain: String, ts: Timestamp) -> Bool {
(try? AppDB?.recordingLogsDelete(r.id, singleEntry: ts, domain: domain)) ?? false
}
}

View File

@@ -0,0 +1,58 @@
import Foundation
#if IOS_SIMULATOR
fileprivate var hook : GlassVPNHook!
class SimulatorVPN {
static var timer: Timer?
static func load() {
QLog.Debug("SQLite path: \(URL.internalDB())")
let db = AppDB!
let deleted = db.dnsLogsDelete("test.com", strict: false)
try? db.run(sql: "DELETE FROM cache;")
QLog.Debug("Deleting \(deleted) rows matching 'test.com' (+ \(db.numberOfChanges) in cache)")
QLog.Debug("Writing 33 test logs")
try? db.logWrite("keeptest.com", blocked: false)
for _ in 1...4 { try? db.logWrite("test.com", blocked: false) }
for _ in 1...7 { try? db.logWrite("i.test.com", blocked: false) }
for i in 1...8 { try? db.logWrite("b.test.com", blocked: i>5) }
for i in 1...13 { try? db.logWrite("bi.test.com", blocked: i%2==0) }
db.dnsLogsPersist()
QLog.Debug("Creating 4 filters")
db.setFilter("b.test.com", .blocked)
db.setFilter("i.test.com", .ignored)
db.setFilter("bi.test.com", [.blocked, .ignored])
QLog.Debug("Done")
}
static func start() {
hook = GlassVPNHook()
timer = Timer.repeating(2, call: #selector(insertRandom), on: self)
}
static func stop() {
timer?.invalidate()
timer = nil
hook.cleanUp()
hook = nil
}
@objc static func insertRandom() {
//QLog.Debug("Inserting 1 periodic log entry")
let domain = "\(arc4random() % 5).count.test.com"
let kill = hook.processDNSRequest(domain)
if kill { QLog.Info("Blocked: \(domain)") }
}
static func sendMsg(_ messageData: Data) {
hook.handleAppMessage(messageData)
}
}
#endif

View File

@@ -1,26 +1,69 @@
import Foundation
import UIKit
let sync = SyncUpdate(periodic: 7)
class SyncUpdate {
private var lastSync: TimeInterval = 0
private var timer: Timer!
private var paused: Int = 1 // first start() will decrement
private(set) var tsEarliest: Timestamp
init(periodic interval: TimeInterval) {
tsEarliest = Pref.DateFilter.lastXMinTimestamp() ?? 0
private var filterType: DateFilterKind
private var range: SQLiteRowRange? // written in reloadRangeFromDB()
/// `tsEarliest ?? 0`
private var tsMin: Timestamp { tsEarliest ?? 0 }
/// `(tsLatest + 1) ?? 0`
private var tsMax: Timestamp { (tsLatest ?? -1) + 1 }
/// Returns invalid range `(-1,-1)` if collection contains no rows
var rows: SQLiteRowRange { get { range ?? (-1,-1) } }
private(set) var tsEarliest: Timestamp? // as set per user, not actual earliest
private(set) var tsLatest: Timestamp? // as set per user, not actual latest
fileprivate init(periodic interval: TimeInterval) {
(filterType, tsEarliest, tsLatest) = Prefs.DateFilter.restrictions()
reloadRangeFromDB()
NotifyDateFilterChanged.observe(call: #selector(didChangeDateFilter), on: self)
timer = Timer.repeating(interval, call: #selector(periodicUpdate), on: self)
syncNow() // because timer will only fire after interval
}
/// Callback fired every `7` seconds.
@objc private func periodicUpdate() { if paused == 0 { syncNow() } }
/// Callback fired when user changes `DateFilter` on root tableView controller
@objc private func didChangeDateFilter() {
self.pause()
let filter = Prefs.DateFilter.restrictions()
filterType = filter.type
DispatchQueue.global().async {
self.set(newEarliest: Pref.DateFilter.lastXMinTimestamp() ?? 0)
// Not necessary, but improve execution order (delete then insert).
if self.tsMin <= (filter.earliest ?? 0) {
self.set(newEarliest: filter.earliest)
self.set(newLatest: filter.latest)
} else {
self.set(newLatest: filter.latest)
self.set(newEarliest: filter.earliest)
}
self.continue()
}
}
/// - Warning: Always call from a background thread!
func needsReloadDB(domain: String? = nil) {
assert(!Thread.isMainThread)
reloadRangeFromDB()
if let dom = domain {
notifyObservers { $0.syncUpdate(self, partialRemove: dom) }
} else {
notifyObservers { $0.syncUpdate(self, reset: rows) }
}
}
// MARK: - Sync Now
/// This will immediately resume timer updates, ignoring previous `pause()` requests.
func start() { paused = 0 }
@@ -37,38 +80,202 @@ class SyncUpdate {
/// Determine rows of outdated entries that should be removed and notify observers as well. (`NotifySyncRemove`)
/// - Note: This method is rate limited. Sync will be performed at most once per second.
/// - Note: This method returns immediatelly. Syncing is done in a background thread.
func syncNow() {
/// - Parameter block: **Always** called on a background thread!
func syncNow(whenDone block: (() -> Void)? = nil) {
let now = Date().timeIntervalSince1970
guard (now - lastSync) > 1 else { return } // rate limiting
guard (now - lastSync) > 1 else { // rate limiting
if let b = block { DispatchQueue.global().async { b() } }
return
}
lastSync = now
self.pause() // reduce concurrent load
DispatchQueue.global().async {
self.pause() // reduce concurrent load
if let inserted = AppDB?.dnsLogsPersist() { // move cache -> heap
NotifySyncInsert.postAsyncMain(inserted)
}
if let lastXFilter = Pref.DateFilter.lastXMinTimestamp() {
self.set(newEarliest: lastXFilter)
}
// TODO: periodic hard delete old logs (will reset rowids!)
self.internalSync()
block?()
self.continue()
}
}
/// Called by `syncNow()`. Split to a separate func to reduce `self.` cluttering
private func internalSync() {
assert(!Thread.isMainThread)
// Always persist logs ...
if let newest = AppDB?.dnsLogsPersist() { // move cache -> heap
if filterType == .ABRange {
// ... even if we filter a few later
if let r = rows(tsMin, tsMax, scope: newest) {
notify(insert: r, .Latest)
}
} else {
notify(insert: newest, .Latest)
}
}
if filterType == .LastXMin {
set(newEarliest: Timestamp.past(minutes: Prefs.DateFilter.LastXMin))
}
// TODO: periodic hard delete old logs (will reset rowids!)
}
// MARK: - Internal
private func rows(_ ts1: Timestamp, _ ts2: Timestamp, scope: SQLiteRowRange = (0,0)) -> SQLiteRowRange? {
AppDB?.dnsLogsRowRange(between: ts1, and: ts2, within: scope)
}
private func reloadRangeFromDB() {
// `nil` is not SQLiteRowRange(0,0) aka. full collection.
// `nil` means invalid range. e.g. ts restriction too high or empty db.
range = rows(tsMin, tsMax)
}
/// Update internal `tsEarliest`, then post `NotifySyncInsert` or `NotifySyncRemove` notification with row ids.
/// - Warning: Always call from a background thread!
private func set(newEarliest: Timestamp) {
let current = tsEarliest
tsEarliest = newEarliest
if current < newEarliest {
if let excess = AppDB?.dnsLogsRowRange(between: current, and: newEarliest) {
NotifySyncRemove.postAsyncMain(excess)
private func set(newEarliest: Timestamp?) {
func from(_ t: Timestamp?) -> Timestamp { t ?? 0 }
func to(_ t: Timestamp) -> Timestamp { tsLatest == nil ? t : min(t, tsMax) }
if let (old, new) = tsEarliest <-/ newEarliest {
if old != nil, (new == nil || new! < old!) {
if let r = rows(from(new), to(old!), scope: (0, range?.start ?? 0)) {
notify(insert: r, .Earliest)
}
} else if range != nil {
if let r = rows(from(old), to(new!), scope: range!) {
notify(remove: r, .Earliest)
}
}
} else if current > newEarliest {
if let missing = AppDB?.dnsLogsRowRange(between: newEarliest, and: current) {
NotifySyncInsert.postAsyncMain(missing)
}
}
/// Update internal `tsLatest`, then post `NotifySyncInsert` or `NotifySyncRemove` notification with row ids.
/// - Warning: Always call from a background thread!
private func set(newLatest: Timestamp?) {
func from(_ t: Timestamp) -> Timestamp { max(t + 1, tsMin) }
func to(_ t: Timestamp?) -> Timestamp { t == nil ? 0 : t! + 1 }
// +1: include upper end because `dnsLogsRowRange` selects `ts < X`
if let (old, new) = tsLatest <-/ newLatest {
if old != nil, (new == nil || old! < new!) {
if let r = rows(from(old!), to(new), scope: (range?.end ?? 0, 0)) {
notify(insert: r, .Latest)
}
} else if range != nil {
if let r = rows(from(new!), to(old), scope: range!) {
notify(remove: r, .Latest)
}
}
} // else: nothing changed
}
}
/// - Warning: Always call from a background thread!
private func notify(insert r: SQLiteRowRange, _ end: SyncUpdateEnd) {
if range == nil { range = r }
else {
switch end {
case .Earliest: range!.start = r.start
case .Latest: range!.end = r.end
}
}
notifyObservers { $0.syncUpdate(self, insert: r, affects: end) }
}
/// - Warning: `range` must not be `nil`!
/// - Warning: Always call from a background thread!
private func notify(remove r: SQLiteRowRange, _ end: SyncUpdateEnd) {
switch end {
case .Earliest: range!.start = r.end + 1
case .Latest: range!.end = r.start - 1
}
if range!.start > range!.end { range = nil }
notifyObservers { $0.syncUpdate(self, remove: r, affects: end) }
}
// MARK: - Observer List
private var observers: [WeakObserver] = []
/// Add `delegate` to observer list and immediatelly call `syncUpdate(reset:)` (on background thread).
func addObserver(_ delegate: SyncUpdateDelegate) {
observers.removeAll { $0.target == nil }
observers.append(.init(target: delegate))
DispatchQueue.global().async {
delegate.syncUpdate(self, reset: self.rows)
}
}
/// - Warning: Always call from a background thread!
private func notifyObservers(_ block: (SyncUpdateDelegate) -> Void) {
assert(!Thread.isMainThread)
self.pause()
for o in observers where o.target != nil { block(o.target!) }
self.continue()
}
}
/// Wrapper class for `SyncUpdateDelegate` that supports weak references
private struct WeakObserver {
weak var target: SyncUpdateDelegate?
weak var pullToRefresh: UIRefreshControl?
}
enum SyncUpdateEnd { case Earliest, Latest }
protocol SyncUpdateDelegate : AnyObject {
/// `SyncUpdate` has unpredictable changes. Reload your `dataSource`.
/// - Warning: This function will **always** be called from a background thread.
func syncUpdate(_ sender: SyncUpdate, reset rows: SQLiteRowRange)
/// `SyncUpdate` added new `rows` to database. Sync changes to your `dataSource`.
/// - Warning: This function will **always** be called from a background thread.
func syncUpdate(_ sender: SyncUpdate, insert rows: SQLiteRowRange, affects: SyncUpdateEnd)
/// `SyncUpdate` outdated some `rows` in database. Sync changes to your `dataSource`.
/// - Warning: This function will **always** be called from a background thread.
func syncUpdate(_ sender: SyncUpdate, remove rows: SQLiteRowRange, affects: SyncUpdateEnd)
/// Background process did delete some entries in database that match `affectedDomain`.
/// Update or remove entries from your `dataSource`.
/// - Warning: This function will **always** be called from a background thread.
func syncUpdate(_ sender: SyncUpdate, partialRemove affectedDomain: String)
}
// MARK: - Pull-To-Refresh
@available(iOS 10.0, *)
extension SyncUpdate {
/// Add Pull-To-Refresh control to `tableViewController`. On action notify `observer.syncUpdate(reset:)`
/// - Warning: Must be called after `addObserver()` such that `observer` exists in list of observers.
func allowPullToRefresh(onTVC tableViewController: UITableViewController?, forObserver: SyncUpdateDelegate) {
guard let i = observers.firstIndex(where: { $0.target === forObserver }) else {
assertionFailure("You must add the observer before enabling Pull-To-Refresh!")
return
}
// remove previous
observers[i].pullToRefresh?.removeTarget(self, action: #selector(pullToRefresh), for: .valueChanged)
observers[i].pullToRefresh = nil
if let tvc = tableViewController {
let rc = UIRefreshControl()
rc.addTarget(self, action: #selector(pullToRefresh), for: .valueChanged)
tvc.tableView.refreshControl = rc
observers[i].pullToRefresh = rc
}
}
/// Pull-To-Refresh callback method. Find observer with corresponding `RefreshControl` and notify `syncUpdate(reset:)`
@objc private func pullToRefresh(sender: UIRefreshControl) {
guard let x = observers.first(where: { $0.pullToRefresh === sender }) else {
assertionFailure("Should never happen. RefreshControl removed from table view while keeping it active somewhere else.")
return
}
syncNow {
x.target?.syncUpdate(self, reset: self.rows)
DispatchQueue.main.sync {
sender.endRefreshing()
}
}
}
}

View File

@@ -1,42 +0,0 @@
import Foundation
#if IOS_SIMULATOR
private let db = AppDB!
private var pStmt: OpaquePointer?
class TestDataSource {
static func load() {
QLog.Debug("SQLite path: \(URL.internalDB())")
let deleted = db.dnsLogsDelete("test.com", strict: false)
try? db.run(sql: "DELETE FROM cache;")
QLog.Debug("Deleting \(deleted) rows matching 'test.com' (+ \(db.numberOfChanges) in cache)")
QLog.Debug("Writing 33 test logs")
pStmt = try! db.logWritePrepare()
try? db.logWrite(pStmt, "keeptest.com", blocked: false)
for _ in 1...4 { try? db.logWrite(pStmt, "test.com", blocked: false) }
for _ in 1...7 { try? db.logWrite(pStmt, "i.test.com", blocked: false) }
for i in 1...8 { try? db.logWrite(pStmt, "b.test.com", blocked: i>5) }
for i in 1...13 { try? db.logWrite(pStmt, "bi.test.com", blocked: i%2==0) }
db.dnsLogsPersist()
QLog.Debug("Creating 4 filters")
db.setFilter("b.test.com", .blocked)
db.setFilter("i.test.com", .ignored)
db.setFilter("bi.test.com", [.blocked, .ignored])
QLog.Debug("Done")
Timer.repeating(2, call: #selector(insertRandom), on: self)
}
@objc static func insertRandom() {
//QLog.Debug("Inserting 1 periodic log entry")
try? db.logWrite(pStmt, "\(arc4random() % 5).count.test.com", blocked: true)
}
}
#endif

View File

@@ -37,6 +37,13 @@ func AskAlert(title: String?, text: String?, buttonText: String = "Continue", bu
return alert
}
/// Show alert hinting the user to go to system settings and re-enable notifications.
func NotificationsDisabledAlert(presentIn viewController: UIViewController) {
Alert(title: "Notifications Disabled",
text: "Go to System Settings > Notifications > AppCheck to re-enable notifications."
).presentIn(viewController)
}
// MARK: Alert with multiple options
/// - Parameters:

View File

@@ -47,6 +47,15 @@ extension NSLayoutConstraint {
@discardableResult static func |(l: NSLayoutConstraint, r: UILayoutPriority) -> NSLayoutConstraint { l.priority = r; return l }
}
extension NSLayoutDimension {
/// Create and activate an `equal` constraint with constant value. Format: `A.anchor =&= constant | priority`
@discardableResult static func =&= (l: NSLayoutDimension, r: CGFloat) -> NSLayoutConstraint { l.constraint(equalToConstant: r).on() }
/// Create and activate a `lessThan` constraint with constant value. Format: `A.anchor =<= constant | priority`
@discardableResult static func =<= (l: NSLayoutDimension, r: CGFloat) -> NSLayoutConstraint { l.constraint(lessThanOrEqualToConstant: r).on() }
/// Create and activate a `greaterThan` constraint with constant value. Format: `A.anchor =>= constant | priority`
@discardableResult static func =>= (l: NSLayoutDimension, r: CGFloat) -> NSLayoutConstraint { l.constraint(greaterThanOrEqualToConstant: r).on() }
}
/*
UIView extension to generate multiple constraints at once
@@ -59,6 +68,7 @@ extension UIView {
private static let inverseItem: [NSLayoutConstraint.Attribute] = [.right, .bottom, .trailing, .lastBaseline, .rightMargin, .bottomMargin, .trailingMargin]
/// Create and active constraints for provided edges. Constraints will anchor the same edge on both `self` and `other`.
/// - Note: Will set `translatesAutoresizingMaskIntoConstraints = false`
/// - Parameters:
/// - edges: List of constraint attributes, e.g. `[.top, .bottom, .left, .right]`
/// - other: Instance to bind to, e.g. `UIView` or `UILayoutGuide`
@@ -66,11 +76,18 @@ extension UIView {
/// - rel: Constraint relation. (Default: `.equal`)
/// - Returns: List of created and active constraints
@discardableResult func anchor(_ edges: [NSLayoutConstraint.Attribute], to other: Any, margin: CGFloat = 0, if rel: NSLayoutConstraint.Relation = .equal) -> [NSLayoutConstraint] {
edges.map {
translatesAutoresizingMaskIntoConstraints = false
return edges.map {
let (A, B) = UIView.inverseItem.contains($0) ? (other, self) : (self, other)
return NSLayoutConstraint(item: A, attribute: $0, relatedBy: rel, toItem: B, attribute: $0, multiplier: 1, constant: margin).on()
}
}
/// Sets the priority with which a view resists being made smaller and larger than its intrinsic size.
func constrainHuggingCompression(_ axis: NSLayoutConstraint.Axis, _ priotity: UILayoutPriority) {
setContentHuggingPriority(priotity, for: axis)
setContentCompressionResistancePriority(priotity, for: axis)
}
}
extension Array where Element: NSLayoutConstraint {

View File

@@ -0,0 +1,25 @@
import UIKit
// See: https://noahgilmore.com/blog/dark-mode-uicolor-compatibility/
extension UIColor {
/// `.systemBackground ?? .white`
static var sysBackground: UIColor { if #available(iOS 13.0, *) { return .systemBackground } else { return .white } }
/// `.link ?? .systemBlue`
static var sysLink: UIColor { if #available(iOS 13.0, *) { return .link } else { return .systemBlue } }
/// `.label ?? .black`
static var sysLabel: UIColor { if #available(iOS 13.0, *) { return .label } else { return .black } }
/// `.secondaryLabel ?? rgba(60, 60, 67, 0.6)`
static var sysLabel2: UIColor { if #available(iOS 13.0, *) { return .secondaryLabel } else { return .init(red: 60/255.0, green: 60/255.0, blue: 67/255.0, alpha: 0.6) } }
/// `.tertiaryLabel ?? rgba(60, 60, 67, 0.3)`
static var sysLabel3: UIColor { if #available(iOS 13.0, *) { return .tertiaryLabel } else { return .init(red: 60/255.0, green: 60/255.0, blue: 67/255.0, alpha: 0.3) } }
}
extension NSMutableAttributedString {
func withColor(_ color: UIColor, fromBack: Int) -> Self {
let l = length - fromBack
let r = (l < 0) ? NSMakeRange(0, length) : NSMakeRange(l, fromBack)
self.addAttribute(.foregroundColor, value: color, range: r)
return self
}
}

View File

@@ -0,0 +1,33 @@
precedencegroup CompareAssignPrecedence {
assignment: true
associativity: left
higherThan: ComparisonPrecedence
}
infix operator <-? : CompareAssignPrecedence
infix operator <-/ : CompareAssignPrecedence
extension Equatable {
/// Assign a new value to `lhs` if `newValue` differs from the previous value. Return `false` if they are equal.
/// - Returns: `true` if `lhs` was overwritten with another value
static func <-?(lhs: inout Self, newValue: Self) -> Bool {
if lhs != newValue {
lhs = newValue
return true
}
return false
}
/// Assign a new value to `lhs` if `newValue` differs from the previous value.
/// Return tuple with both values. Or `nil` if they are equal.
/// - Returns: `nil` if `previousValue == newValue`
static func <-/(lhs: inout Self, newValue: Self) -> (previousValue: Self, newValue: Self)? {
let previousValue = lhs
if previousValue != newValue {
lhs = newValue
return (previousValue, newValue)
}
return nil
}
}

View File

@@ -0,0 +1,51 @@
import UIKit
extension UIFont {
func withTraits(traits: UIFontDescriptor.SymbolicTraits) -> UIFont {
UIFont(descriptor: fontDescriptor.withSymbolicTraits(traits)!, size: 0) // keep size as is
}
func bold() -> UIFont { withTraits(traits: .traitBold) }
func italic() -> UIFont { withTraits(traits: .traitItalic) }
func monoSpace() -> UIFont {
let traits = fontDescriptor.object(forKey: .traits) as? [UIFontDescriptor.TraitKey: Any] ?? [:]
let weight = (traits[.weight] as? CGFloat) ?? UIFont.Weight.regular.rawValue
return .monospacedDigitSystemFont(ofSize: pointSize, weight: .init(rawValue: weight))
}
}
extension NSAttributedString {
static func image(_ img: UIImage) -> Self {
let att = NSTextAttachment()
att.image = img
return self.init(attachment: att)
}
}
extension NSMutableAttributedString {
static private var def: UIFont = .preferredFont(forTextStyle: .body)
func normal(_ str: String, _ style: UIFont.TextStyle = .body) -> Self { append(str, withFont: .preferredFont(forTextStyle: style)) }
func bold(_ str: String, _ style: UIFont.TextStyle = .body) -> Self { append(str, withFont: UIFont.preferredFont(forTextStyle: style).bold()) }
func italic(_ str: String, _ style: UIFont.TextStyle = .body) -> Self { append(str, withFont: UIFont.preferredFont(forTextStyle: style).italic()) }
func h1(_ str: String) -> Self { normal(str, .title1) }
func h2(_ str: String) -> Self { normal(str, .title2) }
func h3(_ str: String) -> Self { normal(str, .title3) }
private func append(_ str: String, withFont: UIFont) -> Self {
append(NSAttributedString(string: str, attributes: [
.font : withFont,
.foregroundColor : UIColor.sysLabel
]))
return self
}
func centered(_ content: NSAttributedString) -> Self {
let before = length
append(content)
let ps = NSMutableParagraphStyle()
ps.alignment = .center
addAttribute(.paragraphStyle, value: ps, range: .init(location: before, length: content.length))
return self
}
}

View File

@@ -1,4 +1,4 @@
import UIKit
import Foundation
struct QLog {
private init() {}
@@ -15,14 +15,3 @@ struct QLog {
print(String(format: "%1.3f %@%@", Date().timeIntervalSince1970, tag, message))
}
}
extension UIColor {
static var sysBg: UIColor { get { if #available(iOS 13.0, *) { return .systemBackground } else { return .white } }}
static var sysFg: UIColor { get { if #available(iOS 13.0, *) { return .label } else { return .black } }}
}
extension UIEdgeInsets {
init(all: CGFloat = 0, top: CGFloat? = nil, left: CGFloat? = nil, bottom: CGFloat? = nil, right: CGFloat? = nil) {
self.init(top: top ?? all, left: left ?? all, bottom: bottom ?? all, right: right ?? all)
}
}

View File

@@ -1,11 +1,9 @@
import Foundation
let NotifyVPNStateChanged = NSNotification.Name("GlassVPNStateChanged") // VPNState!
let NotifyDNSFilterChanged = NSNotification.Name("PSIDNSFilterSettingsChanged") // domain: String?
let NotifyVPNStateChanged = NSNotification.Name("GlassVPNStateChanged") // nil!
let NotifyDNSFilterChanged = NSNotification.Name("PSIDNSFilterSettingsChanged") // domain: String!
let NotifyDateFilterChanged = NSNotification.Name("PSIDateFilterSettingsChanged") // nil!
let NotifyLogHistoryReset = NSNotification.Name("PSILogHistoryReset") // domain: String?
let NotifySyncInsert = NSNotification.Name("PSISyncInsert") // SQLiteRowRange!
let NotifySyncRemove = NSNotification.Name("PSISyncRemove") // SQLiteRowRange!
let NotifySortOrderChanged = NSNotification.Name("PSIDateFilterSortOrderChanged") // nil!
let NotifyRecordingChanged = NSNotification.Name("PSIRecordingChanged") // (Recording, deleted: Bool)!

View File

@@ -1,41 +0,0 @@
import Foundation
var currentVPNState: VPNState = .off
let sync = SyncUpdate(periodic: 7)
public enum VPNState : Int {
case on = 1, inbetween, off
}
struct Pref {
struct DidShowTutorial {
static var Welcome: Bool {
get { UserDefaults.standard.bool(forKey: "didShowTutorialAppWelcome") }
set { UserDefaults.standard.set(newValue, forKey: "didShowTutorialAppWelcome") }
}
static var Recordings: Bool {
get { UserDefaults.standard.bool(forKey: "didShowTutorialRecordings") }
set { UserDefaults.standard.set(newValue, forKey: "didShowTutorialRecordings") }
}
}
struct DateFilter {
static var Kind: DateFilterKind {
get { DateFilterKind(rawValue: UserDefaults.standard.integer(forKey: "dateFilterType"))! }
set { UserDefaults.standard.set(newValue.rawValue, forKey: "dateFilterType") }
}
static var LastXMin: Int {
get { UserDefaults.standard.integer(forKey: "dateFilterLastXMin") }
set { UserDefaults.standard.set(newValue, forKey: "dateFilterLastXMin") }
}
/// Return selected timestamp filter or `nil` if filtering is disabled.
/// - Returns: `Timestamp.now() - LastXMin * 60`
static func lastXMinTimestamp() -> Timestamp? {
if Kind != .LastXMin { return nil }
return Timestamp.past(minutes: Pref.DateFilter.LastXMin)
}
}
}
enum DateFilterKind: Int {
case Off = 0, LastXMin = 1, ABRange = 2;
}

View File

@@ -1,14 +1,5 @@
import UIKit
extension NSMutableAttributedString {
func withColor(_ color: UIColor, fromBack: Int) -> Self {
let l = length - fromBack
let r = (l < 0) ? NSMakeRange(0, length) : NSMakeRange(l, fromBack)
self.addAttribute(.foregroundColor, value: color, range: r)
return self
}
}
extension String {
/// Check if string is equal to `domain` or ends with `.domain`
func isSubdomain(of domain: String) -> Bool { self == domain || self.hasSuffix("." + domain) }
@@ -36,7 +27,7 @@ extension String {
}
}
var listOfSLDs: [String : [String : Bool]] = {
private var listOfSLDs: [String : [String : Bool]] = {
let path = Bundle.main.url(forResource: "third-level", withExtension: "txt")
let content = try! String(contentsOf: path!)
var res: [String : [String : Bool]] = [:]

View File

@@ -19,35 +19,30 @@ extension UITableView {
/// Returns `true` if this `tableView` is the currently frontmost visible
var isFrontmost: Bool { window?.isKeyWindow ?? false }
/// If frontmost window, perform `insertRows()`; If not, perform `reloadData()`
func safeInsertRow(_ index: Int, with animation: UITableView.RowAnimation = .automatic) {
isFrontmost ? insertRows(at: [IndexPath(row: index)], with: animation) : reloadData()
}
/// If frontmost window, perform `insertRows()`; If not, perform `reloadData()`
func safeInsertRows(_ range: Range<Int>, with animation: UITableView.RowAnimation = .automatic) {
isFrontmost ? insertRows(at: range.map {IndexPath(row: $0)}, with: animation) : reloadData()
}
/// If frontmost window, perform `deleteRows()`; If not, perform `reloadData()`
func safeDeleteRows(_ indices: [Int], with animation: UITableView.RowAnimation = .automatic) {
isFrontmost ? deleteRows(at: indices.map {IndexPath(row: $0)}, with: animation) : reloadData()
}
/// If frontmost window, perform `deleteRows()`; If not, perform `reloadData()`
func safeDeleteRows(_ range: Range<Int>, with animation: UITableView.RowAnimation = .automatic) {
isFrontmost ? deleteRows(at: range.map {IndexPath(row: $0)}, with: animation) : reloadData()
}
/// If frontmost window, perform `reloadRows()`; If not, perform `reloadData()`
func safeReloadRow(_ index: Int, with animation: UITableView.RowAnimation = .automatic) {
isFrontmost ? reloadRows(at: [IndexPath(row: index)], with: animation) : reloadData()
}
/// If frontmost window, perform `insertRows()`; If not, perform `reloadData()`
func safeInsertRow(_ index: Int, with animation: UITableView.RowAnimation = .automatic) {
isFrontmost ? insertRows(at: [IndexPath(row: index)], with: animation) : reloadData()
}
/// If frontmost window, perform `moveRow()`; If not, perform `reloadData()`
func safeMoveRow(_ from: Int, to: Int) {
isFrontmost ? moveRow(at: IndexPath(row: from), to: IndexPath(row: to)) : reloadData()
}
/// Scroll table to top (while respecting `contentInset`)
func scrollToTop(animated: Bool) {
let top: CGFloat
if #available(iOS 11.0, *) {
top = adjustedContentInset.top
} else {
top = contentInset.top
}
if contentOffset.y != -top {
setContentOffset(.init(x: 0, y: -top), animated: animated)
}
}
}
@@ -65,10 +60,13 @@ protocol EditableRows {
}
extension EditableRows where Self: UITableViewDelegate {
func getRowActionsIOS9(_ index: IndexPath) -> [UITableViewRowAction]? {
func getRowActionsIOS9(_ index: IndexPath, _ table: UITableView) -> [UITableViewRowAction]? {
let userInfo = editableRowUserInfo(index)
return editableRowActions(index).compactMap { a,t in
let x = UITableViewRowAction(style: a == .delete ? .destructive : .normal, title: t) { self.editableRowCallback($1, a, userInfo) }
let x = UITableViewRowAction(style: a == .delete ? .destructive : .normal, title: t) {
self.editableRowCallback($1, a, userInfo)
table.isEditing = false
}
if let color = editableRowActionColor(index, a) {
x.backgroundColor = color
}

View File

@@ -1,7 +1,5 @@
import Foundation
private let dateTimeFormat = DateFormatter(withFormat: "yyyy-MM-dd HH:mm:ss")
extension DateFormatter {
convenience init(withFormat: String) {
self.init()
@@ -9,26 +7,24 @@ extension DateFormatter {
}
}
extension Timestamp {
/// Time string with format `yyyy-MM-dd HH:mm:ss`
func asDateTime() -> String {
dateTimeFormat.string(from: Date.init(timeIntervalSince1970: Double(self)))
}
extension Date {
/// Convert `Timestamp` to `Date`
func toDate() -> Date {
Date(timeIntervalSince1970: Double(self))
}
init(_ ts: Timestamp) { self.init(timeIntervalSince1970: Double(ts)) }
/// Convert `Date` to `Timestamp`
var timestamp: Timestamp { get { Timestamp(self.timeIntervalSince1970) } }
}
extension Timestamp {
/// Current time as `Timestamp` (second accuracy)
static func now() -> Timestamp {
Timestamp(Date().timeIntervalSince1970)
}
static func now() -> Timestamp { Date().timestamp }
/// Create `Timestamp` with `now() - minutes * 60`
static func past(minutes: Int) -> Timestamp {
now() - Timestamp(minutes * 60)
}
static func past(minutes: Int) -> Timestamp { now() - Timestamp(minutes * 60) }
/// Create `Timestamp` with `m * 60` seconds
static func minutes(_ m: Int) -> Timestamp { Timestamp(m * 60) }
/// Create `Timestamp` with `h * 3600` seconds
static func hours(_ h: Int) -> Timestamp { Timestamp(h * 3600) }
/// Create `Timestamp` with `d * 86400` seconds
static func days(_ d: Int) -> Timestamp { Timestamp(d * 86400) }
}
extension Timer {
@@ -39,6 +35,24 @@ extension Timer {
}
}
// MARK: - DateFormat
enum DateFormat {
private static let _hms = DateFormatter(withFormat: "yyyy-MM-dd HH:mm:ss")
private static let _hm = DateFormatter(withFormat: "yyyy-MM-dd HH:mm")
/// Format: `yyyy-MM-dd HH:mm:ss`
static func seconds(_ date: Date) -> String { _hms.string(from: date) }
/// Format: `yyyy-MM-dd HH:mm:ss`
static func seconds(_ ts: Timestamp) -> String { _hms.string(from: Date(ts)) }
/// Format: `yyyy-MM-dd HH:mm`
static func minutes(_ date: Date) -> String { _hm.string(from: date) }
/// Format: `yyyy-MM-dd HH:mm`
static func minutes(_ ts: Timestamp) -> String { _hm.string(from: Date(ts)) }
}
// MARK: - TimeFormat
struct TimeFormat {
@@ -60,12 +74,18 @@ struct TimeFormat {
// MARK: static
/// Time string with format `HH:mm`
/// Time string with format `[HH:]mm:ss` (hours prepended only if duration is 1h+)
static func from(_ duration: Timestamp) -> String {
String(format: "%02d:%02d", duration / 60, duration % 60)
let min = duration / 60
let sec = duration % 60
if min >= 60 {
return String(format: "%02d:%02d:%02d", min / 60, min % 60, sec)
} else {
return String(format: "%02d:%02d", min, sec)
}
}
/// Duration string with format `HH:mm` or `HH:mm.sss`
/// Duration string with format `mm:ss` or `mm:ss.SSS`
static func from(_ duration: TimeInterval, millis: Bool = false) -> String {
let t = Int(duration)
if millis {
@@ -75,7 +95,7 @@ struct TimeFormat {
return String(format: "%02d:%02d", t / 60, t % 60)
}
/// Duration string with format `HH:mm` or `HH:mm.sss` since reference date
/// Duration string with format `mm:ss` or `mm:ss.SSS` since reference date
static func since(_ date: Date, millis: Bool = false) -> String {
from(Date().timeIntervalSince(date), millis: millis)
}

View File

@@ -12,6 +12,18 @@ fileprivate extension FileManager {
}
}
extension FileManager {
func sizeOf(path: String) -> Int64? {
try? attributesOfItem(atPath: path)[.size] as? Int64
}
func readableSizeOf(path: String) -> String? {
guard let fSize = sizeOf(path: path) else { return nil }
let bcf = ByteCountFormatter()
bcf.countStyle = .file
return bcf.string(fromByteCount: fSize)
}
}
extension URL {
// static func exportDir() -> URL { FileManager.default.exportDir() }
static func appGroupDir() -> URL { FileManager.default.appGroupDir() }

View File

@@ -0,0 +1,44 @@
import UIKit
extension UIView {
func asImage(insets: UIEdgeInsets = .zero) -> UIImage {
if #available(iOS 10.0, *) {
let renderer = UIGraphicsImageRenderer(bounds: bounds.inset(by: insets))
return renderer.image { rendererContext in
layer.render(in: rendererContext.cgContext)
}
} else {
UIGraphicsBeginImageContext(bounds.inset(by: insets).size)
let ctx = UIGraphicsGetCurrentContext()!
ctx.translateBy(x: -insets.left, y: -insets.top)
layer.render(in:ctx)
let image = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return UIImage(cgImage: image!.cgImage!)
}
}
/// Find size that fits into frame with given `width` as precondition.
/// - Parameter preferredHeight:If unset, find smallest possible size.
func fittingSize(fixedWidth: CGFloat, preferredHeight: CGFloat = 0) -> CGSize {
systemLayoutSizeFitting(CGSize(width: fixedWidth, height: preferredHeight), withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel)
}
/// Find size that fits into frame with given `height` as precondition.
/// - Parameter preferredWidth:If unset, find smallest possible size.
func fittingSize(fixedHeight: CGFloat, preferredWidth: CGFloat = 0) -> CGSize {
systemLayoutSizeFitting(CGSize(width: preferredWidth, height: fixedHeight), withHorizontalFittingPriority: .fittingSizeLevel, verticalFittingPriority: .required)
}
}
extension UIEdgeInsets {
init(all: CGFloat = 0, top: CGFloat? = nil, left: CGFloat? = nil, bottom: CGFloat? = nil, right: CGFloat? = nil) {
self.init(top: top ?? all, left: left ?? all, bottom: bottom ?? all, right: right ?? all)
}
}
extension UIStoryboard {
func load<T: UIViewController>(_ identifier: String) -> T {
instantiateViewController(withIdentifier: identifier) as! T
}
}

160
main/GlassVPN.swift Normal file
View File

@@ -0,0 +1,160 @@
import NetworkExtension
let GlassVPN = GlassVPNManager()
enum VPNState : Int { case on = 1, inbetween, off }
final class GlassVPNManager {
static let bundleIdentifier = "de.uni-bamberg.psi.AppCheck.VPN"
private var managerVPN: NETunnelProviderManager?
private(set) var state: VPNState = .off
fileprivate init() {
#if IOS_SIMULATOR
postProcessedVPNState(.on)
SimulatorVPN.start()
#else
NETunnelProviderManager.loadAllFromPreferences { managers, error in
self.managerVPN = managers?.first {
($0.protocolConfiguration as? NETunnelProviderProtocol)?
.providerBundleIdentifier == GlassVPNManager.bundleIdentifier
}
guard let mgr = self.managerVPN else {
self.postRawVPNState(.invalid)
return
}
mgr.loadFromPreferences { _ in
self.postRawVPNState(mgr.connection.status)
}
}
NSNotification.Name.NEVPNStatusDidChange.observe(call: #selector(vpnStatusChanged(_:)), on: self)
#endif
NotifyDNSFilterChanged.observe(call: #selector(didChangeDomainFilter), on: self)
}
func setEnabled(_ newState: Bool) {
#if IOS_SIMULATOR
postProcessedVPNState(newState ? .on : .off)
newState ? SimulatorVPN.start() : SimulatorVPN.stop()
#else
guard let mgr = self.managerVPN else {
self.createNewVPN { manager in
self.managerVPN = manager
self.setEnabled(newState)
}
return
}
let state = mgr.isEnabled && (mgr.connection.status == .connected)
if state != newState {
self.updateVPN({ mgr.isEnabled = true }) {
newState ? try? mgr.connection.startVPNTunnel() : mgr.connection.stopVPNTunnel()
}
}
#endif
}
/// Notify VPN extension about changes
/// - Returns: `true` on success, `false` if VPN is off or message could not be converted to `.utf8`
@discardableResult func send(_ message: VPNAppMessage) -> Bool {
#if IOS_SIMULATOR
if state == .on, let data = message.raw {
SimulatorVPN.sendMsg(data)
return true
}
#else
if let session = self.managerVPN?.connection as? NETunnelProviderSession,
session.status == .connected, let data = message.raw {
do {
try session.sendProviderMessage(data, responseHandler: nil)
return true
} catch {}
}
#endif
return false
}
// MARK: - Notify callback
@objc private func vpnStatusChanged(_ notification: Notification) {
postRawVPNState((notification.object as? NETunnelProviderSession)?.status ?? .invalid)
}
@objc private func didChangeDomainFilter(_ notification: Notification) {
send(.filterUpdate(domain: notification.object as? String))
}
// MARK: - Manage configuration
private func createNewVPN(_ success: @escaping (_ manager: NETunnelProviderManager) -> Void) {
let mgr = NETunnelProviderManager()
mgr.localizedDescription = "AppCheck Monitor"
let proto = NETunnelProviderProtocol()
proto.providerBundleIdentifier = GlassVPNManager.bundleIdentifier
proto.serverAddress = "127.0.0.1"
mgr.protocolConfiguration = proto
mgr.isEnabled = true
mgr.saveToPreferences { error in
guard error == nil else {
self.postProcessedVPNState(.off)
//ErrorAlert(error!).presentIn(self.window?.rootViewController)
return
}
success(mgr)
}
}
private func updateVPN(_ body: @escaping () -> Void, _ onSuccess: @escaping () -> Void) {
self.managerVPN?.loadFromPreferences { error in
guard error == nil else { return }
body()
self.managerVPN?.saveToPreferences { error in
guard error == nil else { return }
onSuccess()
}
}
}
// MARK: - Post Notifications
private func postRawVPNState(_ origState: NEVPNStatus) {
let state: VPNState
switch origState {
case .connected: state = .on
case .connecting, .disconnecting, .reasserting: state = .inbetween
case .invalid, .disconnected: fallthrough
@unknown default: state = .off
}
postProcessedVPNState(state)
}
private func postProcessedVPNState(_ state: VPNState) {
self.state = state
NotifyVPNStateChanged.post()
}
}
// ---------------------------------------------------------------
// |
// | MARK: - VPN message
// |
// ---------------------------------------------------------------
struct VPNAppMessage {
let raw: Data?
init(_ string: String) { raw = string.data(using: .utf8) }
static func filterUpdate(domain: String? = nil) -> Self {
.init("filter-update:\(domain ?? "")")
}
static func autoDelete(after interval: Int) -> Self {
.init("auto-delete:\(interval)")
}
/// Only used for connection alert notifications
static func notificationSettingsChanged() -> Self {
.init("notify-prefs-change:1")
}
}

138
main/GlassVPNHook.swift Normal file
View File

@@ -0,0 +1,138 @@
import Foundation
class GlassVPNHook {
private let queue = DispatchQueue.init(label: "PSIGlassDNSQueue", qos: .userInteractive, target: .main)
private var filterDomains: [String]!
private var filterOptions: [(block: Bool, ignore: Bool, customA: Bool, customB: Bool)]!
private var autoDeleteTimer: Timer? = nil
private var cachedNotify: CachedConnectionAlert!
init() { reset() }
/// Reload from stored settings and rebuilt binary search tree
private func reset() {
reloadDomainFilter()
setAutoDelete(PrefsShared.AutoDeleteLogsDays)
cachedNotify = CachedConnectionAlert()
}
/// Invalidate auto-delete timer and release stored properties. You should nullify this instance afterwards.
func cleanUp() {
filterDomains = nil
filterOptions = nil
autoDeleteTimer?.fire() // one last time before we quit
autoDeleteTimer?.invalidate()
cachedNotify = nil
}
/// Call this method from `PacketTunnelProvider.handleAppMessage(_:completionHandler:)`
func handleAppMessage(_ messageData: Data) {
let message = String(data: messageData, encoding: .utf8)
if let msg = message, let i = msg.firstIndex(of: ":") {
let action = msg.prefix(upTo: i)
let value = msg.suffix(from: msg.index(after: i))
switch action {
case "filter-update":
reloadDomainFilter() // TODO: reload only selected domain?
return
case "auto-delete":
setAutoDelete(Int(value) ?? PrefsShared.AutoDeleteLogsDays)
return
case "notify-prefs-change":
cachedNotify = CachedConnectionAlert()
return
default: break
}
}
NSLog("[VPN.WARN] This should never happen! Received unknown handleAppMessage: \(message ?? messageData.base64EncodedString())")
reset() // just in case we fallback to do everything
}
// MARK: - Process DNS Request
/// Log domain request and post notification (if enabled).
/// - Returns: `true` if the request shoud be blocked.
func processDNSRequest(_ domain: String) -> Bool {
let i = filterIndex(for: domain)
// TODO: disable ignore & block during recordings
let (block, ignore, cA, cB) = (i<0) ? (false, false, false, false) : filterOptions[i]
if ignore {
return block
}
queue.async {
do { try AppDB?.logWrite(domain, blocked: block) }
catch { NSLog("[VPN.WARN] Couldn't write: \(error)") }
}
cachedNotify.postOrIgnore(domain, blck: block, custA: cA, custB: cB)
// TODO: wait for notify response to block or allow connection
return block
}
/// Build binary tree for reverse DNS lookup
private func reloadDomainFilter() {
let tmp = AppDB?.loadFilters()?.map({
(String($0.reversed()), $1)
}).sorted(by: { $0.0 < $1.0 }) ?? []
let t1 = tmp.map { $0.0 }
let t2 = tmp.map { ($1.contains(.blocked),
$1.contains(.ignored),
$1.contains(.customA),
$1.contains(.customB)) }
filterDomains = t1
filterOptions = t2
}
/// Lookup for reverse DNS binary tree
private func filterIndex(for domain: String) -> Int {
let reverseDomain = String(domain.reversed())
var lo = 0, hi = filterDomains.count - 1
while lo <= hi {
let mid = (lo + hi)/2
if filterDomains[mid] < reverseDomain {
lo = mid + 1
} else if reverseDomain < filterDomains[mid] {
hi = mid - 1
} else {
return mid
}
}
if lo > 0, reverseDomain.hasPrefix(filterDomains[lo - 1] + ".") {
return lo - 1
}
return -1
}
// MARK: - Auto-delete Timer
/// Prepare auto-delete timer with interval between 1 hr - 1 day.
/// - Parameter days: Max age to keep when deleting
private func setAutoDelete(_ days: Int) {
autoDeleteTimer?.invalidate()
guard days > 0 else { return }
// Repeat interval uses days as hours. min 1 hr, max 24 hrs.
let interval = TimeInterval(min(24, days) * 60 * 60)
autoDeleteTimer = Timer.scheduledTimer(timeInterval: interval,
target: self, selector: #selector(autoDeleteNow),
userInfo: days, repeats: true)
autoDeleteTimer!.fire()
}
/// Callback fired when old data should be deleted.
@objc private func autoDeleteNow(_ sender: Timer) {
NSLog("[VPN.INFO] Auto-delete old logs")
queue.async {
do {
try AppDB?.dnsLogsDeleteOlderThan(days: sender.userInfo as! Int)
} catch {
NSLog("[VPN.WARN] Couldn't delete logs, will retry in 5 minutes. \(error)")
if sender.isValid {
sender.fireDate = Date().addingTimeInterval(300) // retry in 5 min
}
}
}
}
}

View File

@@ -0,0 +1,43 @@
import Foundation
import UserNotifications
struct CachedConnectionAlert {
let enabled: Bool
let invertedMode: Bool
let listBlocked, listCustomA, listCustomB, listElse: Bool
let tone: AnyObject?
init() {
enabled = PrefsShared.ConnectionAlerts.Enabled
guard #available(iOS 10.0, *), enabled else {
invertedMode = false
listBlocked = false
listCustomA = false
listCustomB = false
listElse = false
tone = nil
return
}
invertedMode = PrefsShared.ConnectionAlerts.ExcludeMode
listBlocked = PrefsShared.ConnectionAlerts.Lists.Blocked
listCustomA = PrefsShared.ConnectionAlerts.Lists.CustomA
listCustomB = PrefsShared.ConnectionAlerts.Lists.CustomB
listElse = PrefsShared.ConnectionAlerts.Lists.Else
tone = UNNotificationSound.from(string: PrefsShared.ConnectionAlerts.Sound)
}
/// If notifications are enabled and allowed, schedule new notification. Otherwise NOOP.
/// - Parameters:
/// - domain: Domain will be used as unique identifier for noticiation center and in notification message.
/// - blck: Indicator whether `domain` is part of `blocked` list
/// - custA: Indicator whether `domain` is part of custom list `A`
/// - custB: Indicator whether `domain` is part of custom list `B`
func postOrIgnore(_ domain: String, blck: Bool, custA: Bool, custB: Bool) {
if #available(iOS 10.0, *), enabled {
let onAnyList = listBlocked && blck || listCustomA && custA || listCustomB && custB || listElse
if invertedMode ? !onAnyList : onAnyList {
PushNotification.scheduleConnectionAlert(domain, sound: tone as! UNNotificationSound?)
}
}
}
}

View File

@@ -0,0 +1,153 @@
import UserNotifications
struct PushNotification {
enum Identifier: String {
case YouShallRecordMoreReminder
case CantStopMeNowReminder
case RestInPeaceTombstone
case AllConnectionAlertNotifications
}
static func allowed(_ closure: @escaping (NotificationRequestState) -> Void) {
guard #available(iOS 10, *) else { return }
UNUserNotificationCenter.current().getNotificationSettings { settings in
let state = NotificationRequestState(settings.authorizationStatus)
DispatchQueue.main.async {
closure(state)
}
}
}
/// Available in iOS 12+
static func requestProvisionalOrDoNothing(_ closure: @escaping (Bool) -> Void) {
guard #available(iOS 12, *) else { return closure(false) }
let opt: UNAuthorizationOptions = [.alert, .sound, .badge, .provisional, .providesAppNotificationSettings]
UNUserNotificationCenter.current().requestAuthorization(options: opt) { granted, _ in
DispatchQueue.main.async {
closure(granted)
}
}
}
static func requestAuthorization(_ closure: @escaping (Bool) -> Void) {
guard #available(iOS 10, *) else { return }
var opt: UNAuthorizationOptions = [.alert, .sound, .badge]
if #available(iOS 12, *) {
opt.formUnion(.providesAppNotificationSettings)
}
UNUserNotificationCenter.current().requestAuthorization(options: opt) { granted, _ in
DispatchQueue.main.async {
closure(granted)
}
}
}
static func hasPending(_ ident: Identifier, _ closure: @escaping (Bool) -> Void) {
guard #available(iOS 10, *) else { return }
UNUserNotificationCenter.current().getPendingNotificationRequests {
let hasIt = $0.contains { $0.identifier == ident.rawValue }
DispatchQueue.main.async {
closure(hasIt)
}
}
}
static func cancel(_ ident: Identifier, keepDelivered: Bool = false) {
guard #available(iOS 10, *) else { return }
let center = UNUserNotificationCenter.current()
guard ident != .AllConnectionAlertNotifications else {
// remove all connection alert notifications while
// keeping general purpose reminder notifications
center.getDeliveredNotifications {
var list = $0.map { $0.request.identifier }
list.removeAll { !$0.contains(".") } // each domain (or IP) has a dot
center.removeDeliveredNotifications(withIdentifiers: list)
// no need to do the same for pending since con-alerts are always immediate
}
return
}
center.removePendingNotificationRequests(withIdentifiers: [ident.rawValue])
if !keepDelivered {
center.removeDeliveredNotifications(withIdentifiers: [ident.rawValue])
}
}
@available(iOS 10.0, *)
static func schedule(_ ident: Identifier, content: UNNotificationContent, trigger: UNNotificationTrigger? = nil, waitUntilDone: Bool = false) {
schedule(ident.rawValue, content: content, trigger: trigger, waitUntilDone: waitUntilDone)
}
@available(iOS 10.0, *)
static func schedule(_ ident: String, content: UNNotificationContent, trigger: UNNotificationTrigger? = nil, waitUntilDone: Bool = false) {
let req = UNNotificationRequest(identifier: ident, content: content, trigger: trigger)
waitUntilDone ? req.pushAndWait() : req.push()
}
}
// MARK: - Reminder Alerts
extension PushNotification {
/// Auto-check preferences whether `withText` is set, then schedule notification to 5 min in the future.
static func scheduleRestartReminderBanner() {
guard #available(iOS 10, *), PrefsShared.RestartReminder.WithText else { return }
schedule(.CantStopMeNowReminder,
content: .make("AppCheck disabled",
body: "AppCheck can't monitor network traffic because VPN has stopped.",
sound: .from(string: PrefsShared.RestartReminder.Sound)),
trigger: .make(Date(timeIntervalSinceNow: 5 * 60)),
waitUntilDone: true)
}
/// Auto-check preferences whether `withBadge` is set, then post badge immediatelly.
/// - Parameter on: If `true`, set `1` on app icon. If `false`, remove badge on app icon.
static func scheduleRestartReminderBadge(on: Bool) {
guard #available(iOS 10, *), PrefsShared.RestartReminder.WithBadge else { return }
schedule(.RestInPeaceTombstone, content: .makeBadge(on ? 1 : 0), waitUntilDone: true)
}
}
// MARK: - Connection Alerts
extension PushNotification {
static private let queue = ThrottledBatchQueue<String>(0.5, using: .init(label: "PSINotificationQueue", qos: .default, target: .global()))
/// Post new notification with given domain name. If notification already exists, increase occurrence count.
/// - Parameter domain: Used in the description and as notification identifier.
@available(iOS 10.0, *)
static func scheduleConnectionAlert(_ domain: String, sound: UNNotificationSound?) {
queue.addDelayed(domain) { batch in
let groupSum = batch.reduce(into: [:]) { $0[$1] = ($0[$1] ?? 0) + 1 }
scheduleConnectionAlertMulti(groupSum, sound: sound)
}
}
/// Internal method to post a batch of counted domains.
@available(iOS 10.0, *)
static private func scheduleConnectionAlertMulti(_ group: [String: Int], sound: UNNotificationSound?) {
UNUserNotificationCenter.current().getDeliveredNotifications { delivered in
for (dom, count) in group {
let num: Int
if let prev = delivered.first(where: { $0.request.identifier == dom })?.request.content {
if let p = prev.body.split(separator: "×").first, let i = Int(p) {
num = count + i
} else {
num = count + 1
}
} else {
num = count
}
schedule(dom, content: .make("DNS connection", body: num > 1 ? "\(num)× \(dom)" : dom, sound: sound))
}
}
}
}

View File

@@ -0,0 +1,28 @@
import UserNotifications
extension PushNotification {
static func scheduleRecordingReminder(force: Bool) {
if force {
scheduleRecordingReminder()
} else {
hasPending(.YouShallRecordMoreReminder) {
if !$0 { scheduleRecordingReminder() }
}
}
}
private static func scheduleRecordingReminder() {
guard #available(iOS 10, *) else { return }
let now = Timestamp.now()
var next = RecordingsDB.lastTimestamp() ?? (now - 1)
while next < now {
next += .days(14)
}
schedule(.YouShallRecordMoreReminder,
content: .make("Start new recording",
body: "It's been a while since your last recording …",
sound: .from(string: Prefs.RecordingReminder.Sound)),
trigger: .make(Date(next)))
}
}

View File

@@ -0,0 +1,83 @@
import UserNotifications
enum NotificationRequestState {
case NotDetermined, Denied, Authorized, Provisional
@available(iOS 10.0, *)
init(_ from: UNAuthorizationStatus) {
switch from {
case .denied: self = .Denied
case .authorized: self = .Authorized
case .provisional: self = .Provisional
case .notDetermined: fallthrough
@unknown default: self = .NotDetermined
}
}
}
@available(iOS 10.0, *)
extension UNNotificationRequest {
func push() {
UNUserNotificationCenter.current().add(self) { error in
if let e = error {
NSLog("[ERROR] Can't add push notification: \(e)")
}
}
}
func pushAndWait() {
let semaphore = DispatchSemaphore(value: 0)
UNUserNotificationCenter.current().add(self) { error in
if let e = error {
NSLog("[ERROR] Can't add push notification: \(e)")
}
semaphore.signal()
}
_ = semaphore.wait(wallTimeout: .distantFuture)
}
}
@available(iOS 10.0, *)
extension UNNotificationContent {
/// - Parameter sound: Use `#default` or `nil` to play the default tone. Use `#mute` to play no tone at all. Else use an `UNNotificationSoundName`.
static func make(_ title: String, body: String, sound: UNNotificationSound? = .default) -> UNNotificationContent {
let x = UNMutableNotificationContent()
// use NSString.localizedUserNotificationString(forKey:arguments:)
x.title = title
x.body = body
x.sound = sound
return x
}
/// - Parameter value: `0` will remove the badge
static func makeBadge(_ value: Int) -> UNNotificationContent {
let x = UNMutableNotificationContent()
x.badge = value as NSNumber?
return x
}
}
@available(iOS 10.0, *)
extension UNNotificationTrigger {
/// Calls `(dateMatching: components, repeats: repeats)`
static func make(_ components: DateComponents, repeats: Bool) -> UNCalendarNotificationTrigger {
UNCalendarNotificationTrigger(dateMatching: components, repeats: repeats)
}
/// Calls `(dateMatching: components(second-year), repeats: false)`
static func make(_ date: Date) -> UNCalendarNotificationTrigger {
let components = Calendar.current.dateComponents([.year,.month,.day,.hour,.minute,.second], from: date)
return UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
}
/// Calls `(timeInterval: time, repeats: repeats)`
static func make(_ time: TimeInterval, repeats: Bool) -> UNTimeIntervalNotificationTrigger {
UNTimeIntervalNotificationTrigger(timeInterval: time, repeats: repeats)
}
}
@available(iOS 10.0, *)
extension UNNotificationSound {
static func from(string: String) -> UNNotificationSound? {
switch string {
case "#mute": return nil
case "#default": return .default
case let name: return .init(named: UNNotificationSoundName(name + ".caf"))
}
}
}

View File

@@ -69,7 +69,7 @@ class TVCPreviousRecords: UITableViewController, EditActionsRemove {
let x = dataSource[indexPath.row]
cell.textLabel?.text = x.title ?? x.fallbackTitle
cell.textLabel?.textColor = (x.title == nil) ? .systemGray : nil
cell.detailTextLabel?.text = "at \(x.start.asDateTime()), duration: \(x.durationString ?? "?")"
cell.detailTextLabel?.text = "at \(DateFormat.seconds(x.start)), duration: \(TimeFormat.from(x.duration ?? 0))"
return cell
}
@@ -77,7 +77,7 @@ class TVCPreviousRecords: UITableViewController, EditActionsRemove {
// MARK: - Editing
override func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
getRowActionsIOS9(indexPath)
getRowActionsIOS9(indexPath, tableView)
}
@available(iOS 11.0, *)
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {

View File

@@ -2,23 +2,58 @@ import UIKit
class TVCRecordingDetails: UITableViewController, EditActionsRemove {
var record: Recording!
private var dataSource: [RecordLog]!
private lazy var isLongRecording: Bool = (record.duration ?? 0) > Timestamp.hours(1)
private var showRaw: Bool = false
/// Sorted by `ts` in ascending order (oldest first)
private lazy var dataSourceRaw: [DomainTsPair] = RecordingsDB.details(record)
/// Sorted by `count` (descending), then alphabetically
private lazy var dataSourceSum: [(domain: String, count: Int)] = {
var result: [String:Int] = [:]
for x in dataSourceRaw {
result[x.domain] = (result[x.domain] ?? 0) + 1 // group and count
}
return result.map{$0}.sorted {
$0.count > $1.count || $0.count == $1.count && $0.domain < $1.domain
}
}()
override func viewDidLoad() {
title = record.title ?? record.fallbackTitle
dataSource = RecordingsDB.details(record)
}
@IBAction private func toggleDisplayStyle(_ sender: UIBarButtonItem) {
showRaw = !showRaw
sender.image = UIImage(named: showRaw ? "line-collapse" : "line-expand")
tableView.reloadData()
}
// MARK: - Table View Data Source
override func tableView(_ _: UITableView, numberOfRowsInSection _: Int) -> Int { dataSource.count }
override func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int {
showRaw ? dataSourceRaw.count : dataSourceSum.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "PreviousRecordDetailCell")!
let x = dataSource[indexPath.row]
cell.textLabel?.text = x.domain
cell.detailTextLabel?.text = "\(x.count)"
let cell: UITableViewCell
if showRaw {
let x = dataSourceRaw[indexPath.row]
if isLongRecording {
cell = tableView.dequeueReusableCell(withIdentifier: "RecordDetailLongCell")!
cell.textLabel?.text = x.domain
cell.detailTextLabel?.text = DateFormat.seconds(x.ts)
} else {
cell = tableView.dequeueReusableCell(withIdentifier: "RecordDetailShortCell")!
cell.textLabel?.text = "+ " + TimeFormat.from(x.ts - record.start)
cell.detailTextLabel?.text = x.domain
}
} else {
let x = dataSourceSum[indexPath.row]
cell = tableView.dequeueReusableCell(withIdentifier: "RecordDetailCountedCell")!
cell.textLabel?.text = x.domain
cell.detailTextLabel?.text = "\(x.count)×"
}
return cell
}
@@ -26,7 +61,7 @@ class TVCRecordingDetails: UITableViewController, EditActionsRemove {
// MARK: - Editing
override func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
getRowActionsIOS9(indexPath)
getRowActionsIOS9(indexPath, tableView)
}
@available(iOS 11.0, *)
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
@@ -34,9 +69,25 @@ class TVCRecordingDetails: UITableViewController, EditActionsRemove {
}
func editableRowCallback(_ index: IndexPath, _ action: RowAction, _ userInfo: Any?) -> Bool {
if RecordingsDB.deleteDetails(record, domain: dataSource[index.row].domain) {
dataSource.remove(at: index.row)
tableView.deleteRows(at: [index], with: .automatic)
if showRaw {
let x = dataSourceRaw[index.row]
if RecordingsDB.deleteSingle(record, domain: x.domain, ts: x.ts) {
if let i = dataSourceSum.firstIndex(where: { $0.domain == x.domain }) {
dataSourceSum[i].count -= 1
if dataSourceSum[i].count == 0 {
dataSourceSum.remove(at: i)
}
}
dataSourceRaw.remove(at: index.row)
tableView.deleteRows(at: [index], with: .automatic)
}
} else {
let dom = dataSourceSum[index.row].domain
if RecordingsDB.deleteDetails(record, domain: dom) {
dataSourceRaw.removeAll { $0.domain == dom }
dataSourceSum.remove(at: index.row)
tableView.deleteRows(at: [index], with: .automatic)
}
}
return true
}

View File

@@ -16,9 +16,9 @@ class VCEditRecording: UIViewController, UITextFieldDelegate, UITextViewDelegate
inputTitle.text = record.title
inputNotes.text = record.notes
inputDetails.text = """
Start: \(record.start.asDateTime())
End: \(record.stop?.asDateTime() ?? "?")
Duration: \(record.durationString ?? "?")
Start: \(DateFormat.seconds(record.start))
End: \(record.stop == nil ? "?" : DateFormat.seconds(record.stop!))
Duration: \(TimeFormat.from(record.duration ?? 0))
"""
validateSaveButton()
if deleteOnCancel { // mark as destructive
@@ -47,6 +47,9 @@ class VCEditRecording: UIViewController, UITextFieldDelegate, UITextViewDelegate
RecordingsDB.update(self.record)
if newlyCreated {
RecordingsDB.persist(self.record)
if Prefs.RecordingReminder.Enabled {
PushNotification.scheduleRecordingReminder(force: true)
}
}
}
}

View File

@@ -12,15 +12,12 @@ class VCRecordings: UIViewController, UINavigationControllerDelegate {
override func viewDidLoad() {
prevRecController = (children.first as! UINavigationController)
prevRecController.delegate = self
// Duplicate font attributes but set monospace
let traits = timeLabel.font.fontDescriptor.object(forKey: .traits) as? [UIFontDescriptor.TraitKey: Any] ?? [:]
let weight = traits[.weight] as? CGFloat ?? UIFont.Weight.regular.rawValue
timeLabel.font = UIFont.monospacedDigitSystemFont(ofSize: timeLabel.font.pointSize, weight: UIFont.Weight(rawValue: weight))
timeLabel.font = timeLabel.font.monoSpace()
// hide timer if not running
updateUI(setRecording: false, animated: false)
currentRecording = RecordingsDB.getCurrent()
if !Pref.DidShowTutorial.Recordings {
if !Prefs.DidShowTutorial.Recordings {
self.perform(#selector(showTutorial), with: nil, afterDelay: 0.5)
}
}
@@ -71,7 +68,7 @@ class VCRecordings: UIViewController, UINavigationControllerDelegate {
guard let r = currentRecording, r.stop == nil else {
return
}
recordingTimer = Timer.repeating(0.086, call: #selector(timerCallback(_:)), on: self, userInfo: r.start.toDate())
recordingTimer = Timer.repeating(0.086, call: #selector(timerCallback(_:)), on: self, userInfo: Date(r.start))
updateUI(setRecording: true, animated: animate)
}
@@ -137,7 +134,7 @@ class VCRecordings: UIViewController, UINavigationControllerDelegate {
))
x.buttonTitleDone = "Got it"
x.present {
Pref.DidShowTutorial.Recordings = true
Prefs.DidShowTutorial.Recordings = true
}
}
}

View File

@@ -0,0 +1,60 @@
import UIKit
protocol AnalysisBarDelegate {
func analysisBarWillOpenCoOccurrence() -> (domain: String, isFQDN: Bool)
}
class VCAnalysisBar: UIViewController, UITabBarDelegate {
@IBOutlet private var tabBar: UITabBar!
override func viewDidLoad() {
if #available(iOS 10.0, *) {
tabBar.unselectedItemTintColor = .sysLink
}
super.viewDidLoad()
}
override func willMove(toParent parent: UIViewController?) {
super.willMove(toParent: parent)
let enabled = (parent as? AnalysisBarDelegate) != nil
for item in tabBar.items! { item.isEnabled = enabled }
}
// MARK: - Tab Bar Appearance
override func viewWillAppear(_: Bool) {
resizeTableViewHeader()
}
override func traitCollectionDidChange(_: UITraitCollection?) {
resizeTableViewHeader()
}
func resizeTableViewHeader() {
guard let tableView = (parent as? UITableViewController)?.tableView,
let head = tableView.tableHeaderView else { return }
// Recalculate and apply new height. Otherwise tabBar won't compress
tabBar.sizeToFit()
head.frame.size.height = tabBar.frame.height
tableView.tableHeaderView = head
}
// MARK: - Tab Bar Delegate
func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) {
tabBar.selectedItem = nil
openCoOccurrence()
}
private func openCoOccurrence() {
guard let delegate = parent as? AnalysisBarDelegate,
let vc: VCCoOccurrence = storyboard?.load("IBCoOccurrence") else {
return
}
let info = delegate.analysisBarWillOpenCoOccurrence()
vc.domainName = info.domain
vc.isFQDN = info.isFQDN
present(vc, animated: true)
}
}

View File

@@ -0,0 +1,152 @@
import UIKit
class VCCoOccurrence: UIViewController, UITableViewDataSource {
var domainName: String!
var isFQDN: Bool!
private var dataSource: [ContextAnalysisResult] = []
@IBOutlet private var tableView: UITableView!
@IBOutlet private var timeSegment: UISegmentedControl!
private let availableTimes = [0, 5, 15, 30]
private var selectedTime = -1 {
didSet { logTimeDelta = log(CGFloat(max(2, selectedTime+1))) }
}
private var logTimeDelta: CGFloat = 1
private var logMaxCount: CGFloat = 1
override func viewDidLoad() {
super.viewDidLoad()
selectedTime = Prefs.ContextAnalyis.CoOccurrenceTime // calls `didSet` and `logTimeDelta`
timeSegment.removeAllSegments() // clear IB values
for (i, time) in availableTimes.enumerated() {
timeSegment.insertSegment(withTitle: TimeFormat(.abbreviated).from(seconds: time), at: i, animated: false)
if time == selectedTime {
timeSegment.selectedSegmentIndex = i
}
}
reloadDataSource()
}
func reloadDataSource() {
dataSource = [("Loading …", 0, 0, 0)]
logMaxCount = 1
tableView.reloadData()
let domain = domainName!
let flag = isFQDN!
let time = Timestamp(selectedTime)
DispatchQueue.global().async { [weak self] in
let temp: [ContextAnalysisResult]
let total: Int32
if let db = AppDB,
let times = db.dnsLogsUniqTs(domain, isFQDN: flag), times.count > 0,
let result = db.contextAnalysis(coOccurrence: times, plusMinus: time, exclude: domain, isFQDN: flag),
result.count > 0
{
temp = result
var sum: Int32 = 0
for x in result { sum += x.count }
total = sum // if statement guarantees >= 1
} else {
temp = []
total = 1
}
DispatchQueue.main.sync { [weak self] in
self?.dataSource = temp
self?.logMaxCount = log(CGFloat(total + 1))
self?.tableView.reloadData()
}
}
}
@IBAction func didChangeTime(_ sender: UISegmentedControl) {
selectedTime = availableTimes[sender.selectedSegmentIndex]
Prefs.ContextAnalyis.CoOccurrenceTime = selectedTime
reloadDataSource()
}
@IBAction func didClose(_ sender: UIBarButtonItem) {
dismiss(animated: true)
}
// MARK: - Table View Data Source
func tableView(_ _: UITableView, numberOfRowsInSection _: Int) -> Int {
dataSource.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "CoOccurrenceCell") as! CoOccurrenceCell
let src = dataSource[indexPath.row]
cell.title.text = src.domain
cell.rank.text = "\(indexPath.row + 1)."
cell.count.text = "\(src.count)"
cell.avgdiff.text = String(format: "%.2fs", src.avg)
// log percentage of total co-occurrence count + 1 (min: log(2))
cell.countMeter.percent = (log(CGFloat(src.count + 1)) / logMaxCount)
// log percentage of selected time window (0s/5s/15s/30s) + 1 (min: log(2))
cell.avgdiffMeter.percent = 1 - (log(CGFloat(src.avg + 1)) / logTimeDelta)
return cell
}
}
class CoOccurrenceCell: UITableViewCell {
@IBOutlet var title: UILabel!
@IBOutlet var rank: TagLabel!
@IBOutlet var count: TagLabel!
@IBOutlet var avgdiff: TagLabel!
@IBOutlet var countMeter: MeterBar!
@IBOutlet var avgdiffMeter: MeterBar!
}
// MARK: - Tutorial Screen
extension VCCoOccurrence {
@IBAction func showInfoScreen() {
let sampleCell: UIImage = {
let cell = tableView.dequeueReusableCell(withIdentifier: "CoOccurrenceCell") as! CoOccurrenceCell
cell.title.text = "example.org"
cell.rank.text = "9."
cell.count.text = "14"
cell.avgdiff.text = String(format: "%.2fs", 0.71)
cell.countMeter.percent = 0.35
cell.avgdiffMeter.percent = 0.95
// Bug: Sometimes dequeue will return a "broken" hidden cell.
// It can't be set visible and thus can't render an image.
// Funnily `cell.contentView` can rendered.
let theView = cell.isHidden ? cell.contentView : cell
// resize view to fit into tutorial sheet
let minWidth = TutorialSheet.verticalWidth - 10 //-> 2 * textContainer.lineFragmentPadding
theView.frame.size = theView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
theView.frame.size.width = min(theView.frame.size.width, minWidth)
// set width in two steps because first call may change layoutMargins
theView.frame.size.width += theView.layoutMargins.left + theView.layoutMargins.right
// FIXME: In case `hidden == false`, backgroundColor will be black in Dark mode.
theView.backgroundColor = tableView.backgroundColor
return theView.asImage(insets: theView.layoutMargins)
}()
let x = TutorialSheet()
x.addSheet().addArrangedSubview(QuickUI.text(attributed: NSMutableAttributedString()
.h3("Co-Occurrence")
.normal(" allows you to find requests that happen often at the same time as the selected domain. " +
"Hence it will give you a hint what Apps might be involved in the activity." +
"\n\nHow do you interpret these results? Lets look at an example:\n\n")
.centered(.image(sampleCell))
.normal("\n\nThe domain ").bold("example.org").normal(" had ").bold("14").normal(" requests with an ").italic("average time divergence").normal(" of ").bold("0.71 seconds").normal(". " +
"That is, these 14 domain calls happend, on average, less then a second before or after the original request of the selected domain." +
"\n\nClose temporal proximity and high occurrence counts are both indicators for domain correlation. " +
"Results are sorted by a ranking index (").bold("9.").normal(") which strikes a balance between the two. " +
"Preferring entries with higher counts as well as low time divergence.")
.italic("\n\nTip: ").normal("As a visual guide you can look for the colored bar beside each value. " +
"The larger the bar, the greater the correlation.")
))
x.present(in: self)
}
}

View File

@@ -1,8 +1,8 @@
import UIKit
class TVCDomains: UITableViewController, UISearchBarDelegate, FilterPipelineDelegate {
class TVCDomains: UITableViewController, UISearchBarDelegate, GroupedDomainDataSourceDelegate {
lazy var source = GroupedDomainDataSource(withDelegate: self, parent: nil)
lazy var source = GroupedDomainDataSource(withParent: nil)
@IBOutlet private var filterButton: UIBarButtonItem!
@IBOutlet private var filterButtonDetail: UIBarButtonItem!
@@ -11,14 +11,12 @@ class TVCDomains: UITableViewController, UISearchBarDelegate, FilterPipelineDele
super.viewDidLoad()
NotifyDateFilterChanged.observe(call: #selector(didChangeDateFilter), on: self)
didChangeDateFilter()
source.delegate = self // init lazy var, ready for tableView data source
}
private var didLoadAlready = false
override func viewDidAppear(_ animated: Bool) {
if !didLoadAlready {
didLoadAlready = true
source.reloadFromSource()
}
// iOS 11+ fix: fuse after `didAppear` to hide on app launch
source.search.fuseWith(tableViewController: self)
}
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
@@ -27,18 +25,20 @@ class TVCDomains: UITableViewController, UISearchBarDelegate, FilterPipelineDele
}
}
// MARK: - Search
@IBAction private func searchButtonTapped(_ sender: UIBarButtonItem) {
source.toggleSearch()
func pushOpen(domain: String) {
let A: TVCHosts = storyboard!.load("requestsHosts")
let B: TVCHostDetails = storyboard!.load("requestsOccurrences")
A.parentDomain = domain.extractDomain()
B.fullDomain = domain
navigationController?.pushViewController(A, animated: false)
navigationController?.pushViewController(B, animated: false)
}
// MARK: - Filter
@IBAction private func filterButtonTapped(_ sender: UIBarButtonItem) {
let vc = self.storyboard!.instantiateViewController(withIdentifier: "domainFilter")
let vc = storyboard!.load("domainFilter")
vc.modalPresentationStyle = .custom
if #available(iOS 13.0, *) {
vc.isModalInPresentation = true
@@ -47,12 +47,12 @@ class TVCDomains: UITableViewController, UISearchBarDelegate, FilterPipelineDele
}
@objc private func didChangeDateFilter() {
switch Pref.DateFilter.Kind {
switch Prefs.DateFilter.Kind {
case .ABRange: // read start/end time
self.filterButtonDetail.title = "AB"
self.filterButton.image = UIImage(named: "filter-filled")
case .LastXMin: // most recent
let lastXMin = Pref.DateFilter.LastXMin
let lastXMin = Prefs.DateFilter.LastXMin
if lastXMin == 0 { fallthrough }
self.filterButtonDetail.title = TimeFormat(.abbreviated).from(minutes: lastXMin)
self.filterButton.image = UIImage(named: "filter-filled")
@@ -76,7 +76,7 @@ class TVCDomains: UITableViewController, UISearchBarDelegate, FilterPipelineDele
return cell
}
func rowNeedsUpdate(_ row: Int) {
func groupedDomainDataSource(needsUpdate row: Int) {
let entry = source[row]
let cell = tableView.cellForRow(at: IndexPath(row: row))
cell?.detailTextLabel?.text = entry.detailCellText

View File

@@ -1,75 +1,99 @@
import UIKit
class TVCHostDetails: UITableViewController {
class TVCHostDetails: UITableViewController, SyncUpdateDelegate, AnalysisBarDelegate {
public var fullDomain: String!
private var dataSource: [GroupedTsOccurrence] = []
// TODO: respect date reverse sort order
override func viewDidLoad() {
super.viewDidLoad()
navigationItem.prompt = fullDomain
super.viewDidLoad()
sync.addObserver(self) // calls `syncUpdate(reset:)`
if #available(iOS 10.0, *) {
tableView.refreshControl = UIRefreshControl(call: #selector(reloadDataSource), on: self)
}
NotifyLogHistoryReset.observe(call: #selector(reloadDataSource), on: self)
NotifySyncInsert.observe(call: #selector(syncInsert), on: self)
NotifySyncRemove.observe(call: #selector(syncRemove), on: self)
reloadDataSource()
}
@objc func reloadDataSource(sender: Any? = nil) {
let refreshControl = sender as? UIRefreshControl
let notification = sender as? Notification
if let affectedDomain = notification?.object as? String {
guard fullDomain.isSubdomain(of: affectedDomain) else { return }
}
DispatchQueue.global().async { [weak self] in
self?.dataSource = AppDB?.timesForDomain(self?.fullDomain ?? "", since: sync.tsEarliest) ?? []
DispatchQueue.main.sync {
self?.tableView.reloadData()
sync.syncNow() // sync outstanding entries in cache
refreshControl?.endRefreshing()
}
sync.allowPullToRefresh(onTVC: self, forObserver: self)
}
}
@objc private func syncInsert(_ notification: Notification) {
let range = notification.object as! SQLiteRowRange
if let latest = AppDB?.timesForDomain(fullDomain, range: range), latest.count > 0 {
dataSource.insert(contentsOf: latest, at: 0)
if tableView.isFrontmost {
let indices = (0..<latest.count).map { IndexPath(row: $0) }
tableView.insertRows(at: indices, with: .left)
} else {
tableView.reloadData()
}
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if let index = tableView.indexPathForSelectedRow?.row {
let tvc = segue.destination as? TVCOccurrenceContext
tvc?.domain = fullDomain
tvc?.ts = dataSource[index].ts
}
}
@objc private func syncRemove(_ notification: Notification) {
let earliest = sync.tsEarliest
if let i = dataSource.firstIndex(where: { $0.ts < earliest }) {
// since they are ordered, we can optimize
let indices = (i..<dataSource.endIndex).map { IndexPath(row: $0) }
dataSource.removeLast(dataSource.count - i)
if tableView.isFrontmost {
tableView.deleteRows(at: indices, with: .automatic)
} else {
tableView.reloadData()
}
}
func analysisBarWillOpenCoOccurrence() -> (domain: String, isFQDN: Bool) {
(fullDomain, true)
}
// MARK: - Table View Data Source
override func tableView(_ _: UITableView, numberOfRowsInSection _: Int) -> Int { dataSource.count }
override func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int { dataSource.count }
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "HostDetailCell")!
let src = dataSource[indexPath.row]
cell.textLabel?.text = src.ts.asDateTime()
cell.detailTextLabel?.text = (src.total > 1) ? "\(src.total)x" : nil
cell.textLabel?.text = DateFormat.seconds(src.ts)
cell.detailTextLabel?.text = (src.total > 1) ? "\(src.total)×" : nil
cell.imageView?.image = (src.blocked > 0 ? UIImage(named: "shield-x") : nil)
return cell
}
}
// ################################
// #
// # MARK: - Partial Update
// #
// ################################
extension TVCHostDetails {
func syncUpdate(_ _: SyncUpdate, reset rows: SQLiteRowRange) {
dataSource = AppDB?.timesForDomain(fullDomain, range: rows) ?? []
DispatchQueue.main.sync { tableView.reloadData() }
}
func syncUpdate(_ _: SyncUpdate, insert rows: SQLiteRowRange, affects: SyncUpdateEnd) {
guard let latest = AppDB?.timesForDomain(fullDomain, range: rows), latest.count > 0 else {
return
}
// Assuming they are ordered by ts and in descending order
let range: Range<Int>
switch affects {
case .Earliest:
range = dataSource.endIndex..<(dataSource.endIndex + latest.count)
dataSource.append(contentsOf: latest)
case .Latest:
range = dataSource.startIndex..<(dataSource.startIndex + latest.count)
dataSource.insert(contentsOf: latest, at: 0)
}
DispatchQueue.main.sync { tableView.safeInsertRows(range, with: .left) }
}
func syncUpdate(_ sender: SyncUpdate, remove _: SQLiteRowRange, affects: SyncUpdateEnd) {
// Assuming they are ordered by ts and in descending order
let range: Range<Int>
switch affects {
case .Earliest:
guard let t = sender.tsEarliest,
let i = dataSource.lastIndex(where: { $0.ts >= t }),
(i+1) < dataSource.count else { return }
range = (i+1)..<dataSource.endIndex
dataSource.removeLast(dataSource.count - (i+1))
case .Latest:
guard let t = sender.tsLatest,
let i = dataSource.firstIndex(where: { $0.ts <= t }),
i > 0 else { return }
range = dataSource.startIndex..<i
dataSource.removeFirst(i)
}
DispatchQueue.main.sync { tableView.safeDeleteRows(range) }
}
func syncUpdate(_ sender: SyncUpdate, partialRemove affectedDomain: String) {
if fullDomain.isSubdomain(of: affectedDomain) {
syncUpdate(sender, reset: sender.rows)
}
}
}

View File

@@ -1,17 +1,18 @@
import UIKit
class TVCHosts: UITableViewController, FilterPipelineDelegate {
class TVCHosts: UITableViewController, GroupedDomainDataSourceDelegate, AnalysisBarDelegate {
lazy var source = GroupedDomainDataSource(withDelegate: self, parent: parentDomain)
lazy var source = GroupedDomainDataSource(withParent: parentDomain)
public var parentDomain: String!
private var isSpecial: Bool = false
override func viewDidLoad() {
super.viewDidLoad()
navigationItem.prompt = parentDomain
super.viewDidLoad()
isSpecial = (parentDomain.first == "#") // aka: "# IP address"
source.reloadFromSource() // init lazy var
source.delegate = self // init lazy var, ready for tableView data source
source.search.fuseWith(tableViewController: self)
}
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
@@ -20,12 +21,11 @@ class TVCHosts: UITableViewController, FilterPipelineDelegate {
}
}
// MARK: - Search
@IBAction private func searchButtonTapped(_ sender: UIBarButtonItem) {
source.toggleSearch()
func analysisBarWillOpenCoOccurrence() -> (domain: String, isFQDN: Bool) {
(parentDomain, false)
}
// MARK: - Table View Data Source
override func tableView(_ _: UITableView, numberOfRowsInSection _: Int) -> Int { source.numberOfRows }
@@ -45,7 +45,7 @@ class TVCHosts: UITableViewController, FilterPipelineDelegate {
return cell
}
func rowNeedsUpdate(_ row: Int) {
func groupedDomainDataSource(needsUpdate row: Int) {
let entry = source[row]
let cell = tableView.cellForRow(at: IndexPath(row: row))
cell?.detailTextLabel?.text = entry.detailCellText

View File

@@ -0,0 +1,97 @@
import UIKit
class TVCOccurrenceContext: UITableViewController {
var ts: Timestamp!
var domain: String!
private let dT: Timestamp = 300 // +/- 5 minutes
private lazy var dataSource: [DomainTsPair] = {
let logs = AppDB?.dnsLogs(between: ts - dT, and: ts + dT) ?? []
return [("[…]", ts - dT)] + logs.reversed() + [("[…]", ts + dT)]
}()
override func viewDidLoad() {
navigationItem.title = "± 5 Min Context"
super.viewDidLoad()
}
override func viewDidAppear(_ animated: Bool) {
jumpToTsZero()
}
@IBAction private func jumpToTsZero() {
if let i = dataSource.firstIndex(where: { isChoosenOne($0) }) {
tableView.scrollToRow(at: IndexPath(row: i), at: .middle, animated: true)
}
}
private func isChoosenOne(_ obj: DomainTsPair) -> Bool {
obj.domain == domain && obj.ts == ts
}
private func firstOrLast(_ row: Int) -> Bool {
row == 0 || row == dataSource.count - 1
}
// MARK: - Table View Data Source
override func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int { dataSource.count }
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "OccurrenceContextCell")!
let src = dataSource[indexPath.row]
cell.detailTextLabel?.text = src.domain
if firstOrLast(indexPath.row) {
cell.detailTextLabel?.textColor = .sysLabel2 // same as textLabel
} else if isChoosenOne(src) {
cell.detailTextLabel?.textColor = .sysLink
} else {
cell.detailTextLabel?.textColor = .sysLabel
}
if src.ts > ts {
cell.textLabel?.text = "+ " + TimeFormat.from(src.ts - ts)
} else if src.ts < ts {
cell.textLabel?.text = " " + TimeFormat.from(ts - src.ts)
} else {
cell.textLabel?.text = "0"
}
//cell.textLabel?.text = String(format: "%+d s", src.ts - ts)
return cell
}
// MARK: - Tap to Copy
private var rowToCopy: Int = Int.max
override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
if firstOrLast(indexPath.row) { return nil }
if rowToCopy == indexPath.row {
UIMenuController.shared.setMenuVisible(false, animated: true)
rowToCopy = Int.max
return nil
}
rowToCopy = indexPath.row
self.becomeFirstResponder()
let cell = tableView.cellForRow(at: indexPath)!
UIMenuController.shared.setTargetRect(cell.bounds, in: cell)
UIMenuController.shared.setMenuVisible(true, animated: true)
return nil
}
override var canBecomeFirstResponder: Bool { true }
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
action == #selector(UIResponderStandardEditActions.copy)
}
override func copy(_ sender: Any?) {
guard rowToCopy < dataSource.count else { return }
UIPasteboard.general.string = dataSource[rowToCopy].domain
rowToCopy = Int.max
}
}

View File

@@ -4,48 +4,51 @@ import UIKit
class VCDateFilter: UIViewController, UIGestureRecognizerDelegate {
@IBOutlet private var segmentControl: UISegmentedControl!
@IBOutlet private var sectionTitle: UILabel!
@IBOutlet private var filterBy: UISegmentedControl!
// entries no older than
@IBOutlet private var durationTitle: UILabel!
@IBOutlet private var durationView: UIView!
@IBOutlet private var durationSlider: UISlider!
@IBOutlet private var durationLabel: UILabel!
private let durationTimes = [0, 1, 20, 60, 360, 720, 1440, 2880, 4320, 10080]
// entries within range
@IBOutlet private var rangeTitle: UILabel!
@IBOutlet private var rangeView: UIView!
@IBOutlet private var buttonRangeStart: UIButton!
@IBOutlet private var buttonRangeEnd: UIButton!
private lazy var tsRangeA: Timestamp = Prefs.DateFilter.RangeA ?? AppDB?.dnsLogsMinDate() ?? .now()
private lazy var tsRangeB: Timestamp = Prefs.DateFilter.RangeB ?? .now()
// order by
@IBOutlet private var orderbyType: UISegmentedControl!
@IBOutlet private var orderbyAsc: UISegmentedControl!
override func viewDidLoad() {
super.viewDidLoad()
segmentControl.selectedSegmentIndex = (Pref.DateFilter.Kind == .ABRange ? 1 : 0)
didChangeSegment(segmentControl)
segmentControl.setEnabled(false, forSegmentAt: 1) // TODO: until range filter is ready
filterBy.selectedSegmentIndex = (Prefs.DateFilter.Kind == .ABRange ? 1 : 0)
didChangeFilterBy(filterBy)
durationSlider.tag = -1 // otherwise wont update because `tag == 0`
durationSlider.value = Float(durationTimes.firstIndex(of: Pref.DateFilter.LastXMin) ?? 0) / 9
durationSlider.value = Float(durationTimes.firstIndex(of: Prefs.DateFilter.LastXMin) ?? 0) / 9
durationSliderChanged(durationSlider)
var a = Timestamp(4).asDateTime() // TODO: load from preferences
var b = Timestamp.now().asDateTime()
a.removeLast(3) // remove seconds
b.removeLast(3)
buttonRangeStart.setTitle(a, for: .normal)
buttonRangeEnd.setTitle(b, for: .normal)
buttonRangeStart.setTitle(DateFormat.minutes(tsRangeA), for: .normal)
buttonRangeEnd.setTitle(DateFormat.minutes(tsRangeB), for: .normal)
orderbyType.selectedSegmentIndex = Prefs.DateFilter.OrderBy.rawValue
orderbyAsc.selectedSegmentIndex = (Prefs.DateFilter.OrderAsc ? 0 : 1)
}
@IBAction private func didChangeSegment(_ sender: UISegmentedControl) {
durationView.isHidden = (sender.selectedSegmentIndex != 0)
rangeView.isHidden = (sender.selectedSegmentIndex != 1)
switch sender.selectedSegmentIndex {
case 0: sectionTitle.text = "Show entries no older than"
case 1: sectionTitle.text = "Show entries within range"
default: break
}
@IBAction private func didChangeFilterBy(_ sender: UISegmentedControl) {
let firstSelected = (sender.selectedSegmentIndex == 0)
durationTitle.isHidden = !firstSelected
durationView.isHidden = !firstSelected
rangeTitle.isHidden = firstSelected
rangeView.isHidden = firstSelected
}
@IBAction private func durationSliderChanged(_ sender: UISlider) {
@@ -59,58 +62,58 @@ class VCDateFilter: UIViewController, UIGestureRecognizerDelegate {
}
@IBAction private func didTapRangeButton(_ sender: UIButton) {
// TODO: show date picker
let flag = (sender == buttonRangeStart)
let oldDate = flag ? Date(self.tsRangeA) : Date(self.tsRangeB)
DatePickerAlert(initial: oldDate).present(in: self) {
var ts = $1.timestamp
ts -= ts % 60 // remove seconds
// if one of these is greater than the other, adjust the latter too.
if flag || self.tsRangeA > ts {
self.tsRangeA = ts // lower end of minute
self.buttonRangeStart.setTitle(DateFormat.minutes(ts), for: .normal)
}
if !flag || ts > self.tsRangeB {
self.tsRangeB = ts + 59 // upper end of minute
self.buttonRangeEnd.setTitle(DateFormat.minutes(ts + 59), for: .normal)
}
}
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
if gestureRecognizer.view == touch.view {
let newXMin = durationSlider.tag
let newKind: DateFilterKind
if segmentControl.selectedSegmentIndex == 1 {
newKind = .ABRange
} else if newXMin > 0 {
newKind = .LastXMin
} else {
newKind = .Off
}
if Pref.DateFilter.Kind != newKind || Pref.DateFilter.LastXMin != newXMin {
Pref.DateFilter.Kind = newKind
Pref.DateFilter.LastXMin = newXMin
NotifyDateFilterChanged.post()
}
if gestureRecognizer.view === touch.view {
saveSettings()
dismiss(animated: true)
}
return false
}
}
// MARK: White Triangle Popup Arrow
@IBDesignable
class PopupTriangle: UIView {
@IBInspectable var rotation: CGFloat = 0
@IBInspectable var color: UIColor = .black
override func draw(_ rect: CGRect) {
guard let c = UIGraphicsGetCurrentContext() else { return }
let w = rect.width, h = rect.height
switch rotation {
case 90: // right
c.lineFromTo(x1: 0, y1: 0, x2: w, y2: h/2)
c.addLine(to: CGPoint(x: 0, y: h))
case 180: // bottom
c.lineFromTo(x1: w, y1: 0, x2: w/2, y2: h)
c.addLine(to: CGPoint(x: 0, y: 0))
case 270: // left
c.lineFromTo(x1: w, y1: h, x2: 0, y2: h/2)
c.addLine(to: CGPoint(x: w, y: 0))
default: // top
c.lineFromTo(x1: 0, y1: h, x2: w/2, y2: 0)
c.addLine(to: CGPoint(x: w, y: h))
private func saveSettings() {
let newXMin = durationSlider.tag
let filterType: DateFilterKind
let orderType: DateFilterOrderBy
switch filterBy.selectedSegmentIndex {
case 0: filterType = (newXMin > 0) ? .LastXMin : .Off
case 1: filterType = .ABRange
default: preconditionFailure()
}
switch orderbyType.selectedSegmentIndex {
case 0: orderType = .Date
case 1: orderType = .Name
case 2: orderType = .Count
default: preconditionFailure()
}
let a = Prefs.DateFilter.OrderBy <-? orderType
let b = Prefs.DateFilter.OrderAsc <-? (orderbyAsc.selectedSegmentIndex == 0)
if a || b {
NotifySortOrderChanged.post()
}
let c = Prefs.DateFilter.Kind <-? filterType
let d = Prefs.DateFilter.LastXMin <-? newXMin
let e = Prefs.DateFilter.RangeA <-? (filterType == .ABRange ? tsRangeA : nil)
let f = Prefs.DateFilter.RangeB <-? (filterType == .ABRange ? tsRangeB : nil)
if c || d || e || f {
NotifyDateFilterChanged.post()
}
c.closePath()
c.setFillColor(color.cgColor)
c.fillPath()
}
}

View File

@@ -0,0 +1,97 @@
import UIKit
import AudioToolbox
protocol NotificationSoundChangedDelegate {
/// Use `#mute` to disable sounds and `#default` to use default notification sound.
func notificationSoundCurrent() -> String
/// Called every time the user changes selection
func notificationSoundChanged(filename: String, title: String)
}
class TVCChooseAlertTone: UITableViewController {
var delegate: NotificationSoundChangedDelegate!
private lazy var selected: String = delegate.notificationSoundCurrent()
private func playTone(_ name: String) {
switch name {
case "#mute": return // No Sound
case "#default": AudioServicesPlayAlertSound(1315) // Default sound
default:
guard let url = Bundle.main.url(forResource: name, withExtension: "caf") else {
preconditionFailure("Something went wrong. Sound file \(name).caf does not exist.")
}
var soundId: SystemSoundID = 0
AudioServicesCreateSystemSoundID(url as CFURL, &soundId)
AudioServicesAddSystemSoundCompletion(soundId, nil, nil, { id, _ -> Void in
AudioServicesDisposeSystemSoundID(id)
}, nil)
AudioServicesPlayAlertSound(soundId)
}
}
// MARK: Table View Delegate
override func numberOfSections(in _: UITableView) -> Int {
AvailableSounds.count
}
override func tableView(_: UITableView, numberOfRowsInSection section: Int) -> Int {
AvailableSounds[section].count
}
override func tableView(_: UITableView, titleForHeaderInSection section: Int) -> String? {
section == 1 ? "AppCheck" : nil
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "SettingsAlertToneCell")!
let src = AvailableSounds[indexPath.section][indexPath.row]
cell.textLabel?.text = src.title
cell.accessoryType = (src.file == selected) ? .checkmark : .none
return cell
}
override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
let src = AvailableSounds[indexPath.section][indexPath.row]
selected = src.file
tableView.reloadData() // re-apply checkmarks
playTone(selected)
delegate.notificationSoundChanged(filename: src.file, title: src.title)
return nil
}
}
// MARK: - Sounds Data Source
// afconvert input.aiff output.caf -d ima4 -f caff -v
fileprivate let AvailableSounds: [[(title: String, file: String)]] = [
[ // System sounds
("None", "#mute"),
("Default", "#default")
], [ // AppCheck sounds
("Clock", "clock"),
("Drum 1", "drum1"),
("Drum 2", "drum2"),
("Plop 1", "plop1"),
("Plop 2", "plop2"),
("Snap 1", "snap1"),
("Snap 2", "snap2"),
("Typewriter 1", "typewriter1"),
("Typewriter 2", "typewriter2"),
("Wood 1", "wood1"),
("Wood 2", "wood2")
]
]
func AlertSoundTitle(for filename: String) -> String {
for section in AvailableSounds {
for row in section {
if row.file == filename {
return row.title
}
}
}
return ""
}

View File

@@ -0,0 +1,135 @@
import UIKit
class TVCConnectionAlerts: UITableViewController {
@IBOutlet var showNotifications: UISwitch!
@IBOutlet var cellSound: UITableViewCell!
@IBOutlet var listsCustomA: UITableViewCell!
@IBOutlet var listsCustomB: UITableViewCell!
override func viewDidLoad() {
super.viewDidLoad()
cascadeEnableConnAlert(PrefsShared.ConnectionAlerts.Enabled)
cellSound.detailTextLabel?.text = AlertSoundTitle(for: PrefsShared.ConnectionAlerts.Sound)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
let (_, _, custA, custB) = DomainFilter.counts()
listsCustomA.detailTextLabel?.text = "\(custA) Domains"
listsCustomB.detailTextLabel?.text = "\(custB) Domains"
}
private func cascadeEnableConnAlert(_ flag: Bool) {
showNotifications.isOn = flag
// en/disable related controls
}
private func getListSelected(_ index: Int) -> Bool {
switch index {
case 0: return PrefsShared.ConnectionAlerts.Lists.Blocked
case 1: return PrefsShared.ConnectionAlerts.Lists.CustomA
case 2: return PrefsShared.ConnectionAlerts.Lists.CustomB
case 3: return PrefsShared.ConnectionAlerts.Lists.Else
default: preconditionFailure()
}
}
private func setListSelected(_ index: Int, _ value: Bool) {
switch index {
case 0: PrefsShared.ConnectionAlerts.Lists.Blocked = value
case 1: PrefsShared.ConnectionAlerts.Lists.CustomA = value
case 2: PrefsShared.ConnectionAlerts.Lists.CustomB = value
case 3: PrefsShared.ConnectionAlerts.Lists.Else = value
default: preconditionFailure()
}
}
// MARK: - Toggles
@IBAction private func toggleShowNotifications(_ sender: UISwitch) {
PrefsShared.ConnectionAlerts.Enabled = sender.isOn
cascadeEnableConnAlert(sender.isOn)
GlassVPN.send(.notificationSettingsChanged())
if sender.isOn {
PushNotification.requestAuthorization { granted in
if !granted {
NotificationsDisabledAlert(presentIn: self)
self.cascadeEnableConnAlert(false)
}
}
} else {
PushNotification.cancel(.AllConnectionAlertNotifications)
}
}
// MARK: - Table View Delegate
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = super.tableView(tableView, cellForRowAt: indexPath)
let checked: Bool
switch indexPath.section {
case 1: // mode selection
checked = (indexPath.row == (PrefsShared.ConnectionAlerts.ExcludeMode ? 1 : 0))
case 2: // include & exclude lists
checked = getListSelected(indexPath.row)
default: return cell // process only checkmarked cells
}
cell.accessoryType = checked ? .checkmark : .none
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
switch indexPath.section {
case 1: // mode selection
PrefsShared.ConnectionAlerts.ExcludeMode = indexPath.row == 1
tableView.reloadSections(.init(integer: 2), with: .none)
case 2: // include & exclude lists
let prev = tableView.cellForRow(at: indexPath)?.accessoryType == .checkmark
setListSelected(indexPath.row, !prev)
default: return // process only checkmarked cells
}
tableView.deselectRow(at: indexPath, animated: true)
tableView.reloadSections(.init(integer: indexPath.section), with: .none)
GlassVPN.send(.notificationSettingsChanged())
}
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
if section == 2 {
return PrefsShared.ConnectionAlerts.ExcludeMode ? "Exclude All" : "Include All"
}
return super.tableView(tableView, titleForHeaderInSection: section)
}
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if let dest = segue.destination as? TVCFilter {
switch segue.identifier {
case "segueFilterListCustomA":
dest.navigationItem.title = "Custom List A"
dest.currentFilter = .customA
case "segueFilterListCustomB":
dest.navigationItem.title = "Custom List B"
dest.currentFilter = .customB
default:
break
}
} else if let tvc = segue.destination as? TVCChooseAlertTone {
tvc.delegate = self
}
}
}
// MARK: - Sound Selection
extension TVCConnectionAlerts: NotificationSoundChangedDelegate {
func notificationSoundCurrent() -> String {
PrefsShared.ConnectionAlerts.Sound
}
func notificationSoundChanged(filename: String, title: String) {
cellSound.detailTextLabel?.text = title
PrefsShared.ConnectionAlerts.Sound = filename
GlassVPN.send(.notificationSettingsChanged())
}
}

View File

@@ -2,23 +2,16 @@ import UIKit
class TVCFilter: UITableViewController, EditActionsRemove {
var currentFilter: FilterOptions = .none // set by segue
private var dataSource: [String] = []
private lazy var dataSource = DomainFilter.list(where: currentFilter)
override func viewDidLoad() {
super.viewDidLoad()
NotifyDNSFilterChanged.observe(call: #selector(didChangeDomainFilter), on: self)
reloadDataSource()
}
func reloadDataSource() {
dataSource = DomainFilter.list(where: currentFilter)
tableView.reloadData()
}
@objc func didChangeDomainFilter(_ notification: Notification) {
guard let domain = notification.object as? String else {
reloadDataSource()
return
preconditionFailure("Domain independent filter reset not implemented")
}
if DomainFilter[domain]?.contains(currentFilter) ?? false {
let i = dataSource.binTreeIndex(of: domain, compare: (<))!
@@ -32,14 +25,10 @@ class TVCFilter: UITableViewController, EditActionsRemove {
}
@IBAction private func addNewFilter() {
let desc: String
switch currentFilter {
case .blocked: desc = "Enter the domain name you wish to block."
case .ignored: desc = "Enter the domain name you wish to ignore."
default: return
}
let alert = AskAlert(title: "Create new filter", text: desc, buttonText: "Add") {
guard let dom = $0.textFields?.first?.text else {
let alert = AskAlert(title: "Create new filter",
text: "Enter the domain name you wish to add.",
buttonText: "Add") {
guard let dom = $0.textFields?.first?.text?.lowercased() else {
return
}
guard dom.contains("."), !dom.isKnownSLD() else {
@@ -71,7 +60,7 @@ class TVCFilter: UITableViewController, EditActionsRemove {
// MARK: - Editing
override func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
getRowActionsIOS9(indexPath)
getRowActionsIOS9(indexPath, tableView)
}
@available(iOS 11.0, *)
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {

View File

@@ -0,0 +1,154 @@
import UIKit
class TVCReminderAlerts: UITableViewController {
@IBOutlet var restartAllow: UISwitch!
@IBOutlet var restartAllowNotify: UISwitch!
@IBOutlet var restartAllowBadge: UISwitch!
@IBOutlet var restartSound: UITableViewCell!
@IBOutlet var recordingAllow: UISwitch!
@IBOutlet var recordingSound: UITableViewCell!
private enum ReminderCellType { case Restart, Recording }
private var selectedSound: ReminderCellType = .Restart
override func viewDidLoad() {
super.viewDidLoad()
restartAllowNotify.isOn = PrefsShared.RestartReminder.WithText
restartAllowBadge.isOn = PrefsShared.RestartReminder.WithBadge
restartSound.detailTextLabel?.text = AlertSoundTitle(for: PrefsShared.RestartReminder.Sound)
recordingSound.detailTextLabel?.text = AlertSoundTitle(for: Prefs.RecordingReminder.Sound)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
readNotificationState { (allowStart, allowRecord, isProvisional) in
self.cascadeEnableRestart(allowStart && !isProvisional)
self.recordingAllow.isOn = (allowRecord && !isProvisional)
self.setIndicateProvisional(isProvisional)
}
}
private func readNotificationState(_ closure: @escaping (Bool, Bool, Bool) -> Void) {
let en1 = PrefsShared.RestartReminder.Enabled
let en2 = Prefs.RecordingReminder.Enabled
closure(en1, en2, false)
guard en1 || en2 else { return }
PushNotification.allowed { state in
switch state {
case .NotDetermined, .Denied: closure(false, false, false)
case .Authorized, .Provisional: closure(en1, en2, state == .Provisional)
}
}
}
private func cascadeEnableRestart(_ flag: Bool) {
restartAllow.isOn = flag
restartAllowNotify.isEnabled = flag
restartAllowBadge.isEnabled = flag
}
private func setIndicateProvisional(_ flag: Bool) {
if flag {
restartAllow.thumbTintColor = .systemGreen
recordingAllow.thumbTintColor = .systemGreen
} else {
// thumb tint is only set in provisional mode
if restartAllow.thumbTintColor <-? nil { restartAllow.isOn = true }
if recordingAllow.thumbTintColor <-? nil { recordingAllow.isOn = true }
}
}
private func updateBadge() {
let flag = (restartAllow.isOn && restartAllowBadge.isOn && GlassVPN.state != .on)
UIApplication.shared.applicationIconBadgeNumber = flag ? 1 : 0
}
}
// MARK: - Toggles
extension TVCReminderAlerts {
@IBAction private func toggleAllowRestartReminder(_ sender: UISwitch) {
PrefsShared.RestartReminder.Enabled = sender.isOn
cascadeEnableRestart(sender.isOn)
updateBadge()
if sender.isOn {
askAuthorization {}
} else {
PushNotification.cancel(.CantStopMeNowReminder)
}
}
@IBAction private func toggleAllowRestartNotify(_ sender: UISwitch) {
PrefsShared.RestartReminder.WithText = sender.isOn
if !sender.isOn {
PushNotification.cancel(.CantStopMeNowReminder)
}
}
@IBAction private func toggleAllowRestartBadge(_ sender: UISwitch) {
PrefsShared.RestartReminder.WithBadge = sender.isOn
updateBadge()
}
@IBAction private func toggleAllowRecordingReminder(_ sender: UISwitch) {
Prefs.RecordingReminder.Enabled = sender.isOn
if sender.isOn {
askAuthorization { PushNotification.scheduleRecordingReminder(force: false) }
} else {
PushNotification.cancel(.YouShallRecordMoreReminder)
}
}
private func askAuthorization(_ closure: @escaping () -> Void) {
setIndicateProvisional(false)
PushNotification.requestAuthorization { granted in
if granted {
closure()
} else {
NotificationsDisabledAlert(presentIn: self)
self.cascadeEnableRestart(false)
self.recordingAllow.isOn = false
}
}
}
}
// MARK: - Sound Selection
extension TVCReminderAlerts: NotificationSoundChangedDelegate {
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if let tvc = segue.destination as? TVCChooseAlertTone {
switch segue.identifier {
case "segueSoundRestartReminder": selectedSound = .Restart
case "segueSoundRecordingReminder": selectedSound = .Recording
default: preconditionFailure()
}
tvc.delegate = self
}
}
func notificationSoundCurrent() -> String {
switch selectedSound {
case .Restart: return PrefsShared.RestartReminder.Sound
case .Recording: return Prefs.RecordingReminder.Sound
}
}
func notificationSoundChanged(filename: String, title: String) {
switch selectedSound {
case .Restart:
restartSound.detailTextLabel?.text = title
PrefsShared.RestartReminder.Sound = filename
case .Recording:
recordingSound.detailTextLabel?.text = title
Prefs.RecordingReminder.Sound = filename
if Prefs.RecordingReminder.Enabled {
PushNotification.scheduleRecordingReminder(force: true)
}
}
}
}

View File

@@ -2,62 +2,69 @@ import UIKit
class TVCSettings: UITableViewController {
private let appDelegate = UIApplication.shared.delegate as! AppDelegate
@IBOutlet var vpnToggle: UISwitch!
@IBOutlet var cellDomainsIgnored: UITableViewCell!
@IBOutlet var cellDomainsBlocked: UITableViewCell!
@IBOutlet var cellPrivacyAutoDelete: UITableViewCell!
@IBOutlet var cellNotificationReminder: UITableViewCell!
@IBOutlet var cellNotificationConnectionAlert: UITableViewCell!
override func viewDidLoad() {
super.viewDidLoad()
NotifyVPNStateChanged.observe(call: #selector(vpnStateChanged(_:)), on: self)
changedState(currentVPNState)
NotifyDNSFilterChanged.observe(call: #selector(reloadDataSource), on: self)
reloadDataSource()
reloadVPNState()
reloadLoggingFilterUI()
reloadPrivacyUI()
NotifyVPNStateChanged.observe(call: #selector(reloadVPNState), on: self)
NotifyDNSFilterChanged.observe(call: #selector(reloadLoggingFilterUI), on: self)
}
@objc func reloadDataSource() {
let (blocked, ignored) = DomainFilter.counts()
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
reloadNotificationState()
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
// FIXME: there is a lag between tap and open when run on device
if let cell = tableView.cellForRow(at: indexPath), cell === cellPrivacyAutoDelete {
openAutoDeletePicker()
}
}
func openRestartVPNSettings() { scrollToSection(0, animated: false) }
func openNotificationSettings() { scrollToSection(2, animated: false) }
private func scrollToSection(_ section: Int, animated: Bool) {
tableView.scrollToRow(at: .init(row: 0, section: section), at: .top, animated: animated)
}
}
// MARK: - VPN Proxy Settings
extension TVCSettings {
@objc private func reloadVPNState() {
vpnToggle.isOn = (GlassVPN.state != .off)
vpnToggle.onTintColor = (GlassVPN.state == .inbetween ? .systemYellow : nil)
UIApplication.shared.applicationIconBadgeNumber =
!vpnToggle.isOn &&
PrefsShared.RestartReminder.Enabled &&
PrefsShared.RestartReminder.WithBadge ? 1 : 0
}
@IBAction private func toggleVPNProxy(_ sender: UISwitch) {
GlassVPN.setEnabled(sender.isOn)
}
}
// MARK: - Logging Filter
extension TVCSettings {
@objc private func reloadLoggingFilterUI() {
let (blocked, ignored, _, _) = DomainFilter.counts()
cellDomainsIgnored.detailTextLabel?.text = "\(ignored) Domains"
cellDomainsBlocked.detailTextLabel?.text = "\(blocked) Domains"
}
@IBAction func toggleVPNProxy(_ sender: UISwitch) {
appDelegate.setProxyEnabled(sender.isOn)
}
@IBAction func exportDB(_ sender: Any) {
let sheet = UIActivityViewController(activityItems: [URL.internalDB()], applicationActivities: nil)
self.present(sheet, animated: true)
}
@IBAction func resetTutorialAlerts(_ sender: UIButton) {
Pref.DidShowTutorial.Welcome = false
Pref.DidShowTutorial.Recordings = false
Alert(title: sender.titleLabel?.text,
text: "\nDone.\n\nYou may need to restart the application.").presentIn(self)
}
@IBAction func clearDatabaseResults(_ sender: Any) {
AskAlert(title: "Clear results?", text:
"You are about to delete all results that have been logged in the past. " +
"Your preferences for blocked and ignored domains are preserved.\n" +
"Continue?", buttonText: "Delete", buttonStyle: .destructive) { _ in
DispatchQueue.global().async {
try? AppDB?.dnsLogsDeleteAll()
NotifyLogHistoryReset.postAsyncMain()
}
}.presentIn(self)
}
@objc func vpnStateChanged(_ notification: Notification) {
changedState(notification.object as! VPNState)
}
func changedState(_ newState: VPNState) {
vpnToggle.isOn = (newState != .off)
vpnToggle.onTintColor = (newState == .inbetween ? .systemYellow : nil)
}
override func tableView(_ tableView: UITableView, accessoryButtonTappedForRowWith indexPath: IndexPath) {
let t:String, d: String
switch tableView.cellForRow(at: indexPath)?.reuseIdentifier {
@@ -86,3 +93,115 @@ class TVCSettings: UITableViewController {
}
}
}
// MARK: - Privacy
extension TVCSettings {
private func reloadPrivacyUI() {
let (num, unit) = getAutoDeleteSelection([1, 7, 31])
let str: String
switch num {
case 0: str = "Never"
case 1: str = "1 \(["Day", "Week", "Month"][unit])"
default: str = "\(num) \(["Days", "Weeks", "Months"][unit])"
}
cellPrivacyAutoDelete.detailTextLabel?.text = str
}
private func getAutoDeleteSelection(_ multiplier: [Int]) -> (Int, Int) {
let current = PrefsShared.AutoDeleteLogsDays
let snd = multiplier.lastIndex { current % $0 == 0 }! // make sure 1 is in list
return (current / multiplier[snd], snd)
}
private func openAutoDeletePicker() {
let multiplier = [1, 7, 31]
let (one, two) = getAutoDeleteSelection(multiplier)
let picker = DurationPickerAlert(
title: "Auto-delete logs",
detail: "Warning: Logs older than the selected interval are deleted immediately! " +
"Logs are also deleted on each app launch, and periodically in the background as long as the VPN is running.",
options: [(0...30).map{"\($0)"}, ["Days", "Weeks", "Months"]],
widths: [0.4, 0.6])
picker.pickerView.setSelection([min(30, one), two])
picker.present(in: self) { _, idx in
let asDays = idx[0] * multiplier[idx[1]]
PrefsShared.AutoDeleteLogsDays = asDays
self.reloadPrivacyUI()
if !GlassVPN.send(.autoDelete(after: asDays)) {
// if VPN isn't active, fallback to immediate local delete
TheGreatDestroyer.deleteLogs(olderThan: asDays)
}
}
}
}
// MARK: - Notification Settings
extension TVCSettings {
private func reloadNotificationState() {
let lbl1 = cellNotificationReminder.detailTextLabel
let lbl2 = cellNotificationConnectionAlert.detailTextLabel
readNotificationState { (realAllowed, provisional) in
lbl1?.text = provisional ? "Enabled" : "Disabled"
lbl2?.text = realAllowed ? "Enabled" : "Disabled"
}
}
private func readNotificationState(_ closure: @escaping (_ all: Bool, _ prov: Bool) -> Void) {
let en1 = PrefsShared.ConnectionAlerts.Enabled
let en2 = Prefs.RecordingReminder.Enabled || PrefsShared.RestartReminder.Enabled
closure(en1, en2)
guard en1 || en2 else { return }
PushNotification.allowed { state in
switch state {
case .NotDetermined, .Denied: closure(false, false)
case .Authorized: closure(en1, en2)
case .Provisional: closure(false, en2)
}
}
}
}
// MARK: - Reset Settings
extension TVCSettings {
@IBAction private func resetTutorialAlerts(_ sender: UIButton) {
Prefs.DidShowTutorial.Welcome = false
Prefs.DidShowTutorial.Recordings = false
Alert(title: sender.titleLabel?.text,
text: "\nDone.\n\nYou may need to restart the application.").presentIn(self)
}
@IBAction private func clearDatabaseResults() {
AskAlert(title: "Clear results?", text:
"You are about to delete all results that have been logged in the past. " +
"Your preferences for blocked and ignored domains are preserved.\n" +
"Continue?", buttonText: "Delete", buttonStyle: .destructive) { _ in
TheGreatDestroyer.deleteAllLogs()
}.presentIn(self)
}
}
// MARK: - Advanced
extension TVCSettings {
@IBAction private func exportDB() {
AppDB?.vacuum()
let sheet = UIActivityViewController(activityItems: [URL.internalDB()], applicationActivities: nil)
self.present(sheet, animated: true)
}
override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
if section == tableView.numberOfSections - 1 {
let fs = FileManager.default.readableSizeOf(path: URL.internalDB().relativePath)
return "Database size: \(fs ?? "0 MB")"
}
return nil
}
}

View File

@@ -5,15 +5,34 @@ class TBCMain: UITabBarController {
override func viewDidLoad() {
super.viewDidLoad()
NotifyVPNStateChanged.observe(call: #selector(vpnStateChanged(_:)), on: self)
changedState(currentVPNState)
reloadTabBarBadge()
NotifyVPNStateChanged.observe(call: #selector(reloadTabBarBadge), on: self)
if !Pref.DidShowTutorial.Welcome {
if !Prefs.DidShowTutorial.Welcome {
self.perform(#selector(showWelcomeMessage), with: nil, afterDelay: 0.5)
}
if #available(iOS 10.0, *) {
initNotifications()
}
}
@objc func showWelcomeMessage() {
@objc private func reloadTabBarBadge() {
let stateView = self.tabBar.items?.last
switch GlassVPN.state {
case .on: stateView?.badgeValue = ""
case .inbetween: stateView?.badgeValue = ""
case .off: stateView?.badgeValue = ""
}
if #available(iOS 10.0, *) {
switch GlassVPN.state {
case .on: stateView?.badgeColor = .systemGreen
case .inbetween: stateView?.badgeColor = .systemYellow
case .off: stateView?.badgeColor = .systemRed
}
}
}
@objc private func showWelcomeMessage() {
let x = TutorialSheet()
x.addSheet().addArrangedSubview(QuickUI.text(attributed: NSMutableAttributedString()
.h1("Welcome\n")
@@ -37,27 +56,83 @@ class TBCMain: UITabBarController {
)
))
x.present {
Pref.DidShowTutorial.Welcome = true
}
}
@objc func vpnStateChanged(_ notification: Notification) {
changedState(notification.object as! VPNState)
}
func changedState(_ newState: VPNState) {
let stateView = self.tabBar.items?.last
switch newState {
case .on: stateView?.badgeValue = ""
case .inbetween: stateView?.badgeValue = ""
case .off: stateView?.badgeValue = ""
}
if #available(iOS 10.0, *) {
switch newState {
case .on: stateView?.badgeColor = .systemGreen
case .inbetween: stateView?.badgeColor = .systemYellow
case .off: stateView?.badgeColor = .systemRed
}
Prefs.DidShowTutorial.Welcome = true
}
}
}
extension TBCMain {
@discardableResult func openTab(_ index: Int) -> UIViewController? {
selectedIndex = index
guard let nav = selectedViewController as? UINavigationController else {
return selectedViewController
}
nav.popToRootViewController(animated: false)
return nav.topViewController
}
}
// MARK: - Push Notifications
@available(iOS 10.0, *)
extension TBCMain: UNUserNotificationCenterDelegate {
func initNotifications() {
UNUserNotificationCenter.current().delegate = self
guard Prefs.RecordingReminder.Enabled else {
return
}
PushNotification.allowed {
switch $0 {
case .NotDetermined:
PushNotification.requestProvisionalOrDoNothing { success in
guard success else { return }
PushNotification.scheduleRecordingReminder(force: false)
}
case .Denied:
break
case .Authorized, .Provisional:
PushNotification.scheduleRecordingReminder(force: false)
}
}
}
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
completionHandler([.alert, .badge, .sound]) // in-app notifications
}
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
defer { completionHandler() }
if isFrontmostModal() {
return // dont intervene user actions
}
switch response.notification.request.identifier {
case PushNotification.Identifier.YouShallRecordMoreReminder.rawValue:
selectedIndex = 1 // open recordings tab
case PushNotification.Identifier.CantStopMeNowReminder.rawValue:
(openTab(2) as! TVCSettings).openRestartVPNSettings()
//case PushNotification.Identifier.RestInPeaceTombstoneReminder // only badge
case let x: // domain notification
(openTab(0) as! TVCDomains).pushOpen(domain: x) // open requests tab
}
}
@available(iOS 12.0, *)
func userNotificationCenter(_ center: UNUserNotificationCenter, openSettingsFor notification: UNNotification?) {
(openTab(2) as! TVCSettings).openNotificationSettings()
}
func isFrontmostModal() -> Bool {
var x = selectedViewController!
while let tmp = x.presentedViewController {
x = tmp
}
if x is UIAlertController {
return true
} else if #available(iOS 13.0, *) {
return x.isModalInPresentation
} else {
return x.modalPresentationStyle == .custom
}
}
}

BIN
media/sounds/clock.caf Normal file

Binary file not shown.

BIN
media/sounds/drum1.caf Normal file

Binary file not shown.

BIN
media/sounds/drum2.caf Normal file

Binary file not shown.

BIN
media/sounds/plop1.caf Normal file

Binary file not shown.

BIN
media/sounds/plop2.caf Normal file

Binary file not shown.

BIN
media/sounds/snap1.caf Normal file

Binary file not shown.

BIN
media/sounds/snap2.caf Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
media/sounds/wood1.caf Normal file

Binary file not shown.

BIN
media/sounds/wood2.caf Normal file

Binary file not shown.