Refactoring I.
- Revamp whole DB to Display flow - Filter Pipeline, arbitrary filtering and sorting - Binary tree arrays for faster lookup & manipulation - DB: introducing custom functions - DB scheme: split req into heap & cache - cache written by GlassVPN only - heap written by Main App only - Introducing DB separation: DBCore, DBCommon, DBAppOnly - Introducing DB data sources: TestDataSource, GroupedDomainDataSource, RecordingsDB, DomainFilter - Background sync: Move entries from cache to heap and notify all observers - GlassVPN: Binary tree filter lookup - GlassVPN: Reusing prepared statement
This commit is contained in:
@@ -7,7 +7,6 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
540C6457240D929300E948F9 /* EditableRows.swift in Sources */ = {isa = PBXBuildFile; fileRef = 540C6456240D929300E948F9 /* EditableRows.swift */; };
|
|
||||||
540E6780242D2CF100871BBE /* VCRecordings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 540E677F242D2CF100871BBE /* VCRecordings.swift */; };
|
540E6780242D2CF100871BBE /* VCRecordings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 540E677F242D2CF100871BBE /* VCRecordings.swift */; };
|
||||||
540E67822433483D00871BBE /* VCEditRecording.swift in Sources */ = {isa = PBXBuildFile; fileRef = 540E67812433483D00871BBE /* VCEditRecording.swift */; };
|
540E67822433483D00871BBE /* VCEditRecording.swift in Sources */ = {isa = PBXBuildFile; fileRef = 540E67812433483D00871BBE /* VCEditRecording.swift */; };
|
||||||
540E67842433FAFE00871BBE /* TVCPreviousRecords.swift in Sources */ = {isa = PBXBuildFile; fileRef = 540E67832433FAFE00871BBE /* TVCPreviousRecords.swift */; };
|
540E67842433FAFE00871BBE /* TVCPreviousRecords.swift in Sources */ = {isa = PBXBuildFile; fileRef = 540E67832433FAFE00871BBE /* TVCPreviousRecords.swift */; };
|
||||||
@@ -21,22 +20,23 @@
|
|||||||
542E2A9A24051556001462DC /* TVCSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 542E2A9924051556001462DC /* TVCSettings.swift */; };
|
542E2A9A24051556001462DC /* TVCSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 542E2A9924051556001462DC /* TVCSettings.swift */; };
|
||||||
543CDB2023EEE61900B7F323 /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 543CDB1F23EEE61900B7F323 /* PacketTunnelProvider.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, ); }; };
|
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 */; };
|
544C95262407B1C700AB89D0 /* SharedState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 544C95252407B1C700AB89D0 /* SharedState.swift */; };
|
||||||
5458EBC0243A3F2200CFEB15 /* TVCRecordingDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5458EBBF243A3F2200CFEB15 /* TVCRecordingDetails.swift */; };
|
5458EBC0243A3F2200CFEB15 /* TVCRecordingDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5458EBBF243A3F2200CFEB15 /* TVCRecordingDetails.swift */; };
|
||||||
545DDDCF243E6267003B6544 /* TutorialSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 545DDDCE243E6267003B6544 /* TutorialSheet.swift */; };
|
545DDDCF243E6267003B6544 /* TutorialSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 545DDDCE243E6267003B6544 /* TutorialSheet.swift */; };
|
||||||
545DDDD124436983003B6544 /* QuickUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 545DDDD024436983003B6544 /* QuickUI.swift */; };
|
545DDDD124436983003B6544 /* QuickUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 545DDDD024436983003B6544 /* QuickUI.swift */; };
|
||||||
545DDDD424466D37003B6544 /* AutoLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 545DDDD324466D37003B6544 /* AutoLayout.swift */; };
|
545DDDD424466D37003B6544 /* AutoLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 545DDDD324466D37003B6544 /* AutoLayout.swift */; };
|
||||||
546063E523FEFAFE008F505A /* SQDB.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B7562223D7B2DC008F0C41 /* SQDB.swift */; };
|
546063E523FEFAFE008F505A /* DBCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B7562223D7B2DC008F0C41 /* DBCore.swift */; };
|
||||||
54751E512423955100168273 /* URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54751E502423955000168273 /* URL.swift */; };
|
54751E512423955100168273 /* URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54751E502423955000168273 /* URL.swift */; };
|
||||||
54751E522423955100168273 /* URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54751E502423955000168273 /* URL.swift */; };
|
54751E522423955100168273 /* URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54751E502423955000168273 /* URL.swift */; };
|
||||||
54953E3323DC752E0054345C /* SQDB.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B7562223D7B2DC008F0C41 /* SQDB.swift */; };
|
54953E3323DC752E0054345C /* DBCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B7562223D7B2DC008F0C41 /* DBCore.swift */; };
|
||||||
54953E5F23DEBE840054345C /* TVCDomains.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54953E5E23DEBE840054345C /* TVCDomains.swift */; };
|
54953E5F23DEBE840054345C /* TVCDomains.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54953E5E23DEBE840054345C /* TVCDomains.swift */; };
|
||||||
54953E6123E0D69A0054345C /* TVCHosts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54953E6023E0D69A0054345C /* TVCHosts.swift */; };
|
54953E6123E0D69A0054345C /* TVCHosts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54953E6023E0D69A0054345C /* TVCHosts.swift */; };
|
||||||
54953E6F23E44CD00054345C /* TVCHostDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54953E6E23E44CD00054345C /* TVCHostDetails.swift */; };
|
54953E6F23E44CD00054345C /* TVCHostDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54953E6E23E44CD00054345C /* TVCHostDetails.swift */; };
|
||||||
54953E7123E473F10054345C /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = 54953E7023E473F10054345C /* Settings.bundle */; };
|
54953E7123E473F10054345C /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = 54953E7023E473F10054345C /* Settings.bundle */; };
|
||||||
54B34594240E6343004C53CC /* TVCFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B34593240E6343004C53CC /* TVCFilter.swift */; };
|
54B34594240E6343004C53CC /* TVCFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B34593240E6343004C53CC /* TVCFilter.swift */; };
|
||||||
54B34596240F0513004C53CC /* TableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B34595240F0513004C53CC /* TableView.swift */; };
|
54B34596240F0513004C53CC /* TableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B34595240F0513004C53CC /* TableView.swift */; };
|
||||||
54B345992414F491004C53CC /* DBWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B345982414F491004C53CC /* DBWrapper.swift */; };
|
|
||||||
54B345A6241BB982004C53CC /* Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B345A5241BB982004C53CC /* Notifications.swift */; };
|
54B345A6241BB982004C53CC /* Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B345A5241BB982004C53CC /* Notifications.swift */; };
|
||||||
54B345A9241BBA0B004C53CC /* Generic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B345A8241BBA0B004C53CC /* Generic.swift */; };
|
54B345A9241BBA0B004C53CC /* Generic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B345A8241BBA0B004C53CC /* Generic.swift */; };
|
||||||
54B345AB241BBA5B004C53CC /* AlertSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B345AA241BBA5B004C53CC /* AlertSheet.swift */; };
|
54B345AB241BBA5B004C53CC /* AlertSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B345AA241BBA5B004C53CC /* AlertSheet.swift */; };
|
||||||
@@ -124,6 +124,16 @@
|
|||||||
54CA02BE2426D4F3003A5E04 /* DDLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54CA02BD2426D4F3003A5E04 /* DDLog.swift */; };
|
54CA02BE2426D4F3003A5E04 /* DDLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54CA02BD2426D4F3003A5E04 /* DDLog.swift */; };
|
||||||
54CA02C32426DCCD003A5E04 /* GCDAsyncSocket.m in Sources */ = {isa = PBXBuildFile; fileRef = 54CA02BF2426DCCC003A5E04 /* GCDAsyncSocket.m */; };
|
54CA02C32426DCCD003A5E04 /* GCDAsyncSocket.m in Sources */ = {isa = PBXBuildFile; fileRef = 54CA02BF2426DCCC003A5E04 /* GCDAsyncSocket.m */; };
|
||||||
54CA02C42426DCCD003A5E04 /* GCDAsyncUdpSocket.m in Sources */ = {isa = PBXBuildFile; fileRef = 54CA02C02426DCCD003A5E04 /* GCDAsyncUdpSocket.m */; };
|
54CA02C42426DCCD003A5E04 /* GCDAsyncUdpSocket.m in Sources */ = {isa = PBXBuildFile; fileRef = 54CA02C02426DCCD003A5E04 /* GCDAsyncUdpSocket.m */; };
|
||||||
|
54D8B97A246C9F2000EB2414 /* FilterPipeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D8B979246C9F2000EB2414 /* FilterPipeline.swift */; };
|
||||||
|
54D8B97C2471A7E000EB2414 /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D8B97B2471A7E000EB2414 /* String.swift */; };
|
||||||
|
54D8B97E2471B88900EB2414 /* DBCommon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D8B97D2471B88900EB2414 /* DBCommon.swift */; };
|
||||||
|
54D8B97F2471B89100EB2414 /* DBCommon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D8B97D2471B88900EB2414 /* DBCommon.swift */; };
|
||||||
|
54D8B9832471BD8100EB2414 /* DBAppOnly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D8B9822471BD8100EB2414 /* DBAppOnly.swift */; };
|
||||||
|
54D8B98624796E9900EB2414 /* GroupedDomainDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D8B98524796E9800EB2414 /* GroupedDomainDataSource.swift */; };
|
||||||
|
54E540F2247C423200F7C34A /* DomainFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54E540F1247C423200F7C34A /* DomainFilter.swift */; };
|
||||||
|
54E540F4247D3F2600F7C34A /* TestDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54E540F3247D3F2600F7C34A /* TestDataSource.swift */; };
|
||||||
|
54E540F8247DB90F00F7C34A /* RecordingsDB.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54E540F7247DB90F00F7C34A /* RecordingsDB.swift */; };
|
||||||
|
54E540FA2482414800F7C34A /* SyncUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54E540F92482414800F7C34A /* SyncUpdate.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -151,7 +161,6 @@
|
|||||||
/* End PBXCopyFilesBuildPhase section */
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
540C6456240D929300E948F9 /* EditableRows.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditableRows.swift; sourceTree = "<group>"; };
|
|
||||||
540E677F242D2CF100871BBE /* VCRecordings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VCRecordings.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>"; };
|
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>"; };
|
540E67832433FAFE00871BBE /* TVCPreviousRecords.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVCPreviousRecords.swift; sourceTree = "<group>"; };
|
||||||
@@ -169,6 +178,8 @@
|
|||||||
543CDB1F23EEE61900B7F323 /* PacketTunnelProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketTunnelProvider.swift; sourceTree = "<group>"; };
|
543CDB1F23EEE61900B7F323 /* PacketTunnelProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketTunnelProvider.swift; sourceTree = "<group>"; };
|
||||||
543CDB2123EEE61900B7F323 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
543CDB2123EEE61900B7F323 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
543CDB2223EEE61900B7F323 /* GlassVPN.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GlassVPN.entitlements; sourceTree = "<group>"; };
|
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>"; };
|
544C95252407B1C700AB89D0 /* SharedState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedState.swift; sourceTree = "<group>"; };
|
||||||
5458EBBF243A3F2200CFEB15 /* TVCRecordingDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVCRecordingDetails.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>"; };
|
545DDDCE243E6267003B6544 /* TutorialSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialSheet.swift; sourceTree = "<group>"; };
|
||||||
@@ -182,13 +193,12 @@
|
|||||||
54953E7023E473F10054345C /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = "<group>"; };
|
54953E7023E473F10054345C /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = "<group>"; };
|
||||||
54B34593240E6343004C53CC /* TVCFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVCFilter.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>"; };
|
54B34595240F0513004C53CC /* TableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableView.swift; sourceTree = "<group>"; };
|
||||||
54B345982414F491004C53CC /* DBWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBWrapper.swift; sourceTree = "<group>"; };
|
|
||||||
54B345A5241BB982004C53CC /* Notifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notifications.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 /* Generic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Generic.swift; sourceTree = "<group>"; };
|
||||||
54B345AA241BBA5B004C53CC /* AlertSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertSheet.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>"; };
|
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>"; };
|
54B345AF242264F8004C53CC /* third-level.txt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "third-level.txt"; sourceTree = "<group>"; };
|
||||||
54B7562223D7B2DC008F0C41 /* SQDB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SQDB.swift; sourceTree = "<group>"; };
|
54B7562223D7B2DC008F0C41 /* DBCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBCore.swift; sourceTree = "<group>"; };
|
||||||
54C056DA23E9E36E00214A3F /* AppInfoType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppInfoType.swift; sourceTree = "<group>"; };
|
54C056DA23E9E36E00214A3F /* AppInfoType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppInfoType.swift; sourceTree = "<group>"; };
|
||||||
54C056DC23E9EEF700214A3F /* BundleIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleIcon.swift; sourceTree = "<group>"; };
|
54C056DC23E9EEF700214A3F /* BundleIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleIcon.swift; sourceTree = "<group>"; };
|
||||||
54CA00D62426A803003A5E04 /* CocoaAsyncSocket.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CocoaAsyncSocket.h; sourceTree = "<group>"; };
|
54CA00D62426A803003A5E04 /* CocoaAsyncSocket.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CocoaAsyncSocket.h; sourceTree = "<group>"; };
|
||||||
@@ -274,6 +284,15 @@
|
|||||||
54CA02C02426DCCD003A5E04 /* GCDAsyncUdpSocket.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GCDAsyncUdpSocket.m; sourceTree = "<group>"; };
|
54CA02C02426DCCD003A5E04 /* GCDAsyncUdpSocket.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GCDAsyncUdpSocket.m; sourceTree = "<group>"; };
|
||||||
54CA02C12426DCCD003A5E04 /* GCDAsyncSocket.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = GCDAsyncSocket.h; sourceTree = "<group>"; };
|
54CA02C12426DCCD003A5E04 /* GCDAsyncSocket.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = GCDAsyncSocket.h; sourceTree = "<group>"; };
|
||||||
54CA02C22426DCCD003A5E04 /* GCDAsyncUdpSocket.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = GCDAsyncUdpSocket.h; sourceTree = "<group>"; };
|
54CA02C22426DCCD003A5E04 /* GCDAsyncUdpSocket.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = GCDAsyncUdpSocket.h; sourceTree = "<group>"; };
|
||||||
|
54D8B979246C9F2000EB2414 /* FilterPipeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterPipeline.swift; sourceTree = "<group>"; };
|
||||||
|
54D8B97B2471A7E000EB2414 /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = "<group>"; };
|
||||||
|
54D8B97D2471B88900EB2414 /* DBCommon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBCommon.swift; sourceTree = "<group>"; };
|
||||||
|
54D8B9822471BD8100EB2414 /* DBAppOnly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBAppOnly.swift; sourceTree = "<group>"; };
|
||||||
|
54D8B98524796E9800EB2414 /* GroupedDomainDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupedDomainDataSource.swift; sourceTree = "<group>"; };
|
||||||
|
54E540F1247C423200F7C34A /* DomainFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainFilter.swift; sourceTree = "<group>"; };
|
||||||
|
54E540F3247D3F2600F7C34A /* TestDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestDataSource.swift; sourceTree = "<group>"; };
|
||||||
|
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>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -348,6 +367,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
54B3459A2415651C004C53CC /* DB */,
|
54B3459A2415651C004C53CC /* DB */,
|
||||||
|
54E540F0247C386500F7C34A /* Data Source */,
|
||||||
54B345A4241BB975004C53CC /* Extensions */,
|
54B345A4241BB975004C53CC /* Extensions */,
|
||||||
545DDDD224436A03003B6544 /* Common Classes */,
|
545DDDD224436A03003B6544 /* Common Classes */,
|
||||||
548B1F9423D338EC005B047C /* main.entitlements */,
|
548B1F9423D338EC005B047C /* main.entitlements */,
|
||||||
@@ -394,7 +414,7 @@
|
|||||||
children = (
|
children = (
|
||||||
545DDDD024436983003B6544 /* QuickUI.swift */,
|
545DDDD024436983003B6544 /* QuickUI.swift */,
|
||||||
545DDDCE243E6267003B6544 /* TutorialSheet.swift */,
|
545DDDCE243E6267003B6544 /* TutorialSheet.swift */,
|
||||||
540C6456240D929300E948F9 /* EditableRows.swift */,
|
54D8B979246C9F2000EB2414 /* FilterPipeline.swift */,
|
||||||
);
|
);
|
||||||
path = "Common Classes";
|
path = "Common Classes";
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -402,8 +422,10 @@
|
|||||||
54B3459A2415651C004C53CC /* DB */ = {
|
54B3459A2415651C004C53CC /* DB */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
54B7562223D7B2DC008F0C41 /* SQDB.swift */,
|
54B7562223D7B2DC008F0C41 /* DBCore.swift */,
|
||||||
54B345982414F491004C53CC /* DBWrapper.swift */,
|
54D8B97D2471B88900EB2414 /* DBCommon.swift */,
|
||||||
|
54D8B9822471BD8100EB2414 /* DBAppOnly.swift */,
|
||||||
|
54B345AC241BBB00004C53CC /* DBExtensions.swift */,
|
||||||
);
|
);
|
||||||
path = DB;
|
path = DB;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -414,10 +436,12 @@
|
|||||||
544C95252407B1C700AB89D0 /* SharedState.swift */,
|
544C95252407B1C700AB89D0 /* SharedState.swift */,
|
||||||
54B345A8241BBA0B004C53CC /* Generic.swift */,
|
54B345A8241BBA0B004C53CC /* Generic.swift */,
|
||||||
54B345A5241BB982004C53CC /* Notifications.swift */,
|
54B345A5241BB982004C53CC /* Notifications.swift */,
|
||||||
54B345AC241BBB00004C53CC /* DBExtensions.swift */,
|
|
||||||
54B345AA241BBA5B004C53CC /* AlertSheet.swift */,
|
54B345AA241BBA5B004C53CC /* AlertSheet.swift */,
|
||||||
54B34595240F0513004C53CC /* TableView.swift */,
|
54448A2F248647D900771C96 /* Time.swift */,
|
||||||
54751E502423955000168273 /* URL.swift */,
|
54751E502423955000168273 /* URL.swift */,
|
||||||
|
54448A2D2486464F00771C96 /* Array.swift */,
|
||||||
|
54D8B97B2471A7E000EB2414 /* String.swift */,
|
||||||
|
54B34595240F0513004C53CC /* TableView.swift */,
|
||||||
545DDDD324466D37003B6544 /* AutoLayout.swift */,
|
545DDDD324466D37003B6544 /* AutoLayout.swift */,
|
||||||
);
|
);
|
||||||
path = Extensions;
|
path = Extensions;
|
||||||
@@ -655,6 +679,18 @@
|
|||||||
path = ProxySocket;
|
path = ProxySocket;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
54E540F0247C386500F7C34A /* Data Source */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
54E540F3247D3F2600F7C34A /* TestDataSource.swift */,
|
||||||
|
54E540F92482414800F7C34A /* SyncUpdate.swift */,
|
||||||
|
54D8B98524796E9800EB2414 /* GroupedDomainDataSource.swift */,
|
||||||
|
54E540F1247C423200F7C34A /* DomainFilter.swift */,
|
||||||
|
54E540F7247DB90F00F7C34A /* RecordingsDB.swift */,
|
||||||
|
);
|
||||||
|
path = "Data Source";
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
@@ -772,33 +808,42 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
54E540F8247DB90F00F7C34A /* RecordingsDB.swift in Sources */,
|
||||||
|
54E540F4247D3F2600F7C34A /* TestDataSource.swift in Sources */,
|
||||||
545DDDD424466D37003B6544 /* AutoLayout.swift in Sources */,
|
545DDDD424466D37003B6544 /* AutoLayout.swift in Sources */,
|
||||||
54B345AD241BBB00004C53CC /* DBExtensions.swift in Sources */,
|
54B345AD241BBB00004C53CC /* DBExtensions.swift in Sources */,
|
||||||
|
54E540F2247C423200F7C34A /* DomainFilter.swift in Sources */,
|
||||||
|
54D8B97E2471B88900EB2414 /* DBCommon.swift in Sources */,
|
||||||
|
54D8B9832471BD8100EB2414 /* DBAppOnly.swift in Sources */,
|
||||||
540E67842433FAFE00871BBE /* TVCPreviousRecords.swift in Sources */,
|
540E67842433FAFE00871BBE /* TVCPreviousRecords.swift in Sources */,
|
||||||
54B345A6241BB982004C53CC /* Notifications.swift in Sources */,
|
54B345A6241BB982004C53CC /* Notifications.swift in Sources */,
|
||||||
|
54448A2E2486464F00771C96 /* Array.swift in Sources */,
|
||||||
54B345AB241BBA5B004C53CC /* AlertSheet.swift in Sources */,
|
54B345AB241BBA5B004C53CC /* AlertSheet.swift in Sources */,
|
||||||
544C95262407B1C700AB89D0 /* SharedState.swift in Sources */,
|
544C95262407B1C700AB89D0 /* SharedState.swift in Sources */,
|
||||||
545DDDCF243E6267003B6544 /* TutorialSheet.swift in Sources */,
|
545DDDCF243E6267003B6544 /* TutorialSheet.swift in Sources */,
|
||||||
54B345A9241BBA0B004C53CC /* Generic.swift in Sources */,
|
54B345A9241BBA0B004C53CC /* Generic.swift in Sources */,
|
||||||
54B34596240F0513004C53CC /* TableView.swift in Sources */,
|
54B34596240F0513004C53CC /* TableView.swift in Sources */,
|
||||||
540E6780242D2CF100871BBE /* VCRecordings.swift in Sources */,
|
540E6780242D2CF100871BBE /* VCRecordings.swift in Sources */,
|
||||||
54953E3323DC752E0054345C /* SQDB.swift in Sources */,
|
54953E3323DC752E0054345C /* DBCore.swift in Sources */,
|
||||||
|
54448A30248647D900771C96 /* Time.swift in Sources */,
|
||||||
54953E6123E0D69A0054345C /* TVCHosts.swift in Sources */,
|
54953E6123E0D69A0054345C /* TVCHosts.swift in Sources */,
|
||||||
540C6457240D929300E948F9 /* EditableRows.swift in Sources */,
|
|
||||||
54751E512423955100168273 /* URL.swift in Sources */,
|
54751E512423955100168273 /* URL.swift in Sources */,
|
||||||
542E2A9A24051556001462DC /* TVCSettings.swift in Sources */,
|
542E2A9A24051556001462DC /* TVCSettings.swift in Sources */,
|
||||||
54953E5F23DEBE840054345C /* TVCDomains.swift in Sources */,
|
54953E5F23DEBE840054345C /* TVCDomains.swift in Sources */,
|
||||||
54C056DB23E9E36E00214A3F /* AppInfoType.swift in Sources */,
|
54C056DB23E9E36E00214A3F /* AppInfoType.swift in Sources */,
|
||||||
|
54D8B98624796E9900EB2414 /* GroupedDomainDataSource.swift in Sources */,
|
||||||
54953E6F23E44CD00054345C /* TVCHostDetails.swift in Sources */,
|
54953E6F23E44CD00054345C /* TVCHostDetails.swift in Sources */,
|
||||||
|
54D8B97C2471A7E000EB2414 /* String.swift in Sources */,
|
||||||
54C056DD23E9EEF700214A3F /* BundleIcon.swift in Sources */,
|
54C056DD23E9EEF700214A3F /* BundleIcon.swift in Sources */,
|
||||||
542E2A982404973F001462DC /* TBCMain.swift in Sources */,
|
542E2A982404973F001462DC /* TBCMain.swift in Sources */,
|
||||||
5458EBC0243A3F2200CFEB15 /* TVCRecordingDetails.swift in Sources */,
|
5458EBC0243A3F2200CFEB15 /* TVCRecordingDetails.swift in Sources */,
|
||||||
5412F8EE24571B8200A63D7A /* VCDateFilter.swift in Sources */,
|
5412F8EE24571B8200A63D7A /* VCDateFilter.swift in Sources */,
|
||||||
545DDDD124436983003B6544 /* QuickUI.swift in Sources */,
|
545DDDD124436983003B6544 /* QuickUI.swift in Sources */,
|
||||||
541AC5D82399498A00A769D7 /* AppDelegate.swift in Sources */,
|
541AC5D82399498A00A769D7 /* AppDelegate.swift in Sources */,
|
||||||
54B345992414F491004C53CC /* DBWrapper.swift in Sources */,
|
|
||||||
540E67822433483D00871BBE /* VCEditRecording.swift in Sources */,
|
540E67822433483D00871BBE /* VCEditRecording.swift in Sources */,
|
||||||
|
54D8B97A246C9F2000EB2414 /* FilterPipeline.swift in Sources */,
|
||||||
54B34594240E6343004C53CC /* TVCFilter.swift in Sources */,
|
54B34594240E6343004C53CC /* TVCFilter.swift in Sources */,
|
||||||
|
54E540FA2482414800F7C34A /* SyncUpdate.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -850,6 +895,7 @@
|
|||||||
54CA02A62426B2FD003A5E04 /* AdapterSocket.swift in Sources */,
|
54CA02A62426B2FD003A5E04 /* AdapterSocket.swift in Sources */,
|
||||||
54CA02742426B2FD003A5E04 /* IPMask.swift in Sources */,
|
54CA02742426B2FD003A5E04 /* IPMask.swift in Sources */,
|
||||||
54CA02BB2426B2FD003A5E04 /* SOCKS5ProxySocket.swift in Sources */,
|
54CA02BB2426B2FD003A5E04 /* SOCKS5ProxySocket.swift in Sources */,
|
||||||
|
54D8B97F2471B89100EB2414 /* DBCommon.swift in Sources */,
|
||||||
54CA02A42426B2FD003A5E04 /* SecureHTTPAdapter.swift in Sources */,
|
54CA02A42426B2FD003A5E04 /* SecureHTTPAdapter.swift in Sources */,
|
||||||
54CA02942426B2FD003A5E04 /* DNSResolver.swift in Sources */,
|
54CA02942426B2FD003A5E04 /* DNSResolver.swift in Sources */,
|
||||||
54CA025F2426B2FD003A5E04 /* ProxyServer.swift in Sources */,
|
54CA025F2426B2FD003A5E04 /* ProxyServer.swift in Sources */,
|
||||||
@@ -881,7 +927,7 @@
|
|||||||
54CA02972426B2FD003A5E04 /* IPPacket.swift in Sources */,
|
54CA02972426B2FD003A5E04 /* IPPacket.swift in Sources */,
|
||||||
54CA026A2426B2FD003A5E04 /* RawSocketFactory.swift in Sources */,
|
54CA026A2426B2FD003A5E04 /* RawSocketFactory.swift in Sources */,
|
||||||
54CA02A02426B2FD003A5E04 /* TunnelEvent.swift in Sources */,
|
54CA02A02426B2FD003A5E04 /* TunnelEvent.swift in Sources */,
|
||||||
546063E523FEFAFE008F505A /* SQDB.swift in Sources */,
|
546063E523FEFAFE008F505A /* DBCore.swift in Sources */,
|
||||||
54CA02872426B2FD003A5E04 /* IPRangeListRule.swift in Sources */,
|
54CA02872426B2FD003A5E04 /* IPRangeListRule.swift in Sources */,
|
||||||
54CA02922426B2FD003A5E04 /* DNSSession.swift in Sources */,
|
54CA02922426B2FD003A5E04 /* DNSSession.swift in Sources */,
|
||||||
54CA026D2426B2FD003A5E04 /* Opt.swift in Sources */,
|
54CA026D2426B2FD003A5E04 /* Opt.swift in Sources */,
|
||||||
|
|||||||
@@ -1,13 +1,47 @@
|
|||||||
import NetworkExtension
|
import NetworkExtension
|
||||||
|
|
||||||
fileprivate var db: SQLiteDatabase?
|
fileprivate var db: SQLiteDatabase!
|
||||||
fileprivate var domainFilters: [String : FilterOptions] = [:]
|
fileprivate var pStmt: OpaquePointer!
|
||||||
|
fileprivate var filterDomains: [String]!
|
||||||
|
fileprivate var filterOptions: [(block: Bool, ignore: Bool)]!
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: Backward DNS Binary Tree Lookup
|
||||||
|
|
||||||
|
fileprivate func reloadDomainFilter() {
|
||||||
|
let tmp = db.loadFilters()?.map({
|
||||||
|
(String($0.reversed()), $1)
|
||||||
|
}).sorted(by: { $0.0 < $1.0 }) ?? []
|
||||||
|
filterDomains = tmp.map { $0.0 }
|
||||||
|
filterOptions = tmp.map { ($1.contains(.blocked), $1.contains(.ignored)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate func filterIndex(for domain: String) -> Int {
|
||||||
|
let reverseDomain = String(domain.reversed())
|
||||||
|
var lo = 0, hi = filterDomains.count - 1
|
||||||
|
while lo <= hi {
|
||||||
|
let mid = (lo + hi)/2
|
||||||
|
if filterDomains[mid] < reverseDomain {
|
||||||
|
lo = mid + 1
|
||||||
|
} else if reverseDomain < filterDomains[mid] {
|
||||||
|
hi = mid - 1
|
||||||
|
} else {
|
||||||
|
return mid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lo > 0, reverseDomain.hasPrefix(filterDomains[lo - 1] + ".") {
|
||||||
|
return lo - 1
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// MARK: ObserverFactory
|
// MARK: ObserverFactory
|
||||||
|
|
||||||
class LDObserverFactory: ObserverFactory {
|
class LDObserverFactory: ObserverFactory {
|
||||||
|
|
||||||
override func getObserverForProxySocket(_ socket: ProxySocket) -> Observer<ProxySocketEvent>? {
|
override func getObserverForProxySocket(_ socket: ProxySocket) -> Observer<ProxySocketEvent>? {
|
||||||
|
// TODO: replace NEKit with custom proxy with minimal footprint
|
||||||
return LDProxySocketObserver()
|
return LDProxySocketObserver()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,66 +49,64 @@ class LDObserverFactory: ObserverFactory {
|
|||||||
override func signal(_ event: ProxySocketEvent) {
|
override func signal(_ event: ProxySocketEvent) {
|
||||||
switch event {
|
switch event {
|
||||||
case .receivedRequest(let session, let socket):
|
case .receivedRequest(let session, let socket):
|
||||||
DDLogDebug("DNS: \(session.host)")
|
let i = filterIndex(for: session.host)
|
||||||
let match = domainFilters.first { session.host == $0.key || session.host.hasSuffix("." + $0.key) }
|
if i >= 0 {
|
||||||
let block = match?.value.contains(.blocked) ?? false
|
let (block, ignore) = filterOptions[i]
|
||||||
let ignore = match?.value.contains(.ignored) ?? false
|
if !ignore { try? db.logWrite(pStmt, session.host, blocked: block) }
|
||||||
if !ignore { try? db?.insertDNSQuery(session.host, blocked: block) }
|
if block { socket.forceDisconnect() }
|
||||||
else { DDLogDebug("ignored") }
|
} else {
|
||||||
if block { DDLogDebug("blocked"); socket.forceDisconnect() }
|
// TODO: disable filter during recordings
|
||||||
|
try? db.logWrite(pStmt, session.host)
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// MARK: NEPacketTunnelProvider
|
// MARK: NEPacketTunnelProvider
|
||||||
|
|
||||||
class PacketTunnelProvider: NEPacketTunnelProvider {
|
class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||||
|
|
||||||
let proxyServerPort: UInt16 = 9090
|
let proxyServerPort: UInt16 = 9090
|
||||||
let proxyServerAddress = "127.0.0.1"
|
let proxyServerAddress = "127.0.0.1"
|
||||||
var proxyServer: GCDHTTPProxyServer!
|
var proxyServer: GCDHTTPProxyServer!
|
||||||
|
|
||||||
func reloadDomainFilter() {
|
override func startTunnel(options: [String : NSObject]?, completionHandler: @escaping (Error?) -> Void) {
|
||||||
domainFilters = db?.loadFilters() ?? [:]
|
|
||||||
}
|
|
||||||
|
|
||||||
override func startTunnel(options: [String : NSObject]?, completionHandler: @escaping (Error?) -> Void) {
|
|
||||||
DDLogVerbose("startTunnel")
|
|
||||||
do {
|
do {
|
||||||
db = try SQLiteDatabase.open()
|
db = try SQLiteDatabase.open()
|
||||||
db!.initScheme()
|
db.initCommonScheme()
|
||||||
|
pStmt = try db.logWritePrepare()
|
||||||
} catch {
|
} catch {
|
||||||
completionHandler(error)
|
completionHandler(error)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if proxyServer != nil {
|
|
||||||
proxyServer.stop()
|
|
||||||
}
|
|
||||||
proxyServer = nil
|
|
||||||
|
|
||||||
reloadDomainFilter()
|
reloadDomainFilter()
|
||||||
|
|
||||||
|
if proxyServer != nil {
|
||||||
|
proxyServer.stop()
|
||||||
|
}
|
||||||
|
proxyServer = nil
|
||||||
|
|
||||||
// Create proxy
|
// Create proxy
|
||||||
let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: proxyServerAddress)
|
let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: proxyServerAddress)
|
||||||
settings.mtu = NSNumber(value: 1500)
|
settings.mtu = NSNumber(value: 1500)
|
||||||
|
|
||||||
let proxySettings = NEProxySettings()
|
let proxySettings = NEProxySettings()
|
||||||
proxySettings.httpEnabled = true;
|
proxySettings.httpEnabled = true;
|
||||||
proxySettings.httpServer = NEProxyServer(address: proxyServerAddress, port: Int(proxyServerPort))
|
proxySettings.httpServer = NEProxyServer(address: proxyServerAddress, port: Int(proxyServerPort))
|
||||||
proxySettings.httpsEnabled = true;
|
proxySettings.httpsEnabled = true;
|
||||||
proxySettings.httpsServer = NEProxyServer(address: proxyServerAddress, port: Int(proxyServerPort))
|
proxySettings.httpsServer = NEProxyServer(address: proxyServerAddress, port: Int(proxyServerPort))
|
||||||
proxySettings.excludeSimpleHostnames = false;
|
proxySettings.excludeSimpleHostnames = false;
|
||||||
proxySettings.exceptionList = []
|
proxySettings.exceptionList = []
|
||||||
proxySettings.matchDomains = [""]
|
proxySettings.matchDomains = [""]
|
||||||
|
|
||||||
settings.dnsSettings = NEDNSSettings(servers: ["127.0.0.1"])
|
settings.dnsSettings = NEDNSSettings(servers: ["127.0.0.1"])
|
||||||
settings.proxySettings = proxySettings;
|
settings.proxySettings = proxySettings;
|
||||||
RawSocketFactory.TunnelProvider = self
|
RawSocketFactory.TunnelProvider = self
|
||||||
ObserverFactory.currentFactory = LDObserverFactory()
|
ObserverFactory.currentFactory = LDObserverFactory()
|
||||||
|
|
||||||
self.setTunnelNetworkSettings(settings) { error in
|
self.setTunnelNetworkSettings(settings) { error in
|
||||||
guard error == nil else {
|
guard error == nil else {
|
||||||
@@ -82,36 +114,38 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
|
|||||||
completionHandler(error)
|
completionHandler(error)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
DDLogVerbose("setTunnelNetworkSettings success \(self.packetFlow)")
|
|
||||||
completionHandler(nil)
|
completionHandler(nil)
|
||||||
|
|
||||||
self.proxyServer = GCDHTTPProxyServer(address: IPAddress(fromString: self.proxyServerAddress), port: Port(port: self.proxyServerPort))
|
self.proxyServer = GCDHTTPProxyServer(address: IPAddress(fromString: self.proxyServerAddress), port: Port(port: self.proxyServerPort))
|
||||||
do {
|
do {
|
||||||
try self.proxyServer.start()
|
try self.proxyServer.start()
|
||||||
completionHandler(nil)
|
completionHandler(nil)
|
||||||
}
|
}
|
||||||
catch let proxyError {
|
catch let proxyError {
|
||||||
DDLogError("Error starting proxy server \(proxyError)")
|
DDLogError("Error starting proxy server \(proxyError)")
|
||||||
completionHandler(proxyError)
|
completionHandler(proxyError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) {
|
override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) {
|
||||||
DDLogVerbose("stopTunnel with reason: \(reason)")
|
DDLogVerbose("stopTunnel with reason: \(reason)")
|
||||||
db = nil
|
|
||||||
DNSServer.currentServer = nil
|
DNSServer.currentServer = nil
|
||||||
RawSocketFactory.TunnelProvider = nil
|
RawSocketFactory.TunnelProvider = nil
|
||||||
ObserverFactory.currentFactory = nil
|
ObserverFactory.currentFactory = nil
|
||||||
proxyServer.stop()
|
proxyServer.stop()
|
||||||
proxyServer = nil
|
proxyServer = nil
|
||||||
completionHandler()
|
db.prepared(finalize: pStmt)
|
||||||
exit(EXIT_SUCCESS)
|
pStmt = nil
|
||||||
}
|
db = nil
|
||||||
|
filterDomains = nil
|
||||||
override func handleAppMessage(_ messageData: Data, completionHandler: ((Data?) -> Void)?) {
|
filterOptions = nil
|
||||||
DDLogVerbose("handleAppMessage")
|
completionHandler()
|
||||||
|
exit(EXIT_SUCCESS)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func handleAppMessage(_ messageData: Data, completionHandler: ((Data?) -> Void)?) {
|
||||||
reloadDomainFilter()
|
reloadDomainFilter()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,16 +14,23 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
UserDefaults.standard.set(false, forKey: "kill_db")
|
UserDefaults.standard.set(false, forKey: "kill_db")
|
||||||
SQLiteDatabase.destroyDatabase()
|
SQLiteDatabase.destroyDatabase()
|
||||||
}
|
}
|
||||||
try? SQLiteDatabase.open().initScheme()
|
if let db = AppDB {
|
||||||
|
db.initCommonScheme()
|
||||||
|
db.initAppOnlyScheme()
|
||||||
|
}
|
||||||
|
|
||||||
DBWrp.initContentOfDB()
|
#if IOS_SIMULATOR
|
||||||
|
TestDataSource.load()
|
||||||
|
#endif
|
||||||
|
|
||||||
loadVPN { mgr in
|
loadVPN { mgr in
|
||||||
self.managerVPN = mgr
|
self.managerVPN = mgr
|
||||||
self.postVPNState()
|
self.postVPNState()
|
||||||
}
|
}
|
||||||
NSNotification.Name.NEVPNStatusDidChange.observe(call: #selector(vpnStatusChanged(_:)), on: self)
|
NSNotification.Name.NEVPNStatusDidChange.observe(call: #selector(vpnStatusChanged(_:)), on: self)
|
||||||
NotifyDNSFilterChanged.observe(call: #selector(filterDidChange), on: self)
|
NotifyDNSFilterChanged.observe(call: #selector(didChangeDomainFilter), on: self)
|
||||||
|
|
||||||
|
sync.start()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +38,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
postRawVPNState((notification.object as? NETunnelProviderSession)?.status ?? .invalid)
|
postRawVPNState((notification.object as? NETunnelProviderSession)?.status ?? .invalid)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func filterDidChange() {
|
@objc private func didChangeDomainFilter() {
|
||||||
// Notify VPN extension about changes
|
// Notify VPN extension about changes
|
||||||
if let session = self.managerVPN?.connection as? NETunnelProviderSession,
|
if let session = self.managerVPN?.connection as? NETunnelProviderSession,
|
||||||
session.status == .connected {
|
session.status == .connected {
|
||||||
|
|||||||
@@ -852,10 +852,60 @@ Duration: 60:00</string>
|
|||||||
</tableViewCell>
|
</tableViewCell>
|
||||||
</cells>
|
</cells>
|
||||||
</tableViewSection>
|
</tableViewSection>
|
||||||
<tableViewSection headerTitle="Other Settings" id="wLR-T2-Qxm">
|
<tableViewSection headerTitle="Reset Settings" id="tBs-BI-JqN">
|
||||||
|
<cells>
|
||||||
|
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="Uii-Jp-53c">
|
||||||
|
<rect key="frame" x="0.0" y="299.5" width="320" height="44"/>
|
||||||
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
|
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="Uii-Jp-53c" id="4Fp-Ox-yrk">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="320" height="44"/>
|
||||||
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
|
<subviews>
|
||||||
|
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="6B5-l4-Hgz">
|
||||||
|
<rect key="frame" x="74.5" y="7" width="171" height="30"/>
|
||||||
|
<fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/>
|
||||||
|
<state key="normal" title="Reset Introduction Alerts"/>
|
||||||
|
<connections>
|
||||||
|
<action selector="resetTutorialAlerts:" destination="qdB-ZO-LHY" eventType="touchUpInside" id="hw8-as-4PZ"/>
|
||||||
|
</connections>
|
||||||
|
</button>
|
||||||
|
</subviews>
|
||||||
|
<constraints>
|
||||||
|
<constraint firstItem="6B5-l4-Hgz" firstAttribute="centerY" secondItem="4Fp-Ox-yrk" secondAttribute="centerY" id="h2Y-P2-Feo"/>
|
||||||
|
<constraint firstItem="6B5-l4-Hgz" firstAttribute="centerX" secondItem="4Fp-Ox-yrk" secondAttribute="centerX" id="jpA-gA-3jY"/>
|
||||||
|
</constraints>
|
||||||
|
</tableViewCellContentView>
|
||||||
|
</tableViewCell>
|
||||||
|
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="Xgc-6Z-IlH">
|
||||||
|
<rect key="frame" x="0.0" y="343.5" width="320" height="44"/>
|
||||||
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
|
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="Xgc-6Z-IlH" id="efR-vn-6MX">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="320" height="44"/>
|
||||||
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
|
<subviews>
|
||||||
|
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="sE3-Vh-0lM">
|
||||||
|
<rect key="frame" x="111.5" y="7" width="97" height="30"/>
|
||||||
|
<fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/>
|
||||||
|
<state key="normal" title="Delete all logs">
|
||||||
|
<color key="titleColor" systemColor="systemRedColor" red="1" green="0.23137254900000001" blue="0.18823529410000001" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
|
</state>
|
||||||
|
<connections>
|
||||||
|
<action selector="clearDatabaseResults:" destination="qdB-ZO-LHY" eventType="touchUpInside" id="adR-Yk-zsB"/>
|
||||||
|
</connections>
|
||||||
|
</button>
|
||||||
|
</subviews>
|
||||||
|
<constraints>
|
||||||
|
<constraint firstItem="sE3-Vh-0lM" firstAttribute="centerX" secondItem="efR-vn-6MX" secondAttribute="centerX" id="TvC-jA-Wp5"/>
|
||||||
|
<constraint firstItem="sE3-Vh-0lM" firstAttribute="centerY" secondItem="efR-vn-6MX" secondAttribute="centerY" id="WoM-cy-cAY"/>
|
||||||
|
</constraints>
|
||||||
|
</tableViewCellContentView>
|
||||||
|
</tableViewCell>
|
||||||
|
</cells>
|
||||||
|
</tableViewSection>
|
||||||
|
<tableViewSection headerTitle="Advanced" id="wLR-T2-Qxm">
|
||||||
<cells>
|
<cells>
|
||||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="Qyy-0U-yhd">
|
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="Qyy-0U-yhd">
|
||||||
<rect key="frame" x="0.0" y="299.5" width="320" height="44"/>
|
<rect key="frame" x="0.0" y="443.5" width="320" height="44"/>
|
||||||
<autoresizingMask key="autoresizingMask"/>
|
<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="Qyy-0U-yhd" id="Mfs-fu-W5k">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="320" height="44"/>
|
<rect key="frame" x="0.0" y="0.0" width="320" height="44"/>
|
||||||
@@ -876,52 +926,6 @@ Duration: 60:00</string>
|
|||||||
</constraints>
|
</constraints>
|
||||||
</tableViewCellContentView>
|
</tableViewCellContentView>
|
||||||
</tableViewCell>
|
</tableViewCell>
|
||||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="wzU-8s-HGb">
|
|
||||||
<rect key="frame" x="0.0" y="343.5" width="320" height="44"/>
|
|
||||||
<autoresizingMask key="autoresizingMask"/>
|
|
||||||
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="wzU-8s-HGb" id="aNM-6U-bho">
|
|
||||||
<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="S6B-i8-CoC">
|
|
||||||
<rect key="frame" x="74.5" y="7" width="171" height="30"/>
|
|
||||||
<fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/>
|
|
||||||
<state key="normal" title="Reset Introduction Alerts"/>
|
|
||||||
<connections>
|
|
||||||
<action selector="resetTutorialAlerts:" destination="qdB-ZO-LHY" eventType="touchUpInside" id="0GX-Ko-bk2"/>
|
|
||||||
</connections>
|
|
||||||
</button>
|
|
||||||
</subviews>
|
|
||||||
<constraints>
|
|
||||||
<constraint firstItem="S6B-i8-CoC" firstAttribute="centerY" secondItem="aNM-6U-bho" secondAttribute="centerY" id="Wet-iT-mke"/>
|
|
||||||
<constraint firstItem="S6B-i8-CoC" firstAttribute="centerX" secondItem="aNM-6U-bho" secondAttribute="centerX" id="qM6-0t-1m4"/>
|
|
||||||
</constraints>
|
|
||||||
</tableViewCellContentView>
|
|
||||||
</tableViewCell>
|
|
||||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="a9C-Qy-pOf">
|
|
||||||
<rect key="frame" x="0.0" y="387.5" width="320" height="44"/>
|
|
||||||
<autoresizingMask key="autoresizingMask"/>
|
|
||||||
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="a9C-Qy-pOf" id="cUk-4x-Weg">
|
|
||||||
<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="17e-nR-aCh">
|
|
||||||
<rect key="frame" x="111.5" y="7" width="97" height="30"/>
|
|
||||||
<fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/>
|
|
||||||
<state key="normal" title="Delete all logs">
|
|
||||||
<color key="titleColor" systemColor="systemRedColor" red="1" green="0.23137254900000001" blue="0.18823529410000001" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
|
||||||
</state>
|
|
||||||
<connections>
|
|
||||||
<action selector="clearDatabaseResults:" destination="qdB-ZO-LHY" eventType="touchUpInside" id="Rep-Do-4OQ"/>
|
|
||||||
</connections>
|
|
||||||
</button>
|
|
||||||
</subviews>
|
|
||||||
<constraints>
|
|
||||||
<constraint firstItem="17e-nR-aCh" firstAttribute="centerX" secondItem="cUk-4x-Weg" secondAttribute="centerX" id="dU5-1x-ETF"/>
|
|
||||||
<constraint firstItem="17e-nR-aCh" firstAttribute="centerY" secondItem="cUk-4x-Weg" secondAttribute="centerY" id="nLq-yi-u2E"/>
|
|
||||||
</constraints>
|
|
||||||
</tableViewCellContentView>
|
|
||||||
</tableViewCell>
|
|
||||||
</cells>
|
</cells>
|
||||||
</tableViewSection>
|
</tableViewSection>
|
||||||
</sections>
|
</sections>
|
||||||
|
|||||||
@@ -1,147 +0,0 @@
|
|||||||
import UIKit
|
|
||||||
|
|
||||||
public enum RowAction {
|
|
||||||
case ignore, block, delete
|
|
||||||
// static let all: [RowAction] = [.ignore, .block, .delete]
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Generic
|
|
||||||
|
|
||||||
protocol EditableRows {
|
|
||||||
func editableRowUserInfo(_ index: IndexPath) -> Any?
|
|
||||||
func editableRowActions(_ index: IndexPath) -> [(RowAction, String)]
|
|
||||||
func editableRowActionColor(_ index: IndexPath, _ action: RowAction) -> UIColor?
|
|
||||||
@discardableResult func editableRowCallback(_ atIndexPath: IndexPath, _ action: RowAction, _ userInfo: Any?) -> Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
extension EditableRows where Self: UITableViewController {
|
|
||||||
fileprivate func getRowActionsIOS9(_ index: IndexPath) -> [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) }
|
|
||||||
if let color = editableRowActionColor(index, a) {
|
|
||||||
x.backgroundColor = color
|
|
||||||
}
|
|
||||||
return x
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@available(iOS 11.0, *)
|
|
||||||
fileprivate func getRowActionsIOS11(_ index: IndexPath) -> UISwipeActionsConfiguration? {
|
|
||||||
let userInfo = editableRowUserInfo(index)
|
|
||||||
return UISwipeActionsConfiguration(actions: editableRowActions(index).compactMap { a,t in
|
|
||||||
let x = UIContextualAction(style: a == .delete ? .destructive : .normal, title: t) { $2(self.editableRowCallback(index, a, userInfo)) }
|
|
||||||
x.backgroundColor = editableRowActionColor(index, a)
|
|
||||||
return x
|
|
||||||
})
|
|
||||||
}
|
|
||||||
func editableRowUserInfo(_ index: IndexPath) -> Any? { nil }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Edit Ignore-Block-Delete
|
|
||||||
|
|
||||||
protocol EditActionsIgnoreBlockDelete : EditableRows {
|
|
||||||
var dataSource: [GroupedDomain] { get set }
|
|
||||||
}
|
|
||||||
extension EditActionsIgnoreBlockDelete where Self: UITableViewController {
|
|
||||||
func editableRowActions(_ index: IndexPath) -> [(RowAction, String)] {
|
|
||||||
let x = dataSource[index.row]
|
|
||||||
if x.domain.starts(with: "#") {
|
|
||||||
return [(.delete, "Delete")]
|
|
||||||
}
|
|
||||||
let b = x.options?.contains(.blocked) ?? false
|
|
||||||
let i = x.options?.contains(.ignored) ?? false
|
|
||||||
return [(.delete, "Delete"), (.block, b ? "Unblock" : "Block"), (.ignore, i ? "Unignore" : "Ignore")]
|
|
||||||
}
|
|
||||||
|
|
||||||
func editableRowActionColor(_: IndexPath, _ action: RowAction) -> UIColor? {
|
|
||||||
action == .block ? .systemOrange : nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func editableRowUserInfo(_ index: IndexPath) -> Any? { dataSource[index.row] }
|
|
||||||
|
|
||||||
func editableRowCallback(_ index: IndexPath, _ action: RowAction, _ userInfo: Any?) -> Bool {
|
|
||||||
let entry = userInfo as! GroupedDomain
|
|
||||||
switch action {
|
|
||||||
case .ignore: showFilterSheet(entry, .ignored)
|
|
||||||
case .block: showFilterSheet(entry, .blocked)
|
|
||||||
case .delete:
|
|
||||||
AlertDeleteLogs(entry.domain, latest: entry.lastModified) {
|
|
||||||
DBWrp.deleteHistory(domain: entry.domain, since: $0)
|
|
||||||
}.presentIn(self)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private func showFilterSheet(_ entry: GroupedDomain, _ filter: FilterOptions) {
|
|
||||||
if entry.options?.contains(filter) ?? false {
|
|
||||||
DBWrp.updateFilter(entry.domain, remove: filter)
|
|
||||||
} else {
|
|
||||||
// TODO: alert sheet
|
|
||||||
DBWrp.updateFilter(entry.domain, add: filter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Extensions
|
|
||||||
extension TVCDomains : EditActionsIgnoreBlockDelete {
|
|
||||||
override func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
|
|
||||||
getRowActionsIOS9(indexPath)
|
|
||||||
}
|
|
||||||
@available(iOS 11.0, *)
|
|
||||||
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
|
|
||||||
getRowActionsIOS11(indexPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension TVCHosts : EditActionsIgnoreBlockDelete {
|
|
||||||
override func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
|
|
||||||
getRowActionsIOS9(indexPath)
|
|
||||||
}
|
|
||||||
@available(iOS 11.0, *)
|
|
||||||
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
|
|
||||||
getRowActionsIOS11(indexPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Edit Remove
|
|
||||||
|
|
||||||
protocol EditActionsRemove : EditableRows {}
|
|
||||||
extension EditActionsRemove where Self: UITableViewController {
|
|
||||||
func editableRowActions(_: IndexPath) -> [(RowAction, String)] { [(.delete, "Remove")] }
|
|
||||||
func editableRowActionColor(_: IndexPath, _: RowAction) -> UIColor? { nil }
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Extensions
|
|
||||||
extension TVCFilter : EditableRows {
|
|
||||||
override func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
|
|
||||||
getRowActionsIOS9(indexPath)
|
|
||||||
}
|
|
||||||
@available(iOS 11.0, *)
|
|
||||||
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
|
|
||||||
getRowActionsIOS11(indexPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension TVCPreviousRecords : EditableRows {
|
|
||||||
override func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
|
|
||||||
getRowActionsIOS9(indexPath)
|
|
||||||
}
|
|
||||||
@available(iOS 11.0, *)
|
|
||||||
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
|
|
||||||
getRowActionsIOS11(indexPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension TVCRecordingDetails : EditableRows {
|
|
||||||
override func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
|
|
||||||
getRowActionsIOS9(indexPath)
|
|
||||||
}
|
|
||||||
@available(iOS 11.0, *)
|
|
||||||
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
|
|
||||||
getRowActionsIOS11(indexPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
416
main/Common Classes/FilterPipeline.swift
Normal file
416
main/Common Classes/FilterPipeline.swift
Normal file
@@ -0,0 +1,416 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
protocol FilterPipelineDelegate: UITableViewController {
|
||||||
|
/// Currently only called when a row is moved and the `tableView` is frontmost.
|
||||||
|
func rowNeedsUpdate(_ row: Int)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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?
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// - Returns: Number of elements in `projection`
|
||||||
|
@inline(__always) func displayObjectCount() -> Int { display.projection.count }
|
||||||
|
|
||||||
|
/// Dereference `projection` index to `dataSource` index
|
||||||
|
/// - Complexity: O(1)
|
||||||
|
@inline(__always) func displayObject(at index: Int) -> T { dataSource[display.projection[index]] }
|
||||||
|
|
||||||
|
/// Search and return first element in `dataSource` that matches `predicate`.
|
||||||
|
/// - Returns: Index in `dataSource` and found object or `nil` if no matching item found.
|
||||||
|
/// - Complexity: O(*n*), where *n* is the length of the `dataSource`.
|
||||||
|
func dataSourceGet(where predicate: ((T) -> Bool)) -> (index: Int, object: T)? {
|
||||||
|
guard let i = dataSource.firstIndex(where: predicate) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the index set of either the last filter layer, or `dataSource` if no filter is set yet.
|
||||||
|
fileprivate func lastFilterLayerIndices() -> [Int] {
|
||||||
|
pipeline.last?.selection ?? dataSource.indices.arr()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get pipeline index of filter with given identifier
|
||||||
|
private func indexOfFilter(_ identifier: String) -> Int? {
|
||||||
|
pipeline.firstIndex(where: {$0.id == identifier})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: manage pipeline
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
/// - 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) {
|
||||||
|
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)
|
||||||
|
pipeline.append(newFilter)
|
||||||
|
display?.apply(moreRestrictive: newFilter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find and remove filter with given identifier. Will automatically update remaining filters and display sorting.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start filter evaluation on all entries from previous filter.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the sort and display order. You should set the `delegate` to automatically update your `tableView`.
|
||||||
|
/// - 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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)
|
||||||
|
}
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push object through filter pipeline to check whether it survives all filters.
|
||||||
|
/// - Parameter index: The index of the object in the original `dataSource`
|
||||||
|
/// - Returns: `changed` is `true` if element persists or should be removed with this update.
|
||||||
|
/// `display` indicates whther element should be shown (`true`) or hidden (`false`).
|
||||||
|
/// - Complexity: O(*m* log *n*), where *m* is the number of filters and *n* the number of elements in each filter.
|
||||||
|
private func processPipeline(with obj: T, at index: Int) -> (changed: Bool, display: Bool) {
|
||||||
|
var keepGoing = true
|
||||||
|
for filter in pipeline {
|
||||||
|
let lastIndex: Int?
|
||||||
|
if keepGoing {
|
||||||
|
(keepGoing, lastIndex) = filter.update(obj, at: index)
|
||||||
|
} else {
|
||||||
|
lastIndex = filter.remove(dataSource: index)
|
||||||
|
}
|
||||||
|
// if it isnt in this layer, it wont appear in the following either
|
||||||
|
if lastIndex == nil { return (false, false) }
|
||||||
|
}
|
||||||
|
return (true, keepGoing)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: data updates
|
||||||
|
|
||||||
|
/// Add new element to the original `dataSource` and immediately apply filter and sorting.
|
||||||
|
/// - 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
|
||||||
|
dataSource.append(obj)
|
||||||
|
for filter in pipeline {
|
||||||
|
if filter.add(obj, at: index) == nil { return }
|
||||||
|
}
|
||||||
|
// survived all filters
|
||||||
|
let displayIndex = display.insertNew(index)
|
||||||
|
delegate?.tableView.safeInsertRow(displayIndex, with: .left)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update element at `index` in the original `dataSource` and immediately re-apply filter and sorting.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - obj: Element to be added. Will overwrite previous `dataSource` object.
|
||||||
|
/// - index: Index in the original `dataSource`
|
||||||
|
/// - Complexity: O(*n* + (*m*+1) log *n*), where *m* is the number of filters and *n* the number of elements in each filter / projection.
|
||||||
|
func update(_ obj: T, at index: Int) {
|
||||||
|
let status = processPipeline(with: obj, at: index)
|
||||||
|
guard status.changed else { return }
|
||||||
|
let oldPos = display.deleteOld(index)
|
||||||
|
dataSource[index] = obj
|
||||||
|
guard status.display else {
|
||||||
|
if let old = oldPos { delegate?.tableView.safeDeleteRows([old]) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let newPos = display.insertNew(index)
|
||||||
|
if let old = oldPos {
|
||||||
|
if old == newPos {
|
||||||
|
delegate?.tableView.safeReloadRow(old)
|
||||||
|
} else {
|
||||||
|
delegate?.tableView.safeMoveRow(old, to: newPos)
|
||||||
|
if delegate?.tableView.isFrontmost ?? false {
|
||||||
|
delegate?.rowNeedsUpdate(newPos)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
delegate?.tableView.safeInsertRow(newPos, with: .left)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove elements from the original `dataSource`, from all filters, and from display sorting.
|
||||||
|
/// - 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.
|
||||||
|
func remove(indices sorted: [Int]) {
|
||||||
|
guard sorted.count > 0 else { return }
|
||||||
|
for i in sorted.reversed() {
|
||||||
|
dataSource.remove(at: i)
|
||||||
|
}
|
||||||
|
for filter in pipeline {
|
||||||
|
filter.shiftRemove(indices: sorted)
|
||||||
|
}
|
||||||
|
let indices = display.shiftRemove(indices: sorted)
|
||||||
|
delegate?.tableView.safeDeleteRows(indices)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Filter
|
||||||
|
|
||||||
|
class PipelineFilter<T> {
|
||||||
|
typealias Predicate = (T) -> Bool
|
||||||
|
|
||||||
|
let id: String
|
||||||
|
private(set) var selection: [Int] = []
|
||||||
|
private let shouldPersist: Predicate
|
||||||
|
|
||||||
|
/// - Parameter predicate: Return `true` if you want to keep the element
|
||||||
|
required init(_ identifier: String, _ predicate: @escaping Predicate) {
|
||||||
|
self.id = identifier
|
||||||
|
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()
|
||||||
|
selection.removeAll { !shouldPersist(dataSource[$0]) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply filter to `obj` and either insert or do nothing.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - obj: Object that should be inserted if filter allows.
|
||||||
|
/// - index: Index of object in original `dataSource`
|
||||||
|
/// - Returns: Index in `selection` or `nil` if `obj` is removed by the filter.
|
||||||
|
/// - Complexity:
|
||||||
|
/// * O(1), if `index` is appended at end.
|
||||||
|
/// * O(log *n*), where *n* is the length of the `selection`.
|
||||||
|
fileprivate func add(_ obj: T, at index: Int) -> Int? {
|
||||||
|
guard shouldPersist(obj) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if selection.last ?? 0 < index { // in case we only append at end
|
||||||
|
selection.append(index)
|
||||||
|
return selection.count - 1
|
||||||
|
}
|
||||||
|
return selection.binTreeInsert(index, compare: (<))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search and remove original `dataSource` index
|
||||||
|
/// - Parameter index: Index of object in original `dataSource`
|
||||||
|
/// - Returns: Index of removed element in `selection` or `nil` if element does not exist
|
||||||
|
/// - Complexity: O(log *n*), where *n* is the length of the `selection`.
|
||||||
|
fileprivate func remove(dataSource index: Int) -> Int? {
|
||||||
|
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.
|
||||||
|
/// - index: Index where the object is located after the update.
|
||||||
|
/// - Returns: `keep` indicates whether the value should be displayed (`true`) or hidden (`false`).
|
||||||
|
/// `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)
|
||||||
|
if shouldPersist(obj) {
|
||||||
|
return (true, currentIndex ?? selection.binTreeInsert(index, compare: (<)))
|
||||||
|
}
|
||||||
|
if let i = currentIndex { selection.remove(at: i) }
|
||||||
|
return (false, currentIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Instead of re-sorting we can decrement all remaining elements after X.
|
||||||
|
/// - Parameter sorted: Elements to remove from collection
|
||||||
|
/// - Complexity: O(*m*+*n*), where *m* is the length of the `selection`.
|
||||||
|
/// *n* is equal to: *length of selection* `-` *index of first element* of `sorted` indices
|
||||||
|
fileprivate func shiftRemove(indices sorted: [Int]) {
|
||||||
|
guard sorted.count > 0 else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var list = sorted
|
||||||
|
var del = list.popLast()
|
||||||
|
for (i, val) in selection.enumerated().reversed() {
|
||||||
|
while let d = del, d > val {
|
||||||
|
del = list.popLast()
|
||||||
|
}
|
||||||
|
guard let d = del else { break }
|
||||||
|
if d < val { selection[i] -= (list.count + 1) }
|
||||||
|
else if d == val { selection.remove(at: i) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Sorting
|
||||||
|
|
||||||
|
class PipelineSorting<T> {
|
||||||
|
typealias Predicate = (T, T) -> Bool
|
||||||
|
|
||||||
|
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.
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a new layer of filtering. Every 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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a layer of filtering. Previous layers are less restrictive and 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add new element and automatically sort according to predicate
|
||||||
|
/// - Parameter index: Index of the element position in the original `dataSource`
|
||||||
|
/// - Returns: Index in the projection
|
||||||
|
/// - Complexity: O(log *n*), where *n* is the length of the `projection`.
|
||||||
|
@discardableResult fileprivate func insertNew(_ index: Int) -> Int {
|
||||||
|
projection.binTreeInsert(index, compare: comperator)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove element from projection
|
||||||
|
/// - Parameter index: Index of the element position in the original `dataSource`
|
||||||
|
/// - Returns: Index in the projection or `nil` if element did not exist
|
||||||
|
/// - Complexity: O(*n*), where *n* is the length of the `projection`.
|
||||||
|
fileprivate func deleteOld(_ index: Int) -> Int? {
|
||||||
|
guard let i = projection.firstIndex(of: index) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
projection.remove(at: i)
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Instead of re-sorting we can decrement all remaining elements after X.
|
||||||
|
/// - Parameter sorted: Elements to remove from collection
|
||||||
|
/// - Returns: List of `projection` indices that were removed (reverse sort order)
|
||||||
|
/// - Complexity: O(*m* log *n*), where *m* is the length of the `projection` and *n* is the length of `sorted`.
|
||||||
|
@discardableResult fileprivate func shiftRemove(indices sorted: [Int]) -> [Int] {
|
||||||
|
guard sorted.count > 0 else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
var listOfDeletes: [Int] = []
|
||||||
|
let min = sorted.first!, max = sorted.last!
|
||||||
|
for (i, val) in projection.enumerated().reversed() {
|
||||||
|
guard val >= min else { continue }
|
||||||
|
if val > max {
|
||||||
|
projection[i] -= sorted.count
|
||||||
|
} else {
|
||||||
|
let c = sorted.binTreeIndex(of: val, compare: (<))!
|
||||||
|
if val == sorted[c] {
|
||||||
|
projection.remove(at: i)
|
||||||
|
listOfDeletes.append(i)
|
||||||
|
} else {
|
||||||
|
projection[i] -= c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return listOfDeletes
|
||||||
|
}
|
||||||
|
}
|
||||||
386
main/DB/DBAppOnly.swift
Normal file
386
main/DB/DBAppOnly.swift
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
import Foundation
|
||||||
|
import SQLite3
|
||||||
|
|
||||||
|
typealias Timestamp = sqlite3_int64
|
||||||
|
|
||||||
|
extension SQLiteDatabase {
|
||||||
|
func initAppOnlyScheme() {
|
||||||
|
try? run(sql: CreateTable.heap)
|
||||||
|
try? run(sql: CreateTable.rec)
|
||||||
|
try? run(sql: CreateTable.recLog)
|
||||||
|
do {
|
||||||
|
try migrateDB()
|
||||||
|
} catch {
|
||||||
|
QLog.Error("during migration: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrateDB() throws {
|
||||||
|
let version = try run(sql: "PRAGMA user_version;") { stmt -> Int32 in
|
||||||
|
try ifStep(stmt, SQLITE_ROW)
|
||||||
|
return sqlite3_column_int(stmt, 0)
|
||||||
|
}
|
||||||
|
if version != 1 {
|
||||||
|
// version 0 -> 1: req(domain) -> heap(fqdn, domain)
|
||||||
|
if version == 0 {
|
||||||
|
try tempMigrate()
|
||||||
|
}
|
||||||
|
try run(sql: "PRAGMA user_version = 1;")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tempMigrate() throws { // TODO: remove with next internal release
|
||||||
|
do {
|
||||||
|
try run(sql: "SELECT 1 FROM req LIMIT 1;") // fails if req doesnt exist
|
||||||
|
createFunction("domainof") { ($0.first as! String).extractDomain() }
|
||||||
|
try run(sql: """
|
||||||
|
BEGIN TRANSACTION;
|
||||||
|
INSERT INTO heap(ts,fqdn,domain,opt) SELECT ts,domain,domainof(domain),nullif(logOpt,0) FROM req;
|
||||||
|
DROP TABLE req;
|
||||||
|
COMMIT;
|
||||||
|
""")
|
||||||
|
} catch { /* no need to migrate */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum TableName: String {
|
||||||
|
case heap, cache
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SQLiteDatabase {
|
||||||
|
fileprivate func lastRowId(_ table: TableName) -> SQLiteRowID {
|
||||||
|
(try? run(sql:"SELECT rowid FROM \(table.rawValue) ORDER BY rowid DESC LIMIT 1;") {
|
||||||
|
try ifStep($0, SQLITE_ROW)
|
||||||
|
return sqlite3_column_int64($0, 0)
|
||||||
|
}) ?? 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct 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 ...) {
|
||||||
|
description.append((description=="" ? prefix : " AND ") + clause)
|
||||||
|
bindings.append(contentsOf: bind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - DNSLog
|
||||||
|
|
||||||
|
extension CreateTable {
|
||||||
|
/// `ts`: Timestamp, `fqdn`: String, `domain`: String, `opt`: Int
|
||||||
|
static var heap: String {"""
|
||||||
|
CREATE TABLE IF NOT EXISTS heap(
|
||||||
|
ts INTEGER DEFAULT (strftime('%s','now')),
|
||||||
|
fqdn TEXT NOT NULL,
|
||||||
|
domain TEXT NOT NULL,
|
||||||
|
opt INTEGER
|
||||||
|
);
|
||||||
|
"""} // opt currently only used as "blocked" flag
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GroupedDomain {
|
||||||
|
let domain: String, total: Int32, blocked: Int32, lastModified: Timestamp
|
||||||
|
var options: FilterOptions? = nil
|
||||||
|
}
|
||||||
|
typealias GroupedTsOccurrence = (ts: Timestamp, total: Int32, blocked: Int32)
|
||||||
|
|
||||||
|
extension SQLiteDatabase {
|
||||||
|
|
||||||
|
// MARK: write
|
||||||
|
|
||||||
|
/// Move newest entries from `cache` to `heap` and return range (in `heap`) of newly inserted entries.
|
||||||
|
/// - Returns: `nil` in case no entries were transmitted.
|
||||||
|
@discardableResult func dnsLogsPersist() -> SQLiteRowRange? {
|
||||||
|
guard lastRowId(.cache) > 0 else { return nil }
|
||||||
|
let before = lastRowId(.heap) + 1
|
||||||
|
createFunction("domainof") { ($0.first as! String).extractDomain() }
|
||||||
|
try? run(sql:"""
|
||||||
|
BEGIN TRANSACTION;
|
||||||
|
INSERT INTO heap(ts,fqdn,domain,opt) SELECT ts,dns,domainof(dns),nullif(opt&1,0) FROM cache;
|
||||||
|
DELETE FROM cache;
|
||||||
|
COMMIT;
|
||||||
|
""")
|
||||||
|
let after = lastRowId(.heap)
|
||||||
|
return (before > after) ? nil : (before, after)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `DELETE FROM heap; DELETE FROM cache;`
|
||||||
|
func dnsLogsDeleteAll() throws {
|
||||||
|
try? run(sql: "DELETE FROM heap; DELETE FROM cache;")
|
||||||
|
vacuum()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete rows matching `ts >= ? AND domain = ?`
|
||||||
|
/// - 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)) }
|
||||||
|
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)
|
||||||
|
return numberOfChanges
|
||||||
|
}) ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)]) {
|
||||||
|
try ifStep($0, SQLITE_ROW)
|
||||||
|
let max = sqlite3_column_int64($0, 1)
|
||||||
|
return (max == 0) ? nil : (sqlite3_column_int64($0, 0), max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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)) }
|
||||||
|
let col: String // fqdn or domain
|
||||||
|
if let parent = parentDomain { // is subdomain
|
||||||
|
col = "fqdn"
|
||||||
|
Where.and("domain = ?", BindText(parent))
|
||||||
|
} else {
|
||||||
|
col = "domain"
|
||||||
|
}
|
||||||
|
if let matching = matchingDomain { // (fqdn = ? OR fqdn LIKE '%.' || ?)
|
||||||
|
Where.and("\(col) = ?", BindText(matching))
|
||||||
|
}
|
||||||
|
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) ?? "",
|
||||||
|
total: sqlite3_column_int($0, 1),
|
||||||
|
blocked: sqlite3_column_int($0, 2),
|
||||||
|
lastModified: sqlite3_column_int64($0, 3))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get list or individual DNS entries. Mutliple entries in the very same second are grouped.
|
||||||
|
/// - 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))
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Recordings
|
||||||
|
|
||||||
|
extension CreateTable {
|
||||||
|
/// `id`: Primary, `start`: Timestamp, `stop`: Timestamp, `appid`: String, `title`: String, `notes`: String
|
||||||
|
static var rec: String {"""
|
||||||
|
CREATE TABLE IF NOT EXISTS rec(
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
start INTEGER DEFAULT (strftime('%s','now')),
|
||||||
|
stop INTEGER,
|
||||||
|
appid TEXT,
|
||||||
|
title TEXT,
|
||||||
|
notes TEXT
|
||||||
|
);
|
||||||
|
"""}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Recording {
|
||||||
|
let id: sqlite3_int64
|
||||||
|
let start: Timestamp
|
||||||
|
let stop: Timestamp?
|
||||||
|
var appId: String? = nil
|
||||||
|
var title: String? = nil
|
||||||
|
var notes: String? = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SQLiteDatabase {
|
||||||
|
|
||||||
|
// MARK: write
|
||||||
|
|
||||||
|
/// Create new recording with `stop` set to `NULL`.
|
||||||
|
func recordingStartNew() throws -> Recording {
|
||||||
|
try run(sql: "INSERT INTO rec (stop) VALUES (NULL);") { stmt -> Recording in
|
||||||
|
try ifStep(stmt, SQLITE_DONE)
|
||||||
|
return try recordingGet(withID: lastInsertedRow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update given recording by setting `stop` to current time.
|
||||||
|
func recordingStop(_ r: inout Recording) {
|
||||||
|
guard r.stop == nil else { return }
|
||||||
|
let theID = r.id
|
||||||
|
try? run(sql: "UPDATE rec SET stop = (strftime('%s','now')) WHERE id = ? LIMIT 1;",
|
||||||
|
bind: [BindInt64(theID)]) { stmt -> Void in
|
||||||
|
try ifStep(stmt, SQLITE_DONE)
|
||||||
|
r = try recordingGet(withID: theID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update given recording by replacing `title`, `appid`, and `notes` with new values.
|
||||||
|
func recordingUpdate(_ r: Recording) {
|
||||||
|
try? run(sql: "UPDATE rec SET title = ?, appid = ?, notes = ? WHERE id = ? LIMIT 1;",
|
||||||
|
bind: [BindTextOrNil(r.title), BindTextOrNil(r.appId), BindTextOrNil(r.notes), BindInt64(r.id)]) { stmt -> Void in
|
||||||
|
sqlite3_step(stmt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete recording and all of its entries.
|
||||||
|
/// - Returns: `true` on success
|
||||||
|
func recordingDelete(_ r: Recording) throws -> Bool {
|
||||||
|
_ = try? recordingLogsDelete(r.id)
|
||||||
|
return try run(sql: "DELETE FROM rec WHERE id = ? LIMIT 1;", bind: [BindInt64(r.id)]) {
|
||||||
|
try ifStep($0, SQLITE_DONE)
|
||||||
|
return numberOfChanges > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: read
|
||||||
|
|
||||||
|
private func readRecording(_ stmt: OpaquePointer) -> Recording {
|
||||||
|
let end = sqlite3_column_int64(stmt, 2)
|
||||||
|
return Recording(id: sqlite3_column_int64(stmt, 0),
|
||||||
|
start: sqlite3_column_int64(stmt, 1),
|
||||||
|
stop: end == 0 ? nil : end,
|
||||||
|
appId: readText(stmt, 3),
|
||||||
|
title: readText(stmt, 4),
|
||||||
|
notes: readText(stmt, 5))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `WHERE stop IS NULL`
|
||||||
|
func recordingGetOngoing() -> Recording? {
|
||||||
|
try? run(sql: "SELECT * FROM rec WHERE stop IS NULL LIMIT 1;") {
|
||||||
|
try ifStep($0, SQLITE_ROW)
|
||||||
|
return readRecording($0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `WHERE stop IS NOT NULL`
|
||||||
|
func recordingGetAll() -> [Recording]? {
|
||||||
|
try? run(sql: "SELECT * FROM rec WHERE stop IS NOT NULL;") {
|
||||||
|
allRows($0) { readRecording($0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `WHERE id = ?`
|
||||||
|
private func recordingGet(withID: sqlite3_int64) throws -> Recording {
|
||||||
|
try run(sql: "SELECT * FROM rec WHERE id = ? LIMIT 1;", bind: [BindInt64(withID)]) {
|
||||||
|
try ifStep($0, SQLITE_ROW)
|
||||||
|
return readRecording($0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - RecordingLog
|
||||||
|
|
||||||
|
extension CreateTable {
|
||||||
|
/// `rid`: Reference `rec(id)`, `ts`: Timestamp, `domain`: String
|
||||||
|
static var recLog: String {"""
|
||||||
|
CREATE TABLE IF NOT EXISTS recLog(
|
||||||
|
rid INTEGER REFERENCES rec(id) ON DELETE CASCADE,
|
||||||
|
ts INTEGER,
|
||||||
|
domain TEXT
|
||||||
|
);
|
||||||
|
"""}
|
||||||
|
}
|
||||||
|
|
||||||
|
typealias RecordLog = (domain: String, count: Int32)
|
||||||
|
|
||||||
|
extension SQLiteDatabase {
|
||||||
|
|
||||||
|
// MARK: write
|
||||||
|
|
||||||
|
/// Duplicate and copy all log entries for given recording to `recLog` table
|
||||||
|
func recordingLogsPersist(_ r: Recording) {
|
||||||
|
guard let end = r.stop else { return }
|
||||||
|
// TODO: make sure cache entries get copied too.
|
||||||
|
// either by copying them directly from cache or perform sync first
|
||||||
|
try? run(sql: """
|
||||||
|
INSERT INTO recLog (rid, ts, domain) SELECT ?, ts, fqdn FROM heap
|
||||||
|
WHERE heap.ts >= ? AND heap.ts <= ?
|
||||||
|
""", bind: [BindInt64(r.id), BindInt64(r.start), BindInt64(end)]) {
|
||||||
|
try ifStep($0, SQLITE_DONE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete all log entries with given recording id. Optional: only delete entries for a single domain
|
||||||
|
/// - Parameter d: If `nil` remove all entries for given recording
|
||||||
|
/// - Returns: Number of deleted rows
|
||||||
|
func recordingLogsDelete(_ recId: sqlite3_int64, matchingDomain d: String? = nil) throws -> Int32 {
|
||||||
|
try run(sql: "DELETE FROM recLog WHERE rid = ? \(d==nil ? "" : "AND domain = ?");",
|
||||||
|
bind: [BindInt64(recId), d==nil ? nil : BindText(d!)]) {
|
||||||
|
try ifStep($0, SQLITE_DONE)
|
||||||
|
return numberOfChanges
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;",
|
||||||
|
bind: [BindInt64(r.id)]) {
|
||||||
|
allRows($0) { (readText($0, 0) ?? "", sqlite3_column_int($0, 1)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - DBSettings
|
||||||
|
|
||||||
|
//extension CreateTable {
|
||||||
|
// static var settings: String {
|
||||||
|
// "CREATE TABLE IF NOT EXISTS settings(key TEXT UNIQUE NOT NULL, val TEXT);"
|
||||||
|
// }
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//extension SQLiteDatabase {
|
||||||
|
// func getSetting(for key: String) -> String? {
|
||||||
|
// try? run(sql: "SELECT val FROM settings WHERE key = ?;",
|
||||||
|
// bind: [BindText(key)]) { readText($0, 0) }
|
||||||
|
// }
|
||||||
|
// func setSetting(_ value: String?, for key: String) {
|
||||||
|
// if let value = value {
|
||||||
|
// try? run(sql: "INSERT OR REPLACE INTO settings (key, val) VALUES (?, ?);",
|
||||||
|
// bind: [BindText(value), BindText(key)]) { step($0) }
|
||||||
|
// } else {
|
||||||
|
// try? run(sql: "DELETE FROM settings WHERE key = ?;",
|
||||||
|
// bind: [BindText(key)]) { step($0) }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//}
|
||||||
88
main/DB/DBCommon.swift
Normal file
88
main/DB/DBCommon.swift
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import Foundation
|
||||||
|
import SQLite3
|
||||||
|
|
||||||
|
enum CreateTable {} // used for CREATE TABLE statements
|
||||||
|
|
||||||
|
extension SQLiteDatabase {
|
||||||
|
func initCommonScheme() {
|
||||||
|
try? run(sql: CreateTable.cache)
|
||||||
|
try? run(sql: CreateTable.filter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - transit
|
||||||
|
|
||||||
|
extension CreateTable {
|
||||||
|
/// `ts`: Timestamp, `dns`: String, `opt`: Int
|
||||||
|
static var cache: String {"""
|
||||||
|
CREATE TABLE IF NOT EXISTS cache(
|
||||||
|
ts INTEGER DEFAULT (strftime('%s','now')),
|
||||||
|
dns TEXT NOT NULL,
|
||||||
|
opt INTEGER
|
||||||
|
);
|
||||||
|
"""}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - filter
|
||||||
|
|
||||||
|
extension CreateTable {
|
||||||
|
/// `domain`: String, `opt`: Int
|
||||||
|
static var filter: String {"""
|
||||||
|
CREATE TABLE IF NOT EXISTS filter(
|
||||||
|
domain TEXT UNIQUE NOT NULL,
|
||||||
|
opt INTEGER
|
||||||
|
);
|
||||||
|
"""}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FilterOptions: OptionSet {
|
||||||
|
let rawValue: Int32
|
||||||
|
static let none = FilterOptions([])
|
||||||
|
static let blocked = FilterOptions(rawValue: 1 << 0)
|
||||||
|
static let ignored = FilterOptions(rawValue: 1 << 1)
|
||||||
|
static let any = FilterOptions(rawValue: 0b11)
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SQLiteDatabase {
|
||||||
|
func loadFilters(where matching: FilterOptions? = nil) -> [String : FilterOptions]? {
|
||||||
|
let rv = matching?.rawValue ?? 0
|
||||||
|
return try? run(sql: "SELECT domain, opt FROM filter \(rv>0 ? "WHERE opt & ?" : "");",
|
||||||
|
bind: rv>0 ? [BindInt32(rv)] : []) {
|
||||||
|
allRowsKeyed($0) {
|
||||||
|
(key: readText($0, 0) ?? "",
|
||||||
|
value: FilterOptions(rawValue: sqlite3_column_int($0, 1)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func setFilter(_ domain: String, _ value: FilterOptions?) {
|
||||||
|
if let rv = value?.rawValue, rv > 0 {
|
||||||
|
try? run(sql: "INSERT OR REPLACE INTO filter (domain, opt) VALUES (?, ?);",
|
||||||
|
bind: [BindText(domain), BindInt32(rv)]) { _ = sqlite3_step($0) }
|
||||||
|
} else {
|
||||||
|
try? run(sql: "DELETE FROM filter WHERE domain = ? LIMIT 1;",
|
||||||
|
bind: [BindText(domain)]) { _ = sqlite3_step($0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// func loadFilterCount() -> (blocked: Int32, ignored: Int32)? {
|
||||||
|
// try? run(sql: "SELECT SUM(opt&1), SUM(opt&2)/2 FROM filter;") {
|
||||||
|
// try ifStep($0, SQLITE_ROW)
|
||||||
|
// return (sqlite3_column_int($0, 0), sqlite3_column_int($0, 1))
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
}
|
||||||
244
main/DB/DBCore.swift
Normal file
244
main/DB/DBCore.swift
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import Foundation
|
||||||
|
import SQLite3
|
||||||
|
|
||||||
|
// iOS 9.3 uses SQLite 3.8.10
|
||||||
|
|
||||||
|
enum SQLiteError: Error {
|
||||||
|
case OpenDatabase(message: String)
|
||||||
|
case Prepare(message: String)
|
||||||
|
case Step(message: String)
|
||||||
|
case Bind(message: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `try? SQLiteDatabase.open()`
|
||||||
|
var AppDB: SQLiteDatabase? { get { try? SQLiteDatabase.open() } }
|
||||||
|
typealias SQLiteRowID = sqlite3_int64
|
||||||
|
typealias SQLiteRowRange = (start: SQLiteRowID, end: SQLiteRowID)
|
||||||
|
|
||||||
|
// MARK: - SQLiteDatabase
|
||||||
|
|
||||||
|
class SQLiteDatabase {
|
||||||
|
fileprivate var functions = [String: [Int: Function]]()
|
||||||
|
private let dbPointer: OpaquePointer?
|
||||||
|
private init(dbPointer: OpaquePointer?) {
|
||||||
|
self.dbPointer = dbPointer
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate var errorMessage: String {
|
||||||
|
if let errorPointer = sqlite3_errmsg(dbPointer) {
|
||||||
|
let errorMessage = String(cString: errorPointer)
|
||||||
|
return errorMessage
|
||||||
|
} else {
|
||||||
|
return "No error message provided from sqlite."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
sqlite3_close(dbPointer)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func destroyDatabase(path: String = URL.internalDB().relativePath) {
|
||||||
|
if FileManager.default.fileExists(atPath: path) {
|
||||||
|
do { try FileManager.default.removeItem(atPath: path) }
|
||||||
|
catch { print("Could not destroy database file: \(path)") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
return SQLiteDatabase(dbPointer: db)
|
||||||
|
} else {
|
||||||
|
defer {
|
||||||
|
if db != nil {
|
||||||
|
sqlite3_close(db)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let errorPointer = sqlite3_errmsg(db) {
|
||||||
|
let message = String(cString: errorPointer)
|
||||||
|
throw SQLiteError.OpenDatabase(message: message)
|
||||||
|
} else {
|
||||||
|
throw SQLiteError.OpenDatabase(message: "No error message provided from sqlite.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run<T>(sql: String, bind: [DBBinding?] = [], step: (OpaquePointer) throws -> T) throws -> T {
|
||||||
|
// print("SQL run: \(sql)")
|
||||||
|
// for x in bind where x != nil {
|
||||||
|
// print(" -> \(x!)")
|
||||||
|
// }
|
||||||
|
var statement: OpaquePointer?
|
||||||
|
guard sqlite3_prepare_v2(dbPointer, sql, -1, &statement, nil) == SQLITE_OK,
|
||||||
|
let stmt = statement else {
|
||||||
|
throw SQLiteError.Prepare(message: errorMessage)
|
||||||
|
}
|
||||||
|
defer { sqlite3_finalize(stmt) }
|
||||||
|
var col: Int32 = 0
|
||||||
|
for b in bind.compactMap({$0}) {
|
||||||
|
col += 1
|
||||||
|
guard b.bind(stmt, col) == SQLITE_OK else {
|
||||||
|
throw SQLiteError.Bind(message: errorMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return try step(stmt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(sql: String) throws {
|
||||||
|
// print("SQL exec: \(sql)")
|
||||||
|
var err: UnsafeMutablePointer<Int8>? = nil
|
||||||
|
if sqlite3_exec(dbPointer, sql, nil, nil, &err) != SQLITE_OK {
|
||||||
|
let errMsg = (err != nil) ? String(cString: err!) : "Unknown execution error"
|
||||||
|
sqlite3_free(err);
|
||||||
|
throw SQLiteError.Step(message: errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ifStep(_ stmt: OpaquePointer, _ expected: Int32) throws {
|
||||||
|
guard sqlite3_step(stmt) == expected else {
|
||||||
|
throw SQLiteError.Step(message: errorMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func vacuum() {
|
||||||
|
try? run(sql: "VACUUM;")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Custom Functions
|
||||||
|
|
||||||
|
// let SQLITE_STATIC = unsafeBitCast(0, sqlite3_destructor_type.self)
|
||||||
|
let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
|
||||||
|
|
||||||
|
public struct Blob {
|
||||||
|
public let bytes: [UInt8]
|
||||||
|
public init(bytes: [UInt8]) { self.bytes = bytes }
|
||||||
|
public init(bytes: UnsafeRawPointer, length: Int) {
|
||||||
|
let i8bufptr = UnsafeBufferPointer(start: bytes.assumingMemoryBound(to: UInt8.self), count: length)
|
||||||
|
self.init(bytes: [UInt8](i8bufptr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SQLiteDatabase {
|
||||||
|
fileprivate typealias Function = @convention(block) (OpaquePointer?, Int32, UnsafeMutablePointer<OpaquePointer?>?) -> Void
|
||||||
|
|
||||||
|
func createFunction(_ function: String, argumentCount: UInt? = nil, deterministic: Bool = false, _ block: @escaping (_ args: [Any?]) -> Any?) {
|
||||||
|
let argc = argumentCount.map { Int($0) } ?? -1
|
||||||
|
let box: Function = { context, argc, argv in
|
||||||
|
let arguments: [Any?] = (0..<Int(argc)).map {
|
||||||
|
let value = argv![$0]
|
||||||
|
switch sqlite3_value_type(value) {
|
||||||
|
case SQLITE_BLOB: return Blob(bytes: sqlite3_value_blob(value), length: Int(sqlite3_value_bytes(value)))
|
||||||
|
case SQLITE_FLOAT: return sqlite3_value_double(value)
|
||||||
|
case SQLITE_INTEGER: return sqlite3_value_int64(value)
|
||||||
|
case SQLITE_NULL: return nil
|
||||||
|
case SQLITE_TEXT: return String(cString: UnsafePointer(sqlite3_value_text(value)))
|
||||||
|
case let type: fatalError("unsupported value type: \(type)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let result = block(arguments)
|
||||||
|
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? 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))") }
|
||||||
|
}
|
||||||
|
var flags = SQLITE_UTF8
|
||||||
|
if deterministic {
|
||||||
|
flags |= SQLITE_DETERMINISTIC
|
||||||
|
}
|
||||||
|
sqlite3_create_function_v2(dbPointer, function, Int32(argc), flags, unsafeBitCast(box, to: UnsafeMutableRawPointer.self), { context, argc, value in
|
||||||
|
let function = unsafeBitCast(sqlite3_user_data(context), to: Function.self)
|
||||||
|
function(context, argc, value)
|
||||||
|
}, nil, nil, nil)
|
||||||
|
if functions[function] == nil { functions[function] = [:] }
|
||||||
|
functions[function]?[argc] = box
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Bindings
|
||||||
|
|
||||||
|
protocol DBBinding {
|
||||||
|
func bind(_ stmt: OpaquePointer!, _ col: Int32) -> Int32
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BindInt32 : DBBinding {
|
||||||
|
let raw: Int32
|
||||||
|
init(_ value: Int32) { raw = value }
|
||||||
|
func bind(_ stmt: OpaquePointer!, _ col: Int32) -> Int32 { sqlite3_bind_int(stmt, col, raw) }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BindInt64 : DBBinding {
|
||||||
|
let raw: sqlite3_int64
|
||||||
|
init(_ value: sqlite3_int64) { raw = value }
|
||||||
|
func bind(_ stmt: OpaquePointer!, _ col: Int32) -> Int32 { sqlite3_bind_int64(stmt, col, raw) }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BindText : DBBinding {
|
||||||
|
let raw: String
|
||||||
|
init(_ value: String) { raw = value }
|
||||||
|
func bind(_ stmt: OpaquePointer!, _ col: Int32) -> Int32 { sqlite3_bind_text(stmt, col, (raw as NSString).utf8String, -1, nil) }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BindTextOrNil : DBBinding {
|
||||||
|
let raw: String?
|
||||||
|
init(_ value: String?) { raw = value }
|
||||||
|
func bind(_ stmt: OpaquePointer!, _ col: Int32) -> Int32 { sqlite3_bind_text(stmt, col, (raw == nil) ? nil : (raw! as NSString).utf8String, -1, nil) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Easy Access func
|
||||||
|
|
||||||
|
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? {
|
||||||
|
let val = sqlite3_column_text(stmt, col)
|
||||||
|
return (val != nil ? String(cString: val!) : nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func allRows<T>(_ stmt: OpaquePointer, _ fn: (OpaquePointer) -> T) -> [T] {
|
||||||
|
var r: [T] = []
|
||||||
|
while (sqlite3_step(stmt) == SQLITE_ROW) { r.append(fn(stmt)) }
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func allRowsKeyed<T,U>(_ stmt: OpaquePointer, _ fn: (OpaquePointer) -> (key: T, value: U)) -> [T:U] {
|
||||||
|
var r: [T:U] = [:]
|
||||||
|
while (sqlite3_step(stmt) == SQLITE_ROW) { let (k,v) = fn(stmt); r[k] = v }
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Prepared Statement
|
||||||
|
|
||||||
|
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 {
|
||||||
|
throw SQLiteError.Prepare(message: errorMessage)
|
||||||
|
}
|
||||||
|
return S
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult func prepared(run pStmt: OpaquePointer!, bind: [DBBinding?] = []) throws -> Int32 {
|
||||||
|
defer { sqlite3_reset(pStmt) }
|
||||||
|
var col: Int32 = 0
|
||||||
|
for b in bind.compactMap({$0}) {
|
||||||
|
col += 1
|
||||||
|
guard b.bind(pStmt, col) == SQLITE_OK else {
|
||||||
|
throw SQLiteError.Bind(message: errorMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sqlite3_step(pStmt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepared(finalize pStmt: OpaquePointer!) {
|
||||||
|
sqlite3_finalize(pStmt)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import Foundation
|
import UIKit
|
||||||
|
|
||||||
extension GroupedDomain {
|
extension GroupedDomain {
|
||||||
/// Return new `GroupedDomain` by adding `total` and `blocked` counts. Set `lastModified` to the maximum of the two.
|
/// Return new `GroupedDomain` by adding `total` and `blocked` counts. Set `lastModified` to the maximum of the two.
|
||||||
@@ -13,15 +13,22 @@ extension GroupedDomain {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Array where Element == GroupedDomain {
|
extension GroupedDomain {
|
||||||
func merge(_ domain: String, options opt: FilterOptions? = nil) -> GroupedDomain {
|
var detailCellText: String { get {
|
||||||
var b: Int32 = 0, t: Int32 = 0, m: Timestamp = 0
|
return blocked > 0
|
||||||
for x in self {
|
? "\(lastModified.asDateTime()) — \(blocked)/\(total) blocked"
|
||||||
b += x.blocked
|
: "\(lastModified.asDateTime()) — \(total)"
|
||||||
t += x.total
|
|
||||||
m = Swift.max(m, x.lastModified)
|
|
||||||
}
|
}
|
||||||
return GroupedDomain(domain: domain, total: t, blocked: b, lastModified: m, options: opt)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension FilterOptions {
|
||||||
|
func tableRowImage() -> UIImage? {
|
||||||
|
let blocked = contains(.blocked)
|
||||||
|
let ignored = contains(.ignored)
|
||||||
|
if blocked { return UIImage(named: ignored ? "block_ignore" : "shield-x") }
|
||||||
|
if ignored { return UIImage(named: "quicklook-not") }
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,10 +38,3 @@ extension Recording {
|
|||||||
var durationString: String? { get { stop == nil ? nil : TimeFormat.from(duration!) } }
|
var durationString: String? { get { stop == nil ? nil : TimeFormat.from(duration!) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Timestamp {
|
|
||||||
/// - Returns: Time string with format `yyyy-MM-dd HH:mm:ss`
|
|
||||||
func asDateTime() -> String { dateTimeFormat.string(from: self) }
|
|
||||||
func toDate() -> Date { Date(timeIntervalSince1970: Double(self)) }
|
|
||||||
static func now() -> Timestamp { Timestamp(Date().timeIntervalSince1970) }
|
|
||||||
static func past(minutes: Int) -> Timestamp { now() - Timestamp(minutes * 60) }
|
|
||||||
}
|
|
||||||
@@ -1,375 +0,0 @@
|
|||||||
import UIKit
|
|
||||||
|
|
||||||
let DBWrp = DBWrapper()
|
|
||||||
fileprivate var AppDB: SQLiteDatabase? { get { try? SQLiteDatabase.open() } }
|
|
||||||
|
|
||||||
class DBWrapper {
|
|
||||||
private var earliestEntry: Timestamp = 0
|
|
||||||
private var latestModification: Timestamp = 0
|
|
||||||
private var dataA: [GroupedDomain] = [] // Domains
|
|
||||||
private var dataB: [[GroupedDomain]] = [] // Hosts
|
|
||||||
private var dataF: [String : FilterOptions] = [:] // Filters
|
|
||||||
private let Q = DispatchQueue(label: "de.uni-bamberg.psi.AppCheck.db-wrapper-queue", attributes: .concurrent)
|
|
||||||
|
|
||||||
// auto update rows callback
|
|
||||||
var currentlyOpenParent: String?
|
|
||||||
weak var dataA_delegate: IncrementalDataSourceUpdate?
|
|
||||||
weak var dataB_delegate: IncrementalDataSourceUpdate?
|
|
||||||
func dataB_delegate(_ parent: String) -> IncrementalDataSourceUpdate? {
|
|
||||||
(currentlyOpenParent == parent) ? dataB_delegate : nil
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Data Source Getter
|
|
||||||
|
|
||||||
func listOfDomains() -> [GroupedDomain] {
|
|
||||||
Q.sync() { dataA }
|
|
||||||
}
|
|
||||||
|
|
||||||
func listOfHosts(_ parent: String) -> [GroupedDomain] {
|
|
||||||
Q.sync() { dataB[ifExist: dataA_index(of: parent)] ?? [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
func dataF_list(_ filter: FilterOptions) -> [String] {
|
|
||||||
Q.sync() { dataF.compactMap { $1.contains(filter) ? $0 : nil } }.sorted()
|
|
||||||
}
|
|
||||||
|
|
||||||
func dataF_counts() -> (blocked: Int, ignored: Int) {
|
|
||||||
Q.sync() { dataF.reduce((0, 0)) {
|
|
||||||
($0.0 + ($1.1.contains(.blocked) ? 1 : 0),
|
|
||||||
$0.1 + ($1.1.contains(.ignored) ? 1 : 0)) }}
|
|
||||||
}
|
|
||||||
|
|
||||||
func listOfTimes(_ domain: String) -> [GroupedTsOccurrence] {
|
|
||||||
return AppDB?.timesForDomain(domain, since: earliestEntry)?.reversed() ?? []
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Init
|
|
||||||
|
|
||||||
func initContentOfDB() {
|
|
||||||
QLog.Debug("SQLite path: \(URL.internalDB())")
|
|
||||||
DispatchQueue.global().async {
|
|
||||||
#if IOS_SIMULATOR
|
|
||||||
self.generateTestData()
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
// dont know why main queue is needed, wont start otherwise
|
|
||||||
Timer.repeating(2, call: #selector(self.insertRandomEntry), on: self)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
self.dataF_init()
|
|
||||||
self.dataAB_init()
|
|
||||||
self.autoSyncTimer_init()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func reloadAfterDateFilterHasChanged() {
|
|
||||||
DispatchQueue.global().async {
|
|
||||||
self.dataAB_init()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func dataF_init() {
|
|
||||||
let list = AppDB?.loadFilters() ?? [:]
|
|
||||||
Q.async(flags: .barrier) {
|
|
||||||
self.dataF = list
|
|
||||||
NotifyDNSFilterChanged.postAsyncMain()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func dataAB_init() {
|
|
||||||
let earliest = Pref.DateFilter.lastXMinTimestamp() ?? 0
|
|
||||||
let list = AppDB?.domainList(since: earliest)
|
|
||||||
Q.async(flags: .barrier) {
|
|
||||||
self.dataA = []
|
|
||||||
self.dataB = []
|
|
||||||
self.earliestEntry = earliest
|
|
||||||
self.latestModification = earliest
|
|
||||||
if let allDomains = list {
|
|
||||||
for (parent, parts) in self.groupBySubdomains(allDomains) {
|
|
||||||
self.dataA.append(parent)
|
|
||||||
self.dataB.append(parts)
|
|
||||||
self.latestModification = max(parent.lastModified, self.latestModification)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
NotifyLogHistoryReset.postAsyncMain()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Auto sync new logs every 7 seconds.
|
|
||||||
private func autoSyncTimer_init() {
|
|
||||||
Q.async() { // using Q to start timer only after init data A,B,F
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
// dont know why main queue is needed, wont start otherwise
|
|
||||||
Timer.repeating(7, call: #selector(self.syncNewestLogs), on: self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Partial Update History
|
|
||||||
|
|
||||||
@objc private func syncNewestLogs() {
|
|
||||||
dataA_mergeInsert();
|
|
||||||
if let lastXFilter = Pref.DateFilter.lastXMinTimestamp() {
|
|
||||||
if earliestEntry < lastXFilter {
|
|
||||||
dataA_mergeDelete(between: earliestEntry, and: lastXFilter)
|
|
||||||
earliestEntry = lastXFilter
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func dataA_mergeInsert() {
|
|
||||||
#if !IOS_SIMULATOR
|
|
||||||
guard currentVPNState == .on else { return }
|
|
||||||
#endif
|
|
||||||
guard let res = AppDB?.domainList(since: latestModification + 1), res.count > 0 else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
QLog.Info("auto sync \(res.count) new logs")
|
|
||||||
Q.async(flags: .barrier) {
|
|
||||||
var c = 0
|
|
||||||
for (parent, parts) in self.groupBySubdomains(res) {
|
|
||||||
if let i = self.dataA_index(of: parent.domain) {
|
|
||||||
self.dataB_mergeInsert(parent.domain, at: i, newChildren: parts)
|
|
||||||
|
|
||||||
let merged = parent + self.dataA.remove(at: i)
|
|
||||||
self.dataA.insert(merged, at: c)
|
|
||||||
self.dataB.insert(self.dataB.remove(at: i), at: c)
|
|
||||||
self.dataA_delegate?.moveRow(merged, from: i, to: c)
|
|
||||||
} else {
|
|
||||||
self.dataA.insert(parent, at: c)
|
|
||||||
self.dataB.insert(parts, at: c)
|
|
||||||
self.dataA_delegate?.insertRow(parent, at: c)
|
|
||||||
self.dataB_delegate(parent.domain)?.replaceData(with: parts);
|
|
||||||
}
|
|
||||||
c += 1
|
|
||||||
self.latestModification = max(parent.lastModified, self.latestModification)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func dataB_mergeInsert(_ dom: String, at index: Int, newChildren: [GroupedDomain]) {
|
|
||||||
let tvc = dataB_delegate(dom)
|
|
||||||
var i = 0
|
|
||||||
for child in newChildren {
|
|
||||||
if let u = dataB[index].firstIndex(where: { $0.domain == child.domain }) {
|
|
||||||
let merged = child + dataB[index].remove(at: u)
|
|
||||||
dataB[index].insert(merged, at: i)
|
|
||||||
tvc?.moveRow(merged, from: u, to: i)
|
|
||||||
} else {
|
|
||||||
dataB[index].insert(child, at: i)
|
|
||||||
tvc?.insertRow(child, at: i)
|
|
||||||
}
|
|
||||||
i += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Soft Delete History
|
|
||||||
// Will delete appearance only. DB still contains a copy.
|
|
||||||
|
|
||||||
/// Technically not really deleting existing logs. Rather query DB for selected range and decrement whatever count is returned.
|
|
||||||
/// This means you should only run the delete operation once. As running multiple times will distrort the data.
|
|
||||||
/// - Parameters:
|
|
||||||
/// - between: Starting with and including
|
|
||||||
/// - and: Up until, exculding
|
|
||||||
private func dataA_mergeDelete(between: Timestamp, and: Timestamp) {
|
|
||||||
guard let res = AppDB?.domainList(between: between, and: and), res.count > 0 else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
QLog.Info("deleting \(res.count) old logs (soft delete)")
|
|
||||||
Q.async(flags: .barrier) {
|
|
||||||
for (parent, parts) in self.groupBySubdomains(res) {
|
|
||||||
guard let i = self.dataA_index(of: parent.domain) else {
|
|
||||||
continue // should never happen anyway
|
|
||||||
}
|
|
||||||
if parent.total < self.dataA[i].total {
|
|
||||||
self.dataB_mergeDelete(parent.domain, at: i, oldChildren: parts)
|
|
||||||
self.dataA[i] = self.dataA[i] - parent
|
|
||||||
self.dataA_delegate?.replaceRow(self.dataA[i], at: i)
|
|
||||||
} else {
|
|
||||||
self.dataA_delete(at: i, parentDomain: parent.domain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func dataB_mergeDelete(_ dom: String, at index: Int, oldChildren: [GroupedDomain]) {
|
|
||||||
let tvc = dataB_delegate(dom)
|
|
||||||
for child in oldChildren {
|
|
||||||
guard let u = dataB[index].firstIndex(where: { $0.domain == child.domain }) else {
|
|
||||||
continue // should never happen anyway
|
|
||||||
}
|
|
||||||
if child.total < dataB[index][u].total {
|
|
||||||
dataB[index][u] = dataB[index][u] - child
|
|
||||||
tvc?.replaceRow(dataB[index][u], at: u)
|
|
||||||
} else {
|
|
||||||
dataB[index].remove(at: u)
|
|
||||||
tvc?.deleteRow(at: u)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func dataA_delete(at index: Int, parentDomain: String) {
|
|
||||||
dataA.remove(at: index)
|
|
||||||
dataB.remove(at: index)
|
|
||||||
dataA_delegate?.deleteRow(at: index)
|
|
||||||
dataB_delegate(parentDomain)?.replaceData(with: [])
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Hard Delete History
|
|
||||||
// will delete DB content. No restore.
|
|
||||||
|
|
||||||
func deleteHistory() {
|
|
||||||
DispatchQueue.global().async {
|
|
||||||
try? AppDB?.destroyContent()
|
|
||||||
AppDB?.vacuum()
|
|
||||||
self.dataAB_init()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func deleteHistory(domain: String, since ts: Timestamp) {
|
|
||||||
DispatchQueue.global().async {
|
|
||||||
let modified = (try? AppDB?.deleteRows(matching: domain, since: max(ts, self.earliestEntry))) ?? 0
|
|
||||||
guard modified > 0 else {
|
|
||||||
return // nothing has changed
|
|
||||||
}
|
|
||||||
QLog.Info("deleting \(modified) old logs (hard delete)")
|
|
||||||
AppDB?.vacuum()
|
|
||||||
self.Q.async(flags: .barrier) {
|
|
||||||
guard let index = self.dataA_index(of: domain) else {
|
|
||||||
return // nothing has changed
|
|
||||||
}
|
|
||||||
let parentDom = self.dataA[index].domain
|
|
||||||
guard let list = AppDB?.domainList(matching: parentDom, since: self.earliestEntry),
|
|
||||||
list.count > 0 else {
|
|
||||||
self.dataA_delete(at: index, parentDomain: parentDom)
|
|
||||||
return // nothing left, after deleting matching rows
|
|
||||||
}
|
|
||||||
// else: incremental update, replace whole list
|
|
||||||
self.dataA[index] = list.merge(parentDom, options: self.dataF[parentDom])
|
|
||||||
self.dataA_delegate?.replaceRow(self.dataA[index], at: index)
|
|
||||||
self.dataB[index].removeAll()
|
|
||||||
for var child in list {
|
|
||||||
child.options = self.dataF[child.domain]
|
|
||||||
self.dataB[index].append(child)
|
|
||||||
}
|
|
||||||
self.dataB_delegate(parentDom)?.replaceData(with: self.dataB[index])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Partial Update Filter
|
|
||||||
|
|
||||||
func updateFilter(_ domain: String, add: FilterOptions) {
|
|
||||||
updateFilter(domain, set: (dataF[domain] ?? FilterOptions()).union(add))
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateFilter(_ domain: String, remove: FilterOptions) {
|
|
||||||
updateFilter(domain, set: dataF[domain]?.subtracting(remove))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// - Parameters:
|
|
||||||
/// - set: Remove a filter with `nil` or `.none`
|
|
||||||
private func updateFilter(_ domain: String, set: FilterOptions?) {
|
|
||||||
AppDB?.setFilter(domain, set)
|
|
||||||
Q.async(flags: .barrier) {
|
|
||||||
self.dataF[domain] = set
|
|
||||||
if let i = self.dataA_index(of: domain) {
|
|
||||||
if domain == self.dataA[i].domain {
|
|
||||||
self.dataA[i].options = (set == FilterOptions.none) ? nil : set
|
|
||||||
self.dataA_delegate?.replaceRow(self.dataA[i], at: i)
|
|
||||||
}
|
|
||||||
if let u = self.dataB[i].firstIndex(where: { $0.domain == domain }) {
|
|
||||||
self.dataB[i][u].options = (set == FilterOptions.none) ? nil : set
|
|
||||||
self.dataB_delegate(self.dataA[i].domain)?.replaceRow(self.dataB[i][u], at: u)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
NotifyDNSFilterChanged.postAsyncMain()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Recordings
|
|
||||||
|
|
||||||
func listOfRecordings() -> [Recording] { AppDB?.allRecordings() ?? [] }
|
|
||||||
func recordingGetCurrent() -> Recording? { AppDB?.ongoingRecording() }
|
|
||||||
func recordingStartNew() -> Recording? { try? AppDB?.startNewRecording() }
|
|
||||||
|
|
||||||
func recordingStop(_ r: inout Recording) { AppDB?.stopRecording(&r) }
|
|
||||||
func recordingPersist(_ r: Recording) { AppDB?.persistRecordingLogs(r) }
|
|
||||||
func recordingDetails(_ r: Recording) -> [RecordLog] { AppDB?.getRecordingsLogs(r) ?? [] }
|
|
||||||
|
|
||||||
func recordingUpdate(_ r: Recording) {
|
|
||||||
AppDB?.updateRecording(r)
|
|
||||||
NotifyRecordingChanged.post((r, false))
|
|
||||||
}
|
|
||||||
|
|
||||||
func recordingDelete(_ r: Recording) {
|
|
||||||
if (try? AppDB?.deleteRecording(r)) == true {
|
|
||||||
NotifyRecordingChanged.post((r, true))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func recordingDeleteDetails(_ r: Recording, domain: String?) -> Bool {
|
|
||||||
((try? AppDB?.deleteRecordingLogs(r.id, matchingDomain: domain)) ?? 0) > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Helper methods
|
|
||||||
|
|
||||||
private func dataA_index(of domain: String) -> Int? {
|
|
||||||
dataA.firstIndex { domain.isSubdomain(of: $0.domain) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private func groupBySubdomains(_ allDomains: [GroupedDomain]) -> [(parent: GroupedDomain, parts: [GroupedDomain])] {
|
|
||||||
var i: Int = 0
|
|
||||||
var indexOf: [String: Int] = [:]
|
|
||||||
var res: [(domain: String, list: [GroupedDomain])] = []
|
|
||||||
for var x in allDomains {
|
|
||||||
let domain = x.domain.splitDomainAndHost().domain
|
|
||||||
x.options = dataF[x.domain]
|
|
||||||
if let y = indexOf[domain] {
|
|
||||||
res[y].list.append(x)
|
|
||||||
} else {
|
|
||||||
res.append((domain, [x]))
|
|
||||||
indexOf[domain] = i
|
|
||||||
i += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return res.map { ($1.merge($0, options: self.dataF[$0]), $1) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Test Data
|
|
||||||
|
|
||||||
extension DBWrapper {
|
|
||||||
private func generateTestData() {
|
|
||||||
guard let db = AppDB else { return }
|
|
||||||
let deleted = (try? db.deleteRows(matching: "test.com")) ?? 0
|
|
||||||
QLog.Debug("Deleting \(deleted) rows matching 'test.com'")
|
|
||||||
|
|
||||||
QLog.Debug("Writing 33 test logs")
|
|
||||||
try? db.insertDNSQuery("keeptest.com", blocked: false)
|
|
||||||
for _ in 1...4 { try? db.insertDNSQuery("test.com", blocked: false) }
|
|
||||||
for _ in 1...7 { try? db.insertDNSQuery("i.test.com", blocked: false) }
|
|
||||||
for i in 1...8 { try? db.insertDNSQuery("b.test.com", blocked: i>5) }
|
|
||||||
for i in 1...13 { try? db.insertDNSQuery("bi.test.com", blocked: i%2==0) }
|
|
||||||
|
|
||||||
QLog.Debug("Creating 4 filters")
|
|
||||||
db.setFilter("b.test.com", .blocked)
|
|
||||||
db.setFilter("i.test.com", .ignored)
|
|
||||||
db.setFilter("bi.test.com", [.blocked, .ignored])
|
|
||||||
|
|
||||||
QLog.Debug("Done")
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func insertRandomEntry() {
|
|
||||||
//QLog.Debug("Inserting 1 periodic log entry")
|
|
||||||
try? AppDB?.insertDNSQuery("\(arc4random() % 5).count.test.com", blocked: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,469 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import SQLite3
|
|
||||||
|
|
||||||
typealias Timestamp = Int64
|
|
||||||
|
|
||||||
struct FilterOptions: OptionSet {
|
|
||||||
let rawValue: Int32
|
|
||||||
static let none = FilterOptions([])
|
|
||||||
static let blocked = FilterOptions(rawValue: 1 << 0)
|
|
||||||
static let ignored = FilterOptions(rawValue: 1 << 1)
|
|
||||||
static let any = FilterOptions(rawValue: 0b11)
|
|
||||||
}
|
|
||||||
|
|
||||||
enum SQLiteError: Error {
|
|
||||||
case OpenDatabase(message: String)
|
|
||||||
case Prepare(message: String)
|
|
||||||
case Step(message: String)
|
|
||||||
case Bind(message: String)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: - SQLiteDatabase
|
|
||||||
|
|
||||||
class SQLiteDatabase {
|
|
||||||
private let dbPointer: OpaquePointer?
|
|
||||||
private init(dbPointer: OpaquePointer?) {
|
|
||||||
self.dbPointer = dbPointer
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate var errorMessage: String {
|
|
||||||
if let errorPointer = sqlite3_errmsg(dbPointer) {
|
|
||||||
let errorMessage = String(cString: errorPointer)
|
|
||||||
return errorMessage
|
|
||||||
} else {
|
|
||||||
return "No error message provided from sqlite."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
sqlite3_close(dbPointer)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func destroyDatabase(path: String = URL.internalDB().relativePath) {
|
|
||||||
if FileManager.default.fileExists(atPath: path) {
|
|
||||||
do { try FileManager.default.removeItem(atPath: path) }
|
|
||||||
catch { print("Could not destroy database file: \(path)") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// static func export() throws -> URL {
|
|
||||||
// let fmt = DateFormatter()
|
|
||||||
// fmt.dateFormat = "yyyy-MM-dd"
|
|
||||||
// let dest = FileManager.default.exportDir().appendingPathComponent("\(fmt.string(from: Date()))-dns-log.sqlite")
|
|
||||||
// try? FileManager.default.removeItem(at: dest)
|
|
||||||
// try FileManager.default.copyItem(at: FileManager.default.internalDB(), to: dest)
|
|
||||||
// return dest
|
|
||||||
// }
|
|
||||||
|
|
||||||
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 {
|
|
||||||
return SQLiteDatabase(dbPointer: db)
|
|
||||||
} else {
|
|
||||||
defer {
|
|
||||||
if db != nil {
|
|
||||||
sqlite3_close(db)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let errorPointer = sqlite3_errmsg(db) {
|
|
||||||
let message = String(cString: errorPointer)
|
|
||||||
throw SQLiteError.OpenDatabase(message: message)
|
|
||||||
} else {
|
|
||||||
throw SQLiteError.OpenDatabase(message: "No error message provided from sqlite.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func run<T>(sql: String, bind: [DBBinding?] = [], step: (OpaquePointer) throws -> T) throws -> T {
|
|
||||||
var statement: OpaquePointer?
|
|
||||||
guard sqlite3_prepare_v2(dbPointer, sql, -1, &statement, nil) == SQLITE_OK,
|
|
||||||
let stmt = statement else {
|
|
||||||
throw SQLiteError.Prepare(message: errorMessage)
|
|
||||||
}
|
|
||||||
defer { sqlite3_finalize(stmt) }
|
|
||||||
var col: Int32 = 0
|
|
||||||
for b in bind.compactMap({$0}) {
|
|
||||||
col += 1
|
|
||||||
guard b.bind(stmt, col) == SQLITE_OK else {
|
|
||||||
throw SQLiteError.Bind(message: errorMessage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return try step(stmt)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ifStep(_ stmt: OpaquePointer, _ expected: Int32) throws {
|
|
||||||
guard sqlite3_step(stmt) == expected else {
|
|
||||||
throw SQLiteError.Step(message: errorMessage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func createTable(table: SQLTable.Type) throws {
|
|
||||||
try run(sql: table.createStatement) { try ifStep($0, SQLITE_DONE) }
|
|
||||||
}
|
|
||||||
|
|
||||||
func vacuum() {
|
|
||||||
try? run(sql: "VACUUM;") { try ifStep($0, SQLITE_DONE) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protocol SQLTable {
|
|
||||||
static var createStatement: String { get }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Bindings
|
|
||||||
|
|
||||||
protocol DBBinding {
|
|
||||||
func bind(_ stmt: OpaquePointer, _ col: Int32) -> Int32
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BindInt32 : DBBinding {
|
|
||||||
let raw: Int32
|
|
||||||
init(_ value: Int32) { raw = value }
|
|
||||||
func bind(_ stmt: OpaquePointer, _ col: Int32) -> Int32 { sqlite3_bind_int(stmt, col, raw) }
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BindInt64 : DBBinding {
|
|
||||||
let raw: sqlite3_int64
|
|
||||||
init(_ value: sqlite3_int64) { raw = value }
|
|
||||||
func bind(_ stmt: OpaquePointer, _ col: Int32) -> Int32 { sqlite3_bind_int64(stmt, col, raw) }
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BindText : DBBinding {
|
|
||||||
let raw: String
|
|
||||||
init(_ value: String) { raw = value }
|
|
||||||
func bind(_ stmt: OpaquePointer, _ col: Int32) -> Int32 { sqlite3_bind_text(stmt, col, (raw as NSString).utf8String, -1, nil) }
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BindTextOrNil : DBBinding {
|
|
||||||
let raw: String?
|
|
||||||
init(_ value: String?) { raw = value }
|
|
||||||
func bind(_ stmt: OpaquePointer, _ col: Int32) -> Int32 { sqlite3_bind_text(stmt, col, (raw == nil) ? nil : (raw! as NSString).utf8String, -1, nil) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Easy Access func
|
|
||||||
|
|
||||||
private extension SQLiteDatabase {
|
|
||||||
func readText(_ stmt: OpaquePointer, _ col: Int32) -> String? {
|
|
||||||
let val = sqlite3_column_text(stmt, col)
|
|
||||||
return (val != nil ? String(cString: val!) : nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func allRows<T>(_ stmt: OpaquePointer, _ fn: (OpaquePointer) -> T) -> [T] {
|
|
||||||
var r: [T] = []
|
|
||||||
while (sqlite3_step(stmt) == SQLITE_ROW) { r.append(fn(stmt)) }
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
func allRowsKeyed<T,U>(_ stmt: OpaquePointer, _ fn: (OpaquePointer) -> (key: T, value: U)) -> [T:U] {
|
|
||||||
var r: [T:U] = [:]
|
|
||||||
while (sqlite3_step(stmt) == SQLITE_ROW) { let (k,v) = fn(stmt); r[k] = v }
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension SQLiteDatabase {
|
|
||||||
func initScheme() {
|
|
||||||
try? self.createTable(table: DNSQueryT.self)
|
|
||||||
try? self.createTable(table: DNSFilterT.self)
|
|
||||||
try? self.createTable(table: Recording.self)
|
|
||||||
try? self.createTable(table: RecordingLog.self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: - DNSQueryT
|
|
||||||
|
|
||||||
private struct DNSQueryT: SQLTable {
|
|
||||||
let ts: Timestamp
|
|
||||||
let domain: String
|
|
||||||
let wasBlocked: Bool
|
|
||||||
let options: FilterOptions
|
|
||||||
static var createStatement: String {
|
|
||||||
return """
|
|
||||||
CREATE TABLE IF NOT EXISTS req(
|
|
||||||
ts INTEGER DEFAULT (strftime('%s','now')),
|
|
||||||
domain TEXT NOT NULL,
|
|
||||||
logOpt INTEGER DEFAULT 0
|
|
||||||
);
|
|
||||||
"""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct GroupedDomain {
|
|
||||||
let domain: String, total: Int32, blocked: Int32, lastModified: Timestamp
|
|
||||||
var options: FilterOptions? = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
extension SQLiteDatabase {
|
|
||||||
|
|
||||||
// MARK: insert
|
|
||||||
|
|
||||||
func insertDNSQuery(_ domain: String, blocked: Bool) throws {
|
|
||||||
try? run(sql: "INSERT INTO req (domain, logOpt) VALUES (?, ?);",
|
|
||||||
bind: [BindText(domain), BindInt32(blocked ? 1 : 0)]) {
|
|
||||||
try ifStep($0, SQLITE_DONE)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: delete
|
|
||||||
|
|
||||||
func destroyContent() throws {
|
|
||||||
try? run(sql: "DROP TABLE IF EXISTS req;") { try ifStep($0, SQLITE_DONE) }
|
|
||||||
try? createTable(table: DNSQueryT.self)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete rows matching `ts >= ? AND "domain" OR "*.domain"`
|
|
||||||
@discardableResult func deleteRows(matching domain: String, since ts: Timestamp = 0) throws -> Int32 {
|
|
||||||
try run(sql: "DELETE FROM req WHERE ts >= ? AND (domain = ? OR domain LIKE '%.' || ?);",
|
|
||||||
bind: [BindInt64(ts), BindText(domain), BindText(domain)]) { stmt -> Int32 in
|
|
||||||
try ifStep(stmt, SQLITE_DONE)
|
|
||||||
return sqlite3_changes(dbPointer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: read
|
|
||||||
|
|
||||||
private func allDomainsGrouped(_ clause: String = "", bind: [DBBinding?] = []) -> [GroupedDomain]? {
|
|
||||||
try? run(sql: "SELECT domain, COUNT(*), SUM(logOpt&1), MAX(ts) FROM req \(clause) GROUP BY domain ORDER BY 4 DESC;", bind: bind) {
|
|
||||||
allRows($0) {
|
|
||||||
GroupedDomain(domain: readText($0, 0) ?? "",
|
|
||||||
total: sqlite3_column_int($0, 1),
|
|
||||||
blocked: sqlite3_column_int($0, 2),
|
|
||||||
lastModified: sqlite3_column_int64($0, 3))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func domainList(since ts: Timestamp = 0) -> [GroupedDomain]? {
|
|
||||||
ts==0 ? allDomainsGrouped() : allDomainsGrouped("WHERE ts >= ?", bind: [BindInt64(ts)])
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get grouped domains matching `ts >= ? AND "domain" OR "*.domain"`
|
|
||||||
func domainList(matching domain: String, since ts: Timestamp = 0) -> [GroupedDomain]? {
|
|
||||||
allDomainsGrouped("WHERE ts >= ? AND (domain = ? OR domain LIKE '%.' || ?)",
|
|
||||||
bind: [BindInt64(ts), BindText(domain), BindText(domain)])
|
|
||||||
}
|
|
||||||
|
|
||||||
/// From `ts1` (including) and up to `ts2` (excluding). `ts1 >= X < ts2`
|
|
||||||
func domainList(between ts1: Timestamp, and ts2: Timestamp) -> [GroupedDomain]? {
|
|
||||||
allDomainsGrouped("WHERE ts >= ? AND ts < ?", bind: [BindInt64(ts1), BindInt64(ts2)])
|
|
||||||
}
|
|
||||||
|
|
||||||
func timesForDomain(_ fullDomain: String, since ts: Timestamp = 0) -> [GroupedTsOccurrence]? {
|
|
||||||
try? run(sql: "SELECT ts, COUNT(ts), SUM(logOpt>0) FROM req WHERE ts >= ? AND domain = ? GROUP BY ts;",
|
|
||||||
bind: [BindInt64(ts), BindText(fullDomain)]) {
|
|
||||||
allRows($0) {
|
|
||||||
(sqlite3_column_int64($0, 0), sqlite3_column_int($0, 1), sqlite3_column_int($0, 2))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
typealias GroupedTsOccurrence = (ts: Timestamp, total: Int32, blocked: Int32)
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: - DNSFilterT
|
|
||||||
|
|
||||||
private struct DNSFilterT: SQLTable {
|
|
||||||
let domain: String
|
|
||||||
let options: FilterOptions
|
|
||||||
static var createStatement: String {
|
|
||||||
return """
|
|
||||||
CREATE TABLE IF NOT EXISTS filter(
|
|
||||||
domain TEXT UNIQUE NOT NULL,
|
|
||||||
opt INTEGER DEFAULT 0
|
|
||||||
);
|
|
||||||
"""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension SQLiteDatabase {
|
|
||||||
|
|
||||||
// MARK: read
|
|
||||||
|
|
||||||
func loadFilters() -> [String : FilterOptions]? {
|
|
||||||
try? run(sql: "SELECT domain, opt FROM filter;") {
|
|
||||||
allRowsKeyed($0) {
|
|
||||||
(key: readText($0, 0) ?? "",
|
|
||||||
value: FilterOptions(rawValue: sqlite3_column_int($0, 1)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: write
|
|
||||||
|
|
||||||
func setFilter(_ domain: String, _ value: FilterOptions?) {
|
|
||||||
func removeFilter() {
|
|
||||||
try? run(sql: "DELETE FROM filter WHERE domain = ? LIMIT 1;",
|
|
||||||
bind: [BindText(domain)]) { stmt -> Void in
|
|
||||||
sqlite3_step(stmt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
guard let rv = value?.rawValue, rv > 0 else {
|
|
||||||
removeFilter()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
func createFilter() throws {
|
|
||||||
try run(sql: "INSERT OR FAIL INTO filter (domain, opt) VALUES (?, ?);",
|
|
||||||
bind: [BindText(domain), BindInt32(rv)]) {
|
|
||||||
try ifStep($0, SQLITE_DONE)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
func updateFilter() {
|
|
||||||
try? run(sql: "UPDATE filter SET opt = ? WHERE domain = ? LIMIT 1;",
|
|
||||||
bind: [BindInt32(rv), BindText(domain)]) { stmt -> Void in
|
|
||||||
sqlite3_step(stmt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
do { try createFilter() } catch { updateFilter() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Recordings
|
|
||||||
|
|
||||||
struct Recording: SQLTable {
|
|
||||||
let id: sqlite3_int64
|
|
||||||
let start: Timestamp
|
|
||||||
let stop: Timestamp?
|
|
||||||
var appId: String? = nil
|
|
||||||
var title: String? = nil
|
|
||||||
var notes: String? = nil
|
|
||||||
static var createStatement: String {
|
|
||||||
return """
|
|
||||||
CREATE TABLE IF NOT EXISTS rec(
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
start INTEGER DEFAULT (strftime('%s','now')),
|
|
||||||
stop INTEGER,
|
|
||||||
appid TEXT,
|
|
||||||
title TEXT,
|
|
||||||
notes TEXT
|
|
||||||
);
|
|
||||||
"""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension SQLiteDatabase {
|
|
||||||
|
|
||||||
// MARK: write
|
|
||||||
|
|
||||||
func startNewRecording() throws -> Recording {
|
|
||||||
try run(sql: "INSERT INTO rec (stop) VALUES (NULL);") { stmt -> Recording in
|
|
||||||
try ifStep(stmt, SQLITE_DONE)
|
|
||||||
return try getRecording(withID: sqlite3_last_insert_rowid(dbPointer))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func stopRecording(_ r: inout Recording) {
|
|
||||||
guard r.stop == nil else { return }
|
|
||||||
let theID = r.id
|
|
||||||
try? run(sql: "UPDATE rec SET stop = (strftime('%s','now')) WHERE id = ? LIMIT 1;",
|
|
||||||
bind: [BindInt64(theID)]) { stmt -> Void in
|
|
||||||
try ifStep(stmt, SQLITE_DONE)
|
|
||||||
r = try getRecording(withID: theID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateRecording(_ r: Recording) {
|
|
||||||
try? run(sql: "UPDATE rec SET title = ?, appid = ?, notes = ? WHERE id = ? LIMIT 1;",
|
|
||||||
bind: [BindTextOrNil(r.title), BindTextOrNil(r.appId), BindTextOrNil(r.notes), BindInt64(r.id)]) { stmt -> Void in
|
|
||||||
sqlite3_step(stmt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func deleteRecording(_ r: Recording) throws -> Bool {
|
|
||||||
_ = try? deleteRecordingLogs(r.id)
|
|
||||||
return try run(sql: "DELETE FROM rec WHERE id = ? LIMIT 1;", bind: [BindInt64(r.id)]) {
|
|
||||||
try ifStep($0, SQLITE_DONE)
|
|
||||||
return sqlite3_changes(dbPointer) > 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: read
|
|
||||||
|
|
||||||
func readRecording(_ stmt: OpaquePointer) -> Recording {
|
|
||||||
let end = sqlite3_column_int64(stmt, 2)
|
|
||||||
return Recording(id: sqlite3_column_int64(stmt, 0),
|
|
||||||
start: sqlite3_column_int64(stmt, 1),
|
|
||||||
stop: end == 0 ? nil : end,
|
|
||||||
appId: readText(stmt, 3),
|
|
||||||
title: readText(stmt, 4),
|
|
||||||
notes: readText(stmt, 5))
|
|
||||||
}
|
|
||||||
|
|
||||||
func ongoingRecording() -> Recording? {
|
|
||||||
try? run(sql: "SELECT * FROM rec WHERE stop IS NULL LIMIT 1;") {
|
|
||||||
try ifStep($0, SQLITE_ROW)
|
|
||||||
return readRecording($0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func allRecordings() -> [Recording]? {
|
|
||||||
try? run(sql: "SELECT * FROM rec WHERE stop IS NOT NULL;") {
|
|
||||||
allRows($0) { readRecording($0) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getRecording(withID: sqlite3_int64) throws -> Recording {
|
|
||||||
try run(sql: "SELECT * FROM rec WHERE id = ? LIMIT 1;", bind: [BindInt64(withID)]) {
|
|
||||||
try ifStep($0, SQLITE_ROW)
|
|
||||||
return readRecording($0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK:
|
|
||||||
|
|
||||||
private struct RecordingLog: SQLTable {
|
|
||||||
let rID: Int32
|
|
||||||
let ts: Timestamp
|
|
||||||
let domain: String
|
|
||||||
static var createStatement: String {
|
|
||||||
return """
|
|
||||||
CREATE TABLE IF NOT EXISTS recLog(
|
|
||||||
rid INTEGER REFERENCES rec(id) ON DELETE CASCADE,
|
|
||||||
ts INTEGER,
|
|
||||||
domain TEXT
|
|
||||||
);
|
|
||||||
"""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension SQLiteDatabase {
|
|
||||||
|
|
||||||
// MARK: write
|
|
||||||
|
|
||||||
func persistRecordingLogs(_ r: Recording) {
|
|
||||||
guard let end = r.stop else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try? run(sql: """
|
|
||||||
INSERT INTO recLog (rid, ts, domain) SELECT ?, ts, domain FROM req
|
|
||||||
WHERE req.ts >= ? AND req.ts <= ?
|
|
||||||
""", bind: [BindInt64(r.id), BindInt64(r.start), BindInt64(end)]) {
|
|
||||||
try ifStep($0, SQLITE_DONE)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func deleteRecordingLogs(_ recId: sqlite3_int64, matchingDomain d: String? = nil) throws -> Int32 {
|
|
||||||
try run(sql: "DELETE FROM recLog WHERE rid = ? \(d==nil ? "" : "AND domain = ?");",
|
|
||||||
bind: [BindInt64(recId), d==nil ? nil : BindText(d!)]) {
|
|
||||||
try ifStep($0, SQLITE_DONE)
|
|
||||||
return sqlite3_changes(dbPointer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: read
|
|
||||||
|
|
||||||
func getRecordingsLogs(_ r: Recording) -> [RecordLog]? {
|
|
||||||
try? run(sql: "SELECT domain, COUNT() FROM recLog WHERE rid = ? GROUP BY domain;",
|
|
||||||
bind: [BindInt64(r.id)]) {
|
|
||||||
allRows($0) { (readText($0, 0), sqlite3_column_int($0, 1)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
typealias RecordLog = (domain: String?, count: Int32)
|
|
||||||
51
main/Data Source/DomainFilter.swift
Normal file
51
main/Data Source/DomainFilter.swift
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum DomainFilter {
|
||||||
|
static private var data: [String: FilterOptions] = {
|
||||||
|
AppDB?.loadFilters() ?? [:]
|
||||||
|
}()
|
||||||
|
|
||||||
|
/// Get filter with given `domain` name
|
||||||
|
@inline(__always) static subscript(_ domain: String) -> FilterOptions? {
|
||||||
|
data[domain]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update local memory object by loading values from persistent db.
|
||||||
|
/// - Note: Will trigger `NotifyDNSFilterChanged` notification.
|
||||||
|
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] {
|
||||||
|
data.compactMap { $1.contains(matching) ? $0 : nil }.sorted()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get total number of blocked and ignored domains. Shown in settings overview.
|
||||||
|
static func counts() -> (blocked: Int, ignored: Int) {
|
||||||
|
data.reduce(into: (0, 0)) {
|
||||||
|
if $1.1.contains(.blocked) { $0.0 += 1 }
|
||||||
|
if $1.1.contains(.ignored) { $0.1 += 1 } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Union `filter` with set.
|
||||||
|
/// - Note: Will trigger `NotifyDNSFilterChanged` notification.
|
||||||
|
static func update(_ domain: String, add filter: FilterOptions) {
|
||||||
|
update(domain, set: (data[domain] ?? FilterOptions()).union(filter))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subtract `filter` from set.
|
||||||
|
/// - Note: Will trigger `NotifyDNSFilterChanged` notification.
|
||||||
|
static func update(_ domain: String, remove filter: FilterOptions) {
|
||||||
|
update(domain, set: data[domain]?.subtracting(filter))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update persistent db, local memory object, and post notification to subscribers
|
||||||
|
/// - Parameter set: Remove a filter with `nil` or `.none`
|
||||||
|
static private func update(_ domain: String, set: FilterOptions?) {
|
||||||
|
AppDB?.setFilter(domain, set)
|
||||||
|
data[domain] = (set == FilterOptions.none) ? nil : set
|
||||||
|
NotifyDNSFilterChanged.post(domain)
|
||||||
|
}
|
||||||
|
}
|
||||||
250
main/Data Source/GroupedDomainDataSource.swift
Normal file
250
main/Data Source/GroupedDomainDataSource.swift
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
// ##########################
|
||||||
|
// #
|
||||||
|
// # MARK: DataSource
|
||||||
|
// #
|
||||||
|
// ##########################
|
||||||
|
|
||||||
|
class GroupedDomainDataSource {
|
||||||
|
|
||||||
|
private var tsLatest: Timestamp = 0
|
||||||
|
|
||||||
|
private let parent: String?
|
||||||
|
let pipeline: FilterPipeline<GroupedDomain>
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if #available(iOS 10.0, *) {
|
||||||
|
tvc.tableView.refreshControl = UIRefreshControl(call: #selector(reloadFromSource), on: self)
|
||||||
|
}
|
||||||
|
NotifyLogHistoryReset.observe(call: #selector(reloadFromSource), on: self)
|
||||||
|
NotifyDNSFilterChanged.observe(call: #selector(didChangeDomainFilter), on: self)
|
||||||
|
NotifySyncInsert.observe(call: #selector(syncInsert), on: self)
|
||||||
|
NotifySyncRemove.observe(call: #selector(syncRemove), on: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.start()
|
||||||
|
} else {
|
||||||
|
pipeline.reload(fromSource: true, whenDone: {
|
||||||
|
sync.start()
|
||||||
|
refreshControl?.endRefreshing()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Callback fired when user editslist 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
|
||||||
|
}
|
||||||
|
if let (i, obj) = pipeline.dataSourceGet(where: { $0.domain == domain }) {
|
||||||
|
var y = obj
|
||||||
|
y.options = DomainFilter[domain]
|
||||||
|
pipeline.update(y, at: i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: Table View Data Source
|
||||||
|
|
||||||
|
@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) {
|
||||||
|
let range = notification.object as! SQLiteRowRange
|
||||||
|
guard let latest = AppDB?.dnsLogsGrouped(range: range, parentDomain: parent) else {
|
||||||
|
assertionFailure("NotifySyncInsert fired with empty range")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Callback fired when background sync removed old entries from the list. (`NotifySyncRemove` notification)
|
||||||
|
@objc private func syncRemove(_ notification: Notification) {
|
||||||
|
let range = notification.object as! SQLiteRowRange
|
||||||
|
guard let outdated = AppDB?.dnsLogsGrouped(range: range, parentDomain: parent),
|
||||||
|
outdated.count > 0 else {
|
||||||
|
assertionFailure("NotifySyncRemove fired with empty range")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ################################
|
||||||
|
// #
|
||||||
|
// # MARK: - Delete History
|
||||||
|
// #
|
||||||
|
// ################################
|
||||||
|
|
||||||
|
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 deleteReloadFromSource(:)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reload a single data source entry. Callback fired by `reloadFromSource()`
|
||||||
|
private func partiallyReloadFromSource(_ 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
|
||||||
|
}
|
||||||
|
var removeOld = true
|
||||||
|
if let new = AppDB?.dnsLogsGrouped(since: sync.tsEarliest, upto: tsLatest, matchingDomain: affected, parentDomain: parent) {
|
||||||
|
assert(new.count < 2)
|
||||||
|
for var x in new {
|
||||||
|
x.options = DomainFilter[x.domain]
|
||||||
|
if old.object.domain == x.domain {
|
||||||
|
pipeline.update(x, at: old.index)
|
||||||
|
removeOld = false
|
||||||
|
} else {
|
||||||
|
pipeline.addNew(x)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if removeOld { pipeline.remove(indices: [old.index]) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ##########################
|
||||||
|
// #
|
||||||
|
// # MARK: - Edit Row
|
||||||
|
// #
|
||||||
|
// ##########################
|
||||||
|
|
||||||
|
protocol GroupedDomainEditRow : EditableRows, FilterPipelineDelegate {
|
||||||
|
var source: GroupedDomainDataSource { get set }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension GroupedDomainEditRow {
|
||||||
|
|
||||||
|
func editableRowActions(_ index: IndexPath) -> [(RowAction, String)] {
|
||||||
|
let x = source[index.row]
|
||||||
|
if x.domain.starts(with: "#") {
|
||||||
|
return [(.delete, "Delete")]
|
||||||
|
}
|
||||||
|
let b = x.options?.contains(.blocked) ?? false
|
||||||
|
let i = x.options?.contains(.ignored) ?? false
|
||||||
|
return [(.delete, "Delete"), (.block, b ? "Unblock" : "Block"), (.ignore, i ? "Unignore" : "Ignore")]
|
||||||
|
}
|
||||||
|
|
||||||
|
func editableRowActionColor(_: IndexPath, _ action: RowAction) -> UIColor? {
|
||||||
|
action == .block ? .systemOrange : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func editableRowUserInfo(_ index: IndexPath) -> Any? { source[index.row] }
|
||||||
|
|
||||||
|
func editableRowCallback(_ index: IndexPath, _ action: RowAction, _ userInfo: Any?) -> Bool {
|
||||||
|
let entry = userInfo as! GroupedDomain
|
||||||
|
switch action {
|
||||||
|
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)
|
||||||
|
}.presentIn(self)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showFilterSheet(_ entry: GroupedDomain, _ filter: FilterOptions) {
|
||||||
|
if entry.options?.contains(filter) ?? false {
|
||||||
|
DomainFilter.update(entry.domain, remove: filter)
|
||||||
|
} else {
|
||||||
|
// TODO: alert sheet
|
||||||
|
DomainFilter.update(entry.domain, add: filter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Extensions
|
||||||
|
extension TVCDomains : GroupedDomainEditRow {
|
||||||
|
override func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
|
||||||
|
getRowActionsIOS9(indexPath)
|
||||||
|
}
|
||||||
|
@available(iOS 11.0, *)
|
||||||
|
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
|
||||||
|
getRowActionsIOS11(indexPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TVCHosts : GroupedDomainEditRow {
|
||||||
|
override func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
|
||||||
|
getRowActionsIOS9(indexPath)
|
||||||
|
}
|
||||||
|
@available(iOS 11.0, *)
|
||||||
|
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
|
||||||
|
getRowActionsIOS11(indexPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
43
main/Data Source/RecordingsDB.swift
Normal file
43
main/Data Source/RecordingsDB.swift
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum RecordingsDB {
|
||||||
|
/// Get last started recording (where `start` is set, but `stop` is not)
|
||||||
|
static func getCurrent() -> Recording? { AppDB?.recordingGetOngoing() }
|
||||||
|
|
||||||
|
/// Create new recording and set `start` timestamp to `now()`
|
||||||
|
static func startNew() -> Recording? { try? AppDB?.recordingStartNew() }
|
||||||
|
|
||||||
|
/// Finalize recording by setting the `stop` timestamp to `now()`
|
||||||
|
static func stop(_ r: inout Recording) { AppDB?.recordingStop(&r) }
|
||||||
|
|
||||||
|
/// Get list of all recordings
|
||||||
|
static func list() -> [Recording] { AppDB?.recordingGetAll() ?? [] }
|
||||||
|
|
||||||
|
/// Copy log entries from generic `heap` table to recording specific `recLog` table
|
||||||
|
static func persist(_ r: Recording) { AppDB?.recordingLogsPersist(r) }
|
||||||
|
|
||||||
|
/// Get list of domains that occured during the recording
|
||||||
|
static func details(_ r: Recording) -> [RecordLog] {
|
||||||
|
AppDB?.recordingLogsGetGrouped(r) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update `title`, `appid`, and `notes` and post `NotifyRecordingChanged` notification.
|
||||||
|
static func update(_ r: Recording) {
|
||||||
|
AppDB?.recordingUpdate(r)
|
||||||
|
NotifyRecordingChanged.post((r, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete whole recording including all entries and post `NotifyRecordingChanged` notification.
|
||||||
|
static func delete(_ r: Recording) {
|
||||||
|
if (try? AppDB?.recordingDelete(r)) == true {
|
||||||
|
NotifyRecordingChanged.post((r, true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete individual entries from recording while keeping the recording alive.
|
||||||
|
/// - Returns: `true` if at least one row is deleted.
|
||||||
|
static func deleteDetails(_ r: Recording, domain: String) -> Bool {
|
||||||
|
((try? AppDB?.recordingLogsDelete(r.id, matchingDomain: domain)) ?? 0) > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
44
main/Data Source/SyncUpdate.swift
Normal file
44
main/Data Source/SyncUpdate.swift
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
class SyncUpdate {
|
||||||
|
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
|
||||||
|
NotifyDateFilterChanged.observe(call: #selector(didChangeDateFilter), on: self)
|
||||||
|
timer = Timer.repeating(interval, call: #selector(periodicUpdate), on: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func didChangeDateFilter() {
|
||||||
|
let lastXFilter = Pref.DateFilter.lastXMinTimestamp() ?? 0
|
||||||
|
if tsEarliest < lastXFilter {
|
||||||
|
if let excess = AppDB?.dnsLogsRowRange(between: tsEarliest, and: lastXFilter) {
|
||||||
|
NotifySyncRemove.post(excess)
|
||||||
|
}
|
||||||
|
} else if tsEarliest > lastXFilter {
|
||||||
|
if let missing = AppDB?.dnsLogsRowRange(between: lastXFilter, and: tsEarliest) {
|
||||||
|
NotifySyncInsert.post(missing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tsEarliest = lastXFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
func pause() { paused += 1 }
|
||||||
|
func start() { if paused > 0 { paused -= 1 } }
|
||||||
|
|
||||||
|
@objc private func periodicUpdate() {
|
||||||
|
guard paused == 0, let db = AppDB else { return }
|
||||||
|
if let inserted = db.dnsLogsPersist() { // move cache -> heap
|
||||||
|
NotifySyncInsert.post(inserted)
|
||||||
|
}
|
||||||
|
if let lastXFilter = Pref.DateFilter.lastXMinTimestamp(), tsEarliest < lastXFilter {
|
||||||
|
if let removed = db.dnsLogsRowRange(between: tsEarliest, and: lastXFilter) {
|
||||||
|
NotifySyncRemove.post(removed)
|
||||||
|
}
|
||||||
|
tsEarliest = lastXFilter
|
||||||
|
}
|
||||||
|
// TODO: periodic hard delete old logs (will reset rowids!)
|
||||||
|
}
|
||||||
|
}
|
||||||
41
main/Data Source/TestDataSource.swift
Normal file
41
main/Data Source/TestDataSource.swift
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
#if IOS_SIMULATOR
|
||||||
|
|
||||||
|
private let db = AppDB!
|
||||||
|
private var pStmt: OpaquePointer?
|
||||||
|
|
||||||
|
class TestDataSource {
|
||||||
|
|
||||||
|
static func load() {
|
||||||
|
QLog.Debug("SQLite path: \(URL.internalDB())")
|
||||||
|
|
||||||
|
let deleted = db.dnsLogsDelete("test.com", strict: false)
|
||||||
|
QLog.Debug("Deleting \(deleted) rows matching 'test.com'")
|
||||||
|
|
||||||
|
QLog.Debug("Writing 33 test logs")
|
||||||
|
pStmt = try! db.logWritePrepare()
|
||||||
|
try? db.logWrite(pStmt, "keeptest.com", blocked: false)
|
||||||
|
for _ in 1...4 { try? db.logWrite(pStmt, "test.com", blocked: false) }
|
||||||
|
for _ in 1...7 { try? db.logWrite(pStmt, "i.test.com", blocked: false) }
|
||||||
|
for i in 1...8 { try? db.logWrite(pStmt, "b.test.com", blocked: i>5) }
|
||||||
|
for i in 1...13 { try? db.logWrite(pStmt, "bi.test.com", blocked: i%2==0) }
|
||||||
|
|
||||||
|
db.dnsLogsPersist()
|
||||||
|
|
||||||
|
QLog.Debug("Creating 4 filters")
|
||||||
|
db.setFilter("b.test.com", .blocked)
|
||||||
|
db.setFilter("i.test.com", .ignored)
|
||||||
|
db.setFilter("bi.test.com", [.blocked, .ignored])
|
||||||
|
|
||||||
|
QLog.Debug("Done")
|
||||||
|
|
||||||
|
Timer.repeating(2, call: #selector(insertRandom), on: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc static func insertRandom() {
|
||||||
|
//QLog.Debug("Inserting 1 periodic log entry")
|
||||||
|
try? db.logWrite(pStmt, "\(arc4random() % 5).count.test.com", blocked: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
extension UIAlertController {
|
extension UIAlertController {
|
||||||
func presentIn(_ viewController: UIViewController?) {
|
func presentIn(_ viewController: UIViewController) {
|
||||||
viewController?.present(self, animated: true)
|
viewController.present(self, animated: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
77
main/Extensions/Array.swift
Normal file
77
main/Extensions/Array.swift
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
//extension Collection {
|
||||||
|
// subscript(ifExist i: Index?) -> Iterator.Element? {
|
||||||
|
// guard let i = i else { return nil }
|
||||||
|
// return indices.contains(i) ? self[i] : nil
|
||||||
|
// }
|
||||||
|
//}
|
||||||
|
|
||||||
|
extension Range where Bound == Int {
|
||||||
|
@inline(__always) func arr() -> [Bound] { self.map { $0 } }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Sorted Array
|
||||||
|
|
||||||
|
extension Array {
|
||||||
|
typealias CompareFn = (Element, Element) -> Bool
|
||||||
|
|
||||||
|
/// Binary tree search operation.
|
||||||
|
/// - Warning: Array must be sorted already.
|
||||||
|
/// - Parameter mustExist: Determine whether to return low index or `nil` if element is missing.
|
||||||
|
/// - Returns: Index or `nil` (only if `mustExist = true` and element does not exist).
|
||||||
|
/// - Complexity: O(log *n*), where *n* is the length of the array.
|
||||||
|
func binTreeIndex(of element: Element, compare fn: CompareFn, mustExist: Bool = false) -> Int? {
|
||||||
|
var lo = 0, hi = self.count - 1
|
||||||
|
while lo <= hi {
|
||||||
|
let mid = (lo + hi)/2
|
||||||
|
if fn(self[mid], element) {
|
||||||
|
lo = mid + 1
|
||||||
|
} else if fn(element, self[mid]) {
|
||||||
|
hi = mid - 1
|
||||||
|
} else {
|
||||||
|
return mid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mustExist ? nil : lo // not found, would be inserted at position lo
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Binary tree insert operation
|
||||||
|
/// - Warning: Array must be sorted already.
|
||||||
|
/// - Returns: Index at which `elem` was inserted
|
||||||
|
/// - Complexity: O(log *n*), where *n* is the length of the array.
|
||||||
|
@discardableResult mutating func binTreeInsert(_ elem: Element, compare fn: CompareFn) -> Int {
|
||||||
|
let newIndex = binTreeIndex(of: elem, compare: fn)!
|
||||||
|
insert(elem, at: newIndex)
|
||||||
|
return newIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Binary tree remove operation
|
||||||
|
/// - Warning: Array must be sorted already.
|
||||||
|
/// - Returns: Index of removed `elem` or `nil` if it does not exist
|
||||||
|
/// - Complexity: O(log *n*), where *n* is the length of the array.
|
||||||
|
@discardableResult mutating func binTreeRemove(_ elem: Element, compare fn: CompareFn) -> Int? {
|
||||||
|
if let i = binTreeIndex(of: elem, compare: fn, mustExist: true) {
|
||||||
|
remove(at: i)
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sorted synchronous comparison between elements
|
||||||
|
/// - Parameter sortedSubset: Must be a strict subset of the sorted array.
|
||||||
|
/// - Returns: List of elements that are **not** present in `sortedSubset`.
|
||||||
|
/// - Complexity: O(*m*+*n*), where *n* is the length of the array and *m* the length of the `sortedSubset`.
|
||||||
|
/// If indices are found earlier, *n* may be significantly less (on average: `n/2`)
|
||||||
|
func difference(toSubset sortedSubset: [Element], compare fn: CompareFn) -> [Element] {
|
||||||
|
var result: [Element] = []
|
||||||
|
var iter = makeIterator()
|
||||||
|
for rhs in sortedSubset {
|
||||||
|
while let lhs = iter.next(), fn(lhs, rhs) {
|
||||||
|
result.append(lhs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,102 +16,6 @@ struct QLog {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Collection {
|
|
||||||
subscript(ifExist i: Index?) -> Iterator.Element? {
|
|
||||||
guard let i = i else { return nil }
|
|
||||||
return indices.contains(i) ? self[i] : nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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]] = [:]
|
|
||||||
content.enumerateLines { line, _ in
|
|
||||||
let dom = line.split(separator: ".")
|
|
||||||
let tld = String(dom.first!)
|
|
||||||
let sld = String(dom.last!)
|
|
||||||
if res[tld] == nil { res[tld] = [:] }
|
|
||||||
res[tld]![sld] = true
|
|
||||||
}
|
|
||||||
return res
|
|
||||||
}()
|
|
||||||
|
|
||||||
extension String {
|
|
||||||
/// Check if string is equal to `domain` or ends with `.domain`
|
|
||||||
func isSubdomain(of domain: String) -> Bool { self == domain || self.hasSuffix("." + domain) }
|
|
||||||
/// Split string into top level domain part and host part
|
|
||||||
func splitDomainAndHost() -> (domain: String, host: String?) {
|
|
||||||
let lastChr = last?.asciiValue ?? 0
|
|
||||||
guard lastChr > UInt8(ascii: "9") || lastChr < UInt8(ascii: "0") else { // IP address
|
|
||||||
return (domain: "# IP connection", host: self)
|
|
||||||
}
|
|
||||||
var parts = components(separatedBy: ".")
|
|
||||||
guard let tld = parts.popLast(), let sld = parts.popLast() else {
|
|
||||||
return (domain: self, host: nil) // no subdomains, just plain SLD
|
|
||||||
}
|
|
||||||
var ending = sld + "." + tld
|
|
||||||
if listOfSLDs[tld]?[sld] ?? false, let rld = parts.popLast() {
|
|
||||||
ending = rld + "." + ending
|
|
||||||
}
|
|
||||||
return (domain: ending, host: parts.joined(separator: "."))
|
|
||||||
}
|
|
||||||
/// Returns `true` if String matches list of known second level domains (e.g., `co.uk`).
|
|
||||||
func isKnownSLD() -> Bool {
|
|
||||||
let parts = components(separatedBy: ".")
|
|
||||||
return parts.count == 2 && listOfSLDs[parts.last!]?[parts.first!] ?? false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Timer {
|
|
||||||
@discardableResult static func repeating(_ interval: TimeInterval, call selector: Selector, on target: Any, userInfo: Any? = nil) -> Timer {
|
|
||||||
Timer.scheduledTimer(timeInterval: interval, target: target, selector: selector,
|
|
||||||
userInfo: userInfo, repeats: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension DateFormatter {
|
|
||||||
convenience init(withFormat: String) {
|
|
||||||
self.init()
|
|
||||||
dateFormat = withFormat
|
|
||||||
}
|
|
||||||
func with(format: String) -> Self {
|
|
||||||
dateFormat = format
|
|
||||||
return self
|
|
||||||
}
|
|
||||||
func string(from ts: Timestamp) -> String {
|
|
||||||
string(from: Date.init(timeIntervalSince1970: Double(ts)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct TimeFormat {
|
|
||||||
static func from(_ duration: Timestamp) -> String {
|
|
||||||
String(format: "%02d:%02d", duration / 60, duration % 60)
|
|
||||||
}
|
|
||||||
static func from(_ duration: TimeInterval, millis: Bool = false) -> String {
|
|
||||||
let t = Int(duration)
|
|
||||||
if millis {
|
|
||||||
let mil = Int(duration * 1000) % 1000
|
|
||||||
return String(format: "%02d:%02d.%03d", t / 60, t % 60, mil)
|
|
||||||
}
|
|
||||||
return String(format: "%02d:%02d", t / 60, t % 60)
|
|
||||||
}
|
|
||||||
static func since(_ date: Date, millis: Bool = false) -> String {
|
|
||||||
from(Date().timeIntervalSince(date), millis: millis)
|
|
||||||
}
|
|
||||||
/// Formatted duration string, e.g., `20 min` or `7 days`
|
|
||||||
/// - Parameters:
|
|
||||||
/// - minutes: Duration in minutes
|
|
||||||
/// - style: Default: `.short`
|
|
||||||
static func short(minutes: Int, style: DateComponentsFormatter.UnitsStyle = .short) -> String? {
|
|
||||||
let dcf = DateComponentsFormatter()
|
|
||||||
dcf.maximumUnitCount = 1
|
|
||||||
dcf.allowedUnits = [.day, .hour, .minute]
|
|
||||||
dcf.unitsStyle = style
|
|
||||||
return dcf.string(from: DateComponents(minute: minutes))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension UIColor {
|
extension UIColor {
|
||||||
static var sysBg: UIColor { get { if #available(iOS 13.0, *) { return .systemBackground } else { return .white } }}
|
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 } }}
|
static var sysFg: UIColor { get { if #available(iOS 13.0, *) { return .label } else { return .black } }}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
let NotifyVPNStateChanged = NSNotification.Name("GlassVPNStateChanged") // VPNState!
|
let NotifyVPNStateChanged = NSNotification.Name("GlassVPNStateChanged") // VPNState!
|
||||||
let NotifyDNSFilterChanged = NSNotification.Name("PSIDNSFilterSettingsChanged") // nil!
|
let NotifyDNSFilterChanged = NSNotification.Name("PSIDNSFilterSettingsChanged") // domain: String?
|
||||||
let NotifyDateFilterChanged = NSNotification.Name("PSIDateFilterSettingsChanged") // nil!
|
let NotifyDateFilterChanged = NSNotification.Name("PSIDateFilterSettingsChanged") // nil!
|
||||||
let NotifyLogHistoryReset = NSNotification.Name("PSILogHistoryReset") // nil!
|
let NotifyLogHistoryReset = NSNotification.Name("PSILogHistoryReset") // domain: String?
|
||||||
|
let NotifySyncInsert = NSNotification.Name("PSISyncInsert") // SQLiteRowRange!
|
||||||
|
let NotifySyncRemove = NSNotification.Name("PSISyncRemove") // SQLiteRowRange!
|
||||||
let NotifyRecordingChanged = NSNotification.Name("PSIRecordingChanged") // (Recording, deleted: Bool)!
|
let NotifyRecordingChanged = NSNotification.Name("PSIRecordingChanged") // (Recording, deleted: Bool)!
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
let dateTimeFormat = DateFormatter(withFormat: "yyyy-MM-dd HH:mm:ss")
|
|
||||||
var currentVPNState: VPNState = .off
|
var currentVPNState: VPNState = .off
|
||||||
|
let sync = SyncUpdate(periodic: 7)
|
||||||
|
|
||||||
public enum VPNState : Int {
|
public enum VPNState : Int {
|
||||||
case on = 1, inbetween, off
|
case on = 1, inbetween, off
|
||||||
|
|||||||
51
main/Extensions/String.swift
Normal file
51
main/Extensions/String.swift
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
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) }
|
||||||
|
|
||||||
|
/// Extract second or third level domain name
|
||||||
|
func extractDomain() -> String {
|
||||||
|
let lastChr = last?.asciiValue ?? 0
|
||||||
|
guard lastChr > UInt8(ascii: "9") || lastChr < UInt8(ascii: "0") else { // IP address
|
||||||
|
return "# IP"
|
||||||
|
}
|
||||||
|
var parts = components(separatedBy: ".")
|
||||||
|
guard let tld = parts.popLast(), let sld = parts.popLast() else {
|
||||||
|
return self // no subdomains, just plain SLD
|
||||||
|
}
|
||||||
|
if listOfSLDs[tld]?[sld] ?? false, let rld = parts.popLast() {
|
||||||
|
return rld + "." + sld + "." + tld
|
||||||
|
}
|
||||||
|
return sld + "." + tld
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if String matches list of known second level domains (e.g., `co.uk`).
|
||||||
|
func isKnownSLD() -> Bool {
|
||||||
|
let parts = components(separatedBy: ".")
|
||||||
|
return parts.count == 2 && listOfSLDs[parts.last!]?[parts.first!] ?? false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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]] = [:]
|
||||||
|
content.enumerateLines { line, _ in
|
||||||
|
let dom = line.split(separator: ".")
|
||||||
|
let tld = String(dom.first!)
|
||||||
|
let sld = String(dom.last!)
|
||||||
|
if res[tld] == nil { res[tld] = [:] }
|
||||||
|
res[tld]![sld] = true
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}()
|
||||||
@@ -1,58 +1,27 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
extension GroupedDomain {
|
|
||||||
var detailCellText: String { get {
|
|
||||||
return blocked > 0
|
|
||||||
? "\(lastModified.asDateTime()) — \(blocked)/\(total) blocked"
|
|
||||||
: "\(lastModified.asDateTime()) — \(total)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension FilterOptions {
|
|
||||||
func tableRowImage() -> UIImage? {
|
|
||||||
let blocked = contains(.blocked)
|
|
||||||
let ignored = contains(.ignored)
|
|
||||||
if blocked { return UIImage(named: ignored ? "block_ignore" : "shield-x") }
|
|
||||||
if ignored { return UIImage(named: "quicklook-not") }
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Pull-to-Refresh
|
|
||||||
|
|
||||||
extension UIRefreshControl {
|
|
||||||
convenience init(call: Selector, on: UITableViewController) {
|
|
||||||
self.init()
|
|
||||||
addTarget(on, action: call, for: .valueChanged)
|
|
||||||
addTarget(self, action: #selector(endRefreshing), for: .valueChanged)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: TableView extensions
|
|
||||||
|
|
||||||
extension IndexPath {
|
extension IndexPath {
|
||||||
/// Convenience init with `section: 0`
|
/// Convenience init with `section: 0`
|
||||||
public init(row: Int) { self.init(row: row, section: 0) }
|
public init(row: Int) { self.init(row: row, section: 0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension UIRefreshControl {
|
||||||
|
convenience init(call: Selector, on target: Any) {
|
||||||
|
self.init()
|
||||||
|
addTarget(target, action: call, for: .valueChanged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - UITableView
|
||||||
|
|
||||||
extension UITableView {
|
extension UITableView {
|
||||||
/// Returns `true` if this `tableView` is the currently frontmost visible
|
/// Returns `true` if this `tableView` is the currently frontmost visible
|
||||||
var isFrontmost: Bool { window?.isKeyWindow ?? false }
|
var isFrontmost: Bool { window?.isKeyWindow ?? false }
|
||||||
|
|
||||||
/// If frontmost window, perform `deleteRows()`; If not, perform `reloadData()`
|
/// If frontmost window, perform `deleteRows()`; If not, perform `reloadData()`
|
||||||
func safeDeleteRow(_ index: Int, with animation: UITableView.RowAnimation = .automatic) {
|
func safeDeleteRows(_ indices: [Int], with animation: UITableView.RowAnimation = .automatic) {
|
||||||
isFrontmost ? deleteRows(at: [IndexPath(row: index)], with: animation) : reloadData()
|
isFrontmost ? deleteRows(at: indices.map {IndexPath(row: $0)}, with: animation) : reloadData()
|
||||||
}
|
}
|
||||||
/// If frontmost window, perform `reloadRows()`; If not, perform `reloadData()`
|
/// If frontmost window, perform `reloadRows()`; If not, perform `reloadData()`
|
||||||
func safeReloadRow(_ index: Int, with animation: UITableView.RowAnimation = .automatic) {
|
func safeReloadRow(_ index: Int, with animation: UITableView.RowAnimation = .automatic) {
|
||||||
@@ -69,71 +38,44 @@ extension UITableView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Incremental Update Delegate
|
// MARK: - EditableRows
|
||||||
|
|
||||||
enum IncrementalDataSourceUpdateOperation {
|
public enum RowAction {
|
||||||
case ReloadTable, Update, Insert, Delete, Move
|
case ignore, block, delete
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol IncrementalDataSourceUpdate : UITableViewController {
|
protocol EditableRows {
|
||||||
var dataSource: [GroupedDomain] { get set }
|
func editableRowUserInfo(_ index: IndexPath) -> Any?
|
||||||
func shouldLiveUpdateIncrementalDataSource() -> Bool
|
func editableRowActions(_ index: IndexPath) -> [(RowAction, String)]
|
||||||
/// - Warning: Called on a background thread!
|
func editableRowActionColor(_ index: IndexPath, _ action: RowAction) -> UIColor?
|
||||||
/// - Parameters:
|
@discardableResult func editableRowCallback(_ atIndexPath: IndexPath, _ action: RowAction, _ userInfo: Any?) -> Bool
|
||||||
/// - operation: Row update action
|
|
||||||
/// - row: Which row index is affected? `IndexPath(row: row)`
|
|
||||||
/// - moveTo: Only set for `Move` operation, otherwise `-1`
|
|
||||||
func didUpdateIncrementalDataSource(_ operation: IncrementalDataSourceUpdateOperation, row: Int, moveTo: Int)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension IncrementalDataSourceUpdate {
|
extension EditableRows where Self: UITableViewDelegate {
|
||||||
func shouldLiveUpdateIncrementalDataSource() -> Bool { true }
|
func getRowActionsIOS9(_ index: IndexPath) -> [UITableViewRowAction]? {
|
||||||
func didUpdateIncrementalDataSource(_: IncrementalDataSourceUpdateOperation, row: Int, moveTo: Int) {}
|
let userInfo = editableRowUserInfo(index)
|
||||||
// TODO: custom handling if cell is being edited
|
return editableRowActions(index).compactMap { a,t in
|
||||||
|
let x = UITableViewRowAction(style: a == .delete ? .destructive : .normal, title: t) { self.editableRowCallback($1, a, userInfo) }
|
||||||
func insertRow(_ obj: GroupedDomain, at index: Int) {
|
if let color = editableRowActionColor(index, a) {
|
||||||
dataSource.insert(obj, at: index)
|
x.backgroundColor = color
|
||||||
if shouldLiveUpdateIncrementalDataSource() {
|
|
||||||
DispatchQueue.main.sync { tableView.safeInsertRow(index, with: .left) }
|
|
||||||
}
|
|
||||||
didUpdateIncrementalDataSource(.Insert, row: index, moveTo: -1)
|
|
||||||
}
|
|
||||||
func moveRow(_ obj: GroupedDomain, from: Int, to: Int) {
|
|
||||||
dataSource.remove(at: from)
|
|
||||||
dataSource.insert(obj, at: to)
|
|
||||||
if shouldLiveUpdateIncrementalDataSource() {
|
|
||||||
DispatchQueue.main.sync {
|
|
||||||
if tableView.isFrontmost {
|
|
||||||
let source = IndexPath(row: from)
|
|
||||||
let cell = tableView.cellForRow(at: source)
|
|
||||||
cell?.detailTextLabel?.text = obj.detailCellText
|
|
||||||
tableView.moveRow(at: source, to: IndexPath(row: to))
|
|
||||||
} else {
|
|
||||||
tableView.reloadData()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return x
|
||||||
}
|
}
|
||||||
didUpdateIncrementalDataSource(.Move, row: from, moveTo: to)
|
|
||||||
}
|
}
|
||||||
func replaceRow(_ obj: GroupedDomain, at index: Int) {
|
@available(iOS 11.0, *)
|
||||||
dataSource[index] = obj
|
func getRowActionsIOS11(_ index: IndexPath) -> UISwipeActionsConfiguration? {
|
||||||
if shouldLiveUpdateIncrementalDataSource() {
|
let userInfo = editableRowUserInfo(index)
|
||||||
DispatchQueue.main.sync { tableView.safeReloadRow(index) }
|
return UISwipeActionsConfiguration(actions: editableRowActions(index).compactMap { a,t in
|
||||||
}
|
let x = UIContextualAction(style: a == .delete ? .destructive : .normal, title: t) { $2(self.editableRowCallback(index, a, userInfo)) }
|
||||||
didUpdateIncrementalDataSource(.Update, row: index, moveTo: -1)
|
x.backgroundColor = editableRowActionColor(index, a)
|
||||||
}
|
return x
|
||||||
func deleteRow(at index: Int) {
|
})
|
||||||
dataSource.remove(at: index)
|
|
||||||
if shouldLiveUpdateIncrementalDataSource() {
|
|
||||||
DispatchQueue.main.sync { tableView.safeDeleteRow(index) }
|
|
||||||
}
|
|
||||||
didUpdateIncrementalDataSource(.Delete, row: index, moveTo: -1)
|
|
||||||
}
|
|
||||||
func replaceData(with newData: [GroupedDomain]) {
|
|
||||||
dataSource = newData
|
|
||||||
if shouldLiveUpdateIncrementalDataSource() {
|
|
||||||
DispatchQueue.main.sync { tableView.reloadData() }
|
|
||||||
}
|
|
||||||
didUpdateIncrementalDataSource(.ReloadTable, row: -1, moveTo: -1)
|
|
||||||
}
|
}
|
||||||
|
func editableRowUserInfo(_ index: IndexPath) -> Any? { nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol EditActionsRemove : EditableRows {}
|
||||||
|
extension EditActionsRemove where Self: UITableViewController {
|
||||||
|
func editableRowActions(_: IndexPath) -> [(RowAction, String)] { [(.delete, "Remove")] }
|
||||||
|
func editableRowActionColor(_: IndexPath, _: RowAction) -> UIColor? { nil }
|
||||||
}
|
}
|
||||||
|
|||||||
74
main/Extensions/Time.swift
Normal file
74
main/Extensions/Time.swift
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
private let dateTimeFormat = DateFormatter(withFormat: "yyyy-MM-dd HH:mm:ss")
|
||||||
|
|
||||||
|
extension DateFormatter {
|
||||||
|
convenience init(withFormat: String) {
|
||||||
|
self.init()
|
||||||
|
dateFormat = withFormat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Timestamp {
|
||||||
|
/// Time string with format `yyyy-MM-dd HH:mm:ss`
|
||||||
|
func asDateTime() -> String {
|
||||||
|
dateTimeFormat.string(from: Date.init(timeIntervalSince1970: Double(self)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert `Timestamp` to `Date`
|
||||||
|
func toDate() -> Date {
|
||||||
|
Date(timeIntervalSince1970: Double(self))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current time as `Timestamp` (second accuracy)
|
||||||
|
static func now() -> Timestamp {
|
||||||
|
Timestamp(Date().timeIntervalSince1970)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create `Timestamp` with `now() - minutes * 60`
|
||||||
|
static func past(minutes: Int) -> Timestamp {
|
||||||
|
now() - Timestamp(minutes * 60)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Timer {
|
||||||
|
/// Recurring timer maintains a strong reference to `target`.
|
||||||
|
@discardableResult static func repeating(_ interval: TimeInterval, call selector: Selector, on target: Any, userInfo: Any? = nil) -> Timer {
|
||||||
|
Timer.scheduledTimer(timeInterval: interval, target: target, selector: selector,
|
||||||
|
userInfo: userInfo, repeats: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TimeFormat {
|
||||||
|
/// Time string with format `HH:mm`
|
||||||
|
static func from(_ duration: Timestamp) -> String {
|
||||||
|
String(format: "%02d:%02d", duration / 60, duration % 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Duration string with format `HH:mm` or `HH:mm.sss`
|
||||||
|
static func from(_ duration: TimeInterval, millis: Bool = false) -> String {
|
||||||
|
let t = Int(duration)
|
||||||
|
if millis {
|
||||||
|
let mil = Int(duration * 1000) % 1000
|
||||||
|
return String(format: "%02d:%02d.%03d", t / 60, t % 60, mil)
|
||||||
|
}
|
||||||
|
return String(format: "%02d:%02d", t / 60, t % 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Duration string with format `HH:mm` or `HH:mm.sss` since reference date
|
||||||
|
static func since(_ date: Date, millis: Bool = false) -> String {
|
||||||
|
from(Date().timeIntervalSince(date), millis: millis)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formatted duration string, e.g., `20 min` or `7 days`
|
||||||
|
/// - Parameters:
|
||||||
|
/// - minutes: Duration in minutes
|
||||||
|
/// - style: Default: `.short`
|
||||||
|
static func short(minutes: Int, style: DateComponentsFormatter.UnitsStyle = .short) -> String? {
|
||||||
|
let dcf = DateComponentsFormatter()
|
||||||
|
dcf.maximumUnitCount = 1
|
||||||
|
dcf.allowedUnits = [.day, .hour, .minute]
|
||||||
|
dcf.unitsStyle = style
|
||||||
|
return dcf.string(from: DateComponents(minute: minutes))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
fileprivate extension FileManager {
|
fileprivate extension FileManager {
|
||||||
func exportDir() -> URL {
|
// func exportDir() -> URL {
|
||||||
try! url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
// try! url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||||
}
|
// }
|
||||||
func appGroupDir() -> URL {
|
func appGroupDir() -> URL {
|
||||||
containerURL(forSecurityApplicationGroupIdentifier: "group.de.uni-bamberg.psi.AppCheck")!
|
containerURL(forSecurityApplicationGroupIdentifier: "group.de.uni-bamberg.psi.AppCheck")!
|
||||||
}
|
}
|
||||||
@@ -13,7 +13,7 @@ fileprivate extension FileManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension URL {
|
extension URL {
|
||||||
static func exportDir() -> URL { FileManager.default.exportDir() }
|
// static func exportDir() -> URL { FileManager.default.exportDir() }
|
||||||
static func appGroupDir() -> URL { FileManager.default.appGroupDir() }
|
static func appGroupDir() -> URL { FileManager.default.appGroupDir() }
|
||||||
static func internalDB() -> URL { FileManager.default.internalDB() }
|
static func internalDB() -> URL { FileManager.default.internalDB() }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ class TVCPreviousRecords: UITableViewController, EditActionsRemove {
|
|||||||
private var dataSource: [Recording] = []
|
private var dataSource: [Recording] = []
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
dataSource = DBWrp.listOfRecordings().reversed() // newest on top
|
dataSource = RecordingsDB.list().reversed() // newest on top
|
||||||
NotifyRecordingChanged.observe(call: #selector(recordingDidChange(_:)), on: self)
|
NotifyRecordingChanged.observe(call: #selector(recordingDidChange(_:)), on: self)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,8 +76,16 @@ class TVCPreviousRecords: UITableViewController, EditActionsRemove {
|
|||||||
|
|
||||||
// MARK: - Editing
|
// MARK: - Editing
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
|
||||||
|
getRowActionsIOS9(indexPath)
|
||||||
|
}
|
||||||
|
@available(iOS 11.0, *)
|
||||||
|
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
|
||||||
|
getRowActionsIOS11(indexPath)
|
||||||
|
}
|
||||||
|
|
||||||
func editableRowCallback(_ index: IndexPath, _ action: RowAction, _ userInfo: Any?) -> Bool {
|
func editableRowCallback(_ index: IndexPath, _ action: RowAction, _ userInfo: Any?) -> Bool {
|
||||||
DBWrp.recordingDelete(self.dataSource[index.row])
|
RecordingsDB.delete(self.dataSource[index.row])
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,15 +6,13 @@ class TVCRecordingDetails: UITableViewController, EditActionsRemove {
|
|||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
title = record.title ?? record.fallbackTitle
|
title = record.title ?? record.fallbackTitle
|
||||||
dataSource = DBWrp.recordingDetails(record)
|
dataSource = RecordingsDB.details(record)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Table View Data Source
|
// MARK: - Table View Data Source
|
||||||
|
|
||||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
override func tableView(_ _: UITableView, numberOfRowsInSection _: Int) -> Int { dataSource.count }
|
||||||
dataSource.count
|
|
||||||
}
|
|
||||||
|
|
||||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||||
let cell = tableView.dequeueReusableCell(withIdentifier: "PreviousRecordDetailCell")!
|
let cell = tableView.dequeueReusableCell(withIdentifier: "PreviousRecordDetailCell")!
|
||||||
@@ -27,10 +25,18 @@ class TVCRecordingDetails: UITableViewController, EditActionsRemove {
|
|||||||
|
|
||||||
// MARK: - Editing
|
// MARK: - Editing
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
|
||||||
|
getRowActionsIOS9(indexPath)
|
||||||
|
}
|
||||||
|
@available(iOS 11.0, *)
|
||||||
|
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
|
||||||
|
getRowActionsIOS11(indexPath)
|
||||||
|
}
|
||||||
|
|
||||||
func editableRowCallback(_ index: IndexPath, _ action: RowAction, _ userInfo: Any?) -> Bool {
|
func editableRowCallback(_ index: IndexPath, _ action: RowAction, _ userInfo: Any?) -> Bool {
|
||||||
if DBWrp.recordingDeleteDetails(record, domain: self.dataSource[index.row].domain) {
|
if RecordingsDB.deleteDetails(record, domain: dataSource[index.row].domain) {
|
||||||
self.dataSource.remove(at: index.row)
|
dataSource.remove(at: index.row)
|
||||||
self.tableView.deleteRows(at: [index], with: .automatic)
|
tableView.deleteRows(at: [index], with: .automatic)
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ class VCEditRecording: UIViewController, UITextFieldDelegate, UITextViewDelegate
|
|||||||
record.title = (inputTitle.text == "") ? nil : inputTitle.text
|
record.title = (inputTitle.text == "") ? nil : inputTitle.text
|
||||||
record.notes = (inputNotes.text == "") ? nil : inputNotes.text
|
record.notes = (inputNotes.text == "") ? nil : inputNotes.text
|
||||||
dismiss(animated: true) {
|
dismiss(animated: true) {
|
||||||
DBWrp.recordingUpdate(self.record)
|
RecordingsDB.update(self.record)
|
||||||
DBWrp.recordingPersist(self.record)
|
RecordingsDB.persist(self.record)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ class VCEditRecording: UIViewController, UITextFieldDelegate, UITextViewDelegate
|
|||||||
override func viewDidDisappear(_ animated: Bool) {
|
override func viewDidDisappear(_ animated: Bool) {
|
||||||
if deleteOnCancel {
|
if deleteOnCancel {
|
||||||
QLog.Debug("deleting record #\(record.id)")
|
QLog.Debug("deleting record #\(record.id)")
|
||||||
DBWrp.recordingDelete(record)
|
RecordingsDB.delete(record)
|
||||||
deleteOnCancel = false
|
deleteOnCancel = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class VCRecordings: UIViewController, UINavigationControllerDelegate {
|
|||||||
timeLabel.font = UIFont.monospacedDigitSystemFont(ofSize: timeLabel.font.pointSize, weight: UIFont.Weight(rawValue: weight))
|
timeLabel.font = UIFont.monospacedDigitSystemFont(ofSize: timeLabel.font.pointSize, weight: UIFont.Weight(rawValue: weight))
|
||||||
// hide timer if not running
|
// hide timer if not running
|
||||||
updateUI(setRecording: false, animated: false)
|
updateUI(setRecording: false, animated: false)
|
||||||
currentRecording = DBWrp.recordingGetCurrent()
|
currentRecording = RecordingsDB.getCurrent()
|
||||||
|
|
||||||
if !Pref.DidShowTutorial.Recordings {
|
if !Pref.DidShowTutorial.Recordings {
|
||||||
self.perform(#selector(showTutorial), with: nil, afterDelay: 0.5)
|
self.perform(#selector(showTutorial), with: nil, afterDelay: 0.5)
|
||||||
@@ -54,11 +54,11 @@ class VCRecordings: UIViewController, UINavigationControllerDelegate {
|
|||||||
|
|
||||||
@IBAction private func startRecordingButtonTapped(_ sender: UIButton) {
|
@IBAction private func startRecordingButtonTapped(_ sender: UIButton) {
|
||||||
if recordingTimer == nil {
|
if recordingTimer == nil {
|
||||||
currentRecording = DBWrp.recordingStartNew()
|
currentRecording = RecordingsDB.startNew()
|
||||||
startTimer(animate: true)
|
startTimer(animate: true)
|
||||||
} else {
|
} else {
|
||||||
stopTimer(animate: true)
|
stopTimer(animate: true)
|
||||||
DBWrp.recordingStop(¤tRecording!)
|
RecordingsDB.stop(¤tRecording!)
|
||||||
prevRecController.popToRootViewController(animated: true)
|
prevRecController.popToRootViewController(animated: true)
|
||||||
let editVC = (prevRecController.topViewController as! TVCPreviousRecords)
|
let editVC = (prevRecController.topViewController as! TVCPreviousRecords)
|
||||||
editVC.insertAndEditRecording(currentRecording!)
|
editVC.insertAndEditRecording(currentRecording!)
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
class TVCDomains: UITableViewController, IncrementalDataSourceUpdate, UISearchBarDelegate {
|
class TVCDomains: UITableViewController, UISearchBarDelegate, FilterPipelineDelegate {
|
||||||
|
|
||||||
|
lazy var source = GroupedDomainDataSource(withDelegate: self, parent: nil)
|
||||||
|
|
||||||
internal var dataSource: [GroupedDomain] = []
|
|
||||||
private func dataSource(at: Int) -> GroupedDomain {
|
|
||||||
dataSource[(searchActive ? searchIndices[at] : at)]
|
|
||||||
}
|
|
||||||
private var searchActive: Bool = false
|
private var searchActive: Bool = false
|
||||||
private var searchIndices: [Int] = []
|
|
||||||
private var searchTerm: String?
|
private var searchTerm: String?
|
||||||
private let searchBar: UISearchBar = {
|
private let searchBar: UISearchBar = {
|
||||||
let x = UISearchBar(frame: CGRect.init(x: 0, y: 0, width: 20, height: 10))
|
let x = UISearchBar(frame: CGRect.init(x: 0, y: 0, width: 20, height: 10))
|
||||||
@@ -22,48 +19,46 @@ class TVCDomains: UITableViewController, IncrementalDataSourceUpdate, UISearchBa
|
|||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
if #available(iOS 10.0, *) {
|
|
||||||
tableView.refreshControl = UIRefreshControl(call: #selector(reloadDataSource), on: self)
|
|
||||||
}
|
|
||||||
NotifyLogHistoryReset.observe(call: #selector(reloadDataSource), on: self)
|
|
||||||
reloadDataSource()
|
|
||||||
DBWrp.dataA_delegate = self
|
|
||||||
searchBar.delegate = self
|
searchBar.delegate = self
|
||||||
NotifyDateFilterChanged.observe(call: #selector(dateFilterChanged), on: self)
|
NotifyDateFilterChanged.observe(call: #selector(didChangeDateFilter), on: self)
|
||||||
dateFilterChanged()
|
didChangeDateFilter()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func reloadDataSource() {
|
private var didLoadAlready = false
|
||||||
dataSource = DBWrp.listOfDomains()
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
if searchActive {
|
if !didLoadAlready {
|
||||||
searchBar(searchBar, textDidChange: "")
|
didLoadAlready = true
|
||||||
} else {
|
source.reloadFromSource()
|
||||||
tableView.reloadData()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
|
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
|
||||||
if let index = tableView.indexPathForSelectedRow?.row {
|
if let index = tableView.indexPathForSelectedRow?.row {
|
||||||
(segue.destination as? TVCHosts)?.parentDomain = dataSource(at: index).domain
|
(segue.destination as? TVCHosts)?.parentDomain = source[index].domain
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Table View Delegate
|
// MARK: - Table View Data Source
|
||||||
|
|
||||||
override func tableView(_ _: UITableView, numberOfRowsInSection _: Int) -> Int {
|
override func tableView(_ _: UITableView, numberOfRowsInSection _: Int) -> Int { source.numberOfRows }
|
||||||
searchActive ? searchIndices.count : dataSource.count
|
|
||||||
}
|
|
||||||
|
|
||||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||||
let cell = tableView.dequeueReusableCell(withIdentifier: "DomainCell")!
|
let cell = tableView.dequeueReusableCell(withIdentifier: "DomainCell")!
|
||||||
let entry = dataSource(at: indexPath.row)
|
let entry = source[indexPath.row]
|
||||||
cell.textLabel?.text = entry.domain
|
cell.textLabel?.text = entry.domain
|
||||||
cell.detailTextLabel?.text = entry.detailCellText
|
cell.detailTextLabel?.text = entry.detailCellText
|
||||||
cell.imageView?.image = entry.options?.tableRowImage()
|
cell.imageView?.image = entry.options?.tableRowImage()
|
||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func rowNeedsUpdate(_ 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
|
// MARK: - Search
|
||||||
|
|
||||||
@@ -77,55 +72,31 @@ class TVCDomains: UITableViewController, IncrementalDataSourceUpdate, UISearchBa
|
|||||||
|
|
||||||
private func setSearch(hidden: Bool) {
|
private func setSearch(hidden: Bool) {
|
||||||
searchActive = !hidden
|
searchActive = !hidden
|
||||||
searchIndices = []
|
|
||||||
searchTerm = nil
|
searchTerm = nil
|
||||||
searchBar.text = nil
|
searchBar.text = nil
|
||||||
tableView.tableHeaderView = hidden ? nil : searchBar
|
tableView.tableHeaderView = hidden ? nil : searchBar
|
||||||
if !hidden { searchBar.becomeFirstResponder() }
|
if searchActive {
|
||||||
|
source.pipeline.addFilter("search") {
|
||||||
|
$0.domain.lowercased().contains(self.searchTerm ?? "")
|
||||||
|
}
|
||||||
|
searchBar.becomeFirstResponder()
|
||||||
|
} else {
|
||||||
|
source.pipeline.removeFilter(withId: "search")
|
||||||
|
}
|
||||||
tableView.reloadData()
|
tableView.reloadData()
|
||||||
}
|
}
|
||||||
|
|
||||||
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
|
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
|
||||||
NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(performSearch), object: nil)
|
NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(performSearch), object: nil)
|
||||||
perform(#selector(performSearch), with: nil, afterDelay: 0.3)
|
perform(#selector(performSearch), with: nil, afterDelay: 0.2)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func performSearch() {
|
@objc private func performSearch() {
|
||||||
searchTerm = searchBar.text?.lowercased() ?? ""
|
searchTerm = searchBar.text?.lowercased() ?? ""
|
||||||
searchIndices = dataSource.enumerated().compactMap {
|
source.pipeline.reloadFilter(withId: "search")
|
||||||
if $1.domain.lowercased().contains(searchTerm!) { return $0 }
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
tableView.reloadData()
|
tableView.reloadData()
|
||||||
}
|
}
|
||||||
|
|
||||||
func shouldLiveUpdateIncrementalDataSource() -> Bool { !searchActive }
|
|
||||||
|
|
||||||
func didUpdateIncrementalDataSource(_ operation: IncrementalDataSourceUpdateOperation, row: Int, moveTo: Int) {
|
|
||||||
guard searchActive else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
switch operation {
|
|
||||||
case .ReloadTable:
|
|
||||||
DispatchQueue.main.sync { tableView.reloadData() }
|
|
||||||
case .Insert:
|
|
||||||
if dataSource[row].domain.lowercased().contains(searchTerm ?? "") {
|
|
||||||
searchIndices.insert(row, at: 0)
|
|
||||||
DispatchQueue.main.sync { tableView.safeInsertRow(0, with: .left) }
|
|
||||||
}
|
|
||||||
case .Delete:
|
|
||||||
if let idx = searchIndices.firstIndex(of: row) {
|
|
||||||
searchIndices.remove(at: idx)
|
|
||||||
DispatchQueue.main.sync { tableView.safeDeleteRow(idx) }
|
|
||||||
}
|
|
||||||
case .Update, .Move:
|
|
||||||
if let idx = searchIndices.firstIndex(of: row) {
|
|
||||||
if operation == .Move { searchIndices[idx] = moveTo }
|
|
||||||
DispatchQueue.main.sync { tableView.safeReloadRow(idx) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Filter
|
// MARK: - Filter
|
||||||
|
|
||||||
@@ -138,7 +109,7 @@ class TVCDomains: UITableViewController, IncrementalDataSourceUpdate, UISearchBa
|
|||||||
present(vc, animated: true)
|
present(vc, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func dateFilterChanged() {
|
@objc private func didChangeDateFilter() {
|
||||||
switch Pref.DateFilter.Kind {
|
switch Pref.DateFilter.Kind {
|
||||||
case .ABRange: // read start/end time
|
case .ABRange: // read start/end time
|
||||||
self.filterButtonDetail.title = "A – B"
|
self.filterButtonDetail.title = "A – B"
|
||||||
|
|||||||
@@ -12,14 +12,55 @@ class TVCHostDetails: UITableViewController {
|
|||||||
tableView.refreshControl = UIRefreshControl(call: #selector(reloadDataSource), on: self)
|
tableView.refreshControl = UIRefreshControl(call: #selector(reloadDataSource), on: self)
|
||||||
}
|
}
|
||||||
NotifyLogHistoryReset.observe(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()
|
reloadDataSource()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func reloadDataSource() {
|
@objc func reloadDataSource(sender: Any? = nil) {
|
||||||
dataSource = DBWrp.listOfTimes(fullDomain)
|
let refreshControl = sender as? UIRefreshControl
|
||||||
tableView.reloadData()
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||||
|
|||||||
@@ -1,45 +1,32 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
class TVCHosts: UITableViewController, IncrementalDataSourceUpdate {
|
class TVCHosts: UITableViewController, FilterPipelineDelegate {
|
||||||
|
|
||||||
|
lazy var source = GroupedDomainDataSource(withDelegate: self, parent: parentDomain)
|
||||||
|
|
||||||
public var parentDomain: String!
|
public var parentDomain: String!
|
||||||
internal var dataSource: [GroupedDomain] = []
|
|
||||||
private var isSpecial: Bool = false
|
private var isSpecial: Bool = false
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
navigationItem.prompt = parentDomain
|
navigationItem.prompt = parentDomain
|
||||||
isSpecial = (parentDomain.first == "#") // aka: "# IP address"
|
isSpecial = (parentDomain.first == "#") // aka: "# IP address"
|
||||||
if #available(iOS 10.0, *) {
|
source.reloadFromSource() // init lazy var
|
||||||
tableView.refreshControl = UIRefreshControl(call: #selector(reloadDataSource), on: self)
|
|
||||||
}
|
|
||||||
NotifyLogHistoryReset.observe(call: #selector(reloadDataSource), on: self)
|
|
||||||
reloadDataSource()
|
|
||||||
DBWrp.currentlyOpenParent = parentDomain
|
|
||||||
DBWrp.dataB_delegate = self
|
|
||||||
}
|
|
||||||
deinit {
|
|
||||||
DBWrp.currentlyOpenParent = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func reloadDataSource() {
|
|
||||||
dataSource = DBWrp.listOfHosts(parentDomain)
|
|
||||||
tableView.reloadData()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
|
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
|
||||||
if let index = tableView.indexPathForSelectedRow?.row {
|
if let index = tableView.indexPathForSelectedRow?.row {
|
||||||
(segue.destination as? TVCHostDetails)?.fullDomain = dataSource[index].domain
|
(segue.destination as? TVCHostDetails)?.fullDomain = source[index].domain
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Data Source
|
// MARK: - Table View Data Source
|
||||||
|
|
||||||
override func tableView(_ _: UITableView, numberOfRowsInSection _: Int) -> Int { dataSource.count }
|
override func tableView(_ _: UITableView, numberOfRowsInSection _: Int) -> Int { source.numberOfRows }
|
||||||
|
|
||||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||||
let cell = tableView.dequeueReusableCell(withIdentifier: "HostCell")!
|
let cell = tableView.dequeueReusableCell(withIdentifier: "HostCell")!
|
||||||
let entry = dataSource[indexPath.row]
|
let entry = source[indexPath.row]
|
||||||
if isSpecial {
|
if isSpecial {
|
||||||
// currently only used for IP addresses
|
// currently only used for IP addresses
|
||||||
cell.textLabel?.text = entry.domain
|
cell.textLabel?.text = entry.domain
|
||||||
@@ -51,4 +38,11 @@ class TVCHosts: UITableViewController, IncrementalDataSourceUpdate {
|
|||||||
cell.imageView?.image = entry.options?.tableRowImage()
|
cell.imageView?.image = entry.options?.tableRowImage()
|
||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func rowNeedsUpdate(_ 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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,7 +76,6 @@ class VCDateFilter: UIViewController, UIGestureRecognizerDelegate {
|
|||||||
if Pref.DateFilter.Kind != newKind || Pref.DateFilter.LastXMin != newXMin {
|
if Pref.DateFilter.Kind != newKind || Pref.DateFilter.LastXMin != newXMin {
|
||||||
Pref.DateFilter.Kind = newKind
|
Pref.DateFilter.Kind = newKind
|
||||||
Pref.DateFilter.LastXMin = newXMin
|
Pref.DateFilter.LastXMin = newXMin
|
||||||
DBWrp.reloadAfterDateFilterHasChanged()
|
|
||||||
NotifyDateFilterChanged.post()
|
NotifyDateFilterChanged.post()
|
||||||
}
|
}
|
||||||
dismiss(animated: true)
|
dismiss(animated: true)
|
||||||
|
|||||||
@@ -1,23 +1,36 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
class TVCFilter: UITableViewController, EditActionsRemove {
|
class TVCFilter: UITableViewController, EditActionsRemove {
|
||||||
var currentFilter: FilterOptions = .none
|
var currentFilter: FilterOptions = .none // set by segue
|
||||||
private var dataSource: [String] = []
|
private var dataSource: [String] = []
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
// if #available(iOS 10.0, *) {
|
NotifyDNSFilterChanged.observe(call: #selector(didChangeDomainFilter), on: self)
|
||||||
// tableView.refreshControl = UIRefreshControl(call: #selector(reloadDataSource), on: self)
|
|
||||||
// }
|
|
||||||
NotifyDNSFilterChanged.observe(call: #selector(reloadDataSource), on: self)
|
|
||||||
reloadDataSource()
|
reloadDataSource()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func reloadDataSource() {
|
func reloadDataSource() {
|
||||||
dataSource = DBWrp.dataF_list(currentFilter)
|
dataSource = DomainFilter.list(where: currentFilter)
|
||||||
tableView.reloadData()
|
tableView.reloadData()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc func didChangeDomainFilter(_ notification: Notification) {
|
||||||
|
guard let domain = notification.object as? String else {
|
||||||
|
reloadDataSource()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if DomainFilter[domain]?.contains(currentFilter) ?? false {
|
||||||
|
let i = dataSource.binTreeIndex(of: domain, compare: (<))!
|
||||||
|
if i >= dataSource.count || dataSource[i] != domain {
|
||||||
|
dataSource.insert(domain, at: i)
|
||||||
|
tableView.safeInsertRow(i)
|
||||||
|
}
|
||||||
|
} else if let i = dataSource.binTreeRemove(domain, compare: (<)) {
|
||||||
|
tableView.safeDeleteRows([i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@IBAction private func addNewFilter() {
|
@IBAction private func addNewFilter() {
|
||||||
let desc: String
|
let desc: String
|
||||||
switch currentFilter {
|
switch currentFilter {
|
||||||
@@ -33,7 +46,7 @@ class TVCFilter: UITableViewController, EditActionsRemove {
|
|||||||
ErrorAlert("Entered domain is not valid. Filter can't match country TLD only.").presentIn(self)
|
ErrorAlert("Entered domain is not valid. Filter can't match country TLD only.").presentIn(self)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
DBWrp.updateFilter(dom, add: self.currentFilter)
|
DomainFilter.update(dom, add: self.currentFilter)
|
||||||
}
|
}
|
||||||
alert.addTextField {
|
alert.addTextField {
|
||||||
$0.placeholder = "cdn.domain.tld"
|
$0.placeholder = "cdn.domain.tld"
|
||||||
@@ -42,7 +55,7 @@ class TVCFilter: UITableViewController, EditActionsRemove {
|
|||||||
alert.presentIn(self)
|
alert.presentIn(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Table View Delegate
|
// MARK: - Table View Data Source
|
||||||
|
|
||||||
override func tableView(_ _: UITableView, numberOfRowsInSection _: Int) -> Int { dataSource.count }
|
override func tableView(_ _: UITableView, numberOfRowsInSection _: Int) -> Int { dataSource.count }
|
||||||
|
|
||||||
@@ -57,11 +70,17 @@ class TVCFilter: UITableViewController, EditActionsRemove {
|
|||||||
|
|
||||||
// MARK: - Editing
|
// MARK: - Editing
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
|
||||||
|
getRowActionsIOS9(indexPath)
|
||||||
|
}
|
||||||
|
@available(iOS 11.0, *)
|
||||||
|
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
|
||||||
|
getRowActionsIOS11(indexPath)
|
||||||
|
}
|
||||||
|
|
||||||
func editableRowCallback(_ index: IndexPath, _ action: RowAction, _ userInfo: Any?) -> Bool {
|
func editableRowCallback(_ index: IndexPath, _ action: RowAction, _ userInfo: Any?) -> Bool {
|
||||||
let domain = dataSource[index.row]
|
let domain = dataSource[index.row]
|
||||||
DBWrp.updateFilter(domain, remove: currentFilter)
|
DomainFilter.update(domain, remove: currentFilter)
|
||||||
dataSource.remove(at: index.row)
|
|
||||||
tableView.deleteRows(at: [index], with: .automatic)
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,11 +16,9 @@ class TVCSettings: UITableViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc func reloadDataSource() {
|
@objc func reloadDataSource() {
|
||||||
let (blocked, ignored) = DBWrp.dataF_counts()
|
let (blocked, ignored) = DomainFilter.counts()
|
||||||
DispatchQueue.main.async {
|
cellDomainsIgnored.detailTextLabel?.text = "\(ignored) Domains"
|
||||||
self.cellDomainsIgnored.detailTextLabel?.text = "\(ignored) Domains"
|
cellDomainsBlocked.detailTextLabel?.text = "\(blocked) Domains"
|
||||||
self.cellDomainsBlocked.detailTextLabel?.text = "\(blocked) Domains"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@IBAction func toggleVPNProxy(_ sender: UISwitch) {
|
@IBAction func toggleVPNProxy(_ sender: UISwitch) {
|
||||||
@@ -28,28 +26,8 @@ class TVCSettings: UITableViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@IBAction func exportDB(_ sender: Any) {
|
@IBAction func exportDB(_ sender: Any) {
|
||||||
// TODO: export partly?
|
|
||||||
// TODO: show header-banner of success
|
|
||||||
// Share Sheet
|
|
||||||
let sheet = UIActivityViewController(activityItems: [URL.internalDB()], applicationActivities: nil)
|
let sheet = UIActivityViewController(activityItems: [URL.internalDB()], applicationActivities: nil)
|
||||||
self.present(sheet, animated: true)
|
self.present(sheet, animated: true)
|
||||||
// Save to Files app
|
|
||||||
// self.present(UIDocumentPickerViewController(url: FileManager.default.internalDB(), in: .exportToService), animated: true)
|
|
||||||
// Shows Alert and exports to Documents directory
|
|
||||||
// AskAlert(title: "Export results?", text: """
|
|
||||||
// This action will copy the internal database to the app's local Documents directory. You can use the Files app to access the database file.
|
|
||||||
//
|
|
||||||
// Note: This will make your DNS requests available to other apps!
|
|
||||||
// """, buttonText: "Export") {
|
|
||||||
// do {
|
|
||||||
// let dest = try SQLiteDatabase.export()
|
|
||||||
// let folder = dest.deletingLastPathComponent()
|
|
||||||
// let out = folder.lastPathComponent + "/" + dest.lastPathComponent
|
|
||||||
// Alert(title: "Successful", text: "File exported to '\(out)'", buttonText: "OK").presentIn(self)
|
|
||||||
// } catch {
|
|
||||||
// ErrorAlert(error).presentIn(self)
|
|
||||||
// }
|
|
||||||
// }.presentIn(self)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@IBAction func resetTutorialAlerts(_ sender: UIButton) {
|
@IBAction func resetTutorialAlerts(_ sender: UIButton) {
|
||||||
@@ -64,7 +42,10 @@ class TVCSettings: UITableViewController {
|
|||||||
"You are about to delete all results that have been logged in the past. " +
|
"You are about to delete all results that have been logged in the past. " +
|
||||||
"Your preferences for blocked and ignored domains are preserved.\n" +
|
"Your preferences for blocked and ignored domains are preserved.\n" +
|
||||||
"Continue?", buttonText: "Delete", buttonStyle: .destructive) { _ in
|
"Continue?", buttonText: "Delete", buttonStyle: .destructive) { _ in
|
||||||
DBWrp.deleteHistory()
|
DispatchQueue.global().async {
|
||||||
|
try? AppDB?.dnsLogsDeleteAll()
|
||||||
|
NotifyLogHistoryReset.postAsyncMain()
|
||||||
|
}
|
||||||
}.presentIn(self)
|
}.presentIn(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user