137 Commits

Author SHA1 Message Date
relikd
e65d84b6ab linguist 2022-03-15 17:16:12 +01:00
relikd
36cb499bf8 update proj file 2021-06-24 10:44:22 +02:00
relikd
f78146f48d Update app name in readme 2021-04-01 16:11:47 +02:00
relikd
34ffd33316 Basic iPad support 2021-04-01 15:43:56 +02:00
relikd
e0158845d3 AppStore preset 2021-04-01 15:38:02 +02:00
relikd
1e8101a699 Rename app 2021-04-01 15:37:15 +02:00
relikd
dabbe10a2c Update readme 2020-10-25 18:54:40 +01:00
relikd
92560bc48d New TLDs 2020-10-17 12:07:11 +02:00
relikd
3607fa9564 Update text 2020-10-09 22:37:49 +02:00
relikd
dfa368cff2 Typo 'if' and settings table background color 2020-10-09 14:23:07 +02:00
relikd
e54d69ef4b Version 1.0.0 (34) 2020-09-19 14:35:26 +02:00
relikd
be8269ad56 Include iOS version in json 2020-09-19 14:25:02 +02:00
relikd
7118ec3b02 Update to Xcode 12 2020-09-17 16:41:18 +02:00
relikd
71045bf0dd Ignore forceDisconnect on background recording 2020-09-17 13:46:18 +02:00
relikd
27abdd66f5 Display long domain names – two lines everywhere 2020-09-14 22:46:58 +02:00
relikd
162e18c912 Cleanup NEKit 2020-09-14 21:10:03 +02:00
relikd
d68e4ec869 Version 1.0.0 (33) 2020-09-14 12:56:30 +02:00
relikd
762263bfbd Default disconnect swdc + pre-check connect message 2020-09-14 12:56:06 +02:00
relikd
b1cddc796e Version 1.0.0 (32) 2020-09-14 11:52:21 +02:00
relikd
77e20f31f5 Persist recording logs in background 2020-09-14 11:51:44 +02:00
relikd
0175f5390e Fix crash trying to access userInfo 2020-09-13 12:13:14 +02:00
relikd
effc305b86 Version 1.0.0 (31) 2020-09-12 22:28:32 +02:00
relikd
c1fe258b0d Force disconnect to prevent domain spamming (optional in advanced settings) 2020-09-12 22:28:11 +02:00
relikd
36a8f0b97b Version 1.0.0 (30) 2020-09-12 11:35:11 +02:00
relikd
33b9cab8a8 Indicate background recording needs more time 2020-09-12 11:32:06 +02:00
relikd
b88874b38b Version 1.0.0 (29) 2020-09-12 10:57:20 +02:00
relikd
f55f3ea32d Disable copy menu on meta cells in 5 min context 2020-09-12 10:42:37 +02:00
relikd
c843bd76a2 Share notes opt-out, assuming notes are created for upload anyway 2020-09-12 10:31:13 +02:00
relikd
4dd2339ed8 Set recording segment color to indicate tap action 2020-09-12 10:27:04 +02:00
relikd
280526bef4 Hide filter button if new recording 2020-09-12 10:04:24 +02:00
relikd
34caffd4a7 Change tutorial text about app recording length 2020-09-12 09:57:53 +02:00
relikd
9e19b457e2 AppStore search: sort local apps case independent 2020-09-11 15:39:23 +02:00
relikd
e6846953b7 Copy upload key to clipboard 2020-09-08 18:35:30 +02:00
relikd
6d78aeac7b Fix header banner display issues 2020-09-08 18:16:21 +02:00
relikd
5d94fe3a0d Version 1.0.0 (28) 2020-09-08 11:56:02 +02:00
relikd
fb680d669b Fix crash on loading App Store search results 2020-09-08 11:52:07 +02:00
relikd
6409e5eaf3 Allow to contribute empty recordings 2020-09-08 04:28:07 +02:00
relikd
39ca9dbdb1 Persist recording logs before save operation (crash-safe) 2020-09-08 03:16:38 +02:00
relikd
27ab2a621a Set recording time as filter 2020-09-08 02:51:25 +02:00
relikd
3f572eeb15 Version 1.0.0 (27) 2020-09-06 10:13:01 +02:00
relikd
e83540d5de Fix empty json log 2020-09-05 23:32:57 +02:00
relikd
847556bec1 Add important notice to app recording 2020-09-05 22:26:04 +02:00
relikd
42b045fb85 Open co-occurrence from recording 2020-09-05 22:07:22 +02:00
relikd
35a211f87f Fix action target self-reference timing issues 2020-09-05 22:05:56 +02:00
relikd
d2fa67e0e3 Reduce redundant code, cell copy menu 2020-09-05 21:05:12 +02:00
relikd
b8660c9a35 Jump from Recordings to Requests tab 2020-09-05 20:08:37 +02:00
relikd
8cd3f7fb3a Fix iOS 10 layout issues 2020-09-04 09:14:35 +02:00
relikd
2ee0272a05 Improve recording contribution view. Replace TextView with interactive TableView. 2020-09-04 09:14:23 +02:00
relikd
4ae82fc763 Show recording how-to at least once after app install 2020-08-31 23:02:46 +02:00
relikd
aac42d7eff Fix duration 2020-08-31 22:42:55 +02:00
relikd
8bb77ef741 Tiny markdown parser, makes tutorial screens editing much simpler 2020-08-31 17:10:11 +02:00
relikd
ff4218981f Discard recording if time criteria not met 2020-08-31 12:18:36 +02:00
relikd
7b7c5f3d9a UI app recording vs. background recording 2020-08-30 00:03:15 +02:00
relikd
1c203e39c3 Fix iOS 10 Tutorial sheet top padding missing 2020-08-29 23:46:13 +02:00
relikd
7dbf21d564 Disable block & ignore filter during recording 2020-08-29 18:36:41 +02:00
relikd
8fcb5ad874 No VPN, no recording 2020-08-29 17:49:30 +02:00
relikd
b4bf705b7f Rename column uploadkey 2020-08-29 14:48:53 +02:00
relikd
69d8321180 check status 'ok' 2020-08-29 14:44:40 +02:00
relikd
b03daeca66 Store sharing key instead of just a bool 2020-08-28 23:41:08 +02:00
relikd
c502484bcf Indicate shared on recordings overview + move isShared check to sharing sheet 2020-08-28 23:02:10 +02:00
relikd
448d69c6d8 Show "no results" in recordings + mark recording as shared 2020-08-28 22:05:49 +02:00
relikd
42aa7cf926 Contribute recording 2020-08-28 18:36:52 +02:00
relikd
52fa2e460e Fix: Wait for busy lock instead of instantly dropping the operation 2020-08-24 00:58:50 +02:00
relikd
8855ae754a Fix: Auto-delete logs did not clear heap 2020-08-24 00:56:14 +02:00
relikd
908a909c87 Share results screen 2020-08-12 18:15:32 +02:00
relikd
41aee797a9 Split storyboard tabs 2020-08-11 20:10:13 +02:00
relikd
685f636d5b Recordings: Choose app instead of custom title 2020-08-11 19:21:07 +02:00
relikd
4af56b0cb1 Reverting to single step persist 2020-08-01 09:42:57 +02:00
relikd
a3973c7e9a Embed Recordings in navigation controller, not the other way around 2020-08-01 09:33:48 +02:00
relikd
b270f30f3c Version 1.0.0 (26) 2020-07-28 15:20:30 +02:00
relikd
03177cee0b Invalidate restart reminder when VPN is running 2020-07-28 15:19:47 +02:00
relikd
9ee094dc20 Version 1.0.0 (25) 2020-07-28 14:50:59 +02:00
relikd
b1d49c6765 Persist logs by renaming table (hopefully reduces lock time) 2020-07-27 21:16:14 +02:00
relikd
b774e2152c Don't perform notification open action if modal window is open 2020-07-27 19:23:16 +02:00
relikd
e398ac8bcd Let notification open domain 2020-07-27 19:06:44 +02:00
relikd
01523b250f Proper VPN simulator with notifications, etc. 2020-07-27 17:50:15 +02:00
relikd
a2b0f311d5 First version with app notifications 2020-07-26 22:32:11 +02:00
relikd
88a52fb92c Version 1.0.0 (24) 2020-07-02 14:12:26 +02:00
relikd
723f1665a7 fittingSize() 2020-07-02 12:26:34 +02:00
relikd
4f92d3d58d Co-Occurrence on domain level 2020-07-02 12:26:07 +02:00
relikd
05d06a4f31 Update readme 2020-07-01 13:31:29 +02:00
relikd
f9ab545e0f Version 1.0.0 (23) 2020-07-01 12:30:22 +02:00
relikd
b10d4c8b36 Show database file size in settings 2020-07-01 11:47:15 +02:00
relikd
5a3ca024f8 Vacuum before export 2020-07-01 10:57:50 +02:00
relikd
92216c0c03 CustomAlert refactoring. Using proper UIPresentationController with adaptive margins 2020-07-01 00:53:25 +02:00
relikd
9ece3474c6 Limit CustomAlert to screen size & cut padding in half if necessary 2020-06-29 00:34:15 +02:00
relikd
6dcc2086e6 Auto-delete logs finished + custom App-to-VPN messages 2020-06-28 23:55:08 +02:00
relikd
08483711e2 Remove two unimportant and verbose error logs 2020-06-28 21:17:37 +02:00
relikd
0e100006d3 Moving extensions around 2020-06-28 17:04:48 +02:00
relikd
710c617862 Move VPN manager logic into its own controller 2020-06-28 16:31:11 +02:00
relikd
3ed25c92cd Render assets as template image 2020-06-28 14:34:16 +02:00
relikd
f7644e6048 Rename Pref -> Prefs 2020-06-28 14:33:36 +02:00
relikd
80afa6aff1 Privacy: Auto-delete logs (no functionality yet) 2020-06-28 14:20:31 +02:00
relikd
43de81929f Alerts with custom views 2020-06-28 01:06:06 +02:00
relikd
e315e71d07 Storyboard constantly trying to replace floats with rounding error 2020-06-27 16:27:48 +02:00
relikd
416eb34799 Reverse context analysis sort order (oldest first) 2020-06-27 16:20:20 +02:00
relikd
b7b13f51b2 Recordings: Toggle between raw logs and summary 2020-06-27 16:13:58 +02:00
relikd
2312187670 DB readText -> col_text 2020-06-27 00:54:50 +02:00
relikd
c7d0dc7c5f UIColor.sysFg -> UIColor.sysLabel 2020-06-27 00:50:47 +02:00
relikd
895cabee80 Context analysis: +/-5min raw logs 2020-06-27 00:40:29 +02:00
relikd
d96ced48c9 Version 1.0.0 (22) 2020-06-26 21:36:51 +02:00
relikd
0b6dbfd888 Co-Occurrence tutorial sheet + small bugfixes 2020-06-26 20:26:30 +02:00
relikd
96656438c6 Context analysis: Co-Occurrence 2020-06-24 13:09:11 +02:00
relikd
4b32df5683 Fix layout constraint warning on iOS 10 2020-06-21 16:20:20 +02:00
relikd
0758bd7dec Fix iOS 9 finish editing of cell 2020-06-21 16:16:39 +02:00
relikd
171dabd83a Search integrated in table view header 2020-06-21 16:13:58 +02:00
relikd
6182a99ebd Exclude TLD when searching host 2020-06-20 13:56:11 +02:00
relikd
8bfedda3ab Version 1.0.0 (21) 2020-06-20 13:28:38 +02:00
relikd
26f6ea1a9a Fix crash when sort and filter change at the same time.
Fix edit table cell during reload
2020-06-20 12:56:56 +02:00
relikd
778f377e42 Disabling prepared statement for now 2020-06-20 01:03:51 +02:00
relikd
f284365469 Fix update of 'last modified' cell if removing latest entries 2020-06-19 17:01:10 +02:00
relikd
5dfb7d4ba4 Remove safeSetRange 2020-06-19 15:40:34 +02:00
relikd
bb9c3a3034 Use nil instead of 0 and -1 2020-06-19 14:24:03 +02:00
relikd
8cf872a4b0 Todos 2020-06-17 01:49:46 +02:00
relikd
e813230824 Fix: re-insert at same position if last row 2020-06-17 01:36:39 +02:00
relikd
e8bfde9243 Fix: Search bar animation table height 2020-06-17 01:26:49 +02:00
relikd
e947ad6d4d Refactoring II.
- Filter by date range
- SyncUpdate tasks run fully asynchronous in background
- Move tableView manipulations into FilterPipelineDelegate
- Move SyncUpdate notification into SyncUpdateDelegate
- Fix: sync cache before persisting a recording
- Restructuring GroupedDomainDataSource
- Performance: db logs queries use rowids instead of timestamps
- Add 'now' button to DatePickerAlert
2020-06-17 00:27:22 +02:00
relikd
0a53898797 DatePickerAlert + DateFormat 2020-06-11 01:32:50 +02:00
relikd
946acc2460 Sort order 2020-06-08 23:38:09 +02:00
relikd
e13b3df2c4 Swap filter and search button button 2020-06-07 12:02:18 +02:00
relikd
7df2fe421e Version 1.0.0 (19) 2020-06-05 18:23:12 +02:00
relikd
b4b89f8bb4 Persist cache with pull-to-refresh + Sync rate limiting 2020-06-05 18:12:31 +02:00
relikd
db41e68f35 Remove filter logic from PipelineSorting 2020-06-05 16:11:42 +02:00
relikd
5acd9bbcc6 Bugfix: sorted array difference 2020-06-05 16:08:17 +02:00
relikd
23eab2310f Search Hosts + search animations + reload table after filter manipulations 2020-06-05 14:27:41 +02:00
relikd
80829ad015 Version 1.0.0 (17) 2020-06-04 18:54:38 +02:00
relikd
661bf5d30a Fix data source update 2020-06-04 18:54:09 +02:00
relikd
38f4166503 Version 1.0.0 (16) 2020-06-04 18:36:36 +02:00
relikd
d96038c7e3 Bounce settings table view 2020-06-04 17:42:12 +02:00
relikd
7d6b071d8a Bugfixes
- Disable cell animations for huge changes
- Updating a cell keeps the old position whenever possible
- Async `didChangeDateFilter`
- Fixes bug where saving a recording would persist entries again
- Small changes to `TimeFormat`, `AlertDeleteLogs` and `binTreeIndex()`
2020-06-04 17:07:37 +02:00
relikd
b17fb3c354 Refactoring I.
- Revamp whole DB to Display flow
- Filter Pipeline, arbitrary filtering and sorting
- Binary tree arrays for faster lookup & manipulation
- DB: introducing custom functions
- DB scheme: split req into heap & cache
- cache written by GlassVPN only
- heap written by Main App only
- Introducing DB separation: DBCore, DBCommon, DBAppOnly
- Introducing DB data sources: TestDataSource, GroupedDomainDataSource, RecordingsDB, DomainFilter
- Background sync: Move entries from cache to heap and notify all observers
- GlassVPN: Binary tree filter lookup
- GlassVPN: Reusing prepared statement
2020-06-02 21:45:08 +02:00
relikd
10b43a0f67 Group multiple timestamps 2020-05-13 21:57:04 +02:00
relikd
4092a9ba55 Fix tableview access on main thread 2020-05-13 21:55:02 +02:00
relikd
2d35c863e4 Readme + 3rd party license 2020-05-13 15:32:38 +02:00
relikd
8424c161b9 Search + lastXMin Filter + dynamic text size 2020-05-13 01:37:50 +02:00
relikd
9485d7e9b5 Fix pull back animation for new recording 2020-04-22 22:30:19 +02:00
relikd
9f26bdfba1 DNS filter: URL text input 2020-04-18 18:57:17 +02:00
180 changed files with 10336 additions and 4140 deletions

4
.gitattributes vendored Normal file
View File

