Compare commits
100 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e65d84b6ab | ||
|
|
36cb499bf8 | ||
|
|
f78146f48d | ||
|
|
34ffd33316 | ||
|
|
e0158845d3 | ||
|
|
1e8101a699 | ||
|
|
dabbe10a2c | ||
|
|
92560bc48d | ||
|
|
3607fa9564 | ||
|
|
dfa368cff2 | ||
|
|
e54d69ef4b | ||
|
|
be8269ad56 | ||
|
|
7118ec3b02 | ||
|
|
71045bf0dd | ||
|
|
27abdd66f5 | ||
|
|
162e18c912 | ||
|
|
d68e4ec869 | ||
|
|
762263bfbd | ||
|
|
b1cddc796e | ||
|
|
77e20f31f5 | ||
|
|
0175f5390e | ||
|
|
effc305b86 | ||
|
|
c1fe258b0d | ||
|
|
36a8f0b97b | ||
|
|
33b9cab8a8 | ||
|
|
b88874b38b | ||
|
|
f55f3ea32d | ||
|
|
c843bd76a2 | ||
|
|
4dd2339ed8 | ||
|
|
280526bef4 | ||
|
|
34caffd4a7 | ||
|
|
9e19b457e2 | ||
|
|
e6846953b7 | ||
|
|
6d78aeac7b | ||
|
|
5d94fe3a0d | ||
|
|
fb680d669b | ||
|
|
6409e5eaf3 | ||
|
|
39ca9dbdb1 | ||
|
|
27ab2a621a | ||
|
|
3f572eeb15 | ||
|
|
e83540d5de | ||
|
|
847556bec1 | ||
|
|
42b045fb85 | ||
|
|
35a211f87f | ||
|
|
d2fa67e0e3 | ||
|
|
b8660c9a35 | ||
|
|
8cd3f7fb3a | ||
|
|
2ee0272a05 | ||
|
|
4ae82fc763 | ||
|
|
aac42d7eff | ||
|
|
8bb77ef741 | ||
|
|
ff4218981f | ||
|
|
7b7c5f3d9a | ||
|
|
1c203e39c3 | ||
|
|
7dbf21d564 | ||
|
|
8fcb5ad874 | ||
|
|
b4bf705b7f | ||
|
|
69d8321180 | ||
|
|
b03daeca66 | ||
|
|
c502484bcf | ||
|
|
448d69c6d8 | ||
|
|
42aa7cf926 | ||
|
|
52fa2e460e | ||
|
|
8855ae754a | ||
|
|
908a909c87 | ||
|
|
41aee797a9 | ||
|
|
685f636d5b | ||
|
|
4af56b0cb1 | ||
|
|
a3973c7e9a | ||
|
|
b270f30f3c | ||
|
|
03177cee0b | ||
|
|
9ee094dc20 | ||
|
|
b1d49c6765 | ||
|
|
b774e2152c | ||
|
|
e398ac8bcd | ||
|
|
01523b250f | ||
|
|
a2b0f311d5 | ||
|
|
88a52fb92c | ||
|
|
723f1665a7 | ||
|
|
4f92d3d58d | ||
|
|
05d06a4f31 | ||
|
|
f9ab545e0f | ||
|
|
b10d4c8b36 | ||
|
|
5a3ca024f8 | ||
|
|
92216c0c03 | ||
|
|
9ece3474c6 | ||
|
|
6dcc2086e6 | ||
|
|
08483711e2 | ||
|
|
0e100006d3 | ||
|
|
710c617862 | ||
|
|
3ed25c92cd | ||
|
|
f7644e6048 | ||
|
|
80afa6aff1 | ||
|
|
43de81929f | ||
|
|
e315e71d07 | ||
|
|
416eb34799 | ||
|
|
b7b13f51b2 | ||
|
|
2312187670 | ||
|
|
c7d0dc7c5f | ||
|
|
895cabee80 |
4
.gitattributes
vendored
Normal 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
@@ -4,4 +4,6 @@ build/
|
||||
DerivedData/
|
||||
|
||||
Carthage/Checkouts
|
||||
Carthage/Build
|
||||
Carthage/Build
|
||||
|
||||
.DS_Store
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -1,50 +1,8 @@
|
||||
import NetworkExtension
|
||||
|
||||
fileprivate var filterDomains: [String]!
|
||||
fileprivate var filterOptions: [(block: Bool, ignore: Bool)]!
|
||||
|
||||
|
||||
// MARK: Backward DNS Binary Tree Lookup
|
||||
|
||||
fileprivate func reloadDomainFilter() {
|
||||
let tmp = AppDB?.loadFilters()?.map({
|
||||
(String($0.reversed()), $1)
|
||||
}).sorted(by: { $0.0 < $1.0 }) ?? []
|
||||
filterDomains = tmp.map { $0.0 }
|
||||
filterOptions = tmp.map { ($1.contains(.blocked), $1.contains(.ignored)) }
|
||||
}
|
||||
|
||||
fileprivate func filterIndex(for domain: String) -> Int {
|
||||
let reverseDomain = String(domain.reversed())
|
||||
var lo = 0, hi = filterDomains.count - 1
|
||||
while lo <= hi {
|
||||
let mid = (lo + hi)/2
|
||||
if filterDomains[mid] < reverseDomain {
|
||||
lo = mid + 1
|
||||
} else if reverseDomain < filterDomains[mid] {
|
||||
hi = mid - 1
|
||||
} else {
|
||||
return mid
|
||||
}
|
||||
}
|
||||
if lo > 0, reverseDomain.hasPrefix(filterDomains[lo - 1] + ".") {
|
||||
return lo - 1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
private let queue = DispatchQueue.init(label: "PSIGlassDNSQueue", qos: .userInteractive, target: .main)
|
||||
|
||||
private func logAsync(_ domain: String, blocked: Bool) {
|
||||
queue.async {
|
||||
do {
|
||||
try AppDB?.logWrite(domain, blocked: blocked)
|
||||
} catch {
|
||||
DDLogWarn("Couldn't write: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let connectMessage: Data = "CONNECT".data(using: .ascii)!
|
||||
let swcdUserAgent: Data = "User-Agent: swcd".data(using: .ascii)!
|
||||
fileprivate var hook : GlassVPNHook!
|
||||
|
||||
// MARK: ObserverFactory
|
||||
|
||||
@@ -59,14 +17,17 @@ class LDObserverFactory: ObserverFactory {
|
||||
override func signal(_ event: ProxySocketEvent) {
|
||||
switch event {
|
||||
case .receivedRequest(let session, let socket):
|
||||
let i = filterIndex(for: session.host)
|
||||
if i >= 0 {
|
||||
let (block, ignore) = filterOptions[i]
|
||||
if !ignore { logAsync(session.host, blocked: block) }
|
||||
if block { 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 {
|
||||
// TODO: disable filter during recordings
|
||||
logAsync(session.host, blocked: false)
|
||||
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
|
||||
@@ -80,25 +41,63 @@ class LDObserverFactory: ObserverFactory {
|
||||
|
||||
class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||
|
||||
let proxyServerPort: UInt16 = 9090
|
||||
let proxyServerAddress = "127.0.0.1"
|
||||
var proxyServer: GCDHTTPProxyServer!
|
||||
private let proxyServerPort: UInt16 = 9090
|
||||
private let proxyServerAddress = "127.0.0.1"
|
||||
private var proxyServer: GCDHTTPProxyServer!
|
||||
|
||||
// MARK: Delegate
|
||||
|
||||
override func startTunnel(options: [String : NSObject]?, completionHandler: @escaping (Error?) -> Void) {
|
||||
DDLogVerbose("startTunnel with with options: \(String(describing: options))")
|
||||
PrefsShared.registerDefaults()
|
||||
do {
|
||||
try SQLiteDatabase.open().initCommonScheme()
|
||||
} catch {
|
||||
completionHandler(error)
|
||||
completionHandler(error) // if we cant open db, fail immediately
|
||||
return
|
||||
}
|
||||
reloadDomainFilter()
|
||||
|
||||
if proxyServer != nil {
|
||||
proxyServer.stop()
|
||||
}
|
||||
// stop previous if any
|
||||
if proxyServer != nil { proxyServer.stop() }
|
||||
proxyServer = nil
|
||||
|
||||
// Create proxy
|
||||
willInitProxy()
|
||||
|
||||
self.setTunnelNetworkSettings(createProxy()) { error in
|
||||
guard error == nil else {
|
||||
DDLogError("setTunnelNetworkSettings error: \(error!)")
|
||||
completionHandler(error)
|
||||
return
|
||||
}
|
||||
self.proxyServer = GCDHTTPProxyServer(address: IPAddress(fromString: self.proxyServerAddress), port: Port(port: self.proxyServerPort))
|
||||
do {
|
||||
try self.proxyServer.start()
|
||||
self.didInitProxy()
|
||||
completionHandler(nil)
|
||||
} catch let proxyError {
|
||||
DDLogError("Error starting proxy server \(proxyError)")
|
||||
completionHandler(proxyError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) {
|
||||
DDLogVerbose("stopTunnel with reason: \(reason)")
|
||||
shutdown()
|
||||
completionHandler()
|
||||
exit(EXIT_SUCCESS)
|
||||
}
|
||||
|
||||
override func handleAppMessage(_ messageData: Data, completionHandler: ((Data?) -> Void)?) {
|
||||
hook.handleAppMessage(messageData)
|
||||
}
|
||||
|
||||
// MARK: Helper
|
||||
|
||||
private func willInitProxy() {
|
||||
hook = GlassVPNHook()
|
||||
}
|
||||
|
||||
private func createProxy() -> NEPacketTunnelNetworkSettings {
|
||||
let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: proxyServerAddress)
|
||||
settings.mtu = NSNumber(value: 1500)
|
||||
|
||||
@@ -115,42 +114,29 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||
settings.proxySettings = proxySettings;
|
||||
RawSocketFactory.TunnelProvider = self
|
||||
ObserverFactory.currentFactory = LDObserverFactory()
|
||||
|
||||
self.setTunnelNetworkSettings(settings) { error in
|
||||
guard error == nil else {
|
||||
DDLogError("setTunnelNetworkSettings error: \(String(describing: error))")
|
||||
completionHandler(error)
|
||||
return
|
||||
}
|
||||
completionHandler(nil)
|
||||
|
||||
self.proxyServer = GCDHTTPProxyServer(address: IPAddress(fromString: self.proxyServerAddress), port: Port(port: self.proxyServerPort))
|
||||
do {
|
||||
try self.proxyServer.start()
|
||||
completionHandler(nil)
|
||||
}
|
||||
catch let proxyError {
|
||||
DDLogError("Error starting proxy server \(proxyError)")
|
||||
completionHandler(proxyError)
|
||||
}
|
||||
return settings
|
||||
}
|
||||
|
||||
private func didInitProxy() {
|
||||
if PrefsShared.RestartReminder.Enabled {
|
||||
PushNotification.scheduleRestartReminderBadge(on: false)
|
||||
PushNotification.cancel(.CantStopMeNowReminder)
|
||||
}
|
||||
}
|
||||
|
||||
override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) {
|
||||
DDLogVerbose("stopTunnel with reason: \(reason)")
|
||||
private func shutdown() {
|
||||
// proxy
|
||||
DNSServer.currentServer = nil
|
||||
RawSocketFactory.TunnelProvider = nil
|
||||
ObserverFactory.currentFactory = nil
|
||||
proxyServer.stop()
|
||||
proxyServer = nil
|
||||
filterDomains = nil
|
||||
filterOptions = nil
|
||||
completionHandler()
|
||||
exit(EXIT_SUCCESS)
|
||||
}
|
||||
|
||||
override func handleAppMessage(_ messageData: Data, completionHandler: ((Data?) -> Void)?) {
|
||||
reloadDomainFilter()
|
||||
// custom
|
||||
hook.cleanUp()
|
||||
hook = nil
|
||||
if PrefsShared.RestartReminder.Enabled {
|
||||
PushNotification.scheduleRestartReminderBadge(on: true)
|
||||
PushNotification.scheduleRestartReminderBanner()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
BIN
GlassVPN/SwiftSocket/.DS_Store
vendored
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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? {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -141,7 +141,14 @@ public class NWTCPSocket: NSObject, RawTCPSocketProtocol {
|
||||
|
||||
connection!.readMinimumLength(1, maximumLength: Opt.MAXNWTCPSocketReadDataSize) { data, error in
|
||||
guard error == nil else {
|
||||
DDLogError("NWTCPSocket got an error when reading data: \(String(describing: error))")
|
||||
let e = error! as NSError
|
||||
let ignore = (
|
||||
e.domain == "kNWErrorDomainPOSIX" && e.code == POSIXError.ECANCELED.rawValue // Operation canceled
|
||||
|| e.domain == NSPOSIXErrorDomain && e.code == POSIXError.ENOTCONN.rawValue // Socket is not connected
|
||||
)
|
||||
if !ignore {
|
||||
DDLogError("NWTCPSocket got an error when reading data: \(String(describing: error))")
|
||||
}
|
||||
self.queueCall {
|
||||
self.disconnect()
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
open class ResponseGeneratorFactory {
|
||||
static var HTTPProxyResponseGenerator: ResponseGenerator.Type?
|
||||
static var SOCKS5ProxyResponseGenerator: ResponseGenerator.Type?
|
||||
}
|
||||
48
GlassVPN/zhuhaow-NEKit/Rule/AllRule.swift
vendored
@@ -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
|
||||
}
|
||||
}
|
||||
60
GlassVPN/zhuhaow-NEKit/Rule/DNSFailRule.swift
vendored
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
16
GlassVPN/zhuhaow-NEKit/Rule/DirectRule.swift
vendored
@@ -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())
|
||||
}
|
||||
}
|
||||
84
GlassVPN/zhuhaow-NEKit/Rule/DomainListRule.swift
vendored
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
37
GlassVPN/zhuhaow-NEKit/Rule/Rule.swift
vendored
@@ -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
|
||||
}
|
||||
}
|
||||
80
GlassVPN/zhuhaow-NEKit/Rule/RuleManager.swift
vendored
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
4
GlassVPN/zhuhaow-NEKit/Tunnel/Tunnel.swift
vendored
@@ -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)
|
||||
}
|
||||
|
||||
69
README.md
@@ -1,4 +1,4 @@
|
||||
AppCheck – Privacy Monitor
|
||||
AppChk – Privacy Monitor
|
||||
==========================
|
||||
|
||||
A pocket DNS monitor and network filter.
|
||||
@@ -8,42 +8,71 @@ A pocket DNS monitor and network filter.
|
||||
|
||||
## What is it?
|
||||
|
||||
AppCheck helps you identify which applications communicate with third parties.
|
||||
It does so by logging network requests.
|
||||
AppCheck learns only the destination addresses, not the actual data that is exchanged.
|
||||
AppChk helps you identify applications that communicate with other parties.
|
||||
|
||||
Your data belongs to you.
|
||||
Therefore, monitoring and analysis take place on your device only.
|
||||
The app does not share any data with us or any other third-party – unless you choose to.
|
||||
Join the [Testflight beta][testflight] or look at the evaluation results [appchk.de].
|
||||
|
||||
|
||||
### How does it work?
|
||||
|
||||
AppCheck creates a local VPN tunnel to intercept all network connections.
|
||||
For each connection AppCheck looks into the DNS headers only, namely the domain names.
|
||||
These domain names are logged in the background while the VPN is active.
|
||||
That means, AppCheck does not have to be active in the foreground all the time.
|
||||
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 history of previous connections
|
||||
- See the history of previous connections
|
||||
- Block unwanted traffic based on domain names
|
||||
- Record app specific activity<sup>1</sup>
|
||||
- Apply logging filters
|
||||
|
||||
**… and soon:**
|
||||
|
||||
- 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
|
||||
- Occurrence Context Analysis
|
||||
- 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, recording is not limited to any single application. Remember to force-quit all other applications before starting a recording.
|
||||
<sup>1</sup> Due to technical limitations, recordings can not be restricted to a single application. Remember to force-quit all other applications before starting a recording.
|
||||
|
||||
|
||||
## Research Project
|
||||
|
||||
*information will be added soon*
|
||||
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/
|
||||
|
||||
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 141 KiB |
@@ -1,13 +1,9 @@
|
||||
import UIKit
|
||||
import NetworkExtension
|
||||
|
||||
let VPNConfigBundleIdentifier = "de.uni-bamberg.psi.AppCheck.VPN"
|
||||
|
||||
@UIApplicationMain
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
|
||||
var window: UIWindow?
|
||||
var managerVPN: NETunnelProviderManager?
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
if UserDefaults.standard.bool(forKey: "kill_db") {
|
||||
@@ -19,123 +15,25 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
db.initAppOnlyScheme()
|
||||
}
|
||||
|
||||
#if IOS_SIMULATOR
|
||||
TestDataSource.load()
|
||||
#endif
|
||||
Prefs.registerDefaults()
|
||||
PrefsShared.registerDefaults()
|
||||
|
||||
loadVPN { mgr in
|
||||
self.managerVPN = mgr
|
||||
self.postVPNState()
|
||||
}
|
||||
NSNotification.Name.NEVPNStatusDidChange.observe(call: #selector(vpnStatusChanged(_:)), on: self)
|
||||
NotifyDNSFilterChanged.observe(call: #selector(didChangeDomainFilter), on: self)
|
||||
#if IOS_SIMULATOR
|
||||
SimulatorVPN.load()
|
||||
#endif
|
||||
|
||||
sync.start()
|
||||
return true
|
||||
}
|
||||
|
||||
@objc private func vpnStatusChanged(_ notification: Notification) {
|
||||
postRawVPNState((notification.object as? NETunnelProviderSession)?.status ?? .invalid)
|
||||
}
|
||||
|
||||
@objc private func didChangeDomainFilter() {
|
||||
// Notify VPN extension about changes
|
||||
if let session = self.managerVPN?.connection as? NETunnelProviderSession,
|
||||
session.status == .connected {
|
||||
try? session.sendProviderMessage("filter-update".data(using: .ascii)!, responseHandler: nil)
|
||||
}
|
||||
}
|
||||
|
||||
func setProxyEnabled(_ newState: Bool) {
|
||||
guard let mgr = self.managerVPN else {
|
||||
self.createNewVPN { manager in
|
||||
self.managerVPN = manager
|
||||
self.setProxyEnabled(newState)
|
||||
}
|
||||
return
|
||||
}
|
||||
let state = mgr.isEnabled && (mgr.connection.status == .connected)
|
||||
if state != newState {
|
||||
self.updateVPN({ mgr.isEnabled = true }) {
|
||||
newState ? try? mgr.connection.startVPNTunnel() : mgr.connection.stopVPNTunnel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: VPN
|
||||
|
||||
private func createNewVPN(_ success: @escaping (_ manager: NETunnelProviderManager) -> Void) {
|
||||
let mgr = NETunnelProviderManager()
|
||||
mgr.localizedDescription = "AppCheck Monitor"
|
||||
let proto = NETunnelProviderProtocol()
|
||||
proto.providerBundleIdentifier = VPNConfigBundleIdentifier
|
||||
proto.serverAddress = "127.0.0.1"
|
||||
mgr.protocolConfiguration = proto
|
||||
mgr.isEnabled = true
|
||||
mgr.saveToPreferences { error in
|
||||
guard error == nil else {
|
||||
self.postProcessedVPNState(.off)
|
||||
//ErrorAlert(error!).presentIn(self.window?.rootViewController)
|
||||
return
|
||||
}
|
||||
success(mgr)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadVPN(_ finally: @escaping (_ manager: NETunnelProviderManager?) -> Void) {
|
||||
NETunnelProviderManager.loadAllFromPreferences { managers, error in
|
||||
guard let mgrs = managers, mgrs.count > 0 else {
|
||||
finally(nil)
|
||||
return
|
||||
}
|
||||
for mgr in mgrs {
|
||||
if let proto = (mgr.protocolConfiguration as? NETunnelProviderProtocol) {
|
||||
if proto.providerBundleIdentifier == VPNConfigBundleIdentifier {
|
||||
finally(mgr)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
finally(nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateVPN(_ body: @escaping () -> Void, _ onSuccess: @escaping () -> Void) {
|
||||
self.managerVPN?.loadFromPreferences { error in
|
||||
guard error == nil else { return }
|
||||
body()
|
||||
self.managerVPN?.saveToPreferences { error in
|
||||
guard error == nil else { return }
|
||||
onSuccess()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func postVPNState() {
|
||||
guard let mgr = self.managerVPN else {
|
||||
self.postRawVPNState(.invalid)
|
||||
return
|
||||
}
|
||||
mgr.loadFromPreferences { _ in
|
||||
self.postRawVPNState(mgr.connection.status)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Notifications
|
||||
|
||||
private func postRawVPNState(_ origState: NEVPNStatus) {
|
||||
let state: VPNState
|
||||
switch origState {
|
||||
case .connected: state = .on
|
||||
case .connecting, .disconnecting, .reasserting: state = .inbetween
|
||||
case .invalid, .disconnected: fallthrough
|
||||
@unknown default: state = .off
|
||||
}
|
||||
postProcessedVPNState(state)
|
||||
}
|
||||
|
||||
private func postProcessedVPNState(_ state: VPNState) {
|
||||
currentVPNState = state
|
||||
NotifyVPNStateChanged.post(state)
|
||||
func applicationDidBecomeActive(_ application: UIApplication) {
|
||||
TheGreatDestroyer.deleteLogs(olderThan: PrefsShared.AutoDeleteLogsDays)
|
||||
// FIXME: Does not reflect changes performed by GlassVPN auto-delete while app is open.
|
||||
// It will update whenever app restarts or becomes active again (only if deleteLogs has something to delete!)
|
||||
// This is a known issue and tolerated.
|
||||
}
|
||||
}
|
||||
|
||||
extension URL {
|
||||
@discardableResult func open() -> Bool { UIApplication.shared.openURL(self) }
|
||||
}
|
||||
|
||||
BIN
main/Assets.xcassets/.DS_Store
vendored
26
main/Assets.xcassets/circle-check.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "img.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "img@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "img@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
BIN
main/Assets.xcassets/circle-check.imageset/img.png
vendored
Normal file
|
After Width: | Height: | Size: 247 B |
BIN
main/Assets.xcassets/circle-check.imageset/img@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 423 B |
BIN
main/Assets.xcassets/circle-check.imageset/img@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 572 B |
26
main/Assets.xcassets/circle-x.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "img.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "img@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "img@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
BIN
main/Assets.xcassets/circle-x.imageset/img.png
vendored
Normal file
|
After Width: | Height: | Size: 235 B |
BIN
main/Assets.xcassets/circle-x.imageset/img@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 400 B |
BIN
main/Assets.xcassets/circle-x.imageset/img@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 530 B |
26
main/Assets.xcassets/detail-help.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "img.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "img@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "img@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
BIN
main/Assets.xcassets/detail-help.imageset/img.png
vendored
Normal file
|
After Width: | Height: | Size: 322 B |
BIN
main/Assets.xcassets/detail-help.imageset/img@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 510 B |
BIN
main/Assets.xcassets/detail-help.imageset/img@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 701 B |
@@ -19,5 +19,8 @@
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,5 +19,8 @@
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,5 +19,8 @@
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
|
||||
26
main/Assets.xcassets/jump-to-target.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "img.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "img@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "img@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
BIN
main/Assets.xcassets/jump-to-target.imageset/img.png
vendored
Normal file
|
After Width: | Height: | Size: 230 B |
BIN
main/Assets.xcassets/jump-to-target.imageset/img@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 409 B |
BIN
main/Assets.xcassets/jump-to-target.imageset/img@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 544 B |
26
main/Assets.xcassets/line-collapse.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "img.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "img@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "img@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
BIN
main/Assets.xcassets/line-collapse.imageset/img.png
vendored
Normal file
|
After Width: | Height: | Size: 150 B |
BIN
main/Assets.xcassets/line-collapse.imageset/img@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 216 B |
BIN
main/Assets.xcassets/line-collapse.imageset/img@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 283 B |
26
main/Assets.xcassets/line-expand.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "img.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "img@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "img@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
BIN
main/Assets.xcassets/line-expand.imageset/img.png
vendored
Normal file
|
After Width: | Height: | Size: 156 B |
BIN
main/Assets.xcassets/line-expand.imageset/img@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 224 B |
BIN
main/Assets.xcassets/line-expand.imageset/img@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 282 B |
237
main/Common Classes/CustomAlert.swift
Normal file
@@ -0,0 +1,237 @@
|
||||
import UIKit
|
||||
|
||||
class CustomAlert<CustomView: UIView>: UIViewController {
|
||||
|
||||
private let alertTitle: String?
|
||||
private let alertDetail: String?
|
||||
|
||||
private let customView: CustomView
|
||||
private var callback: ((CustomView) -> Void)?
|
||||
|
||||
/// Default: `[Cancel, Save]`
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import UIKit
|
||||
|
||||
class DatePickerAlert: UIViewController {
|
||||
|
||||
override var keyCommands: [UIKeyCommand]? {
|
||||
[UIKeyCommand(input: UIKeyCommand.inputEscape, modifierFlags: [], action: #selector(didTapCancel))]
|
||||
}
|
||||
|
||||
private var callback: (Date) -> Void
|
||||
private let picker: UIDatePicker = {
|
||||
let x = UIDatePicker()
|
||||
let h = x.sizeThatFits(.zero).height
|
||||
x.frame = CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: h)
|
||||
return x
|
||||
}()
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
@discardableResult required init(presentIn viewController: UIViewController, configure: ((UIDatePicker) -> Void)? = nil, onSuccess: @escaping (Date) -> Void) {
|
||||
callback = onSuccess
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
modalPresentationStyle = .custom
|
||||
if #available(iOS 13.0, *) {
|
||||
isModalInPresentation = true
|
||||
}
|
||||
presentIn(viewController, configure)
|
||||
}
|
||||
|
||||
internal override func loadView() {
|
||||
let cancel = QuickUI.button("Discard", target: self, action: #selector(didTapCancel))
|
||||
let save = QuickUI.button("Save", target: self, action: #selector(didTapSave))
|
||||
let now = QuickUI.button("Now", target: self, action: #selector(didTapNow))
|
||||
save.titleLabel?.font = save.titleLabel?.font.bold()
|
||||
now.titleLabel?.font = now.titleLabel?.font.bold()
|
||||
now.setTitleColor(.sysFg, for: .normal)
|
||||
//cancel.setTitleColor(.systemRed, for: .normal)
|
||||
|
||||
let buttons = UIStackView(arrangedSubviews: [cancel, now, save])
|
||||
buttons.axis = .horizontal
|
||||
buttons.distribution = .equalSpacing
|
||||
|
||||
let bg = UIView(frame: picker.frame)
|
||||
bg.frame.size.height += buttons.frame.height + 15
|
||||
bg.frame.origin.y = UIScreen.main.bounds.height - bg.frame.height - 15
|
||||
bg.backgroundColor = .sysBg
|
||||
bg.addSubview(picker)
|
||||
bg.addSubview(buttons)
|
||||
|
||||
let clearBg = UIView()
|
||||
clearBg.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
clearBg.addSubview(bg)
|
||||
|
||||
picker.anchor([.leading, .trailing, .top], to: bg)
|
||||
picker.bottomAnchor =&= buttons.topAnchor
|
||||
buttons.anchor([.leading, .trailing], to: bg, margin: 25)
|
||||
buttons.bottomAnchor =&= bg.bottomAnchor - 15
|
||||
bg.anchor([.leading, .trailing, .bottom], to: clearBg)
|
||||
|
||||
view = clearBg
|
||||
view.isHidden = true // otherwise picker will flash on present
|
||||
}
|
||||
|
||||
@objc private func didTapNow() {
|
||||
picker.date = Date()
|
||||
}
|
||||
|
||||
@objc private func didTapSave() {
|
||||
dismiss(animated: true) {
|
||||
self.callback(self.picker.date)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func didTapCancel() {
|
||||
dismiss(animated: true)
|
||||
}
|
||||
|
||||
private func presentIn(_ viewController: UIViewController, _ configure: ((UIDatePicker) -> Void)? = nil) {
|
||||
viewController.present(self, animated: false) {
|
||||
let control = self.view.subviews.first!
|
||||
let prev = control.frame.origin.y
|
||||
control.frame.origin.y += control.frame.height
|
||||
self.view.isHidden = false
|
||||
|
||||
configure?(self.picker)
|
||||
|
||||
UIView.animate(withDuration: 0.3) {
|
||||
self.view.backgroundColor = UIColor.black.withAlphaComponent(0.5)
|
||||
control.frame.origin.y = prev
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) {
|
||||
UIView.animate(withDuration: 0.3, animations: {
|
||||
let control = self.view.subviews.first!
|
||||
self.view.backgroundColor = .clear
|
||||
control.frame.origin.y += control.frame.height
|
||||
}) { _ in
|
||||
super.dismiss(animated: false, completion: completion)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,7 @@ class TagLabel: UILabel {
|
||||
@IBDesignable
|
||||
class MeterBar: UIView {
|
||||
@IBInspectable var percent: CGFloat = 0 { didSet { setNeedsDisplay() } }
|
||||
@IBInspectable var barColor: UIColor = .sysFg
|
||||
@IBInspectable var barColor: UIColor = .sysLink
|
||||
@IBInspectable var horizontal: Bool = false
|
||||
|
||||
private var normPercent: CGFloat { 1 - max(0, min(percent, 1)) }
|
||||
|
||||
71
main/Common Classes/NotificationBanner.swift
Normal 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?()
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
126
main/Common Classes/Prefs.swift
Normal 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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
105
main/Common Classes/PrefsShared.swift
Normal 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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, *) {
|
||||
|
||||
@@ -33,6 +33,13 @@ class SearchBarManager: NSObject, UISearchResultsUpdating {
|
||||
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
|
||||
@@ -42,7 +49,7 @@ class SearchBarManager: NSObject, UISearchResultsUpdating {
|
||||
}
|
||||
|
||||
/// Search callback
|
||||
func updateSearchResults(for controller: UISearchController) {
|
||||
internal func updateSearchResults(for controller: UISearchController) {
|
||||
NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(performSearch), object: nil)
|
||||
perform(#selector(performSearch), with: nil, afterDelay: 0.2)
|
||||
}
|
||||
|
||||
182
main/Common Classes/SlideInAnimation.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
31
main/Common Classes/ThrottledBatchQueue.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
68
main/Common Classes/TinyMarkdown.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -25,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
|
||||
@@ -37,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)
|
||||
@@ -59,7 +59,7 @@ class TutorialSheet: UIViewController, UIScrollViewDelegate {
|
||||
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
|
||||
@@ -132,9 +132,9 @@ class TutorialSheet: UIViewController, UIScrollViewDelegate {
|
||||
sheetBg.addSubview(button)
|
||||
|
||||
pager.anchor([.top, .left, .right], to: sheetBg)
|
||||
pageScroll.topAnchor =&= pager.bottomAnchor
|
||||
pageScroll.topAnchor =&= pager.bottomAnchor | .defaultHigh
|
||||
pageScroll.anchor([.left, .right, .top, .bottom], to: sheetBg, margin: sheetInset) | .defaultHigh
|
||||
button.topAnchor =&= pageScroll.bottomAnchor
|
||||
button.topAnchor =&= pageScroll.bottomAnchor | .defaultHigh
|
||||
button.anchor([.bottom, .centerX], to: sheetBg)
|
||||
// button.bottomAnchor =&= sheetBg.bottomAnchor - 30
|
||||
// button.centerXAnchor =&= sheetBg.centerXAnchor
|
||||
|
||||
@@ -20,27 +20,19 @@ extension SQLiteDatabase {
|
||||
try ifStep(stmt, SQLITE_ROW)
|
||||
return sqlite3_column_int(stmt, 0)
|
||||
}
|
||||
if version != 1 {
|
||||
if version != 2 {
|
||||
QLog.Info("migrate db \(version) -> 2")
|
||||
// version 0 -> 1: req(domain) -> heap(fqdn, domain)
|
||||
if version == 0 {
|
||||
try tempMigrate()
|
||||
// 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 = 1;")
|
||||
try run(sql: "PRAGMA user_version = 2;")
|
||||
}
|
||||
}
|
||||
|
||||
private func tempMigrate() throws { // TODO: remove with next internal release
|
||||
do {
|
||||
try run(sql: "SELECT 1 FROM req LIMIT 1;") // fails if req doesnt exist
|
||||
createFunction("domainof") { ($0.first as! String).extractDomain() }
|
||||
try run(sql: """
|
||||
BEGIN TRANSACTION;
|
||||
INSERT INTO heap(ts,fqdn,domain,opt) SELECT ts,domain,domainof(domain),nullif(logOpt,0) FROM req;
|
||||
DROP TABLE req;
|
||||
COMMIT;
|
||||
""")
|
||||
} catch { /* no need to migrate */ }
|
||||
}
|
||||
}
|
||||
|
||||
private enum TableName: String {
|
||||
@@ -54,6 +46,10 @@ extension SQLiteDatabase {
|
||||
return sqlite3_column_int64($0, 0)
|
||||
}) ?? 0
|
||||
}
|
||||
|
||||
fileprivate func col_ts(_ stmt: OpaquePointer, _ col: Int32) -> Timestamp {
|
||||
sqlite3_column_int64(stmt, col)
|
||||
}
|
||||
}
|
||||
|
||||
class WhereClauseBuilder: CustomStringConvertible {
|
||||
@@ -105,6 +101,7 @@ struct GroupedDomain {
|
||||
var options: FilterOptions? = nil
|
||||
}
|
||||
typealias GroupedTsOccurrence = (ts: Timestamp, total: Int32, blocked: Int32)
|
||||
typealias DomainTsPair = (domain: String, ts: Timestamp)
|
||||
|
||||
extension SQLiteDatabase {
|
||||
|
||||
@@ -116,11 +113,9 @@ extension SQLiteDatabase {
|
||||
guard lastRowId(.cache) > 0 else { return nil }
|
||||
let before = lastRowId(.heap) + 1
|
||||
createFunction("domainof") { ($0.first as! String).extractDomain() }
|
||||
try? run(sql:"""
|
||||
BEGIN TRANSACTION;
|
||||
transaction("""
|
||||
INSERT INTO heap(ts,fqdn,domain,opt) SELECT ts,dns,domainof(dns),nullif(opt&1,0) FROM cache;
|
||||
DELETE FROM cache;
|
||||
COMMIT;
|
||||
""")
|
||||
let after = lastRowId(.heap)
|
||||
return (before > after) ? nil : (before, after)
|
||||
@@ -150,7 +145,7 @@ extension SQLiteDatabase {
|
||||
func dnsLogsMinDate() -> Timestamp? {
|
||||
try? run(sql:"SELECT min(ts) FROM heap") {
|
||||
try ifStep($0, SQLITE_ROW)
|
||||
return sqlite3_column_int64($0, 0)
|
||||
return col_ts($0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,8 +159,19 @@ extension SQLiteDatabase {
|
||||
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 = sqlite3_column_int64($0, 1)
|
||||
return (max == 0) ? nil : (sqlite3_column_int64($0, 0), max)
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,10 +195,10 @@ extension SQLiteDatabase {
|
||||
}
|
||||
return try? run(sql: "SELECT \(col), COUNT(*), COUNT(opt), MAX(ts) FROM heap \(Where) GROUP BY \(col);", bind: Where.bindings) {
|
||||
allRows($0) {
|
||||
GroupedDomain(domain: readText($0, 0) ?? "",
|
||||
GroupedDomain(domain: col_text($0, 0) ?? "",
|
||||
total: sqlite3_column_int($0, 1),
|
||||
blocked: sqlite3_column_int($0, 2),
|
||||
lastModified: sqlite3_column_int64($0, 3))
|
||||
lastModified: col_ts($0, 3))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,7 +212,7 @@ extension SQLiteDatabase {
|
||||
let Where = WhereClauseBuilder().and(in: range).and("fqdn = ?", BindText(fqdn))
|
||||
return try? run(sql: "SELECT ts, COUNT(ts), COUNT(opt) FROM heap \(Where) GROUP BY ts ORDER BY ts DESC;", bind: Where.bindings) {
|
||||
allRows($0) {
|
||||
(sqlite3_column_int64($0, 0), sqlite3_column_int($0, 1), sqlite3_column_int($0, 2))
|
||||
(col_ts($0, 0), sqlite3_column_int($0, 1), sqlite3_column_int($0, 2))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -228,9 +234,10 @@ extension SQLiteDatabase {
|
||||
}
|
||||
|
||||
/// Get sorted, unique list of `ts` with given `fqdn`.
|
||||
func dnsLogsUniqTs(_ fqdn: String) -> [Timestamp]? {
|
||||
try? run(sql: "SELECT DISTINCT ts FROM heap WHERE fqdn = ? ORDER BY ts;", bind: [BindText(fqdn)]) {
|
||||
allRows($0) { sqlite3_column_int64($0, 0) }
|
||||
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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,7 +248,7 @@ extension SQLiteDatabase {
|
||||
/// - dt: Search for `ts - dt <= X <= ts + dt`
|
||||
/// - fqdn: Rows matching this domain will be excluded from the result set.
|
||||
/// - Returns: List of tuples ordered by rank (ASC).
|
||||
func contextAnalysis(coOccurrence times: [Timestamp], plusMinus dt: Timestamp, exclude fqdn: String) -> [ContextAnalysisResult]? {
|
||||
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
|
||||
@@ -266,12 +273,12 @@ extension SQLiteDatabase {
|
||||
SELECT fqdn, count, avg, (\(fnRank)) rank FROM (
|
||||
SELECT fqdn, COUNT(*) count, AVG(dist) avg FROM (
|
||||
SELECT fqdn, fnDist(ts) dist FROM heap
|
||||
WHERE ts BETWEEN ? AND ? AND fqdn != ? AND dist <= ?
|
||||
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(fqdn), BindInt64(dt)]) {
|
||||
""", bind: [BindInt64(dt), BindInt64(low), BindInt64(high), BindText(domain), BindInt64(dt)]) {
|
||||
allRows($0) {
|
||||
(readText($0, 0) ?? "", sqlite3_column_int($0, 1), sqlite3_column_double($0, 2), sqlite3_column_double($0, 3))
|
||||
(col_text($0, 0) ?? "", sqlite3_column_int($0, 1), sqlite3_column_double($0, 2), sqlite3_column_double($0, 3))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -282,7 +289,7 @@ extension SQLiteDatabase {
|
||||
// MARK: - Recordings
|
||||
|
||||
extension CreateTable {
|
||||
/// `id`: Primary, `start`: Timestamp, `stop`: Timestamp, `appid`: String, `title`: String, `notes`: String
|
||||
/// `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,
|
||||
@@ -290,19 +297,25 @@ extension CreateTable {
|
||||
stop INTEGER,
|
||||
appid TEXT,
|
||||
title TEXT,
|
||||
notes 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 {
|
||||
|
||||
@@ -329,8 +342,9 @@ extension SQLiteDatabase {
|
||||
|
||||
/// Update given recording by replacing `title`, `appid`, and `notes` with new values.
|
||||
func recordingUpdate(_ r: Recording) {
|
||||
try? run(sql: "UPDATE rec SET title = ?, appid = ?, notes = ? WHERE id = ? LIMIT 1;",
|
||||
bind: [BindTextOrNil(r.title), BindTextOrNil(r.appId), BindTextOrNil(r.notes), BindInt64(r.id)]) { stmt -> Void in
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -348,37 +362,55 @@ extension SQLiteDatabase {
|
||||
// MARK: read
|
||||
|
||||
private func readRecording(_ stmt: OpaquePointer) -> Recording {
|
||||
let end = sqlite3_column_int64(stmt, 2)
|
||||
let end = col_ts(stmt, 2)
|
||||
return Recording(id: sqlite3_column_int64(stmt, 0),
|
||||
start: sqlite3_column_int64(stmt, 1),
|
||||
start: col_ts(stmt, 1),
|
||||
stop: end == 0 ? nil : end,
|
||||
appId: readText(stmt, 3),
|
||||
title: readText(stmt, 4),
|
||||
notes: readText(stmt, 5))
|
||||
appId: col_text(stmt, 3),
|
||||
title: col_text(stmt, 4),
|
||||
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 * FROM rec WHERE stop IS NULL LIMIT 1;") {
|
||||
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 * FROM rec WHERE stop IS NOT NULL;") {
|
||||
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 * FROM rec WHERE id = ? LIMIT 1;", bind: [BindInt64(withID)]) {
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -396,8 +428,6 @@ extension CreateTable {
|
||||
"""}
|
||||
}
|
||||
|
||||
typealias RecordLog = (domain: String, count: Int32)
|
||||
|
||||
extension SQLiteDatabase {
|
||||
|
||||
// MARK: write
|
||||
@@ -426,13 +456,24 @@ extension SQLiteDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete one recording log entry with given `recording id`, matching `domain`, and `ts`.
|
||||
/// - Returns: `true` if row was deleted
|
||||
func recordingLogsDelete(_ recId: sqlite3_int64, singleEntry ts: Timestamp, domain: String) throws -> Bool {
|
||||
try run(sql: "DELETE FROM recLog WHERE rid = ? AND ts = ? AND domain = ? LIMIT 1;",
|
||||
bind: [BindInt64(recId), BindInt64(ts), BindText(domain)]) {
|
||||
try ifStep($0, SQLITE_DONE)
|
||||
return numberOfChanges > 0
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: read
|
||||
|
||||
/// List of domains and count occurences for given recording.
|
||||
func recordingLogsGetGrouped(_ r: Recording) -> [RecordLog]? {
|
||||
try? run(sql: "SELECT domain, COUNT() FROM recLog WHERE rid = ? GROUP BY domain;",
|
||||
/// - Returns: List of `(domain, ts)` pairs. Sorted by `ts` in ascending order (oldest first)
|
||||
func recordingLogsGet(_ r: Recording) -> [DomainTsPair]? {
|
||||
try? run(sql: "SELECT domain, ts FROM recLog WHERE rid = ? ORDER BY ts ASC, rowid DESC;",
|
||||
bind: [BindInt64(r.id)]) {
|
||||
allRows($0) { (readText($0, 0) ?? "", sqlite3_column_int($0, 1)) }
|
||||
allRows($0) { (col_text($0, 0) ?? "", col_ts($0, 1)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,9 +39,26 @@ extension SQLiteDatabase {
|
||||
/// `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)])
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,7 +79,9 @@ struct FilterOptions: OptionSet {
|
||||
static let none = FilterOptions([])
|
||||
static let blocked = FilterOptions(rawValue: 1 << 0)
|
||||
static let ignored = FilterOptions(rawValue: 1 << 1)
|
||||
static let any = FilterOptions(rawValue: 0b11)
|
||||
static let customA = FilterOptions(rawValue: 1 << 2)
|
||||
static let customB = FilterOptions(rawValue: 1 << 3)
|
||||
static let any = FilterOptions(rawValue: 0b1111)
|
||||
}
|
||||
|
||||
extension SQLiteDatabase {
|
||||
@@ -71,7 +90,7 @@ extension SQLiteDatabase {
|
||||
return try? run(sql: "SELECT domain, opt FROM filter \(rv>0 ? "WHERE opt & ?" : "");",
|
||||
bind: rv>0 ? [BindInt32(rv)] : []) {
|
||||
allRowsKeyed($0) {
|
||||
(key: readText($0, 0) ?? "",
|
||||
(key: col_text($0, 0) ?? "",
|
||||
value: FilterOptions(rawValue: sqlite3_column_int($0, 1)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ class SQLiteDatabase {
|
||||
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) }
|
||||
@@ -91,15 +92,20 @@ class SQLiteDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
/// `BEGIN TRANSACTION; \(sql); COMMIT;` on exception rollback.
|
||||
func transaction(_ sql: String) {
|
||||
do { try run(sql: "BEGIN TRANSACTION; \(sql); COMMIT;") }
|
||||
catch { rollback() }
|
||||
}
|
||||
|
||||
func ifStep(_ stmt: OpaquePointer, _ expected: Int32) throws {
|
||||
guard sqlite3_step(stmt) == expected else {
|
||||
throw SQLiteError.Step(message: errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func vacuum() {
|
||||
try? run(sql: "VACUUM;")
|
||||
}
|
||||
func vacuum() { NSLog("[SQL] VACUUM"); try? run(sql: "VACUUM;"); }
|
||||
func rollback() { NSLog("[SQL] ROLLBACK"); try? run(sql: "ROLLBACK;"); }
|
||||
}
|
||||
|
||||
|
||||
@@ -163,6 +169,10 @@ 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 }
|
||||
@@ -193,7 +203,7 @@ extension SQLiteDatabase {
|
||||
var numberOfChanges: Int32 { get { sqlite3_changes(dbPointer) } }
|
||||
var lastInsertedRow: SQLiteRowID { get { sqlite3_last_insert_rowid(dbPointer) } }
|
||||
|
||||
func readText(_ stmt: OpaquePointer, _ col: Int32) -> String? {
|
||||
func col_text(_ stmt: OpaquePointer, _ col: Int32) -> String? {
|
||||
let val = sqlite3_column_text(stmt, col)
|
||||
return (val != nil ? String(cString: val!) : nil)
|
||||
}
|
||||
|
||||
@@ -33,8 +33,13 @@ extension FilterOptions {
|
||||
}
|
||||
|
||||
extension Recording {
|
||||
var fallbackTitle: String { get { "Unnamed Recording #\(id)" } }
|
||||
var duration: Timestamp? { get { stop == nil ? nil : stop! - start } }
|
||||
var durationString: String? { get { stop == nil ? nil : TimeFormat.from(duration!) } }
|
||||
static let minTimeLongTerm: Timestamp = .hours(1)
|
||||
|
||||
var fallbackTitle: String { get {
|
||||
isLongTerm ? "Background Recording" : "Unnamed Recording #\(id)"
|
||||
} }
|
||||
var duration: Timestamp { get { (stop ?? .now()) - start } }
|
||||
var isLongTerm: Bool { duration > Recording.minTimeLongTerm }
|
||||
var isShared: Bool { uploadkey?.count ?? 0 > 0}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ struct TheGreatDestroyer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fired when user taps on Settings -> Delete All Logs
|
||||
/// Fired when user taps on Settings -> "Delete All Logs"
|
||||
static func deleteAllLogs() {
|
||||
sync.pause()
|
||||
DispatchQueue.global().async {
|
||||
@@ -26,4 +26,21 @@ struct TheGreatDestroyer {
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fired when user changes Settings -> "Auto-delete logs" and every time the App enters foreground
|
||||
static func deleteLogs(olderThan days: Int) {
|
||||
guard days > 0 else { return }
|
||||
sync.pause()
|
||||
DispatchQueue.global().async {
|
||||
defer { sync.continue() }
|
||||
QLog.Info("Auto-delete logs")
|
||||
do {
|
||||
if try AppDB!.dnsLogsDeleteOlderThan(days: days) {
|
||||
sync.needsReloadDB()
|
||||
}
|
||||
} catch {
|
||||
QLog.Warning("Couldn't auto-delete logs, \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,10 +21,13 @@ enum DomainFilter {
|
||||
}
|
||||
|
||||
/// Get total number of blocked and ignored domains. Shown in settings overview.
|
||||
static func counts() -> (blocked: Int, ignored: Int) {
|
||||
data.reduce(into: (0, 0)) {
|
||||
static func counts() -> (blocked: Int, ignored: Int, listCustomA: Int, listCustomB: Int) {
|
||||
data.reduce(into: (0, 0, 0, 0)) {
|
||||
if $1.1.contains(.blocked) { $0.0 += 1 }
|
||||
if $1.1.contains(.ignored) { $0.1 += 1 } }
|
||||
if $1.1.contains(.ignored) { $0.1 += 1 }
|
||||
if $1.1.contains(.customA) { $0.2 += 1 }
|
||||
if $1.1.contains(.customB) { $0.3 += 1 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Union `filter` with set.
|
||||
|
||||
@@ -55,8 +55,8 @@ class GroupedDomainDataSource: FilterPipelineDelegate, SyncUpdateDelegate {
|
||||
/// Read user defaults and apply new sorting order. Either by setting a new or reversing the current.
|
||||
/// - Parameter force: If `true` set new sorting even if the type does not differ.
|
||||
private func resetSortingOrder(force: Bool = false) {
|
||||
let orderAscChanged = (orderAsc <-? Pref.DateFilter.OrderAsc)
|
||||
let orderTypChanged = (currentOrder <-? Pref.DateFilter.OrderBy)
|
||||
let orderAscChanged = (orderAsc <-? Prefs.DateFilter.OrderAsc)
|
||||
let orderTypChanged = (currentOrder <-? Prefs.DateFilter.OrderBy)
|
||||
if orderTypChanged || force {
|
||||
switch currentOrder {
|
||||
case .Date:
|
||||
|
||||
@@ -13,6 +13,9 @@ enum RecordingsDB {
|
||||
/// Get list of all recordings
|
||||
static func list() -> [Recording] { AppDB?.recordingGetAll() ?? [] }
|
||||
|
||||
/// Get `Timestamp` of latest recording
|
||||
static func lastTimestamp() -> Timestamp? { AppDB?.recordingLastTimestamp() }
|
||||
|
||||
/// Copy log entries from generic `heap` table to recording specific `recLog` table
|
||||
static func persist(_ r: Recording) {
|
||||
sync.syncNow { // persist changes in cache before copying recording details
|
||||
@@ -21,8 +24,20 @@ enum RecordingsDB {
|
||||
}
|
||||
|
||||
/// Get list of domains that occured during the recording
|
||||
static func details(_ r: Recording) -> [RecordLog] {
|
||||
AppDB?.recordingLogsGetGrouped(r) ?? []
|
||||
static func details(_ r: Recording) -> [DomainTsPair] {
|
||||
AppDB?.recordingLogsGet(r) ?? []
|
||||
}
|
||||
|
||||
/// Get dictionary of domains with `ts` in ascending order.
|
||||
static func detailCluster(_ r: Recording) -> [String : [Timestamp]] {
|
||||
var cluster: [String : [Timestamp]] = [:]
|
||||
for (dom, ts) in details(r) {
|
||||
if cluster[dom] == nil {
|
||||
cluster[dom] = []
|
||||
}
|
||||
cluster[dom]!.append(ts - r.start)
|
||||
}
|
||||
return cluster
|
||||
}
|
||||
|
||||
/// Update `title`, `appid`, and `notes` and post `NotifyRecordingChanged` notification.
|
||||
@@ -43,5 +58,16 @@ enum RecordingsDB {
|
||||
static func deleteDetails(_ r: Recording, domain: String) -> Bool {
|
||||
((try? AppDB?.recordingLogsDelete(r.id, matchingDomain: domain)) ?? 0) > 0
|
||||
}
|
||||
|
||||
/// Delete individual entries from recording while keeping the recording alive.
|
||||
/// - Returns: `true` if at least one row is deleted.
|
||||
static func deleteSingle(_ r: Recording, domain: String, ts: Timestamp) -> Bool {
|
||||
(try? AppDB?.recordingLogsDelete(r.id, singleEntry: ts, domain: domain)) ?? false
|
||||
}
|
||||
|
||||
/// Return list of previously used apps found in all recordings.
|
||||
static func appList() -> [AppBundleInfo] {
|
||||
AppDB?.appBundleList() ?? []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@ import Foundation
|
||||
|
||||
#if IOS_SIMULATOR
|
||||
|
||||
class TestDataSource {
|
||||
fileprivate var hook : GlassVPNHook!
|
||||
|
||||
class SimulatorVPN {
|
||||
static var timer: Timer?
|
||||
|
||||
static func load() {
|
||||
QLog.Debug("SQLite path: \(URL.internalDB())")
|
||||
@@ -27,13 +30,36 @@ class TestDataSource {
|
||||
db.setFilter("bi.test.com", [.blocked, .ignored])
|
||||
|
||||
QLog.Debug("Done")
|
||||
|
||||
Timer.repeating(2, call: #selector(insertRandom), on: self)
|
||||
}
|
||||
|
||||
static func start() {
|
||||
hook = GlassVPNHook()
|
||||
timer = Timer.repeating(2, call: #selector(insertRandom), on: self)
|
||||
}
|
||||
|
||||
static func stop() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
hook.cleanUp()
|
||||
hook = nil
|
||||
}
|
||||
|
||||
@objc static func insertRandom() {
|
||||
//QLog.Debug("Inserting 1 periodic log entry")
|
||||
try? AppDB?.logWrite("\(arc4random() % 5).count.test.com", blocked: true)
|
||||
let rand = arc4random() % 8
|
||||
let domain: String
|
||||
switch rand {
|
||||
case 6: domain = "tmp.b.test.com"
|
||||
case 7: domain = "tmp.i.test.com"
|
||||
case 8: domain = "tmp.bi.test.com"
|
||||
default: domain = "\(rand).count.test.com"
|
||||
}
|
||||
let kill = hook.processDNSRequest(domain)
|
||||
if kill { QLog.Info("Blocked: \(domain)") }
|
||||
}
|
||||
|
||||
static func sendMsg(_ messageData: Data) {
|
||||
hook.handleAppMessage(messageData)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,5 +1,7 @@
|
||||
import UIKit
|
||||
|
||||
let sync = SyncUpdate(periodic: 7)
|
||||
|
||||
class SyncUpdate {
|
||||
private var lastSync: TimeInterval = 0
|
||||
private var timer: Timer!
|
||||
@@ -18,8 +20,8 @@ class SyncUpdate {
|
||||
private(set) var tsLatest: Timestamp? // as set per user, not actual latest
|
||||
|
||||
|
||||
init(periodic interval: TimeInterval) {
|
||||
(filterType, tsEarliest, tsLatest) = Pref.DateFilter.restrictions()
|
||||
fileprivate init(periodic interval: TimeInterval) {
|
||||
(filterType, tsEarliest, tsLatest) = Prefs.DateFilter.restrictions()
|
||||
reloadRangeFromDB()
|
||||
|
||||
NotifyDateFilterChanged.observe(call: #selector(didChangeDateFilter), on: self)
|
||||
@@ -33,7 +35,7 @@ class SyncUpdate {
|
||||
/// Callback fired when user changes `DateFilter` on root tableView controller
|
||||
@objc private func didChangeDateFilter() {
|
||||
self.pause()
|
||||
let filter = Pref.DateFilter.restrictions()
|
||||
let filter = Prefs.DateFilter.restrictions()
|
||||
filterType = filter.type
|
||||
DispatchQueue.global().async {
|
||||
// Not necessary, but improve execution order (delete then insert).
|
||||
@@ -109,7 +111,7 @@ class SyncUpdate {
|
||||
}
|
||||
}
|
||||
if filterType == .LastXMin {
|
||||
set(newEarliest: Timestamp.past(minutes: Pref.DateFilter.LastXMin))
|
||||
set(newEarliest: Timestamp.past(minutes: Prefs.DateFilter.LastXMin))
|
||||
}
|
||||
// TODO: periodic hard delete old logs (will reset rowids!)
|
||||
}
|
||||
|
||||
@@ -31,12 +31,21 @@ func ErrorAlert(_ errorDescription: String, buttonText: String = "Dismiss") -> U
|
||||
/// - Parameters:
|
||||
/// - buttonText: Default: `"Continue"`
|
||||
/// - buttonStyle: Default: `.default`
|
||||
func AskAlert(title: String?, text: String?, buttonText: String = "Continue", buttonStyle: UIAlertAction.Style = .default, action: @escaping (UIAlertController) -> Void) -> UIAlertController {
|
||||
let alert = Alert(title: title, text: text, buttonText: "Cancel")
|
||||
func AskAlert(title: String?, text: String?, buttonText: String = "Continue", cancelButton: String = "Cancel", buttonStyle: UIAlertAction.Style = .default, action: @escaping (UIAlertController) -> Void) -> UIAlertController {
|
||||
let alert = Alert(title: title, text: text, buttonText: cancelButton)
|
||||
alert.addAction(UIAlertAction(title: buttonText, style: buttonStyle) { _ in action(alert) })
|
||||
return alert
|
||||
}
|
||||
|
||||
/// Show alert hinting the user to go to system settings and re-enable notifications.
|
||||
func NotificationsDisabledAlert(presentIn viewController: UIViewController) {
|
||||
AskAlert(title: "Notifications Disabled",
|
||||
text: "Go to System Settings > Notifications > AppChk to re-enable notifications.",
|
||||
buttonText: "Open settings") { _ in
|
||||
URL(string: UIApplication.openSettingsURLString)?.open()
|
||||
}.presentIn(viewController)
|
||||
}
|
||||
|
||||
// MARK: Alert with multiple options
|
||||
|
||||
/// - Parameters:
|
||||
|
||||
@@ -47,6 +47,15 @@ extension NSLayoutConstraint {
|
||||
@discardableResult static func |(l: NSLayoutConstraint, r: UILayoutPriority) -> NSLayoutConstraint { l.priority = r; return l }
|
||||
}
|
||||
|
||||
extension NSLayoutDimension {
|
||||
/// Create and activate an `equal` constraint with constant value. Format: `A.anchor =&= constant | priority`
|
||||
@discardableResult static func =&= (l: NSLayoutDimension, r: CGFloat) -> NSLayoutConstraint { l.constraint(equalToConstant: r).on() }
|
||||
/// Create and activate a `lessThan` constraint with constant value. Format: `A.anchor =<= constant | priority`
|
||||
@discardableResult static func =<= (l: NSLayoutDimension, r: CGFloat) -> NSLayoutConstraint { l.constraint(lessThanOrEqualToConstant: r).on() }
|
||||
/// Create and activate a `greaterThan` constraint with constant value. Format: `A.anchor =>= constant | priority`
|
||||
@discardableResult static func =>= (l: NSLayoutDimension, r: CGFloat) -> NSLayoutConstraint { l.constraint(greaterThanOrEqualToConstant: r).on() }
|
||||
}
|
||||
|
||||
/*
|
||||
UIView extension to generate multiple constraints at once
|
||||
|
||||
@@ -73,6 +82,12 @@ extension UIView {
|
||||
return NSLayoutConstraint(item: A, attribute: $0, relatedBy: rel, toItem: B, attribute: $0, multiplier: 1, constant: margin).on()
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the priority with which a view resists being made smaller and larger than its intrinsic size.
|
||||
func constrainHuggingCompression(_ axis: NSLayoutConstraint.Axis, _ priotity: UILayoutPriority) {
|
||||
setContentHuggingPriority(priotity, for: axis)
|
||||
setContentCompressionResistancePriority(priotity, for: axis)
|
||||
}
|
||||
}
|
||||
|
||||
extension Array where Element: NSLayoutConstraint {
|
||||
|
||||
25
main/Extensions/Color.swift
Normal file
@@ -0,0 +1,25 @@
|
||||
import UIKit
|
||||
|
||||
// See: https://noahgilmore.com/blog/dark-mode-uicolor-compatibility/
|
||||
extension UIColor {
|
||||
/// `.systemBackground ?? .white`
|
||||
static var sysBackground: UIColor { if #available(iOS 13.0, *) { return .systemBackground } else { return .white } }
|
||||
/// `.link ?? .systemBlue`
|
||||
static var sysLink: UIColor { if #available(iOS 13.0, *) { return .link } else { return .systemBlue } }
|
||||
|
||||
/// `.label ?? .black`
|
||||
static var sysLabel: UIColor { if #available(iOS 13.0, *) { return .label } else { return .black } }
|
||||
/// `.secondaryLabel ?? rgba(60, 60, 67, 0.6)`
|
||||
static var sysLabel2: UIColor { if #available(iOS 13.0, *) { return .secondaryLabel } else { return .init(red: 60/255.0, green: 60/255.0, blue: 67/255.0, alpha: 0.6) } }
|
||||
/// `.tertiaryLabel ?? rgba(60, 60, 67, 0.3)`
|
||||
static var sysLabel3: UIColor { if #available(iOS 13.0, *) { return .tertiaryLabel } else { return .init(red: 60/255.0, green: 60/255.0, blue: 67/255.0, alpha: 0.3) } }
|
||||
}
|
||||
|
||||
extension NSMutableAttributedString {
|
||||
func withColor(_ color: UIColor, fromBack: Int) -> Self {
|
||||
let l = length - fromBack
|
||||
let r = (l < 0) ? NSMakeRange(0, length) : NSMakeRange(l, fromBack)
|
||||
self.addAttribute(.foregroundColor, value: color, range: r)
|
||||
return self
|
||||
}
|
||||
}
|
||||
33
main/Extensions/Equatable.swift
Normal file
@@ -0,0 +1,33 @@
|
||||
|
||||
precedencegroup CompareAssignPrecedence {
|
||||
assignment: true
|
||||
associativity: left
|
||||
higherThan: ComparisonPrecedence
|
||||
}
|
||||
|
||||
infix operator <-? : CompareAssignPrecedence
|
||||
infix operator <-/ : CompareAssignPrecedence
|
||||
|
||||
extension Equatable {
|
||||
/// Assign a new value to `lhs` if `newValue` differs from the previous value. Return `false` if they are equal.
|
||||
/// - Returns: `true` if `lhs` was overwritten with another value
|
||||
static func <-?(lhs: inout Self, newValue: Self) -> Bool {
|
||||
if lhs != newValue {
|
||||
lhs = newValue
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// Assign a new value to `lhs` if `newValue` differs from the previous value.
|
||||
/// Return tuple with both values. Or `nil` if they are equal.
|
||||
/// - Returns: `nil` if `previousValue == newValue`
|
||||
static func <-/(lhs: inout Self, newValue: Self) -> (previousValue: Self, newValue: Self)? {
|
||||
let previousValue = lhs
|
||||
if previousValue != newValue {
|
||||
lhs = newValue
|
||||
return (previousValue, newValue)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ extension UIFont {
|
||||
}
|
||||
func bold() -> UIFont { withTraits(traits: .traitBold) }
|
||||
func italic() -> UIFont { withTraits(traits: .traitItalic) }
|
||||
func boldItalic() -> UIFont { withTraits(traits: [.traitBold, .traitItalic]) }
|
||||
func monoSpace() -> UIFont {
|
||||
let traits = fontDescriptor.object(forKey: .traits) as? [UIFontDescriptor.TraitKey: Any] ?? [:]
|
||||
let weight = (traits[.weight] as? CGFloat) ?? UIFont.Weight.regular.rawValue
|
||||
@@ -13,39 +14,35 @@ extension UIFont {
|
||||
}
|
||||
}
|
||||
|
||||
extension NSAttributedString {
|
||||
static func image(_ img: UIImage) -> Self {
|
||||
extension NSMutableAttributedString {
|
||||
convenience init(image: UIImage, centered: Bool = false) {
|
||||
self.init()
|
||||
let att = NSTextAttachment()
|
||||
att.image = img
|
||||
return self.init(attachment: att)
|
||||
att.image = image
|
||||
append(.init(attachment: att))
|
||||
if centered {
|
||||
let ps = NSMutableParagraphStyle()
|
||||
ps.alignment = .center
|
||||
addAttribute(.paragraphStyle, value: ps, range: .init(location: 0, length: length))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension NSMutableAttributedString {
|
||||
static private var def: UIFont = .preferredFont(forTextStyle: .body)
|
||||
@discardableResult func normal(_ str: String, _ style: UIFont.TextStyle = .body) -> Self { append(str, withFont: .preferredFont(forTextStyle: style)) }
|
||||
@discardableResult func bold(_ str: String, _ style: UIFont.TextStyle = .body) -> Self { append(str, withFont: UIFont.preferredFont(forTextStyle: style).bold()) }
|
||||
@discardableResult func italic(_ str: String, _ style: UIFont.TextStyle = .body) -> Self { append(str, withFont: UIFont.preferredFont(forTextStyle: style).italic()) }
|
||||
@discardableResult func boldItalic(_ str: String, _ style: UIFont.TextStyle = .body) -> Self { append(str, withFont: UIFont.preferredFont(forTextStyle: style).boldItalic()) }
|
||||
|
||||
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) }
|
||||
@discardableResult func h1(_ str: String) -> Self { normal(str, .title1) }
|
||||
@discardableResult func h2(_ str: String) -> Self { normal(str, .title2) }
|
||||
@discardableResult 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
|
||||
.foregroundColor : UIColor.sysLabel
|
||||
]))
|
||||
return self
|
||||
}
|
||||
|
||||
func centered(_ content: NSAttributedString) -> Self {
|
||||
let before = length
|
||||
append(content)
|
||||
let ps = NSMutableParagraphStyle()
|
||||
ps.alignment = .center
|
||||
addAttribute(.paragraphStyle, value: ps, range: .init(location: before, length: content.length))
|
||||
return self
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import UIKit
|
||||
|
||||
struct QLog {
|
||||
private init() {}
|
||||
static func m(_ message: String) { write("", message) }
|
||||
static func Info(_ message: String) { write("[INFO] ", message) }
|
||||
#if DEBUG
|
||||
static func Debug(_ message: String) { write("[DEBUG] ", message) }
|
||||
#else
|
||||
static func Debug(_ _: String) {}
|
||||
#endif
|
||||
static func Error(_ message: String) { write("[ERROR] ", message) }
|
||||
static func Warning(_ message: String) { write("[WARN] ", message) }
|
||||
private static func write(_ tag: String, _ message: String) {
|
||||
print(String(format: "%1.3f %@%@", Date().timeIntervalSince1970, tag, message))
|
||||
}
|
||||
}
|
||||
|
||||
extension UIColor {
|
||||
static var sysBg: UIColor { get { if #available(iOS 13.0, *) { return .systemBackground } else { return .white } }}
|
||||
static var sysFg: UIColor { get { if #available(iOS 13.0, *) { return .label } else { return .black } }}
|
||||
}
|
||||
|
||||
extension UIEdgeInsets {
|
||||
init(all: CGFloat = 0, top: CGFloat? = nil, left: CGFloat? = nil, bottom: CGFloat? = nil, right: CGFloat? = nil) {
|
||||
self.init(top: top ?? all, left: left ?? all, bottom: bottom ?? all, right: right ?? all)
|
||||
}
|
||||
}
|
||||
|
||||
precedencegroup CompareAssignPrecedence {
|
||||
assignment: true
|
||||
associativity: left
|
||||
higherThan: ComparisonPrecedence
|
||||
}
|
||||
|
||||
infix operator <-? : CompareAssignPrecedence
|
||||
infix operator <-/ : CompareAssignPrecedence
|
||||
extension Equatable {
|
||||
/// Assign a new value to `lhs` if `newValue` differs from the previous value. Return `false` if they are equal.
|
||||
/// - Returns: `true` if `lhs` was overwritten with another value
|
||||
static func <-?(lhs: inout Self, newValue: Self) -> Bool {
|
||||
if lhs != newValue {
|
||||
lhs = newValue
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// Assign a new value to `lhs` if `newValue` differs from the previous value.
|
||||
/// Return tuple with both values. Or `nil` if they are equal.
|
||||
/// - Returns: `nil` if `previousValue == newValue`
|
||||
static func <-/(lhs: inout Self, newValue: Self) -> (previousValue: Self, newValue: Self)? {
|
||||
let previousValue = lhs
|
||||
if previousValue != newValue {
|
||||
lhs = newValue
|
||||
return (previousValue, newValue)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||