diff --git a/AppCheck.xcodeproj/project.pbxproj b/AppCheck.xcodeproj/project.pbxproj index f876283..b2cff7e 100644 --- a/AppCheck.xcodeproj/project.pbxproj +++ b/AppCheck.xcodeproj/project.pbxproj @@ -27,6 +27,7 @@ 54448A30248647D900771C96 /* Time.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54448A2F248647D900771C96 /* Time.swift */; }; 54448A3224899A4000771C96 /* SearchBarManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54448A3124899A4000771C96 /* SearchBarManager.swift */; }; 544C95262407B1C700AB89D0 /* SharedState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 544C95252407B1C700AB89D0 /* SharedState.swift */; }; + 544F912024A67EC5001D4B00 /* TVCOccurrenceContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 544F911F24A67EC5001D4B00 /* TVCOccurrenceContext.swift */; }; 5458EBC0243A3F2200CFEB15 /* TVCRecordingDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5458EBBF243A3F2200CFEB15 /* TVCRecordingDetails.swift */; }; 545DDDCF243E6267003B6544 /* TutorialSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 545DDDCE243E6267003B6544 /* TutorialSheet.swift */; }; 545DDDD124436983003B6544 /* QuickUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 545DDDD024436983003B6544 /* QuickUI.swift */; }; @@ -191,6 +192,7 @@ 54448A2F248647D900771C96 /* Time.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Time.swift; sourceTree = ""; }; 54448A3124899A4000771C96 /* SearchBarManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBarManager.swift; sourceTree = ""; }; 544C95252407B1C700AB89D0 /* SharedState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedState.swift; sourceTree = ""; }; + 544F911F24A67EC5001D4B00 /* TVCOccurrenceContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVCOccurrenceContext.swift; sourceTree = ""; }; 5458EBBF243A3F2200CFEB15 /* TVCRecordingDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVCRecordingDetails.swift; sourceTree = ""; }; 545DDDCE243E6267003B6544 /* TutorialSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialSheet.swift; sourceTree = ""; }; 545DDDD024436983003B6544 /* QuickUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickUI.swift; sourceTree = ""; }; @@ -403,6 +405,7 @@ isa = PBXGroup; children = ( 541FC47724A1453F009154D8 /* VCCoOccurrence.swift */, + 544F911F24A67EC5001D4B00 /* TVCOccurrenceContext.swift */, ); path = Analytics; sourceTree = ""; @@ -852,6 +855,7 @@ 54B34596240F0513004C53CC /* TableView.swift in Sources */, 540E6780242D2CF100871BBE /* VCRecordings.swift in Sources */, 54953E3323DC752E0054345C /* DBCore.swift in Sources */, + 544F912024A67EC5001D4B00 /* TVCOccurrenceContext.swift in Sources */, 54448A30248647D900771C96 /* Time.swift in Sources */, 54953E6123E0D69A0054345C /* TVCHosts.swift in Sources */, 54751E512423955100168273 /* URL.swift in Sources */, diff --git a/main/Assets.xcassets/.DS_Store b/main/Assets.xcassets/.DS_Store index a60f8ea..2e96e05 100644 Binary files a/main/Assets.xcassets/.DS_Store and b/main/Assets.xcassets/.DS_Store differ diff --git a/main/Assets.xcassets/jump-to-target.imageset/Contents.json b/main/Assets.xcassets/jump-to-target.imageset/Contents.json new file mode 100644 index 0000000..d9287fd --- /dev/null +++ b/main/Assets.xcassets/jump-to-target.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "img.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "img@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "img@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/main/Assets.xcassets/jump-to-target.imageset/img.png b/main/Assets.xcassets/jump-to-target.imageset/img.png new file mode 100644 index 0000000..029f82f Binary files /dev/null and b/main/Assets.xcassets/jump-to-target.imageset/img.png differ diff --git a/main/Assets.xcassets/jump-to-target.imageset/img@2x.png b/main/Assets.xcassets/jump-to-target.imageset/img@2x.png new file mode 100644 index 0000000..f4f949c Binary files /dev/null and b/main/Assets.xcassets/jump-to-target.imageset/img@2x.png differ diff --git a/main/Assets.xcassets/jump-to-target.imageset/img@3x.png b/main/Assets.xcassets/jump-to-target.imageset/img@3x.png new file mode 100644 index 0000000..c78379f Binary files /dev/null and b/main/Assets.xcassets/jump-to-target.imageset/img@3x.png differ diff --git a/main/Base.lproj/Main.storyboard b/main/Base.lproj/Main.storyboard index a1691fc..b120633 100644 --- a/main/Base.lproj/Main.storyboard +++ b/main/Base.lproj/Main.storyboard @@ -388,7 +388,7 @@ - + @@ -404,11 +404,11 @@ - + - + + + + @@ -512,13 +515,13 @@ - + @@ -638,6 +641,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1244,6 +1298,7 @@ Duration: 60:00 + diff --git a/main/DB/DBAppOnly.swift b/main/DB/DBAppOnly.swift index 10b0286..0bb5edf 100644 --- a/main/DB/DBAppOnly.swift +++ b/main/DB/DBAppOnly.swift @@ -54,6 +54,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 +109,7 @@ struct GroupedDomain { var options: FilterOptions? = nil } typealias GroupedTsOccurrence = (ts: Timestamp, total: Int32, blocked: Int32) +typealias DomainTsPair = (domain: String, ts: Timestamp) extension SQLiteDatabase { @@ -150,7 +155,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 +169,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) { + (readText($0, 0) ?? "", col_ts($0, 1)) + } } } @@ -192,7 +208,7 @@ extension SQLiteDatabase { GroupedDomain(domain: readText($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 +222,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)) } } } @@ -230,7 +246,7 @@ 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) } + allRows($0) { col_ts($0, 0) } } } @@ -348,9 +364,9 @@ 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), diff --git a/main/Extensions/Generic.swift b/main/Extensions/Generic.swift index 3bbf75a..1a6b621 100644 --- a/main/Extensions/Generic.swift +++ b/main/Extensions/Generic.swift @@ -16,9 +16,21 @@ struct QLog { } } +// See: https://noahgilmore.com/blog/dark-mode-uicolor-compatibility/ 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 } }} + /// `.systemBackground ?? .white` + static var sysBg: UIColor { if #available(iOS 13.0, *) { return .systemBackground } else { return .white } } + /// `.label ?? .black` + static var sysFg: UIColor { if #available(iOS 13.0, *) { return .label } else { return .black } } + /// `.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 UIEdgeInsets { diff --git a/main/Requests/Analytics/TVCOccurrenceContext.swift b/main/Requests/Analytics/TVCOccurrenceContext.swift new file mode 100644 index 0000000..a20d026 --- /dev/null +++ b/main/Requests/Analytics/TVCOccurrenceContext.swift @@ -0,0 +1,100 @@ +import UIKit + +class TVCOccurrenceContext: UITableViewController { + + var ts: Timestamp! + var domain: String! + + private let dT: Timestamp = 300 // +/- 5 minutes + private lazy var dataSource: [DomainTsPair] = { + var list: [DomainTsPair] = [] + list.append(("[…]", ts + dT)) + list.append(contentsOf: AppDB?.dnsLogs(between: ts - dT, and: ts + dT) ?? []) + list.append(("[…]", ts - dT)) + return list + }() + + override func viewDidLoad() { + navigationItem.title = "± 5 Min Context" + super.viewDidLoad() + } + + override func viewDidAppear(_ animated: Bool) { + jumpToTsZero() + } + + @IBAction private func jumpToTsZero() { + if let i = dataSource.firstIndex(where: { isChoosenOne($0) }) { + tableView.scrollToRow(at: IndexPath(row: i), at: .middle, animated: true) + } + } + + private func isChoosenOne(_ obj: DomainTsPair) -> Bool { + obj.domain == domain && obj.ts == ts + } + + private func firstOrLast(_ row: Int) -> Bool { + row == 0 || row == dataSource.count - 1 + } + + + // MARK: - Table View Data Source + + override func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int { dataSource.count } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "OccurrenceContextCell")! + let src = dataSource[indexPath.row] + cell.detailTextLabel?.text = src.domain + + if firstOrLast(indexPath.row) { + cell.detailTextLabel?.textColor = .sysLabel2 // same as textLabel + } else if isChoosenOne(src) { + cell.detailTextLabel?.textColor = .sysLink + } else { + cell.detailTextLabel?.textColor = .sysLabel + } + + if src.ts > ts { + cell.textLabel?.text = "+ " + TimeFormat.from(src.ts - ts) + } else if src.ts < ts { + cell.textLabel?.text = "− " + TimeFormat.from(ts - src.ts) + } else { + cell.textLabel?.text = "0" + } + //cell.textLabel?.text = String(format: "%+d s", src.ts - ts) + return cell + } + + + // MARK: - Tap to Copy + + private var rowToCopy: Int = Int.max + + override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { + if firstOrLast(indexPath.row) { return nil } + if rowToCopy == indexPath.row { + UIMenuController.shared.setMenuVisible(false, animated: true) + rowToCopy = Int.max + return nil + } + rowToCopy = indexPath.row + self.becomeFirstResponder() + let cell = tableView.cellForRow(at: indexPath)! + UIMenuController.shared.setTargetRect(cell.bounds, in: cell) + UIMenuController.shared.setMenuVisible(true, animated: true) + return nil + } + + override var canBecomeFirstResponder: Bool { true } + + override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + action == #selector(UIResponderStandardEditActions.copy) + } + + override func copy(_ sender: Any?) { + guard rowToCopy < dataSource.count else { return } + UIPasteboard.general.string = dataSource[rowToCopy].domain + rowToCopy = Int.max + } +} diff --git a/main/Requests/TVCHostDetails.swift b/main/Requests/TVCHostDetails.swift index 6fccfa7..11448b3 100644 --- a/main/Requests/TVCHostDetails.swift +++ b/main/Requests/TVCHostDetails.swift @@ -14,14 +14,14 @@ class TVCHostDetails: UITableViewController, SyncUpdateDelegate, UITabBarDelegat sync.addObserver(self) // calls `syncUpdate(reset:)` if #available(iOS 10.0, *) { sync.allowPullToRefresh(onTVC: self, forObserver: self) - actionsBar.unselectedItemTintColor = .systemBlue + actionsBar.unselectedItemTintColor = .sysLink } UIDevice.orientationDidChangeNotification.observe(call: #selector(didChangeOrientation), on: self) } // MARK: - Table View Data Source - override func tableView(_ _: UITableView, numberOfRowsInSection _: Int) -> Int { dataSource.count } + override func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int { dataSource.count } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "HostDetailCell")! @@ -53,6 +53,10 @@ extension TVCHostDetails { override func prepare(for segue: UIStoryboardSegue, sender: Any?) { if segue.identifier == "segueAnalysisCoOccurrence" { (segue.destination as? VCCoOccurrence)?.fqdn = fullDomain + } else if let index = tableView.indexPathForSelectedRow?.row { + let tvc = segue.destination as? TVCOccurrenceContext + tvc?.domain = fullDomain + tvc?.ts = dataSource[index].ts } } }