@@ -0,0 +1,4 @@
GlassVPN/SwiftSocket/** linguist-vendored
GlassVPN/robbiehanson-CocoaAsyncSocket/** linguist-vendored
GlassVPN/zhuhaow-NEKit/** linguist-vendored
GlassVPN/zhuhaow-Resolver/** linguist-vendored

4
.gitignore vendored
View File

@@ -4,4 +4,6 @@ build/
DerivedData/
Carthage/Checkouts
Carthage/Build
Carthage/Build
.DS_Store

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1130"
LastUpgradeVersion = "1250"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
@@ -15,9 +15,9 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "541AC5D32399498A00A769D7"
BuildableName = "AppCheck.app"
BlueprintName = "AppCheck"
ReferencedContainer = "container:AppCheck.xcodeproj">
BuildableName = "AppChk.app"
BlueprintName = "AppChk"
ReferencedContainer = "container:AppChk.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
@@ -45,9 +45,9 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "541AC5D32399498A00A769D7"
BuildableName = "AppCheck.app"
BlueprintName = "AppCheck"
ReferencedContainer = "container:AppCheck.xcodeproj">
BuildableName = "AppChk.app"
BlueprintName = "AppChk"
ReferencedContainer = "container:AppChk.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
@@ -61,9 +61,9 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "541AC5D32399498A00A769D7"
BuildableName = "AppCheck.app"
BlueprintName = "AppCheck"
ReferencedContainer = "container:AppCheck.xcodeproj">
BuildableName = "AppChk.app"
BlueprintName = "AppChk"
ReferencedContainer = "container:AppChk.xcodeproj">
</BuildableReference>
</MacroExpansion>
</ProfileAction>

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1130"
LastUpgradeVersion = "1250"
wasCreatedForAppExtension = "YES"
version = "2.0">
<BuildAction
@@ -18,7 +18,7 @@
BlueprintIdentifier = "543CDB1C23EEE61900B7F323"
BuildableName = "GlassVPN.appex"
BlueprintName = "GlassVPN"
ReferencedContainer = "container:AppCheck.xcodeproj">
ReferencedContainer = "container:AppChk.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
@@ -30,9 +30,9 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "541AC5D32399498A00A769D7"
BuildableName = "AppCheck.app"
BlueprintName = "AppCheck"
ReferencedContainer = "container:AppCheck.xcodeproj">
BuildableName = "AppChk.app"
BlueprintName = "AppChk"
ReferencedContainer = "container:AppChk.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
@@ -62,9 +62,9 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "541AC5D32399498A00A769D7"
BuildableName = "AppCheck.app"
BlueprintName = "AppCheck"
ReferencedContainer = "container:AppCheck.xcodeproj">
BuildableName = "AppChk.app"
BlueprintName = "AppChk"
ReferencedContainer = "container:AppChk.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
@@ -74,15 +74,16 @@
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
askForAppToLaunch = "Yes"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "541AC5D32399498A00A769D7"
BuildableName = "AppCheck.app"
BlueprintName = "AppCheck"
ReferencedContainer = "container:AppCheck.xcodeproj">
BuildableName = "AppChk.app"
BlueprintName = "AppChk"
ReferencedContainer = "container:AppChk.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>

View File

@@ -20,6 +20,8 @@
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>

View File

@@ -1,13 +1,15 @@
import NetworkExtension
fileprivate var db: SQLiteDatabase?
fileprivate var domainFilters: [String : FilterOptions] = [:]
let connectMessage: Data = "CONNECT".data(using: .ascii)!
let swcdUserAgent: Data = "User-Agent: swcd".data(using: .ascii)!
fileprivate var hook : GlassVPNHook!
// MARK: ObserverFactory
class LDObserverFactory: ObserverFactory {
override func getObserverForProxySocket(_ socket: ProxySocket) -> Observer<ProxySocketEvent>? {
// TODO: replace NEKit with custom proxy with minimal footprint
return LDProxySocketObserver()
}
@@ -15,103 +17,126 @@ 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() }
var kill = !hook.isBackgroundRecording && hook.forceDisconnectUnresolvable && session.ipAddress.isEmpty
if kill || socket.isCancelled { // isCancelled is set by branch below
hook.silentlyPrevented(session.host)
} else {
kill = hook.processDNSRequest(session.host)
}
if kill { socket.forceDisconnect() }
case .readData(let data, on: let socket):
if !hook.isBackgroundRecording, hook.forceDisconnectSWCD,
data.starts(with: connectMessage), data.range(of: swcdUserAgent) != nil {
socket.disconnect() // sets isCancelled above
}
default:
break
}
}
}
}
// MARK: NEPacketTunnelProvider
class PacketTunnelProvider: NEPacketTunnelProvider {
let proxyServerPort: UInt16 = 9090
let proxyServerAddress = "127.0.0.1"
var proxyServer: GCDHTTPProxyServer!
private let proxyServerPort: UInt16 = 9090
private let proxyServerAddress = "127.0.0.1"
private var proxyServer: GCDHTTPProxyServer!
func reloadDomainFilter() {
domainFilters = db?.loadFilters() ?? [:]
}
override func startTunnel(options: [String : NSObject]?, completionHandler: @escaping (Error?) -> Void) {
DDLogVerbose("startTunnel")
// MARK: Delegate
override func startTunnel(options: [String : NSObject]?, completionHandler: @escaping (Error?) -> Void) {
DDLogVerbose("startTunnel with with options: \(String(describing: options))")
PrefsShared.registerDefaults()
do {
db = try SQLiteDatabase.open()
db!.initScheme()
try SQLiteDatabase.open().initCommonScheme()
} catch {
completionHandler(error)
completionHandler(error) // if we cant open db, fail immediately
return
}
if proxyServer != nil {
proxyServer.stop()
}
proxyServer = nil
reloadDomainFilter()
// stop previous if any
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;
RawSocketFactory.TunnelProvider = self
ObserverFactory.currentFactory = LDObserverFactory()
willInitProxy()
self.setTunnelNetworkSettings(settings) { error in
self.setTunnelNetworkSettings(createProxy()) { error in
guard error == nil else {
DDLogError("setTunnelNetworkSettings error: \(String(describing: error))")
DDLogError("setTunnelNetworkSettings error: \(error!)")
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()
self.didInitProxy()
completionHandler(nil)
} catch let proxyError {
DDLogError("Error starting proxy server \(proxyError)")
completionHandler(proxyError)
}
}
}
}
override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) {
DDLogVerbose("stopTunnel with reason: \(reason)")
db = nil
shutdown()
completionHandler()
exit(EXIT_SUCCESS)
}
override func handleAppMessage(_ messageData: Data, completionHandler: ((Data?) -> Void)?) {
hook.handleAppMessage(messageData)
}
// MARK: Helper
private func willInitProxy() {
hook = GlassVPNHook()
}
private func createProxy() -> NEPacketTunnelNetworkSettings {
let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: proxyServerAddress)
settings.mtu = NSNumber(value: 1500)
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()
return settings
}
private func didInitProxy() {
if PrefsShared.RestartReminder.Enabled {
PushNotification.scheduleRestartReminderBadge(on: false)
PushNotification.cancel(.CantStopMeNowReminder)
}
}
private func shutdown() {
// proxy
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")
reloadDomainFilter()
}
RawSocketFactory.TunnelProvider = nil
ObserverFactory.currentFactory = nil
proxyServer.stop()
proxyServer = nil
// custom
hook.cleanUp()
hook = nil
if PrefsShared.RestartReminder.Enabled {
PushNotification.scheduleRestartReminderBadge(on: true)
PushNotification.scheduleRestartReminderBanner()
}
}
}

BIN
GlassVPN/SwiftSocket/.DS_Store vendored Normal file

Binary file not shown.

View 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.

View File

@@ -1,16 +0,0 @@
import Foundation
public enum RuleMatchEvent: EventType {
public var description: String {
switch self {
case let .ruleMatched(session, rule: rule):
return "Rule \(rule) matched session \(session)."
case let .ruleDidNotMatch(session, rule: rule):
return "Rule \(rule) did not match session \(session)."
case let .dnsRuleMatched(session, rule: rule, type: type, result: result):
return "Rule \(rule) matched DNS session \(session) of type \(type), the result is \(result)."
}
}
case ruleMatched(ConnectSession, rule: Rule), ruleDidNotMatch(ConnectSession, rule: Rule), dnsRuleMatched(DNSSession, rule: Rule, type: DNSSessionMatchType, result: DNSSessionMatchResult)
}

View File

@@ -20,8 +20,4 @@ open class ObserverFactory {
open func getObserverForProxyServer(_ server: ProxyServer) -> Observer<ProxyServerEvent>? {
return nil
}
open func getObserverForRuleManager(_ manager: RuleManager) -> Observer<RuleMatchEvent>? {
return nil
}
}

View File

@@ -74,8 +74,6 @@ open class DNSServer: DNSResolverDelegate, IPStackProtocol {
return
}
RuleManager.currentManager.matchDNS(session, type: .domain)
switch session.matchResult! {
case .fake:
guard setUpFakeIP(session) else {
@@ -248,10 +246,6 @@ open class DNSServer: DNSResolverDelegate, IPStackProtocol {
session.realIP = message.resolvedIPv4Address
if session.matchResult != .fake && session.matchResult != .real {
RuleManager.currentManager.matchDNS(session, type: .ip)
}
switch session.matchResult! {
case .fake:
if !self.setUpFakeIP(session) {

View File

@@ -7,7 +7,6 @@ open class DNSSession {
open var fakeIP: IPAddress?
open var realResponseMessage: DNSMessage?
var realResponseIPPacket: IPPacket?
open var matchedRule: Rule?
open var matchResult: DNSSessionMatchResult?
var indexToMatch = 0
var expireAt: Date?

27
GlassVPN/zhuhaow-NEKit/LICENSE.md vendored Normal file
View 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.

View File

@@ -21,9 +21,6 @@ public final class ConnectSession {
/// The requested port.
public let port: Int
/// The rule to use to connect to remote.
public var matchedRule: Rule?
/// Whether If the `requestedHost` is an IP address.
public let fakeIPEnabled: Bool
@@ -126,11 +123,6 @@ public final class ConnectSession {
host = session.requestMessage.queries[0].name
ipAddress = session.realIP?.presentation ?? ""
matchedRule = session.matchedRule
// if session.countryCode != nil {
// country = session.countryCode!
// }
return true
}

View File

@@ -17,7 +17,6 @@ open class HTTPHeader {
// Chunk is not supported yet.
open var contentLength: Int = 0
open var headers: [(String, String)] = []
open var rawHeader: Data?
public init(headerString: String) throws {
let lines = headerString.components(separatedBy: "\r\n")
@@ -127,7 +126,6 @@ open class HTTPHeader {
}
try self.init(headerString: headerString)
rawHeader = headerData
}
open subscript(index: String) -> String? {

View File

@@ -1,24 +0,0 @@
import Foundation
/// The SOCKS5 proxy server.
public final class GCDSOCKS5ProxyServer: GCDProxyServer {
/**
Create an instance of SOCKS5 proxy server.
- parameter address: The address of proxy server.
- parameter port: The port of proxy server.
*/
override public init(address: IPAddress?, port: Port) {
super.init(address: address, port: port)
}
/**
Handle the new accepted socket as a SOCKS5 proxy connection.
- parameter socket: The accepted socket.
*/
override public func handleNewGCDSocket(_ socket: GCDTCPSocket) {
let proxySocket = SOCKS5ProxySocket(socket: socket)
didAcceptNewSocket(proxySocket)
}
}

View File

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

View File

@@ -1,6 +0,0 @@
import Foundation
open class ResponseGeneratorFactory {
static var HTTPProxyResponseGenerator: ResponseGenerator.Type?
static var SOCKS5ProxyResponseGenerator: ResponseGenerator.Type?
}

View File

@@ -1,48 +0,0 @@
import Foundation
/// The rule matches all DNS and connect sessions.
open class AllRule: Rule {
fileprivate let adapterFactory: AdapterFactory
open override var description: String {
return "<AllRule>"
}
/**
Create a new `AllRule` instance.
- parameter adapterFactory: The factory which builds a corresponding adapter when needed.
*/
public init(adapterFactory: AdapterFactory) {
self.adapterFactory = adapterFactory
super.init()
}
/**
Match DNS session to this rule.
- parameter session: The DNS session to match.
- parameter type: What kind of information is available.
- returns: The result of match.
*/
override open func matchDNS(_ session: DNSSession, type: DNSSessionMatchType) -> DNSSessionMatchResult {
// only return real IP when we connect to remote directly
if let _ = adapterFactory as? DirectAdapterFactory {
return .real
} else {
return .fake
}
}
/**
Match connect session to this rule.
- parameter session: connect session to match.
- returns: The configured adapter.
*/
override open func match(_ session: ConnectSession) -> AdapterFactory? {
return adapterFactory
}
}

View File

@@ -1,60 +0,0 @@
import Foundation
/// The rule matches the request which failed to look up.
open class DNSFailRule: Rule {
fileprivate let adapterFactory: AdapterFactory
open override var description: String {
return "<DNSFailRule>"
}
/**
Create a new `DNSFailRule` instance.
- parameter adapterFactory: The factory which builds a corresponding adapter when needed.
*/
public init(adapterFactory: AdapterFactory) {
self.adapterFactory = adapterFactory
super.init()
}
/**
Match DNS request to this rule.
- parameter session: The DNS session to match.
- parameter type: What kind of information is available.
- returns: The result of match.
*/
override open func matchDNS(_ session: DNSSession, type: DNSSessionMatchType) -> DNSSessionMatchResult {
guard type == .ip else {
return .unknown
}
// only return real IP when we connect to remote directly
if session.realIP == nil {
if let _ = adapterFactory as? DirectAdapterFactory {
return .real
} else {
return .fake
}
} else {
return .pass
}
}
/**
Match connect session to this rule.
- parameter session: connect session to match.
- returns: The configured adapter.
*/
override open func match(_ session: ConnectSession) -> AdapterFactory? {
if session.ipAddress == "" {
return adapterFactory
} else {
return nil
}
}
}

View File

@@ -1,16 +0,0 @@
import Foundation
/// The rule matches every request and returns direct adapter.
///
/// This is equivalent to create an `AllRule` with a `DirectAdapterFactory`.
open class DirectRule: AllRule {
open override var description: String {
return "<DirectRule>"
}
/**
Create a new `DirectRule` instance.
*/
public init() {
super.init(adapterFactory: DirectAdapterFactory())
}
}

View File

@@ -1,84 +0,0 @@
import Foundation
/// The rule matches the host domain to a list of predefined criteria.
open class DomainListRule: Rule {
public enum MatchCriterion {
case regex(NSRegularExpression), prefix(String), suffix(String), keyword(String), complete(String)
func match(_ domain: String) -> Bool {
switch self {
case .regex(let regex):
return regex.firstMatch(in: domain, options: [], range: NSRange(location: 0, length: domain.utf8.count)) != nil
case .prefix(let prefix):
return domain.hasPrefix(prefix)
case .suffix(let suffix):
return domain.hasSuffix(suffix)
case .keyword(let keyword):
return domain.contains(keyword)
case .complete(let match):
return domain == match
}
}
}
fileprivate let adapterFactory: AdapterFactory
open override var description: String {
return "<DomainListRule>"
}
/// The list of criteria to match to.
open var matchCriteria: [MatchCriterion] = []
/**
Create a new `DomainListRule` instance.
- parameter adapterFactory: The factory which builds a corresponding adapter when needed.
- parameter criteria: The list of criteria to match.
*/
public init(adapterFactory: AdapterFactory, criteria: [MatchCriterion]) {
self.adapterFactory = adapterFactory
self.matchCriteria = criteria
}
/**
Match DNS request to this rule.
- parameter session: The DNS session to match.
- parameter type: What kind of information is available.
- returns: The result of match.
*/
override open func matchDNS(_ session: DNSSession, type: DNSSessionMatchType) -> DNSSessionMatchResult {
if matchDomain(session.requestMessage.queries.first!.name) {
if let _ = adapterFactory as? DirectAdapterFactory {
return .real
}
return .fake
}
return .pass
}
/**
Match connect session to this rule.
- parameter session: connect session to match.
- returns: The configured adapter if matched, return `nil` if not matched.
*/
override open func match(_ session: ConnectSession) -> AdapterFactory? {
if matchDomain(session.host) {
return adapterFactory
}
return nil
}
fileprivate func matchDomain(_ domain: String) -> Bool {
for criterion in matchCriteria {
if criterion.match(domain) {
return true
}
}
return false
}
}

View File

@@ -1,75 +0,0 @@
import Foundation
/// The rule matches the ip of the target hsot to a list of IP ranges.
open class IPRangeListRule: Rule {
fileprivate let adapterFactory: AdapterFactory
open override var description: String {
return "<IPRangeList>"
}
/// The list of regular expressions to match to.
open var ranges: [IPRange] = []
/**
Create a new `IPRangeListRule` instance.
- parameter adapterFactory: The factory which builds a corresponding adapter when needed.
- parameter ranges: The list of IP ranges to match. The IP ranges are expressed in CIDR form ("127.0.0.1/8") or range form ("127.0.0.1+16777216").
- throws: The error when parsing the IP range.
*/
public init(adapterFactory: AdapterFactory, ranges: [String]) throws {
self.adapterFactory = adapterFactory
self.ranges = try ranges.map {
let range = try IPRange(withString: $0)
return range
}
}
/**
Match DNS request to this rule.
- parameter session: The DNS session to match.
- parameter type: What kind of information is available.
- returns: The result of match.
*/
override open func matchDNS(_ session: DNSSession, type: DNSSessionMatchType) -> DNSSessionMatchResult {
guard type == .ip else {
return .unknown
}
// Probably we should match all answers?
guard let ip = session.realIP else {
return .pass
}
for range in ranges {
if range.contains(ip: ip) {
return .fake
}
}
return .pass
}
/**
Match connect session to this rule.
- parameter session: connect session to match.
- returns: The configured adapter if matched, return `nil` if not matched.
*/
override open func match(_ session: ConnectSession) -> AdapterFactory? {
guard let ip = IPAddress(fromString: session.ipAddress) else {
return nil
}
for range in ranges {
if range.contains(ip: ip) {
return adapterFactory
}
}
return nil
}
}

