Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9ab545e0f | ||
|
|
b10d4c8b36 | ||
|
|
5a3ca024f8 | ||
|
|
92216c0c03 | ||
|
|
9ece3474c6 | ||
|
|
6dcc2086e6 | ||
|
|
08483711e2 | ||
|
|
0e100006d3 | ||
|
|
710c617862 | ||
|
|
3ed25c92cd | ||
|
|
f7644e6048 | ||
|
|
80afa6aff1 | ||
|
|
43de81929f | ||
|
|
e315e71d07 | ||
|
|
416eb34799 | ||
|
|
b7b13f51b2 | ||
|
|
2312187670 | ||
|
|
c7d0dc7c5f | ||
|
|
895cabee80 | ||
|
|
d96ced48c9 | ||
|
|
0b6dbfd888 | ||
|
|
96656438c6 | ||
|
|
4b32df5683 | ||
|
|
0758bd7dec | ||
|
|
171dabd83a | ||
|
|
6182a99ebd | ||
|
|
8bfedda3ab | ||
|
|
26f6ea1a9a | ||
|
|
778f377e42 | ||
|
|
f284365469 | ||
|
|
5dfb7d4ba4 | ||
|
|
bb9c3a3034 | ||
|
|
8cf872a4b0 | ||
|
|
e813230824 | ||
|
|
e8bfde9243 | ||
|
|
e947ad6d4d | ||
|
|
0a53898797 | ||
|
|
946acc2460 | ||
|
|
e13b3df2c4 | ||
|
|
7df2fe421e | ||
|
|
b4b89f8bb4 | ||
|
|
db41e68f35 | ||
|
|
5acd9bbcc6 | ||
|
|
23eab2310f |
@@ -7,6 +7,8 @@
|
||||
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 */; };
|
||||
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 */; };
|
||||
@@ -16,13 +18,17 @@
|
||||
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 */; };
|
||||
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 */; };
|
||||
544C95262407B1C700AB89D0 /* SharedState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 544C95252407B1C700AB89D0 /* SharedState.swift */; };
|
||||
54448A3224899A4000771C96 /* SearchBarManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54448A3124899A4000771C96 /* SearchBarManager.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 */; };
|
||||
@@ -35,10 +41,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 */; };
|
||||
@@ -134,6 +141,13 @@
|
||||
54E540F4247D3F2600F7C34A /* TestDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54E540F3247D3F2600F7C34A /* TestDataSource.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 */
|
||||
@@ -161,6 +175,7 @@
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
5404AEEC24A95F3F003B2F54 /* SlideInAnimation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlideInAnimation.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>"; };
|
||||
@@ -172,6 +187,10 @@
|
||||
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>"; };
|
||||
543CDB1D23EEE61900B7F323 /* GlassVPN.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = GlassVPN.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
@@ -180,7 +199,8 @@
|
||||
543CDB2223EEE61900B7F323 /* GlassVPN.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GlassVPN.entitlements; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
544C95252407B1C700AB89D0 /* SharedState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedState.swift; sourceTree = "<group>"; };
|
||||
54448A3124899A4000771C96 /* SearchBarManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBarManager.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>"; };
|
||||
@@ -191,10 +211,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>"; };
|
||||
@@ -293,6 +314,12 @@
|
||||
54E540F3247D3F2600F7C34A /* TestDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestDataSource.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 */
|
||||
@@ -320,6 +347,7 @@
|
||||
5412F8ED24571B8100A63D7A /* VCDateFilter.swift */,
|
||||
54953E6023E0D69A0054345C /* TVCHosts.swift */,
|
||||
54953E6E23E44CD00054345C /* TVCHostDetails.swift */,
|
||||
541FC47424A12CE9009154D8 /* Analytics */,
|
||||
);
|
||||
path = Requests;
|
||||
sourceTree = "<group>";
|
||||
@@ -372,6 +400,7 @@
|
||||
545DDDD224436A03003B6544 /* Common Classes */,
|
||||
548B1F9423D338EC005B047C /* main.entitlements */,
|
||||
541AC5D72399498A00A769D7 /* AppDelegate.swift */,
|
||||
54E67E4A24A8C6370025D261 /* GlassVPN.swift */,
|
||||
542E2A972404973F001462DC /* TBCMain.swift */,
|
||||
540C6454240D5BAE00E948F9 /* Requests */,
|
||||
540E677E242D2CD200871BBE /* Recordings */,
|
||||
@@ -386,6 +415,15 @@
|
||||
path = main;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
541FC47424A12CE9009154D8 /* Analytics */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
541FC47724A1453F009154D8 /* VCCoOccurrence.swift */,
|
||||
544F911F24A67EC5001D4B00 /* TVCOccurrenceContext.swift */,
|
||||
);
|
||||
path = Analytics;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
542E2A9B24051F79001462DC /* media */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -412,9 +450,15 @@
|
||||
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 */,
|
||||
);
|
||||
path = "Common Classes";
|
||||
sourceTree = "<group>";
|
||||
@@ -426,6 +470,7 @@
|
||||
54D8B97D2471B88900EB2414 /* DBCommon.swift */,
|
||||
54D8B9822471BD8100EB2414 /* DBAppOnly.swift */,
|
||||
54B345AC241BBB00004C53CC /* DBExtensions.swift */,
|
||||
541FC9862497D81C00962623 /* TheGreatDestroyer.swift */,
|
||||
);
|
||||
path = DB;
|
||||
sourceTree = "<group>";
|
||||
@@ -433,12 +478,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 */,
|
||||
@@ -808,42 +856,55 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
54E67E4924A8B1280025D261 /* Prefs.swift in Sources */,
|
||||
54E540F8247DB90F00F7C34A /* RecordingsDB.swift in Sources */,
|
||||
54E67E4F24A8E2910025D261 /* Equatable.swift in Sources */,
|
||||
54E540F4247D3F2600F7C34A /* TestDataSource.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 */,
|
||||
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 */,
|
||||
54953E3323DC752E0054345C /* DBCore.swift in Sources */,
|
||||
54E67E4624A8B0FE0025D261 /* PrefsShared.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 */,
|
||||
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 */,
|
||||
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 */,
|
||||
54E540FA2482414800F7C34A /* SyncUpdate.swift in Sources */,
|
||||
5404AEED24A95F3F003B2F54 /* SlideInAnimation.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -861,6 +922,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 */,
|
||||
@@ -1095,7 +1157,7 @@
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = main/main.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 17;
|
||||
CURRENT_PROJECT_VERSION = 23;
|
||||
INFOPLIST_FILE = main/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
@@ -1114,7 +1176,7 @@
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = main/main.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 17;
|
||||
CURRENT_PROJECT_VERSION = 23;
|
||||
INFOPLIST_FILE = main/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
@@ -1133,7 +1195,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = GlassVPN/GlassVPN.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 17;
|
||||
CURRENT_PROJECT_VERSION = 23;
|
||||
INFOPLIST_FILE = GlassVPN/Info.plist;
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "de.uni-bamberg.psi.AppCheck.VPN";
|
||||
@@ -1151,7 +1213,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = GlassVPN/GlassVPN.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 17;
|
||||
CURRENT_PROJECT_VERSION = 23;
|
||||
INFOPLIST_FILE = GlassVPN/Info.plist;
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "de.uni-bamberg.psi.AppCheck.VPN";
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import NetworkExtension
|
||||
|
||||
fileprivate var db: SQLiteDatabase!
|
||||
fileprivate var pStmt: OpaquePointer!
|
||||
fileprivate var filterDomains: [String]!
|
||||
fileprivate var filterOptions: [(block: Bool, ignore: Bool)]!
|
||||
|
||||
@@ -9,7 +7,7 @@ fileprivate var filterOptions: [(block: Bool, ignore: Bool)]!
|
||||
// MARK: Backward DNS Binary Tree Lookup
|
||||
|
||||
fileprivate func reloadDomainFilter() {
|
||||
let tmp = db.loadFilters()?.map({
|
||||
let tmp = AppDB?.loadFilters()?.map({
|
||||
(String($0.reversed()), $1)
|
||||
}).sorted(by: { $0.0 < $1.0 }) ?? []
|
||||
filterDomains = tmp.map { $0.0 }
|
||||
@@ -35,6 +33,18 @@ fileprivate func filterIndex(for domain: String) -> Int {
|
||||
return -1
|
||||
}
|
||||
|
||||
private let queue = DispatchQueue.init(label: "PSIGlassDNSQueue", qos: .userInteractive, target: .main)
|
||||
|
||||
private func logAsync(_ domain: String, blocked: Bool) {
|
||||
queue.async {
|
||||
do {
|
||||
try AppDB?.logWrite(domain, blocked: blocked)
|
||||
} catch {
|
||||
DDLogWarn("Couldn't write: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: ObserverFactory
|
||||
|
||||
@@ -52,11 +62,11 @@ class LDObserverFactory: ObserverFactory {
|
||||
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 !ignore { logAsync(session.host, blocked: block) }
|
||||
if block { socket.forceDisconnect() }
|
||||
} else {
|
||||
// TODO: disable filter during recordings
|
||||
try? db.logWrite(pStmt, session.host)
|
||||
logAsync(session.host, blocked: false)
|
||||
}
|
||||
default:
|
||||
break
|
||||
@@ -70,20 +80,26 @@ 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!
|
||||
|
||||
private var autoDeleteTimer: Timer? = nil
|
||||
|
||||
private func reloadSettings() {
|
||||
reloadDomainFilter()
|
||||
setAutoDelete(PrefsShared.AutoDeleteLogsDays)
|
||||
}
|
||||
|
||||
override func startTunnel(options: [String : NSObject]?, completionHandler: @escaping (Error?) -> Void) {
|
||||
DDLogVerbose("startTunnel with with options: \(String(describing: options))")
|
||||
do {
|
||||
db = try SQLiteDatabase.open()
|
||||
db.initCommonScheme()
|
||||
pStmt = try db.logWritePrepare()
|
||||
try SQLiteDatabase.open().initCommonScheme()
|
||||
} catch {
|
||||
completionHandler(error)
|
||||
return
|
||||
}
|
||||
reloadDomainFilter()
|
||||
reloadSettings()
|
||||
|
||||
if proxyServer != nil {
|
||||
proxyServer.stop()
|
||||
@@ -135,17 +151,65 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||
ObserverFactory.currentFactory = nil
|
||||
proxyServer.stop()
|
||||
proxyServer = nil
|
||||
db.prepared(finalize: pStmt)
|
||||
pStmt = nil
|
||||
db = nil
|
||||
filterDomains = nil
|
||||
filterOptions = nil
|
||||
autoDeleteTimer?.fire() // one last time before we quit
|
||||
autoDeleteTimer?.invalidate()
|
||||
completionHandler()
|
||||
exit(EXIT_SUCCESS)
|
||||
}
|
||||
|
||||
override func handleAppMessage(_ messageData: Data, completionHandler: ((Data?) -> Void)?) {
|
||||
reloadDomainFilter()
|
||||
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
|
||||
default: break
|
||||
}
|
||||
}
|
||||
DDLogWarn("This should never happen! Received unknown handleAppMessage: \(message ?? messageData.base64EncodedString())")
|
||||
reloadSettings() // just in case we fallback to do everything
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ################################################################
|
||||
// #
|
||||
// # MARK: - Auto-delete Timer
|
||||
// #
|
||||
// ################################################################
|
||||
|
||||
extension PacketTunnelProvider {
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
@objc private func autoDeleteNow(_ sender: Timer) {
|
||||
DDLogInfo("Auto-delete old logs")
|
||||
queue.async {
|
||||
do {
|
||||
try AppDB?.dnsLogsDeleteOlderThan(days: sender.userInfo as! Int)
|
||||
} catch {
|
||||
DDLogWarn("Couldn't delete logs, will retry in 5 minutes. \(error)")
|
||||
if sender.isValid {
|
||||
sender.fireDate = Date().addingTimeInterval(300) // retry in 5 min
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
@@ -23,119 +19,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
TestDataSource.load()
|
||||
#endif
|
||||
|
||||
loadVPN { mgr in
|
||||
self.managerVPN = mgr
|
||||
self.postVPNState()
|
||||
}
|
||||
NSNotification.Name.NEVPNStatusDidChange.observe(call: #selector(vpnStatusChanged(_:)), on: self)
|
||||
NotifyDNSFilterChanged.observe(call: #selector(didChangeDomainFilter), on: self)
|
||||
|
||||
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
@@ -19,5 +19,8 @@
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,5 +19,8 @@
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
|
||||
26
main/Assets.xcassets/intersection.imageset/Contents.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
main/Assets.xcassets/intersection.imageset/img.png
vendored
Normal file
|
After Width: | Height: | Size: 385 B |
BIN
main/Assets.xcassets/intersection.imageset/img@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 695 B |
BIN
main/Assets.xcassets/intersection.imageset/img@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
26
main/Assets.xcassets/jump-to-target.imageset/Contents.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
main/Assets.xcassets/jump-to-target.imageset/img.png
vendored
Normal file
|
After Width: | Height: | Size: 230 B |
BIN
main/Assets.xcassets/jump-to-target.imageset/img@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 409 B |
BIN
main/Assets.xcassets/jump-to-target.imageset/img@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 544 B |
26
main/Assets.xcassets/line-collapse.imageset/Contents.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
main/Assets.xcassets/line-collapse.imageset/img.png
vendored
Normal file
|
After Width: | Height: | Size: 150 B |
BIN
main/Assets.xcassets/line-collapse.imageset/img@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 216 B |
BIN
main/Assets.xcassets/line-collapse.imageset/img@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 283 B |
26
main/Assets.xcassets/line-expand.imageset/Contents.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
main/Assets.xcassets/line-expand.imageset/img.png
vendored
Normal file
|
After Width: | Height: | Size: 156 B |
BIN
main/Assets.xcassets/line-expand.imageset/img@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 224 B |
BIN
main/Assets.xcassets/line-expand.imageset/img@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 282 B |
@@ -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"/>
|
||||
@@ -352,19 +388,30 @@
|
||||
<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">
|
||||
<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"/>
|
||||
<tabBar key="tableHeaderView" contentMode="scaleToFill" fixedFrame="YES" translucent="NO" translatesAutoresizingMaskIntoConstraints="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="h7Z-Qr-pJ5" id="qNN-nI-Kub"/>
|
||||
</connections>
|
||||
</tabBar>
|
||||
<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"/>
|
||||
@@ -372,7 +419,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"/>
|
||||
@@ -380,6 +427,9 @@
|
||||
</label>
|
||||
</subviews>
|
||||
</tableViewCellContentView>
|
||||
<connections>
|
||||
<segue destination="rjy-Di-Cru" kind="push" id="SfC-iY-Ce0"/>
|
||||
</connections>
|
||||
</tableViewCell>
|
||||
</prototypes>
|
||||
<connections>
|
||||
@@ -388,11 +438,262 @@
|
||||
</connections>
|
||||
</tableView>
|
||||
<navigationItem key="navigationItem" title="Occurrences" prompt="com.domain.network.cdn" id="bys-2u-rHs"/>
|
||||
<connections>
|
||||
<outlet property="actionsBar" destination="1Jy-zg-CXR" id="7x3-Vy-i9C"/>
|
||||
<segue destination="W5Q-oz-bFb" kind="modal" identifier="segueAnalysisCoOccurrence" id="ukY-Dy-AIA"/>
|
||||
</connections>
|
||||
</tableViewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="UxH-PH-KQy" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="2100" y="-1250"/>
|
||||
</scene>
|
||||
<!--Co Occurrence-->
|
||||
<scene sceneID="Gbm-AP-b72">
|
||||
<objects>
|
||||
<viewController 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="548"/>
|
||||
<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="56"/>
|
||||
<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="16" 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="56" width="320" height="492"/>
|
||||
<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>
|
||||
@@ -499,7 +800,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"/>
|
||||
@@ -699,14 +1000,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"/>
|
||||
@@ -723,13 +1024,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>
|
||||
@@ -852,10 +1207,38 @@ Duration: 60:00</string>
|
||||
</tableViewCell>
|
||||
</cells>
|
||||
</tableViewSection>
|
||||
<tableViewSection headerTitle="Privacy" 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="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="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="14" width="114" 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="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"/>
|
||||
<nil key="textColor"/>
|
||||
<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="299.5" width="320" height="44"/>
|
||||
<rect key="frame" x="0.0" y="399.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"/>
|
||||
@@ -877,7 +1260,7 @@ Duration: 60:00</string>
|
||||
</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"/>
|
||||
<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="Xgc-6Z-IlH" id="efR-vn-6MX">
|
||||
<rect key="frame" x="0.0" y="0.0" width="320" height="44"/>
|
||||
@@ -890,7 +1273,7 @@ Duration: 60:00</string>
|
||||
<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"/>
|
||||
<action selector="clearDatabaseResults" destination="qdB-ZO-LHY" eventType="touchUpInside" id="heU-m1-oJq"/>
|
||||
</connections>
|
||||
</button>
|
||||
</subviews>
|
||||
@@ -902,27 +1285,27 @@ Duration: 60:00</string>
|
||||
</tableViewCell>
|
||||
</cells>
|
||||
</tableViewSection>
|
||||
<tableViewSection headerTitle="Advanced" id="wLR-T2-Qxm">
|
||||
<tableViewSection headerTitle="Advanced" id="Vlg-nm-VB3">
|
||||
<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"/>
|
||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="VnR-9B-1zl">
|
||||
<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="Qyy-0U-yhd" id="Mfs-fu-W5k">
|
||||
<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="9Ko-sD-7x0">
|
||||
<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="3gu-WF-3Xa"/>
|
||||
<action selector="exportDB" destination="qdB-ZO-LHY" eventType="touchUpInside" id="FYN-Zz-UK4"/>
|
||||
</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"/>
|
||||
<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>
|
||||
@@ -938,6 +1321,7 @@ Duration: 60:00</string>
|
||||
<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="cellPrivacyAutoDelete" destination="Qyy-0U-yhd" id="PzN-iv-kFl"/>
|
||||
<outlet property="vpnToggle" destination="kmY-ot-lJW" id="yeS-DE-FfR"/>
|
||||
</connections>
|
||||
</tableViewController>
|
||||
@@ -961,7 +1345,7 @@ Duration: 60:00</string>
|
||||
<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">
|
||||
<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"/>
|
||||
@@ -995,7 +1379,10 @@ Duration: 60:00</string>
|
||||
</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>
|
||||
|
||||
237
main/Common Classes/CustomAlert.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:)` 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,30 +42,16 @@ class FilterPipeline<T> {
|
||||
return (i, dataSource[i])
|
||||
}
|
||||
|
||||
/// Search and return list of `dataSource` elements that match the given `predicate`.
|
||||
/// - Returns: Sorted list of indices and objects in `dataSource`.
|
||||
/// - Complexity: O(*m* + *n*), where *n* is the length of the `dataSource` and *m* is the number of matches.
|
||||
// func dataSourceAll(where predicate: ((T) -> Bool)) -> [(index: Int, object: T)] {
|
||||
// dataSource.enumerated().compactMap { predicate($1) ? ($0, $1) : nil }
|
||||
// }
|
||||
|
||||
/// Re-query data source and re-built filter and display sorting order.
|
||||
/// - 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.delegate?.tableView.reloadData()
|
||||
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 yet.
|
||||
fileprivate func lastFilterLayerIndices() -> [Int] {
|
||||
/// Returns the index set of either the last filter layer, or `dataSource` if no filter is set.
|
||||
fileprivate func lastLayerIndices() -> [Int] {
|
||||
pipeline.last?.selection ?? dataSource.indices.arr()
|
||||
}
|
||||
|
||||
@@ -86,65 +65,78 @@ 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.
|
||||
/// - 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)
|
||||
resetFilters(startingAt: i)
|
||||
} else {
|
||||
newFilter.reset(to: dataSource, previous: pipeline.last)
|
||||
newFilter.reset(to: dataSource, previous: lastLayerIndices())
|
||||
pipeline.append(newFilter)
|
||||
display?.apply(moreRestrictive: newFilter)
|
||||
display?.apply(moreRestrictive: newFilter.selection)
|
||||
}
|
||||
delegate?.filterPipelineDidReset()
|
||||
}
|
||||
|
||||
/// Find and remove filter with given identifier. Will automatically update remaining filters and display sorting.
|
||||
/// - Note: Will call `filterPipelineDidReset()`
|
||||
func removeFilter(withId ident: String) {
|
||||
if let i = indexOfFilter(ident) {
|
||||
pipeline.remove(at: i)
|
||||
if i == pipeline.count {
|
||||
// only if we don't reset other layers we can assure `toLessRestrictive`
|
||||
display?.reset(toLessRestrictive: pipeline.last)
|
||||
} else {
|
||||
resetFilters(startingAt: i)
|
||||
}
|
||||
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?.apply(lessRestrictive: lastLayerIndices())
|
||||
} else {
|
||||
resetFilters(startingAt: i)
|
||||
}
|
||||
delegate?.filterPipelineDidReset()
|
||||
}
|
||||
|
||||
/// Start filter evaluation on all entries from previous filter.
|
||||
/// - Note: Will call `filterPipelineDidReset()`
|
||||
func reloadFilter(withId ident: String) {
|
||||
if let i = indexOfFilter(ident) {
|
||||
resetFilters(startingAt: i)
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove last `k` filters from the filter pipeline. Thus showing more entries from previous layers.
|
||||
func popLastFilter(k: Int = 1) {
|
||||
guard k > 0, k <= pipeline.count else { return }
|
||||
pipeline.removeLast(k)
|
||||
display?.reset(toLessRestrictive: pipeline.last)
|
||||
guard let i = indexOfFilter(ident) else { return }
|
||||
resetFilters(startingAt: i)
|
||||
delegate?.filterPipelineDidReset()
|
||||
}
|
||||
|
||||
/// Sets the sort and display order. You should set the `delegate` to automatically update your `tableView`.
|
||||
/// - 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)
|
||||
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.
|
||||
/// - Parameter index: Must be: `index <= pipeline.count`
|
||||
private func resetFilters(startingAt index: Int = 0) {
|
||||
for i in index..<pipeline.count {
|
||||
pipeline[i].reset(to: dataSource, previous: (i>0) ? pipeline[i-1] : nil)
|
||||
pipeline[i].reset(to: dataSource, previous: (i>0)
|
||||
? pipeline[i-1].selection : dataSource.indices.arr())
|
||||
}
|
||||
// Reset is NOT less-restrictive because filters are dynamic
|
||||
// Calling reset on a filter twice may yield different results
|
||||
// E.g. if filter uses variables outside of scope (current time, search term)
|
||||
display?.reset()
|
||||
display?.reset(to: lastLayerIndices())
|
||||
}
|
||||
|
||||
/// Push object through filter pipeline to check whether it survives all filters.
|
||||
@@ -170,21 +162,8 @@ class FilterPipeline<T> {
|
||||
|
||||
// MARK: data updates
|
||||
|
||||
/// Disable individual cell updates (update, move, insert & remove actions)
|
||||
func pauseCellAnimations(if condition: Bool) {
|
||||
cellAnimations = delegate?.tableView.isFrontmost ?? false && !condition
|
||||
}
|
||||
|
||||
/// 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 = false) {
|
||||
if !cellAnimations {
|
||||
cellAnimations = true
|
||||
if reloadTable { 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
|
||||
@@ -194,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`
|
||||
@@ -211,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.
|
||||
@@ -244,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
|
||||
@@ -264,10 +242,9 @@ class PipelineFilter<T> {
|
||||
shouldPersist = predicate
|
||||
}
|
||||
|
||||
/// Reset selection indices by copying the indices from the previous filter or using
|
||||
/// the indices of the data source if no previous filter is present.
|
||||
fileprivate func reset(to dataSource: [T], previous filter: PipelineFilter<T>? = nil) {
|
||||
selection = (filter != nil) ? filter!.selection : dataSource.indices.arr()
|
||||
/// Reset `selection` by copying the indices and applying the filter function
|
||||
fileprivate func reset(to dataSource: [T], previous filterIndices: [Int]) {
|
||||
selection = filterIndices
|
||||
selection.removeAll { !shouldPersist(dataSource[$0]) }
|
||||
}
|
||||
|
||||
@@ -298,14 +275,6 @@ class PipelineFilter<T> {
|
||||
selection.binTreeRemove(index, compare: (<))
|
||||
}
|
||||
|
||||
/// Find `selection` index for corresponding `dataSource` index
|
||||
/// - Parameter index: Index of object in original `dataSource`
|
||||
/// - Returns: Index in `selection` or `nil` if element does not exist.
|
||||
/// - Complexity: O(log *n*), where *n* is the length of the `selection`.
|
||||
fileprivate func index(ofDataSource index: Int) -> Int? {
|
||||
selection.binTreeIndex(of: index, compare: (<), mustExist: true)
|
||||
}
|
||||
|
||||
/// Perform filter check and update internal `selection` indices.
|
||||
/// - Parameters:
|
||||
/// - obj: Object that was inserted or updated.
|
||||
@@ -314,7 +283,7 @@ class PipelineFilter<T> {
|
||||
/// `idx` contains the selection filter index or `nil` if the value should be removed.
|
||||
/// - Complexity: O(log *n*), where *n* is the length of the `selection`.
|
||||
fileprivate func update(_ obj: T, at index: Int) -> (keep: Bool, idx: Int?) {
|
||||
let currentIndex = self.index(ofDataSource: index)
|
||||
let currentIndex = selection.binTreeIndex(of: index, compare: (<), mustExist: true)
|
||||
if shouldPersist(obj) {
|
||||
return (true, currentIndex ?? selection.binTreeInsert(index, compare: (<)))
|
||||
}
|
||||
@@ -351,42 +320,42 @@ class PipelineSorting<T> {
|
||||
|
||||
private(set) var projection: [Int] = []
|
||||
private let comperator: (Int, Int) -> Bool // links to pipeline.dataSource
|
||||
private let previousLayerIndices: () -> [Int] // links to pipeline
|
||||
|
||||
/// 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])
|
||||
}
|
||||
previousLayerIndices = { [unowned pipe] in
|
||||
pipe.lastFilterLayerIndices()
|
||||
}
|
||||
reset()
|
||||
reset(to: pipe.lastLayerIndices())
|
||||
}
|
||||
|
||||
/// Apply a new layer of filtering. Every layer can only restrict the display even further.
|
||||
/// - 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]) {
|
||||
projection = filterIndices.sorted(by: comperator)
|
||||
}
|
||||
|
||||
/// After adding a new layer of filtering the new layer can only restrict the display even further.
|
||||
/// Therefore, indices that were removed in the last layer will be removed from the projection too.
|
||||
/// - Complexity: O(*m* log *n*), where *m* is the length of the `projection` and *n* the length of the `filter`.
|
||||
fileprivate func apply(moreRestrictive filter: PipelineFilter<T>) {
|
||||
projection.removeAll { filter.index(ofDataSource: $0) == nil }
|
||||
fileprivate func apply(moreRestrictive filterIndices: [Int]) {
|
||||
projection.removeAll { !filterIndices.binTreeExists($0, compare: (<)) }
|
||||
}
|
||||
|
||||
/// Remove a layer of filtering. Previous layers are less restrictive and contain more indices.
|
||||
/// 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.
|
||||
/// - Parameter filter: If `nil`, reset to last filter layer or `dataSource`
|
||||
/// - Complexity:
|
||||
/// * O(*m* log *n*), if `filter != nil`.
|
||||
/// Where *n* is the length of the `projection` and *m* is the difference between both layers.
|
||||
/// * O(*n* log *n*), if `filter == nil`.
|
||||
/// Where *n* is the length of the previous layer (or `dataSource`).
|
||||
fileprivate func reset(toLessRestrictive filter: PipelineFilter<T>? = nil) {
|
||||
if let indices = filter?.selection.difference(toSubset: projection.sorted(), compare: (<)) {
|
||||
for idx in indices {
|
||||
insertNew(idx)
|
||||
}
|
||||
} else {
|
||||
projection = previousLayerIndices().sorted(by: comperator)
|
||||
/// - Complexity: O(*m* log *n*), where *m* is the difference to the previous layer and *n* is the length of the `projection`.
|
||||
fileprivate func apply(lessRestrictive filterIndices: [Int]) {
|
||||
for x in filterIndices.difference(toSubset: projection.sorted(), compare: (<)) {
|
||||
insertNew(x)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,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
|
||||
|
||||
83
main/Common Classes/IBViews.swift
Normal 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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
77
main/Common Classes/Prefs.swift
Normal file
@@ -0,0 +1,77 @@
|
||||
import Foundation
|
||||
|
||||
enum Prefs {
|
||||
private static func Int(_ key: String) -> Int { UserDefaults.standard.integer(forKey: key) }
|
||||
private static func Int(_ val: Int, _ key: String) { UserDefaults.standard.set(val, forKey: key) }
|
||||
private static func Bool(_ key: String) -> Bool { UserDefaults.standard.bool(forKey: key) }
|
||||
private static func Bool(_ val: Bool, _ key: String) { UserDefaults.standard.set(val, forKey: key) }
|
||||
private static func `Any`(_ key: String) -> Any? { UserDefaults.standard.object(forKey: key) }
|
||||
private static func `Any`(_ val: Any?, _ key: String) { UserDefaults.standard.set(val, forKey: key) }
|
||||
|
||||
enum DidShowTutorial {
|
||||
static var Welcome: Bool {
|
||||
get { Prefs.Bool("didShowTutorialAppWelcome") }
|
||||
set { Prefs.Bool(newValue, "didShowTutorialAppWelcome") }
|
||||
}
|
||||
static var Recordings: Bool {
|
||||
get { Prefs.Bool("didShowTutorialRecordings") }
|
||||
set { Prefs.Bool(newValue, "didShowTutorialRecordings") }
|
||||
}
|
||||
}
|
||||
enum ContextAnalyis {
|
||||
static var CoOccurrenceTime: Int? {
|
||||
get { Prefs.Any("contextAnalyisCoOccurrenceTime") as? Int }
|
||||
set { Prefs.Any(newValue, "contextAnalyisCoOccurrenceTime") }
|
||||
}
|
||||
}
|
||||
enum DateFilter {
|
||||
static var Kind: DateFilterKind {
|
||||
get { DateFilterKind(rawValue: Prefs.Int("dateFilterType"))! }
|
||||
set { Prefs.Int(newValue.rawValue, "dateFilterType") }
|
||||
}
|
||||
/// Default: `0` (disabled)
|
||||
static var LastXMin: Int {
|
||||
get { Prefs.Int("dateFilterLastXMin") }
|
||||
set { Prefs.Int(newValue, "dateFilterLastXMin") }
|
||||
}
|
||||
/// Default: `nil` (disabled)
|
||||
static var RangeA: Timestamp? {
|
||||
get { Prefs.Any("dateFilterRangeA") as? Timestamp }
|
||||
set { Prefs.Any(newValue, "dateFilterRangeA") }
|
||||
}
|
||||
/// Default: `nil` (disabled)
|
||||
static var RangeB: Timestamp? {
|
||||
get { Prefs.Any("dateFilterRangeB") as? Timestamp }
|
||||
set { Prefs.Any(newValue, "dateFilterRangeB") }
|
||||
}
|
||||
/// default: `.Date`
|
||||
static var OrderBy: DateFilterOrderBy {
|
||||
get { DateFilterOrderBy(rawValue: Prefs.Int("dateFilterOderType"))! }
|
||||
set { Prefs.Int(newValue.rawValue, "dateFilterOderType") }
|
||||
}
|
||||
/// default: `false` (Desc)
|
||||
static var OrderAsc: Bool {
|
||||
get { Prefs.Bool("dateFilterOderAsc") }
|
||||
set { Prefs.Bool(newValue, "dateFilterOderAsc") }
|
||||
}
|
||||
|
||||
/// - 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
enum DateFilterKind: Int {
|
||||
case Off = 0, LastXMin = 1, ABRange = 2;
|
||||
}
|
||||
enum DateFilterOrderBy: Int {
|
||||
case Date = 0, Name = 1, Count = 2;
|
||||
}
|
||||
15
main/Common Classes/PrefsShared.swift
Normal file
@@ -0,0 +1,15 @@
|
||||
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(_ val: Int, _ key: String) { suite.set(val, forKey: key) }
|
||||
// private static func Obj(_ key: String) -> Any? { suite.object(forKey: key) }
|
||||
// private static func Obj(_ val: Any?, _ key: String) { suite.set(val, forKey: key) }
|
||||
|
||||
static var AutoDeleteLogsDays: Int {
|
||||
get { Int("AutoDeleteLogsDays") }
|
||||
set { Int(newValue, "AutoDeleteLogsDays"); suite.synchronize() }
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
57
main/Common Classes/SearchBarManager.swift
Normal file
@@ -0,0 +1,57 @@
|
||||
import UIKit
|
||||
|
||||
class SearchBarManager: NSObject, UISearchResultsUpdating {
|
||||
|
||||
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 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()
|
||||
|
||||
UITextField.appearance(whenContainedInInstancesOf: [UISearchBar.self])
|
||||
.defaultTextAttributes = [.font: UIFont.preferredFont(forTextStyle: .body)]
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/// Search callback
|
||||
func updateSearchResults(for controller: UISearchController) {
|
||||
NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(performSearch), object: nil)
|
||||
perform(#selector(performSearch), with: nil, afterDelay: 0.2)
|
||||
}
|
||||
|
||||
/// Internal callback function for delayed text evaluation.
|
||||
/// This way we can avoid unnecessary searches while user is typing.
|
||||
@objc private func performSearch() {
|
||||
term = controller.searchBar.text?.lowercased() ?? ""
|
||||
isActive = term.count > 0
|
||||
onChangeCallback(term)
|
||||
}
|
||||
}
|
||||
190
main/Common Classes/SlideInAnimation.swift
Normal file
@@ -0,0 +1,190 @@
|
||||
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.systemLayoutSizeFitting(
|
||||
CGSize(width: preferred.width, height: full.height),
|
||||
withHorizontalFittingPriority: .fittingSizeLevel,
|
||||
verticalFittingPriority: .required
|
||||
)
|
||||
return CGSize(width: min(fitted.width, full.width), height: full.height)
|
||||
case .top, .bottom:
|
||||
let fitted = target.systemLayoutSizeFitting(
|
||||
CGSize(width: full.width, height: preferred.height),
|
||||
withHorizontalFittingPriority: .required,
|
||||
verticalFittingPriority: .fittingSizeLevel
|
||||
)
|
||||
return CGSize(width: full.width, height: min(fitted.height, full.height))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -54,16 +54,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 +109,7 @@ struct GroupedDomain {
|
||||
var options: FilterOptions? = nil
|
||||
}
|
||||
typealias GroupedTsOccurrence = (ts: Timestamp, total: Int32, blocked: Int32)
|
||||
typealias DomainTsPair = (domain: String, ts: Timestamp)
|
||||
|
||||
extension SQLiteDatabase {
|
||||
|
||||
@@ -109,9 +131,9 @@ extension SQLiteDatabase {
|
||||
return (before > after) ? nil : (before, after)
|
||||
}
|
||||
|
||||
/// `DELETE FROM heap; DELETE FROM cache;`
|
||||
/// `DELETE FROM cache; DELETE FROM heap;`
|
||||
func dnsLogsDeleteAll() throws {
|
||||
try? run(sql: "DELETE FROM heap; DELETE FROM cache;")
|
||||
try? run(sql: "DELETE FROM cache; DELETE FROM heap;")
|
||||
vacuum()
|
||||
}
|
||||
|
||||
@@ -119,8 +141,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 +151,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 +205,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 +217,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 +230,71 @@ 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(_ fqdn: String) -> [Timestamp]? {
|
||||
try? run(sql: "SELECT DISTINCT ts FROM heap WHERE fqdn = ? ORDER BY ts;", bind: [BindText(fqdn)]) {
|
||||
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 fqdn: String) -> [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 fqdn != ? AND dist <= ?
|
||||
) GROUP BY fqdn
|
||||
) ORDER BY rank ASC LIMIT 99;
|
||||
""", bind: [BindInt64(dt), BindInt64(low), BindInt64(high), BindText(fqdn), 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 +364,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`
|
||||
@@ -318,8 +412,6 @@ extension CreateTable {
|
||||
"""}
|
||||
}
|
||||
|
||||
typealias RecordLog = (domain: String, count: Int32)
|
||||
|
||||
extension SQLiteDatabase {
|
||||
|
||||
// MARK: write
|
||||
@@ -348,13 +440,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)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,10 +70,10 @@ 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 blocked = FilterOptions(rawValue: 1 << 0)
|
||||
static let ignored = FilterOptions(rawValue: 1 << 1)
|
||||
static let any = FilterOptions(rawValue: 0b11)
|
||||
}
|
||||
|
||||
@@ -65,7 +83,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)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -142,6 +138,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 +193,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 +218,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
|
||||
|
||||
@@ -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!) } }
|
||||
}
|
||||
|
||||
|
||||
43
main/DB/TheGreatDestroyer.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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] {
|
||||
|
||||
@@ -1,76 +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(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.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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,100 +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
|
||||
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])
|
||||
}
|
||||
}
|
||||
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])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// #################################
|
||||
// #
|
||||
// # MARK: - Cell Animations
|
||||
// #
|
||||
// #################################
|
||||
|
||||
extension GroupedDomainDataSource {
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -185,8 +238,8 @@ extension GroupedDomainDataSource {
|
||||
// #
|
||||
// ##########################
|
||||
|
||||
protocol GroupedDomainEditRow : EditableRows, FilterPipelineDelegate {
|
||||
var source: GroupedDomainDataSource { get set }
|
||||
protocol GroupedDomainEditRow : UIViewController, EditableRows {
|
||||
var source: GroupedDomainDataSource { get }
|
||||
}
|
||||
|
||||
extension GroupedDomainEditRow {
|
||||
@@ -213,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
|
||||
@@ -233,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? {
|
||||
@@ -243,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? {
|
||||
|
||||
@@ -15,13 +15,14 @@ enum RecordingsDB {
|
||||
|
||||
/// 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 +43,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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,55 +1,281 @@
|
||||
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() {
|
||||
let lastXFilter = Pref.DateFilter.lastXMinTimestamp() ?? 0
|
||||
let before = tsEarliest
|
||||
tsEarliest = lastXFilter
|
||||
if before < lastXFilter {
|
||||
DispatchQueue.global().async {
|
||||
if let excess = AppDB?.dnsLogsRowRange(between: before, and: lastXFilter) {
|
||||
NotifySyncRemove.postAsyncMain(excess)
|
||||
}
|
||||
self.pause()
|
||||
let filter = Prefs.DateFilter.restrictions()
|
||||
filterType = filter.type
|
||||
DispatchQueue.global().async {
|
||||
// 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)
|
||||
}
|
||||
} else if before > lastXFilter {
|
||||
DispatchQueue.global().async {
|
||||
if let missing = AppDB?.dnsLogsRowRange(between: lastXFilter, and: before) {
|
||||
NotifySyncInsert.postAsyncMain(missing)
|
||||
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 }
|
||||
|
||||
/// All calls must be balanced with `continue()` calls.
|
||||
/// Can be nested within other `pause-continue` pairs.
|
||||
/// - Warning: An execution branch that results in unbalanced pairs will completely disable updates!
|
||||
func pause() { paused += 1 }
|
||||
|
||||
/// Must be balanced with a `pause()` call. A `continue()` without a `pause()` is a `nop`.
|
||||
/// - Note: Internally the sync timer keeps running. The `pause` will simply ignore execution during that time.
|
||||
func `continue`() { if paused > 0 { paused -= 1 } }
|
||||
|
||||
/// Persist logs from cache and notify all observers. (`NotifySyncInsert`)
|
||||
/// 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.
|
||||
/// - Parameter block: **Always** called on a background thread!
|
||||
func syncNow(whenDone block: (() -> Void)? = nil) {
|
||||
let now = Date().timeIntervalSince1970
|
||||
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.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?) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func start() { paused = 0 }
|
||||
func pause() { paused += 1 }
|
||||
func `continue`() { if paused > 0 { paused -= 1 } }
|
||||
|
||||
func syncNow() {
|
||||
self.pause() // reduce concurrent load
|
||||
/// 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 inserted = AppDB?.dnsLogsPersist() { // move cache -> heap
|
||||
NotifySyncInsert.post(inserted)
|
||||
}
|
||||
if let lastXFilter = Pref.DateFilter.lastXMinTimestamp(), tsEarliest < lastXFilter {
|
||||
if let removed = AppDB?.dnsLogsRowRange(between: tsEarliest, and: lastXFilter) {
|
||||
NotifySyncRemove.post(removed)
|
||||
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)
|
||||
}
|
||||
}
|
||||
tsEarliest = lastXFilter
|
||||
}
|
||||
// TODO: periodic hard delete old logs (will reset rowids!)
|
||||
|
||||
}
|
||||
|
||||
/// - 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,25 +2,22 @@ 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 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")
|
||||
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) }
|
||||
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()
|
||||
|
||||
@@ -36,7 +33,7 @@ class TestDataSource {
|
||||
|
||||
@objc static func insertRandom() {
|
||||
//QLog.Debug("Inserting 1 periodic log entry")
|
||||
try? db.logWrite(pStmt, "\(arc4random() % 5).count.test.com", blocked: true)
|
||||
try? AppDB?.logWrite("\(arc4random() % 5).count.test.com", blocked: true)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -82,6 +82,7 @@ extension Array {
|
||||
result.append(lhs)
|
||||
}
|
||||
}
|
||||
result.append(contentsOf: iter)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
25
main/Extensions/Color.swift
Normal 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
|
||||
}
|
||||
}
|
||||
33
main/Extensions/Equatable.swift
Normal 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
|
||||
}
|
||||
}
|
||||
51
main/Extensions/Font.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)!
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]] = [:]
|
||||
|
||||
@@ -19,22 +19,38 @@ 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()
|
||||
}
|
||||
|
||||
/// Recalculate and apply new `tableHeaderView` height.
|
||||
func sizeHeaderToFit() {
|
||||
if let head = tableHeaderView {
|
||||
head.frame.size.height = head.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).height
|
||||
tableHeaderView = head
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -52,10 +68,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
|
||||
}
|
||||
|
||||
@@ -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,22 @@ 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) }
|
||||
}
|
||||
|
||||
extension Timer {
|
||||
@@ -39,6 +33,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 +72,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 +93,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)
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
|
||||
26
main/Extensions/View.swift
Normal file
@@ -0,0 +1,26 @@
|
||||
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!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
139
main/GlassVPN.swift
Normal file
@@ -0,0 +1,139 @@
|
||||
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() {
|
||||
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)
|
||||
NotifyDNSFilterChanged.observe(call: #selector(didChangeDomainFilter), on: self)
|
||||
}
|
||||
|
||||
func setEnabled(_ newState: Bool) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 let session = self.managerVPN?.connection as? NETunnelProviderSession,
|
||||
session.status == .connected, let data = message.raw {
|
||||
do {
|
||||
try session.sendProviderMessage(data, responseHandler: nil)
|
||||
return true
|
||||
} catch {}
|
||||
}
|
||||
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)")
|
||||
}
|
||||
}
|
||||
@@ -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? {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
97
main/Requests/Analytics/TVCOccurrenceContext.swift
Normal 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
|
||||
}
|
||||
}
|
||||
150
main/Requests/Analytics/VCCoOccurrence.swift
Normal file
@@ -0,0 +1,150 @@
|
||||
import UIKit
|
||||
|
||||
class VCCoOccurrence: UIViewController, UITableViewDataSource {
|
||||
var fqdn: String!
|
||||
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 ?? 5 // 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 = fqdn!
|
||||
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), times.count > 0,
|
||||
let result = db.contextAnalysis(coOccurrence: times, plusMinus: time, exclude: domain),
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,22 @@
|
||||
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)
|
||||
|
||||
private var searchActive: Bool = false
|
||||
private var searchTerm: String?
|
||||
private let searchBar: UISearchBar = {
|
||||
let x = UISearchBar(frame: CGRect.init(x: 0, y: 0, width: 20, height: 10))
|
||||
x.sizeToFit()
|
||||
x.showsCancelButton = true
|
||||
x.autocapitalizationType = .none
|
||||
x.autocorrectionType = .no
|
||||
return x
|
||||
}()
|
||||
@IBOutlet private var filterButton: UIBarButtonItem!
|
||||
@IBOutlet private var filterButtonDetail: UIBarButtonItem!
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
searchBar.delegate = self
|
||||
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?) {
|
||||
@@ -39,6 +26,34 @@ class TVCDomains: UITableViewController, UISearchBarDelegate, FilterPipelineDele
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Filter
|
||||
|
||||
@IBAction private func filterButtonTapped(_ sender: UIBarButtonItem) {
|
||||
let vc = self.storyboard!.instantiateViewController(withIdentifier: "domainFilter")
|
||||
vc.modalPresentationStyle = .custom
|
||||
if #available(iOS 13.0, *) {
|
||||
vc.isModalInPresentation = true
|
||||
}
|
||||
present(vc, animated: true)
|
||||
}
|
||||
|
||||
@objc private func didChangeDateFilter() {
|
||||
switch Prefs.DateFilter.Kind {
|
||||
case .ABRange: // read start/end time
|
||||
self.filterButtonDetail.title = "A – B"
|
||||
self.filterButton.image = UIImage(named: "filter-filled")
|
||||
case .LastXMin: // most recent
|
||||
let lastXMin = Prefs.DateFilter.LastXMin
|
||||
if lastXMin == 0 { fallthrough }
|
||||
self.filterButtonDetail.title = TimeFormat(.abbreviated).from(minutes: lastXMin)
|
||||
self.filterButton.image = UIImage(named: "filter-filled")
|
||||
default:
|
||||
self.filterButtonDetail.title = ""
|
||||
self.filterButton.image = UIImage(named: "filter-clear")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Table View Data Source
|
||||
|
||||
override func tableView(_ _: UITableView, numberOfRowsInSection _: Int) -> Int { source.numberOfRows }
|
||||
@@ -52,76 +67,10 @@ 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
|
||||
cell?.imageView?.image = entry.options?.tableRowImage()
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Search
|
||||
|
||||
@IBAction private func searchButtonTapped(_ sender: UIBarButtonItem) {
|
||||
setSearch(hidden: searchActive)
|
||||
}
|
||||
|
||||
func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
|
||||
setSearch(hidden: true)
|
||||
}
|
||||
|
||||
private func setSearch(hidden: Bool) {
|
||||
searchActive = !hidden
|
||||
searchTerm = nil
|
||||
searchBar.text = nil
|
||||
tableView.tableHeaderView = hidden ? nil : searchBar
|
||||
if searchActive {
|
||||
source.pipeline.addFilter("search") {
|
||||
$0.domain.lowercased().contains(self.searchTerm ?? "")
|
||||
}
|
||||
searchBar.becomeFirstResponder()
|
||||
} else {
|
||||
source.pipeline.removeFilter(withId: "search")
|
||||
}
|
||||
tableView.reloadData()
|
||||
}
|
||||
|
||||
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
|
||||
NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(performSearch), object: nil)
|
||||
perform(#selector(performSearch), with: nil, afterDelay: 0.2)
|
||||
}
|
||||
|
||||
@objc private func performSearch() {
|
||||
searchTerm = searchBar.text?.lowercased() ?? ""
|
||||
source.pipeline.reloadFilter(withId: "search")
|
||||
tableView.reloadData()
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Filter
|
||||
|
||||
@IBAction private func filterButtonTapped(_ sender: UIBarButtonItem) {
|
||||
let vc = self.storyboard!.instantiateViewController(withIdentifier: "domainFilter")
|
||||
vc.modalPresentationStyle = .custom
|
||||
if #available(iOS 13.0, *) {
|
||||
vc.isModalInPresentation = true
|
||||
}
|
||||
present(vc, animated: true)
|
||||
}
|
||||
|
||||
@objc private func didChangeDateFilter() {
|
||||
switch Pref.DateFilter.Kind {
|
||||
case .ABRange: // read start/end time
|
||||
self.filterButtonDetail.title = "A – B"
|
||||
self.filterButton.image = UIImage(named: "filter-filled")
|
||||
case .LastXMin: // most recent
|
||||
let lastXMin = Pref.DateFilter.LastXMin
|
||||
if lastXMin == 0 { fallthrough }
|
||||
self.filterButtonDetail.title = TimeFormat(.abbreviated).from(minutes: lastXMin)
|
||||
self.filterButton.image = UIImage(named: "filter-filled")
|
||||
default:
|
||||
self.filterButtonDetail.title = ""
|
||||
self.filterButton.image = UIImage(named: "filter-clear")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,74 +1,119 @@
|
||||
import UIKit
|
||||
|
||||
class TVCHostDetails: UITableViewController {
|
||||
class TVCHostDetails: UITableViewController, SyncUpdateDelegate, UITabBarDelegate {
|
||||
|
||||
@IBOutlet private var actionsBar: UITabBar!
|
||||
|
||||
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()
|
||||
refreshControl?.endRefreshing()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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()
|
||||
}
|
||||
sync.allowPullToRefresh(onTVC: self, forObserver: self)
|
||||
actionsBar.unselectedItemTintColor = .sysLink
|
||||
}
|
||||
UIDevice.orientationDidChangeNotification.observe(call: #selector(didChangeOrientation), on: self)
|
||||
}
|
||||
|
||||
// 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: - Tab Bar
|
||||
// #
|
||||
// #########################
|
||||
|
||||
extension TVCHostDetails {
|
||||
|
||||
@objc private func didChangeOrientation(_ sender: Notification) {
|
||||
tableView.sizeHeaderToFit() // otherwise TabBar won't compress
|
||||
}
|
||||
|
||||
func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) {
|
||||
tabBar.selectedItem = nil
|
||||
performSegue(withIdentifier: "segueAnalysisCoOccurrence", sender: nil)
|
||||
}
|
||||
|
||||
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
|
||||
if segue.identifier == "segueAnalysisCoOccurrence" {
|
||||
(segue.destination as? VCCoOccurrence)?.fqdn = fullDomain
|
||||
} else if let index = tableView.indexPathForSelectedRow?.row {
|
||||
let tvc = segue.destination as? TVCOccurrenceContext
|
||||
tvc?.domain = fullDomain
|
||||
tvc?.ts = dataSource[index].ts
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ################################
|
||||
// #
|
||||
// # 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import UIKit
|
||||
|
||||
class TVCHosts: UITableViewController, FilterPipelineDelegate {
|
||||
class TVCHosts: UITableViewController, GroupedDomainDataSourceDelegate {
|
||||
|
||||
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,6 +21,7 @@ class TVCHosts: UITableViewController, FilterPipelineDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Table View Data Source
|
||||
|
||||
override func tableView(_ _: UITableView, numberOfRowsInSection _: Int) -> Int { source.numberOfRows }
|
||||
@@ -39,7 +41,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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: (<))!
|
||||
@@ -71,7 +64,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? {
|
||||
|
||||
@@ -2,60 +2,40 @@ 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!
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
NotifyVPNStateChanged.observe(call: #selector(vpnStateChanged(_:)), on: self)
|
||||
changedState(currentVPNState)
|
||||
NotifyDNSFilterChanged.observe(call: #selector(reloadDataSource), on: self)
|
||||
reloadToggleState()
|
||||
reloadDataSource()
|
||||
NotifyVPNStateChanged.observe(call: #selector(reloadToggleState), on: self)
|
||||
NotifyDNSFilterChanged.observe(call: #selector(reloadDataSource), on: self)
|
||||
}
|
||||
|
||||
@objc func reloadDataSource() {
|
||||
|
||||
// MARK: - VPN Proxy Settings
|
||||
|
||||
@IBAction private func toggleVPNProxy(_ sender: UISwitch) {
|
||||
GlassVPN.setEnabled(sender.isOn)
|
||||
}
|
||||
|
||||
@objc private func reloadToggleState() {
|
||||
vpnToggle.isOn = (GlassVPN.state != .off)
|
||||
vpnToggle.onTintColor = (GlassVPN.state == .inbetween ? .systemYellow : nil)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Logging Filter
|
||||
|
||||
@objc private func reloadDataSource() {
|
||||
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)
|
||||
let (one, two) = autoDeleteSelection([1, 7, 31])
|
||||
cellPrivacyAutoDelete.detailTextLabel?.text = autoDeleteString(one, unit: two)
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, accessoryButtonTappedForRowWith indexPath: IndexPath) {
|
||||
@@ -85,4 +65,89 @@ class TVCSettings: UITableViewController {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Privacy
|
||||
|
||||
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 {
|
||||
let multiplier = [1, 7, 31]
|
||||
let (one, two) = autoDeleteSelection(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
|
||||
cell.detailTextLabel?.text = autoDeleteString(idx[0], unit: idx[1])
|
||||
let asDays = idx[0] * multiplier[idx[1]]
|
||||
PrefsShared.AutoDeleteLogsDays = asDays
|
||||
if !GlassVPN.send(.autoDelete(after: asDays)) {
|
||||
// if VPN isn't active, fallback to immediate local delete
|
||||
TheGreatDestroyer.deleteLogs(olderThan: asDays)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Reset Settings
|
||||
|
||||
@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
|
||||
|
||||
@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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// -------------------------------
|
||||
// |
|
||||
// | MARK: - Helper methods
|
||||
// |
|
||||
// -------------------------------
|
||||
|
||||
private func autoDeleteSelection(_ 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 autoDeleteString(_ num: Int, unit: Int) -> String {
|
||||
switch num {
|
||||
case 0: return "Never"
|
||||
case 1: return "1 \(["Day", "Week", "Month"][unit])"
|
||||
default: return "\(num) \(["Days", "Weeks", "Months"][unit])"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,15 +5,31 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@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 +53,7 @@ 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||