Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80829ad015 | ||
|
|
661bf5d30a | ||
|
|
38f4166503 | ||
|
|
d96038c7e3 | ||
|
|
7d6b071d8a | ||
|
|
b17fb3c354 | ||
|
|
10b43a0f67 | ||
|
|
4092a9ba55 | ||
|
|
2d35c863e4 | ||
|
|
8424c161b9 | ||
|
|
9485d7e9b5 | ||
|
|
9f26bdfba1 | ||
|
|
412d533275 | ||
|
|
245bb46e4f | ||
|
|
70508c1325 | ||
|
|
b44fd788b5 | ||
|
|
80f3503e16 | ||
|
|
d0056c0275 | ||
|
|
e7560479ee | ||
|
|
ed5298f7a2 | ||
|
|
647eca310f | ||
|
|
515c296b26 | ||
|
|
61ae50cdfa | ||
|
|
fcb6e9c5dd | ||
|
|
79f836016a | ||
|
|
144773ddaa |
@@ -7,7 +7,10 @@
|
||||
objects = {
|
||||
|
||||
/* 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 */; };
|
||||
540E67822433483D00871BBE /* VCEditRecording.swift in Sources */ = {isa = PBXBuildFile; fileRef = 540E67812433483D00871BBE /* VCEditRecording.swift */; };
|
||||
540E67842433FAFE00871BBE /* TVCPreviousRecords.swift in Sources */ = {isa = PBXBuildFile; fileRef = 540E67832433FAFE00871BBE /* TVCPreviousRecords.swift */; };
|
||||
5412F8EE24571B8200A63D7A /* VCDateFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5412F8ED24571B8100A63D7A /* VCDateFilter.swift */; };
|
||||
541A957623E602DF00C09C19 /* LaunchIcon.png in Resources */ = {isa = PBXBuildFile; fileRef = 541A957523E602DF00C09C19 /* LaunchIcon.png */; };
|
||||
541AC5D82399498A00A769D7 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 541AC5D72399498A00A769D7 /* AppDelegate.swift */; };
|
||||
541AC5DD2399498A00A769D7 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 541AC5DB2399498A00A769D7 /* Main.storyboard */; };
|
||||
@@ -17,22 +20,27 @@
|
||||
542E2A9A24051556001462DC /* TVCSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 542E2A9924051556001462DC /* TVCSettings.swift */; };
|
||||
543CDB2023EEE61900B7F323 /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 543CDB1F23EEE61900B7F323 /* PacketTunnelProvider.swift */; };
|
||||
543CDB2523EEE61900B7F323 /* GlassVPN.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 543CDB1D23EEE61900B7F323 /* GlassVPN.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
54448A2E2486464F00771C96 /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54448A2D2486464F00771C96 /* Array.swift */; };
|
||||
54448A30248647D900771C96 /* Time.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54448A2F248647D900771C96 /* Time.swift */; };
|
||||
544C95262407B1C700AB89D0 /* SharedState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 544C95252407B1C700AB89D0 /* SharedState.swift */; };
|
||||
546063E523FEFAFE008F505A /* SQDB.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B7562223D7B2DC008F0C41 /* SQDB.swift */; };
|
||||
54751E512423955100168273 /* FileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54751E502423955000168273 /* FileManager.swift */; };
|
||||
54751E522423955100168273 /* FileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54751E502423955000168273 /* FileManager.swift */; };
|
||||
54953E3323DC752E0054345C /* SQDB.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B7562223D7B2DC008F0C41 /* SQDB.swift */; };
|
||||
5458EBC0243A3F2200CFEB15 /* TVCRecordingDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5458EBBF243A3F2200CFEB15 /* TVCRecordingDetails.swift */; };
|
||||
545DDDCF243E6267003B6544 /* TutorialSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 545DDDCE243E6267003B6544 /* TutorialSheet.swift */; };
|
||||
545DDDD124436983003B6544 /* QuickUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 545DDDD024436983003B6544 /* QuickUI.swift */; };
|
||||
545DDDD424466D37003B6544 /* AutoLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 545DDDD324466D37003B6544 /* AutoLayout.swift */; };
|
||||
546063E523FEFAFE008F505A /* DBCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B7562223D7B2DC008F0C41 /* DBCore.swift */; };
|
||||
54751E512423955100168273 /* URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54751E502423955000168273 /* URL.swift */; };
|
||||
54751E522423955100168273 /* URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54751E502423955000168273 /* URL.swift */; };
|
||||
54953E3323DC752E0054345C /* DBCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B7562223D7B2DC008F0C41 /* DBCore.swift */; };
|
||||
54953E5F23DEBE840054345C /* TVCDomains.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54953E5E23DEBE840054345C /* TVCDomains.swift */; };
|
||||
54953E6123E0D69A0054345C /* TVCHosts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54953E6023E0D69A0054345C /* TVCHosts.swift */; };
|
||||
54953E6F23E44CD00054345C /* TVCHostDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54953E6E23E44CD00054345C /* TVCHostDetails.swift */; };
|
||||
54953E7123E473F10054345C /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = 54953E7023E473F10054345C /* Settings.bundle */; };
|
||||
54B34594240E6343004C53CC /* TVCFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B34593240E6343004C53CC /* TVCFilter.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 */; };
|
||||
54B345A9241BBA0B004C53CC /* Generic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B345A8241BBA0B004C53CC /* Generic.swift */; };
|
||||
54B345AB241BBA5B004C53CC /* AlertSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B345AA241BBA5B004C53CC /* AlertSheet.swift */; };
|
||||
54B345AD241BBB00004C53CC /* GroupedDomain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B345AC241BBB00004C53CC /* GroupedDomain.swift */; };
|
||||
54B345AD241BBB00004C53CC /* DBExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B345AC241BBB00004C53CC /* DBExtensions.swift */; };
|
||||
54B345B0242264F8004C53CC /* third-level.txt in Resources */ = {isa = PBXBuildFile; fileRef = 54B345AF242264F8004C53CC /* third-level.txt */; };
|
||||
54C056DB23E9E36E00214A3F /* AppInfoType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54C056DA23E9E36E00214A3F /* AppInfoType.swift */; };
|
||||
54C056DD23E9EEF700214A3F /* BundleIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54C056DC23E9EEF700214A3F /* BundleIcon.swift */; };
|
||||
@@ -86,8 +94,6 @@
|
||||
54CA02952426B2FD003A5E04 /* DNSEnums.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54CA02242426B2FC003A5E04 /* DNSEnums.swift */; };
|
||||
54CA02962426B2FD003A5E04 /* PacketProtocolParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54CA02262426B2FC003A5E04 /* PacketProtocolParser.swift */; };
|
||||
54CA02972426B2FD003A5E04 /* IPPacket.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54CA02272426B2FC003A5E04 /* IPPacket.swift */; };
|
||||
54CA02982426B2FD003A5E04 /* IPMutablePacket.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54CA02282426B2FC003A5E04 /* IPMutablePacket.swift */; };
|
||||
54CA02992426B2FD003A5E04 /* TCPMutablePacket.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54CA02292426B2FC003A5E04 /* TCPMutablePacket.swift */; };
|
||||
54CA029A2426B2FD003A5E04 /* Observer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54CA022B2426B2FC003A5E04 /* Observer.swift */; };
|
||||
54CA029C2426B2FD003A5E04 /* AdapterSocketEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54CA022E2426B2FC003A5E04 /* AdapterSocketEvent.swift */; };
|
||||
54CA029D2426B2FD003A5E04 /* ProxyServerEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54CA022F2426B2FC003A5E04 /* ProxyServerEvent.swift */; };
|
||||
@@ -102,8 +108,6 @@
|
||||
54CA02A72426B2FD003A5E04 /* DirectAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54CA023B2426B2FC003A5E04 /* DirectAdapter.swift */; };
|
||||
54CA02A82426B2FD003A5E04 /* SOCKS5Adapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54CA023C2426B2FC003A5E04 /* SOCKS5Adapter.swift */; };
|
||||
54CA02A92426B2FD003A5E04 /* RejectAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54CA023D2426B2FC003A5E04 /* RejectAdapter.swift */; };
|
||||
54CA02AA2426B2FD003A5E04 /* SpeedAdapterFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54CA023F2426B2FC003A5E04 /* SpeedAdapterFactory.swift */; };
|
||||
54CA02AB2426B2FD003A5E04 /* ShadowsocksAdapterFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54CA02402426B2FC003A5E04 /* ShadowsocksAdapterFactory.swift */; };
|
||||
54CA02AC2426B2FD003A5E04 /* AuthenticationServerAdapterFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54CA02412426B2FC003A5E04 /* AuthenticationServerAdapterFactory.swift */; };
|
||||
54CA02AD2426B2FD003A5E04 /* RejectAdapterFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54CA02422426B2FC003A5E04 /* RejectAdapterFactory.swift */; };
|
||||
54CA02AE2426B2FD003A5E04 /* AdapterFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54CA02432426B2FD003A5E04 /* AdapterFactory.swift */; };
|
||||
@@ -112,10 +116,6 @@
|
||||
54CA02B12426B2FD003A5E04 /* ServerAdapterFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54CA02462426B2FD003A5E04 /* ServerAdapterFactory.swift */; };
|
||||
54CA02B22426B2FD003A5E04 /* AdapterFactoryManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54CA02472426B2FD003A5E04 /* AdapterFactoryManager.swift */; };
|
||||
54CA02B32426B2FD003A5E04 /* HTTPAdapterFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54CA02482426B2FD003A5E04 /* HTTPAdapterFactory.swift */; };
|
||||
54CA02B42426B2FD003A5E04 /* StreamObfuscater.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54CA024A2426B2FD003A5E04 /* StreamObfuscater.swift */; };
|
||||
54CA02B52426B2FD003A5E04 /* CryptoStreamProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54CA024B2426B2FD003A5E04 /* CryptoStreamProcessor.swift */; };
|
||||
54CA02B62426B2FD003A5E04 /* ProtocolObfuscater.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54CA024C2426B2FD003A5E04 /* ProtocolObfuscater.swift */; };
|
||||
54CA02B72426B2FD003A5E04 /* ShadowsocksAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54CA024D2426B2FD003A5E04 /* ShadowsocksAdapter.swift */; };
|
||||
54CA02B82426B2FD003A5E04 /* HTTPProxySocket.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54CA024F2426B2FD003A5E04 /* HTTPProxySocket.swift */; };
|
||||
54CA02B92426B2FD003A5E04 /* DirectProxySocket.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54CA02502426B2FD003A5E04 /* DirectProxySocket.swift */; };
|
||||
54CA02BA2426B2FD003A5E04 /* ProxySocket.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54CA02512426B2FD003A5E04 /* ProxySocket.swift */; };
|
||||
@@ -124,6 +124,16 @@
|
||||
54CA02BE2426D4F3003A5E04 /* DDLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54CA02BD2426D4F3003A5E04 /* DDLog.swift */; };
|
||||
54CA02C32426DCCD003A5E04 /* GCDAsyncSocket.m in Sources */ = {isa = PBXBuildFile; fileRef = 54CA02BF2426DCCC003A5E04 /* GCDAsyncSocket.m */; };
|
||||
54CA02C42426DCCD003A5E04 /* GCDAsyncUdpSocket.m in Sources */ = {isa = PBXBuildFile; fileRef = 54CA02C02426DCCD003A5E04 /* GCDAsyncUdpSocket.m */; };
|
||||
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 */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -151,7 +161,10 @@
|
||||
/* End PBXCopyFilesBuildPhase 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>"; };
|
||||
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>"; };
|
||||
5412F8ED24571B8100A63D7A /* VCDateFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VCDateFilter.swift; sourceTree = "<group>"; };
|
||||
541A957523E602DF00C09C19 /* LaunchIcon.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = LaunchIcon.png; sourceTree = "<group>"; };
|
||||
541AC5D42399498A00A769D7 /* AppCheck.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AppCheck.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
541AC5D72399498A00A769D7 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
@@ -165,8 +178,14 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
54751E502423955000168273 /* FileManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileManager.swift; sourceTree = "<group>"; };
|
||||
5458EBBF243A3F2200CFEB15 /* TVCRecordingDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVCRecordingDetails.swift; sourceTree = "<group>"; };
|
||||
545DDDCE243E6267003B6544 /* TutorialSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialSheet.swift; sourceTree = "<group>"; };
|
||||
545DDDD024436983003B6544 /* QuickUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickUI.swift; sourceTree = "<group>"; };
|
||||
545DDDD324466D37003B6544 /* AutoLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoLayout.swift; sourceTree = "<group>"; };
|
||||
54751E502423955000168273 /* URL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URL.swift; sourceTree = "<group>"; };
|
||||
548B1F9423D338EC005B047C /* main.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = main.entitlements; sourceTree = "<group>"; };
|
||||
54953E5E23DEBE840054345C /* TVCDomains.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVCDomains.swift; sourceTree = "<group>"; };
|
||||
54953E6023E0D69A0054345C /* TVCHosts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVCHosts.swift; sourceTree = "<group>"; };
|
||||
@@ -174,13 +193,12 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
54B345AC241BBB00004C53CC /* GroupedDomain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupedDomain.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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -234,8 +252,6 @@
|
||||
54CA02242426B2FC003A5E04 /* DNSEnums.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DNSEnums.swift; sourceTree = "<group>"; };
|
||||
54CA02262426B2FC003A5E04 /* PacketProtocolParser.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PacketProtocolParser.swift; sourceTree = "<group>"; };
|
||||
54CA02272426B2FC003A5E04 /* IPPacket.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IPPacket.swift; sourceTree = "<group>"; };
|
||||
54CA02282426B2FC003A5E04 /* IPMutablePacket.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IPMutablePacket.swift; sourceTree = "<group>"; };
|
||||
54CA02292426B2FC003A5E04 /* TCPMutablePacket.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TCPMutablePacket.swift; sourceTree = "<group>"; };
|
||||
54CA022B2426B2FC003A5E04 /* Observer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Observer.swift; sourceTree = "<group>"; };
|
||||
54CA022E2426B2FC003A5E04 /* AdapterSocketEvent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AdapterSocketEvent.swift; sourceTree = "<group>"; };
|
||||
54CA022F2426B2FC003A5E04 /* ProxyServerEvent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProxyServerEvent.swift; sourceTree = "<group>"; };
|
||||
@@ -250,8 +266,6 @@
|
||||
54CA023B2426B2FC003A5E04 /* DirectAdapter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DirectAdapter.swift; sourceTree = "<group>"; };
|
||||
54CA023C2426B2FC003A5E04 /* SOCKS5Adapter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SOCKS5Adapter.swift; sourceTree = "<group>"; };
|
||||
54CA023D2426B2FC003A5E04 /* RejectAdapter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RejectAdapter.swift; sourceTree = "<group>"; };
|
||||
54CA023F2426B2FC003A5E04 /* SpeedAdapterFactory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SpeedAdapterFactory.swift; sourceTree = "<group>"; };
|
||||
54CA02402426B2FC003A5E04 /* ShadowsocksAdapterFactory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShadowsocksAdapterFactory.swift; sourceTree = "<group>"; };
|
||||
54CA02412426B2FC003A5E04 /* AuthenticationServerAdapterFactory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthenticationServerAdapterFactory.swift; sourceTree = "<group>"; };
|
||||
54CA02422426B2FC003A5E04 /* RejectAdapterFactory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RejectAdapterFactory.swift; sourceTree = "<group>"; };
|
||||
54CA02432426B2FD003A5E04 /* AdapterFactory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AdapterFactory.swift; sourceTree = "<group>"; };
|
||||
@@ -260,10 +274,6 @@
|
||||
54CA02462426B2FD003A5E04 /* ServerAdapterFactory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ServerAdapterFactory.swift; sourceTree = "<group>"; };
|
||||
54CA02472426B2FD003A5E04 /* AdapterFactoryManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AdapterFactoryManager.swift; sourceTree = "<group>"; };
|
||||
54CA02482426B2FD003A5E04 /* HTTPAdapterFactory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HTTPAdapterFactory.swift; sourceTree = "<group>"; };
|
||||
54CA024A2426B2FD003A5E04 /* StreamObfuscater.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StreamObfuscater.swift; sourceTree = "<group>"; };
|
||||
54CA024B2426B2FD003A5E04 /* CryptoStreamProcessor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CryptoStreamProcessor.swift; sourceTree = "<group>"; };
|
||||
54CA024C2426B2FD003A5E04 /* ProtocolObfuscater.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProtocolObfuscater.swift; sourceTree = "<group>"; };
|
||||
54CA024D2426B2FD003A5E04 /* ShadowsocksAdapter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShadowsocksAdapter.swift; sourceTree = "<group>"; };
|
||||
54CA024F2426B2FD003A5E04 /* HTTPProxySocket.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HTTPProxySocket.swift; sourceTree = "<group>"; };
|
||||
54CA02502426B2FD003A5E04 /* DirectProxySocket.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DirectProxySocket.swift; sourceTree = "<group>"; };
|
||||
54CA02512426B2FD003A5E04 /* ProxySocket.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProxySocket.swift; sourceTree = "<group>"; };
|
||||
@@ -274,6 +284,15 @@
|
||||
54CA02C02426DCCD003A5E04 /* GCDAsyncUdpSocket.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GCDAsyncUdpSocket.m; sourceTree = "<group>"; };
|
||||
54CA02C12426DCCD003A5E04 /* GCDAsyncSocket.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = GCDAsyncSocket.h; sourceTree = "<group>"; };
|
||||
54CA02C22426DCCD003A5E04 /* GCDAsyncUdpSocket.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = GCDAsyncUdpSocket.h; sourceTree = "<group>"; };
|
||||
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 */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -298,6 +317,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
54953E5E23DEBE840054345C /* TVCDomains.swift */,
|
||||
5412F8ED24571B8100A63D7A /* VCDateFilter.swift */,
|
||||
54953E6023E0D69A0054345C /* TVCHosts.swift */,
|
||||
54953E6E23E44CD00054345C /* TVCHostDetails.swift */,
|
||||
);
|
||||
@@ -313,6 +333,17 @@
|
||||
path = Settings;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
540E677E242D2CD200871BBE /* Recordings */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
540E677F242D2CF100871BBE /* VCRecordings.swift */,
|
||||
540E67832433FAFE00871BBE /* TVCPreviousRecords.swift */,
|
||||
540E67812433483D00871BBE /* VCEditRecording.swift */,
|
||||
5458EBBF243A3F2200CFEB15 /* TVCRecordingDetails.swift */,
|
||||
);
|
||||
path = Recordings;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
541AC5CB2399498A00A769D7 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -336,16 +367,18 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
54B3459A2415651C004C53CC /* DB */,
|
||||
54E540F0247C386500F7C34A /* Data Source */,
|
||||
54B345A4241BB975004C53CC /* Extensions */,
|
||||
545DDDD224436A03003B6544 /* Common Classes */,
|
||||
548B1F9423D338EC005B047C /* main.entitlements */,
|
||||
541AC5D72399498A00A769D7 /* AppDelegate.swift */,
|
||||
542E2A972404973F001462DC /* TBCMain.swift */,
|
||||
54B34597240F18DD004C53CC /* TVC Extensions */,
|
||||
540C6454240D5BAE00E948F9 /* Requests */,
|
||||
540E677E242D2CD200871BBE /* Recordings */,
|
||||
540C6455240D5BD200E948F9 /* Settings */,
|
||||
54B345B12422E029004C53CC /* unused */,
|
||||
541AC5DB2399498A00A769D7 /* Main.storyboard */,
|
||||
541AC5E02399498B00A769D7 /* LaunchScreen.storyboard */,
|
||||
541AC5DB2399498A00A769D7 /* Main.storyboard */,
|
||||
541AC5DE2399498B00A769D7 /* Assets.xcassets */,
|
||||
541AC5E32399498B00A769D7 /* Info.plist */,
|
||||
54953E7023E473F10054345C /* Settings.bundle */,
|
||||
@@ -376,19 +409,23 @@
|
||||
path = GlassVPN;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
54B34597240F18DD004C53CC /* TVC Extensions */ = {
|
||||
545DDDD224436A03003B6544 /* Common Classes */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
540C6456240D929300E948F9 /* EditableRows.swift */,
|
||||
545DDDD024436983003B6544 /* QuickUI.swift */,
|
||||
545DDDCE243E6267003B6544 /* TutorialSheet.swift */,
|
||||
54D8B979246C9F2000EB2414 /* FilterPipeline.swift */,
|
||||
);
|
||||
path = "TVC Extensions";
|
||||
path = "Common Classes";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
54B3459A2415651C004C53CC /* DB */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
54B7562223D7B2DC008F0C41 /* SQDB.swift */,
|
||||
54B345982414F491004C53CC /* DBWrapper.swift */,
|
||||
54B7562223D7B2DC008F0C41 /* DBCore.swift */,
|
||||
54D8B97D2471B88900EB2414 /* DBCommon.swift */,
|
||||
54D8B9822471BD8100EB2414 /* DBAppOnly.swift */,
|
||||
54B345AC241BBB00004C53CC /* DBExtensions.swift */,
|
||||
);
|
||||
path = DB;
|
||||
sourceTree = "<group>";
|
||||
@@ -400,9 +437,12 @@
|
||||
54B345A8241BBA0B004C53CC /* Generic.swift */,
|
||||
54B345A5241BB982004C53CC /* Notifications.swift */,
|
||||
54B345AA241BBA5B004C53CC /* AlertSheet.swift */,
|
||||
54B345AC241BBB00004C53CC /* GroupedDomain.swift */,
|
||||
54448A2F248647D900771C96 /* Time.swift */,
|
||||
54751E502423955000168273 /* URL.swift */,
|
||||
54448A2D2486464F00771C96 /* Array.swift */,
|
||||
54D8B97B2471A7E000EB2414 /* String.swift */,
|
||||
54B34595240F0513004C53CC /* TableView.swift */,
|
||||
54751E502423955000168273 /* FileManager.swift */,
|
||||
545DDDD324466D37003B6544 /* AutoLayout.swift */,
|
||||
);
|
||||
path = Extensions;
|
||||
sourceTree = "<group>";
|
||||
@@ -562,8 +602,6 @@
|
||||
children = (
|
||||
54CA02262426B2FC003A5E04 /* PacketProtocolParser.swift */,
|
||||
54CA02272426B2FC003A5E04 /* IPPacket.swift */,
|
||||
54CA02282426B2FC003A5E04 /* IPMutablePacket.swift */,
|
||||
54CA02292426B2FC003A5E04 /* TCPMutablePacket.swift */,
|
||||
);
|
||||
path = Packet;
|
||||
sourceTree = "<group>";
|
||||
@@ -611,7 +649,6 @@
|
||||
54CA023C2426B2FC003A5E04 /* SOCKS5Adapter.swift */,
|
||||
54CA023D2426B2FC003A5E04 /* RejectAdapter.swift */,
|
||||
54CA023E2426B2FC003A5E04 /* Factory */,
|
||||
54CA02492426B2FD003A5E04 /* Shadowsocks */,
|
||||
);
|
||||
path = AdapterSocket;
|
||||
sourceTree = "<group>";
|
||||
@@ -619,8 +656,6 @@
|
||||
54CA023E2426B2FC003A5E04 /* Factory */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
54CA023F2426B2FC003A5E04 /* SpeedAdapterFactory.swift */,
|
||||
54CA02402426B2FC003A5E04 /* ShadowsocksAdapterFactory.swift */,
|
||||
54CA02412426B2FC003A5E04 /* AuthenticationServerAdapterFactory.swift */,
|
||||
54CA02422426B2FC003A5E04 /* RejectAdapterFactory.swift */,
|
||||
54CA02432426B2FD003A5E04 /* AdapterFactory.swift */,
|
||||
@@ -633,17 +668,6 @@
|
||||
path = Factory;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
54CA02492426B2FD003A5E04 /* Shadowsocks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
54CA024A2426B2FD003A5E04 /* StreamObfuscater.swift */,
|
||||
54CA024B2426B2FD003A5E04 /* CryptoStreamProcessor.swift */,
|
||||
54CA024C2426B2FD003A5E04 /* ProtocolObfuscater.swift */,
|
||||
54CA024D2426B2FD003A5E04 /* ShadowsocksAdapter.swift */,
|
||||
);
|
||||
path = Shadowsocks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
54CA024E2426B2FD003A5E04 /* ProxySocket */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -655,6 +679,18 @@
|
||||
path = ProxySocket;
|
||||
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 */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@@ -772,25 +808,42 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
54B345AD241BBB00004C53CC /* GroupedDomain.swift in Sources */,
|
||||
54E540F8247DB90F00F7C34A /* RecordingsDB.swift in Sources */,
|
||||
54E540F4247D3F2600F7C34A /* TestDataSource.swift in Sources */,
|
||||
545DDDD424466D37003B6544 /* AutoLayout.swift in Sources */,
|
||||
54B345AD241BBB00004C53CC /* DBExtensions.swift in Sources */,
|
||||
54E540F2247C423200F7C34A /* DomainFilter.swift in Sources */,
|
||||
54D8B97E2471B88900EB2414 /* DBCommon.swift in Sources */,
|
||||
54D8B9832471BD8100EB2414 /* DBAppOnly.swift in Sources */,
|
||||
540E67842433FAFE00871BBE /* TVCPreviousRecords.swift in Sources */,
|
||||
54B345A6241BB982004C53CC /* Notifications.swift in Sources */,
|
||||
54448A2E2486464F00771C96 /* Array.swift in Sources */,
|
||||
54B345AB241BBA5B004C53CC /* AlertSheet.swift in Sources */,
|
||||
544C95262407B1C700AB89D0 /* SharedState.swift in Sources */,
|
||||
545DDDCF243E6267003B6544 /* TutorialSheet.swift in Sources */,
|
||||
54B345A9241BBA0B004C53CC /* Generic.swift in Sources */,
|
||||
54B34596240F0513004C53CC /* TableView.swift in Sources */,
|
||||
54953E3323DC752E0054345C /* SQDB.swift in Sources */,
|
||||
540E6780242D2CF100871BBE /* VCRecordings.swift in Sources */,
|
||||
54953E3323DC752E0054345C /* DBCore.swift in Sources */,
|
||||
54448A30248647D900771C96 /* Time.swift in Sources */,
|
||||
54953E6123E0D69A0054345C /* TVCHosts.swift in Sources */,
|
||||
540C6457240D929300E948F9 /* EditableRows.swift in Sources */,
|
||||
54751E512423955100168273 /* FileManager.swift in Sources */,
|
||||
54751E512423955100168273 /* URL.swift in Sources */,
|
||||
542E2A9A24051556001462DC /* TVCSettings.swift in Sources */,
|
||||
54953E5F23DEBE840054345C /* TVCDomains.swift in Sources */,
|
||||
54C056DB23E9E36E00214A3F /* AppInfoType.swift in Sources */,
|
||||
54D8B98624796E9900EB2414 /* GroupedDomainDataSource.swift in Sources */,
|
||||
54953E6F23E44CD00054345C /* TVCHostDetails.swift in Sources */,
|
||||
54D8B97C2471A7E000EB2414 /* String.swift in Sources */,
|
||||
54C056DD23E9EEF700214A3F /* BundleIcon.swift in Sources */,
|
||||
542E2A982404973F001462DC /* TBCMain.swift in Sources */,
|
||||
5458EBC0243A3F2200CFEB15 /* TVCRecordingDetails.swift in Sources */,
|
||||
5412F8EE24571B8200A63D7A /* VCDateFilter.swift in Sources */,
|
||||
545DDDD124436983003B6544 /* QuickUI.swift in Sources */,
|
||||
541AC5D82399498A00A769D7 /* AppDelegate.swift in Sources */,
|
||||
54B345992414F491004C53CC /* DBWrapper.swift in Sources */,
|
||||
540E67822433483D00871BBE /* VCEditRecording.swift in Sources */,
|
||||
54D8B97A246C9F2000EB2414 /* FilterPipeline.swift in Sources */,
|
||||
54B34594240E6343004C53CC /* TVCFilter.swift in Sources */,
|
||||
54E540FA2482414800F7C34A /* SyncUpdate.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -802,7 +855,6 @@
|
||||
54CA025D2426B2FD003A5E04 /* HTTPHeader.swift in Sources */,
|
||||
54CA02832426B2FD003A5E04 /* DNSSessionMatchResult.swift in Sources */,
|
||||
54CA02862426B2FD003A5E04 /* RuleManager.swift in Sources */,
|
||||
54CA02B52426B2FD003A5E04 /* CryptoStreamProcessor.swift in Sources */,
|
||||
54CA02B82426B2FD003A5E04 /* HTTPProxySocket.swift in Sources */,
|
||||
54CA02C32426DCCD003A5E04 /* GCDAsyncSocket.m in Sources */,
|
||||
54CA02752426B2FD003A5E04 /* IPRange.swift in Sources */,
|
||||
@@ -812,7 +864,6 @@
|
||||
54CA02C42426DCCD003A5E04 /* GCDAsyncUdpSocket.m in Sources */,
|
||||
54CA02BA2426B2FD003A5E04 /* ProxySocket.swift in Sources */,
|
||||
54CA025E2426B2FD003A5E04 /* ResponseGeneratorFactory.swift in Sources */,
|
||||
54CA02982426B2FD003A5E04 /* IPMutablePacket.swift in Sources */,
|
||||
54CA02892426B2FD003A5E04 /* Tunnel.swift in Sources */,
|
||||
54CA029F2426B2FD003A5E04 /* ProxySocketEvent.swift in Sources */,
|
||||
54CA027D2426B2FD003A5E04 /* GlobalIntializer.swift in Sources */,
|
||||
@@ -823,10 +874,8 @@
|
||||
54CA029E2426B2FD003A5E04 /* EventType.swift in Sources */,
|
||||
54CA02912426B2FD003A5E04 /* DNSMessage.swift in Sources */,
|
||||
54CA02712426B2FD003A5E04 /* UInt128.swift in Sources */,
|
||||
54CA02B62426B2FD003A5E04 /* ProtocolObfuscater.swift in Sources */,
|
||||
54CA02882426B2FD003A5E04 /* QueueFactory.swift in Sources */,
|
||||
54CA02A12426B2FD003A5E04 /* RuleMatchEvent.swift in Sources */,
|
||||
54CA02AB2426B2FD003A5E04 /* ShadowsocksAdapterFactory.swift in Sources */,
|
||||
54CA02BE2426D4F3003A5E04 /* DDLog.swift in Sources */,
|
||||
54CA02962426B2FD003A5E04 /* PacketProtocolParser.swift in Sources */,
|
||||
54CA02932426B2FD003A5E04 /* DNSServer.swift in Sources */,
|
||||
@@ -840,26 +889,24 @@
|
||||
54CA026B2426B2FD003A5E04 /* GCDTCPSocket.swift in Sources */,
|
||||
54CA028E2426B2FD003A5E04 /* IPStackProtocol.swift in Sources */,
|
||||
54CA01D52426B252003A5E04 /* SafeDict.swift in Sources */,
|
||||
54CA02992426B2FD003A5E04 /* TCPMutablePacket.swift in Sources */,
|
||||
54CA027B2426B2FD003A5E04 /* HTTPAuthentication.swift in Sources */,
|
||||
54CA02762426B2FD003A5E04 /* IPAddress.swift in Sources */,
|
||||
54CA02B02426B2FD003A5E04 /* SecureHTTPAdapterFactory.swift in Sources */,
|
||||
54CA02A62426B2FD003A5E04 /* AdapterSocket.swift in Sources */,
|
||||
54CA02742426B2FD003A5E04 /* IPMask.swift in Sources */,
|
||||
54CA02BB2426B2FD003A5E04 /* SOCKS5ProxySocket.swift in Sources */,
|
||||
54D8B97F2471B89100EB2414 /* DBCommon.swift in Sources */,
|
||||
54CA02A42426B2FD003A5E04 /* SecureHTTPAdapter.swift in Sources */,
|
||||
54CA02942426B2FD003A5E04 /* DNSResolver.swift in Sources */,
|
||||
54CA025F2426B2FD003A5E04 /* ProxyServer.swift in Sources */,
|
||||
54CA02842426B2FD003A5E04 /* Rule.swift in Sources */,
|
||||
54CA02B92426B2FD003A5E04 /* DirectProxySocket.swift in Sources */,
|
||||
54751E522423955100168273 /* FileManager.swift in Sources */,
|
||||
54751E522423955100168273 /* URL.swift in Sources */,
|
||||
54CA02A92426B2FD003A5E04 /* RejectAdapter.swift in Sources */,
|
||||
54CA02732426B2FD003A5E04 /* IPPool.swift in Sources */,
|
||||
54CA027E2426B2FD003A5E04 /* DomainListRule.swift in Sources */,
|
||||
54CA02782426B2FD003A5E04 /* BinaryDataScanner.swift in Sources */,
|
||||
54CA02B12426B2FD003A5E04 /* ServerAdapterFactory.swift in Sources */,
|
||||
54CA02B42426B2FD003A5E04 /* StreamObfuscater.swift in Sources */,
|
||||
54CA02AA2426B2FD003A5E04 /* SpeedAdapterFactory.swift in Sources */,
|
||||
54CA02952426B2FD003A5E04 /* DNSEnums.swift in Sources */,
|
||||
54CA02802426B2FD003A5E04 /* DNSSessionMatchType.swift in Sources */,
|
||||
54CA02A22426B2FD003A5E04 /* ObserverFactory.swift in Sources */,
|
||||
@@ -880,10 +927,9 @@
|
||||
54CA02972426B2FD003A5E04 /* IPPacket.swift in Sources */,
|
||||
54CA026A2426B2FD003A5E04 /* RawSocketFactory.swift in Sources */,
|
||||
54CA02A02426B2FD003A5E04 /* TunnelEvent.swift in Sources */,
|
||||
546063E523FEFAFE008F505A /* SQDB.swift in Sources */,
|
||||
546063E523FEFAFE008F505A /* DBCore.swift in Sources */,
|
||||
54CA02872426B2FD003A5E04 /* IPRangeListRule.swift in Sources */,
|
||||
54CA02922426B2FD003A5E04 /* DNSSession.swift in Sources */,
|
||||
54CA02B72426B2FD003A5E04 /* ShadowsocksAdapter.swift in Sources */,
|
||||
54CA026D2426B2FD003A5E04 /* Opt.swift in Sources */,
|
||||
54CA02B32426B2FD003A5E04 /* HTTPAdapterFactory.swift in Sources */,
|
||||
54CA02702426B2FD003A5E04 /* HTTPStreamScanner.swift in Sources */,
|
||||
@@ -1049,7 +1095,7 @@
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = main/main.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 12;
|
||||
CURRENT_PROJECT_VERSION = 17;
|
||||
INFOPLIST_FILE = main/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
@@ -1068,7 +1114,7 @@
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = main/main.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 12;
|
||||
CURRENT_PROJECT_VERSION = 17;
|
||||
INFOPLIST_FILE = main/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
@@ -1087,7 +1133,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = GlassVPN/GlassVPN.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 12;
|
||||
CURRENT_PROJECT_VERSION = 17;
|
||||
INFOPLIST_FILE = GlassVPN/Info.plist;
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "de.uni-bamberg.psi.AppCheck.VPN";
|
||||
@@ -1105,7 +1151,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = GlassVPN/GlassVPN.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 12;
|
||||
CURRENT_PROJECT_VERSION = 17;
|
||||
INFOPLIST_FILE = GlassVPN/Info.plist;
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "de.uni-bamberg.psi.AppCheck.VPN";
|
||||
|
||||
@@ -1,13 +1,47 @@
|
||||
import NetworkExtension
|
||||
|
||||
fileprivate var db: SQLiteDatabase?
|
||||
fileprivate var domainFilters: [String : FilterOptions] = [:]
|
||||
fileprivate var db: SQLiteDatabase!
|
||||
fileprivate var pStmt: OpaquePointer!
|
||||
fileprivate var filterDomains: [String]!
|
||||
fileprivate var filterOptions: [(block: Bool, ignore: Bool)]!
|
||||
|
||||
|
||||
// MARK: Backward DNS Binary Tree Lookup
|
||||
|
||||
fileprivate func reloadDomainFilter() {
|
||||
let tmp = db.loadFilters()?.map({
|
||||
(String($0.reversed()), $1)
|
||||
}).sorted(by: { $0.0 < $1.0 }) ?? []
|
||||
filterDomains = tmp.map { $0.0 }
|
||||
filterOptions = tmp.map { ($1.contains(.blocked), $1.contains(.ignored)) }
|
||||
}
|
||||
|
||||
fileprivate func filterIndex(for domain: String) -> Int {
|
||||
let reverseDomain = String(domain.reversed())
|
||||
var lo = 0, hi = filterDomains.count - 1
|
||||
while lo <= hi {
|
||||
let mid = (lo + hi)/2
|
||||
if filterDomains[mid] < reverseDomain {
|
||||
lo = mid + 1
|
||||
} else if reverseDomain < filterDomains[mid] {
|
||||
hi = mid - 1
|
||||
} else {
|
||||
return mid
|
||||
}
|
||||
}
|
||||
if lo > 0, reverseDomain.hasPrefix(filterDomains[lo - 1] + ".") {
|
||||
return lo - 1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
|
||||
// MARK: ObserverFactory
|
||||
|
||||
class LDObserverFactory: ObserverFactory {
|
||||
|
||||
|
||||
override func getObserverForProxySocket(_ socket: ProxySocket) -> Observer<ProxySocketEvent>? {
|
||||
// TODO: replace NEKit with custom proxy with minimal footprint
|
||||
return LDProxySocketObserver()
|
||||
}
|
||||
|
||||
@@ -15,66 +49,64 @@ class LDObserverFactory: ObserverFactory {
|
||||
override func signal(_ event: ProxySocketEvent) {
|
||||
switch event {
|
||||
case .receivedRequest(let session, let socket):
|
||||
DDLogDebug("DNS: \(session.host)")
|
||||
let match = domainFilters.first { session.host == $0.key || session.host.hasSuffix("." + $0.key) }
|
||||
let block = match?.value.contains(.blocked) ?? false
|
||||
let ignore = match?.value.contains(.ignored) ?? false
|
||||
if !ignore { try? db?.insertDNSQuery(session.host, blocked: block) }
|
||||
else { DDLogDebug("ignored") }
|
||||
if block { DDLogDebug("blocked"); socket.forceDisconnect() }
|
||||
let i = filterIndex(for: session.host)
|
||||
if i >= 0 {
|
||||
let (block, ignore) = filterOptions[i]
|
||||
if !ignore { try? db.logWrite(pStmt, session.host, blocked: block) }
|
||||
if block { socket.forceDisconnect() }
|
||||
} else {
|
||||
// TODO: disable filter during recordings
|
||||
try? db.logWrite(pStmt, session.host)
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: NEPacketTunnelProvider
|
||||
|
||||
class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||
|
||||
let proxyServerPort: UInt16 = 9090
|
||||
let proxyServerAddress = "127.0.0.1"
|
||||
let proxyServerAddress = "127.0.0.1"
|
||||
var proxyServer: GCDHTTPProxyServer!
|
||||
|
||||
func reloadDomainFilter() {
|
||||
domainFilters = db?.loadFilters() ?? [:]
|
||||
}
|
||||
|
||||
override func startTunnel(options: [String : NSObject]?, completionHandler: @escaping (Error?) -> Void) {
|
||||
DDLogVerbose("startTunnel")
|
||||
override func startTunnel(options: [String : NSObject]?, completionHandler: @escaping (Error?) -> Void) {
|
||||
do {
|
||||
db = try SQLiteDatabase.open()
|
||||
db!.initScheme()
|
||||
db.initCommonScheme()
|
||||
pStmt = try db.logWritePrepare()
|
||||
} catch {
|
||||
completionHandler(error)
|
||||
return
|
||||
}
|
||||
if proxyServer != nil {
|
||||
proxyServer.stop()
|
||||
}
|
||||
proxyServer = nil
|
||||
|
||||
reloadDomainFilter()
|
||||
|
||||
if proxyServer != nil {
|
||||
proxyServer.stop()
|
||||
}
|
||||
proxyServer = nil
|
||||
|
||||
// Create proxy
|
||||
let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: proxyServerAddress)
|
||||
settings.mtu = NSNumber(value: 1500)
|
||||
|
||||
let proxySettings = NEProxySettings()
|
||||
proxySettings.httpEnabled = true;
|
||||
proxySettings.httpServer = NEProxyServer(address: proxyServerAddress, port: Int(proxyServerPort))
|
||||
proxySettings.httpsEnabled = true;
|
||||
proxySettings.httpsServer = NEProxyServer(address: proxyServerAddress, port: Int(proxyServerPort))
|
||||
proxySettings.excludeSimpleHostnames = false;
|
||||
proxySettings.exceptionList = []
|
||||
proxySettings.matchDomains = [""]
|
||||
|
||||
settings.dnsSettings = NEDNSSettings(servers: ["127.0.0.1"])
|
||||
settings.proxySettings = proxySettings;
|
||||
let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: proxyServerAddress)
|
||||
settings.mtu = NSNumber(value: 1500)
|
||||
|
||||
let proxySettings = NEProxySettings()
|
||||
proxySettings.httpEnabled = true;
|
||||
proxySettings.httpServer = NEProxyServer(address: proxyServerAddress, port: Int(proxyServerPort))
|
||||
proxySettings.httpsEnabled = true;
|
||||
proxySettings.httpsServer = NEProxyServer(address: proxyServerAddress, port: Int(proxyServerPort))
|
||||
proxySettings.excludeSimpleHostnames = false;
|
||||
proxySettings.exceptionList = []
|
||||
proxySettings.matchDomains = [""]
|
||||
|
||||
settings.dnsSettings = NEDNSSettings(servers: ["127.0.0.1"])
|
||||
settings.proxySettings = proxySettings;
|
||||
RawSocketFactory.TunnelProvider = self
|
||||
ObserverFactory.currentFactory = LDObserverFactory()
|
||||
ObserverFactory.currentFactory = LDObserverFactory()
|
||||
|
||||
self.setTunnelNetworkSettings(settings) { error in
|
||||
guard error == nil else {
|
||||
@@ -82,36 +114,38 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||
completionHandler(error)
|
||||
return
|
||||
}
|
||||
DDLogVerbose("setTunnelNetworkSettings success \(self.packetFlow)")
|
||||
completionHandler(nil)
|
||||
|
||||
self.proxyServer = GCDHTTPProxyServer(address: IPAddress(fromString: self.proxyServerAddress), port: Port(port: self.proxyServerPort))
|
||||
do {
|
||||
try self.proxyServer.start()
|
||||
completionHandler(nil)
|
||||
}
|
||||
catch let proxyError {
|
||||
DDLogError("Error starting proxy server \(proxyError)")
|
||||
completionHandler(proxyError)
|
||||
}
|
||||
do {
|
||||
try self.proxyServer.start()
|
||||
completionHandler(nil)
|
||||
}
|
||||
catch let proxyError {
|
||||
DDLogError("Error starting proxy server \(proxyError)")
|
||||
completionHandler(proxyError)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) {
|
||||
DDLogVerbose("stopTunnel with reason: \(reason)")
|
||||
db = nil
|
||||
DNSServer.currentServer = nil
|
||||
RawSocketFactory.TunnelProvider = nil
|
||||
ObserverFactory.currentFactory = nil
|
||||
proxyServer.stop()
|
||||
proxyServer = nil
|
||||
completionHandler()
|
||||
exit(EXIT_SUCCESS)
|
||||
}
|
||||
|
||||
override func handleAppMessage(_ messageData: Data, completionHandler: ((Data?) -> Void)?) {
|
||||
DDLogVerbose("handleAppMessage")
|
||||
RawSocketFactory.TunnelProvider = nil
|
||||
ObserverFactory.currentFactory = nil
|
||||
proxyServer.stop()
|
||||
proxyServer = nil
|
||||
db.prepared(finalize: pStmt)
|
||||
pStmt = nil
|
||||
db = nil
|
||||
filterDomains = nil
|
||||
filterOptions = nil
|
||||
completionHandler()
|
||||
exit(EXIT_SUCCESS)
|
||||
}
|
||||
|
||||
override func handleAppMessage(_ messageData: Data, completionHandler: ((Data?) -> Void)?) {
|
||||
reloadDomainFilter()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
35
GlassVPN/robbiehanson-CocoaAsyncSocket/LICENSE.txt
Normal file
@@ -0,0 +1,35 @@
|
||||
This library is in the public domain.
|
||||
However, not all organizations are allowed to use such a license.
|
||||
For example, Germany doesn't recognize the Public Domain and one is not allowed to use libraries under such license (or similar).
|
||||
|
||||
Thus, the library is now dual licensed,
|
||||
and one is allowed to choose which license they would like to use.
|
||||
|
||||
##################################################
|
||||
License Option #1 :
|
||||
##################################################
|
||||
|
||||
Public Domain
|
||||
|
||||
##################################################
|
||||
License Option #2 :
|
||||
##################################################
|
||||
|
||||
Software License Agreement (BSD License)
|
||||
|
||||
Copyright (c) 2017, Deusty, LLC
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use of this software in source and binary forms,
|
||||
with or without modification, are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above
|
||||
copyright notice, this list of conditions and the
|
||||
following disclaimer.
|
||||
|
||||
* Neither the name of Deusty LLC nor the names of its
|
||||
contributors may be used to endorse or promote products
|
||||
derived from this software without specific prior
|
||||
written permission of Deusty LLC.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -1,76 +0,0 @@
|
||||
//import Foundation
|
||||
//
|
||||
//enum ChangeType {
|
||||
// case Address, Port
|
||||
//}
|
||||
//
|
||||
//public class IPMutablePacket {
|
||||
// // Support only IPv4 for now
|
||||
//
|
||||
// let version: IPVersion
|
||||
// let proto: TransportType
|
||||
// let IPHeaderLength: Int
|
||||
// var sourceAddress: IPv4Address {
|
||||
// get {
|
||||
// return IPv4Address(fromBytesInNetworkOrder: payload.bytes.advancedBy(12))
|
||||
// }
|
||||
// set {
|
||||
// setIPv4Address(sourceAddress, newAddress: newValue, at: 12)
|
||||
// }
|
||||
// }
|
||||
// var destinationAddress: IPv4Address {
|
||||
// get {
|
||||
// return IPv4Address(fromBytesInNetworkOrder: payload.bytes.advancedBy(16))
|
||||
// }
|
||||
// set {
|
||||
// setIPv4Address(destinationAddress, newAddress: newValue, at: 16)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// let payload: NSMutableData
|
||||
//
|
||||
// public init(payload: NSData) {
|
||||
// let vl = UnsafePointer<UInt8>(payload.bytes).memory
|
||||
// version = IPVersion(rawValue: vl >> 4)!
|
||||
// IPHeaderLength = Int(vl & 0x0F) * 4
|
||||
// let p = UnsafePointer<UInt8>(payload.bytes.advancedBy(9)).memory
|
||||
// proto = TransportType(rawValue: p)!
|
||||
// self.payload = NSMutableData(data: payload)
|
||||
// }
|
||||
//
|
||||
// func updateChecksum(oldValue: UInt16, newValue: UInt16, type: ChangeType) {
|
||||
// if type == .Address {
|
||||
// updateChecksum(oldValue, newValue: newValue, at: 10)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // swiftlint:disable:next variable_name
|
||||
// internal func updateChecksum(oldValue: UInt16, newValue: UInt16, at: Int) {
|
||||
// let oldChecksum = UnsafePointer<UInt16>(payload.bytes.advancedBy(at)).memory
|
||||
// let oc32 = UInt32(~oldChecksum)
|
||||
// let ov32 = UInt32(~oldValue)
|
||||
// let nv32 = UInt32(newValue)
|
||||
// var newChecksum32 = oc32 &+ ov32 &+ nv32
|
||||
// newChecksum32 = (newChecksum32 & 0xFFFF) + (newChecksum32 >> 16)
|
||||
// newChecksum32 = (newChecksum32 & 0xFFFF) &+ (newChecksum32 >> 16)
|
||||
// var newChecksum = ~UInt16(newChecksum32)
|
||||
// payload.replaceBytesInRange(NSRange(location: at, length: 2), withBytes: &newChecksum, length: 2)
|
||||
// }
|
||||
//
|
||||
// // swiftlint:disable:next variable_name
|
||||
// private func foldChecksum(checksum: UInt32) -> UInt32 {
|
||||
// var checksum = checksum
|
||||
// while checksum > 0xFFFF {
|
||||
// checksum = (checksum & 0xFFFF) + (checksum >> 16)
|
||||
// }
|
||||
// return checksum
|
||||
// }
|
||||
//
|
||||
// // swiftlint:disable:next variable_name
|
||||
// private func setIPv4Address(oldAddress: IPv4Address, newAddress: IPv4Address, at: Int) {
|
||||
// payload.replaceBytesInRange(NSRange(location: at, length: 4), withBytes: newAddress.bytesInNetworkOrder, length: 4)
|
||||
// updateChecksum(UnsafePointer<UInt16>(oldAddress.bytesInNetworkOrder).memory, newValue: UnsafePointer<UInt16>(newAddress.bytesInNetworkOrder).memory, type: .Address)
|
||||
// updateChecksum(UnsafePointer<UInt16>(oldAddress.bytesInNetworkOrder).advancedBy(1).memory, newValue: UnsafePointer<UInt16>(newAddress.bytesInNetworkOrder).advancedBy(1).memory, type: .Address)
|
||||
// }
|
||||
//
|
||||
//}
|
||||
@@ -1,32 +0,0 @@
|
||||
//import Foundation
|
||||
//
|
||||
//class TCPMutablePacket: IPMutablePacket {
|
||||
// var sourcePort: Port {
|
||||
// get {
|
||||
// return Port(bytesInNetworkOrder: payload.bytes.advancedBy(IPHeaderLength))
|
||||
// }
|
||||
// set {
|
||||
// setPort(sourcePort, newPort: newValue, at: 0)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// var destinationPort: Port {
|
||||
// get {
|
||||
// return Port(bytesInNetworkOrder: payload.bytes.advancedBy(IPHeaderLength + 2))
|
||||
// }
|
||||
// set {
|
||||
// setPort(destinationPort, newPort: newValue, at: 2)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// override func updateChecksum(oldValue: UInt16, newValue: UInt16, type: ChangeType) {
|
||||
// super.updateChecksum(oldValue, newValue: newValue, type: type)
|
||||
// updateChecksum(oldValue, newValue: newValue, at: IPHeaderLength + 16)
|
||||
// }
|
||||
//
|
||||
// // swiftlint:disable:next variable_name
|
||||
// private func setPort(oldPort: Port, newPort: Port, at: Int) {
|
||||
// payload.replaceBytesInRange(NSRange(location: at + IPHeaderLength, length: 2), withBytes: newPort.bytesInNetworkOrder, length: 2)
|
||||
// updateChecksum(oldPort.valueInNetworkOrder, newValue: newPort.valueInNetworkOrder, type: .Port)
|
||||
// }
|
||||
//}
|
||||
27
GlassVPN/zhuhaow-NEKit/LICENSE.md
Normal file
@@ -0,0 +1,27 @@
|
||||
Copyright (c) 2016, Zhuhao Wang
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
* Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
* Neither the name of NEKit nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -1,28 +0,0 @@
|
||||
//import Foundation
|
||||
//
|
||||
///// Factory building Shadowsocks adapter.
|
||||
//open class ShadowsocksAdapterFactory: ServerAdapterFactory {
|
||||
// let protocolObfuscaterFactory: ShadowsocksAdapter.ProtocolObfuscater.Factory
|
||||
// let cryptorFactory: ShadowsocksAdapter.CryptoStreamProcessor.Factory
|
||||
// let streamObfuscaterFactory: ShadowsocksAdapter.StreamObfuscater.Factory
|
||||
//
|
||||
// public init(serverHost: String, serverPort: Int, protocolObfuscaterFactory: ShadowsocksAdapter.ProtocolObfuscater.Factory, cryptorFactory: ShadowsocksAdapter.CryptoStreamProcessor.Factory, streamObfuscaterFactory: ShadowsocksAdapter.StreamObfuscater.Factory) {
|
||||
// self.protocolObfuscaterFactory = protocolObfuscaterFactory
|
||||
// self.cryptorFactory = cryptorFactory
|
||||
// self.streamObfuscaterFactory = streamObfuscaterFactory
|
||||
// super.init(serverHost: serverHost, serverPort: serverPort)
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// Get a Shadowsocks adapter.
|
||||
//
|
||||
// - parameter session: The connect session.
|
||||
//
|
||||
// - returns: The built adapter.
|
||||
// */
|
||||
// override open func getAdapterFor(session: ConnectSession) -> AdapterSocket {
|
||||
// let adapter = ShadowsocksAdapter(host: serverHost, port: serverPort, protocolObfuscater: protocolObfuscaterFactory.build(), cryptor: cryptorFactory.build(), streamObfuscator: streamObfuscaterFactory.build(for: session))
|
||||
// adapter.socket = RawSocketFactory.getRawSocket()
|
||||
// return adapter
|
||||
// }
|
||||
//}
|
||||
@@ -1,26 +0,0 @@
|
||||
//import Foundation
|
||||
//
|
||||
///// Factory building speed adapter.
|
||||
//open class SpeedAdapterFactory: AdapterFactory {
|
||||
// open var adapterFactories: [(AdapterFactory, Int)]!
|
||||
//
|
||||
// public override init() {}
|
||||
//
|
||||
// /**
|
||||
// Get a speed adapter.
|
||||
//
|
||||
// - parameter session: The connect session.
|
||||
//
|
||||
// - returns: The built adapter.
|
||||
// */
|
||||
// override open func getAdapterFor(session: ConnectSession) -> AdapterSocket {
|
||||
// let adapters = adapterFactories.map { adapterFactory, delay -> (AdapterSocket, Int) in
|
||||
// let adapter = adapterFactory.getAdapterFor(session: session)
|
||||
// adapter.socket = RawSocketFactory.getRawSocket()
|
||||
// return (adapter, delay)
|
||||
// }
|
||||
// let speedAdapter = SpeedAdapter()
|
||||
// speedAdapter.adapters = adapters
|
||||
// return speedAdapter
|
||||
// }
|
||||
//}
|
||||
@@ -14,7 +14,7 @@ public class SOCKS5Adapter: AdapterSocket {
|
||||
|
||||
var internalStatus: SOCKS5AdapterStatus = .invalid
|
||||
|
||||
let helloData = Data(bytes: UnsafePointer<UInt8>(([0x05, 0x01, 0x00] as [UInt8])), count: 3)
|
||||
let helloData = Data([0x05, 0x01, 0x00])
|
||||
|
||||
public enum ReadTag: Int {
|
||||
case methodResponse = -20000, connectResponseFirstPart, connectResponseSecondPart
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
//import Foundation
|
||||
//
|
||||
//extension ShadowsocksAdapter {
|
||||
// public class CryptoStreamProcessor {
|
||||
// public class Factory {
|
||||
// let password: String
|
||||
// let algorithm: CryptoAlgorithm
|
||||
// let key: Data
|
||||
//
|
||||
// public init(password: String, algorithm: CryptoAlgorithm) {
|
||||
// self.password = password
|
||||
// self.algorithm = algorithm
|
||||
// key = CryptoHelper.getKey(password, methodType: algorithm)
|
||||
// }
|
||||
//
|
||||
// public func build() -> CryptoStreamProcessor {
|
||||
// return CryptoStreamProcessor(key: key, algorithm: algorithm)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// public weak var inputStreamProcessor: StreamObfuscater.StreamObfuscaterBase!
|
||||
// public weak var outputStreamProcessor: ProtocolObfuscater.ProtocolObfuscaterBase!
|
||||
//
|
||||
// var readIV: Data!
|
||||
// let key: Data
|
||||
// let algorithm: CryptoAlgorithm
|
||||
//
|
||||
// var sendKey = false
|
||||
//
|
||||
// var buffer = Buffer(capacity: 0)
|
||||
//
|
||||
// lazy var writeIV: Data = {
|
||||
// [unowned self] in
|
||||
// CryptoHelper.getIV(self.algorithm)
|
||||
// }()
|
||||
// lazy var ivLength: Int = {
|
||||
// [unowned self] in
|
||||
// CryptoHelper.getIVLength(self.algorithm)
|
||||
// }()
|
||||
// lazy var encryptor: StreamCryptoProtocol = {
|
||||
// [unowned self] in
|
||||
// self.getCrypto(.encrypt)
|
||||
// }()
|
||||
// lazy var decryptor: StreamCryptoProtocol = {
|
||||
// [unowned self] in
|
||||
// self.getCrypto(.decrypt)
|
||||
// }()
|
||||
//
|
||||
// init(key: Data, algorithm: CryptoAlgorithm) {
|
||||
// self.key = key
|
||||
// self.algorithm = algorithm
|
||||
// }
|
||||
//
|
||||
// func encrypt(data: inout Data) {
|
||||
// return encryptor.update(&data)
|
||||
// }
|
||||
//
|
||||
// func decrypt(data: inout Data) {
|
||||
// return decryptor.update(&data)
|
||||
// }
|
||||
//
|
||||
// public func input(data: Data) throws {
|
||||
// var data = data
|
||||
//
|
||||
// if readIV == nil {
|
||||
// buffer.append(data: data)
|
||||
// readIV = buffer.get(length: ivLength)
|
||||
// guard readIV != nil else {
|
||||
// try inputStreamProcessor!.input(data: Data())
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// data = buffer.get() ?? Data()
|
||||
// buffer.release()
|
||||
// }
|
||||
//
|
||||
// decrypt(data: &data)
|
||||
// try inputStreamProcessor!.input(data: data)
|
||||
// }
|
||||
//
|
||||
// public func output(data: Data) {
|
||||
// var data = data
|
||||
// encrypt(data: &data)
|
||||
// if sendKey {
|
||||
// return outputStreamProcessor!.output(data: data)
|
||||
// } else {
|
||||
// sendKey = true
|
||||
// var out = Data(capacity: data.count + writeIV.count)
|
||||
// out.append(writeIV)
|
||||
// out.append(data)
|
||||
//
|
||||
// return outputStreamProcessor!.output(data: out)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// private func getCrypto(_ operation: CryptoOperation) -> StreamCryptoProtocol {
|
||||
// switch algorithm {
|
||||
// case .AES128CFB, .AES192CFB, .AES256CFB:
|
||||
// switch operation {
|
||||
// case .decrypt:
|
||||
// return CCCrypto(operation: .decrypt, mode: .cfb, algorithm: .aes, initialVector: readIV, key: key)
|
||||
// case .encrypt:
|
||||
// return CCCrypto(operation: .encrypt, mode: .cfb, algorithm: .aes, initialVector: writeIV, key: key)
|
||||
// }
|
||||
// case .CHACHA20:
|
||||
// switch operation {
|
||||
// case .decrypt:
|
||||
// return SodiumStreamCrypto(key: key, iv: readIV, algorithm: .chacha20)
|
||||
// case .encrypt:
|
||||
// return SodiumStreamCrypto(key: key, iv: writeIV, algorithm: .chacha20)
|
||||
// }
|
||||
// case .SALSA20:
|
||||
// switch operation {
|
||||
// case .decrypt:
|
||||
// return SodiumStreamCrypto(key: key, iv: readIV, algorithm: .salsa20)
|
||||
// case .encrypt:
|
||||
// return SodiumStreamCrypto(key: key, iv: writeIV, algorithm: .salsa20)
|
||||
// }
|
||||
// case .RC4MD5:
|
||||
// var combinedKey = Data(capacity: key.count + ivLength)
|
||||
// combinedKey.append(key)
|
||||
// switch operation {
|
||||
// case .decrypt:
|
||||
// combinedKey.append(readIV)
|
||||
// return CCCrypto(operation: .decrypt, mode: .rc4, algorithm: .rc4, initialVector: nil, key: MD5Hash.final(combinedKey))
|
||||
// case .encrypt:
|
||||
// combinedKey.append(writeIV)
|
||||
// return CCCrypto(operation: .encrypt, mode: .rc4, algorithm: .rc4, initialVector: nil, key: MD5Hash.final(combinedKey))
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
@@ -1,371 +0,0 @@
|
||||
//import Foundation
|
||||
//
|
||||
//extension ShadowsocksAdapter {
|
||||
// public struct ProtocolObfuscater {
|
||||
// public class Factory {
|
||||
// public init() {}
|
||||
//
|
||||
// public func build() -> ProtocolObfuscaterBase {
|
||||
// return ProtocolObfuscaterBase()
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// public class ProtocolObfuscaterBase {
|
||||
// public weak var inputStreamProcessor: CryptoStreamProcessor!
|
||||
// public weak var outputStreamProcessor: ShadowsocksAdapter!
|
||||
//
|
||||
// public func start() {}
|
||||
// public func input(data: Data) throws {}
|
||||
// public func output(data: Data) {}
|
||||
//
|
||||
// public func didWrite() {}
|
||||
// }
|
||||
//
|
||||
// public class OriginProtocolObfuscater: ProtocolObfuscaterBase {
|
||||
//
|
||||
// public class Factory: ProtocolObfuscater.Factory {
|
||||
// public override init() {}
|
||||
//
|
||||
// public override func build() -> ShadowsocksAdapter.ProtocolObfuscater.ProtocolObfuscaterBase {
|
||||
// return OriginProtocolObfuscater()
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// public override func start() {
|
||||
// outputStreamProcessor.becomeReadyToForward()
|
||||
// }
|
||||
//
|
||||
// public override func input(data: Data) throws {
|
||||
// try inputStreamProcessor.input(data: data)
|
||||
// }
|
||||
//
|
||||
// public override func output(data: Data) {
|
||||
// outputStreamProcessor.output(data: data)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// public class HTTPProtocolObfuscater: ProtocolObfuscaterBase {
|
||||
//
|
||||
// public class Factory: ProtocolObfuscater.Factory {
|
||||
// let method: String
|
||||
// let hosts: [String]
|
||||
// let customHeader: String?
|
||||
//
|
||||
// public init(method: String = "GET", hosts: [String], customHeader: String?) {
|
||||
// self.method = method
|
||||
// self.hosts = hosts
|
||||
// self.customHeader = customHeader
|
||||
// }
|
||||
//
|
||||
// public override func build() -> ShadowsocksAdapter.ProtocolObfuscater.ProtocolObfuscaterBase {
|
||||
// return HTTPProtocolObfuscater(method: method, hosts: hosts, customHeader: customHeader)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// static let headerLength = 30
|
||||
// static let userAgent = ["Mozilla/5.0 (Windows NT 6.3; WOW64; rv:40.0) Gecko/20100101 Firefox/40.0",
|
||||
// "Mozilla/5.0 (Windows NT 6.3; WOW64; rv:40.0) Gecko/20100101 Firefox/44.0",
|
||||
// "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2228.0 Safari/537.36",
|
||||
// "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/535.11 (KHTML, like Gecko) Ubuntu/11.10 Chromium/27.0.1453.93 Chrome/27.0.1453.93 Safari/537.36",
|
||||
// "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:35.0) Gecko/20100101 Firefox/35.0",
|
||||
// "Mozilla/5.0 (compatible; WOW64; MSIE 10.0; Windows NT 6.2)",
|
||||
// "Mozilla/5.0 (Windows; U; Windows NT 6.1; en-US) AppleWebKit/533.20.25 (KHTML, like Gecko) Version/5.0.4 Safari/533.20.27",
|
||||
// "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.3; Trident/7.0; .NET4.0E; .NET4.0C)",
|
||||
// "Mozilla/5.0 (Windows NT 6.3; Trident/7.0; rv:11.0) like Gecko",
|
||||
// "Mozilla/5.0 (Linux; Android 4.4; Nexus 5 Build/BuildID) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/30.0.0.0 Mobile Safari/537.36",
|
||||
// "Mozilla/5.0 (iPad; CPU OS 5_0 like Mac OS X) AppleWebKit/534.46 (KHTML, like Gecko) Version/5.1 Mobile/9A334 Safari/7534.48.3",
|
||||
// "Mozilla/5.0 (iPhone; CPU iPhone OS 5_0 like Mac OS X) AppleWebKit/534.46 (KHTML, like Gecko) Version/5.1 Mobile/9A334 Safari/7534.48.3"]
|
||||
//
|
||||
// let method: String
|
||||
// let hosts: [String]
|
||||
// let customHeader: String?
|
||||
//
|
||||
// var readingFakeHeader = false
|
||||
// var sendHeader = false
|
||||
// var remaining = false
|
||||
//
|
||||
// var buffer = Buffer(capacity: 8192)
|
||||
//
|
||||
// public init(method: String = "GET", hosts: [String], customHeader: String?) {
|
||||
// self.method = method
|
||||
// self.hosts = hosts
|
||||
// self.customHeader = customHeader
|
||||
// }
|
||||
//
|
||||
// private func generateHeader(encapsulating data: Data) -> String {
|
||||
// let ind = Int(arc4random_uniform(UInt32(hosts.count)))
|
||||
// let host = outputStreamProcessor.port == 80 ? hosts[ind] : "\(hosts[ind]):\(outputStreamProcessor.port)"
|
||||
// var header = "\(method) /\(hexlify(data: data)) HTTP/1.1\r\nHost: \(host)\r\n"
|
||||
// if let customHeader = customHeader {
|
||||
// header += customHeader
|
||||
// } else {
|
||||
// let ind = Int(arc4random_uniform(UInt32(HTTPProtocolObfuscater.userAgent.count)))
|
||||
// header += "User-Agent: \(HTTPProtocolObfuscater.userAgent[ind])\r\nAccept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\r\nAccept-Language: en-US,en;q=0.8\r\nAccept-Encoding: gzip, deflate\r\nDNT: 1\r\nConnection: keep-alive"
|
||||
// }
|
||||
// header += "\r\n\r\n"
|
||||
// return header
|
||||
// }
|
||||
//
|
||||
// private func hexlify(data: Data) -> String {
|
||||
// var result = ""
|
||||
// for i in data {
|
||||
// result = result.appendingFormat("%%%02x", i)
|
||||
// }
|
||||
// return result
|
||||
// }
|
||||
//
|
||||
// public override func start() {
|
||||
// readingFakeHeader = true
|
||||
// outputStreamProcessor.becomeReadyToForward()
|
||||
// }
|
||||
//
|
||||
// public override func input(data: Data) throws {
|
||||
// if readingFakeHeader {
|
||||
// buffer.append(data: data)
|
||||
// if buffer.get(to: Utils.HTTPData.DoubleCRLF) != nil {
|
||||
// readingFakeHeader = false
|
||||
// if let remainData = buffer.get() {
|
||||
// try inputStreamProcessor.input(data: remainData)
|
||||
// return
|
||||
// }
|
||||
// }
|
||||
// try inputStreamProcessor.input(data: Data())
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// try inputStreamProcessor.input(data: data)
|
||||
// }
|
||||
//
|
||||
// public override func output(data: Data) {
|
||||
// if sendHeader {
|
||||
// outputStreamProcessor.output(data: data)
|
||||
// } else {
|
||||
// var fakeRequestDataLength = inputStreamProcessor.key.count + HTTPProtocolObfuscater.headerLength
|
||||
// if data.count - fakeRequestDataLength > 64 {
|
||||
// fakeRequestDataLength += Int(arc4random_uniform(64))
|
||||
// } else {
|
||||
// fakeRequestDataLength = data.count
|
||||
// }
|
||||
//
|
||||
// var outputData = generateHeader(encapsulating: data.subdata(in: 0 ..< fakeRequestDataLength)).data(using: .utf8)!
|
||||
// outputData.append(data.subdata(in: fakeRequestDataLength ..< data.count))
|
||||
// sendHeader = true
|
||||
// outputStreamProcessor.output(data: outputData)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// public class TLSProtocolObfuscater: ProtocolObfuscaterBase {
|
||||
//
|
||||
// public class Factory: ProtocolObfuscater.Factory {
|
||||
// let hosts: [String]
|
||||
//
|
||||
// public init(hosts: [String]) {
|
||||
// self.hosts = hosts
|
||||
// }
|
||||
//
|
||||
// public override func build() -> ShadowsocksAdapter.ProtocolObfuscater.ProtocolObfuscaterBase {
|
||||
// return TLSProtocolObfuscater(hosts: hosts)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// let hosts: [String]
|
||||
// let clientID: Data = {
|
||||
// var id = Data(count: 32)
|
||||
// Utils.Random.fill(data: &id)
|
||||
// return id
|
||||
// }()
|
||||
//
|
||||
// private var status = 0
|
||||
//
|
||||
// private var buffer = Buffer(capacity: 1024)
|
||||
//
|
||||
// init(hosts: [String]) {
|
||||
// self.hosts = hosts
|
||||
// }
|
||||
//
|
||||
// public override func start() {
|
||||
// handleStatus0()
|
||||
// outputStreamProcessor.socket.readDataTo(length: 129)
|
||||
// }
|
||||
//
|
||||
// public override func input(data: Data) throws {
|
||||
// switch status {
|
||||
// case 8:
|
||||
// try handleInput(data: data)
|
||||
// case 1:
|
||||
// outputStreamProcessor.becomeReadyToForward()
|
||||
// default:
|
||||
// break
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// public override func output(data: Data) {
|
||||
// switch status {
|
||||
// case 8:
|
||||
// handleStatus8(data: data)
|
||||
// return
|
||||
// case 1:
|
||||
// handleStatus1(data: data)
|
||||
// return
|
||||
// default:
|
||||
// break
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// private func authData() -> Data {
|
||||
// var time = UInt32(Date.init().timeIntervalSince1970).bigEndian
|
||||
// var output = Data(count: 32)
|
||||
// var key = inputStreamProcessor.key
|
||||
// key.append(clientID)
|
||||
//
|
||||
// withUnsafeBytes(of: &time) {
|
||||
// output.replaceSubrange(0 ..< 4, with: $0)
|
||||
// }
|
||||
//
|
||||
// Utils.Random.fill(data: &output, from: 4, length: 18)
|
||||
// output.withUnsafeBytes {
|
||||
// output.replaceSubrange(22 ..< 32, with: HMAC.final(value: $0.baseAddress!, length: 22, algorithm: .SHA1, key: key).subdata(in: 0..<10))
|
||||
// }
|
||||
// return output
|
||||
// }
|
||||
//
|
||||
// private func pack(data: Data) -> Data {
|
||||
// var output = Data()
|
||||
// var left = data.count
|
||||
// while left > 0 {
|
||||
// let blockSize = UInt16(min(Int(arc4random_uniform(UInt32(UInt16.max))) % 4096 + 100, left))
|
||||
// var blockSizeBE = blockSize.bigEndian
|
||||
// output.append(contentsOf: [0x17, 0x03, 0x03])
|
||||
// withUnsafeBytes(of: &blockSizeBE) {
|
||||
// output.append($0.baseAddress!.assumingMemoryBound(to: UInt8.self), count: $0.count)
|
||||
// }
|
||||
// output.append(data.subdata(in: data.count - left ..< data.count - left + Int(blockSize)))
|
||||
// left -= Int(blockSize)
|
||||
// }
|
||||
// return output
|
||||
// }
|
||||
//
|
||||
// private func handleStatus8(data: Data) {
|
||||
// outputStreamProcessor.output(data: pack(data: data))
|
||||
// }
|
||||
//
|
||||
// private func handleStatus0() {
|
||||
// status = 1
|
||||
//
|
||||
// var outData = Data()
|
||||
// outData.append(contentsOf: [0x03, 0x03])
|
||||
// outData.append(authData())
|
||||
// outData.append(0x20)
|
||||
// outData.append(clientID)
|
||||
// outData.append(contentsOf: [0x00, 0x1c, 0xc0, 0x2b, 0xc0, 0x2f, 0xcc, 0xa9, 0xcc, 0xa8, 0xcc, 0x14, 0xcc, 0x13, 0xc0, 0x0a, 0xc0, 0x14, 0xc0, 0x09, 0xc0, 0x13, 0x00, 0x9c, 0x00, 0x35, 0x00, 0x2f, 0x00, 0x0a])
|
||||
// outData.append("0100".data(using: .utf8)!)
|
||||
//
|
||||
// var extData = Data()
|
||||
// extData.append(contentsOf: [0xff, 0x01, 0x00, 0x01, 0x00])
|
||||
// let hostData = hosts[Int(arc4random_uniform(UInt32(hosts.count)))].data(using: .utf8)!
|
||||
//
|
||||
// var sniData = Data(capacity: hosts.count + 2 + 1 + 2 + 2 + 2)
|
||||
//
|
||||
// sniData.append(contentsOf: [0x00, 0x00])
|
||||
//
|
||||
// var _lenBE = UInt16(hostData.count + 5).bigEndian
|
||||
// withUnsafeBytes(of: &_lenBE) {
|
||||
// sniData.append($0.baseAddress!.assumingMemoryBound(to: UInt8.self), count: $0.count)
|
||||
// }
|
||||
//
|
||||
// _lenBE = UInt16(hostData.count + 3).bigEndian
|
||||
// withUnsafeBytes(of: &_lenBE) {
|
||||
// sniData.append($0.baseAddress!.assumingMemoryBound(to: UInt8.self), count: $0.count)
|
||||
// }
|
||||
//
|
||||
// sniData.append(0x00)
|
||||
//
|
||||
// _lenBE = UInt16(hostData.count).bigEndian
|
||||
// withUnsafeBytes(of: &_lenBE) {
|
||||
// sniData.append($0.baseAddress!.assumingMemoryBound(to: UInt8.self), count: $0.count)
|
||||
// }
|
||||
//
|
||||
// sniData.append(hostData)
|
||||
//
|
||||
// extData.append(sniData)
|
||||
//
|
||||
// extData.append(contentsOf: [0x00, 0x17, 0x00, 0x00, 0x00, 0x23, 0x00, 0xd0])
|
||||
//
|
||||
// var randomData = Data(count: 208)
|
||||
// Utils.Random.fill(data: &randomData)
|
||||
// extData.append(randomData)
|
||||
//
|
||||
// extData.append(contentsOf: [0x00, 0x0d, 0x00, 0x16, 0x00, 0x14, 0x06, 0x01, 0x06, 0x03, 0x05, 0x01, 0x05, 0x03, 0x04, 0x01, 0x04, 0x03, 0x03, 0x01, 0x03, 0x03, 0x02, 0x01, 0x02, 0x03])
|
||||
// extData.append(contentsOf: [0x00, 0x05, 0x00, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00])
|
||||
// extData.append(contentsOf: [0x00, 0x12, 0x00, 0x00])
|
||||
// extData.append(contentsOf: [0x75, 0x50, 0x00, 0x00])
|
||||
// extData.append(contentsOf: [0x00, 0x0b, 0x00, 0x02, 0x01, 0x00])
|
||||
// extData.append(contentsOf: [0x00, 0x0a, 0x00, 0x06, 0x00, 0x04, 0x00, 0x17, 0x00, 0x18])
|
||||
//
|
||||
// _lenBE = UInt16(extData.count).bigEndian
|
||||
// withUnsafeBytes(of: &_lenBE) {
|
||||
// outData.append($0.baseAddress!.assumingMemoryBound(to: UInt8.self), count: $0.count)
|
||||
// }
|
||||
// outData.append(extData)
|
||||
//
|
||||
// var outputData = Data(capacity: outData.count + 9)
|
||||
// outputData.append(contentsOf: [0x16, 0x03, 0x01])
|
||||
// _lenBE = UInt16(outData.count + 4).bigEndian
|
||||
// withUnsafeBytes(of: &_lenBE) {
|
||||
// outputData.append($0.baseAddress!.assumingMemoryBound(to: UInt8.self), count: $0.count)
|
||||
// }
|
||||
// outputData.append(contentsOf: [0x01, 0x00])
|
||||
// _lenBE = UInt16(outData.count).bigEndian
|
||||
// withUnsafeBytes(of: &_lenBE) {
|
||||
// outputData.append($0.baseAddress!.assumingMemoryBound(to: UInt8.self), count: $0.count)
|
||||
// }
|
||||
// outputData.append(outData)
|
||||
// outputStreamProcessor.output(data: outputData)
|
||||
// }
|
||||
//
|
||||
// private func handleStatus1(data: Data) {
|
||||
// status = 8
|
||||
//
|
||||
// var outputData = Data()
|
||||
// outputData.append(contentsOf: [0x14, 0x03, 0x03, 0x00, 0x01, 0x01, 0x16, 0x03, 0x03, 0x00, 0x20])
|
||||
// var random = Data(count: 22)
|
||||
// Utils.Random.fill(data: &random)
|
||||
// outputData.append(random)
|
||||
//
|
||||
// var key = inputStreamProcessor.key
|
||||
// key.append(clientID)
|
||||
// outputData.withUnsafeBytes {
|
||||
// outputData.append(HMAC.final(value: $0.baseAddress!, length: outputData.count, algorithm: .SHA1, key: key).subdata(in: 0..<10))
|
||||
// }
|
||||
//
|
||||
// outputData.append(pack(data: data))
|
||||
//
|
||||
// outputStreamProcessor.output(data: outputData)
|
||||
// }
|
||||
//
|
||||
// private func handleInput(data: Data) throws {
|
||||
// buffer.append(data: data)
|
||||
// var unpackedData = Data()
|
||||
// while buffer.left > 5 {
|
||||
// buffer.skip(3)
|
||||
// var length: Int = 0
|
||||
// buffer.withUnsafeBytes { (ptr: UnsafePointer<UInt16>) in
|
||||
// length = Int(ptr.pointee.byteSwapped)
|
||||
// }
|
||||
// buffer.skip(2)
|
||||
// if buffer.left >= length {
|
||||
// unpackedData.append(buffer.get(length: length)!)
|
||||
// continue
|
||||
// } else {
|
||||
// buffer.setBack(length: 5)
|
||||
// break
|
||||
// }
|
||||
// }
|
||||
// buffer.squeeze()
|
||||
// try inputStreamProcessor.input(data: unpackedData)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// }
|
||||
//}
|
||||
@@ -1,112 +0,0 @@
|
||||
//import Foundation
|
||||
//import CommonCrypto
|
||||
//
|
||||
///// This adapter connects to remote through Shadowsocks proxy.
|
||||
//public class ShadowsocksAdapter: AdapterSocket {
|
||||
// enum ShadowsocksAdapterStatus {
|
||||
// case invalid,
|
||||
// connecting,
|
||||
// connected,
|
||||
// forwarding,
|
||||
// stopped
|
||||
// }
|
||||
//
|
||||
// enum EncryptMethod: String {
|
||||
// case AES128CFB = "AES-128-CFB", AES192CFB = "AES-192-CFB", AES256CFB = "AES-256-CFB"
|
||||
//
|
||||
// static let allValues: [EncryptMethod] = [.AES128CFB, .AES192CFB, .AES256CFB]
|
||||
// }
|
||||
//
|
||||
// public let host: String
|
||||
// public let port: Int
|
||||
//
|
||||
// var internalStatus: ShadowsocksAdapterStatus = .invalid
|
||||
//
|
||||
// private let protocolObfuscater: ProtocolObfuscater.ProtocolObfuscaterBase
|
||||
// private let cryptor: CryptoStreamProcessor
|
||||
// private let streamObfuscator: StreamObfuscater.StreamObfuscaterBase
|
||||
//
|
||||
// public init(host: String, port: Int, protocolObfuscater: ProtocolObfuscater.ProtocolObfuscaterBase, cryptor: CryptoStreamProcessor, streamObfuscator: StreamObfuscater.StreamObfuscaterBase) {
|
||||
// self.host = host
|
||||
// self.port = port
|
||||
// self.protocolObfuscater = protocolObfuscater
|
||||
// self.cryptor = cryptor
|
||||
// self.streamObfuscator = streamObfuscator
|
||||
//
|
||||
// super.init()
|
||||
//
|
||||
// protocolObfuscater.inputStreamProcessor = cryptor
|
||||
// protocolObfuscater.outputStreamProcessor = self
|
||||
//
|
||||
// cryptor.inputStreamProcessor = streamObfuscator
|
||||
// cryptor.outputStreamProcessor = protocolObfuscater
|
||||
//
|
||||
// streamObfuscator.inputStreamProcessor = self
|
||||
// streamObfuscator.outputStreamProcessor = cryptor
|
||||
// }
|
||||
//
|
||||
// override public func openSocketWith(session: ConnectSession) {
|
||||
// super.openSocketWith(session: session)
|
||||
//
|
||||
// do {
|
||||
// internalStatus = .connecting
|
||||
// try socket.connectTo(host: host, port: port, enableTLS: false, tlsSettings: nil)
|
||||
// } catch let error {
|
||||
// observer?.signal(.errorOccured(error, on: self))
|
||||
// disconnect()
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// override public func didConnectWith(socket: RawTCPSocketProtocol) {
|
||||
// super.didConnectWith(socket: socket)
|
||||
//
|
||||
// internalStatus = .connected
|
||||
//
|
||||
// protocolObfuscater.start()
|
||||
// }
|
||||
//
|
||||
// override public func didRead(data: Data, from socket: RawTCPSocketProtocol) {
|
||||
// super.didRead(data: data, from: socket)
|
||||
//
|
||||
// do {
|
||||
// try protocolObfuscater.input(data: data)
|
||||
// } catch {
|
||||
// disconnect()
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// public override func write(data: Data) {
|
||||
// streamObfuscator.output(data: data)
|
||||
// }
|
||||
//
|
||||
// public func write(rawData: Data) {
|
||||
// super.write(data: rawData)
|
||||
// }
|
||||
//
|
||||
// public func input(data: Data) {
|
||||
// delegate?.didRead(data: data, from: self)
|
||||
// }
|
||||
//
|
||||
// public func output(data: Data) {
|
||||
// write(rawData: data)
|
||||
// }
|
||||
//
|
||||
// override public func didWrite(data: Data?, by socket: RawTCPSocketProtocol) {
|
||||
// super.didWrite(data: data, by: socket)
|
||||
//
|
||||
// protocolObfuscater.didWrite()
|
||||
//
|
||||
// switch internalStatus {
|
||||
// case .forwarding:
|
||||
// delegate?.didWrite(data: data, by: self)
|
||||
// default:
|
||||
// return
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// func becomeReadyToForward() {
|
||||
// internalStatus = .forwarding
|
||||
// observer?.signal(.readyForForward(self))
|
||||
// delegate?.didBecomeReadyToForwardWith(socket: self)
|
||||
// }
|
||||
//}
|
||||
@@ -1,167 +0,0 @@
|
||||
//import Foundation
|
||||
//
|
||||
//extension ShadowsocksAdapter {
|
||||
// public struct StreamObfuscater {
|
||||
// public class Factory {
|
||||
// public init() {}
|
||||
//
|
||||
// public func build(for session: ConnectSession) -> StreamObfuscaterBase {
|
||||
// return StreamObfuscaterBase(for: session)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// public class StreamObfuscaterBase {
|
||||
// public weak var inputStreamProcessor: ShadowsocksAdapter!
|
||||
// private weak var _outputStreamProcessor: CryptoStreamProcessor!
|
||||
// public var outputStreamProcessor: CryptoStreamProcessor! {
|
||||
// get {
|
||||
// return _outputStreamProcessor
|
||||
// }
|
||||
// set {
|
||||
// _outputStreamProcessor = newValue
|
||||
// key = _outputStreamProcessor?.key
|
||||
// writeIV = _outputStreamProcessor?.writeIV
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// public var key: Data?
|
||||
// public var writeIV: Data?
|
||||
//
|
||||
// let session: ConnectSession
|
||||
//
|
||||
// init(for session: ConnectSession) {
|
||||
// self.session = session
|
||||
// }
|
||||
//
|
||||
// func output(data: Data) {}
|
||||
// func input(data: Data) throws {}
|
||||
// }
|
||||
//
|
||||
// public class OriginStreamObfuscater: StreamObfuscaterBase {
|
||||
// public class Factory: StreamObfuscater.Factory {
|
||||
// public override init() {}
|
||||
//
|
||||
// public override func build(for session: ConnectSession) -> ShadowsocksAdapter.StreamObfuscater.StreamObfuscaterBase {
|
||||
// return OriginStreamObfuscater(for: session)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// private var requestSend = false
|
||||
//
|
||||
// private func requestData(withData data: Data) -> Data {
|
||||
// let hostLength = session.host.utf8.count
|
||||
// let length = 1 + 1 + hostLength + 2 + data.count
|
||||
// var response = Data(count: length)
|
||||
// response[0] = 3
|
||||
// response[1] = UInt8(hostLength)
|
||||
// response.replaceSubrange(2..<2+hostLength, with: session.host.utf8)
|
||||
// var beport = UInt16(session.port).bigEndian
|
||||
// withUnsafeBytes(of: &beport) {
|
||||
// response.replaceSubrange(2+hostLength..<4+hostLength, with: $0)
|
||||
// }
|
||||
// response.replaceSubrange(4+hostLength..<length, with: data)
|
||||
// return response
|
||||
// }
|
||||
//
|
||||
// public override func input(data: Data) throws {
|
||||
// inputStreamProcessor!.input(data: data)
|
||||
// }
|
||||
//
|
||||
// public override func output(data: Data) {
|
||||
// if requestSend {
|
||||
// return outputStreamProcessor!.output(data: data)
|
||||
// } else {
|
||||
// requestSend = true
|
||||
// return outputStreamProcessor!.output(data: requestData(withData: data))
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// public class OTAStreamObfuscater: StreamObfuscaterBase {
|
||||
// public class Factory: StreamObfuscater.Factory {
|
||||
// public override init() {}
|
||||
//
|
||||
// public override func build(for session: ConnectSession) -> ShadowsocksAdapter.StreamObfuscater.StreamObfuscaterBase {
|
||||
// return OTAStreamObfuscater(for: session)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// private var count: UInt32 = 0
|
||||
//
|
||||
// private let DATA_BLOCK_SIZE = 0xFFFF - 12
|
||||
//
|
||||
// private var requestSend = false
|
||||
//
|
||||
// private func requestData() -> Data {
|
||||
// var response: [UInt8] = [0x13]
|
||||
// response.append(UInt8(session.host.utf8.count))
|
||||
// response += [UInt8](session.host.utf8)
|
||||
// response += [UInt8](Utils.toByteArray(UInt16(session.port)).reversed())
|
||||
// var responseData = Data(bytes: UnsafePointer<UInt8>(response), count: response.count)
|
||||
// var keyiv = Data(count: key!.count + writeIV!.count)
|
||||
//
|
||||
// keyiv.replaceSubrange(0..<writeIV!.count, with: writeIV!)
|
||||
// keyiv.replaceSubrange(writeIV!.count..<writeIV!.count + key!.count, with: key!)
|
||||
// responseData.append(HMAC.final(value: responseData, algorithm: .SHA1, key: keyiv).subdata(in: 0..<10))
|
||||
// return responseData
|
||||
// }
|
||||
//
|
||||
// public override func input(data: Data) throws {
|
||||
// inputStreamProcessor!.input(data: data)
|
||||
// }
|
||||
//
|
||||
// public override func output(data: Data) {
|
||||
// let fullBlockCount = data.count / DATA_BLOCK_SIZE
|
||||
// var outputSize = fullBlockCount * (DATA_BLOCK_SIZE + 10 + 2)
|
||||
// if data.count > fullBlockCount * DATA_BLOCK_SIZE {
|
||||
// outputSize += data.count - fullBlockCount * DATA_BLOCK_SIZE + 10 + 2
|
||||
// }
|
||||
//
|
||||
// let _requestData: Data = requestData()
|
||||
// if !requestSend {
|
||||
// outputSize += _requestData.count
|
||||
// }
|
||||
//
|
||||
// var outputData = Data(count: outputSize)
|
||||
// var outputOffset = 0
|
||||
// var dataOffset = 0
|
||||
//
|
||||
// if !requestSend {
|
||||
// requestSend = true
|
||||
// outputData.replaceSubrange(0..<_requestData.count, with: _requestData)
|
||||
// outputOffset += _requestData.count
|
||||
// }
|
||||
//
|
||||
// while outputOffset != outputSize {
|
||||
// let blockLength = min(data.count - dataOffset, DATA_BLOCK_SIZE)
|
||||
// var len = UInt16(blockLength).bigEndian
|
||||
// withUnsafeBytes(of: &len) {
|
||||
// outputData.replaceSubrange(outputOffset..<outputOffset+2, with: $0)
|
||||
// }
|
||||
//
|
||||
// var kc = Data(count: writeIV!.count + MemoryLayout.size(ofValue: count))
|
||||
// kc.replaceSubrange(0..<writeIV!.count, with: writeIV!)
|
||||
// var c = count.bigEndian
|
||||
// let ms = MemoryLayout.size(ofValue: c)
|
||||
// withUnsafeBytes(of: &c) {
|
||||
// kc.replaceSubrange(writeIV!.count..<writeIV!.count+ms, with: $0)
|
||||
// }
|
||||
//
|
||||
// data.withUnsafeBytes {
|
||||
// outputData.replaceSubrange(outputOffset+2..<outputOffset+12, with: HMAC.final(value: $0.baseAddress!.advanced(by: dataOffset), length: blockLength, algorithm: .SHA1, key: kc).subdata(in: 0..<10))
|
||||
// }
|
||||
//
|
||||
// data.withUnsafeBytes {
|
||||
// outputData.replaceSubrange(outputOffset+12..<outputOffset+12+blockLength, with: $0.baseAddress!.advanced(by: dataOffset), count: blockLength)
|
||||
// }
|
||||
//
|
||||
// count += 1
|
||||
// outputOffset += 12 + blockLength
|
||||
// dataOffset += blockLength
|
||||
// }
|
||||
//
|
||||
// return outputStreamProcessor!.output(data: outputData)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
9
GlassVPN/zhuhaow-Resolver/License
Normal file
@@ -0,0 +1,9 @@
|
||||
The MIT License
|
||||
|
||||
Copyright 2018 Zhuhao Wang
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
49
README.md
Normal file
@@ -0,0 +1,49 @@
|
||||
AppCheck – Privacy Monitor
|
||||
==========================
|
||||
|
||||
A pocket DNS monitor and network filter.
|
||||
|
||||

|
||||
|
||||
|
||||
## What is it?
|
||||
|
||||
AppCheck helps you identify which applications communicate with third parties.
|
||||
It does so by logging network requests.
|
||||
AppCheck learns only the destination addresses, not the actual data that is exchanged.
|
||||
|
||||
Your data belongs to you.
|
||||
Therefore, monitoring and analysis take place on your device only.
|
||||
The app does not share any data with us or any other third-party – unless you choose to.
|
||||
|
||||
|
||||
### How does it work?
|
||||
|
||||
AppCheck creates a local VPN tunnel to intercept all network connections.
|
||||
For each connection AppCheck looks into the DNS headers only, namely the domain names.
|
||||
These domain names are logged in the background while the VPN is active.
|
||||
That means, AppCheck does not have to be active in the foreground all the time.
|
||||
|
||||
|
||||
## Features
|
||||
|
||||
- See outgoing (DNS) network requests in real-time
|
||||
- See history of previous connections
|
||||
- Block unwanted traffic based on domain names
|
||||
- Record app specific activity<sup>1</sup>
|
||||
- Apply logging filters
|
||||
|
||||
**… and soon:**
|
||||
|
||||
- Alert Monitor & reminder
|
||||
- Occurrence Context Analysis
|
||||
- Participate in privacy research
|
||||
|
||||
|
||||
<sup>1</sup> Due to technical limitations, recording is not limited to any single application. Remember to force-quit all other applications before starting a recording.
|
||||
|
||||
|
||||
## Research Project
|
||||
|
||||
*information will be added soon*
|
||||
|
||||
BIN
doc/screenshot.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
@@ -14,16 +14,23 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
UserDefaults.standard.set(false, forKey: "kill_db")
|
||||
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
|
||||
self.managerVPN = mgr
|
||||
self.postVPNState()
|
||||
}
|
||||
NSNotification.Name.NEVPNStatusDidChange.observe(call: #selector(vpnStatusChanged(_:)), on: self)
|
||||
NotifyFilterChanged.observe(call: #selector(filterDidChange), on: self)
|
||||
NotifyDNSFilterChanged.observe(call: #selector(didChangeDomainFilter), on: self)
|
||||
|
||||
sync.start()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -31,7 +38,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
postRawVPNState((notification.object as? NETunnelProviderSession)?.status ?? .invalid)
|
||||
}
|
||||
|
||||
@objc private func filterDidChange() {
|
||||
@objc private func didChangeDomainFilter() {
|
||||
// Notify VPN extension about changes
|
||||
if let session = self.managerVPN?.connection as? NETunnelProviderSession,
|
||||
session.status == .connected {
|
||||
|
||||
23
main/Assets.xcassets/filter-clear.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "img.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "img@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "img@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
main/Assets.xcassets/filter-clear.imageset/img.png
vendored
Normal file
|
After Width: | Height: | Size: 187 B |
BIN
main/Assets.xcassets/filter-clear.imageset/img@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 289 B |
BIN
main/Assets.xcassets/filter-clear.imageset/img@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 395 B |
23
main/Assets.xcassets/filter-filled.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "img.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "img@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "img@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
main/Assets.xcassets/filter-filled.imageset/img.png
vendored
Normal file
|
After Width: | Height: | Size: 155 B |
BIN
main/Assets.xcassets/filter-filled.imageset/img@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 226 B |
BIN
main/Assets.xcassets/filter-filled.imageset/img@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 302 B |
@@ -27,7 +27,7 @@
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="52.173913043478265" y="375"/>
|
||||
<point key="canvasLocation" x="120" y="100"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
|
||||
448
main/Common Classes/FilterPipeline.swift
Normal file
@@ -0,0 +1,448 @@
|
||||
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?
|
||||
|
||||
private var cellAnimations: Bool = true
|
||||
|
||||
required init(withDelegate: FilterPipelineDelegate) {
|
||||
delegate = withDelegate
|
||||
}
|
||||
|
||||
/// Set a new `dataSource` query and immediately apply all filters and sorting.
|
||||
/// - Note: You must call `reload(fromSource:)` manually!
|
||||
/// - Note: Always use `[unowned self]`
|
||||
func setDataSource(query: @escaping DataSourceQuery) {
|
||||
sourceQuery = query
|
||||
}
|
||||
|
||||
/// - 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)? {
|
||||
// TODO: use sorted dataSource for binary lookup?
|
||||
// would require to shift filter and sorting indices for every new element
|
||||
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
|
||||
|
||||
/// Disable individual cell updates (update, move, insert & remove actions)
|
||||
func pauseCellAnimations(if condition: Bool) {
|
||||
cellAnimations = delegate?.tableView.isFrontmost ?? false && !condition
|
||||
}
|
||||
|
||||
/// Allow individual cell updates (update, move, insert & remove actions) if tableView `isFrontmost`
|
||||
/// - Parameter reloadTable: If `true` and cell animations are disabled, perform `tableView.reloadData()`
|
||||
func continueCellAnimations(reloadTable: Bool = false) {
|
||||
if !cellAnimations {
|
||||
cellAnimations = true
|
||||
if reloadTable { delegate?.tableView.reloadData() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Add new element to the original `dataSource` and immediately apply filter and sorting.
|
||||
/// - 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)
|
||||
if cellAnimations { 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 {
|
||||
dataSource[index] = obj // we need to update anyway
|
||||
return
|
||||
}
|
||||
let oldPos = display.deleteOld(index)
|
||||
dataSource[index] = obj
|
||||
guard status.display else {
|
||||
if cellAnimations, oldPos != -1 { delegate?.tableView.safeDeleteRows([oldPos]) }
|
||||
return
|
||||
}
|
||||
let newPos = display.insertNew(index, previousIndex: oldPos)
|
||||
if cellAnimations {
|
||||
if oldPos == -1 {
|
||||
delegate?.tableView.safeInsertRow(newPos, with: .left)
|
||||
} else {
|
||||
if oldPos == newPos {
|
||||
delegate?.tableView.safeReloadRow(oldPos)
|
||||
} else {
|
||||
delegate?.tableView.safeMoveRow(oldPos, to: newPos)
|
||||
if delegate?.tableView.isFrontmost ?? false {
|
||||
delegate?.rowNeedsUpdate(newPos)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
if cellAnimations { 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
|
||||
/// - Parameters:
|
||||
/// - index: Index of the element position in the original `dataSource`
|
||||
/// - prev: If greater than `0`, try re-insert at the same position.
|
||||
/// - Returns: Index in the projection
|
||||
/// - Complexity: O(log *n*), where *n* is the length of the `projection`.
|
||||
@discardableResult fileprivate func insertNew(_ index: Int, previousIndex prev: Int = -1) -> Int {
|
||||
if prev >= 0, prev < projection.count {
|
||||
if (prev == 0 || !comperator(index, projection[prev - 1])), !comperator(projection[prev], index) {
|
||||
// If element can be inserted at the same position without resorting, do that
|
||||
projection.insert(index, at: prev)
|
||||
return prev
|
||||
}
|
||||
}
|
||||
return 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 `-1` 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 -1
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
70
main/Common Classes/QuickUI.swift
Normal file
@@ -0,0 +1,70 @@
|
||||
import UIKit
|
||||
|
||||
struct QuickUI {
|
||||
|
||||
static func button(_ title: String, target: Any? = nil, action: Selector? = nil) -> UIButton {
|
||||
let x = UIButton(type: .roundedRect)
|
||||
x.setTitle(title, for: .normal)
|
||||
x.titleLabel?.font = .preferredFont(forTextStyle: .body)
|
||||
x.sizeToFit()
|
||||
if let a = action { x.addTarget(target, action: a, for: .touchUpInside) }
|
||||
if #available(iOS 10.0, *) {
|
||||
x.titleLabel?.adjustsFontForContentSizeCategory = true
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
static func image(_ img: UIImage?, frame: CGRect = CGRect.zero) -> UIImageView {
|
||||
let x = UIImageView(frame: frame)
|
||||
x.contentMode = .scaleAspectFit
|
||||
x.image = img
|
||||
return x
|
||||
}
|
||||
|
||||
static func text(_ str: String, frame: CGRect = CGRect.zero) -> UITextView {
|
||||
let x = UITextView(frame: frame)
|
||||
x.font = .preferredFont(forTextStyle: .body) // .systemFont(ofSize: UIFont.systemFontSize)
|
||||
x.isSelectable = false
|
||||
x.isEditable = false
|
||||
x.text = str
|
||||
if #available(iOS 10.0, *) {
|
||||
x.adjustsFontForContentSizeCategory = true
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
static func text(attributed: NSAttributedString, frame: CGRect = CGRect.zero) -> UITextView {
|
||||
let txt = self.text("", frame: frame)
|
||||
txt.attributedText = attributed
|
||||
return txt
|
||||
}
|
||||
}
|
||||
|
||||
extension NSMutableAttributedString {
|
||||
static private var def: UIFont = .preferredFont(forTextStyle: .body)
|
||||
|
||||
func normal(_ str: String, _ style: UIFont.TextStyle = .body) -> Self { append(str, withFont: .preferredFont(forTextStyle: style)) }
|
||||
func bold(_ str: String, _ style: UIFont.TextStyle = .body) -> Self { append(str, withFont: UIFont.preferredFont(forTextStyle: style).bold()) }
|
||||
func italic(_ str: String, _ style: UIFont.TextStyle = .body) -> Self { append(str, withFont: UIFont.preferredFont(forTextStyle: style).italic()) }
|
||||
|
||||
func h1(_ str: String) -> Self { normal(str, .title1) }
|
||||
func h2(_ str: String) -> Self { normal(str, .title2) }
|
||||
func h3(_ str: String) -> Self { normal(str, .title3) }
|
||||
|
||||
private func append(_ str: String, withFont: UIFont) -> Self {
|
||||
append(NSAttributedString(string: str, attributes: [
|
||||
.font : withFont,
|
||||
.foregroundColor : UIColor.sysFg
|
||||
]))
|
||||
return self
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
extension UIFont {
|
||||
func withTraits(traits: UIFontDescriptor.SymbolicTraits) -> UIFont {
|
||||
UIFont(descriptor: fontDescriptor.withSymbolicTraits(traits)!, size: 0) // keep size as is
|
||||
}
|
||||
func bold() -> UIFont { withTraits(traits: .traitBold) }
|
||||
func italic() -> UIFont { withTraits(traits: .traitItalic) }
|
||||
}
|
||||
199
main/Common Classes/TutorialSheet.swift
Normal file
@@ -0,0 +1,199 @@
|
||||
import UIKit
|
||||
|
||||
fileprivate let margin: CGFloat = 20
|
||||
fileprivate let cornerRadius: CGFloat = 15
|
||||
fileprivate let uniRect = CGRect(x: 0, y: 0, width: 500, height: 500)
|
||||
|
||||
class TutorialSheet: UIViewController, UIScrollViewDelegate {
|
||||
|
||||
public var buttonTitleNext: String = "Next"
|
||||
public var buttonTitleDone: String = "Close"
|
||||
|
||||
private var priorIndex: Int?
|
||||
private var lastAnchor: NSLayoutConstraint?
|
||||
private var shouldAnimate: Bool = true
|
||||
private var shouldCloseBlock: (() -> Bool)? = nil
|
||||
private var didCloseBlock: (() -> Void)? = nil
|
||||
|
||||
private let sheetBg: UIView = {
|
||||
let x = UIView(frame: uniRect)
|
||||
x.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
x.backgroundColor = .sysBg
|
||||
x.layer.cornerRadius = cornerRadius
|
||||
x.layer.shadowColor = UIColor.black.cgColor
|
||||
x.layer.shadowRadius = 10
|
||||
x.layer.shadowOpacity = 0.75
|
||||
x.layer.shadowOffset = CGSize(width: 0, height: 4)
|
||||
return x
|
||||
}()
|
||||
|
||||
private let pager: UIPageControl = {
|
||||
let x = UIPageControl(frame: uniRect)
|
||||
x.frame.size.height = x.size(forNumberOfPages: 1).height
|
||||
x.currentPageIndicatorTintColor = UIColor.sysFg.withAlphaComponent(0.5)
|
||||
x.pageIndicatorTintColor = UIColor.sysFg.withAlphaComponent(0.25)
|
||||
x.numberOfPages = 0
|
||||
x.hidesForSinglePage = true
|
||||
x.addTarget(self, action: #selector(pagerDidChange), for: .valueChanged)
|
||||
return x
|
||||
}()
|
||||
|
||||
private let pageScroll: UIScrollView = {
|
||||
let x = UIScrollView(frame: uniRect)
|
||||
x.bounces = false
|
||||
x.isPagingEnabled = true
|
||||
x.showsVerticalScrollIndicator = false
|
||||
x.showsHorizontalScrollIndicator = false
|
||||
|
||||
let content = UIView()
|
||||
x.addSubview(content)
|
||||
content.translatesAutoresizingMaskIntoConstraints = false
|
||||
content.anchor([.left, .right, .top, .bottom], to: x)
|
||||
content.anchor([.width, .height], to: x) | .defaultLow
|
||||
return x
|
||||
}()
|
||||
|
||||
private let button: UIButton = {
|
||||
let x = QuickUI.button("", target: self, action: #selector(buttonTapped))
|
||||
x.contentEdgeInsets = UIEdgeInsets(all: 8)
|
||||
return x
|
||||
}()
|
||||
|
||||
|
||||
// MARK: Init
|
||||
|
||||
required init?(coder: NSCoder) { super.init(coder: coder) }
|
||||
|
||||
required init() {
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
view = makeControlUI()
|
||||
modalPresentationStyle = .custom
|
||||
if #available(iOS 13.0, *) {
|
||||
isModalInPresentation = true
|
||||
}
|
||||
UIDevice.orientationDidChangeNotification.observe(call: #selector(didChangeOrientation), on: self)
|
||||
}
|
||||
|
||||
/// Present Tutorial Sheet Controller
|
||||
/// - Parameter viewController: If set to `nil`, use main application as canvas. (Default: `nil`)
|
||||
/// - Parameter animate: Use `present` and `dismiss` animations. (Default: `true`)
|
||||
/// - Parameter shouldClose: Called before the view controller is dismissed. Return `false` to prevent the dismissal.
|
||||
/// Use this block to extract user data from input fields. (Default: `nil`)
|
||||
/// - Parameter didClose: Called after the view controller is completely dismissed (with animations).
|
||||
/// Use this block to update UI and visible changes. (Default: `nil`)
|
||||
func present(in viewController: UIViewController? = nil, animate: Bool = true, shouldClose: (() -> Bool)? = nil, didClose: (() -> Void)? = nil) {
|
||||
guard let vc = viewController ?? UIApplication.shared.keyWindow?.rootViewController else {
|
||||
return
|
||||
}
|
||||
shouldCloseBlock = shouldClose
|
||||
didCloseBlock = didClose
|
||||
shouldAnimate = animate
|
||||
vc.present(self, animated: animate)
|
||||
}
|
||||
|
||||
|
||||
// MARK: Dynamic UI
|
||||
|
||||
@discardableResult func addSheet(_ closure: ((UIStackView) -> Void)? = nil) -> UIStackView {
|
||||
pager.numberOfPages += 1
|
||||
updateButtonTitle()
|
||||
let x = UIStackView(frame: pageScroll.bounds)
|
||||
x.translatesAutoresizingMaskIntoConstraints = false
|
||||
x.axis = .vertical
|
||||
x.backgroundColor = UIColor.black
|
||||
x.isOpaque = true
|
||||
guard let content = pageScroll.subviews.first else {
|
||||
return x
|
||||
}
|
||||
let prev = content.subviews.last
|
||||
content.addSubview(x)
|
||||
x.anchor([.top, .width, .height], to: pageScroll)
|
||||
x.leadingAnchor =&= (prev==nil ? content.leadingAnchor : prev!.trailingAnchor)
|
||||
lastAnchor?.isActive = false
|
||||
lastAnchor = (x.trailingAnchor =&= pageScroll.trailingAnchor)
|
||||
closure?(x)
|
||||
return x
|
||||
}
|
||||
|
||||
|
||||
// MARK: Static UI
|
||||
|
||||
private func makeControlUI() -> UIView {
|
||||
pageScroll.delegate = self
|
||||
|
||||
sheetBg.addSubview(pager)
|
||||
sheetBg.addSubview(pageScroll)
|
||||
sheetBg.addSubview(button)
|
||||
|
||||
for x in sheetBg.subviews { x.translatesAutoresizingMaskIntoConstraints = false }
|
||||
|
||||
pager.anchor([.top, .left, .right], to: sheetBg)
|
||||
pageScroll.topAnchor =&= pager.bottomAnchor
|
||||
pageScroll.anchor([.left, .right, .top, .bottom], to: sheetBg, margin: cornerRadius/2) | .defaultHigh
|
||||
button.topAnchor =&= pageScroll.bottomAnchor
|
||||
button.anchor([.bottom, .centerX], to: sheetBg)
|
||||
// button.bottomAnchor =&= sheetBg.bottomAnchor - 30
|
||||
// button.centerXAnchor =&= sheetBg.centerXAnchor
|
||||
|
||||
let bg = UIView(frame: uniRect)
|
||||
bg.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
bg.addSubview(sheetBg)
|
||||
|
||||
let h: CGFloat = UIApplication.shared.isStatusBarHidden ? 0 : UIApplication.shared.statusBarFrame.height
|
||||
sheetBg.frame = bg.frame.inset(by: UIEdgeInsets(all: margin, top: margin + h))
|
||||
return bg
|
||||
}
|
||||
|
||||
|
||||
// MARK: Delegates
|
||||
|
||||
override func viewWillLayoutSubviews() {
|
||||
priorIndex = pager.currentPage
|
||||
}
|
||||
|
||||
@objc private func didChangeOrientation() {
|
||||
if let i = priorIndex {
|
||||
priorIndex = nil
|
||||
switchToSheet(i, animated: false)
|
||||
}
|
||||
for case let x as UIStackView in pageScroll.subviews.first!.subviews {
|
||||
x.axis = (x.frame.width > x.frame.height) ? .horizontal : .vertical
|
||||
}
|
||||
}
|
||||
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
let w = scrollView.frame.width
|
||||
let new = Int((scrollView.contentOffset.x + w/2) / w)
|
||||
if pager.currentPage != new {
|
||||
pager.currentPage = new
|
||||
updateButtonTitle()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func pagerDidChange(sender: UIPageControl) {
|
||||
switchToSheet(sender.currentPage, animated: true)
|
||||
}
|
||||
|
||||
private func switchToSheet(_ i: Int, animated: Bool) {
|
||||
pageScroll.setContentOffset(CGPoint(x: CGFloat(i) * pageScroll.bounds.width, y: 0), animated: animated)
|
||||
}
|
||||
|
||||
private func updateButtonTitle() {
|
||||
let last = (pager.currentPage == pager.numberOfPages - 1)
|
||||
let title = last ? buttonTitleDone : buttonTitleNext
|
||||
if button.title(for: .normal) != title {
|
||||
button.setTitle(title, for: .normal)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func buttonTapped() {
|
||||
let next = pager.currentPage + 1
|
||||
if next < pager.numberOfPages {
|
||||
switchToSheet(next, animated: true)
|
||||
} else {
|
||||
if shouldCloseBlock?() ?? true {
|
||||
dismiss(animated: shouldAnimate, completion: didCloseBlock)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
40
main/DB/DBExtensions.swift
Normal file
@@ -0,0 +1,40 @@
|
||||
import UIKit
|
||||
|
||||
extension GroupedDomain {
|
||||
/// Return new `GroupedDomain` by adding `total` and `blocked` counts. Set `lastModified` to the maximum of the two.
|
||||
static func +(a: GroupedDomain, b: GroupedDomain) -> Self {
|
||||
GroupedDomain(domain: a.domain, total: a.total + b.total, blocked: a.blocked + b.blocked,
|
||||
lastModified: max(a.lastModified, b.lastModified), options: a.options ?? b.options )
|
||||
}
|
||||
/// Return new `GroupedDomain` by subtracting `total` and `blocked` counts.
|
||||
static func -(a: GroupedDomain, b: GroupedDomain) -> Self {
|
||||
GroupedDomain(domain: a.domain, total: a.total - b.total, blocked: a.blocked - b.blocked,
|
||||
lastModified: a.lastModified, options: a.options )
|
||||
}
|
||||
}
|
||||
|
||||
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 Recording {
|
||||
var fallbackTitle: String { get { "Unnamed Recording #\(id)" } }
|
||||
var duration: Timestamp? { get { stop == nil ? nil : stop! - start } }
|
||||
var durationString: String? { get { stop == nil ? nil : TimeFormat.from(duration!) } }
|
||||
}
|
||||
|
||||
@@ -1,276 +0,0 @@
|
||||
import UIKit
|
||||
|
||||
let DBWrp = DBWrapper()
|
||||
|
||||
class DBWrapper {
|
||||
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 } }
|
||||
}
|
||||
|
||||
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?) -> [(Timestamp, Bool)] {
|
||||
guard let domain = domain else { return [] }
|
||||
return AppDB?.timesForDomain(domain)?.reversed() ?? []
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
func initContentOfDB() {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
private func dataF_init() {
|
||||
let list = AppDB?.loadFilters() ?? [:]
|
||||
Q.async(flags: .barrier) {
|
||||
self.dataF = list
|
||||
NotifyFilterChanged.postAsyncMain()
|
||||
}
|
||||
}
|
||||
|
||||
private func dataAB_init() {
|
||||
let list = AppDB?.domainList()
|
||||
Q.async(flags: .barrier) {
|
||||
self.dataA = []
|
||||
self.dataB = []
|
||||
self.latestModification = 0
|
||||
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() {
|
||||
QLog.Debug("\(#function)")
|
||||
#if !IOS_SIMULATOR
|
||||
guard currentVPNState == .on else { return }
|
||||
#endif
|
||||
guard let res = AppDB?.domainList(since: latestModification), 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.mergeExistingParts(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)
|
||||
}
|
||||
c += 1
|
||||
self.latestModification = max(parent.lastModified, self.latestModification)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func mergeExistingParts(_ 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: - Delete History
|
||||
|
||||
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: ts)) ?? 0
|
||||
guard modified > 0 else {
|
||||
return // nothing has changed
|
||||
}
|
||||
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), list.count > 0 else {
|
||||
self.dataA.remove(at: index)
|
||||
self.dataB.remove(at: index)
|
||||
self.dataA_delegate?.deleteRow(at: index)
|
||||
self.dataB_delegate(parentDom)?.replaceData(with: [])
|
||||
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)
|
||||
}
|
||||
}
|
||||
NotifyFilterChanged.postAsyncMain()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 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,304 +0,0 @@
|
||||
import Foundation
|
||||
import SQLite3
|
||||
|
||||
typealias Timestamp = Int64
|
||||
struct GroupedDomain {
|
||||
let domain: String, total: Int32, blocked: Int32, lastModified: Timestamp
|
||||
var options: FilterOptions? = nil
|
||||
}
|
||||
|
||||
struct FilterOptions: OptionSet {
|
||||
let rawValue: Int32
|
||||
static let none = FilterOptions(rawValue: 0)
|
||||
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
|
||||
|
||||
var AppDB: SQLiteDatabase? { get { try? SQLiteDatabase.open() } }
|
||||
|
||||
class SQLiteDatabase {
|
||||
private let dbPointer: OpaquePointer?
|
||||
private init(dbPointer: OpaquePointer?) {
|
||||
// print("SQLite path: \(basePath!.absoluteString)")
|
||||
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: ((OpaquePointer) -> Bool)?, 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) }
|
||||
guard bind?(stmt) ?? true 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, bind: nil) {
|
||||
try ifStep($0, SQLITE_DONE)
|
||||
}
|
||||
}
|
||||
|
||||
func vacuum() {
|
||||
try? run(sql: "VACUUM;", bind: nil) { try ifStep($0, SQLITE_DONE) }
|
||||
}
|
||||
}
|
||||
|
||||
protocol SQLTable {
|
||||
static var createStatement: String { get }
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Easy Access func
|
||||
|
||||
private extension SQLiteDatabase {
|
||||
func bindInt(_ stmt: OpaquePointer, _ col: Int32, _ value: Int32) -> Bool {
|
||||
sqlite3_bind_int(stmt, col, value) == SQLITE_OK
|
||||
}
|
||||
|
||||
func bindInt64(_ stmt: OpaquePointer, _ col: Int32, _ value: sqlite3_int64) -> Bool {
|
||||
sqlite3_bind_int64(stmt, col, value) == SQLITE_OK
|
||||
}
|
||||
|
||||
func bindText(_ stmt: OpaquePointer, _ col: Int32, _ value: String) -> Bool {
|
||||
sqlite3_bind_text(stmt, col, (value as NSString).utf8String, -1, nil) == SQLITE_OK
|
||||
}
|
||||
|
||||
func readText(_ stmt: OpaquePointer, _ col: Int32) -> String? {
|
||||
let val = sqlite3_column_text(stmt, col)
|
||||
return (val != nil ? String(cString: val!) : nil)
|
||||
}
|
||||
|
||||
func readGroupedDomain(_ stmt: OpaquePointer) -> GroupedDomain {
|
||||
GroupedDomain(domain: readText(stmt, 0) ?? "",
|
||||
total: sqlite3_column_int(stmt, 1),
|
||||
blocked: sqlite3_column_int(stmt, 2),
|
||||
lastModified: sqlite3_column_int64(stmt, 3))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 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 BIGINT DEFAULT (strftime('%s','now')),
|
||||
domain VARCHAR(255) NOT NULL,
|
||||
logOpt INT DEFAULT 0
|
||||
);
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
extension SQLiteDatabase {
|
||||
|
||||
// MARK: insert
|
||||
|
||||
func insertDNSQuery(_ domain: String, blocked: Bool) throws {
|
||||
try? run(sql: "INSERT INTO req (domain, logOpt) VALUES (?, ?);", bind: {
|
||||
self.bindText($0, 1, domain) && self.bindInt($0, 2, blocked ? 1 : 0)
|
||||
}) {
|
||||
try ifStep($0, SQLITE_DONE)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: delete
|
||||
|
||||
func destroyContent() throws {
|
||||
try? run(sql: "DROP TABLE IF EXISTS req;", bind: nil) {
|
||||
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: {
|
||||
self.bindInt64($0, 1, ts) && self.bindText($0, 2, domain) && self.bindText($0, 3, domain)
|
||||
}) { stmt -> Int32 in
|
||||
try ifStep(stmt, SQLITE_DONE)
|
||||
return sqlite3_changes(dbPointer)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: read
|
||||
|
||||
func domainList(since ts: Timestamp = 0) -> [GroupedDomain]? {
|
||||
try? run(sql: "SELECT domain, COUNT(*), SUM(logOpt&1), MAX(ts) FROM req \(ts == 0 ? "" : "WHERE ts > ?") GROUP BY domain ORDER BY 4 DESC;", bind: {
|
||||
ts == 0 || self.bindInt64($0, 1, ts)
|
||||
}) {
|
||||
allRows($0) { readGroupedDomain($0) }
|
||||
}
|
||||
}
|
||||
|
||||
func domainList(matching domain: String) -> [GroupedDomain]? {
|
||||
try? run(sql: "SELECT domain, COUNT(*), SUM(logOpt&1), MAX(ts) FROM req WHERE (domain = ? OR domain LIKE '%.' || ?) GROUP BY domain ORDER BY 4 DESC;", bind: {
|
||||
self.bindText($0, 1, domain) && self.bindText($0, 2, domain)
|
||||
}) {
|
||||
allRows($0) { readGroupedDomain($0) }
|
||||
}
|
||||
}
|
||||
|
||||
func timesForDomain(_ fullDomain: String) -> [(Timestamp, Bool)]? {
|
||||
try? run(sql: "SELECT ts, logOpt FROM req WHERE domain = ?;", bind: {
|
||||
self.bindText($0, 1, fullDomain)
|
||||
}) {
|
||||
allRows($0) { (sqlite3_column_int64($0, 0), sqlite3_column_int($0, 1) > 0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - DNSFilterT
|
||||
|
||||
private struct DNSFilterT: SQLTable {
|
||||
let domain: String
|
||||
let options: FilterOptions
|
||||
static var createStatement: String {
|
||||
return """
|
||||
CREATE TABLE IF NOT EXISTS filter(
|
||||
domain VARCHAR(255) UNIQUE NOT NULL,
|
||||
opt INT DEFAULT 0
|
||||
);
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
extension SQLiteDatabase {
|
||||
|
||||
// MARK: read
|
||||
|
||||
func loadFilters() -> [String : FilterOptions]? {
|
||||
try? run(sql: "SELECT domain, opt FROM filter ORDER BY domain ASC;", bind: nil) {
|
||||
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: {
|
||||
self.bindText($0, 1, 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: {
|
||||
self.bindText($0, 1, domain) && self.bindInt($0, 2, rv)
|
||||
}) {
|
||||
try ifStep($0, SQLITE_DONE)
|
||||
}
|
||||
}
|
||||
func updateFilter() {
|
||||
try? run(sql: "UPDATE filter SET opt = ? WHERE domain = ? LIMIT 1;", bind: {
|
||||
self.bindInt($0, 1, rv) && self.bindText($0, 2, domain)
|
||||
}) { stmt -> Void in
|
||||
sqlite3_step(stmt)
|
||||
}
|
||||
}
|
||||
do { try createFilter() } catch { updateFilter() }
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
252
main/Data Source/GroupedDomainDataSource.swift
Normal file
@@ -0,0 +1,252 @@
|
||||
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.continue()
|
||||
} else {
|
||||
pipeline.reload(fromSource: true, whenDone: {
|
||||
sync.continue()
|
||||
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) {
|
||||
sync.pause()
|
||||
defer { sync.continue() }
|
||||
let range = notification.object as! SQLiteRowRange
|
||||
guard let latest = AppDB?.dnsLogsGrouped(range: range, parentDomain: parent) else {
|
||||
assertionFailure("NotifySyncInsert fired with empty range")
|
||||
return
|
||||
}
|
||||
pipeline.pauseCellAnimations(if: latest.count > 14)
|
||||
for x in latest {
|
||||
if let (i, obj) = pipeline.dataSourceGet(where: { $0.domain == x.domain }) {
|
||||
pipeline.update(obj + x, at: i)
|
||||
} else {
|
||||
var y = x
|
||||
y.options = DomainFilter[x.domain]
|
||||
pipeline.addNew(y)
|
||||
}
|
||||
tsLatest = max(tsLatest, x.lastModified)
|
||||
}
|
||||
pipeline.continueCellAnimations(reloadTable: true)
|
||||
}
|
||||
|
||||
/// Callback fired when background sync removed old entries from the list. (`NotifySyncRemove` notification)
|
||||
@objc private func syncRemove(_ notification: Notification) {
|
||||
sync.pause()
|
||||
defer { sync.continue() }
|
||||
let range = notification.object as! SQLiteRowRange
|
||||
guard let outdated = AppDB?.dnsLogsGrouped(range: range, parentDomain: parent),
|
||||
outdated.count > 0 else {
|
||||
return
|
||||
}
|
||||
pipeline.pauseCellAnimations(if: outdated.count > 14)
|
||||
var listOfDeletes: [Int] = []
|
||||
for x in outdated {
|
||||
guard let (i, obj) = pipeline.dataSourceGet(where: { $0.domain == x.domain }) else {
|
||||
assertionFailure("Try to remove non-existent element")
|
||||
continue // should never happen
|
||||
}
|
||||
if obj.total > x.total {
|
||||
pipeline.update(obj - x, at: i)
|
||||
} else {
|
||||
listOfDeletes.append(i)
|
||||
}
|
||||
}
|
||||
pipeline.remove(indices: listOfDeletes.sorted())
|
||||
pipeline.continueCellAnimations(reloadTable: true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ################################
|
||||
// #
|
||||
// # MARK: - Delete History
|
||||
// #
|
||||
// ################################
|
||||
|
||||
extension GroupedDomainDataSource {
|
||||
|
||||
/// Callback fired when user performs row edit -> delete action
|
||||
func deleteHistory(domain: String, since ts: Timestamp) {
|
||||
let flag = (parent != nil)
|
||||
DispatchQueue.global().async {
|
||||
guard let db = AppDB, db.dnsLogsDelete(domain, strict: flag, since: ts) > 0 else {
|
||||
return // nothing has changed
|
||||
}
|
||||
db.vacuum()
|
||||
NotifyLogHistoryReset.postAsyncMain(domain) // calls partiallyReloadFromSource(:)
|
||||
}
|
||||
}
|
||||
|
||||
/// Reload a single data source entry. Callback fired by `reloadFromSource()`
|
||||
/// Only useful if `affectedFQDN` currently exists in `dataSource`. Can either update or remove entry.
|
||||
private func partiallyReloadFromSource(_ affectedFQDN: String) {
|
||||
let affectedParent = affectedFQDN.extractDomain()
|
||||
guard parent == nil || parent == affectedParent else {
|
||||
return // does not affect current table
|
||||
}
|
||||
let affected = (parent == nil ? affectedParent : affectedFQDN)
|
||||
guard let old = pipeline.dataSourceGet(where: { $0.domain == affected }) else {
|
||||
// can only happen if delete sheet is open while background sync removed the element
|
||||
return
|
||||
}
|
||||
if var updated = AppDB?.dnsLogsGrouped(since: sync.tsEarliest, upto: tsLatest,
|
||||
matchingDomain: affected, parentDomain: parent)?.first {
|
||||
assert(old.object.domain == updated.domain)
|
||||
updated.options = DomainFilter[updated.domain]
|
||||
pipeline.update(updated, at: old.index)
|
||||
} else {
|
||||
pipeline.remove(indices: [old.index])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ##########################
|
||||
// #
|
||||
// # 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)
|
||||
}
|
||||
}
|
||||
46
main/Data Source/RecordingsDB.swift
Normal file
@@ -0,0 +1,46 @@
|
||||
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) {
|
||||
sync.syncNow() // persist changes in cache before copying recording details
|
||||
AppDB?.recordingLogsPersist(r)
|
||||
}
|
||||
|
||||
/// Get list of domains that occured during the recording
|
||||
static func details(_ r: Recording) -> [RecordLog] {
|
||||
AppDB?.recordingLogsGetGrouped(r) ?? []
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
|
||||
55
main/Data Source/SyncUpdate.swift
Normal file
@@ -0,0 +1,55 @@
|
||||
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 periodicUpdate() { if paused == 0 { syncNow() } }
|
||||
|
||||
@objc private func didChangeDateFilter() {
|
||||
let lastXFilter = Pref.DateFilter.lastXMinTimestamp() ?? 0
|
||||
let before = tsEarliest
|
||||
tsEarliest = lastXFilter
|
||||
if before < lastXFilter {
|
||||
DispatchQueue.global().async {
|
||||
if let excess = AppDB?.dnsLogsRowRange(between: before, and: lastXFilter) {
|
||||
NotifySyncRemove.postAsyncMain(excess)
|
||||
}
|
||||
}
|
||||
} else if before > lastXFilter {
|
||||
DispatchQueue.global().async {
|
||||
if let missing = AppDB?.dnsLogsRowRange(between: lastXFilter, and: before) {
|
||||
NotifySyncInsert.postAsyncMain(missing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func start() { paused = 0 }
|
||||
func pause() { paused += 1 }
|
||||
func `continue`() { if paused > 0 { paused -= 1 } }
|
||||
|
||||
func syncNow() {
|
||||
self.pause() // reduce concurrent load
|
||||
|
||||
if let inserted = AppDB?.dnsLogsPersist() { // move cache -> heap
|
||||
NotifySyncInsert.post(inserted)
|
||||
}
|
||||
if let lastXFilter = Pref.DateFilter.lastXMinTimestamp(), tsEarliest < lastXFilter {
|
||||
if let removed = AppDB?.dnsLogsRowRange(between: tsEarliest, and: lastXFilter) {
|
||||
NotifySyncRemove.post(removed)
|
||||
}
|
||||
tsEarliest = lastXFilter
|
||||
}
|
||||
// TODO: periodic hard delete old logs (will reset rowids!)
|
||||
|
||||
self.continue()
|
||||
}
|
||||
}
|
||||
42
main/Data Source/TestDataSource.swift
Normal file
@@ -0,0 +1,42 @@
|
||||
import Foundation
|
||||
|
||||
#if IOS_SIMULATOR
|
||||
|
||||
private let db = AppDB!
|
||||
private var pStmt: OpaquePointer?
|
||||
|
||||
class TestDataSource {
|
||||
|
||||
static func load() {
|
||||
QLog.Debug("SQLite path: \(URL.internalDB())")
|
||||
|
||||
let deleted = db.dnsLogsDelete("test.com", strict: false)
|
||||
try? db.run(sql: "DELETE FROM cache;")
|
||||
QLog.Debug("Deleting \(deleted) rows matching 'test.com' (+ \(db.numberOfChanges) in cache)")
|
||||
|
||||
QLog.Debug("Writing 33 test logs")
|
||||
pStmt = try! db.logWritePrepare()
|
||||
try? db.logWrite(pStmt, "keeptest.com", blocked: false)
|
||||
for _ in 1...4 { try? db.logWrite(pStmt, "test.com", blocked: false) }
|
||||
for _ in 1...7 { try? db.logWrite(pStmt, "i.test.com", blocked: false) }
|
||||
for i in 1...8 { try? db.logWrite(pStmt, "b.test.com", blocked: i>5) }
|
||||
for i in 1...13 { try? db.logWrite(pStmt, "bi.test.com", blocked: i%2==0) }
|
||||
|
||||
db.dnsLogsPersist()
|
||||
|
||||
QLog.Debug("Creating 4 filters")
|
||||
db.setFilter("b.test.com", .blocked)
|
||||
db.setFilter("i.test.com", .ignored)
|
||||
db.setFilter("bi.test.com", [.blocked, .ignored])
|
||||
|
||||
QLog.Debug("Done")
|
||||
|
||||
Timer.repeating(2, call: #selector(insertRandom), on: self)
|
||||
}
|
||||
|
||||
@objc static func insertRandom() {
|
||||
//QLog.Debug("Inserting 1 periodic log entry")
|
||||
try? db.logWrite(pStmt, "\(arc4random() % 5).count.test.com", blocked: true)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,57 +1,66 @@
|
||||
import UIKit
|
||||
|
||||
extension UIAlertController {
|
||||
func presentIn(_ viewController: UIViewController) {
|
||||
viewController.present(self, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Basic Alerts
|
||||
|
||||
/// - Parameters:
|
||||
/// - buttonText: Default: `"Dismiss"`
|
||||
func Alert(title: String?, text: String?, buttonText: String = "Dismiss") -> UIAlertController {
|
||||
let alert = UIAlertController(title: title, message: text, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: buttonText, style: .cancel, handler: nil))
|
||||
return alert
|
||||
}
|
||||
|
||||
/// - Parameters:
|
||||
/// - buttonText: Default:`"Dismiss"`
|
||||
func ErrorAlert(_ error: Error, buttonText: String = "Dismiss") -> UIAlertController {
|
||||
return Alert(title: "Error", text: error.localizedDescription, buttonText: buttonText)
|
||||
}
|
||||
|
||||
func AskAlert(title: String?, text: String?, buttonText: String = "Continue", buttonStyle: UIAlertAction.Style = .default, action: @escaping () -> Void) -> UIAlertController {
|
||||
let alert = Alert(title: title, text: text, buttonText: "Cancel")
|
||||
alert.addAction(UIAlertAction(title: buttonText, style: buttonStyle) { _ in action() })
|
||||
return alert
|
||||
/// - Parameters:
|
||||
/// - buttonText: Default: `"Dismiss"`
|
||||
func ErrorAlert(_ errorDescription: String, buttonText: String = "Dismiss") -> UIAlertController {
|
||||
return Alert(title: "Error", text: errorDescription, buttonText: buttonText)
|
||||
}
|
||||
|
||||
extension UIAlertController {
|
||||
func presentIn(_ viewController: UIViewController?) {
|
||||
viewController?.present(self, animated: true, completion: nil)
|
||||
}
|
||||
/// - Parameters:
|
||||
/// - buttonText: Default: `"Continue"`
|
||||
/// - buttonStyle: Default: `.default`
|
||||
func AskAlert(title: String?, text: String?, buttonText: String = "Continue", buttonStyle: UIAlertAction.Style = .default, action: @escaping (UIAlertController) -> Void) -> UIAlertController {
|
||||
let alert = Alert(title: title, text: text, buttonText: "Cancel")
|
||||
alert.addAction(UIAlertAction(title: buttonText, style: buttonStyle) { _ in action(alert) })
|
||||
return alert
|
||||
}
|
||||
|
||||
// MARK: Alert with multiple options
|
||||
|
||||
func AlertWithOptions(title: String?, text: String?, buttons: [String], lastIsDestructive: Bool = false, callback: @escaping (_ index: Int?) -> Void) -> UIAlertController {
|
||||
/// - Parameters:
|
||||
/// - buttons: Default: `[]`
|
||||
/// - lastIsDestructive: Default: `false`
|
||||
/// - cancelButtonText: Default: `"Dismiss"`
|
||||
func BottomAlert(title: String?, text: String?, buttons: [String] = [], lastIsDestructive: Bool = false, cancelButtonText: String = "Cancel", callback: @escaping (_ index: Int?) -> Void) -> UIAlertController {
|
||||
let alert = UIAlertController(title: title, message: text, preferredStyle: .actionSheet)
|
||||
for (i, btn) in buttons.enumerated() {
|
||||
let dangerous = (lastIsDestructive && i + 1 == buttons.count)
|
||||
alert.addAction(UIAlertAction(title: btn, style: dangerous ? .destructive : .default) { _ in callback(i) })
|
||||
}
|
||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in callback(nil) })
|
||||
alert.addAction(UIAlertAction(title: cancelButtonText, style: .cancel) { _ in callback(nil) })
|
||||
return alert
|
||||
}
|
||||
|
||||
func AlertDeleteLogs(_ domain: String, latest: Timestamp, success: @escaping (_ tsMin: Timestamp) -> Void) -> UIAlertController {
|
||||
let sinceNow = TimestampNow() - latest
|
||||
var buttons = ["Last 5 minutes", "Last 15 minutes", "Last hour", "Last 24 hours", "Delete everything"]
|
||||
var times: [Timestamp] = [300, 900, 3600, 86400]
|
||||
while times.count > 0, times[0] < sinceNow {
|
||||
buttons.removeFirst()
|
||||
times.removeFirst()
|
||||
}
|
||||
return AlertWithOptions(title: "Delete logs", text: "Delete logs for domain '\(domain)'", buttons: buttons, lastIsDestructive: true) {
|
||||
guard let idx = $0 else {
|
||||
return
|
||||
}
|
||||
if idx >= times.count {
|
||||
success(0)
|
||||
} else {
|
||||
success(Timestamp(Date().timeIntervalSince1970) - times[idx])
|
||||
let minutesPassed = (Timestamp.now() - latest) / 60
|
||||
let times: [Int] = [5, 15, 60, 1440].compactMap { minutesPassed < $0 ? $0 : nil }
|
||||
let fmt = TimeFormat(.full, allowed: [.hour, .minute])
|
||||
let labels = times.map { "Last " + (fmt.from(minutes: $0) ?? "?") }
|
||||
return BottomAlert(title: "Delete logs", text: "Delete logs for domain '\(domain)'", buttons: labels + ["Delete everything"], lastIsDestructive: true) {
|
||||
if let i = $0 {
|
||||
success(i < times.count ? Timestamp.past(minutes: times[i]) : 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
87
main/Extensions/Array.swift
Normal file
@@ -0,0 +1,87 @@
|
||||
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.
|
||||
/// - Parameters:
|
||||
/// - mustExist: Determine whether to return low index or `nil` if element is missing.
|
||||
/// - first: If `true`, keep searching for first matching element.
|
||||
/// - 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, findFirst: Bool = false) -> Int? {
|
||||
var found = false
|
||||
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 {
|
||||
if !findFirst { return mid } // exit early if we dont care about first index
|
||||
hi = mid - 1
|
||||
found = true
|
||||
}
|
||||
}
|
||||
return (mustExist && !found) ? nil : lo // not found, would be inserted at position lo
|
||||
}
|
||||
|
||||
/// Binary tree lookup whether element exists. Performs `binTreeIndex(of:compare:mustExist:)` internally.
|
||||
func binTreeExists(_ element: Element, compare fn: CompareFn) -> Bool {
|
||||
binTreeIndex(of: element, compare: fn, mustExist: true) != nil
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
82
main/Extensions/AutoLayout.swift
Normal file
@@ -0,0 +1,82 @@
|
||||
import UIKit
|
||||
|
||||
/*
|
||||
Readable Auto Layout Constraints
|
||||
|
||||
Usage:
|
||||
A.anchor =&= multiplier * B.anchor + constant | priority
|
||||
*/
|
||||
|
||||
infix operator =&= : AdditionPrecedence
|
||||
infix operator =<= : AdditionPrecedence
|
||||
infix operator =>= : AdditionPrecedence
|
||||
//infix operator | : AdditionPrecedence
|
||||
|
||||
/// Create and activate an `equal` constraint between left and right anchor. Format: `A.anchor =&= multiplier * B.anchor + constant | priority`
|
||||
@discardableResult func =&= <T>(l: NSLayoutAnchor<T>, r: NSLayoutAnchor<T>) -> NSLayoutConstraint { l.constraint(equalTo: r).on() }
|
||||
/// Create and activate a `lessThan` constraint between left and right anchor. Format: `A.anchor =<= multiplier * B.anchor + constant | priority`
|
||||
@discardableResult func =<= <T>(l: NSLayoutAnchor<T>, r: NSLayoutAnchor<T>) -> NSLayoutConstraint { l.constraint(lessThanOrEqualTo: r).on() }
|
||||
/// Create and activate a `greaterThan` constraint between left and right anchor. Format: `A.anchor =>= multiplier * B.anchor + constant | priority`
|
||||
@discardableResult func =>= <T>(l: NSLayoutAnchor<T>, r: NSLayoutAnchor<T>) -> NSLayoutConstraint { l.constraint(greaterThanOrEqualTo: r).on() }
|
||||
|
||||
extension NSLayoutDimension { // higher precedence, so multiply first
|
||||
/// Create intermediate anchor multiplier result.
|
||||
static func *(l: CGFloat, r: NSLayoutDimension) -> AnchorMultiplier { .init(anchor: r, m: l) }
|
||||
}
|
||||
|
||||
/// Intermediate `NSLayoutConstraint` anchor with multiplier supplement
|
||||
struct AnchorMultiplier {
|
||||
let anchor: NSLayoutDimension, m: CGFloat
|
||||
|
||||
/// Create and activate an `equal` constraint between left and right anchor. Format: `A.anchor =&= multiplier * B.anchor + constant | priority`
|
||||
@discardableResult static func =&=(l: NSLayoutDimension, r: Self) -> NSLayoutConstraint { l.constraint(equalTo: r.anchor, multiplier: r.m).on() }
|
||||
/// Create and activate a `lessThan` constraint between left and right anchor. Format: `A.anchor =<= multiplier * B.anchor + constant | priority`
|
||||
@discardableResult static func =<=(l: NSLayoutDimension, r: Self) -> NSLayoutConstraint { l.constraint(lessThanOrEqualTo: r.anchor, multiplier: r.m).on() }
|
||||
/// Create and activate a `greaterThan` constraint between left and right anchor. Format: `A.anchor =>= multiplier * B.anchor + constant | priority`
|
||||
@discardableResult static func =>=(l: NSLayoutDimension, r: Self) -> NSLayoutConstraint { l.constraint(greaterThanOrEqualTo: r.anchor, multiplier: r.m).on() }
|
||||
}
|
||||
|
||||
extension NSLayoutConstraint {
|
||||
/// Change `isActive`to `true` and return `self`
|
||||
func on() -> Self { isActive = true; return self }
|
||||
/// Change `constant`attribute and return `self`
|
||||
@discardableResult static func +(l: NSLayoutConstraint, r: CGFloat) -> NSLayoutConstraint { l.constant = r; return l }
|
||||
/// Change `constant` attribute and return `self`
|
||||
@discardableResult static func -(l: NSLayoutConstraint, r: CGFloat) -> NSLayoutConstraint { l.constant = -r; return l }
|
||||
/// Change `priority` attribute and return `self`
|
||||
@discardableResult static func |(l: NSLayoutConstraint, r: UILayoutPriority) -> NSLayoutConstraint { l.priority = r; return l }
|
||||
}
|
||||
|
||||
/*
|
||||
UIView extension to generate multiple constraints at once
|
||||
|
||||
Usage:
|
||||
child.anchor([.width, .height], to: parent) | .defaultLow
|
||||
*/
|
||||
|
||||
extension UIView {
|
||||
/// Edges that need the relation to flip arguments. For these we need to inverse the constant value and relation.
|
||||
private static let inverseItem: [NSLayoutConstraint.Attribute] = [.right, .bottom, .trailing, .lastBaseline, .rightMargin, .bottomMargin, .trailingMargin]
|
||||
|
||||
/// Create and active constraints for provided edges. Constraints will anchor the same edge on both `self` and `other`.
|
||||
/// - Parameters:
|
||||
/// - edges: List of constraint attributes, e.g. `[.top, .bottom, .left, .right]`
|
||||
/// - other: Instance to bind to, e.g. `UIView` or `UILayoutGuide`
|
||||
/// - margin: Used as constant value. Multiplier will always be `1.0`. If you need to change the multiplier, use single constraints instead. (Default: `0`)
|
||||
/// - rel: Constraint relation. (Default: `.equal`)
|
||||
/// - Returns: List of created and active constraints
|
||||
@discardableResult func anchor(_ edges: [NSLayoutConstraint.Attribute], to other: Any, margin: CGFloat = 0, if rel: NSLayoutConstraint.Relation = .equal) -> [NSLayoutConstraint] {
|
||||
edges.map {
|
||||
let (A, B) = UIView.inverseItem.contains($0) ? (other, self) : (self, other)
|
||||
return NSLayoutConstraint(item: A, attribute: $0, relatedBy: rel, toItem: B, attribute: $0, multiplier: 1, constant: margin).on()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Array where Element: NSLayoutConstraint {
|
||||
/// set `priority` on all elements and return same list
|
||||
@discardableResult static func |(l: Self, r: UILayoutPriority) -> Self {
|
||||
for x in l { x.priority = r }
|
||||
return l
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
struct QLog {
|
||||
private init() {}
|
||||
@@ -16,76 +16,13 @@ struct QLog {
|
||||
}
|
||||
}
|
||||
|
||||
extension Collection {
|
||||
subscript(ifExist i: Index?) -> Iterator.Element? {
|
||||
guard let i = i else { return nil }
|
||||
return indices.contains(i) ? self[i] : nil
|
||||
}
|
||||
extension UIColor {
|
||||
static var sysBg: UIColor { get { if #available(iOS 13.0, *) { return .systemBackground } else { return .white } }}
|
||||
static var sysFg: UIColor { get { if #available(iOS 13.0, *) { return .label } else { return .black } }}
|
||||
}
|
||||
|
||||
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: "."))
|
||||
|
||||
// var allDots = enumerated().compactMap { $1 == "." ? $0 : nil }
|
||||
// let d1 = allDots.popLast() // we dont care about TLD
|
||||
// guard let d2 = allDots.popLast() else {
|
||||
// return (domain: self, host: nil) // no subdomains, just plain SLD
|
||||
// }
|
||||
// // TODO: check third level domains
|
||||
//// let d3 = allDots.popLast()
|
||||
// return (String(suffix(count - d2 - 1)), String(prefix(d2)))
|
||||
extension UIEdgeInsets {
|
||||
init(all: CGFloat = 0, top: CGFloat? = nil, left: CGFloat? = nil, bottom: CGFloat? = nil, right: CGFloat? = nil) {
|
||||
self.init(top: top ?? all, left: left ?? all, bottom: bottom ?? all, right: right ?? all)
|
||||
}
|
||||
}
|
||||
|
||||
extension 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)))
|
||||
}
|
||||
}
|
||||
|
||||
func TimestampNow() -> Timestamp { Timestamp(Date().timeIntervalSince1970) }
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
extension GroupedDomain {
|
||||
static func +(a: GroupedDomain, b: GroupedDomain) -> Self {
|
||||
GroupedDomain(domain: a.domain, total: a.total + b.total, blocked: a.blocked + b.blocked,
|
||||
lastModified: max(a.lastModified, b.lastModified), options: a.options ?? b.options )
|
||||
}
|
||||
}
|
||||
|
||||
extension Array where Element == GroupedDomain {
|
||||
func merge(_ domain: String, options opt: FilterOptions? = nil) -> GroupedDomain {
|
||||
var b: Int32 = 0, t: Int32 = 0, m: Timestamp = 0
|
||||
for x in self {
|
||||
b += x.blocked
|
||||
t += x.total
|
||||
m = Swift.max(m, x.lastModified)
|
||||
}
|
||||
return GroupedDomain(domain: domain, total: t, blocked: b, lastModified: m, options: opt)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
import Foundation
|
||||
|
||||
let NotifyVPNStateChanged = NSNotification.Name("GlassVPNStateChanged") // VPNState!
|
||||
let NotifyFilterChanged = NSNotification.Name("PSIFilterSettingsChanged") // nil!
|
||||
let NotifyLogHistoryReset = NSNotification.Name("PSILogHistoryReset") // nil!
|
||||
let NotifyDNSFilterChanged = NSNotification.Name("PSIDNSFilterSettingsChanged") // domain: String?
|
||||
let NotifyDateFilterChanged = NSNotification.Name("PSIDateFilterSettingsChanged") // nil!
|
||||
let NotifyLogHistoryReset = NSNotification.Name("PSILogHistoryReset") // domain: String?
|
||||
let NotifySyncInsert = NSNotification.Name("PSISyncInsert") // SQLiteRowRange!
|
||||
let NotifySyncRemove = NSNotification.Name("PSISyncRemove") // SQLiteRowRange!
|
||||
let NotifyRecordingChanged = NSNotification.Name("PSIRecordingChanged") // (Recording, deleted: Bool)!
|
||||
|
||||
|
||||
extension NSNotification.Name {
|
||||
|
||||
@@ -1,8 +1,41 @@
|
||||
import Foundation
|
||||
|
||||
let dateTimeFormat = DateFormatter(withFormat: "yyyy-MM-dd HH:mm:ss")
|
||||
var currentVPNState: VPNState = .off
|
||||
let sync = SyncUpdate(periodic: 7)
|
||||
|
||||
public enum VPNState : Int {
|
||||
case on = 1, inbetween, off
|
||||
}
|
||||
|
||||
struct Pref {
|
||||
struct DidShowTutorial {
|
||||
static var Welcome: Bool {
|
||||
get { UserDefaults.standard.bool(forKey: "didShowTutorialAppWelcome") }
|
||||
set { UserDefaults.standard.set(newValue, forKey: "didShowTutorialAppWelcome") }
|
||||
}
|
||||
static var Recordings: Bool {
|
||||
get { UserDefaults.standard.bool(forKey: "didShowTutorialRecordings") }
|
||||
set { UserDefaults.standard.set(newValue, forKey: "didShowTutorialRecordings") }
|
||||
}
|
||||
}
|
||||
struct DateFilter {
|
||||
static var Kind: DateFilterKind {
|
||||
get { DateFilterKind(rawValue: UserDefaults.standard.integer(forKey: "dateFilterType"))! }
|
||||
set { UserDefaults.standard.set(newValue.rawValue, forKey: "dateFilterType") }
|
||||
}
|
||||
static var LastXMin: Int {
|
||||
get { UserDefaults.standard.integer(forKey: "dateFilterLastXMin") }
|
||||
set { UserDefaults.standard.set(newValue, forKey: "dateFilterLastXMin") }
|
||||
}
|
||||
|
||||
/// Return selected timestamp filter or `nil` if filtering is disabled.
|
||||
/// - Returns: `Timestamp.now() - LastXMin * 60`
|
||||
static func lastXMinTimestamp() -> Timestamp? {
|
||||
if Kind != .LastXMin { return nil }
|
||||
return Timestamp.past(minutes: Pref.DateFilter.LastXMin)
|
||||
}
|
||||
}
|
||||
}
|
||||
enum DateFilterKind: Int {
|
||||
case Off = 0, LastXMin = 1, ABRange = 2;
|
||||
}
|
||||
|
||||
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,93 +1,81 @@
|
||||
import UIKit
|
||||
|
||||
extension GroupedDomain {
|
||||
var detailCellText: String { get {
|
||||
return blocked > 0
|
||||
? "\(dateTimeFormat.string(from: lastModified)) — \(blocked)/\(total) blocked"
|
||||
: "\(dateTimeFormat.string(from: lastModified)) — \(total)"
|
||||
}
|
||||
}
|
||||
extension IndexPath {
|
||||
/// Convenience init with `section: 0`
|
||||
public init(row: Int) { self.init(row: row, section: 0) }
|
||||
}
|
||||
|
||||
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) {
|
||||
convenience init(call: Selector, on target: Any) {
|
||||
self.init()
|
||||
addTarget(on, action: call, for: .valueChanged)
|
||||
addTarget(self, action: #selector(endRefreshing), for: .valueChanged)
|
||||
addTarget(target, action: call, for: .valueChanged)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - UITableView
|
||||
|
||||
extension UITableView {
|
||||
/// Returns `true` if this `tableView` is the currently frontmost visible
|
||||
var isFrontmost: Bool { window?.isKeyWindow ?? false }
|
||||
|
||||
/// If frontmost window, perform `deleteRows()`; If not, perform `reloadData()`
|
||||
func safeDeleteRows(_ indices: [Int], with animation: UITableView.RowAnimation = .automatic) {
|
||||
isFrontmost ? deleteRows(at: indices.map {IndexPath(row: $0)}, with: animation) : reloadData()
|
||||
}
|
||||
/// If frontmost window, perform `reloadRows()`; If not, perform `reloadData()`
|
||||
func safeReloadRow(_ index: Int, with animation: UITableView.RowAnimation = .automatic) {
|
||||
isFrontmost ? reloadRows(at: [IndexPath(row: index)], with: animation) : reloadData()
|
||||
}
|
||||
/// If frontmost window, perform `insertRows()`; If not, perform `reloadData()`
|
||||
func safeInsertRow(_ index: Int, with animation: UITableView.RowAnimation = .automatic) {
|
||||
isFrontmost ? insertRows(at: [IndexPath(row: index)], with: animation) : reloadData()
|
||||
}
|
||||
/// If frontmost window, perform `moveRow()`; If not, perform `reloadData()`
|
||||
func safeMoveRow(_ from: Int, to: Int) {
|
||||
isFrontmost ? moveRow(at: IndexPath(row: from), to: IndexPath(row: to)) : reloadData()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Incremental Update Delegate
|
||||
|
||||
protocol IncrementalDataSourceUpdate : UITableViewController {
|
||||
var dataSource: [GroupedDomain] { get set }
|
||||
// MARK: - EditableRows
|
||||
|
||||
public enum RowAction {
|
||||
case ignore, block, delete
|
||||
}
|
||||
|
||||
extension IncrementalDataSourceUpdate {
|
||||
func ifDisplayed(_ block: () -> Void) {
|
||||
DispatchQueue.main.sync {
|
||||
if self.tableView.window?.isKeyWindow ?? false {
|
||||
block()
|
||||
// TODO: custom handling if cell is being edited
|
||||
} else {
|
||||
self.tableView.reloadData()
|
||||
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: UITableViewDelegate {
|
||||
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
|
||||
}
|
||||
}
|
||||
func insertRow(_ obj: GroupedDomain, at index: Int) {
|
||||
dataSource.insert(obj, at: index)
|
||||
ifDisplayed {
|
||||
self.tableView.insertRows(at: [IndexPath(row: index, section: 0)], with: .left)
|
||||
}
|
||||
}
|
||||
func moveRow(_ obj: GroupedDomain, from: Int, to: Int) {
|
||||
dataSource.remove(at: from)
|
||||
dataSource.insert(obj, at: to)
|
||||
ifDisplayed {
|
||||
let source = IndexPath(row: from, section: 0)
|
||||
let cell = self.tableView.cellForRow(at: source)
|
||||
cell?.detailTextLabel?.text = obj.detailCellText
|
||||
self.tableView.moveRow(at: source, to: IndexPath(row: to, section: 0))
|
||||
}
|
||||
}
|
||||
func replaceRow(_ obj: GroupedDomain, at index: Int) {
|
||||
dataSource[index] = obj
|
||||
ifDisplayed {
|
||||
self.tableView.reloadRows(at: [IndexPath(row: index, section: 0)], with: .automatic)
|
||||
}
|
||||
}
|
||||
func deleteRow(at index: Int) {
|
||||
dataSource.remove(at: index)
|
||||
ifDisplayed {
|
||||
self.tableView.deleteRows(at: [IndexPath(row: index, section: 0)], with: .automatic)
|
||||
}
|
||||
}
|
||||
func replaceData(with newData: [GroupedDomain]) {
|
||||
dataSource = newData
|
||||
ifDisplayed {
|
||||
self.tableView.reloadData()
|
||||
}
|
||||
@available(iOS 11.0, *)
|
||||
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 }
|
||||
}
|
||||
|
||||
protocol EditActionsRemove : EditableRows {}
|
||||
extension EditActionsRemove where Self: UITableViewController {
|
||||
func editableRowActions(_: IndexPath) -> [(RowAction, String)] { [(.delete, "Remove")] }
|
||||
func editableRowActionColor(_: IndexPath, _: RowAction) -> UIColor? { nil }
|
||||
}
|
||||
|
||||
82
main/Extensions/Time.swift
Normal file
@@ -0,0 +1,82 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - TimeFormat
|
||||
|
||||
struct TimeFormat {
|
||||
private var formatter: DateComponentsFormatter
|
||||
|
||||
/// Init new formatter with exactly 1 unit count. E.g., `61 min -> 1 hr`
|
||||
/// - Parameter allowed: Default: `[.day, .hour, .minute, .second]`
|
||||
init(_ style: DateComponentsFormatter.UnitsStyle, allowed: NSCalendar.Unit = [.day, .hour, .minute, .second]) {
|
||||
formatter = DateComponentsFormatter()
|
||||
formatter.maximumUnitCount = 1
|
||||
formatter.allowedUnits = allowed
|
||||
formatter.unitsStyle = style
|
||||
}
|
||||
|
||||
/// Formatted duration string, e.g., `20 min` or `7 days`
|
||||
func from(days: Int = 0, hours: Int = 0, minutes: Int = 0, seconds: Int = 0) -> String? {
|
||||
formatter.string(from: DateComponents(day: days, hour: hours, minute: minutes, second: seconds))
|
||||
}
|
||||
|
||||
// MARK: static
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,19 @@
|
||||
import Foundation
|
||||
|
||||
fileprivate extension FileManager {
|
||||
func exportDir() -> URL {
|
||||
try! url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
}
|
||||
// func exportDir() -> URL {
|
||||
// try! url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
// }
|
||||
func appGroupDir() -> URL {
|
||||
containerURL(forSecurityApplicationGroupIdentifier: "group.de.uni-bamberg.psi.AppCheck")!
|
||||
}
|
||||
func internalDB() -> URL {
|
||||
appGroupDir().appendingPathComponent("dns-logs.sqlite")
|
||||
}
|
||||
func appGroupIPC() -> URL {
|
||||
appGroupDir().appendingPathComponent("data-exchange.dat")
|
||||
}
|
||||
}
|
||||
|
||||
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 internalDB() -> URL { FileManager.default.internalDB() }
|
||||
static func appGroupIPC() -> URL { FileManager.default.appGroupIPC() }
|
||||
}
|
||||
91
main/Recordings/TVCPreviousRecords.swift
Normal file
@@ -0,0 +1,91 @@
|
||||
import UIKit
|
||||
|
||||
class TVCPreviousRecords: UITableViewController, EditActionsRemove {
|
||||
private var dataSource: [Recording] = []
|
||||
|
||||
override func viewDidLoad() {
|
||||
dataSource = RecordingsDB.list().reversed() // newest on top
|
||||
NotifyRecordingChanged.observe(call: #selector(recordingDidChange(_:)), on: self)
|
||||
}
|
||||
|
||||
func insertAndEditRecording(_ r: Recording) {
|
||||
insertNewRecord(r)
|
||||
editRecord(r, isNewRecording: true)
|
||||
}
|
||||
|
||||
@objc private func recordingDidChange(_ notification: Notification) {
|
||||
let (new, deleted) = notification.object as! (Recording, Bool)
|
||||
if let i = dataSource.firstIndex(where: { $0.id == new.id }) {
|
||||
if deleted {
|
||||
dataSource.remove(at: i)
|
||||
tableView.deleteRows(at: [IndexPath(row: i)], with: .automatic)
|
||||
} else {
|
||||
dataSource[i] = new
|
||||
tableView.reloadRows(at: [IndexPath(row: i)], with: .automatic)
|
||||
}
|
||||
} else if !deleted {
|
||||
insertNewRecord(new)
|
||||
}
|
||||
}
|
||||
|
||||
private func insertNewRecord(_ record: Recording) {
|
||||
dataSource.insert(record, at: 0)
|
||||
tableView.insertRows(at: [IndexPath(row: 0)], with: .top)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Table View Delegate
|
||||
|
||||
override func tableView(_ tableView: UITableView, accessoryButtonTappedForRowWith indexPath: IndexPath) {
|
||||
editRecord(dataSource[indexPath.row])
|
||||
}
|
||||
|
||||
private func editRecord(_ record: Recording, isNewRecording: Bool = false) {
|
||||
performSegue(withIdentifier: "editRecordSegue", sender: (record, isNewRecording))
|
||||
}
|
||||
|
||||
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
|
||||
if segue.identifier == "editRecordSegue" {
|
||||
let (record, newlyCreated) = sender as! (Recording, Bool)
|
||||
let target = segue.destination as! VCEditRecording
|
||||
target.record = record
|
||||
target.deleteOnCancel = newlyCreated
|
||||
} else if segue.identifier == "openRecordDetailsSegue" {
|
||||
if let i = tableView.indexPathForSelectedRow {
|
||||
(segue.destination as? TVCRecordingDetails)?.record = dataSource[i.row]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Table View Data Source
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
dataSource.count
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "PreviousRecordCell")!
|
||||
let x = dataSource[indexPath.row]
|
||||
cell.textLabel?.text = x.title ?? x.fallbackTitle
|
||||
cell.textLabel?.textColor = (x.title == nil) ? .systemGray : nil
|
||||
cell.detailTextLabel?.text = "at \(x.start.asDateTime()), duration: \(x.durationString ?? "?")"
|
||||
return cell
|
||||
}
|
||||
|
||||
|
||||
// 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 {
|
||||
RecordingsDB.delete(self.dataSource[index.row])
|
||||
return true
|
||||
}
|
||||
}
|
||||
43
main/Recordings/TVCRecordingDetails.swift
Normal file
@@ -0,0 +1,43 @@
|
||||
import UIKit
|
||||
|
||||
class TVCRecordingDetails: UITableViewController, EditActionsRemove {
|
||||
var record: Recording!
|
||||
private var dataSource: [RecordLog]!
|
||||
|
||||
override func viewDidLoad() {
|
||||
title = record.title ?? record.fallbackTitle
|
||||
dataSource = RecordingsDB.details(record)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Table View Data Source
|
||||
|
||||
override func tableView(_ _: UITableView, numberOfRowsInSection _: Int) -> Int { dataSource.count }
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "PreviousRecordDetailCell")!
|
||||
let x = dataSource[indexPath.row]
|
||||
cell.textLabel?.text = x.domain
|
||||
cell.detailTextLabel?.text = "\(x.count)"
|
||||
return cell
|
||||
}
|
||||
|
||||
|
||||
// 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 {
|
||||
if RecordingsDB.deleteDetails(record, domain: dataSource[index.row].domain) {
|
||||
dataSource.remove(at: index.row)
|
||||
tableView.deleteRows(at: [index], with: .automatic)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
128
main/Recordings/VCEditRecording.swift
Normal file
@@ -0,0 +1,128 @@
|
||||
import UIKit
|
||||
|
||||
class VCEditRecording: UIViewController, UITextFieldDelegate, UITextViewDelegate {
|
||||
var record: Recording!
|
||||
var deleteOnCancel: Bool = false
|
||||
|
||||
@IBOutlet private var buttonCancel: UIBarButtonItem!
|
||||
@IBOutlet private var buttonSave: UIBarButtonItem!
|
||||
@IBOutlet private var inputTitle: UITextField!
|
||||
@IBOutlet private var inputNotes: UITextView!
|
||||
@IBOutlet private var inputDetails: UITextView!
|
||||
@IBOutlet private var noteBottom: NSLayoutConstraint!
|
||||
|
||||
override func viewDidLoad() {
|
||||
inputTitle.placeholder = record.fallbackTitle
|
||||
inputTitle.text = record.title
|
||||
inputNotes.text = record.notes
|
||||
inputDetails.text = """
|
||||
Start: \(record.start.asDateTime())
|
||||
End: \(record.stop?.asDateTime() ?? "?")
|
||||
Duration: \(record.durationString ?? "?")
|
||||
"""
|
||||
validateSaveButton()
|
||||
if deleteOnCancel { // mark as destructive
|
||||
buttonCancel.tintColor = .systemRed
|
||||
if #available(iOS 13.0, *) {
|
||||
isModalInPresentation = true
|
||||
}
|
||||
}
|
||||
UIResponder.keyboardWillShowNotification.observe(call: #selector(keyboardWillShow), on: self)
|
||||
UIResponder.keyboardWillHideNotification.observe(call: #selector(keyboardWillHide), on: self)
|
||||
}
|
||||
|
||||
|
||||
// MARK: Save & Cancel Buttons
|
||||
|
||||
@IBAction func didTapSave(_ sender: UIBarButtonItem) {
|
||||
let newlyCreated = deleteOnCancel
|
||||
if newlyCreated {
|
||||
// if remains true, `viewDidDisappear` will delete the record
|
||||
deleteOnCancel = false
|
||||
}
|
||||
QLog.Debug("updating record #\(record.id)")
|
||||
record.title = (inputTitle.text == "") ? nil : inputTitle.text
|
||||
record.notes = (inputNotes.text == "") ? nil : inputNotes.text
|
||||
dismiss(animated: true) {
|
||||
RecordingsDB.update(self.record)
|
||||
if newlyCreated {
|
||||
RecordingsDB.persist(self.record)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction func didTapCancel(_ sender: UIBarButtonItem) {
|
||||
QLog.Debug("discard edit of record #\(record.id)")
|
||||
dismiss(animated: true)
|
||||
}
|
||||
|
||||
override func viewDidDisappear(_ animated: Bool) {
|
||||
if deleteOnCancel {
|
||||
QLog.Debug("deleting record #\(record.id)")
|
||||
RecordingsDB.delete(record)
|
||||
deleteOnCancel = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: Handle Keyboard & Notes Frame
|
||||
|
||||
private var isEditingNotes: Bool = false
|
||||
private var keyboardHeight: CGFloat = 0
|
||||
|
||||
@IBAction func hideKeyboard() { view.endEditing(false) }
|
||||
|
||||
func textViewDidBeginEditing(_ textView: UITextView) {
|
||||
if textView == inputNotes {
|
||||
isEditingNotes = true
|
||||
updateKeyboard()
|
||||
}
|
||||
}
|
||||
|
||||
func textViewDidEndEditing(_ textView: UITextView) {
|
||||
if textView == inputNotes {
|
||||
isEditingNotes = false
|
||||
updateKeyboard()
|
||||
}
|
||||
}
|
||||
|
||||
@objc func keyboardWillShow(_ notification: NSNotification) {
|
||||
keyboardHeight = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue.height ?? 0
|
||||
updateKeyboard()
|
||||
}
|
||||
|
||||
@objc func keyboardWillHide(_ notification: NSNotification) {
|
||||
keyboardHeight = 0
|
||||
updateKeyboard()
|
||||
}
|
||||
|
||||
private func updateKeyboard() {
|
||||
guard let parent = inputNotes.superview, let stack = parent.superview else {
|
||||
return
|
||||
}
|
||||
let adjust = (isEditingNotes && keyboardHeight > 0)
|
||||
stack.subviews.forEach{ $0.isHidden = (adjust && $0 != parent) }
|
||||
|
||||
let title = parent.subviews.first as! UILabel
|
||||
title.font = .preferredFont(forTextStyle: adjust ? .subheadline : .title2)
|
||||
title.sizeToFit()
|
||||
title.frame.size.width = parent.frame.width
|
||||
|
||||
noteBottom.constant = adjust ? view.frame.height - stack.frame.maxY - keyboardHeight : 0
|
||||
}
|
||||
|
||||
|
||||
// MARK: TextField & TextView Delegate
|
||||
|
||||
func textFieldDidChangeSelection(_ _: UITextField) { validateSaveButton() }
|
||||
func textViewDidChange(_ _: UITextView) { validateSaveButton() }
|
||||
|
||||
private func validateSaveButton() {
|
||||
let changed = (inputTitle.text != record.title ?? "" || inputNotes.text != record.notes ?? "")
|
||||
buttonSave.isEnabled = changed || deleteOnCancel // always allow save for new recordings
|
||||
}
|
||||
|
||||
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
||||
textField == inputTitle ? inputNotes.becomeFirstResponder() : true
|
||||
}
|
||||
}
|
||||
143
main/Recordings/VCRecordings.swift
Normal file
@@ -0,0 +1,143 @@
|
||||
import UIKit
|
||||
|
||||
class VCRecordings: UIViewController, UINavigationControllerDelegate {
|
||||
private var currentRecording: Recording?
|
||||
private var recordingTimer: Timer?
|
||||
|
||||
@IBOutlet private var timeLabel: UILabel!
|
||||
@IBOutlet private var startButton: UIButton!
|
||||
@IBOutlet private var startNewRecView: UIView!
|
||||
private var prevRecController: UINavigationController!
|
||||
|
||||
override func viewDidLoad() {
|
||||
prevRecController = (children.first as! UINavigationController)
|
||||
prevRecController.delegate = self
|
||||
// Duplicate font attributes but set monospace
|
||||
let traits = timeLabel.font.fontDescriptor.object(forKey: .traits) as? [UIFontDescriptor.TraitKey: Any] ?? [:]
|
||||
let weight = traits[.weight] as? CGFloat ?? UIFont.Weight.regular.rawValue
|
||||
timeLabel.font = UIFont.monospacedDigitSystemFont(ofSize: timeLabel.font.pointSize, weight: UIFont.Weight(rawValue: weight))
|
||||
// hide timer if not running
|
||||
updateUI(setRecording: false, animated: false)
|
||||
currentRecording = RecordingsDB.getCurrent()
|
||||
|
||||
if !Pref.DidShowTutorial.Recordings {
|
||||
self.perform(#selector(showTutorial), with: nil, afterDelay: 0.5)
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
if currentRecording != nil { startTimer(animate: false) }
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
stopTimer(animate: false)
|
||||
}
|
||||
|
||||
func navigationController(_ nav: UINavigationController, willShow vc: UIViewController, animated: Bool) {
|
||||
hideNewRecording(isRootVC: (vc == nav.viewControllers.first), didShow: false)
|
||||
}
|
||||
|
||||
func navigationController(_ nav: UINavigationController, didShow vc: UIViewController, animated: Bool) {
|
||||
// TODO: use interactive animation handler to dynamically animate "new recording" view
|
||||
hideNewRecording(isRootVC: (vc == nav.viewControllers.first), didShow: true)
|
||||
}
|
||||
|
||||
private func hideNewRecording(isRootVC: Bool, didShow: Bool) {
|
||||
if isRootVC == didShow {
|
||||
UIView.animate(withDuration: 0.3) {
|
||||
self.startNewRecView.isHidden = !isRootVC // hide "new recording" if details open
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: Start New Recording
|
||||
|
||||
@IBAction private func startRecordingButtonTapped(_ sender: UIButton) {
|
||||
if recordingTimer == nil {
|
||||
currentRecording = RecordingsDB.startNew()
|
||||
startTimer(animate: true)
|
||||
} else {
|
||||
stopTimer(animate: true)
|
||||
RecordingsDB.stop(¤tRecording!)
|
||||
prevRecController.popToRootViewController(animated: true)
|
||||
let editVC = (prevRecController.topViewController as! TVCPreviousRecords)
|
||||
editVC.insertAndEditRecording(currentRecording!)
|
||||
currentRecording = nil // otherwise it will restart
|
||||
}
|
||||
}
|
||||
|
||||
private func startTimer(animate: Bool) {
|
||||
guard let r = currentRecording, r.stop == nil else {
|
||||
return
|
||||
}
|
||||
recordingTimer = Timer.repeating(0.086, call: #selector(timerCallback(_:)), on: self, userInfo: r.start.toDate())
|
||||
updateUI(setRecording: true, animated: animate)
|
||||
}
|
||||
|
||||
@objc private func timerCallback(_ sender: Timer) {
|
||||
timeLabel.text = TimeFormat.since(sender.userInfo as! Date, millis: true)
|
||||
}
|
||||
|
||||
private func stopTimer(animate: Bool) {
|
||||
recordingTimer?.invalidate()
|
||||
recordingTimer = nil
|
||||
updateUI(setRecording: false, animated: animate)
|
||||
}
|
||||
|
||||
private func updateUI(setRecording: Bool, animated: Bool) {
|
||||
let title = setRecording ? "Stop Recording" : "Start New Recording"
|
||||
let color = setRecording ? UIColor.systemRed : nil
|
||||
let yT = setRecording ? 0 : -timeLabel.frame.height
|
||||
let yB = (setRecording ? 1 : 0.5) * (startButton.superview!.frame.height - startButton.frame.height)
|
||||
if !animated { // else title will flash
|
||||
startButton.titleLabel?.text = title
|
||||
}
|
||||
UIView.animate(withDuration: animated ? 0.3 : 0) {
|
||||
self.timeLabel.frame.origin.y = yT
|
||||
self.startButton.frame.origin.y = yB
|
||||
self.startButton.setTitle(title, for: .normal)
|
||||
self.startButton.setTitleColor(color, for: .normal)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: Tutorial View Controller
|
||||
|
||||
@objc private func showTutorial() {
|
||||
let x = TutorialSheet()
|
||||
x.addSheet().addArrangedSubview(QuickUI.text(attributed: NSMutableAttributedString()
|
||||
.h1("What are Recordings?\n")
|
||||
.normal("\nSimilar to the default logging, recordings will intercept every request and log it for later review. " +
|
||||
"Recordings are usually 3 – 5 minutes long and cover a single application. " +
|
||||
"You can utilize recordings for App analysis or to get a ground truth for background traffic." +
|
||||
"\n\n" +
|
||||
"Optionally, you can help us by providing app specific recordings. " +
|
||||
"Together with your findings we can create a community driven privacy monitor. " +
|
||||
"The research results will help you and others avoid Apps that unnecessarily share data with third-party providers.")
|
||||
))
|
||||
x.addSheet().addArrangedSubview(QuickUI.text(attributed: NSMutableAttributedString()
|
||||
.h1("How to record?\n")
|
||||
.normal("\nBefore you begin a new recording make sure that you quit all running applications. " +
|
||||
"Tap on the 'Start Recording' button and switch to the application you'd like to inspect. " +
|
||||
"Use the App as you would normally. Try to get to all corners and functionality the App provides. " +
|
||||
"When you feel that you have captured enough content, come back to ").italic("AppCheck").normal(" and stop the recording." +
|
||||
"\n\n" +
|
||||
"Upon completion you will find your recording in the 'Previous Recordings' section. " +
|
||||
"You can review your results and remove user specific information if necessary.")
|
||||
))
|
||||
x.addSheet().addArrangedSubview(QuickUI.text(attributed: NSMutableAttributedString()
|
||||
.h1("Share results\n")
|
||||
.normal("\nThis step is completely ").bold("optional").normal(". " +
|
||||
"You can choose to share your results with us. " +
|
||||
"We can compare similar applications and suggest privacy friendly alternatives. " +
|
||||
"Together with other likeminded individuals we can increase the awareness for privacy friendly design." +
|
||||
"\n\n" +
|
||||
"Thank you very much.")
|
||||
))
|
||||
x.buttonTitleDone = "Got it"
|
||||
x.present {
|
||||
Pref.DidShowTutorial.Recordings = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,41 +1,127 @@
|
||||
import UIKit
|
||||
|
||||
class TVCDomains: UITableViewController, IncrementalDataSourceUpdate {
|
||||
class TVCDomains: UITableViewController, UISearchBarDelegate, FilterPipelineDelegate {
|
||||
|
||||
internal var dataSource: [GroupedDomain] = []
|
||||
lazy var source = GroupedDomainDataSource(withDelegate: self, parent: nil)
|
||||
|
||||
private var searchActive: Bool = false
|
||||
private var searchTerm: String?
|
||||
private let searchBar: UISearchBar = {
|
||||
let x = UISearchBar(frame: CGRect.init(x: 0, y: 0, width: 20, height: 10))
|
||||
x.sizeToFit()
|
||||
x.showsCancelButton = true
|
||||
x.autocapitalizationType = .none
|
||||
x.autocorrectionType = .no
|
||||
return x
|
||||
}()
|
||||
@IBOutlet private var filterButton: UIBarButtonItem!
|
||||
@IBOutlet private var filterButtonDetail: UIBarButtonItem!
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
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
|
||||
NotifyDateFilterChanged.observe(call: #selector(didChangeDateFilter), on: self)
|
||||
didChangeDateFilter()
|
||||
}
|
||||
|
||||
@objc func reloadDataSource() {
|
||||
dataSource = DBWrp.listOfDomains()
|
||||
tableView.reloadData()
|
||||
private var didLoadAlready = false
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
if !didLoadAlready {
|
||||
didLoadAlready = true
|
||||
source.reloadFromSource()
|
||||
}
|
||||
}
|
||||
|
||||
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
|
||||
if let index = tableView.indexPathForSelectedRow?.row {
|
||||
(segue.destination as? TVCHosts)?.parentDomain = dataSource[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 { dataSource.count }
|
||||
override func tableView(_ _: UITableView, numberOfRowsInSection _: Int) -> Int { source.numberOfRows }
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "DomainCell")!
|
||||
let entry = dataSource[indexPath.row]
|
||||
let entry = source[indexPath.row]
|
||||
cell.textLabel?.text = entry.domain
|
||||
cell.detailTextLabel?.text = entry.detailCellText
|
||||
cell.imageView?.image = entry.options?.tableRowImage()
|
||||
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
|
||||
|
||||
@IBAction private func searchButtonTapped(_ sender: UIBarButtonItem) {
|
||||
setSearch(hidden: searchActive)
|
||||
}
|
||||
|
||||
func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
|
||||
setSearch(hidden: true)
|
||||
}
|
||||
|
||||
private func setSearch(hidden: Bool) {
|
||||
searchActive = !hidden
|
||||
searchTerm = nil
|
||||
searchBar.text = nil
|
||||
tableView.tableHeaderView = hidden ? nil : searchBar
|
||||
if searchActive {
|
||||
source.pipeline.addFilter("search") {
|
||||
$0.domain.lowercased().contains(self.searchTerm ?? "")
|
||||
}
|
||||
searchBar.becomeFirstResponder()
|
||||
} else {
|
||||
source.pipeline.removeFilter(withId: "search")
|
||||
}
|
||||
tableView.reloadData()
|
||||
}
|
||||
|
||||
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
|
||||
NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(performSearch), object: nil)
|
||||
perform(#selector(performSearch), with: nil, afterDelay: 0.2)
|
||||
}
|
||||
|
||||
@objc private func performSearch() {
|
||||
searchTerm = searchBar.text?.lowercased() ?? ""
|
||||
source.pipeline.reloadFilter(withId: "search")
|
||||
tableView.reloadData()
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Filter
|
||||
|
||||
@IBAction private func filterButtonTapped(_ sender: UIBarButtonItem) {
|
||||
let vc = self.storyboard!.instantiateViewController(withIdentifier: "domainFilter")
|
||||
vc.modalPresentationStyle = .custom
|
||||
if #available(iOS 13.0, *) {
|
||||
vc.isModalInPresentation = true
|
||||
}
|
||||
present(vc, animated: true)
|
||||
}
|
||||
|
||||
@objc private func didChangeDateFilter() {
|
||||
switch Pref.DateFilter.Kind {
|
||||
case .ABRange: // read start/end time
|
||||
self.filterButtonDetail.title = "A – B"
|
||||
self.filterButton.image = UIImage(named: "filter-filled")
|
||||
case .LastXMin: // most recent
|
||||
let lastXMin = Pref.DateFilter.LastXMin
|
||||
if lastXMin == 0 { fallthrough }
|
||||
self.filterButtonDetail.title = TimeFormat(.abbreviated).from(minutes: lastXMin)
|
||||
self.filterButton.image = UIImage(named: "filter-filled")
|
||||
default:
|
||||
self.filterButtonDetail.title = ""
|
||||
self.filterButton.image = UIImage(named: "filter-clear")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import UIKit
|
||||
class TVCHostDetails: UITableViewController {
|
||||
|
||||
public var fullDomain: String!
|
||||
private var dataSource: [(ts: Timestamp, blocked: Bool)] = []
|
||||
private var dataSource: [GroupedTsOccurrence] = []
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
@@ -12,21 +12,63 @@ class TVCHostDetails: UITableViewController {
|
||||
tableView.refreshControl = UIRefreshControl(call: #selector(reloadDataSource), on: self)
|
||||
}
|
||||
NotifyLogHistoryReset.observe(call: #selector(reloadDataSource), on: self)
|
||||
NotifySyncInsert.observe(call: #selector(syncInsert), on: self)
|
||||
NotifySyncRemove.observe(call: #selector(syncRemove), on: self)
|
||||
reloadDataSource()
|
||||
}
|
||||
|
||||
@objc func reloadDataSource() {
|
||||
dataSource = DBWrp.listOfTimes(fullDomain)
|
||||
tableView.reloadData()
|
||||
@objc func reloadDataSource(sender: Any? = nil) {
|
||||
let refreshControl = sender as? UIRefreshControl
|
||||
let notification = sender as? Notification
|
||||
if let affectedDomain = notification?.object as? String {
|
||||
guard fullDomain.isSubdomain(of: affectedDomain) else { return }
|
||||
}
|
||||
DispatchQueue.global().async { [weak self] in
|
||||
self?.dataSource = AppDB?.timesForDomain(self?.fullDomain ?? "", since: sync.tsEarliest) ?? []
|
||||
DispatchQueue.main.sync {
|
||||
self?.tableView.reloadData()
|
||||
refreshControl?.endRefreshing()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func syncInsert(_ notification: Notification) {
|
||||
let range = notification.object as! SQLiteRowRange
|
||||
if let latest = AppDB?.timesForDomain(fullDomain, range: range), latest.count > 0 {
|
||||
dataSource.insert(contentsOf: latest, at: 0)
|
||||
if tableView.isFrontmost {
|
||||
let indices = (0..<latest.count).map { IndexPath(row: $0) }
|
||||
tableView.insertRows(at: indices, with: .left)
|
||||
} else {
|
||||
tableView.reloadData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func syncRemove(_ notification: Notification) {
|
||||
let earliest = sync.tsEarliest
|
||||
if let i = dataSource.firstIndex(where: { $0.ts < earliest }) {
|
||||
// since they are ordered, we can optimize
|
||||
let indices = (i..<dataSource.endIndex).map { IndexPath(row: $0) }
|
||||
dataSource.removeLast(dataSource.count - i)
|
||||
if tableView.isFrontmost {
|
||||
tableView.deleteRows(at: indices, with: .automatic)
|
||||
} else {
|
||||
tableView.reloadData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Table View Data Source
|
||||
|
||||
override func tableView(_ _: UITableView, numberOfRowsInSection _: Int) -> Int { dataSource.count }
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "HostDetailCell")!
|
||||
let src = dataSource[indexPath.row]
|
||||
cell.textLabel?.text = dateTimeFormat.string(from: src.ts)
|
||||
cell.imageView?.image = (src.blocked ? UIImage(named: "shield-x") : nil)
|
||||
cell.textLabel?.text = src.ts.asDateTime()
|
||||
cell.detailTextLabel?.text = (src.total > 1) ? "\(src.total)x" : nil
|
||||
cell.imageView?.image = (src.blocked > 0 ? UIImage(named: "shield-x") : nil)
|
||||
return cell
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +1,32 @@
|
||||
import UIKit
|
||||
|
||||
class TVCHosts: UITableViewController, IncrementalDataSourceUpdate {
|
||||
class TVCHosts: UITableViewController, FilterPipelineDelegate {
|
||||
|
||||
lazy var source = GroupedDomainDataSource(withDelegate: self, parent: parentDomain)
|
||||
|
||||
public var parentDomain: String!
|
||||
internal var dataSource: [GroupedDomain] = []
|
||||
private var isSpecial: Bool = false
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
navigationItem.prompt = parentDomain
|
||||
isSpecial = (parentDomain.first == "#") // aka: "# IP address"
|
||||
if #available(iOS 10.0, *) {
|
||||
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()
|
||||
source.reloadFromSource() // init lazy var
|
||||
}
|
||||
|
||||
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
|
||||
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 {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "HostCell")!
|
||||
let entry = dataSource[indexPath.row]
|
||||
let entry = source[indexPath.row]
|
||||
if isSpecial {
|
||||
// currently only used for IP addresses
|
||||
cell.textLabel?.text = entry.domain
|
||||
@@ -51,4 +38,11 @@ class TVCHosts: UITableViewController, IncrementalDataSourceUpdate {
|
||||
cell.imageView?.image = entry.options?.tableRowImage()
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
116
main/Requests/VCDateFilter.swift
Normal file
@@ -0,0 +1,116 @@
|
||||
import UIKit
|
||||
|
||||
// TODO: (count > x) filter
|
||||
|
||||
class VCDateFilter: UIViewController, UIGestureRecognizerDelegate {
|
||||
|
||||
@IBOutlet private var segmentControl: UISegmentedControl!
|
||||
@IBOutlet private var sectionTitle: UILabel!
|
||||
|
||||
// entries no older than
|
||||
@IBOutlet private var durationView: UIView!
|
||||
@IBOutlet private var durationSlider: UISlider!
|
||||
@IBOutlet private var durationLabel: UILabel!
|
||||
private let durationTimes = [0, 1, 20, 60, 360, 720, 1440, 2880, 4320, 10080]
|
||||
|
||||
// entries within range
|
||||
@IBOutlet private var rangeView: UIView!
|
||||
@IBOutlet private var buttonRangeStart: UIButton!
|
||||
@IBOutlet private var buttonRangeEnd: UIButton!
|
||||
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
segmentControl.selectedSegmentIndex = (Pref.DateFilter.Kind == .ABRange ? 1 : 0)
|
||||
didChangeSegment(segmentControl)
|
||||
segmentControl.setEnabled(false, forSegmentAt: 1) // TODO: until range filter is ready
|
||||
|
||||
durationSlider.tag = -1 // otherwise wont update because `tag == 0`
|
||||
durationSlider.value = Float(durationTimes.firstIndex(of: Pref.DateFilter.LastXMin) ?? 0) / 9
|
||||
durationSliderChanged(durationSlider)
|
||||
|
||||
var a = Timestamp(4).asDateTime() // TODO: load from preferences
|
||||
var b = Timestamp.now().asDateTime()
|
||||
a.removeLast(3) // remove seconds
|
||||
b.removeLast(3)
|
||||
buttonRangeStart.setTitle(a, for: .normal)
|
||||
buttonRangeEnd.setTitle(b, for: .normal)
|
||||
}
|
||||
|
||||
@IBAction private func didChangeSegment(_ sender: UISegmentedControl) {
|
||||
durationView.isHidden = (sender.selectedSegmentIndex != 0)
|
||||
rangeView.isHidden = (sender.selectedSegmentIndex != 1)
|
||||
switch sender.selectedSegmentIndex {
|
||||
case 0: sectionTitle.text = "Show entries no older than"
|
||||
case 1: sectionTitle.text = "Show entries within range"
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction private func durationSliderChanged(_ sender: UISlider) {
|
||||
let i = Int((sender.value + (0.499/9)) * 9) // mid-value-switch
|
||||
guard i >= 0, i <= 9 else { return }
|
||||
sender.value = Float(i) / 9
|
||||
if sender.tag != durationTimes[i] {
|
||||
sender.tag = durationTimes[i]
|
||||
durationLabel.text = (sender.tag == 0 ? "Off" : TimeFormat(.short).from(minutes: sender.tag))
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction private func didTapRangeButton(_ sender: UIButton) {
|
||||
// TODO: show date picker
|
||||
}
|
||||
|
||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
|
||||
if gestureRecognizer.view == touch.view {
|
||||
let newXMin = durationSlider.tag
|
||||
let newKind: DateFilterKind
|
||||
if segmentControl.selectedSegmentIndex == 1 {
|
||||
newKind = .ABRange
|
||||
} else if newXMin > 0 {
|
||||
newKind = .LastXMin
|
||||
} else {
|
||||
newKind = .Off
|
||||
}
|
||||
if Pref.DateFilter.Kind != newKind || Pref.DateFilter.LastXMin != newXMin {
|
||||
Pref.DateFilter.Kind = newKind
|
||||
Pref.DateFilter.LastXMin = newXMin
|
||||
NotifyDateFilterChanged.post()
|
||||
}
|
||||
dismiss(animated: true)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: White Triangle Popup Arrow
|
||||
|
||||
@IBDesignable
|
||||
class PopupTriangle: UIView {
|
||||
@IBInspectable var rotation: CGFloat = 0
|
||||
@IBInspectable var color: UIColor = .black
|
||||
|
||||
override func draw(_ rect: CGRect) {
|
||||
guard let c = UIGraphicsGetCurrentContext() else { return }
|
||||
let w = rect.width, h = rect.height
|
||||
switch rotation {
|
||||
case 90: // right
|
||||
c.lineFromTo(x1: 0, y1: 0, x2: w, y2: h/2)
|
||||
c.addLine(to: CGPoint(x: 0, y: h))
|
||||
case 180: // bottom
|
||||
c.lineFromTo(x1: w, y1: 0, x2: w/2, y2: h)
|
||||
c.addLine(to: CGPoint(x: 0, y: 0))
|
||||
case 270: // left
|
||||
c.lineFromTo(x1: w, y1: h, x2: 0, y2: h/2)
|
||||
c.addLine(to: CGPoint(x: w, y: 0))
|
||||
default: // top
|
||||
c.lineFromTo(x1: 0, y1: h, x2: w/2, y2: 0)
|
||||
c.addLine(to: CGPoint(x: w, y: h))
|
||||
}
|
||||
c.closePath()
|
||||
c.setFillColor(color.cgColor)
|
||||
c.fillPath()
|
||||
}
|
||||
}
|
||||
@@ -1,40 +1,114 @@
|
||||
import UIKit
|
||||
|
||||
class TVCFilter: UITableViewController, EditActionsRemove {
|
||||
var currentFilter: FilterOptions = .none
|
||||
var currentFilter: FilterOptions = .none // set by segue
|
||||
private var dataSource: [String] = []
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
if #available(iOS 10.0, *) {
|
||||
tableView.refreshControl = UIRefreshControl(call: #selector(reloadDataSource), on: self)
|
||||
}
|
||||
NotifyFilterChanged.observe(call: #selector(reloadDataSource), on: self)
|
||||
NotifyDNSFilterChanged.observe(call: #selector(didChangeDomainFilter), on: self)
|
||||
reloadDataSource()
|
||||
}
|
||||
|
||||
@objc func reloadDataSource() {
|
||||
dataSource = DBWrp.dataF_list(currentFilter)
|
||||
func reloadDataSource() {
|
||||
dataSource = DomainFilter.list(where: currentFilter)
|
||||
tableView.reloadData()
|
||||
}
|
||||
|
||||
// MARK: - Table View Delegate
|
||||
@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() {
|
||||
let desc: String
|
||||
switch currentFilter {
|
||||
case .blocked: desc = "Enter the domain name you wish to block."
|
||||
case .ignored: desc = "Enter the domain name you wish to ignore."
|
||||
default: return
|
||||
}
|
||||
let alert = AskAlert(title: "Create new filter", text: desc, buttonText: "Add") {
|
||||
guard let dom = $0.textFields?.first?.text else {
|
||||
return
|
||||
}
|
||||
guard dom.contains("."), !dom.isKnownSLD() else {
|
||||
ErrorAlert("Entered domain is not valid. Filter can't match country TLD only.").presentIn(self)
|
||||
return
|
||||
}
|
||||
DomainFilter.update(dom, add: self.currentFilter)
|
||||
}
|
||||
alert.addTextField {
|
||||
$0.placeholder = "cdn.domain.tld"
|
||||
$0.keyboardType = .URL
|
||||
}
|
||||
alert.presentIn(self)
|
||||
}
|
||||
|
||||
// MARK: - Table View Data Source
|
||||
|
||||
override func tableView(_ _: UITableView, numberOfRowsInSection _: Int) -> Int { dataSource.count }
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "DomainFilterCell")!
|
||||
cell.textLabel?.text = dataSource[indexPath.row]
|
||||
if cell.gestureRecognizers?.isEmpty ?? true {
|
||||
cell.addGestureRecognizer(UILongPressGestureRecognizer(target: self, action: #selector(didLongTap)))
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
// 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 {
|
||||
let domain = self.dataSource[index.row]
|
||||
DBWrp.updateFilter(domain, remove: currentFilter)
|
||||
self.dataSource.remove(at: index.row)
|
||||
self.tableView.deleteRows(at: [index], with: .automatic)
|
||||
let domain = dataSource[index.row]
|
||||
DomainFilter.update(domain, remove: currentFilter)
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Long Press Gesture
|
||||
|
||||
private var cellTitleCopy: String?
|
||||
|
||||
@objc private func didLongTap(_ sender: UILongPressGestureRecognizer) {
|
||||
guard let cell = sender.view as? UITableViewCell else {
|
||||
return
|
||||
}
|
||||
if sender.state == .began {
|
||||
cellTitleCopy = cell.textLabel?.text
|
||||
self.becomeFirstResponder()
|
||||
let menu = UIMenuController.shared
|
||||
// menu.setTargetRect(CGRect(origin: sender.location(in: cell), size: CGSize.zero), in: cell)
|
||||
menu.setTargetRect(cell.bounds, in: cell)
|
||||
menu.setMenuVisible(true, animated: true)
|
||||
}
|
||||
}
|
||||
override var canBecomeFirstResponder: Bool { get { true } }
|
||||
|
||||
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
||||
action == #selector(UIResponderStandardEditActions.copy)
|
||||
}
|
||||
|
||||
override func copy(_ sender: Any?) {
|
||||
UIPasteboard.general.string = cellTitleCopy
|
||||
cellTitleCopy = nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,16 +11,14 @@ class TVCSettings: UITableViewController {
|
||||
super.viewDidLoad()
|
||||
NotifyVPNStateChanged.observe(call: #selector(vpnStateChanged(_:)), on: self)
|
||||
changedState(currentVPNState)
|
||||
NotifyFilterChanged.observe(call: #selector(reloadDataSource), on: self)
|
||||
NotifyDNSFilterChanged.observe(call: #selector(reloadDataSource), on: self)
|
||||
reloadDataSource()
|
||||
}
|
||||
|
||||
@objc func reloadDataSource() {
|
||||
let (blocked, ignored) = DBWrp.dataF_counts()
|
||||
DispatchQueue.main.async {
|
||||
self.cellDomainsIgnored.detailTextLabel?.text = "\(ignored) Domains"
|
||||
self.cellDomainsBlocked.detailTextLabel?.text = "\(blocked) Domains"
|
||||
}
|
||||
let (blocked, ignored) = DomainFilter.counts()
|
||||
cellDomainsIgnored.detailTextLabel?.text = "\(ignored) Domains"
|
||||
cellDomainsBlocked.detailTextLabel?.text = "\(blocked) Domains"
|
||||
}
|
||||
|
||||
@IBAction func toggleVPNProxy(_ sender: UISwitch) {
|
||||
@@ -28,36 +26,26 @@ class TVCSettings: UITableViewController {
|
||||
}
|
||||
|
||||
@IBAction func exportDB(_ sender: Any) {
|
||||
// TODO: export partly?
|
||||
// TODO: show header-banner of success
|
||||
// Share Sheet
|
||||
let sheet = UIActivityViewController(activityItems: [URL.internalDB()], applicationActivities: nil)
|
||||
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) {
|
||||
Pref.DidShowTutorial.Welcome = false
|
||||
Pref.DidShowTutorial.Recordings = false
|
||||
Alert(title: sender.titleLabel?.text,
|
||||
text: "\nDone.\n\nYou may need to restart the application.").presentIn(self)
|
||||
}
|
||||
|
||||
@IBAction func clearDatabaseResults(_ sender: Any) {
|
||||
AskAlert(title: "Clear results?", text: """
|
||||
You are about to delete all results that have been logged in the past. Your preference for blocked and ignored domains is preserved.
|
||||
Continue?
|
||||
""", buttonText: "Delete", buttonStyle: .destructive) {
|
||||
DBWrp.deleteHistory()
|
||||
AskAlert(title: "Clear results?", text:
|
||||
"You are about to delete all results that have been logged in the past. " +
|
||||
"Your preferences for blocked and ignored domains are preserved.\n" +
|
||||
"Continue?", buttonText: "Delete", buttonStyle: .destructive) { _ in
|
||||
DispatchQueue.global().async {
|
||||
try? AppDB?.dnsLogsDeleteAll()
|
||||
NotifyLogHistoryReset.postAsyncMain()
|
||||
}
|
||||
}.presentIn(self)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,13 +5,40 @@ class TBCMain: UITabBarController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
// perform(#selector(showWelcomeMessage), with: nil, afterDelay: 3)
|
||||
NotifyVPNStateChanged.observe(call: #selector(vpnStateChanged(_:)), on: self)
|
||||
changedState(currentVPNState)
|
||||
|
||||
if !Pref.DidShowTutorial.Welcome {
|
||||
self.perform(#selector(showWelcomeMessage), with: nil, afterDelay: 0.5)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func showWelcomeMessage() {
|
||||
performSegue(withIdentifier: "welcome", sender: nil)
|
||||
let x = TutorialSheet()
|
||||
x.addSheet().addArrangedSubview(QuickUI.text(attributed: NSMutableAttributedString()
|
||||
.h1("Welcome\n")
|
||||
.normal("\nAppCheck helps you identify which applications communicate with third parties. " +
|
||||
"It does so by logging network requests. " +
|
||||
"AppCheck learns only the destination addresses, not the actual data that is exchanged." +
|
||||
"\n\n" +
|
||||
"Your data belongs to you. " +
|
||||
"Therefore, monitoring and analysis take place on your device only. " +
|
||||
"The app does not share any data with us or any other third-party. " +
|
||||
"Unless you choose to.")
|
||||
))
|
||||
x.addSheet().addArrangedSubview(QuickUI.text(attributed: NSMutableAttributedString()
|
||||
.h1("How it works\n")
|
||||
.normal("\nAppCheck creates a local VPN tunnel to intercept all network connections. " +
|
||||
"For each connection AppCheck looks into the DNS headers only, namely the domain names. " +
|
||||
"\n" +
|
||||
"These domain names are logged in the background while the VPN is active. " +
|
||||
"That means, AppCheck does not have to be active in the foreground. " +
|
||||
"You can close the app and come back later to see the results."
|
||||
)
|
||||
))
|
||||
x.present {
|
||||
Pref.DidShowTutorial.Welcome = true
|
||||
}
|
||||
}
|
||||
|
||||
@objc func vpnStateChanged(_ notification: Notification) {
|
||||
|
||||
@@ -1,125 +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) }
|
||||
x.backgroundColor = editableRowActionColor(index, a)
|
||||
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)
|
||||
}
|
||||
}
|
||||