View File

@@ -1,37 +0,0 @@
import Foundation
/// The rule defines what to do for DNS requests and connect sessions.
open class Rule: CustomStringConvertible {
open var description: String {
return "<Rule>"
}
/**
Create a new rule.
*/
public init() {
}
/**
Match DNS request to this rule.
- parameter session: The DNS session to match.
- parameter type: What kind of information is available.
- returns: The result of match.
*/
open func matchDNS(_ session: DNSSession, type: DNSSessionMatchType) -> DNSSessionMatchResult {
return .real
}
/**
Match connect session to this rule.
- parameter session: connect session to match.
- returns: The configured adapter if matched, return `nil` if not matched.
*/
open func match(_ session: ConnectSession) -> AdapterFactory? {
return nil
}
}

View File

@@ -1,80 +0,0 @@
import Foundation
/// The class managing rules.
open class RuleManager {
/// The current used `RuleManager`, there is only one manager should be used at a time.
///
/// - note: This should be set before any DNS or connect sessions.
public static var currentManager: RuleManager = RuleManager(fromRules: [], appendDirect: true)
/// The rule list.
var rules: [Rule] = []
open var observer: Observer<RuleMatchEvent>?
/**
Create a new `RuleManager` from the given rules.
- parameter rules: The rules.
- parameter appendDirect: Whether to append a `DirectRule` at the end of the list so any request does not match with any rule go directly.
*/
public init(fromRules rules: [Rule], appendDirect: Bool = false) {
self.rules = []
if appendDirect || self.rules.count == 0 {
self.rules.append(DirectRule())
}
observer = ObserverFactory.currentFactory?.getObserverForRuleManager(self)
}
/**
Match DNS request to all rules.
- parameter session: The DNS session to match.
- parameter type: What kind of information is available.
*/
func matchDNS(_ session: DNSSession, type: DNSSessionMatchType) {
for (i, rule) in rules[session.indexToMatch..<rules.count].enumerated() {
let result = rule.matchDNS(session, type: type)
observer?.signal(.dnsRuleMatched(session, rule: rule, type: type, result: result))
switch result {
case .fake, .real, .unknown:
session.matchedRule = rule
session.matchResult = result
session.indexToMatch = i + session.indexToMatch // add the offset
return
case .pass:
break
}
}
}
/**
Match connect session to all rules.
- parameter session: connect session to match.
- returns: The matched configured adapter.
*/
func match(_ session: ConnectSession) -> AdapterFactory! {
if session.matchedRule != nil {
observer?.signal(.ruleMatched(session, rule: session.matchedRule!))
return session.matchedRule!.match(session)
}
for rule in rules {
if let adapterFactory = rule.match(session) {
observer?.signal(.ruleMatched(session, rule: rule))
session.matchedRule = rule
return adapterFactory
} else {
observer?.signal(.ruleDidNotMatch(session, rule: rule))
}
}
return nil // this should never happens
}
}

View File

@@ -1,27 +0,0 @@
import Foundation
/// This is a very simple wrapper of a dict of type `[String: AdapterFactory]`.
///
/// Use it as a normal dict.
public class AdapterFactoryManager {
private var factoryDict: [String: AdapterFactory]
public subscript(index: String) -> AdapterFactory? {
get {
if index == "direct" {
return DirectAdapterFactory()
}
return factoryDict[index]
}
set { factoryDict[index] = newValue }
}
/**
Initialize a new factory manager.
- parameter factoryDict: The factory dict.
*/
public init(factoryDict: [String: AdapterFactory]) {
self.factoryDict = factoryDict
}
}

View File

@@ -1,11 +0,0 @@
import Foundation
/// Factory building server adapter which requires authentication.
open class HTTPAuthenticationAdapterFactory: ServerAdapterFactory {
let auth: HTTPAuthentication?
required public init(serverHost: String, serverPort: Int, auth: HTTPAuthentication?) {
self.auth = auth
super.init(serverHost: serverHost, serverPort: serverPort)
}
}

View File

@@ -1,21 +0,0 @@
import Foundation
/// Factory building HTTP adapter.
open class HTTPAdapterFactory: HTTPAuthenticationAdapterFactory {
required public init(serverHost: String, serverPort: Int, auth: HTTPAuthentication?) {
super.init(serverHost: serverHost, serverPort: serverPort, auth: auth)
}
/**
Get a HTTP adapter.
- parameter session: The connect session.
- returns: The built adapter.
*/
override open func getAdapterFor(session: ConnectSession) -> AdapterSocket {
let adapter = HTTPAdapter(serverHost: serverHost, serverPort: serverPort, auth: auth)
adapter.socket = RawSocketFactory.getRawSocket()
return adapter
}
}

View File

@@ -1,13 +0,0 @@
import Foundation
open class RejectAdapterFactory: AdapterFactory {
public let delay: Int
public init(delay: Int = Opt.RejectAdapterDefaultDelay) {
self.delay = delay
}
override open func getAdapterFor(session: ConnectSession) -> AdapterSocket {
return RejectAdapter(delay: delay)
}
}

View File

@@ -1,21 +0,0 @@
import Foundation
/// Factory building SOCKS5 adapter.
open class SOCKS5AdapterFactory: ServerAdapterFactory {
override public init(serverHost: String, serverPort: Int) {
super.init(serverHost: serverHost, serverPort: serverPort)
}
/**
Get a SOCKS5 adapter.
- parameter session: The connect session.
- returns: The built adapter.
*/
override open func getAdapterFor(session: ConnectSession) -> AdapterSocket {
let adapter = SOCKS5Adapter(serverHost: serverHost, serverPort: serverPort)
adapter.socket = RawSocketFactory.getRawSocket()
return adapter
}
}

View File

@@ -1,21 +0,0 @@
import Foundation
/// Factory building secured HTTP (HTTP with SSL) adapter.
open class SecureHTTPAdapterFactory: HTTPAdapterFactory {
required public init(serverHost: String, serverPort: Int, auth: HTTPAuthentication?) {
super.init(serverHost: serverHost, serverPort: serverPort, auth: auth)
}
/**
Get a secured HTTP adapter.
- parameter session: The connect session.
- returns: The built adapter.
*/
override open func getAdapterFor(session: ConnectSession) -> AdapterSocket {
let adapter = SecureHTTPAdapter(serverHost: serverHost, serverPort: serverPort, auth: auth)
adapter.socket = RawSocketFactory.getRawSocket()
return adapter
}
}

View File

@@ -1,12 +0,0 @@
import Foundation
/// Factory building adapter with proxy server host and port.
open class ServerAdapterFactory: AdapterFactory {
let serverHost: String
let serverPort: Int
public init(serverHost: String, serverPort: Int) {
self.serverHost = serverHost
self.serverPort = serverPort
}
}

View File

@@ -1,110 +0,0 @@
import Foundation
public enum HTTPAdapterError: Error, CustomStringConvertible {
case invalidURL, serailizationFailure
public var description: String {
switch self {
case .invalidURL:
return "Invalid url when connecting through proxy"
case .serailizationFailure:
return "Failed to serialize HTTP CONNECT header"
}
}
}
/// This adapter connects to remote host through a HTTP proxy.
public class HTTPAdapter: AdapterSocket {
enum HTTPAdapterStatus {
case invalid,
connecting,
readingResponse,
forwarding,
stopped
}
/// The host domain of the HTTP proxy.
let serverHost: String
/// The port of the HTTP proxy.
let serverPort: Int
/// The authentication information for the HTTP proxy.
let auth: HTTPAuthentication?
/// Whether the connection to the proxy should be secured or not.
var secured: Bool
var internalStatus: HTTPAdapterStatus = .invalid
public init(serverHost: String, serverPort: Int, auth: HTTPAuthentication?) {
self.serverHost = serverHost
self.serverPort = serverPort
self.auth = auth
secured = false
super.init()
}
override public func openSocketWith(session: ConnectSession) {
super.openSocketWith(session: session)
guard !isCancelled else {
return
}
do {
internalStatus = .connecting
try socket.connectTo(host: serverHost, port: serverPort, enableTLS: secured, tlsSettings: nil)
} catch {}
}
override public func didConnectWith(socket: RawTCPSocketProtocol) {
super.didConnectWith(socket: socket)
guard let url = URL(string: "\(session.host):\(session.port)") else {
observer?.signal(.errorOccured(HTTPAdapterError.invalidURL, on: self))
disconnect()
return
}
let message = CFHTTPMessageCreateRequest(kCFAllocatorDefault, "CONNECT" as CFString, url as CFURL, kCFHTTPVersion1_1).takeRetainedValue()
if let authData = auth {
CFHTTPMessageSetHeaderFieldValue(message, "Proxy-Authorization" as CFString, authData.authString() as CFString?)
}
CFHTTPMessageSetHeaderFieldValue(message, "Host" as CFString, "\(session.host):\(session.port)" as CFString?)
CFHTTPMessageSetHeaderFieldValue(message, "Content-Length" as CFString, "0" as CFString?)
guard let requestData = CFHTTPMessageCopySerializedMessage(message)?.takeRetainedValue() else {
observer?.signal(.errorOccured(HTTPAdapterError.serailizationFailure, on: self))
disconnect()
return
}
internalStatus = .readingResponse
write(data: requestData as Data)
socket.readDataTo(data: Utils.HTTPData.DoubleCRLF)
}
override public func didRead(data: Data, from socket: RawTCPSocketProtocol) {
super.didRead(data: data, from: socket)
switch internalStatus {
case .readingResponse:
internalStatus = .forwarding
observer?.signal(.readyForForward(self))
delegate?.didBecomeReadyToForwardWith(socket: self)
case .forwarding:
observer?.signal(.readData(data, on: self))
delegate?.didRead(data: data, from: self)
default:
return
}
}
override public func didWrite(data: Data?, by socket: RawTCPSocketProtocol) {
super.didWrite(data: data, by: socket)
if internalStatus == .forwarding {
observer?.signal(.wroteData(data, on: self))
delegate?.didWrite(data: data, by: self)
}
}
}

View File

@@ -1,49 +0,0 @@
import Foundation
public class RejectAdapter: AdapterSocket {
public let delay: Int
public init(delay: Int) {
self.delay = delay
}
override public func openSocketWith(session: ConnectSession) {
super.openSocketWith(session: session)
QueueFactory.getQueue().asyncAfter(deadline: DispatchTime.now() + DispatchTimeInterval.milliseconds(delay)) {
[weak self] in
self?.disconnect()
}
}
/**
Disconnect the socket elegantly.
*/
public override func disconnect(becauseOf error: Error? = nil) {
guard !isCancelled else {
return
}
_cancelled = true
session.disconnected(becauseOf: error, by: .adapter)
observer?.signal(.disconnectCalled(self))
_status = .closed
delegate?.didDisconnectWith(socket: self)
}
/**
Disconnect the socket immediately.
*/
public override func forceDisconnect(becauseOf error: Error? = nil) {
guard !isCancelled else {
return
}
_cancelled = true
session.disconnected(becauseOf: error, by: .adapter)
observer?.signal(.forceDisconnectCalled(self))
_status = .closed
delegate?.didDisconnectWith(socket: self)
}
}

View File

@@ -1,112 +0,0 @@
import Foundation
public class SOCKS5Adapter: AdapterSocket {
enum SOCKS5AdapterStatus {
case invalid,
connecting,
readingMethodResponse,
readingResponseFirstPart,
readingResponseSecondPart,
forwarding
}
public let serverHost: String
public let serverPort: Int
var internalStatus: SOCKS5AdapterStatus = .invalid
let helloData = Data([0x05, 0x01, 0x00])
public enum ReadTag: Int {
case methodResponse = -20000, connectResponseFirstPart, connectResponseSecondPart
}
public enum WriteTag: Int {
case open = -21000, connectIPv4, connectIPv6, connectDomainLength, connectPort
}
public init(serverHost: String, serverPort: Int) {
self.serverHost = serverHost
self.serverPort = serverPort
super.init()
}
public override func openSocketWith(session: ConnectSession) {
super.openSocketWith(session: session)
guard !isCancelled else {
return
}
do {
internalStatus = .connecting
try socket.connectTo(host: serverHost, port: serverPort, enableTLS: false, tlsSettings: nil)
} catch {}
}
public override func didConnectWith(socket: RawTCPSocketProtocol) {
super.didConnectWith(socket: socket)
write(data: helloData)
internalStatus = .readingMethodResponse
socket.readDataTo(length: 2)
}
public override func didRead(data: Data, from socket: RawTCPSocketProtocol) {
super.didRead(data: data, from: socket)
switch internalStatus {
case .readingMethodResponse:
var response: [UInt8]
if session.isIPv4() {
response = [0x05, 0x01, 0x00, 0x01]
let address = IPAddress(fromString: session.host)!
response += [UInt8](address.dataInNetworkOrder)
} else if session.isIPv6() {
response = [0x05, 0x01, 0x00, 0x04]
let address = IPAddress(fromString: session.host)!
response += [UInt8](address.dataInNetworkOrder)
} else {
response = [0x05, 0x01, 0x00, 0x03]
response.append(UInt8(session.host.utf8.count))
response += [UInt8](session.host.utf8)
}
let portBytes: [UInt8] = Utils.toByteArray(UInt16(session.port)).reversed()
response.append(contentsOf: portBytes)
write(data: Data(response))
internalStatus = .readingResponseFirstPart
socket.readDataTo(length: 5)
case .readingResponseFirstPart:
var readLength = 0
switch data[3] {
case 1:
readLength = 3 + 2
case 3:
readLength = Int(data[4]) + 2
case 4:
readLength = 15 + 2
default:
break
}
internalStatus = .readingResponseSecondPart
socket.readDataTo(length: readLength)
case .readingResponseSecondPart:
internalStatus = .forwarding
observer?.signal(.readyForForward(self))
delegate?.didBecomeReadyToForwardWith(socket: self)
case .forwarding:
delegate?.didRead(data: data, from: self)
default:
return
}
}
override open func didWrite(data: Data?, by socket: RawTCPSocketProtocol) {
super.didWrite(data: data, by: socket)
if internalStatus == .forwarding {
delegate?.didWrite(data: data, by: self)
}
}
}

View File

@@ -1,9 +0,0 @@
import Foundation
/// This adapter connects to remote host through a HTTP proxy with SSL.
public class SecureHTTPAdapter: HTTPAdapter {
override public init(serverHost: String, serverPort: Int, auth: HTTPAuthentication?) {
super.init(serverHost: serverHost, serverPort: serverPort, auth: auth)
secured = true
}
}

View File

@@ -1,113 +0,0 @@
import Foundation
/// This class just forwards data directly.
/// - note: It is designed to work with tun2socks only.
public class DirectProxySocket: ProxySocket {
enum DirectProxyReadStatus: CustomStringConvertible {
case invalid,
forwarding,
stopped
var description: String {
switch self {
case .invalid:
return "invalid"
case .forwarding:
return "forwarding"
case .stopped:
return "stopped"
}
}
}
enum DirectProxyWriteStatus {
case invalid,
forwarding,
stopped
var description: String {
switch self {
case .invalid:
return "invalid"
case .forwarding:
return "forwarding"
case .stopped:
return "stopped"
}
}
}
private var readStatus: DirectProxyReadStatus = .invalid
private var writeStatus: DirectProxyWriteStatus = .invalid
public var readStatusDescription: String {
return readStatus.description
}
public var writeStatusDescription: String {
return writeStatus.description
}
/**
Begin reading and processing data from the socket.
- note: Since there is nothing to read and process before forwarding data, this just calls `delegate?.didReceiveRequest`.
*/
override public func openSocket() {
super.openSocket()
guard !isCancelled else {
return
}
if let address = socket.destinationIPAddress, let port = socket.destinationPort {
session = ConnectSession(host: address.presentation, port: Int(port.value))
observer?.signal(.receivedRequest(session!, on: self))
delegate?.didReceive(session: session!, from: self)
} else {
forceDisconnect()
}
}
/**
Response to the `AdapterSocket` on the other side of the `Tunnel` which has succefully connected to the remote server.
- parameter adapter: The `AdapterSocket`.
*/
override public func respondTo(adapter: AdapterSocket) {
super.respondTo(adapter: adapter)
guard !isCancelled else {
return
}
readStatus = .forwarding
writeStatus = .forwarding
observer?.signal(.readyForForward(self))
delegate?.didBecomeReadyToForwardWith(socket: self)
}
/**
The socket did read some data.
- parameter data: The data read from the socket.
- parameter from: The socket where the data is read from.
*/
override open func didRead(data: Data, from: RawTCPSocketProtocol) {
super.didRead(data: data, from: from)
delegate?.didRead(data: data, from: self)
}
/**
The socket did send some data.
- parameter data: The data which have been sent to remote (acknowledged). Note this may not be available since the data may be released to save memory.
- parameter by: The socket where the data is sent out.
*/
override open func didWrite(data: Data?, by: RawTCPSocketProtocol) {
super.didWrite(data: data, by: by)
delegate?.didWrite(data: data, by: self)
}
}

