Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: refine to menubar & allow log view text to be select and copy #2

Open
wants to merge 6 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 15 additions & 20 deletions ApplicationLibrary/Service/UIProfileUpdateTask.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,35 +9,30 @@ import Library
public class UIProfileUpdateTask: BGAppRefreshTask {
private static let taskSchedulerPermittedIdentifier = "\(FilePath.packageName).update_profiles"

private actor Register {
private var registered = false
func configure() async throws {
if !registered {
let success = BGTaskScheduler.shared.register(forTaskWithIdentifier: taskSchedulerPermittedIdentifier, using: nil) { task in
NSLog("profile update task started")
Task {
await UIProfileUpdateTask.getAndUpdateProfiles(task)
}
private static var registered = false
public static func configure() throws {
if !registered {
let success = BGTaskScheduler.shared.register(forTaskWithIdentifier: taskSchedulerPermittedIdentifier, using: nil) { task in
NSLog("profile update task started")
Task {
await UIProfileUpdateTask.getAndUpdateProfiles(task)
}
if !success {
throw NSError(domain: "register failed", code: 0)
}
registered = true
}
if !success {
throw NSError(domain: "register task failed", code: 0)
}
registered = true
}
Task {
BGTaskScheduler.shared.cancelAllTaskRequests()
let profiles = try await ProfileManager.listAutoUpdateEnabled()
if profiles.isEmpty {
return
}
try scheduleUpdate(ProfileUpdateTask.calculateEarliestBeginDate(profiles))
}
}

private static let register = Register()
public static func configure() async throws {
try await register.configure()
if await UIApplication.shared.backgroundRefreshStatus != .available {
Task {
Task {
if await UIApplication.shared.backgroundRefreshStatus != .available {
await updateOnce()
}
}
Expand Down
74 changes: 74 additions & 0 deletions ApplicationLibrary/Views/Connections/Connection.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import Foundation

public struct Connection: Codable {
public let id: String
public let inbound: String
public let inboundType: String
public let ipVersion: Int32
public let network: String
public let source: String
public let destination: String
public let domain: String
public let displayDestination: String
public let protocolName: String
public let user: String
public let fromOutbound: String
public let createdAt: Date
public let closedAt: Date?
public var upload: Int64
public var download: Int64
public var uploadTotal: Int64
public var downloadTotal: Int64
public let rule: String
public let outbound: String
public let outboundType: String
public let chain: [String]

var hashValue: Int {
var value = id.hashValue
(value, _) = value.addingReportingOverflow(upload.hashValue)
(value, _) = value.addingReportingOverflow(download.hashValue)
(value, _) = value.addingReportingOverflow(uploadTotal.hashValue)
(value, _) = value.addingReportingOverflow(downloadTotal.hashValue)
return value
}

func performSearch(_ content: String) -> Bool {
for item in content.components(separatedBy: " ") {
let itemSep = item.components(separatedBy: ":")
if itemSep.count == 2 {
if !performSearchType(type: itemSep[0], value: itemSep[1]) {
return false
}
continue
}
if !performSearchPlain(item) {
return false
}
}
return true
}

private func performSearchPlain(_ content: String) -> Bool {
destination.contains(content) ||
domain.contains(content)
}

private func performSearchType(type: String, value: String) -> Bool {
switch type {
// TODO: impl more
case "network":
return network == value
case "inbound":
return inbound.contains(value)
case "inbound.type":
return inboundType == value
case "source":
return source.contains(value)
case "destination":
return destination.contains(value)
default:
return false
}
}
}
56 changes: 56 additions & 0 deletions ApplicationLibrary/Views/Connections/ConnectionDetailsView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import Foundation
import Libbox
import SwiftUI

public struct ConnectionDetailsView: View {
private let connection: Connection
public init(_ connection: Connection) {
self.connection = connection
}

public var body: some View {
FormView {
if connection.closedAt != nil {
FormTextItem("State", "Closed")
FormTextItem("Created At", connection.createdAt.myFormat)
} else {
FormTextItem("State", "Active")
FormTextItem("Created At", connection.createdAt.myFormat)
}
if let closedAt = connection.closedAt {
FormTextItem("Closed At", closedAt.myFormat)
}
FormTextItem("Upload", LibboxFormatBytes(connection.uploadTotal))
FormTextItem("Download", LibboxFormatBytes(connection.downloadTotal))
Section("Metadata") {
FormTextItem("Inbound", connection.inbound)
FormTextItem("Inbound Type", connection.inboundType)
FormTextItem("IP Version", "\(connection.ipVersion)")
FormTextItem("Network", connection.network.uppercased())
FormTextItem("Source", connection.source)
FormTextItem("Destination", connection.destination)
if !connection.domain.isEmpty {
FormTextItem("Domain", connection.domain)
}
if !connection.protocolName.isEmpty {
FormTextItem("Protocol", connection.protocolName)
}
if !connection.user.isEmpty {
FormTextItem("User", connection.user)
}
if !connection.fromOutbound.isEmpty {
FormTextItem("From Outbound", connection.fromOutbound)
}
if !connection.rule.isEmpty {
FormTextItem("Match Rule", connection.rule)
}
FormTextItem("Outbound", connection.outbound)
FormTextItem("Outbound Type", connection.outboundType)
if connection.chain.count > 1 {
FormTextItem("Chain", connection.chain.reversed().joined(separator: "/"))
}
}
}
.navigationTitle("Connection")
}
}
31 changes: 31 additions & 0 deletions ApplicationLibrary/Views/Connections/ConnectionListPage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import Foundation
import SwiftUI

public enum ConnectionListPage: Int, CaseIterable, Identifiable {
public var id: Self {
self
}

case active
case closed
}

public extension ConnectionListPage {
var title: String {
switch self {
case .active:
return NSLocalizedString("Active", comment: "")
case .closed:
return NSLocalizedString("Closed", comment: "")
}
}

var label: some View {
switch self {
case .active:
return Label(title, systemImage: "play.fill")
case .closed:
return Label(title, systemImage: "stop.fill")
}
}
}
158 changes: 158 additions & 0 deletions ApplicationLibrary/Views/Connections/ConnectionListView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import Libbox
import Library
import SwiftUI

@MainActor
public struct ConnectionListView: View {
@Environment(\.scenePhase) private var scenePhase
@State private var isLoading = true
@StateObject private var commandClient = CommandClient(.connections)
@State private var connections: [Connection] = []
@State private var selection: ConnectionListPage = .active
@State private var searchText = ""
@State private var alert: Alert?

public init() {}
public var body: some View {
VStack {
if isLoading {
Text("Loading...")
} else {
if connections.isEmpty {
Text("Empty connections")
} else {
ScrollView {
LazyVGrid(columns: [GridItem(.flexible())], alignment: .leading) {
ForEach(connections.filter { it in
searchText == "" || it.performSearch(searchText)
}, id: \.hashValue) { it in
ConnectionView(it)
}
}
}
.padding()
}
}
}
.toolbar {
ToolbarItem {
Menu {
Picker("State", selection: $commandClient.connectionStateFilter) {
ForEach(ConnectionStateFilter.allCases) { state in
Text(state.name)
}
}

Picker("Sort By", selection: $commandClient.connectionSort) {
ForEach(ConnectionSort.allCases, id: \.self) { sortBy in
Text(sortBy.name)
}
}

Button("Close All Connections", role: .destructive) {
do {
try LibboxNewStandaloneCommandClient()!.closeConnections()
} catch {
alert = Alert(error)
}
}
} label: {
Label("Filter", systemImage: "line.3.horizontal.circle")
}
}
}
.searchable(text: $searchText)
.alertBinding($alert)
.onAppear {
connect()
}
.onDisappear {
commandClient.disconnect()
}
.onChangeCompat(of: scenePhase) { newValue in
if newValue == .active {
commandClient.connect()
} else {
commandClient.disconnect()
}
}
.onChangeCompat(of: commandClient.connectionStateFilter) { it in
commandClient.filterConnectionsNow()
Task {
await SharedPreferences.connectionStateFilter.set(it.rawValue)
}
}
.onChangeCompat(of: commandClient.connectionSort) { it in
commandClient.filterConnectionsNow()
Task {
await SharedPreferences.connectionSort.set(it.rawValue)
}
}
.onReceive(commandClient.$connections, perform: { connections in
if let connections {
self.connections = convertConnections(connections)
isLoading = false
}
})
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
#if os(iOS)
.background(Color(uiColor: .systemGroupedBackground))
#endif
}

private var backgroundColor: Color {
#if os(iOS)
return Color(uiColor: .secondarySystemGroupedBackground)
#elseif os(macOS)
return Color(nsColor: .textBackgroundColor)
#elseif os(tvOS)
return Color(uiColor: .black)
#endif
}

private func connect() {
if ApplicationLibrary.inPreview {
isLoading = false
} else {
commandClient.connect()
}
}

private func convertConnections(_ goConnections: [LibboxConnection]) -> [Connection] {
var connections = [Connection]()
for goConnection in goConnections {
if goConnection.outboundType == "dns" {
continue
}
var closedAt: Date?
if goConnection.closedAt > 0 {
closedAt = Date(timeIntervalSince1970: Double(goConnection.closedAt) / 1000)
}
connections.append(Connection(
id: goConnection.id_,
inbound: goConnection.inbound,
inboundType: goConnection.inboundType,
ipVersion: goConnection.ipVersion,
network: goConnection.network,
source: goConnection.source,
destination: goConnection.destination,
domain: goConnection.domain,
displayDestination: goConnection.displayDestination(),
protocolName: goConnection.protocol,
user: goConnection.user,
fromOutbound: goConnection.fromOutbound,
createdAt: Date(timeIntervalSince1970: Double(goConnection.createdAt) / 1000),
closedAt: closedAt,
upload: goConnection.uplink,
download: goConnection.downlink,
uploadTotal: goConnection.uplinkTotal,
downloadTotal: goConnection.downlinkTotal,
rule: goConnection.rule,
outbound: goConnection.outbound,
outboundType: goConnection.outboundType,
chain: goConnection.chain()!.toArray()
))
}
return connections
}
}
Loading