View File

@@ -1,244 +0,0 @@
import Foundation
public class SOCKS5ProxySocket: ProxySocket {
enum SOCKS5ProxyReadStatus: CustomStringConvertible {
case invalid,
readingVersionIdentifierAndNumberOfMethods,
readingMethods,
readingConnectHeader,
readingIPv4Address,
readingDomainLength,
readingDomain,
readingIPv6Address,
readingPort,
forwarding,
stopped
var description: String {
switch self {
case .invalid:
return "invalid"
case .readingVersionIdentifierAndNumberOfMethods:
return "reading version and methods"
case .readingMethods:
return "reading methods"
case .readingConnectHeader:
return "reading connect header"
case .readingIPv4Address:
return "IPv4 address"
case .readingDomainLength:
return "domain length"
case .readingDomain:
return "domain"
case .readingIPv6Address:
return "IPv6 address"
case .readingPort:
return "reading port"
case .forwarding:
return "forwarding"
case .stopped:
return "stopped"
}
}
}
enum SOCKS5ProxyWriteStatus: CustomStringConvertible {
case invalid,
sendingResponse,
forwarding,
stopped
var description: String {
switch self {
case .invalid:
return "invalid"
case .sendingResponse:
return "sending response"
case .forwarding:
return "forwarding"
case .stopped:
return "stopped"
}
}
}
/// The remote host to connect to.
public var destinationHost: String!
/// The remote port to connect to.
public var destinationPort: Int!
private var readStatus: SOCKS5ProxyReadStatus = .invalid
private var writeStatus: SOCKS5ProxyWriteStatus = .invalid
public var readStatusDescription: String {
return readStatus.description
}
public var writeStatusDescription: String {
return writeStatus.description
}
/**
Begin reading and processing data from the socket.
*/
override public func openSocket() {
super.openSocket()
guard !isCancelled else {
return
}
readStatus = .readingVersionIdentifierAndNumberOfMethods
socket.readDataTo(length: 2)
}
// swiftlint:disable function_body_length
// swiftlint:disable cyclomatic_complexity
/**
The socket did read some data.
- parameter data: The data read from the socket.
- parameter from: The socket where the data is read from.
*/
override public func didRead(data: Data, from: RawTCPSocketProtocol) {
super.didRead(data: data, from: from)
switch readStatus {
case .forwarding:
delegate?.didRead(data: data, from: self)
case .readingVersionIdentifierAndNumberOfMethods:
data.withUnsafeBytes { pointer in
let p = pointer.bindMemory(to: Int8.self)
guard p.baseAddress!.pointee == 5 else {
// TODO: notify observer
self.disconnect()
return
}
guard p.baseAddress!.successor().pointee > 0 else {
// TODO: notify observer
self.disconnect()
return
}
self.readStatus = .readingMethods
self.socket.readDataTo(length: Int(p.baseAddress!.successor().pointee))
}
case .readingMethods:
// TODO: check for 0x00 in read data
let response = Data([0x05, 0x00])
// we would not be able to read anything before the data is written out, so no need to handle the dataWrote event.
write(data: response)
readStatus = .readingConnectHeader
socket.readDataTo(length: 4)
case .readingConnectHeader:
data.withUnsafeBytes { pointer in
let p = pointer.bindMemory(to: Int8.self)
guard p.baseAddress!.pointee == 5 && p.baseAddress!.successor().pointee == 1 else {
// TODO: notify observer
self.disconnect()
return
}
switch p.baseAddress!.advanced(by: 3).pointee {
case 1:
readStatus = .readingIPv4Address
socket.readDataTo(length: 4)
case 3:
readStatus = .readingDomainLength
socket.readDataTo(length: 1)
case 4:
readStatus = .readingIPv6Address
socket.readDataTo(length: 16)
default:
break
}
}
case .readingIPv4Address:
var address = Data(count: Int(INET_ADDRSTRLEN))
_ = data.withUnsafeBytes { data_ptr in
address.withUnsafeMutableBytes { addr_ptr in
inet_ntop(AF_INET, data_ptr.baseAddress!, addr_ptr.bindMemory(to: Int8.self).baseAddress!, socklen_t(INET_ADDRSTRLEN))
}
}
destinationHost = String(data: address, encoding: .utf8)
readStatus = .readingPort
socket.readDataTo(length: 2)
case .readingIPv6Address:
var address = Data(count: Int(INET6_ADDRSTRLEN))
_ = data.withUnsafeBytes { data_ptr in
address.withUnsafeMutableBytes { addr_ptr in
inet_ntop(AF_INET6, data_ptr.baseAddress!, addr_ptr.bindMemory(to: Int8.self).baseAddress!, socklen_t(INET6_ADDRSTRLEN))
}
}
destinationHost = String(data: address, encoding: .utf8)
readStatus = .readingPort
socket.readDataTo(length: 2)
case .readingDomainLength:
readStatus = .readingDomain
socket.readDataTo(length: Int(data.first!))
case .readingDomain:
destinationHost = String(data: data, encoding: .utf8)
readStatus = .readingPort
socket.readDataTo(length: 2)
case .readingPort:
data.withUnsafeBytes {
destinationPort = Int($0.load(as: UInt16.self).bigEndian)
}
readStatus = .forwarding
session = ConnectSession(host: destinationHost, port: destinationPort)
observer?.signal(.receivedRequest(session!, on: self))
delegate?.didReceive(session: session!, from: self)
default:
return
}
}
/**
The socket did send some data.
- parameter data: The data which have been sent to remote (acknowledged). Note this may not be available since the data may be released to save memory.
- parameter from: The socket where the data is sent out.
*/
override public func didWrite(data: Data?, by: RawTCPSocketProtocol) {
super.didWrite(data: data, by: by)
switch writeStatus {
case .forwarding:
delegate?.didWrite(data: data, by: self)
case .sendingResponse:
writeStatus = .forwarding
observer?.signal(.readyForForward(self))
delegate?.didBecomeReadyToForwardWith(socket: self)
default:
return
}
}
/**
Response to the `AdapterSocket` on the other side of the `Tunnel` which has succefully connected to the remote server.
- parameter adapter: The `AdapterSocket`.
*/
override public func respondTo(adapter: AdapterSocket) {
super.respondTo(adapter: adapter)
guard !isCancelled else {
return
}
var responseBytes = [UInt8](repeating: 0, count: 10)
responseBytes[0...3] = [0x05, 0x00, 0x00, 0x01]
let responseData = Data(responseBytes)
writeStatus = .sendingResponse
write(data: responseData)
}
}

View File

@@ -170,9 +170,7 @@ public class Tunnel: NSObject, SocketDelegate {
return
}
let manager = RuleManager.currentManager
let factory = manager.match(session)!
adapterSocket = factory.getAdapterFor(session: session)
adapterSocket = DirectAdapterFactory().getAdapterFor(session: session)
adapterSocket!.delegate = self
adapterSocket!.openSocketWith(session: session)
}

9
GlassVPN/zhuhaow-Resolver/License vendored Normal file
View 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.

78
README.md Normal file
View File

@@ -0,0 +1,78 @@
AppChk Privacy Monitor
==========================
A pocket DNS monitor and network filter.
![screenshot](doc/screenshot.png)
## What is it?
AppChk helps you identify applications that communicate with other parties.
Join the [Testflight beta][testflight] or look at the evaluation results [appchk.de].
### How does it work?
AppChk creates a local VPN proxy to intercept all network connections.
For each connection, AppChk looks into the DNS headers only, namely the domain names.
These domain names are logged in the background while the VPN is running.
AppChk does not need to be active all the time.
### What about privacy?
Your data belongs to you.
Therefore, monitoring takes place on your device only.
AppChk learns only the destination addresses, not the actual data that is exchanged.
The app does not share any data with us or any other third-party unless you choose to.
### How can I contribute?
AppChk allows you to record app-specific activity.
You can share these recordings with the community; it can help you and others avoid phony applications, even before you install an app.
Join the [Testflight beta][testflight]
## Features
- See outgoing (DNS) network requests in real-time
- See the history of previous connections
- Block unwanted traffic based on domain names
- Record app-specific activity<sup>1</sup>
- Apply logging filters (block or ignore) and display filters (specific range or last x minutes)
- Sort results by time, name, or occurrence count
- Context Analysis
- What other domains often occur at the same time?
- What happened immediately before or after the action?
- Export results for custom analysis
- Alert Monitor & reminder
- Participate in privacy research
- Contribute your results
- See what others have unveiled
- How much traffic does this app produce?
<sup>1</sup> Due to technical limitations, recordings can not be restricted to a single application. Remember to force-quit all other applications before starting a recording.
## Research Project
This research project is an effort to shine a light on the background activity of iOS apps, making the otherwise hidden network connections visible to everyone.
The goal is to make privacy more accessible to the general public.
And thus create incentives for app developers to respect users' privacy.
We want to offer users, activists, data protection authorities, and data protection officers an easily accessible and flexible tool to assess the privacy measures of iOS applications.
AppChk allows users to:
- get a visual overview of an apps communication signature
- assess how an app ranks within its peer group or category
- influence the ranking according to their preferences
The evaluation results page is at [appchk.de] and the research paper at [arxiv](https://arxiv.org/abs/2104.06167).
[testflight]: https://testflight.apple.com/join/9jjaFeHO
[appchk.de]: https://appchk.de/

BIN
doc/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

View File

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

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

Binary file not shown.

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 423 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 572 B

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 400 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 530 B

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 510 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 701 B

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 B

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 B

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 695 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 409 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 B

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

View File

@@ -1,779 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="16096" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="sfA-EG-18J">
<device id="retina4_0" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="16087"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--Main-->
<scene sceneID="7Rl-BK-ry5">
<objects>
<tabBarController id="sfA-EG-18J" customClass="TBCMain" customModule="AppCheck" customModuleProvider="target" sceneMemberID="viewController">
<tabBar key="tabBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" id="qza-ey-Iaz">
<rect key="frame" x="0.0" y="0.0" width="414" height="49"/>
<autoresizingMask key="autoresizingMask"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</tabBar>
<connections>
<segue destination="RcB-4v-fd4" kind="relationship" relationship="viewControllers" id="cmC-pu-5n2"/>
<segue destination="hm5-7q-Zfi" kind="relationship" relationship="viewControllers" id="pfK-BR-9lf"/>
<segue destination="dIk-JY-9vE" kind="relationship" relationship="viewControllers" id="AwW-3j-iAg"/>
</connections>
</tabBarController>
<placeholder placeholderIdentifier="IBFirstResponder" id="RDz-8t-yhN" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="-819" y="150"/>
</scene>
<!--Requests-->
<scene sceneID="bDO-X1-bCe">
<objects>
<navigationController id="RcB-4v-fd4" sceneMemberID="viewController">
<tabBarItem key="tabBarItem" title="Requests" image="journal" id="Sj5-Kb-Li8"/>
<navigationBar key="navigationBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" id="HWd-73-m8j">
<rect key="frame" x="0.0" y="0.0" width="320" height="44"/>
<autoresizingMask key="autoresizingMask"/>
</navigationBar>
<connections>
<segue destination="pdd-aM-sKl" kind="relationship" relationship="rootViewController" id="oMe-a0-xN7"/>
</connections>
</navigationController>
<placeholder placeholderIdentifier="IBFirstResponder" id="8j4-AX-JBN" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="0.0" y="-1250"/>
</scene>
<!--Domains-->
<scene sceneID="MN1-aZ-cZt">
<objects>
<tableViewController id="pdd-aM-sKl" customClass="TVCDomains" customModule="AppCheck" customModuleProvider="target" sceneMemberID="viewController">
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="prototypes" style="plain" separatorStyle="default" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="28" sectionFooterHeight="28" id="kj3-8X-TyT">
<rect key="frame" x="0.0" y="0.0" width="320" height="568"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<prototypes>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" selectionStyle="default" accessoryType="disclosureIndicator" hidesAccessoryWhenEditing="NO" indentationWidth="10" reuseIdentifier="DomainCell" textLabel="0HB-5f-eB1" detailTextLabel="MRe-Eq-gvc" style="IBUITableViewCellStyleSubtitle" id="F8D-aK-j1W">
<rect key="frame" x="0.0" y="28" width="320" height="55.5"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="F8D-aK-j1W" id="FY2-xr-hqh">
<rect key="frame" x="0.0" y="0.0" width="293" height="55.5"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Title" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="0HB-5f-eB1">
<rect key="frame" x="16" y="10" width="33.5" height="20.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Subtitle" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="MRe-Eq-gvc">
<rect key="frame" x="16" y="31.5" width="44" height="14.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" type="system" pointSize="12"/>
<color key="textColor" systemColor="secondaryLabelColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</tableViewCellContentView>
<connections>
<segue destination="WcC-nb-Vf5" kind="push" id="EVQ-hO-JE9"/>
</connections>
</tableViewCell>
</prototypes>
<connections>
<outlet property="dataSource" destination="pdd-aM-sKl" id="4fX-iP-7Oa"/>
<outlet property="delegate" destination="pdd-aM-sKl" id="3RN-az-SYU"/>
</connections>
</tableView>
<navigationItem key="navigationItem" title="Domains" id="nY5-jL-QT9"/>
</tableViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="jfx-iA-E0v" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="700" y="-1250"/>
</scene>
<!--Hosts-->
<scene sceneID="ZCV-Yx-jjW">
<objects>
<tableViewController id="WcC-nb-Vf5" customClass="TVCHosts" customModule="AppCheck" customModuleProvider="target" sceneMemberID="viewController">
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="prototypes" style="plain" separatorStyle="default" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="28" sectionFooterHeight="28" id="nRF-dc-dC2">
<rect key="frame" x="0.0" y="0.0" width="320" height="568"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<prototypes>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" accessoryType="disclosureIndicator" indentationWidth="10" reuseIdentifier="HostCell" textLabel="Rnk-SP-UHm" detailTextLabel="ovQ-lJ-hWJ" style="IBUITableViewCellStyleSubtitle" id="uv0-9B-Zbb">
<rect key="frame" x="0.0" y="28" width="320" height="55.5"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="uv0-9B-Zbb" id="6vH-Du-gCg">
<rect key="frame" x="0.0" y="0.0" width="293" height="55.5"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Title" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="Rnk-SP-UHm">
<rect key="frame" x="16" y="10" width="33.5" height="20.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Subtitle" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="ovQ-lJ-hWJ">
<rect key="frame" x="16" y="31.5" width="44" height="14.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" type="system" pointSize="12"/>
<color key="textColor" systemColor="secondaryLabelColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</tableViewCellContentView>
<connections>
<segue destination="h7Z-Qr-pJ5" kind="push" id="TPa-Zn-eOs"/>
</connections>
</tableViewCell>
</prototypes>
<connections>
<outlet property="dataSource" destination="WcC-nb-Vf5" id="szM-iI-Jgi"/>
<outlet property="delegate" destination="WcC-nb-Vf5" id="sBd-BW-Wg6"/>
</connections>
</tableView>
<navigationItem key="navigationItem" title="Hosts" prompt="com.app.Example" id="TvD-8U-F05"/>
</tableViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="Gdi-Xi-JUL" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="1400" y="-1250"/>
</scene>
<!--Occurrences-->
<scene sceneID="ws3-sK-l8m">
<objects>
<tableViewController id="h7Z-Qr-pJ5" customClass="TVCHostDetails" customModule="AppCheck" customModuleProvider="target" sceneMemberID="viewController">
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="prototypes" style="plain" separatorStyle="default" allowsSelection="NO" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="28" sectionFooterHeight="28" id="4ms-FO-Fge">
<rect key="frame" x="0.0" y="0.0" width="320" height="568"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<prototypes>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" reuseIdentifier="HostDetailCell" textLabel="J2P-mU-Vad" style="IBUITableViewCellStyleDefault" id="ZCA-Dz-i92">
<rect key="frame" x="0.0" y="28" width="320" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="ZCA-Dz-i92" id="nxe-48-jAQ">
<rect key="frame" x="0.0" y="0.0" width="320" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Title" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" enabled="NO" adjustsFontSizeToFit="NO" id="J2P-mU-Vad">
<rect key="frame" x="16" y="0.0" width="288" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</tableViewCellContentView>
</tableViewCell>
</prototypes>
<connections>
<outlet property="dataSource" destination="h7Z-Qr-pJ5" id="fyW-Av-fWY"/>
<outlet property="delegate" destination="h7Z-Qr-pJ5" id="gBq-jA-u5V"/>
</connections>
</tableView>
<navigationItem key="navigationItem" title="Occurrences" prompt="com.domain.network.cdn" id="bys-2u-rHs"/>
</tableViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="UxH-PH-KQy" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="2100" y="-1250"/>
</scene>
<!--Recordings-->
<scene sceneID="ODR-PD-nTU">
<objects>
<viewController id="hm5-7q-Zfi" customClass="VCRecordings" customModule="AppCheck" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="JYr-yE-eGS">
<rect key="frame" x="0.0" y="0.0" width="320" height="568"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="Wz5-zb-gwz">
<rect key="frame" x="0.0" y="0.0" width="320" height="519"/>
<subviews>
<view clipsSubviews="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="La3-9e-6TK" userLabel="NewRec">
<rect key="frame" x="0.0" y="0.0" width="320" height="90"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="00:00.000" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="rbR-np-cXD">
<rect key="frame" x="0.0" y="0.0" width="320" height="54"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" flexibleMaxY="YES"/>
<accessibility key="accessibilityConfiguration">
<accessibilityTraits key="traits" staticText="YES" updatesFrequently="YES"/>
</accessibility>
<fontDescription key="fontDescription" type="system" weight="ultraLight" pointSize="32"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" id="vAq-EZ-Gmx">
<rect key="frame" x="0.0" y="54" width="320" height="36"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" flexibleMaxY="YES"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleTitle2"/>
<state key="normal" title="Stop Recording">
<color key="titleColor" systemColor="systemRedColor" red="1" green="0.23137254900000001" blue="0.18823529410000001" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</state>
<connections>
<action selector="startRecordingButtonTapped:" destination="hm5-7q-Zfi" eventType="touchUpInside" id="hEp-UI-i6R"/>
</connections>
</button>
</subviews>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<constraints>
<constraint firstAttribute="height" constant="90" id="bqy-bR-yVI"/>
</constraints>
</view>
<containerView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="v3Z-HR-abM">
<rect key="frame" x="0.0" y="98" width="320" height="421"/>
<connections>
<segue destination="C7Q-Vu-xAC" kind="embed" id="ZTW-t1-5G1"/>
</connections>
</containerView>
</subviews>
</stackView>
</subviews>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<constraints>
<constraint firstItem="Wz5-zb-gwz" firstAttribute="leading" secondItem="lFq-fl-zah" secondAttribute="leading" id="Sjv-qq-h50"/>
<constraint firstItem="Wz5-zb-gwz" firstAttribute="trailing" secondItem="lFq-fl-zah" secondAttribute="trailing" id="hhA-jU-DJS"/>
<constraint firstItem="Wz5-zb-gwz" firstAttribute="bottom" secondItem="lFq-fl-zah" secondAttribute="bottom" id="m6I-NP-LhY"/>
<constraint firstItem="Wz5-zb-gwz" firstAttribute="top" secondItem="lFq-fl-zah" secondAttribute="top" id="pEc-Cz-v9f"/>
</constraints>
<viewLayoutGuide key="safeArea" id="lFq-fl-zah"/>
</view>
<tabBarItem key="tabBarItem" title="Recordings" image="tag" id="mGk-aq-MRP"/>
<connections>
<outlet property="startButton" destination="vAq-EZ-Gmx" id="FSo-GH-jtd"/>
<outlet property="startNewRecView" destination="La3-9e-6TK" id="I5w-aK-DlY"/>
<outlet property="timeLabel" destination="rbR-np-cXD" id="EEe-8F-HT6"/>
</connections>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="Wfy-Tp-A9o" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="0.0" y="-550"/>
</scene>
<!--Navigation Controller-->
<scene sceneID="GQx-dK-qb5">
<objects>
<navigationController id="C7Q-Vu-xAC" sceneMemberID="viewController">
<navigationItem key="navigationItem" id="mCN-Hk-Z5i"/>
<navigationBar key="navigationBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" translucent="NO" id="ByI-P4-oVv">
<rect key="frame" x="0.0" y="0.0" width="320" height="44"/>
<autoresizingMask key="autoresizingMask"/>
</navigationBar>
<toolbar key="toolbar" opaque="NO" clearsContextBeforeDrawing="NO" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" id="NtQ-rp-G6c">
<autoresizingMask key="autoresizingMask"/>
</toolbar>
<connections>
<segue destination="Fln-DD-aId" kind="relationship" relationship="rootViewController" id="smF-1g-aDM"/>
</connections>
</navigationController>
<placeholder placeholderIdentifier="IBFirstResponder" id="GEP-3e-6Ko" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="700" y="-550"/>
</scene>
<!--Previous Recordings-->
<scene sceneID="RqA-Jc-FDE">
<objects>
<tableViewController id="Fln-DD-aId" customClass="TVCPreviousRecords" customModule="AppCheck" customModuleProvider="target" sceneMemberID="viewController">
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="prototypes" style="plain" separatorStyle="default" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="28" sectionFooterHeight="28" id="7cH-g6-H5z">
<rect key="frame" x="0.0" y="0.0" width="320" height="377"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<prototypes>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" accessoryType="detailButton" indentationWidth="10" reuseIdentifier="PreviousRecordCell" textLabel="hr0-Xt-5gV" detailTextLabel="Xav-Ub-clj" style="IBUITableViewCellStyleSubtitle" id="3kW-3B-1bx">
<rect key="frame" x="0.0" y="28" width="320" height="55.5"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="3kW-3B-1bx" id="OKV-a6-jjd">
<rect key="frame" x="0.0" y="0.0" width="280" height="55.5"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Title" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="hr0-Xt-5gV">
<rect key="frame" x="16" y="10" width="33.5" height="20.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Subtitle" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="Xav-Ub-clj">
<rect key="frame" x="16" y="31.5" width="44" height="14.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" type="system" pointSize="12"/>
<color key="textColor" systemColor="secondaryLabelColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</tableViewCellContentView>
<connections>
<segue destination="50g-BI-Q6S" kind="push" identifier="openRecordDetailsSegue" id="arP-jR-O9d"/>
</connections>
</tableViewCell>
</prototypes>
<connections>
<outlet property="dataSource" destination="Fln-DD-aId" id="oHb-mU-M1Z"/>
<outlet property="delegate" destination="Fln-DD-aId" id="6PY-c0-Nfp"/>
</connections>
</tableView>
<navigationItem key="navigationItem" title="Previous Recordings" id="ow1-cy-qXt"/>
<connections>
<segue destination="VRk-wv-rhk" kind="modal" identifier="editRecordSegue" id="8rY-sA-Iig"/>
</connections>
</tableViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="Lta-uo-x4m" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="1400" y="-550"/>
</scene>
<!--Edit Recording-->
<scene sceneID="pqx-CU-4AP">
<objects>
<viewController id="VRk-wv-rhk" customClass="VCEditRecording" customModule="AppCheck" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="rXz-Mk-wrK">
<rect key="frame" x="0.0" y="0.0" width="320" height="421"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<navigationBar contentMode="scaleToFill" translucent="NO" translatesAutoresizingMaskIntoConstraints="NO" id="2yS-xK-Wac">
<rect key="frame" x="0.0" y="0.0" width="320" height="44"/>
<gestureRecognizers/>
<items>
<navigationItem title="Edit" id="JSi-oz-VRx">
<barButtonItem key="leftBarButtonItem" systemItem="cancel" id="TGg-60-wZW">
<connections>
<action selector="didTapCancel:" destination="VRk-wv-rhk" id="Kff-ed-gdd"/>
</connections>
</barButtonItem>
<barButtonItem key="rightBarButtonItem" enabled="NO" style="done" systemItem="save" id="rWg-hE-Ydl">
<connections>
<action selector="didTapSave:" destination="VRk-wv-rhk" id="Xee-qo-bQx"/>
</connections>
</barButtonItem>
</navigationItem>
</items>
<connections>
<outletCollection property="gestureRecognizers" destination="klV-Ed-xzV" appends="YES" id="Huf-jb-4Ef"/>
</connections>
</navigationBar>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" alignment="center" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="xdn-EU-IMx">
<rect key="frame" x="16" y="56" width="288" height="355"/>
<subviews>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Guy-Ra-fpS" userLabel="Title">
<rect key="frame" x="0.0" y="0.0" width="288" height="58"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Title" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="Et0-8d-CId">
<rect key="frame" x="0.0" y="0.0" width="288" height="24"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" flexibleMaxY="YES"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleTitle2"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<textField opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="left" contentVerticalAlignment="center" placeholder="Unnamed Recording #12345678" textAlignment="natural" minimumFontSize="17" clearButtonMode="whileEditing" id="OCX-wu-l5d">
<rect key="frame" x="4" y="24" width="280" height="34"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" flexibleMaxY="YES"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<textInputTraits key="textInputTraits" returnKeyType="next"/>
<connections>
<outlet property="delegate" destination="VRk-wv-rhk" id="uJL-hB-9w7"/>
</connections>
</textField>
</subviews>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<constraints>
<constraint firstAttribute="height" constant="58" id="5ew-Cq-VKh"/>
</constraints>
</view>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="ybL-UG-dwT" userLabel="Notes">
<rect key="frame" x="0.0" y="66" width="288" height="190"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Notes" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="QJp-6C-yoZ">
<rect key="frame" x="0.0" y="0.0" width="288" height="24"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" flexibleMaxY="YES"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleTitle2"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" showsHorizontalScrollIndicator="NO" translatesAutoresizingMaskIntoConstraints="NO" id="NXU-yU-eST">
<rect key="frame" x="0.0" y="24" width="288" height="166"/>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<string key="text">1. Line
2. Line
3. Line
4. Line</string>
<color key="textColor" systemColor="labelColor" cocoaTouchSystemColor="darkTextColor"/>
<fontDescription key="fontDescription" type="system" pointSize="14"/>
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
<connections>
<outlet property="delegate" destination="VRk-wv-rhk" id="vej-jI-13V"/>
</connections>
</textView>
</subviews>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<constraints>
<constraint firstItem="NXU-yU-eST" firstAttribute="leading" secondItem="ybL-UG-dwT" secondAttribute="leading" id="D6U-8L-f9m"/>
<constraint firstAttribute="height" relation="greaterThanOrEqual" priority="750" constant="107" id="Pfy-uW-kRl"/>
<constraint firstItem="NXU-yU-eST" firstAttribute="trailing" secondItem="ybL-UG-dwT" secondAttribute="trailing" id="eBc-6g-nWr"/>
<constraint firstItem="NXU-yU-eST" firstAttribute="top" secondItem="QJp-6C-yoZ" secondAttribute="bottom" id="mnZ-WQ-LX8"/>
<constraint firstItem="NXU-yU-eST" firstAttribute="bottom" secondItem="ybL-UG-dwT" secondAttribute="bottom" id="vFS-tG-E43"/>
</constraints>
</view>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="QiY-Mm-Dej" userLabel="Details">
<rect key="frame" x="0.0" y="264" width="288" height="91"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Details" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="FR1-Nt-XuB">
<rect key="frame" x="0.0" y="0.0" width="288" height="24"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" flexibleMaxY="YES"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleTitle2"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<textView clipsSubviews="YES" multipleTouchEnabled="YES" userInteractionEnabled="NO" contentMode="scaleToFill" fixedFrame="YES" bounces="NO" scrollEnabled="NO" showsHorizontalScrollIndicator="NO" showsVerticalScrollIndicator="NO" bouncesZoom="NO" editable="NO" selectable="NO" translatesAutoresizingMaskIntoConstraints="NO" id="pql-H5-k6U">
<rect key="frame" x="0.0" y="24" width="288" height="67"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" flexibleMaxY="YES"/>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<string key="text">Start: 1970-01-01 01:00
End: 1970-01-01 02:00
Duration: 60:00</string>
<color key="textColor" systemColor="labelColor" cocoaTouchSystemColor="darkTextColor"/>
<fontDescription key="fontDescription" type="system" weight="light" pointSize="14"/>
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
</textView>
</subviews>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<constraints>
<constraint firstAttribute="height" priority="250" constant="91" id="or7-9o-FZb"/>
</constraints>
</view>
</subviews>
<constraints>
<constraint firstItem="ybL-UG-dwT" firstAttribute="width" secondItem="Guy-Ra-fpS" secondAttribute="width" id="PUH-xO-ZbD"/>
<constraint firstItem="QiY-Mm-Dej" firstAttribute="width" secondItem="Guy-Ra-fpS" secondAttribute="width" id="U6e-10-j55"/>
<constraint firstItem="Guy-Ra-fpS" firstAttribute="width" secondItem="xdn-EU-IMx" secondAttribute="width" id="ZCJ-ol-1Jv"/>
</constraints>
</stackView>
</subviews>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<constraints>
<constraint firstItem="2yS-xK-Wac" firstAttribute="trailing" secondItem="fMa-Lq-tGz" secondAttribute="trailing" id="1io-bA-4p9"/>
<constraint firstItem="2yS-xK-Wac" firstAttribute="leading" secondItem="fMa-Lq-tGz" secondAttribute="leading" id="Fv1-fO-22V"/>
<constraint firstItem="xdn-EU-IMx" firstAttribute="leading" secondItem="fMa-Lq-tGz" secondAttribute="leading" constant="16" id="JuR-Ro-IPi"/>
<constraint firstItem="xdn-EU-IMx" firstAttribute="top" secondItem="2yS-xK-Wac" secondAttribute="bottom" constant="12" id="Lec-83-aaD"/>
<constraint firstItem="xdn-EU-IMx" firstAttribute="trailing" secondItem="fMa-Lq-tGz" secondAttribute="trailing" constant="-16" id="hhC-bL-G3S"/>
<constraint firstItem="xdn-EU-IMx" firstAttribute="bottom" secondItem="fMa-Lq-tGz" secondAttribute="bottom" constant="-10" id="p7W-sr-Wch"/>
<constraint firstItem="2yS-xK-Wac" firstAttribute="top" secondItem="fMa-Lq-tGz" secondAttribute="top" id="yKh-gv-mgg"/>
</constraints>
<viewLayoutGuide key="safeArea" id="fMa-Lq-tGz"/>
</view>
<connections>
<outlet property="buttonCancel" destination="TGg-60-wZW" id="5Ej-7t-jaD"/>
<outlet property="buttonSave" destination="rWg-hE-Ydl" id="zfM-kx-erX"/>
<outlet property="inputDetails" destination="pql-H5-k6U" id="NXm-8f-5E6"/>
<outlet property="inputNotes" destination="NXU-yU-eST" id="c2n-cG-aLq"/>
<outlet property="inputTitle" destination="OCX-wu-l5d" id="PeC-F5-4mx"/>
<outlet property="noteBottom" destination="vFS-tG-E43" id="Bxh-Tl-E2U"/>
</connections>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="KN7-F1-BOL" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
<tapGestureRecognizer id="klV-Ed-xzV">
<connections>
<action selector="hideKeyboard" destination="VRk-wv-rhk" id="iDb-kK-nli"/>
</connections>
</tapGestureRecognizer>
</objects>
<point key="canvasLocation" x="2100" y="-550"/>
</scene>
<!--Logs-->
<scene sceneID="DxJ-8o-gTM">
<objects>
<tableViewController id="50g-BI-Q6S" customClass="TVCRecordingDetails" customModule="AppCheck" customModuleProvider="target" sceneMemberID="viewController">
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="prototypes" style="plain" separatorStyle="default" allowsSelection="NO" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="28" sectionFooterHeight="28" id="cLV-Db-JxM">
<rect key="frame" x="0.0" y="0.0" width="320" height="377"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<prototypes>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" reuseIdentifier="PreviousRecordDetailCell" textLabel="rN0-kA-Eln" detailTextLabel="xRp-XG-oKf" style="IBUITableViewCellStyleValue1" id="ceT-cF-lLF">
<rect key="frame" x="0.0" y="28" width="320" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="ceT-cF-lLF" id="c5Y-xg-hSL">
<rect key="frame" x="0.0" y="0.0" width="320" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Title" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="rN0-kA-Eln">
<rect key="frame" x="16" y="12" width="33.5" height="20.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Detail" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="xRp-XG-oKf">
<rect key="frame" x="260" y="12" width="44" height="20.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<color key="textColor" systemColor="secondaryLabelColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</tableViewCellContentView>
</tableViewCell>
</prototypes>
<connections>
<outlet property="dataSource" destination="50g-BI-Q6S" id="SFM-IM-FRx"/>
<outlet property="delegate" destination="50g-BI-Q6S" id="LBY-sp-dg0"/>
</connections>
</tableView>
<navigationItem key="navigationItem" title="Logs" id="AXT-fV-keV"/>
</tableViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="lan-I9-b0a" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="2800" y="-550"/>
</scene>
<!--Settings-->
<scene sceneID="OEQ-fb-haL">
<objects>
<navigationController id="dIk-JY-9vE" sceneMemberID="viewController">
<tabBarItem key="tabBarItem" title="Settings" image="settings" id="dQu-wE-a8u"/>
<navigationBar key="navigationBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" id="yYW-rX-VnB">
<rect key="frame" x="0.0" y="0.0" width="320" height="44"/>
<autoresizingMask key="autoresizingMask"/>
</navigationBar>
<connections>
<segue destination="qdB-ZO-LHY" kind="relationship" relationship="rootViewController" id="qJW-Jc-O4D"/>
</connections>
</navigationController>
<placeholder placeholderIdentifier="IBFirstResponder" id="bg9-bR-vlx" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="0.0" y="150"/>
</scene>
<!--Settings-->
<scene sceneID="gEe-ny-NaU">
<objects>
<tableViewController id="qdB-ZO-LHY" customClass="TVCSettings" customModule="AppCheck" customModuleProvider="target" sceneMemberID="viewController">
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" bounces="NO" alwaysBounceVertical="YES" dataMode="static" style="grouped" separatorStyle="default" rowHeight="44" estimatedRowHeight="-1" sectionHeaderHeight="18" sectionFooterHeight="18" id="8kq-PY-wp7">
<rect key="frame" x="0.0" y="0.0" width="320" height="568"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<sections>
<tableViewSection headerTitle="VPN Proxy Settings" id="w58-6X-Jea">
<cells>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="ghM-ze-fvp">
<rect key="frame" x="0.0" y="55.5" width="320" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="ghM-ze-fvp" id="d2v-vz-QIB">
<rect key="frame" x="0.0" y="0.0" width="320" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<switch opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="750" verticalHuggingPriority="750" contentHorizontalAlignment="center" contentVerticalAlignment="center" on="YES" translatesAutoresizingMaskIntoConstraints="NO" id="kmY-ot-lJW">
<rect key="frame" x="255" y="6.5" width="51" height="31"/>
<connections>
<action selector="toggleVPNProxy:" destination="qdB-ZO-LHY" eventType="valueChanged" id="y95-2Z-Uep"/>
</connections>
</switch>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="VPN Proxy enabled" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Qha-4I-go0">
<rect key="frame" x="16" y="12" width="147" height="20"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<constraints>
<constraint firstItem="kmY-ot-lJW" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="Qha-4I-go0" secondAttribute="trailing" constant="8" id="Lnx-hC-xOx"/>
<constraint firstItem="kmY-ot-lJW" firstAttribute="trailing" secondItem="d2v-vz-QIB" secondAttribute="trailingMargin" id="Ylz-D4-hz4"/>
<constraint firstItem="Qha-4I-go0" firstAttribute="centerY" secondItem="d2v-vz-QIB" secondAttribute="centerY" id="dKE-By-qEu"/>
<constraint firstItem="kmY-ot-lJW" firstAttribute="centerY" secondItem="Qha-4I-go0" secondAttribute="centerY" id="dgh-tx-Y8a"/>
<constraint firstItem="Qha-4I-go0" firstAttribute="leading" secondItem="d2v-vz-QIB" secondAttribute="leadingMargin" id="rHx-0D-DPX"/>
</constraints>
</tableViewCellContentView>
</tableViewCell>
</cells>
</tableViewSection>
<tableViewSection headerTitle="Logging Filter" id="EcH-KA-eLE">
<cells>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" accessoryType="detailDisclosureButton" indentationWidth="10" reuseIdentifier="settingsIgnoredCell" textLabel="UdM-Zm-G9p" detailTextLabel="bHb-Tw-nPR" style="IBUITableViewCellStyleValue2" id="fZR-we-Y0k">
<rect key="frame" x="0.0" y="155.5" width="320" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="fZR-we-Y0k" id="eqc-fj-p0d">
<rect key="frame" x="0.0" y="0.0" width="261" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Ignore" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="UdM-Zm-G9p">
<rect key="frame" x="16" y="14" width="91" height="16"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" type="system" pointSize="13"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="0 Domains" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="bHb-Tw-nPR">
<rect key="frame" x="113" y="14" width="64.5" height="16"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" type="system" pointSize="13"/>
<color key="textColor" systemColor="secondaryLabelColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</tableViewCellContentView>
<connections>
<segue destination="q3B-Yi-1bx" kind="push" identifier="segueFilterIgnored" id="EzT-Xq-wka"/>
</connections>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" accessoryType="detailDisclosureButton" indentationWidth="10" reuseIdentifier="settingsBlockedCell" textLabel="fI0-Nt-Ucf" detailTextLabel="CGG-47-cdc" style="IBUITableViewCellStyleValue2" id="3pw-7c-M6R">
<rect key="frame" x="0.0" y="199.5" width="320" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="3pw-7c-M6R" id="Smv-n1-917">
<rect key="frame" x="0.0" y="0.0" width="261" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Block" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="fI0-Nt-Ucf">
<rect key="frame" x="16" y="14" width="91" height="16"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" type="system" pointSize="13"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="0 Domains" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="CGG-47-cdc">
<rect key="frame" x="113" y="14" width="64.5" height="16"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" type="system" pointSize="13"/>
<color key="textColor" systemColor="secondaryLabelColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</tableViewCellContentView>
<connections>
<segue destination="q3B-Yi-1bx" kind="push" identifier="segueFilterBlocked" id="cOY-j0-75m"/>
</connections>
</tableViewCell>
</cells>
</tableViewSection>
<tableViewSection headerTitle="Other Settings" id="wLR-T2-Qxm">
<cells>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="Qyy-0U-yhd">
<rect key="frame" x="0.0" y="299.5" width="320" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="Qyy-0U-yhd" id="Mfs-fu-W5k">
<rect key="frame" x="0.0" y="0.0" width="320" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="9Ko-sD-7x0">
<rect key="frame" x="125" y="7" width="70" height="30"/>
<state key="normal" title="Export DB"/>
<connections>
<action selector="exportDB:" destination="qdB-ZO-LHY" eventType="touchUpInside" id="3gu-WF-3Xa"/>
</connections>
</button>
</subviews>
<constraints>
<constraint firstItem="9Ko-sD-7x0" firstAttribute="centerX" secondItem="Mfs-fu-W5k" secondAttribute="centerX" id="LzG-xg-XTg"/>
<constraint firstItem="9Ko-sD-7x0" firstAttribute="centerY" secondItem="Mfs-fu-W5k" secondAttribute="centerY" id="SXw-dC-2kl"/>
</constraints>
</tableViewCellContentView>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="wzU-8s-HGb">
<rect key="frame" x="0.0" y="343.5" width="320" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="wzU-8s-HGb" id="aNM-6U-bho">
<rect key="frame" x="0.0" y="0.0" width="320" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="S6B-i8-CoC">
<rect key="frame" x="74.5" y="7" width="171" height="30"/>
<state key="normal" title="Reset Introduction Alerts"/>
<connections>
<action selector="resetTutorialAlerts:" destination="qdB-ZO-LHY" eventType="touchUpInside" id="0GX-Ko-bk2"/>
</connections>
</button>
</subviews>
<constraints>
<constraint firstItem="S6B-i8-CoC" firstAttribute="centerY" secondItem="aNM-6U-bho" secondAttribute="centerY" id="Wet-iT-mke"/>
<constraint firstItem="S6B-i8-CoC" firstAttribute="centerX" secondItem="aNM-6U-bho" secondAttribute="centerX" id="qM6-0t-1m4"/>
</constraints>
</tableViewCellContentView>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="a9C-Qy-pOf">
<rect key="frame" x="0.0" y="387.5" width="320" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="a9C-Qy-pOf" id="cUk-4x-Weg">
<rect key="frame" x="0.0" y="0.0" width="320" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="17e-nR-aCh">
<rect key="frame" x="111.5" y="7" width="97" height="30"/>
<state key="normal" title="Delete all logs">
<color key="titleColor" systemColor="systemRedColor" red="1" green="0.23137254900000001" blue="0.18823529410000001" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</state>
<connections>
<action selector="clearDatabaseResults:" destination="qdB-ZO-LHY" eventType="touchUpInside" id="Rep-Do-4OQ"/>
</connections>
</button>
</subviews>
<constraints>
<constraint firstItem="17e-nR-aCh" firstAttribute="centerX" secondItem="cUk-4x-Weg" secondAttribute="centerX" id="dU5-1x-ETF"/>
<constraint firstItem="17e-nR-aCh" firstAttribute="centerY" secondItem="cUk-4x-Weg" secondAttribute="centerY" id="nLq-yi-u2E"/>
</constraints>
</tableViewCellContentView>
</tableViewCell>
</cells>
</tableViewSection>
</sections>
<connections>
<outlet property="dataSource" destination="qdB-ZO-LHY" id="RH3-xR-dpC"/>
<outlet property="delegate" destination="qdB-ZO-LHY" id="eYf-Xd-2Jq"/>
</connections>
</tableView>
<navigationItem key="navigationItem" title="Settings" id="9Ce-p2-kGX"/>
<connections>
<outlet property="cellDomainsBlocked" destination="3pw-7c-M6R" id="AHT-FE-z0s"/>
<outlet property="cellDomainsIgnored" destination="fZR-we-Y0k" id="Huy-N3-gz7"/>
<outlet property="vpnToggle" destination="kmY-ot-lJW" id="yeS-DE-FfR"/>
</connections>
</tableViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="VNK-Z0-T0a" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="700" y="150"/>
</scene>
<!--Domains-->
<scene sceneID="218-uP-X7b">
<objects>
<tableViewController id="q3B-Yi-1bx" customClass="TVCFilter" customModule="AppCheck" customModuleProvider="target" sceneMemberID="viewController">
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="prototypes" style="plain" separatorStyle="default" allowsSelection="NO" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="28" sectionFooterHeight="28" id="GSg-ZZ-F8J">
<rect key="frame" x="0.0" y="0.0" width="320" height="568"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<prototypes>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" reuseIdentifier="DomainFilterCell" textLabel="MrS-rb-RLB" style="IBUITableViewCellStyleDefault" id="EO2-ww-xuz">
<rect key="frame" x="0.0" y="28" width="320" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="EO2-ww-xuz" id="AtR-ce-uYs">
<rect key="frame" x="0.0" y="0.0" width="320" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Title" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="MrS-rb-RLB">
<rect key="frame" x="16" y="0.0" width="288" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</tableViewCellContentView>
</tableViewCell>
</prototypes>
<connections>
<outlet property="dataSource" destination="q3B-Yi-1bx" id="eWw-VO-n1c"/>
<outlet property="delegate" destination="q3B-Yi-1bx" id="02X-f0-d1a"/>
</connections>
</tableView>
<navigationItem key="navigationItem" title="Domains" id="FWA-IG-VIb">
<barButtonItem key="rightBarButtonItem" systemItem="add" id="RFW-bp-wwH">
<connections>
<action selector="addNewFilter" destination="q3B-Yi-1bx" id="JID-eH-y0p"/>
</connections>
</barButtonItem>
</navigationItem>
</tableViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="Xzo-dO-WpK" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="1400" y="150"/>
</scene>
</scenes>
<inferredMetricsTieBreakers>
<segue reference="EzT-Xq-wka"/>
</inferredMetricsTieBreakers>
<resources>
<image name="journal" width="25" height="25"/>
<image name="settings" width="25" height="25"/>
<image name="tag" width="25" height="25"/>
</resources>
</document>

View File

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

View File

@@ -1,147 +0,0 @@
import UIKit
public enum RowAction {
case ignore, block, delete
// static let all: [RowAction] = [.ignore, .block, .delete]
}
// MARK: - Generic
protocol EditableRows {
func editableRowUserInfo(_ index: IndexPath) -> Any?
func editableRowActions(_ index: IndexPath) -> [(RowAction, String)]
func editableRowActionColor(_ index: IndexPath, _ action: RowAction) -> UIColor?
@discardableResult func editableRowCallback(_ atIndexPath: IndexPath, _ action: RowAction, _ userInfo: Any?) -> Bool
}
extension EditableRows where Self: UITableViewController {
fileprivate func getRowActionsIOS9(_ index: IndexPath) -> [UITableViewRowAction]? {
let userInfo = editableRowUserInfo(index)
return editableRowActions(index).compactMap { a,t in
let x = UITableViewRowAction(style: a == .delete ? .destructive : .normal, title: t) { self.editableRowCallback($1, a, userInfo) }
if let color = editableRowActionColor(index, a) {
x.backgroundColor = color
}
return x
}
}
@available(iOS 11.0, *)
fileprivate func getRowActionsIOS11(_ index: IndexPath) -> UISwipeActionsConfiguration? {
let userInfo = editableRowUserInfo(index)
return UISwipeActionsConfiguration(actions: editableRowActions(index).compactMap { a,t in
let x = UIContextualAction(style: a == .delete ? .destructive : .normal, title: t) { $2(self.editableRowCallback(index, a, userInfo)) }
x.backgroundColor = editableRowActionColor(index, a)
return x
})
}
func editableRowUserInfo(_ index: IndexPath) -> Any? { nil }
}
// MARK: - Edit Ignore-Block-Delete
protocol EditActionsIgnoreBlockDelete : EditableRows {
var dataSource: [GroupedDomain] { get set }
}
extension EditActionsIgnoreBlockDelete where Self: UITableViewController {
func editableRowActions(_ index: IndexPath) -> [(RowAction, String)] {
let x = dataSource[index.row]
if x.domain.starts(with: "#") {
return [(.delete, "Delete")]
}
let b = x.options?.contains(.blocked) ?? false
let i = x.options?.contains(.ignored) ?? false
return [(.delete, "Delete"), (.block, b ? "Unblock" : "Block"), (.ignore, i ? "Unignore" : "Ignore")]
}
func editableRowActionColor(_: IndexPath, _ action: RowAction) -> UIColor? {
action == .block ? .systemOrange : nil
}
func editableRowUserInfo(_ index: IndexPath) -> Any? { dataSource[index.row] }
func editableRowCallback(_ index: IndexPath, _ action: RowAction, _ userInfo: Any?) -> Bool {
let entry = userInfo as! GroupedDomain
switch action {
case .ignore: showFilterSheet(entry, .ignored)
case .block: showFilterSheet(entry, .blocked)
case .delete:
AlertDeleteLogs(entry.domain, latest: entry.lastModified) {
DBWrp.deleteHistory(domain: entry.domain, since: $0)
}.presentIn(self)
}
return true
}
private func showFilterSheet(_ entry: GroupedDomain, _ filter: FilterOptions) {
if entry.options?.contains(filter) ?? false {
DBWrp.updateFilter(entry.domain, remove: filter)
} else {
// TODO: alert sheet
DBWrp.updateFilter(entry.domain, add: filter)
}
}
}
// MARK: Extensions
extension TVCDomains : EditActionsIgnoreBlockDelete {
override func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
getRowActionsIOS9(indexPath)
}
@available(iOS 11.0, *)
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
getRowActionsIOS11(indexPath)
}
}
extension TVCHosts : EditActionsIgnoreBlockDelete {
override func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
getRowActionsIOS9(indexPath)
}
@available(iOS 11.0, *)
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
getRowActionsIOS11(indexPath)
}
}
// MARK: - Edit Remove
protocol EditActionsRemove : EditableRows {}
extension EditActionsRemove where Self: UITableViewController {
func editableRowActions(_: IndexPath) -> [(RowAction, String)] { [(.delete, "Remove")] }
func editableRowActionColor(_: IndexPath, _: RowAction) -> UIColor? { nil }
}
// MARK: Extensions
extension TVCFilter : EditableRows {
override func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
getRowActionsIOS9(indexPath)
}
@available(iOS 11.0, *)
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
getRowActionsIOS11(indexPath)
}
}
extension TVCPreviousRecords : EditableRows {
override func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
getRowActionsIOS9(indexPath)
}
@available(iOS 11.0, *)
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
getRowActionsIOS11(indexPath)
}
}
extension TVCRecordingDetails : EditableRows {
override func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
getRowActionsIOS9(indexPath)
}
@available(iOS 11.0, *)
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
getRowActionsIOS11(indexPath)
}
}

View File

@@ -0,0 +1,418 @@
import UIKit
protocol FilterPipelineDelegate: AnyObject {
/// Call `reloadData()`
func filterPipelineDidReset()
/// Call `safeDeleteRows()`
func filterPipeline(delete rows: [Int])
/// Call `safeInsertRow()`
func filterPipeline(insert row: Int)
/// Call `safeReloadRow()`
func filterPipeline(update row: Int)
/// Call `safeMoveRow()`
func filterPipeline(move oldRow: Int, to newRow: Int)
}
// MARK: - FilterPipeline
class FilterPipeline<T> {
private(set) fileprivate var dataSource: [T] = []
private var pipeline: [PipelineFilter<T>] = []
private var display: PipelineSorting<T>!
weak var delegate: FilterPipelineDelegate?
/// - 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])
}
/// Set new data source and re-built filter and display sorting order.
/// - Note: Will call `filterPipelineDidReset()`
func reset(dataSource: [T]) {
self.dataSource = dataSource
self.resetFilters()
delegate?.filterPipelineDidReset()
}
/// Returns the index set of either the last filter layer, or `dataSource` if no filter is set.
fileprivate func lastLayerIndices() -> [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.
/// - Warning: Use `[unowned self]` to prevent retain cycles!
/// - Note: Will call `filterPipelineDidReset()`
/// - Parameters:
/// - identifier: Use this id to find the filter again. For reload and remove operations.
/// - otherId: If `nil` or non-existent the new filter will be appended at the end.
/// - predicate: Return `true` if you want to keep the element.
func addFilter(_ identifier: String, before otherId: String? = nil, _ predicate: @escaping PipelineFilter<T>.Predicate) {
guard indexOfFilter(identifier) == nil else { return }
let newFilter = PipelineFilter(identifier, predicate)
if let other = otherId, let i = indexOfFilter(other) {
pipeline.insert(newFilter, at: i)
resetFilters(startingAt: i)
} else {
newFilter.reset(to: dataSource, previous: lastLayerIndices())
pipeline.append(newFilter)
display?.apply(moreRestrictive: newFilter.selection)
}
delegate?.filterPipelineDidReset()
}
/// Find and remove filter with given identifier. Will automatically update remaining filters and display sorting.
/// - Note: Will call `filterPipelineDidReset()`
func removeFilter(withId ident: String) {
guard let i = indexOfFilter(ident) else { return }
pipeline.remove(at: i)
if i == pipeline.count {
// only if we don't reset other layers we can assure `toLessRestrictive`
display?.apply(lessRestrictive: lastLayerIndices())
} else {
resetFilters(startingAt: i)
}
delegate?.filterPipelineDidReset()
}
/// Start filter evaluation on all entries from previous filter.
/// - Note: Will call `filterPipelineDidReset()`
func reloadFilter(withId ident: String) {
guard let i = indexOfFilter(ident) else { return }
resetFilters(startingAt: i)
delegate?.filterPipelineDidReset()
}
/// Sets the sort and display order. You should set the `delegate` to automatically update your `tableView`.
/// - Warning: Use `[unowned self]` to prevent retain cycles!
/// - Note: Will call `filterPipelineDidReset()`
/// - Parameter predicate: Return `true` if first element should be sorted before second element.
func setSorting(_ predicate: @escaping PipelineSorting<T>.Predicate) {
display = .init(predicate, pipe: self)
delegate?.filterPipelineDidReset()
}
/// Will reverse the current display order without resorting. This is faster than setting a new sorting `predicate`.
/// However, the `predicate` must be dynamic and support a sort order flag.
/// - Note: Will call `filterPipelineDidReset()`
/// - Warning: Make sure `predicate` does reflect the change or it will lead to data inconsistency!
func reverseSorting() {
// TODO: use semaphore to prevent concurrent edits
display?.reverseOrder()
delegate?.filterPipelineDidReset()
}
/// Re-built filter and display sorting order.
/// - Parameter index: Must be: `index <= pipeline.count`
private func resetFilters(startingAt index: Int = 0) {
for i in index..<pipeline.count {
pipeline[i].reset(to: dataSource, previous: (i>0)
? pipeline[i-1].selection : dataSource.indices.arr())
}
// Reset is NOT less-restrictive because filters are dynamic
// Calling reset on a filter twice may yield different results
// E.g. if filter uses variables outside of scope (current time, search term)
display?.reset(to: lastLayerIndices())
}
/// Push object through filter pipeline to check whether it survives all filters.
/// - Parameter index: The index of the object in the original `dataSource`
/// - Returns: `changed` is `true` if element persists or should be removed with this update.
/// `display` indicates whther element should be shown (`true`) or hidden (`false`).
/// - Complexity: O(*m* log *n*), where *m* is the number of filters and *n* the number of elements in each filter.
private func processPipeline(with obj: T, at index: Int) -> (changed: Bool, display: Bool) {
var keepGoing = true
for filter in pipeline {
let lastIndex: Int?
if keepGoing {
(keepGoing, lastIndex) = filter.update(obj, at: index)
} else {
lastIndex = filter.remove(dataSource: index)
}
// if it isnt in this layer, it wont appear in the following either
if lastIndex == nil { return (false, false) }
}
return (true, keepGoing)
}
// MARK: data updates
/// Add new element to the original `dataSource` and immediately apply filter and sorting.
/// - Note: Will call `filterPipeline(insert:)` if not filtered.
/// - Complexity: O((*m*+1) log *n*), where *m* is the number of filters and *n* the number of elements in each filter.
func addNew(_ obj: T) {
let index = dataSource.count
dataSource.append(obj)
for filter in pipeline {
if filter.add(obj, at: index) == nil { return }
}
// survived all filters
let displayIndex = display.insertNew(index)
delegate?.filterPipeline(insert: displayIndex)
}
/// Update element at `index` in the original `dataSource` and immediately re-apply filter and sorting.
/// - Note: Will call `filterPipeline(delete:)`, `(insert:)`, `(update:)`, or `(move:)`
/// - Parameters:
/// - obj: Element to be added. Will overwrite previous `dataSource` object.
/// - index: Index in the original `dataSource`
/// - 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 oldPos != -1 { delegate?.filterPipeline(delete: [oldPos]) }
return
}
let newPos = display.insertNew(index, previousIndex: oldPos)
if oldPos == -1 {
delegate?.filterPipeline(insert: newPos)
} else {
if oldPos == newPos {
delegate?.filterPipeline(update: oldPos)
} else {
delegate?.filterPipeline(move: oldPos, to: newPos)
}
}
}
/// Remove elements from the original `dataSource`, from all filters, and from display sorting.
/// - Note: Will call `filterPipeline(delete:)` if `sorted` array is not empty.
/// - Parameter sorted: Indices in the original `dataSource`
/// - Complexity: O(*t*(*m*+*n*) + *m* log *n*), where *t* is the number of filters,
/// *m* the number of elements in each filter / projection, and *n* the length of `sorted` indices.
func remove(indices sorted: [Int]) {
guard sorted.count > 0 else { return }
for i in sorted.reversed() {
dataSource.remove(at: i)
}
for filter in pipeline {
filter.shiftRemove(indices: sorted)
}
let indices = display.shiftRemove(indices: sorted)
delegate?.filterPipeline(delete: indices)
}
}
// MARK: - Filter
class PipelineFilter<T>: CustomStringConvertible {
var description: String { "\(Self.self)(id: \(id))" }
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` by copying the indices and applying the filter function
fileprivate func reset(to dataSource: [T], previous filterIndices: [Int]) {
selection = filterIndices
selection.removeAll { !shouldPersist(dataSource[$0]) }
}
/// 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: (<))
}
/// 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 = selection.binTreeIndex(of: index, compare: (<), mustExist: true)
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
/// Create a fresh, already sorted, display order projection.
/// - Parameter predicate: Return `true` if first element should be sorted before second element.
/// - Complexity: O(*n* log *n*), where *n* is the length of the `filter`.
required init(_ predicate: @escaping Predicate, pipe: FilterPipeline<T>) {
comperator = { [unowned pipe] in
predicate(pipe.dataSource[$0], pipe.dataSource[$1])
}
reset(to: pipe.lastLayerIndices())
}
/// - Warning: Make sure `predicate` does reflect the change. Or it will lead to data inconsistency.
/// - Complexity: O(*n*), where *n* is the length of the `filter`.
fileprivate func reverseOrder() {
projection.reverse()
}
/// Replace current `projection` with new filter indices and apply sorting.
/// - Complexity: O(*n* log *n*), where *n* is the length of the `filter`.
fileprivate func reset(to filterIndices: [Int]) {
projection = filterIndices.sorted(by: comperator)
}
/// After adding a new layer of filtering the new layer can only restrict the display even further.
/// Therefore, indices that were removed in the last layer will be removed from the projection too.
/// - Complexity: O(*m* log *n*), where *m* is the length of the `projection` and *n* the length of the `filter`.
fileprivate func apply(moreRestrictive filterIndices: [Int]) {
projection.removeAll { !filterIndices.binTreeExists($0, compare: (<)) }
}
/// After removing a layer of filtering the previous layers are less restrictive and thus contain more indices.
/// Therefore, the difference between both index sets will be inserted into the projection.
/// - Complexity: O(*m* log *n*), where *m* is the difference to the previous layer and *n* is the length of the `projection`.
fileprivate func apply(lessRestrictive filterIndices: [Int]) {
for x in filterIndices.difference(toSubset: projection.sorted(), compare: (<)) {
insertNew(x)
}
}
/// 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 { // '<=' because previous delete removed one element
if (prev == 0 || !comperator(index, projection[prev - 1])),
(prev == projection.count || !comperator(projection[prev], index)) {
// If element can be inserted at the same position without resorting, do that
projection.insert(index, at: prev)
return prev
}
}
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
}
}

View File

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

View File

@@ -0,0 +1,71 @@
import UIKit
struct NotificationBanner {
enum Style {
case fail, ok
}
let view: UIView
init(_ msg: String, style: Style) {
let bg, fg: UIColor
let imgName: String
switch style {
case .fail:
bg = .systemRed
fg = UIColor.black.withAlphaComponent(0.80)
imgName = "circle-x"
case .ok:
bg = .systemGreen
fg = UIColor.black.withAlphaComponent(0.65)
imgName = "circle-check"
}
view = UIView()
view.backgroundColor = bg
let lbl = QuickUI.label(msg, style: .callout)
lbl.textColor = fg
lbl.numberOfLines = 0
lbl.font = lbl.font.bold()
let img = QuickUI.image(UIImage(named: imgName))
img.tintColor = fg
view.addSubview(lbl)
view.addSubview(img)
img.anchor([.centerY], to: lbl)
lbl.anchor([.bottom, .trailing], to: view.layoutMarginsGuide)
img.widthAnchor =&= 25
img.heightAnchor =&= 25
if #available(iOS 11, *) {
img.leadingAnchor =&= view.layoutMarginsGuide.leadingAnchor
lbl.topAnchor =&= view.layoutMarginsGuide.topAnchor
} else {
img.leadingAnchor =&= view.leadingAnchor + 8
lbl.topAnchor =&= view.topAnchor + 8
}
lbl.leadingAnchor =&= img.trailingAnchor + 8
img.bottomAnchor =<= view.bottomAnchor - 8 | .init(rawValue: 999)
lbl.bottomAnchor =<= view.bottomAnchor - 8 | .init(rawValue: 999)
}
/// Animate header banner from the top of the view. Show for `delay` seconds and then hide again.
/// - Parameter onClose: Run after the close animation finishes.
func present(in vc: UIViewController, hideAfter delay: TimeInterval = 3, onClose: (() -> Void)? = nil) {
vc.view.addSubview(view)
view.anchor([.leading, .trailing], to: vc.view!)
view.widthAnchor =&= vc.view!.widthAnchor // Bug? left-right is not sufficient
vc.view.layoutIfNeeded() // sets the height
let h = view.frame.height
let constraint = view.topAnchor =&= vc.view.topAnchor - h
vc.view.layoutIfNeeded() // hide view
UIView.animate(withDuration: 0.3, animations: {
constraint.constant = 0
vc.view.layoutIfNeeded() // animate view
UIView.animate(withDuration: 0.3, delay: delay, options: .curveLinear, animations: {
constraint.constant = -h
vc.view.layoutIfNeeded() // hide again
}, completion: { _ in
self.view.removeFromSuperview()
onClose?()
})
})
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,64 @@
import UIKit
class SearchBarManager: NSObject, UISearchResultsUpdating {
private(set) var isActive = false
private(set) var term = ""
private lazy var controller: UISearchController = {
let x = UISearchController(searchResultsController: nil)
x.searchBar.autocapitalizationType = .none
x.searchBar.autocorrectionType = .no
x.obscuresBackgroundDuringPresentation = false
x.searchResultsUpdater = self
return x
}()
private weak var tvc: UITableViewController?
private let onChangeCallback: (String) -> Void
/// Prepare `UISearchBar` for user input
/// - Parameter onChange: Code that will be executed every time the user changes the text (with 0.2s delay)
required init(onChange: @escaping (String) -> Void) {
onChangeCallback = onChange
super.init()
UITextField.appearance(whenContainedInInstancesOf: [UISearchBar.self])
.defaultTextAttributes = [.font: UIFont.preferredFont(forTextStyle: .body)]
}
/// Assigns the `UISearchBar` to `tableView.tableHeaderView` (iOS 9) or `navigationItem.searchController` (iOS 11).
func fuseWith(tableViewController: UITableViewController?) {
guard tvc !== tableViewController else { return }
tvc = tableViewController
if #available(iOS 11.0, *) {
tvc?.navigationItem.searchController = controller
} else {
let thv = tvc?.tableView.tableHeaderView
guard thv == nil || thv is UISearchBar else {
// Don't overwrite actions bar (co-occurrence, etc.)
// FIXME: find alternative or iOS 9-10 users can't search in hosts
tvc = nil
return
}
controller.loadViewIfNeeded() // Fix: "Attempting to load the view of a view controller while it is deallocating"
tvc?.definesPresentationContext = true // make search bar disappear if user changes scene (eg. select cell)
//tvc?.tableView.backgroundView = UIView() // iOS 11+ bug: bright white background in dark mode
tvc?.tableView.tableHeaderView = controller.searchBar
tvc?.tableView.setContentOffset(.init(x: 0, y: controller.searchBar.frame.height), animated: false)
}
}
/// Search callback
internal func updateSearchResults(for controller: UISearchController) {
NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(performSearch), object: nil)
perform(#selector(performSearch), with: nil, afterDelay: 0.2)
}
/// Internal callback function for delayed text evaluation.
/// This way we can avoid unnecessary searches while user is typing.
@objc private func performSearch() {
term = controller.searchBar.text?.lowercased() ?? ""
isActive = term.count > 0
onChangeCallback(term)
}
}

View File

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

View File

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

View File

@@ -0,0 +1,68 @@
import UIKit
struct TinyMarkdown {
/// Load markdown file and run through a (very) simple parser (see below).
/// - Parameters:
/// - filename: Will automatically append `.md` extension
/// - replacements: Replace a single occurrence of search string with an attributed replacement.
static func load(_ filename: String, replacements: [String : NSMutableAttributedString] = [:]) -> UITextView {
let url = Bundle.main.url(forResource: filename, withExtension: "md")!
let str = NSMutableAttributedString(withMarkdown: try! String(contentsOf: url))
for (key, val) in replacements {
guard let r = str.string.range(of: key) else {
QLog.Debug("WARN: markdown key '\(key)' does not exist in \(filename)")
continue
}
str.replaceCharacters(in: NSRange(r, in: str.string), with: val)
}
return QuickUI.text(attributed: str)
}
}
extension NSMutableAttributedString {
/// Supports only: `#h1`, `##h2`, `###h3`, `_italic_`, `__bold__`, `___boldItalic___`
convenience init(withMarkdown content: String) {
self.init()
let emph = try! NSRegularExpression(pattern: #"(?<=(^|\W))(_{1,3})(\S|\S.*?\S)\2"#, options: [])
beginEditing()
content.enumerateLines { (line, _) in
if line.starts(with: "#") {
var h = 0
for char in line {
if char == "#" { h += 1 }
else { break }
}
var line = line
line.removeFirst(h)
line = line.trimmingCharacters(in: CharacterSet(charactersIn: " "))
switch h {
case 1: self.h1(line + "\n")
case 2: self.h2(line + "\n")
default: self.h3(line + "\n")
}
} else {
let nsline = line as NSString
let range = NSRange(location: 0, length: nsline.length)
var i = 0
for x in emph.matches(in: line, options: [], range: range) {
let r = x.range
self.normal(nsline.substring(from: i, to: r.location))
i = r.upperBound
let before = nsline.substring(with: r)
let after = before.trimmingCharacters(in: CharacterSet(charactersIn: "_"))
switch (before.count - after.count) / 2 {
case 1: self.italic(after)
case 2: self.bold(after)
default: self.boldItalic(after)
}
}
if i < range.length {
self.normal(nsline.substring(from: i, to: range.length) + "\n")
} else {
self.normal("\n")
}
}
}
endEditing()
}
}

View File

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

505
main/DB/DBAppOnly.swift Normal file
View File

@@ -0,0 +1,505 @@
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 != 2 {
QLog.Info("migrate db \(version) -> 2")
// version 0 -> 1: req(domain) -> heap(fqdn, domain)
// version 1 -> 2: rec(+subtitle, +opt)
if version == 1 {
transaction("""
ALTER TABLE rec ADD COLUMN subtitle TEXT;
ALTER TABLE rec ADD COLUMN uploadkey TEXT;
""")
}
try run(sql: "PRAGMA user_version = 2;")
}
}
}
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
}
fileprivate func col_ts(_ stmt: OpaquePointer, _ col: Int32) -> Timestamp {
sqlite3_column_int64(stmt, col)
}
}
class WhereClauseBuilder: CustomStringConvertible {
var description: String = ""
private let prefix: String
private(set) var bindings: [DBBinding] = []
init(prefix p: String = "WHERE") { prefix = "\(p) " }
/// Append new clause by either prepending `WHERE` prefix or placing `AND` between clauses.
@discardableResult func and(_ clause: String, _ bind: DBBinding ...) -> Self {
description.append((description=="" ? prefix : " AND ") + clause)
bindings.append(contentsOf: bind)
return self
}
/// Restrict to `rowid >= {range}.start AND rowid <= {range}.end`.
/// Omitted if range is `nil` or individually if a value is `0`.
@discardableResult func and(in range: SQLiteRowRange) -> Self {
if range.start != 0 { and("rowid >= ?", BindInt64(range.start)) }
if range.end != 0 { and("rowid <= ?", BindInt64(range.end)) }
return self
}
/// Restrict to `ts >= {min} AND ts < {max}`. Omit one or the other if value is `0`.
@discardableResult func and(min: Timestamp = 0, max: Timestamp = 0) -> Self {
if min != 0 { and("ts >= ?", BindInt64(min)) }
if max != 0 { and("ts < ?", BindInt64(max)) }
return self
}
}
// 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)
typealias DomainTsPair = (domain: String, ts: Timestamp)
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() }
transaction("""
INSERT INTO heap(ts,fqdn,domain,opt) SELECT ts,dns,domainof(dns),nullif(opt&1,0) FROM cache;
DELETE FROM cache;
""")
let after = lastRowId(.heap)
return (before > after) ? nil : (before, after)
}
/// `DELETE FROM cache; DELETE FROM heap;`
func dnsLogsDeleteAll() throws {
try? run(sql: "DELETE FROM cache; DELETE FROM heap;")
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 {
let Where = WhereClauseBuilder().and(min: ts)
Where.and("\(strict ? "fqdn" : "domain") = ?", BindText(domain)) // (fqdn = ? OR fqdn LIKE '%.' || ?)
return (try? run(sql: "DELETE FROM heap \(Where);", bind: Where.bindings) { stmt -> Int32 in
try ifStep(stmt, SQLITE_DONE)
return numberOfChanges
}) ?? 0
}
// MARK: read
/// `SELECT min(ts) FROM heap`
func dnsLogsMinDate() -> Timestamp? {
try? run(sql:"SELECT min(ts) FROM heap") {
try ifStep($0, SQLITE_ROW)
return col_ts($0, 0)
}
}
/// Select min and max row id with given condition `ts >= ? AND ts < ?`
/// - Parameters:
/// - ts1: Restrict min `rowid` to `ts >= ?`. Pass `0` to omit restriction.
/// - ts2: Restrict max `rowid` to `ts < ?`. Pass `0` to omit restriction.
/// - range: If set, only look at the specified range. Default: `(0,0)`
/// - Returns: `nil` in case no rows are matching the condition
func dnsLogsRowRange(between ts1: Timestamp, and ts2: Timestamp, within range: SQLiteRowRange = (0,0)) -> SQLiteRowRange? {
let Where = WhereClauseBuilder().and(in: range).and(min: ts1, max: ts2)
return try? run(sql:"SELECT min(rowid), max(rowid) FROM heap \(Where);", bind: Where.bindings) {
try ifStep($0, SQLITE_ROW)
let max = col_ts($0, 1)
return (max == 0) ? nil : (col_ts($0, 0), max)
}
}
/// Get raw logs between two timestamps. `ts >= ? AND ts <= ?`
/// - Returns: List sorted by `ts` in descending order (newest entries first).
func dnsLogs(between ts1: Timestamp, and ts2: Timestamp) -> [DomainTsPair]? {
try? run(sql: "SELECT fqdn, ts FROM heap WHERE ts >= ? AND ts <= ? ORDER BY ts DESC, rowid ASC;",
bind: [BindInt64(ts1), BindInt64(ts2)]) {
allRows($0) {
(col_text($0, 0) ?? "", col_ts($0, 1))
}
}
}
/// Group DNS logs by domain, count occurences and number of blocked requests.
/// - Parameters:
/// - range: Whenever possible set range to improve SQL lookup times. `start <= rowid <= end `
/// - 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, matchingDomain: String? = nil, parentDomain: String? = nil) -> [GroupedDomain]? {
let Where = WhereClauseBuilder().and(in: range)
let col: String // fqdn or domain
if let parent = parentDomain { // is subdomain
col = "fqdn"
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: col_text($0, 0) ?? "",
total: sqlite3_column_int($0, 1),
blocked: sqlite3_column_int($0, 2),
lastModified: col_ts($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 `
/// - Returns: List sorted by reverse timestamp order (newest first)
func timesForDomain(_ fqdn: String, range: SQLiteRowRange) -> [GroupedTsOccurrence]? {
let Where = WhereClauseBuilder().and(in: range).and("fqdn = ?", BindText(fqdn))
return try? run(sql: "SELECT ts, COUNT(ts), COUNT(opt) FROM heap \(Where) GROUP BY ts ORDER BY ts DESC;", bind: Where.bindings) {
allRows($0) {
(col_ts($0, 0), sqlite3_column_int($0, 1), sqlite3_column_int($0, 2))
}
}
}
}
// MARK: - Context Analysis
typealias ContextAnalysisResult = (domain: String, count: Int32, avg: Double, rank: Double)
extension SQLiteDatabase {
/// Number of times how often given `fqdn` appears in the database
func dnsLogsCount(fqdn: String) -> Int? {
try? run(sql: "SELECT COUNT(*) FROM heap WHERE fqdn = ?;", bind: [BindText(fqdn)]) {
try ifStep($0, SQLITE_ROW)
return Int(sqlite3_column_int($0, 0))
}
}
/// Get sorted, unique list of `ts` with given `fqdn`.
func dnsLogsUniqTs(_ domain: String, isFQDN flag: Bool) -> [Timestamp]? {
try? run(sql: "SELECT DISTINCT ts FROM heap WHERE \(flag ? "fqdn" : "domain") = ? ORDER BY ts;",
bind: [BindText(domain)]) {
allRows($0) { col_ts($0, 0) }
}
}
/// Find other domains occurring regularly at roughly the same time as `fqdn`.
/// - Warning: `times` list must be **sorted** by time in ascending order.
/// - Parameters:
/// - times: List of `ts` from `dnsLogsUniqTs(fqdn)`
/// - dt: Search for `ts - dt <= X <= ts + dt`
/// - fqdn: Rows matching this domain will be excluded from the result set.
/// - Returns: List of tuples ordered by rank (ASC).
func contextAnalysis(coOccurrence times: [Timestamp], plusMinus dt: Timestamp, exclude domain: String, isFQDN flag: Bool) -> [ContextAnalysisResult]? {
guard times.count > 0 else { return nil }
createFunction("fnDist") {
let x = $0.first as! Timestamp
let i = times.binTreeIndex(of: x, compare: <)!
let dist: Timestamp
switch i {
case 0: dist = times[0] - x
case times.count: dist = x - times[i-1]
default: dist = min(times[i] - x, x - times[i-1])
}
return dist
}
// `avg ^ 2`: prefer results that are closer to `times`
// `_ / count`: prefer results with higher occurrence count
// `time / 2`: Weighting factor (low: prefer close, high: prefer count)
// `time` helpful esp. for smaller spans. `avg^2` will raise faster anyway.
let fnRank = "(avg * avg + (? / 2.0) + 1) / count" // +1 in case time == 0 -> avg^2 == 0
// improve query by excluding entries that are: before the first, or after the last ts
let low = times.first! - dt
let high = times.last! + dt
return try? run(sql: """
SELECT fqdn, count, avg, (\(fnRank)) rank FROM (
SELECT fqdn, COUNT(*) count, AVG(dist) avg FROM (
SELECT fqdn, fnDist(ts) dist FROM heap
WHERE ts BETWEEN ? AND ? AND \(flag ? "fqdn" : "domain") != ? AND dist <= ?
) GROUP BY fqdn
) ORDER BY rank ASC LIMIT 99;
""", bind: [BindInt64(dt), BindInt64(low), BindInt64(high), BindText(domain), BindInt64(dt)]) {
allRows($0) {
(col_text($0, 0) ?? "", sqlite3_column_int($0, 1), sqlite3_column_double($0, 2), sqlite3_column_double($0, 3))
}
}
}
}
// MARK: - Recordings
extension CreateTable {
/// `id`: Primary, `start`: Timestamp, `stop`: Timestamp, `appid`: String, `title`: String, `subtitle`: 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,
subtitle TEXT,
notes TEXT,
uploadkey TEXT
);
"""}
}
let readRecordingSelect = "id, start, stop, appid, title, subtitle, notes, uploadkey"
struct Recording {
let id: sqlite3_int64
let start: Timestamp
let stop: Timestamp?
var appId: String? = nil
var title: String? = nil
var subtitle: String? = nil
var notes: String? = nil
var uploadkey: String? = nil
}
typealias AppBundleInfo = (bundleId: String, name: String?, author: String?)
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 appid = ?, title = ?, subtitle = ?, notes = ?, uploadkey = ? WHERE id = ? LIMIT 1;",
bind: [BindTextOrNil(r.appId), BindTextOrNil(r.title), BindTextOrNil(r.subtitle),
BindTextOrNil(r.notes), BindTextOrNil(r.uploadkey), 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 = col_ts(stmt, 2)
return Recording(id: sqlite3_column_int64(stmt, 0),
start: col_ts(stmt, 1),
stop: end == 0 ? nil : end,
appId: col_text(stmt, 3),
title: col_text(stmt, 4),
subtitle: col_text(stmt, 5),
notes: col_text(stmt, 6),
uploadkey: col_text(stmt, 7))
}
/// `WHERE stop IS NULL`
func recordingGetOngoing() -> Recording? {
try? run(sql: "SELECT \(readRecordingSelect) FROM rec WHERE stop IS NULL LIMIT 1;") {
try ifStep($0, SQLITE_ROW)
return readRecording($0)
}
}
/// Get `Timestamp` of last recording.
func recordingLastTimestamp() -> Timestamp? {
try? run(sql: "SELECT stop FROM rec WHERE stop IS NOT NULL ORDER BY rowid DESC LIMIT 1;") {
try ifStep($0, SQLITE_ROW)
return col_ts($0, 0)
}
}
/// `WHERE stop IS NOT NULL`
func recordingGetAll() -> [Recording]? {
try? run(sql: "SELECT \(readRecordingSelect) 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 \(readRecordingSelect) FROM rec WHERE id = ? LIMIT 1;", bind: [BindInt64(withID)]) {
try ifStep($0, SQLITE_ROW)
return readRecording($0)
}
}
func appBundleList() -> [AppBundleInfo]? {
try? run(sql: "SELECT appid, title, subtitle FROM rec WHERE appid IS NOT NULL GROUP BY appid ORDER BY lower(title) ASC;") {
allRows($0) {
AppBundleInfo(col_text($0, 0)!, col_text($0, 1), col_text($0, 2))
}
}
}
}
// 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
);
"""}
}
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
}
}
/// Delete one recording log entry with given `recording id`, matching `domain`, and `ts`.
/// - Returns: `true` if row was deleted
func recordingLogsDelete(_ recId: sqlite3_int64, singleEntry ts: Timestamp, domain: String) throws -> Bool {
try run(sql: "DELETE FROM recLog WHERE rid = ? AND ts = ? AND domain = ? LIMIT 1;",
bind: [BindInt64(recId), BindInt64(ts), BindText(domain)]) {
try ifStep($0, SQLITE_DONE)
return numberOfChanges > 0
}
}
// MARK: read
/// List of domains and count occurences for given recording.
/// - Returns: List of `(domain, ts)` pairs. Sorted by `ts` in ascending order (oldest first)
func recordingLogsGet(_ r: Recording) -> [DomainTsPair]? {
try? run(sql: "SELECT domain, ts FROM recLog WHERE rid = ? ORDER BY ts ASC, rowid DESC;",
bind: [BindInt64(r.id)]) {
allRows($0) { (col_text($0, 0) ?? "", col_ts($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) }
// }
// }
//}

113
main/DB/DBCommon.swift Normal file
View File

@@ -0,0 +1,113 @@
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)])
// }
/// `INSERT INTO cache (dns, opt) VALUES (?, ?);`
func logWrite(_ domain: String, blocked: Bool = false) throws {
try self.run(sql: "INSERT INTO cache (dns, opt) VALUES (?, ?);",
bind: [BindText(domain), BindInt32(blocked ? 1 : 0)])
{ try ifStep($0, SQLITE_DONE) }
}
/// `DELETE FROM cache WHERE ts < (now - ? days);`
/// - Parameter days: if `0` or negative, this function does nothing.
/// - Returns: `true` if at least one row was deleted.
@discardableResult func dnsLogsDeleteOlderThan(days: Int) throws -> Bool {
guard days > 0 else { return false }
func delFrom(_ table: String) throws -> Bool {
return try self.run(sql: "DELETE FROM \(table) WHERE ts < strftime('%s', 'now', ?);",
bind: [BindText("-\(days) days")]) {
try ifStep($0, SQLITE_DONE)
return numberOfChanges > 0
}
}
let didDelHeap = try delFrom("heap")
let didDelCache = try delFrom("cache")
return didDelHeap || didDelCache
}
}
// 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 customA = FilterOptions(rawValue: 1 << 2)
static let customB = FilterOptions(rawValue: 1 << 3)
static let any = FilterOptions(rawValue: 0b1111)
}
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: col_text($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))
// }
// }
}

252
main/DB/DBCore.swift Normal file
View File

@@ -0,0 +1,252 @@
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
/// `0` indicates an unbound edge.
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_v2(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?
if sqlite3_open_v2(path, &db, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK {
sqlite3_busy_timeout(db, 800)
return SQLiteDatabase(dbPointer: db)
} else {
defer { sqlite3_close_v2(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)
}
}
/// `BEGIN TRANSACTION; \(sql); COMMIT;` on exception rollback.
func transaction(_ sql: String) {
do { try run(sql: "BEGIN TRANSACTION; \(sql); COMMIT;") }
catch { rollback() }
}
func ifStep(_ stmt: OpaquePointer, _ expected: Int32) throws {
guard sqlite3_step(stmt) == expected else {
throw SQLiteError.Step(message: errorMessage)
}
}
func vacuum() { NSLog("[SQL] VACUUM"); try? run(sql: "VACUUM;"); }
func rollback() { NSLog("[SQL] ROLLBACK"); try? run(sql: "ROLLBACK;"); }
}
// 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? Bool { sqlite3_result_int(context, r ? 1 : 0) }
else if let r = result as? String { sqlite3_result_text(context, r, Int32(r.count), SQLITE_TRANSIENT) }
else if result == nil { sqlite3_result_null(context) }
else { fatalError("unsupported result type: \(String(describing: result))") }
}
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 BindNull : DBBinding {
func bind(_ stmt: OpaquePointer!, _ col: Int32) -> Int32 { sqlite3_bind_null(stmt, col) }
}
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 col_text(_ 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 {
sqlite3_finalize(pStmt)
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)
}
}

Some files were not shown because too many files have changed in this diff Show More