Merge remote-tracking branch 'upstream/dev' into dev

This commit is contained in:
n3t1zen
2026-05-04 17:40:29 +08:00
98 changed files with 9201 additions and 1194 deletions
@@ -81,6 +81,14 @@ public final class NWSocket {
try await sendAndAwait(content: LibboxEncodeChunkedMessage(data), timeout: timeout, phase: "write")
}
public func readRaw(count: Int, timeout: TimeInterval = 60) async throws -> Data {
try await receiveExactly(count: count, timeout: timeout, phase: "read raw body")
}
public func writeRaw(_ data: Data, timeout: TimeInterval = 30) async throws {
try await sendAndAwait(content: data, timeout: timeout, phase: "write raw body")
}
public func send(_ data: Data?) {
guard let data else {
return
@@ -52,14 +52,15 @@ public enum ProfileUpdateTask {
static func updateProfiles(_ profiles: [Profile]) async -> Bool {
var success = true
for profile in profiles {
let profileName = profile.name
if profile.lastUpdated! > Date(timeIntervalSinceNow: -profile.autoUpdateIntervalOrDefault) {
continue
}
do {
try await profile.updateRemoteProfile()
NSLog("Updated profile \(profile.name)")
NSLog("Updated profile %@", profileName)
} catch {
NSLog("Update profile \(profile.name) failed: \(error.localizedDescription)")
NSLog("Update profile %@ failed: %@", profileName, error.localizedDescription)
success = false
}
}
@@ -0,0 +1,94 @@
import BinaryCodable
import Foundation
import Library
public enum ReportType: String, Codable {
case crash
case oom
public var directoryName: String {
switch self {
case .crash: return "crash_reports"
case .oom: return "oom_reports"
}
}
}
public enum ReportTransferMessageType: UInt8 {
case error = 0
case report = 1
case complete = 2
case ack = 3
}
public struct ReportTransferManifest: Codable {
public var reportType: ReportType
public var timestamp: TimeInterval
public var totalBytes: UInt64
public var files: [ReportTransferManifestFile]
public init(reportType: ReportType, timestamp: TimeInterval, totalBytes: UInt64, files: [ReportTransferManifestFile]) {
self.reportType = reportType
self.timestamp = timestamp
self.totalBytes = totalBytes
self.files = files
}
}
public struct ReportTransferManifestFile: Codable {
public var name: String
public var size: UInt64
public init(name: String, size: UInt64) {
self.name = name
self.size = size
}
}
public struct ReportTransferError: LocalizedError {
public let errorDescription: String?
public init(_ message: String) {
errorDescription = message
}
}
public enum ReportTransferService {
public static let applicationServiceName = "sing-box:report-transfer"
public static let fileChunkSize = 64 * 1024
}
public enum ReportTransferMessage {
public static func encodeReport(_ manifest: ReportTransferManifest) throws -> Data {
var data = Data([ReportTransferMessageType.report.rawValue])
try data.append(BinaryEncoder().encode(manifest))
return data
}
public static func encodeComplete() -> Data {
Data([ReportTransferMessageType.complete.rawValue])
}
public static func encodeAck() -> Data {
Data([ReportTransferMessageType.ack.rawValue])
}
public static func encodeError(_ message: String) -> Data {
var data = Data([ReportTransferMessageType.error.rawValue])
data.append(Data(message.utf8))
return data
}
public static func decodeType(_ data: Data) -> ReportTransferMessageType? {
guard !data.isEmpty else { return nil }
return ReportTransferMessageType(rawValue: data[0])
}
public static func decodeReport(_ data: Data) throws -> ReportTransferManifest {
try BinaryDecoder().decode(ReportTransferManifest.self, from: data.dropFirst())
}
public static func decodeError(_ data: Data) -> String {
String(data: data.dropFirst(), encoding: .utf8) ?? "Unknown error"
}
}
@@ -0,0 +1,191 @@
#if os(iOS)
import Foundation
import Library
import Network
import os
import UIKit
private let logger = Logger(category: "ReportTransferServer")
public extension Notification.Name {
static let reportReceived = Notification.Name("reportReceived")
}
public class ReportTransferServer {
private var listener: NWListener
@available(iOS 16.0, *)
public init() throws {
listener = try NWListener(using: .applicationService)
listener.service = NWListener.Service(applicationService: ReportTransferService.applicationServiceName)
listener.newConnectionHandler = { connection in
connection.stateUpdateHandler = { state in
if state == .ready {
Task.detached {
try await Task.sleep(nanoseconds: NSEC_PER_MSEC * 100)
await ReportTransferConnection(connection).process()
}
}
}
connection.start(queue: .global())
}
}
public func start() {
listener.start(queue: .global())
}
public func cancel() {
listener.cancel()
}
class ReportTransferConnection {
private let connection: NWSocket
private var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid
init(_ connection: NWConnection) {
self.connection = NWSocket(connection)
}
func process() async {
beginBackgroundTask()
defer { endBackgroundTask() }
do {
let message = try await connection.read()
guard let type = ReportTransferMessage.decodeType(message) else {
throw ReportTransferError("Invalid report transfer message")
}
switch type {
case .report:
let manifest = try ReportTransferMessage.decodeReport(message)
try await importReport(manifest)
logger.info("report transfer server: received report")
await MainActor.run {
NotificationCenter.default.post(name: .reportReceived, object: manifest.reportType)
}
try await connection.write(ReportTransferMessage.encodeAck())
case .error:
let errorMsg = ReportTransferMessage.decodeError(message)
logger.warning("report transfer server: client error: \(errorMsg)")
case .complete, .ack:
throw ReportTransferError("Unexpected report transfer message")
}
} catch {
logger.warning("report transfer server: \(error.localizedDescription)")
await writeError(error.localizedDescription)
}
}
private func importReport(_ manifest: ReportTransferManifest) async throws {
guard !manifest.files.isEmpty else {
throw ReportTransferError("Report is empty")
}
let expectedBytes = manifest.files.reduce(0) { $0 + $1.size }
guard expectedBytes == manifest.totalBytes else {
throw ReportTransferError("Invalid report manifest")
}
let reportsDir = FilePath.workingDirectory.appendingPathComponent(manifest.reportType.directoryName, isDirectory: true)
try FileManager.default.createDirectory(at: reportsDir, withIntermediateDirectories: true)
let date = Date(timeIntervalSince1970: manifest.timestamp)
let artifactURL = ReportArchive.nextAvailableArtifactURL(in: reportsDir, for: date)
let stagingURL = nextAvailableStagingArtifactURL(in: reportsDir, for: artifactURL.lastPathComponent)
try FileManager.default.createDirectory(at: stagingURL, withIntermediateDirectories: true)
do {
var receivedBytes: UInt64 = 0
for file in manifest.files {
let fileURL = stagingURL.appendingPathComponent(file.name)
FileManager.default.createFile(atPath: fileURL.path, contents: nil)
do {
let handle = try FileHandle(forWritingTo: fileURL)
defer { try? handle.close() }
var remaining = file.size
while remaining > 0 {
let chunkSize = Int(min(UInt64(ReportTransferService.fileChunkSize), remaining))
let data = try await connection.readRaw(count: chunkSize)
try handle.write(contentsOf: data)
remaining -= UInt64(data.count)
receivedBytes += UInt64(data.count)
}
}
}
guard receivedBytes == manifest.totalBytes else {
throw ReportTransferError("Report transfer was incomplete")
}
let completion = try await connection.read()
guard let completionType = ReportTransferMessage.decodeType(completion) else {
throw ReportTransferError("Invalid report transfer message")
}
switch completionType {
case .complete:
break
case .error:
throw ReportTransferError(ReportTransferMessage.decodeError(completion))
case .report, .ack:
throw ReportTransferError("Unexpected report transfer message")
}
let metadataURL = stagingURL.appendingPathComponent(ReportArchive.metadataFileName)
if FileManager.default.fileExists(atPath: metadataURL.path) {
let metadataData = try Data(contentsOf: metadataURL)
try writeMetadataWithDeviceOrigin(metadataData, to: metadataURL)
}
try FileManager.default.moveItem(at: stagingURL, to: artifactURL)
} catch {
try? FileManager.default.removeItem(at: stagingURL)
throw error
}
}
private func nextAvailableStagingArtifactURL(in directory: URL, for artifactName: String) -> URL {
var index = 0
while true {
let suffix = index == 0 ? "" : "-\(index)"
let candidate = directory.appendingPathComponent(".\(artifactName).partial\(suffix)", isDirectory: true)
if !FileManager.default.fileExists(atPath: candidate.path) {
return candidate
}
index += 1
}
}
private func writeMetadataWithDeviceOrigin(_ data: Data, to url: URL) throws {
guard var json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
try data.write(to: url, options: .atomic)
return
}
json["deviceOrigin"] = ReportArchive.tvOSDeviceOrigin
let patched = try JSONSerialization.data(withJSONObject: json)
try patched.write(to: url, options: .atomic)
}
private func writeError(_ message: String) async {
try? await connection.write(ReportTransferMessage.encodeError(message))
}
private func beginBackgroundTask() {
backgroundTaskID = UIApplication.shared.beginBackgroundTask { [weak self] in
logger.warning("report transfer server: background task expiring")
self?.connection.cancel()
self?.endBackgroundTask()
}
}
private func endBackgroundTask() {
guard backgroundTaskID != .invalid else { return }
UIApplication.shared.endBackgroundTask(backgroundTaskID)
backgroundTaskID = .invalid
}
}
}
#endif
@@ -0,0 +1,205 @@
#if os(macOS)
import AppKit
import Foundation
import Libbox
import Library
import os
import SwiftUI
private let logger = Logger(category: "UpdateManager")
@MainActor
public class UpdateManager: ObservableObject {
private static let minimumSemver = "0.0.0-0"
@Published public var updateInfo: UpdateInfo?
@Published public var isUpdateSheetPresented = false
@Published public var isChecking = false
@Published public var isDownloading = false
@Published public var downloadProgress: Double = 0
@Published public var alert: AlertState?
public init() {}
public func updateTrackChanged(to track: UpdateTrack) async {
await SharedPreferences.updateTrack.set(track.rawValue)
guard let updateInfo, !track.allows(updateInfo) else {
return
}
await setUpdateInfo(nil)
}
@discardableResult
public func loadCachedUpdate() async -> Bool {
let cached = await SharedPreferences.cachedUpdateInfo.get()
guard !cached.isEmpty,
let data = cached.data(using: .utf8),
let info = try? JSONDecoder().decode(UpdateInfo.self, from: data)
else {
return false
}
let track = await currentTrack()
guard track.allows(info),
shouldKeepCachedUpdate(info.versionName, track: track, currentVersion: Bundle.main.version)
else {
await setUpdateInfo(nil)
return false
}
updateInfo = info
return await shouldAutomaticallyPresent(info)
}
@discardableResult
public func checkForUpdate(presentIfFound: Bool = false, force: Bool = false, showsAlertOnFailure: Bool = true) async -> Bool {
do {
guard let info = try await refreshUpdateInfo(force: force, showsAlertOnFailure: showsAlertOnFailure) else {
return false
}
guard presentIfFound else {
return false
}
return await shouldAutomaticallyPresent(info)
} catch {
return false
}
}
public func showUpdateSheet() async {
guard let updateInfo else { return }
await SharedPreferences.lastShownUpdateVersion.set(updateInfo.versionName)
isUpdateSheetPresented = true
}
public func dismissUpdateSheet() {
guard isUpdateSheetPresented else { return }
isUpdateSheetPresented = false
}
public func downloadAndInstall(environments: ExtensionEnvironments) async {
guard let updateInfo else { return }
isDownloading = true
downloadProgress = 0
alert = nil
do {
let pkgURL = try await PKGDownloader.download(from: updateInfo.downloadURL, expectedSize: updateInfo.fileSize) { [weak self] progress in
Task { @MainActor in
self?.downloadProgress = progress
}
}
let authRef = try PKGInstaller.authorize()
var profile = environments.extensionProfile
if profile == nil {
await environments.reload()
profile = environments.extensionProfile
}
if let profile, profile.status.isConnected {
try? await profile.stop()
var waitCount = 0
while profile.status != .disconnected, waitCount < 10 {
try? await Task.sleep(nanoseconds: 500_000_000)
waitCount += 1
}
}
try await Task.detached {
try PKGInstaller.install(pkgPath: pkgURL.path, authorization: authRef)
}.value
do {
try PKGInstaller.scheduleInstalledApplicationRelaunch()
} catch {
logger.warning("relaunch failed: \(error.localizedDescription)")
}
exit(0)
} catch PKGInstallerError.authorizationCancelled {
isDownloading = false
} catch {
isDownloading = false
logger.error("update failed: \(error.localizedDescription)")
alert = AlertState(action: "install update", error: error)
}
}
func refreshUpdateInfo(force: Bool = false, showsAlertOnFailure: Bool = true) async throws -> UpdateInfo? {
guard !isChecking else {
throw CancellationError()
}
isChecking = true
if showsAlertOnFailure {
alert = nil
}
defer { isChecking = false }
do {
let track = await currentTrack()
let info = try await GitHubUpdateChecker.checkAsync(track: track, force: force)
let currentTrack = await currentTrack()
guard track == currentTrack else {
throw CancellationError()
}
await setUpdateInfo(info)
return info
} catch is CancellationError {
throw CancellationError()
} catch {
logger.error("check for update failed: \(error.localizedDescription)")
if showsAlertOnFailure {
alert = AlertState(action: "check for update", error: error)
}
throw error
}
}
private func currentTrack() async -> UpdateTrack {
let trackString = await SharedPreferences.updateTrack.get()
return UpdateTrack.resolved(from: trackString)
}
private func shouldAutomaticallyPresent(_ updateInfo: UpdateInfo) async -> Bool {
let lastShownVersion = await SharedPreferences.lastShownUpdateVersion.get()
return lastShownVersion != updateInfo.versionName
}
private func shouldKeepCachedUpdate(_ version: String, track: UpdateTrack, currentVersion: String) -> Bool {
guard Self.isValidSemver(version) else {
return false
}
if LibboxCompareSemver(version, currentVersion) {
return true
}
return track == .stable && Self.isValidPrereleaseSemver(currentVersion)
}
private static func isValidSemver(_ version: String) -> Bool {
let trimmedVersion = version.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmedVersion == minimumSemver || LibboxCompareSemver(trimmedVersion, minimumSemver)
}
private static func isValidPrereleaseSemver(_ version: String) -> Bool {
let trimmedVersion = version.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmedVersion.contains("-") && isValidSemver(trimmedVersion)
}
private func setUpdateInfo(_ updateInfo: UpdateInfo?) async {
self.updateInfo = updateInfo
guard let updateInfo,
let data = try? JSONEncoder().encode(updateInfo)
else {
dismissUpdateSheet()
await SharedPreferences.cachedUpdateInfo.set("")
await SharedPreferences.lastShownUpdateVersion.set("")
return
}
await SharedPreferences.cachedUpdateInfo.set(String(decoding: data, as: UTF8.self))
}
}
#endif
@@ -80,24 +80,33 @@ public func FormItem(_ title: String, @ViewBuilder content: () -> some View) ->
.layoutPriority(1)
}
#elseif os(macOS)
content()
LabeledContent(title) {
content()
.labelsHidden()
}
#endif
}
public func FormToggle(_ titleKey: LocalizedStringKey, _ subtitleKey: LocalizedStringKey, _ isOn: Binding<Bool>, _ action: @escaping (_ newValue: Bool) async -> Void) -> some View {
public func FormToggle(_ titleKey: LocalizedStringKey, _ subtitleKey: LocalizedStringKey, _ isOn: Binding<Bool>, header: LocalizedStringKey? = nil, _ action: @escaping (_ newValue: Bool) async -> Void) -> some View {
#if os(macOS)
Toggle(isOn: isOn) {
VStack(alignment: .leading) {
Text(titleKey)
Spacer()
Text(subtitleKey)
.font(.subheadline)
.foregroundStyle(.secondary)
Section {
Toggle(isOn: isOn) {
VStack(alignment: .leading) {
Text(titleKey)
Spacer()
Text(subtitleKey)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
.onChangeCompat(of: isOn.wrappedValue) { newValue in
Task {
await action(newValue)
.onChangeCompat(of: isOn.wrappedValue) { newValue in
Task {
await action(newValue)
}
}
} header: {
if let header {
Text(header)
}
}
#else
@@ -108,6 +117,10 @@ public func FormToggle(_ titleKey: LocalizedStringKey, _ subtitleKey: LocalizedS
await action(newValue)
}
}
} header: {
if let header {
Text(header)
}
} footer: {
Text(subtitleKey)
.frame(maxWidth: .infinity, alignment: .leading)
@@ -165,16 +165,10 @@ public struct GlobalChecksModifier: ViewModifier {
let disableWarnings = await SharedPreferences.disableDeprecatedWarnings.get()
guard !disableWarnings else { return }
do {
let reports = try LibboxNewStandaloneCommandClient()!.getDeprecatedNotes()
if reports.hasNext() {
await MainActor.run {
showNextDeprecatedNote(reports)
}
}
} catch {
guard let reports = try? LibboxNewStandaloneCommandClient()!.getDeprecatedNotes() else { return }
if reports.hasNext() {
await MainActor.run {
alert = AlertState(action: "check deprecated notes", error: error)
showNextDeprecatedNote(reports)
}
}
}
@@ -192,7 +186,7 @@ public struct GlobalChecksModifier: ViewModifier {
}
}
if report.migrationLink.isEmpty {
#if os(tvOS)
var state = AlertState(
title: String(localized: "Deprecated Warning"),
message: report.message(),
@@ -200,17 +194,27 @@ public struct GlobalChecksModifier: ViewModifier {
)
state.onDismiss = continueChain
alert = state
} else {
alert = AlertState(
title: String(localized: "Deprecated Warning"),
message: report.message(),
primaryButton: .default(String(localized: "Documentation")) {
openURL(URL(string: report.migrationLink)!)
},
secondaryButton: .cancel(String(localized: "Ok")),
onDismiss: continueChain
)
}
#else
if report.migrationLink.isEmpty {
var state = AlertState(
title: String(localized: "Deprecated Warning"),
message: report.message(),
dismissButton: .cancel(String(localized: "Ok"))
)
state.onDismiss = continueChain
alert = state
} else {
alert = AlertState(
title: String(localized: "Deprecated Warning"),
message: report.message(),
primaryButton: .default(String(localized: "Documentation")) {
openURL(URL(string: report.migrationLink)!)
},
secondaryButton: .cancel(String(localized: "Ok")),
onDismiss: continueChain
)
}
#endif
}
#if os(macOS)
@@ -0,0 +1,92 @@
import SwiftUI
private struct OrderedStringMap {
let entries: [(key: String, value: String)]
init?(data: Data) {
guard let json = String(data: data, encoding: .utf8) else { return nil }
var entries: [(key: String, value: String)] = []
var rest = json[...]
func skip(_ ch: Character) -> Bool {
rest = rest.drop(while: \.isWhitespace)
guard rest.first == ch else { return false }
rest = rest.dropFirst()
return true
}
func readString() -> String? {
rest = rest.drop(while: \.isWhitespace)
guard rest.first == "\"" else { return nil }
rest = rest.dropFirst()
var s = ""
while let ch = rest.first, ch != "\"" {
if ch == "\\" { rest = rest.dropFirst() }
if let c = rest.first { s.append(c); rest = rest.dropFirst() }
}
if !rest.isEmpty { rest = rest.dropFirst() }
return s
}
guard skip("{") else { return nil }
while true {
guard let key = readString(), skip(":"), let value = readString() else { break }
if !value.isEmpty { entries.append((key: key, value: value)) }
if !skip(",") { break }
}
self.entries = entries
}
}
@MainActor
public struct MetadataFormView: View {
@State private var entries: [(key: String, value: String)] = []
@State private var isLoading = true
let url: URL
let title: String
public init(url: URL, title: String) {
self.url = url
self.title = title
}
public var body: some View {
FormView {
if !isLoading {
Section {
ForEach(entries, id: \.key) { entry in
FormTextItem(LocalizedStringKey(entry.key), entry.value)
}
}
}
}
.overlay {
if isLoading {
ProgressView()
} else if entries.isEmpty {
Text("Empty")
.foregroundStyle(.secondary)
}
}
.onAppear {
Task.detached {
let loaded = loadEntries()
await MainActor.run {
entries = loaded
isLoading = false
}
}
}
.navigationTitle(title)
}
private nonisolated func loadEntries() -> [(key: String, value: String)] {
guard let data = try? Data(contentsOf: url),
let map = OrderedStringMap(data: data)
else {
return []
}
return map.entries
}
}
@@ -3,10 +3,10 @@ import SwiftUI
@MainActor
public struct SheetContent<Content: View>: View {
private let title: String
private let title: LocalizedStringKey
private let content: Content
public init(_ title: String, @ViewBuilder content: () -> Content) {
public init(_ title: LocalizedStringKey, @ViewBuilder content: () -> Content) {
self.title = title
self.content = content()
}
@@ -0,0 +1,95 @@
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
import SwiftUI
#if os(tvOS)
struct PlainTextView: UIViewRepresentable {
let content: String
private static let monoFont = UIFont.monospacedSystemFont(ofSize: 24, weight: .regular)
func makeUIView(context _: Context) -> UITextView {
let textView = UITextView()
// isSelectable must be true for UITextView to be focusable on tvOS.
// Without focus, the Siri Remote cannot scroll the content.
// SwiftUI ScrollView + Text / LazyVStack + .focusable() do NOT work
// reliably inside navigation destinations on tvOS.
textView.isSelectable = true
textView.isUserInteractionEnabled = true
textView.isScrollEnabled = true
textView.backgroundColor = .clear
textView.textContainerInset = UIEdgeInsets(top: 40, left: 40, bottom: 40, right: 40)
textView.textContainer.lineFragmentPadding = 0
textView.font = Self.monoFont
textView.textColor = .label
textView.text = content
textView.panGestureRecognizer.allowedTouchTypes = [NSNumber(value: UITouch.TouchType.indirect.rawValue)]
return textView
}
func updateUIView(_: UITextView, context _: Context) {}
}
#elseif os(iOS)
struct PlainTextView: UIViewRepresentable {
let content: String
private static let monoFont = UIFont.monospacedSystemFont(ofSize: 12, weight: .regular)
func makeUIView(context _: Context) -> UITextView {
let textView = UITextView()
textView.isEditable = false
textView.isSelectable = true
textView.isScrollEnabled = false
textView.backgroundColor = .clear
textView.textContainerInset = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)
textView.textContainer.lineFragmentPadding = 0
textView.font = Self.monoFont
textView.textColor = .label
textView.text = content
textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
return textView
}
func updateUIView(_: UITextView, context _: Context) {}
}
#elseif os(macOS)
struct PlainTextView: NSViewRepresentable {
let content: String
private static let monoFont = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular)
func makeNSView(context _: Context) -> NSScrollView {
let scrollView = NSScrollView()
scrollView.hasVerticalScroller = true
scrollView.hasHorizontalScroller = false
scrollView.autohidesScrollers = true
let textView = NSTextView()
textView.isEditable = false
textView.isSelectable = true
textView.drawsBackground = false
textView.textContainerInset = NSSize(width: 16, height: 16)
textView.font = Self.monoFont
textView.textColor = .labelColor
textView.autoresizingMask = [.width]
textView.string = content
if let textContainer = textView.textContainer {
textContainer.widthTracksTextView = true
textContainer.containerSize = NSSize(width: scrollView.contentSize.width, height: .greatestFiniteMagnitude)
textContainer.lineFragmentPadding = 0
}
scrollView.documentView = textView
return scrollView
}
func updateNSView(_: NSScrollView, context _: Context) {}
}
#endif
@@ -82,7 +82,7 @@ public struct ShareButtonCompat<Label: View>: View {
do {
let shareItem = try await itemURL()
await MainActor.run {
presentShareController(shareItem)
presentShareSheet(shareItem)
}
} catch {
await MainActor.run {
@@ -91,22 +91,6 @@ public struct ShareButtonCompat<Label: View>: View {
}
}
private func presentShareController(_ item: URL) {
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootViewController = windowScene.keyWindow?.rootViewController
else {
return
}
var topViewController = rootViewController
while let presented = topViewController.presentedViewController {
topViewController = presented
}
topViewController.present(
UIActivityViewController(activityItems: [item], applicationActivities: nil),
animated: true
)
}
#elseif os(macOS)
private nonisolated func shareItemAsync() async {
do {
@@ -125,7 +109,7 @@ public struct ShareButtonCompat<Label: View>: View {
}
#if os(macOS)
private struct SharingServicePicker: NSViewRepresentable {
struct SharingServicePicker: NSViewRepresentable {
@Binding private var isPresented: Bool
@Binding private var alert: AlertState?
@Binding private var item: URL?
@@ -146,6 +146,33 @@ public extension View {
}
#endif
public struct ActionIconButton: View {
let systemImage: String
let action: () -> Void
public init(_ systemImage: String, action: @escaping () -> Void) {
self.systemImage = systemImage
self.action = action
}
public var body: some View {
Button(action: action) {
Image(systemName: systemImage)
.font(.system(size: 12))
#if !os(tvOS)
.frame(width: 44, height: 32)
.background(Color.secondary.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 8))
#endif
.contentShape(Rectangle())
}
.buttonStyle(.plain)
#if os(tvOS)
.actionButtonStyle()
#endif
}
}
public extension View {
func cardStyle() -> some View {
modifier(CardStyleModifier())
@@ -60,10 +60,15 @@ public struct ConnectionListView: View {
#endif
.alert($viewModel.alert)
.onAppear {
if !environments.connectionSearchText.isEmpty {
viewModel.searchText = environments.connectionSearchText
viewModel.isSearching = true
}
viewModel.connect()
commandClient.connect()
}
.onDisappear {
environments.connectionSearchText = viewModel.searchText
viewModel.disconnect()
commandClient.disconnect()
}
@@ -32,7 +32,7 @@ public struct ConnectionView: View {
HStack {
VStack(alignment: .leading) {
HStack(alignment: .center) {
Text("\(connection.network.uppercased()) \(connection.displayDestination)")
Text(verbatim: "\(connection.network.uppercased()) \(connection.displayDestination)")
Spacer()
if connection.closedAt == nil {
Text("Active").foregroundStyle(.green)
@@ -45,8 +45,8 @@ public struct ConnectionView: View {
HStack {
if let closedAt = connection.closedAt {
VStack(alignment: .leading) {
Text("\(LibboxFormatBytes(connection.uploadTotal))")
Text("\(LibboxFormatBytes(connection.downloadTotal))")
Text(verbatim: "\(LibboxFormatBytes(connection.uploadTotal))")
Text(verbatim: "\(LibboxFormatBytes(connection.downloadTotal))")
}
.font(.caption2)
VStack(alignment: .leading) {
@@ -60,8 +60,8 @@ public struct ConnectionView: View {
}
} else {
VStack(alignment: .leading) {
Text("\(LibboxFormatBytes(connection.upload))/s")
Text("\(LibboxFormatBytes(connection.download))/s")
Text(verbatim: "\(LibboxFormatBytes(connection.upload))/s")
Text(verbatim: "\(LibboxFormatBytes(connection.download))/s")
}
.font(.caption2)
VStack(alignment: .leading) {
@@ -79,10 +79,14 @@ public struct ConnectionView: View {
}
}
.foregroundColor(.textColor)
#if !os(tvOS)
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
#endif
}
#if !os(tvOS)
.buttonStyle(.plain)
.padding(16)
.cardStyle()
#endif
.alert($alert)
@@ -199,11 +199,11 @@ import SwiftUI
Spacer()
if !isProfileCard {
Toggle("", isOn: Binding(
Toggle(isOn: Binding(
get: { isEnabled },
set: { _ in onToggle() }
))
.labelsHidden()
)) {}
.labelsHidden()
}
Button {
@@ -13,24 +13,24 @@ public struct DownloadTrafficCard: View {
DashboardCardHeader(icon: "arrow.down.circle.fill", title: "Download")
if Variant.screenshotMode {
Text("249 MB/s")
Text(verbatim: "249 MB/s")
.font(.title2)
.fontWeight(.medium)
Text("5.6 GB")
Text(verbatim: "5.6 GB")
.font(.subheadline)
.foregroundStyle(.secondary)
} else if let message = commandClient.status, message.trafficAvailable {
Text("\(LibboxFormatBytes(message.downlink))/s")
Text(verbatim: "\(LibboxFormatBytes(message.downlink))/s")
.font(.title2)
.fontWeight(.medium)
Text(LibboxFormatBytes(message.downlinkTotal))
.font(.subheadline)
.foregroundStyle(.secondary)
} else {
Text("...")
Text(verbatim: "...")
.font(.title2)
.fontWeight(.medium)
Text("...")
Text(verbatim: "...")
.font(.subheadline)
.foregroundStyle(.secondary)
}
@@ -22,7 +22,7 @@ public struct HTTPProxyCard: View {
HStack {
DashboardCardHeader(icon: "network", title: "System HTTP Proxy")
Spacer()
Toggle("", isOn: $systemProxyEnabled)
Toggle(isOn: $systemProxyEnabled) {}
.labelsHidden()
#if os(macOS)
.toggleStyle(.switch)
@@ -361,7 +361,7 @@ public struct ProfileCard: View {
url = try await profile.origin.generateJSONShareFileAsync(name: "\(profile.name).json")
}
#if os(iOS)
presentShareController(url)
presentShareSheet(url)
#elseif os(macOS)
let anchorView = viewModel.shareButtonView ?? NSApp.keyWindow?.contentView ?? NSView()
NSSharingServicePicker(items: [url]).show(
@@ -400,23 +400,6 @@ public struct ProfileCard: View {
}
}
#if os(iOS)
private func presentShareController(_ item: URL) {
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootViewController = windowScene.keyWindow?.rootViewController
else {
return
}
var topViewController = rootViewController
while let presented = topViewController.presentedViewController {
topViewController = presented
}
topViewController.present(
UIActivityViewController(activityItems: [item], applicationActivities: nil),
animated: true
)
}
#endif
#endif
private func prepareQRSShare(_ profile: ProfilePreview) {
@@ -13,24 +13,24 @@ public struct UploadTrafficCard: View {
DashboardCardHeader(icon: "arrow.up.circle.fill", title: "Upload")
if Variant.screenshotMode {
Text("38 B/s")
Text(verbatim: "38 B/s")
.font(.title2)
.fontWeight(.medium)
Text("52 MB")
Text(verbatim: "52 MB")
.font(.subheadline)
.foregroundStyle(.secondary)
} else if let message = commandClient.status, message.trafficAvailable {
Text("\(LibboxFormatBytes(message.uplink))/s")
Text(verbatim: "\(LibboxFormatBytes(message.uplink))/s")
.font(.title2)
.fontWeight(.medium)
Text(LibboxFormatBytes(message.uplinkTotal))
.font(.subheadline)
.foregroundStyle(.secondary)
} else {
Text("...")
Text(verbatim: "...")
.font(.title2)
.fontWeight(.medium)
Text("...")
Text(verbatim: "...")
.font(.subheadline)
.foregroundStyle(.secondary)
}
@@ -63,6 +63,7 @@ public struct StartStopButton: View {
if !profile.status.isConnected {
Label("Start", systemImage: "play.fill")
.padding(.horizontal, 12)
} else {
Label("Stop", systemImage: "stop.fill")
}
@@ -41,13 +41,7 @@ public class GroupListViewModel: BaseViewModel {
var items = [OutboundGroupItem]()
let itemIterator = goGroup.getItems()!
while itemIterator.hasNext() {
let goItem = itemIterator.next()!
items.append(OutboundGroupItem(
tag: goItem.tag,
type: goItem.type,
urlTestTime: Date(timeIntervalSince1970: Double(goItem.urlTestTime)),
urlTestDelay: UInt16(goItem.urlTestDelay)
))
items.append(OutboundGroupItem(itemIterator.next()!))
}
var selected = goGroup.selected
@@ -18,7 +18,7 @@ public struct GroupView: View {
Text(group.displayType)
.font(.subheadline)
.foregroundColor(.secondary)
Text("\(group.items.count)")
Text(verbatim: "\(group.items.count)")
.font(.subheadline)
.padding(EdgeInsets(top: 2, leading: 4, bottom: 2, trailing: 4))
.background(Color.gray.opacity(0.5))
+7 -3
View File
@@ -15,15 +15,16 @@ public struct LogView: View {
public init() {}
public var body: some View {
LogViewContent(commandClient: environments.commandClient)
LogViewContent(commandClient: environments.commandClient, initialSearchText: environments.logSearchText)
}
}
private struct LogViewContent: View {
@EnvironmentObject private var environments: ExtensionEnvironments
@StateObject private var viewModel: LogViewModel
init(commandClient: CommandClient) {
_viewModel = StateObject(wrappedValue: LogViewModel(commandClient: commandClient))
init(commandClient: CommandClient, initialSearchText: String = "") {
_viewModel = StateObject(wrappedValue: LogViewModel(commandClient: commandClient, searchText: initialSearchText))
}
var body: some View {
@@ -35,6 +36,9 @@ private struct LogViewContent: View {
toolbarButtons
}
}
.onDisappear {
environments.logSearchText = viewModel.searchText
}
.alert($viewModel.alert)
.background(
LogExportView(
+39 -12
View File
@@ -18,6 +18,8 @@ public class LogDataModel: ObservableObject {
private let commandClient: CommandClient
private weak var viewModel: LogViewModel?
private var pausedLogSnapshot: [LogEntry]?
private var lastPaused = false
private var lastProcessedLogCount = 0
private var lastEffectiveLevel: Int?
private var lastSearchText = ""
@@ -48,38 +50,59 @@ public class LogDataModel: ObservableObject {
let debouncedSearchText = viewModel.$searchText
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
Publishers.CombineLatest4(
commandClient.$logList,
commandClient.$defaultLogLevel,
viewModel.$selectedLogLevel,
debouncedSearchText
Publishers.CombineLatest(
Publishers.CombineLatest4(
commandClient.$logList,
commandClient.$defaultLogLevel,
viewModel.$selectedLogLevel,
debouncedSearchText
),
viewModel.$isPaused
)
.receive(on: DispatchQueue.main)
.sink { [weak self] logList, defaultLogLevel, selectedLogLevel, searchText in
.sink { [weak self] combined, isPaused in
guard let self else { return }
let (logList, defaultLogLevel, selectedLogLevel, searchText) = combined
let effectiveLevel = selectedLogLevel ?? defaultLogLevel
if isPaused, !self.lastPaused {
self.pausedLogSnapshot = logList
self.lastProcessedLogCount = 0
} else if !isPaused, self.lastPaused {
self.pausedLogSnapshot = nil
self.lastProcessedLogCount = 0
}
self.lastPaused = isPaused
let sourceList = self.pausedLogSnapshot ?? logList
if isPaused, effectiveLevel == self.lastEffectiveLevel, searchText == self.lastSearchText,
self.lastProcessedLogCount > 0
{
return
}
let canIncrement = self.lastProcessedLogCount > 0 &&
logList.count > self.lastProcessedLogCount &&
sourceList.count > self.lastProcessedLogCount &&
effectiveLevel == self.lastEffectiveLevel &&
searchText == self.lastSearchText
if canIncrement {
let newLogs = logList[self.lastProcessedLogCount...]
let newLogs = sourceList[self.lastProcessedLogCount...]
let newFilteredLogs = newLogs.filter { log in
log.level <= effectiveLevel &&
(searchText.isEmpty || log.message.contains(searchText))
}
self.filteredLogs.append(contentsOf: newFilteredLogs)
} else {
self.filteredLogs = logList.filter { log in
self.filteredLogs = sourceList.filter { log in
log.level <= effectiveLevel &&
(searchText.isEmpty || log.message.contains(searchText))
}
}
self.updateVisibleLogs()
self.lastProcessedLogCount = logList.count
self.lastProcessedLogCount = sourceList.count
self.lastEffectiveLevel = effectiveLevel
self.lastSearchText = searchText
}
@@ -88,6 +111,8 @@ public class LogDataModel: ObservableObject {
public func clearLogs() {
viewModel?.isPaused = false
pausedLogSnapshot = nil
lastPaused = false
lastProcessedLogCount = 0
lastEffectiveLevel = nil
lastSearchText = ""
@@ -130,7 +155,7 @@ public class LogDataModel: ObservableObject {
do {
let text = getLogsText()
let dateString = Self.dateFormatter.string(from: Date())
let tempDirectory = FileManager.default.temporaryDirectory
let tempDirectory = FilePath.cacheDirectory
let fileURL = tempDirectory.appendingPathComponent("logs-\(dateString).txt")
try text.write(to: fileURL, atomically: true, encoding: .utf8)
logFileURL = fileURL
@@ -151,8 +176,10 @@ public class LogViewModel: BaseViewModel {
public let commandClient: CommandClient
public private(set) var dataModel: LogDataModel!
public init(commandClient: CommandClient) {
public init(commandClient: CommandClient, searchText: String = "") {
self.commandClient = commandClient
self.searchText = searchText
isSearching = !searchText.isEmpty
super.init()
dataModel = LogDataModel(commandClient: commandClient, viewModel: self)
}
+10 -1
View File
@@ -13,6 +13,7 @@ public enum NavigationPage: Int, CaseIterable, Identifiable {
case connections
#endif
case logs
case tools
case settings
}
@@ -23,6 +24,8 @@ public extension NavigationPage {
self = .dashboard
case "logs":
self = .logs
case "tools":
self = .tools
case "settings":
self = .settings
#if os(macOS)
@@ -38,7 +41,7 @@ public extension NavigationPage {
#if os(macOS)
static var macosDefaultPages: [NavigationPage] {
[.logs, .settings]
[.logs, .tools, .settings]
}
#endif
@@ -59,6 +62,8 @@ public extension NavigationPage {
#endif
case .logs:
return String(localized: "Logs")
case .tools:
return String(localized: "Tools")
case .settings:
return String(localized: "Settings")
}
@@ -76,6 +81,8 @@ public extension NavigationPage {
#endif
case .logs:
return "list.bullet.rectangle"
case .tools:
return "terminal.fill"
case .settings:
return "gear.circle.fill"
}
@@ -95,6 +102,8 @@ public extension NavigationPage {
#endif
case .logs:
LogView()
case .tools:
ToolsView()
case .settings:
SettingView()
}
@@ -117,7 +117,7 @@ public struct EditProfileContentView: View {
private var defaultEditorView: some View {
#if os(tvOS)
ScrollView {
TextField("", text: readOnly ? .constant(viewModel.profileContent) : $viewModel.profileContent, axis: .vertical)
TextField(text: readOnly ? .constant(viewModel.profileContent) : $viewModel.profileContent, axis: .vertical) {}
.lineLimit(1000)
.font(Font.system(.caption2, design: .monospaced))
.autocorrectionDisabled(true)
@@ -80,7 +80,7 @@ public struct QRSDisplayView: View {
} label: {
Image(systemName: "minus")
}
Text("\(Int(sliceSize))")
Text(verbatim: "\(Int(sliceSize))")
.foregroundStyle(.secondary)
.frame(minWidth: 50)
Button {
@@ -89,7 +89,7 @@ public struct QRSDisplayView: View {
Image(systemName: "plus")
}
#else
Text("\(Int(sliceSize))")
Text(verbatim: "\(Int(sliceSize))")
.foregroundStyle(.secondary)
#endif
}
@@ -195,7 +195,7 @@
.animation(.easeInOut(duration: 0.2), value: progress)
if total > 0 {
Text("\(min(99, Int(progress * 100)))%")
Text(verbatim: "\(min(99, Int(progress * 100)))%")
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(.white)
}
@@ -60,8 +60,8 @@ public struct CoreView: View {
}
if Variant.isBeta {
Section {}
FormToggle("Disable Deprecated Warnings", "Do not show warnings about usages of deprecated features.", $disableDeprecatedWarnings) { newValue in
FormToggle("Disable Deprecated Warnings", "Do not show warnings about usages of deprecated features.", $disableDeprecatedWarnings, header: "Beta Settings") {
newValue in
await SharedPreferences.disableDeprecatedWarnings.set(newValue)
}
}
@@ -0,0 +1,194 @@
#if os(macOS)
enum GitHubEmoji {
static func replaceShortcodes(in text: String) -> String {
text.replacing(/:([\w+-]+):/) { match in
shortcodes[String(match.1)] ?? String(match.0)
}
}
/// Common GitHub / gitmoji shortcodes Unicode emoji
private static let shortcodes: [String: String] = [
// Gitmoji (commit conventions)
"art": "🎨",
"zap": "",
"fire": "🔥",
"bug": "🐛",
"ambulance": "🚑",
"sparkles": "",
"memo": "📝",
"rocket": "🚀",
"lipstick": "💄",
"tada": "🎉",
"white_check_mark": "",
"lock": "🔒",
"closed_lock_with_key": "🔐",
"bookmark": "🔖",
"rotating_light": "🚨",
"construction": "🚧",
"green_heart": "💚",
"arrow_down": "⬇️",
"arrow_up": "⬆️",
"pushpin": "📌",
"construction_worker": "👷",
"chart_with_upwards_trend": "📈",
"recycle": "♻️",
"heavy_plus_sign": "",
"heavy_minus_sign": "",
"wrench": "🔧",
"hammer": "🔨",
"globe_with_meridians": "🌐",
"pencil2": "✏️",
"pencil": "📝",
"poop": "💩",
"rewind": "",
"twisted_rightwards_arrows": "🔀",
"package": "📦",
"alien": "👽",
"truck": "🚚",
"page_facing_up": "📄",
"boom": "💥",
"bento": "🍱",
"wheelchair": "",
"bulb": "💡",
"beers": "🍻",
"speech_balloon": "💬",
"card_file_box": "🗃️",
"loud_sound": "🔊",
"mute": "🔇",
"busts_in_silhouette": "👥",
"children_crossing": "🚸",
"building_construction": "🏗️",
"iphone": "📱",
"clown_face": "🤡",
"egg": "🥚",
"see_no_evil": "🙈",
"camera_flash": "📸",
"alembic": "⚗️",
"mag": "🔍",
"label": "🏷️",
"seedling": "🌱",
"triangular_flag_on_post": "🚩",
"goal_net": "🥅",
"dizzy": "💫",
"wastebasket": "🗑️",
"passport_control": "🛂",
"adhesive_bandage": "🩹",
"monocle_face": "🧐",
"coffin": "⚰️",
"test_tube": "🧪",
"necktie": "👔",
"stethoscope": "🩺",
"bricks": "🧱",
"technologist": "🧑‍💻",
// Common faces & people
"smile": "😄",
"laughing": "😆",
"blush": "😊",
"smiley": "😃",
"grinning": "😀",
"wink": "😉",
"heart_eyes": "😍",
"kissing_heart": "😘",
"sunglasses": "😎",
"thinking": "🤔",
"thumbsup": "👍",
"+1": "👍",
"thumbsdown": "👎",
"-1": "👎",
"clap": "👏",
"pray": "🙏",
"wave": "👋",
"raised_hands": "🙌",
"ok_hand": "👌",
"point_up": "☝️",
"point_down": "👇",
"point_left": "👈",
"point_right": "👉",
"muscle": "💪",
// Hearts & symbols
"heart": "❤️",
"broken_heart": "💔",
"star": "",
"star2": "🌟",
"warning": "⚠️",
"x": "",
"heavy_check_mark": "✔️",
"question": "",
"exclamation": "",
"bangbang": "‼️",
"interrobang": "⁉️",
"100": "💯",
// Objects & nature
"gear": "⚙️",
"key": "🔑",
"link": "🔗",
"shield": "🛡️",
"bell": "🔔",
"no_bell": "🔕",
"clipboard": "📋",
"books": "📚",
"book": "📖",
"computer": "💻",
"desktop_computer": "🖥️",
"electric_plug": "🔌",
"battery": "🔋",
"floppy_disk": "💾",
"file_folder": "📁",
"open_file_folder": "📂",
"calendar": "📅",
"clock1": "🕐",
"hourglass": "",
"stopwatch": "⏱️",
"timer_clock": "⏲️",
"inbox_tray": "📥",
"outbox_tray": "📤",
"envelope": "✉️",
"email": "📧",
"newspaper": "📰",
"scroll": "📜",
"trophy": "🏆",
"medal_sports": "🏅",
"gem": "💎",
"hammer_and_wrench": "🛠️",
"nut_and_bolt": "🔩",
"chains": "⛓️",
"magnet": "🧲",
"trash": "🗑️",
"world_map": "🗺️",
// Arrows & indicators
"arrow_right": "➡️",
"arrow_left": "⬅️",
"arrow_upper_right": "↗️",
"arrow_lower_right": "↘️",
"arrows_counterclockwise": "🔄",
"back": "🔙",
"new": "🆕",
"up": "🆙",
"cool": "🆒",
"free": "🆓",
"information_source": "",
// Nature & weather
"sunny": "☀️",
"cloud": "☁️",
"snowflake": "❄️",
"rainbow": "🌈",
"ocean": "🌊",
"leaves": "🍃",
"four_leaf_clover": "🍀",
"evergreen_tree": "🌲",
"deciduous_tree": "🌳",
"cactus": "🌵",
"cherry_blossom": "🌸",
"rose": "🌹",
"sunflower": "🌻",
"herb": "🌿",
]
}
#endif
@@ -23,14 +23,21 @@ public struct AppView: View {
@State private var isLoading = true
@State private var selectedLanguage: String?
@State private var cacheSize: Int64 = 0
@State private var cacheSizeText = ""
#if os(macOS)
@State private var startAtLogin = false
@Environment(\.showMenuBarExtra) private var showMenuBarExtra
@Environment(\.menuBarExtraSpeedMode) private var menuBarExtraSpeedMode
@State private var menuBarExtraInBackground = false
@State private var systemExtensionInstalled = false
@State private var helperStatusLoaded = false
@State private var rootHelperRegistrationStatus: SMAppService.Status = .notRegistered
@EnvironmentObject private var environments: ExtensionEnvironments
@EnvironmentObject private var updateManager: UpdateManager
@State private var updateTrack: UpdateTrack = .stable
@State private var checkUpdateEnabled = false
#endif
@State private var alert: AlertState?
@@ -90,21 +97,139 @@ public struct AppView: View {
}
}
#endif
FormTextItem("Cache Size", cacheSizeText)
if cacheSize > 0 {
FormButton(role: .destructive) {
Task.detached {
let cacheDir = FilePath.cacheDirectory
let workingDir = FilePath.workingDirectory
if let contents = try? FileManager.default.contentsOfDirectory(
at: cacheDir,
includingPropertiesForKeys: nil
) {
for item in contents {
if item.lastPathComponent == workingDir.lastPathComponent {
continue
}
try? FileManager.default.removeItem(at: item)
}
}
await MainActor.run {
cacheSize = 0
cacheSizeText = ByteCountFormatter.string(fromByteCount: 0, countStyle: .file)
}
}
} label: {
Label("Clear Cache", systemImage: "trash")
.foregroundColor(.red)
}
}
#if os(macOS)
if Variant.useSystemExtension {
Section("System Extension") {
Section("Update Settings") {
Picker("Update Track", selection: $updateTrack) {
Text("Stable").tag(UpdateTrack.stable)
Text("Beta").tag(UpdateTrack.beta)
}
.onChangeCompat(of: updateTrack) { newValue in
Task {
await updateManager.updateTrackChanged(to: newValue)
}
}
Toggle("Automatic Update Check", isOn: $checkUpdateEnabled)
.onChangeCompat(of: checkUpdateEnabled) { newValue in
Task {
await SharedPreferences.checkUpdateEnabled.set(newValue)
}
}
FormButton {
Task {
await updateSystemExtension()
do {
if try await updateManager.refreshUpdateInfo() != nil {
await updateManager.showUpdateSheet()
} else {
alert = AlertState(
title: String(localized: "Check Update"),
message: String(localized: "No updates available")
)
}
} catch {}
}
} label: {
Label("Update", systemImage: "arrow.down.doc.fill")
if updateManager.isChecking {
HStack(spacing: 6) {
ProgressView()
.controlSize(.small)
Text("Checking...")
}
} else {
Label("Check Update", systemImage: "arrow.triangle.2.circlepath")
}
}
FormButton(role: .destructive) {
Task {
await uninstallSystemExtension()
.disabled(updateManager.isChecking)
.contextMenu {
Button("Force Show Latest Version as Update") {
Task {
do {
if try await updateManager.refreshUpdateInfo(force: true) != nil {
await updateManager.showUpdateSheet()
} else {
alert = AlertState(
title: String(localized: "Check Update"),
message: String(localized: "No updates available")
)
}
} catch {}
}
}
.disabled(updateManager.isChecking)
}
if let info = updateManager.updateInfo {
FormButton {
Task {
await updateManager.showUpdateSheet()
}
} label: {
HStack {
Label("Update", systemImage: "arrow.down.circle")
Spacer()
Text(verbatim: "v\(info.versionName)")
.foregroundStyle(.secondary)
}
}
}
}
Section("System Extension") {
if systemExtensionInstalled {
FormButton {
Task {
await updateSystemExtension()
}
} label: {
Label("Update", systemImage: "arrow.down.doc.fill")
}
FormButton(role: .destructive) {
Task {
await uninstallSystemExtension()
}
} label: {
Label("Uninstall", systemImage: "trash.fill").foregroundColor(.red)
}
} else {
FormButton {
Task {
await installSystemExtension()
}
} label: {
Label("Install", systemImage: "lock.doc.fill")
}
} label: {
Label("Uninstall", systemImage: "trash.fill").foregroundColor(.red)
}
}
@@ -163,7 +288,10 @@ public struct AppView: View {
}
}
.alert($alert)
.navigationTitle("App")
#if os(macOS)
.alert($updateManager.alert)
#endif
.navigationTitle("App")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
@@ -174,6 +302,12 @@ public struct AppView: View {
#if os(macOS)
startAtLogin = SMAppService.mainApp.status == .enabled
menuBarExtraInBackground = await SharedPreferences.menuBarExtraInBackground.get()
if Variant.useSystemExtension {
systemExtensionInstalled = await SystemExtension.isInstalled()
let trackString = await SharedPreferences.updateTrack.get()
updateTrack = UpdateTrack.resolved(from: trackString)
checkUpdateEnabled = await SharedPreferences.checkUpdateEnabled.get()
}
#endif
isLoading = false
#if os(macOS)
@@ -182,6 +316,7 @@ public struct AppView: View {
helperStatusLoaded = true
}
#endif
refreshCacheSize()
}
private static func currentLanguage() -> String? {
@@ -264,6 +399,20 @@ public struct AppView: View {
}
}
private func installSystemExtension() async {
do {
if let result = try await SystemExtension.install() {
if result == .willCompleteAfterReboot {
alert = AlertState(errorMessage: String(localized: "Need Reboot"))
return
}
}
systemExtensionInstalled = true
} catch {
alert = AlertState(action: "install system extension", error: error)
}
}
private func updateSystemExtension() async {
do {
if let result = try await SystemExtension.install(forceUpdate: true) {
@@ -290,6 +439,7 @@ public struct AppView: View {
if let result = try await SystemExtension.uninstall() {
switch result {
case .completed:
systemExtensionInstalled = false
alert = AlertState(
title: String(localized: "Uninstall"),
message: String(localized: "System Extension removed.")
@@ -333,4 +483,33 @@ public struct AppView: View {
}
#endif
private func refreshCacheSize() {
Task.detached {
let total = Self.calculateDirSize(FilePath.cacheDirectory)
let working = Self.calculateDirSize(FilePath.workingDirectory)
let size = max(total - working, 0)
await MainActor.run {
cacheSize = size
cacheSizeText = ByteCountFormatter.string(fromByteCount: size, countStyle: .file)
}
}
}
private static func calculateDirSize(_ dir: URL) -> Int64 {
guard let enumerator = FileManager.default.enumerator(
at: dir,
includingPropertiesForKeys: [.fileSizeKey],
options: [.skipsHiddenFiles]
) else {
return 0
}
var size: Int64 = 0
for case let fileURL as URL in enumerator {
if let fileSize = try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize {
size += Int64(fileSize)
}
}
return size
}
}
@@ -793,7 +793,7 @@ private struct StringListSection: View {
Text(title)
Spacer()
if !items.isEmpty {
Text("\(items.count)")
Text(verbatim: "\(items.count)")
.foregroundStyle(.secondary)
}
}
@@ -806,7 +806,7 @@ private struct StringListSection: View {
Text(title)
Spacer()
if !items.isEmpty {
Text("\(items.count)")
Text(verbatim: "\(items.count)")
.foregroundStyle(.secondary)
}
}
@@ -6,10 +6,6 @@ struct PacketTunnelView: View {
@State private var isLoading = true
@State private var alert: AlertState?
#if !os(macOS)
@State private var ignoreMemoryLimit = false
#endif
@State private var includeAllNetworks = false
@State private var excludeAPNs = false
@State private var excludeCellularServices = false
@@ -28,15 +24,6 @@ struct PacketTunnelView: View {
}
} else {
FormView {
#if !os(macOS)
FormToggle("Ignore Memory Limit", """
Do not enforce memory limits on sing-box. Will cause OOM on non-jailbroken devices.
""", $ignoreMemoryLimit) { newValue in
await SharedPreferences.ignoreMemoryLimit.set(newValue)
await restartService()
}
#endif
#if !os(tvOS)
FormToggle("includeAllNetworks", """
If this property is true, the system routes network traffic through the tunnel except traffic for designated system services necessary for maintaining expected device functionality. You can exclude some types of traffic using the **excludeAPNs**, **excludeLocalNetworks**, and **excludeCellularServices** properties in combination with this property.
@@ -135,9 +122,6 @@ struct PacketTunnelView: View {
@MainActor
private func loadSettings() async {
#if !os(macOS)
ignoreMemoryLimit = await SharedPreferences.ignoreMemoryLimit.get()
#endif
#if !os(tvOS)
includeAllNetworks = await SharedPreferences.includeAllNetworks.get()
excludeLocalNetworks = await SharedPreferences.excludeLocalNetworks.get()
@@ -28,6 +28,7 @@ public struct ProfileOverrideView: View {
FormToggle("No Default Route", """
By default, segment routing is used in `auto_route` instead of global routing.
If `<route_address/route_exclude_address>` exists in the configuration, this item will not take effect on the corresponding network (commonly used to resolve HomeKit compatibility issues).
On macOS, enabling this option will cause Internet Sharing to not work properly.
""", $autoRouteUseSubRangesByDefault) { newValue in
await SharedPreferences.autoRouteUseSubRangesByDefault.set(newValue)
await reloadService()
@@ -1,64 +0,0 @@
import Foundation
import Library
import SwiftUI
@MainActor
public struct ServiceLogView: View {
@Environment(\.dismiss) private var dismiss
@StateObject private var viewModel = ServiceLogViewModel()
private let logFont = Font.system(.caption, design: .monospaced)
public init() {}
public var body: some View {
Group {
if viewModel.isLoading {
ProgressView().onAppear {
Task {
await viewModel.loadContent()
}
}
} else {
if viewModel.isEmpty {
Text("Empty content")
} else {
ScrollView {
Text(viewModel.content)
.font(logFont)
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.padding()
}
}
}
.toolbar {
if !viewModel.isEmpty {
#if !os(tvOS)
ShareButtonCompat($viewModel.alert) {
Label("Export", systemImage: "square.and.arrow.up.fill")
} itemURL: {
try await viewModel.generateShareFileAsync()
}
#endif
Button(role: .destructive) {
Task {
await viewModel.deleteContent(dismiss: dismiss)
}
} label: {
#if !os(tvOS)
Label("Delete", systemImage: "trash.fill")
#else
Image(systemName: "trash.fill")
.tint(.red)
#endif
}
}
}
.alert($viewModel.alert)
.navigationTitle("Service Log")
#if os(tvOS)
.focusable()
#endif
}
}
@@ -1,77 +0,0 @@
import Foundation
import Library
import SwiftUI
@MainActor
final class ServiceLogViewModel: BaseViewModel {
@Published var content = ""
override init() {
super.init()
isLoading = true
}
var isEmpty: Bool {
content.isEmpty
}
nonisolated func loadContent() async {
let primaryLogURL = FilePath.cacheDirectory.appendingPathComponent("stderr.log")
let secondaryLogURL = FilePath.cacheDirectory.appendingPathComponent("stderr.log.old")
var content = await BlockingIO.run {
if let primaryContent = try? String(contentsOf: primaryLogURL), !primaryContent.isEmpty {
return primaryContent
}
return (try? String(contentsOf: secondaryLogURL)) ?? ""
}
#if DEBUG
if content.isEmpty {
content = "Empty content"
}
#endif
if !content.isEmpty {
var systemInfo = utsname()
uname(&systemInfo)
let machineMirror = Mirror(reflecting: systemInfo.machine)
let machineName = machineMirror.children.reduce("") { identifier, element in
guard let value = element.value as? Int8, value != 0 else { return identifier }
return identifier + String(UnicodeScalar(UInt8(value)))
}
var deviceInfo = String("Machine: ") + machineName + "\n"
#if os(iOS)
await deviceInfo += String("System: ") + (UIDevice.current.systemName) + " " + (UIDevice.current.systemVersion) + "\n"
#elseif os(macOS)
deviceInfo += String("System: ") + "macOS " + ProcessInfo().operatingSystemVersionString + "\n"
#endif
content = deviceInfo + "\n" + content
}
await MainActor.run { [content] in
self.content = content
isLoading = false
}
}
nonisolated func deleteContent(dismiss: DismissAction) async {
let primaryLogURL = FilePath.cacheDirectory.appendingPathComponent("stderr.log")
let secondaryLogURL = FilePath.cacheDirectory.appendingPathComponent("stderr.log.old")
await BlockingIO.run {
try? FileManager.default.removeItem(at: primaryLogURL)
try? FileManager.default.removeItem(at: secondaryLogURL)
}
await MainActor.run {
dismiss()
isLoading = true
}
}
func generateShareFile() throws -> URL {
try content.generateShareFile(name: "service.log")
}
func generateShareFileAsync() async throws -> URL {
let content = content
return try await BlockingIO.run {
try content.generateShareFile(name: "service.log")
}
}
}
@@ -149,14 +149,17 @@ public struct SettingView: View {
}
#endif
@StateObject private var viewModel = SettingViewModel()
public init() {}
public var body: some View {
FormView {
Section {
ForEach([Tabs.app, Tabs.core, Tabs.packetTunnel, Tabs.onDemandRules, Tabs.profileOverride]) { it in
it.navigationLink
}
Tabs.app.navigationLink
Tabs.core.navigationLink
#if !os(tvOS)
Tabs.packetTunnel.navigationLink
#endif
Tabs.onDemandRules.navigationLink
Tabs.profileOverride.navigationLink
}
#if !os(tvOS)
Section("About") {
@@ -193,25 +196,6 @@ public struct SettingView: View {
#endif
}
#endif
Section("Debug") {
FormNavigationLink {
ServiceLogView()
} label: {
Label("Service Log", systemImage: "doc.on.clipboard")
}
FormTextItem("Taiwan Flag Available", "touchid") {
if viewModel.isLoading {
Text("Loading...")
.onAppear {
Task.detached {
await viewModel.checkTaiwanFlagAvailability()
}
}
} else {
Text(viewModel.taiwanFlagAvailable.toString())
}
}
}
}
#if os(macOS)
.formNavigationDestination(for: SettingsPage.self) { page in
@@ -0,0 +1,75 @@
#if os(macOS)
import AppKit
import Library
import MarkdownUI
import SwiftUI
public struct UpdateSheet: View {
@ObservedObject var updateManager: UpdateManager
@EnvironmentObject private var environments: ExtensionEnvironments
public init(updateManager: UpdateManager) {
self.updateManager = updateManager
}
public var body: some View {
VStack(spacing: 16) {
Text("Check Update")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
Text("New version available: \(updateManager.updateInfo?.versionName ?? "")")
.font(.subheadline)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
if let releaseNotes = updateManager.updateInfo?.releaseNotes, !releaseNotes.isEmpty {
ScrollView {
Markdown(GitHubEmoji.replaceShortcodes(in: releaseNotes))
.markdownTheme(.gitHub.text {
FontSize(10)
})
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxHeight: 300)
}
if updateManager.isDownloading {
ProgressView(value: updateManager.downloadProgress)
}
HStack(spacing: 12) {
if let releaseURL = updateManager.updateInfo?.releaseURL,
let url = URL(string: releaseURL)
{
Button("View Release") {
NSWorkspace.shared.open(url)
}
}
Spacer()
Button("Cancel", role: .cancel) {
updateManager.dismissUpdateSheet()
}
.keyboardShortcut(.escape, modifiers: [])
.disabled(updateManager.isDownloading)
Button("Update") {
Task {
await updateManager.downloadAndInstall(environments: environments)
}
}
.keyboardShortcut(.defaultAction)
.disabled(updateManager.isDownloading)
}
}
.padding(20)
.frame(minWidth: 480)
.interactiveDismissDisabled(updateManager.isDownloading)
.alert($updateManager.alert)
}
}
#endif
@@ -0,0 +1,164 @@
import Library
import SwiftUI
@MainActor
public struct CrashReportDetailView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var environments: ExtensionEnvironments
@State private var alert: AlertState?
@State private var files: [CrashReportFile] = []
@State private var isLoading = true
#if os(macOS)
@State private var sharePresented = false
@State private var shareItemURL: URL?
#elseif os(tvOS)
@State private var showExport = false
#endif
let report: CrashReport
public init(report: CrashReport) {
self.report = report
}
private var manager: CrashReportManager {
environments.crashReportManager
}
#if !os(tvOS)
private func shareReport(includeConfig: Bool) async {
do {
let zipURL = try await createReportZip(
reportID: report.id, fileURL: report.fileURL,
cacheSubdirectory: ReportType.crash.directoryName, includeConfig: includeConfig
)
#if os(iOS)
presentShareSheet(zipURL)
#elseif os(macOS)
shareItemURL = zipURL
sharePresented = true
#endif
} catch {
alert = AlertState(action: "export crash reports", error: error)
}
}
#endif
public var body: some View {
FormView {
if !isLoading, !files.isEmpty {
Section("Files") {
ForEach(files) { file in
if file.id == .metadata {
FormNavigationLink {
MetadataFormView(url: file.fileURL, title: file.displayName)
} label: {
Text(file.displayName)
}
} else {
FormNavigationLink {
ReportFileContentView(fileURL: file.fileURL, displayName: file.displayName)
} label: {
Text(file.displayName)
}
}
}
}
}
}
.overlay {
if isLoading {
ProgressView()
} else if files.isEmpty {
Text("Empty")
.foregroundStyle(.secondary)
}
}
.onAppear {
Task {
files = await manager.availableFiles(for: report)
manager.markAsRead(report)
isLoading = false
}
}
.alert($alert)
#if os(tvOS)
.navigationDestination(isPresented: $showExport) {
ExportReportView(reportType: .crash, reportURL: report.fileURL, reportDate: report.date)
.toolbar {
ToolbarItemGroup(placement: .topBarLeading) {
BackButton()
}
}
}
#elseif os(macOS)
.background(SharingServicePicker($sharePresented, $alert, $shareItemURL))
#endif
.toolbar {
if !isLoading, !files.isEmpty {
#if os(tvOS)
ToolbarItem(placement: .confirmationAction) {
Button {
showExport = true
} label: {
Image(systemName: "square.and.arrow.up")
}
}
ToolbarItem(placement: .confirmationAction) {
Button {
Task {
await manager.delete(report)
dismiss()
}
} label: {
Image(systemName: "trash.fill")
}
.tint(.red)
}
#else
if files.contains(where: { $0.id == .configContent }) {
Menu {
Button {
Task {
await shareReport(includeConfig: false)
}
} label: {
Label("Share", systemImage: "square.and.arrow.up")
}
Button {
Task {
await shareReport(includeConfig: true)
}
} label: {
Label("Share With Configuration", systemImage: "square.and.arrow.up.on.square")
}
} label: {
Label("Share", systemImage: "square.and.arrow.up")
}
} else {
Button {
Task {
await shareReport(includeConfig: false)
}
} label: {
Label("Share", systemImage: "square.and.arrow.up")
}
}
Button(role: .destructive) {
Task {
await manager.delete(report)
dismiss()
}
} label: {
Label("Delete", systemImage: "trash.fill")
.foregroundStyle(.red)
}
.tint(.red)
#endif
}
}
.navigationTitle(report.date.formatted(date: .abbreviated, time: .shortened))
}
}
@@ -0,0 +1,249 @@
import Libbox
import Library
import SwiftUI
@MainActor
public struct CrashReportListView: View {
@EnvironmentObject private var environments: ExtensionEnvironments
@State private var isLoading = true
@State private var alert: AlertState?
#if os(tvOS)
@State private var showCrashTrigger = false
@State private var selectedReport: CrashReport?
#endif
public init() {}
private var manager: CrashReportManager {
environments.crashReportManager
}
public var body: some View {
FormView {
if !isLoading {
Section {
if manager.reports.isEmpty {
Text("Empty")
.foregroundStyle(.secondary)
} else {
ForEach(manager.reports) { report in
#if os(tvOS)
Button {
selectedReport = report
} label: {
reportLabel(report)
}
#else
FormNavigationLink {
CrashReportDetailView(report: report)
} label: {
reportLabel(report)
}
#endif
}
}
} header: {
Text("Reports")
} footer: {
Text("You will receive a report when a crash occurs.")
}
}
}
.overlay {
if isLoading {
ProgressView()
}
}
.onAppear {
Task {
await manager.refresh()
isLoading = false
}
}
.navigationTitle("Crash Report")
.alert($alert)
#if os(tvOS)
.navigationDestination(item: $selectedReport) { report in
CrashReportDetailView(report: report)
.toolbar {
ToolbarItemGroup(placement: .topBarLeading) {
BackButton()
}
}
}
#endif
#if os(tvOS)
.navigationDestination(isPresented: $showCrashTrigger) {
CrashTriggerView()
}
.toolbar {
if Variant.inDebug {
ToolbarItem(placement: .confirmationAction) {
Button {
showCrashTrigger = true
} label: {
Image(systemName: "ant.fill")
}
}
}
if !manager.reports.isEmpty {
ToolbarItem(placement: .confirmationAction) {
Button {
Task {
await manager.deleteAll()
}
} label: {
Image(systemName: "trash.fill")
}
.tint(.red)
}
}
}
#else
.toolbar {
if !manager.reports.isEmpty || Variant.inDebug {
Menu {
if Variant.inDebug {
Menu {
Menu("Application") {
Button("Go Crash") {
LibboxTriggerGoPanic()
}
Button("Native Crash") {
DispatchQueue.global().asyncAfter(deadline: .now() + .milliseconds(200)) {
fatalError("debug native crash")
}
}
}
if let profile = environments.extensionProfile {
NetworkExtensionCrashMenu(profile: profile)
}
#if os(macOS)
RootHelperCrashMenu()
#endif
} label: {
Label("Crash Trigger", systemImage: "ant.fill")
}
}
if !manager.reports.isEmpty {
Button(role: .destructive) {
Task {
await manager.deleteAll()
}
} label: {
Label("Delete All", systemImage: "trash.fill")
}
}
} label: {
Label("Others", systemImage: "line.3.horizontal.circle")
}
}
}
#endif
}
private func reportLabel(_ report: CrashReport) -> some View {
ReportLabel(date: report.date, isRead: report.isRead, origin: report.origin)
}
}
#if os(tvOS)
private struct CrashTriggerView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var environments: ExtensionEnvironments
var body: some View {
Form {
Section("Application") {
Button("Go Crash") {
LibboxTriggerGoPanic()
}
Button("Native Crash") {
DispatchQueue.global().asyncAfter(deadline: .now() + .milliseconds(200)) {
fatalError("debug native crash")
}
}
}
if let profile = environments.extensionProfile, profile.status.isConnectedStrict {
Section("NetworkExtension") {
Button("Go Crash") {
try? LibboxNewStandaloneCommandClient()?.triggerGoCrash()
dismiss()
Task {
try? await Task.sleep(nanoseconds: NSEC_PER_SEC)
await environments.crashReportManager.refresh()
}
}
Button("Native Crash") {
try? LibboxNewStandaloneCommandClient()?.triggerNativeCrash()
dismiss()
Task {
try? await Task.sleep(nanoseconds: NSEC_PER_SEC)
await environments.crashReportManager.refresh()
}
}
}
}
}
.navigationTitle("Crash Trigger")
.toolbar {
ToolbarItemGroup(placement: .topBarLeading) {
BackButton()
}
}
}
}
#else
private struct NetworkExtensionCrashMenu: View {
@EnvironmentObject private var environments: ExtensionEnvironments
@ObservedObject var profile: ExtensionProfile
var body: some View {
if profile.status.isConnectedStrict {
Menu("NetworkExtension") {
Button("Go Crash") {
try? LibboxNewStandaloneCommandClient()?.triggerGoCrash()
Task {
try? await Task.sleep(nanoseconds: NSEC_PER_SEC)
await environments.crashReportManager.refresh()
}
}
Button("Native Crash") {
try? LibboxNewStandaloneCommandClient()?.triggerNativeCrash()
Task {
try? await Task.sleep(nanoseconds: NSEC_PER_SEC)
await environments.crashReportManager.refresh()
}
}
}
}
}
}
#endif
#if os(macOS)
private struct RootHelperCrashMenu: View {
@EnvironmentObject private var environments: ExtensionEnvironments
var body: some View {
if Variant.useSystemExtension, HelperServiceManager.rootHelperStatus == .enabled {
Menu("RootHelper") {
Button("Go Crash") {
try? RootHelperClient.shared.triggerGoCrash()
Task {
try? await Task.sleep(nanoseconds: NSEC_PER_SEC)
await environments.crashReportManager.refresh()
}
}
Button("Native Crash") {
try? RootHelperClient.shared.triggerNativeCrash()
Task {
try? await Task.sleep(nanoseconds: NSEC_PER_SEC)
await environments.crashReportManager.refresh()
}
}
}
}
}
}
#endif
@@ -0,0 +1,205 @@
#if os(tvOS)
import DeviceDiscoveryUI
import Library
import Network
import SwiftUI
private struct StreamedReportFile {
let name: String
let fileURL: URL
let size: UInt64
}
@MainActor
public struct ExportReportView: View {
@Environment(\.dismiss) private var dismiss
@StateObject private var viewModel = ExportReportViewModel()
let reportType: ReportType
let reportURL: URL
let reportDate: Date
public init(reportType: ReportType, reportURL: URL, reportDate: Date) {
self.reportType = reportType
self.reportURL = reportURL
self.reportDate = reportDate
}
public var body: some View {
VStack(alignment: .center) {
if !viewModel.selected {
Form {
Section {
EmptyView()
} footer: {
Text("To export this report to your iPhone or iPad, make sure sing-box is the **same version** on both devices and **VPN is disabled**.")
}
DevicePicker(
.applicationService(name: ReportTransferService.applicationServiceName)
) { endpoint in
viewModel.selected = true
Task {
await viewModel.handleEndpoint(endpoint, reportType: reportType, reportURL: reportURL, reportDate: reportDate)
}
} label: {
Text("Select Device")
} fallback: {
EmptyView()
} parameters: {
.applicationService
}
}
} else if viewModel.exportComplete {
VStack(spacing: 16) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 64))
.foregroundStyle(.green)
Text("Export Complete")
.font(.headline)
}
} else {
VStack(spacing: 16) {
ProgressView()
Text("Sending...")
}
}
}
.focusSection()
.alert($viewModel.alert)
.navigationTitle("Export Report")
.onChange(of: viewModel.exportComplete) { newValue in
if newValue {
Task {
try? await Task.sleep(nanoseconds: NSEC_PER_SEC * 2)
dismiss()
}
}
}
}
}
@MainActor
private final class ExportReportViewModel: BaseViewModel {
@Published var selected = false
@Published var exportComplete = false
private var connection: NWConnection?
private var socket: NWSocket?
func reset() {
cancelConnection()
selected = false
}
private func cancelConnection() {
if let connection {
connection.stateUpdateHandler = nil
connection.cancel()
self.connection = nil
}
if let socket {
socket.cancel()
self.socket = nil
}
}
func handleEndpoint(_ endpoint: NWEndpoint, reportType: ReportType, reportURL: URL, reportDate: Date) async {
let connection = NWConnection(to: endpoint, using: NWParameters.applicationService)
self.connection = connection
let socket = NWSocket(connection)
self.socket = socket
connection.stateUpdateHandler = { state in
switch state {
case let .failed(error):
DispatchQueue.main.async { [self] in
reset()
alert = AlertState(action: "connect to device", error: error)
}
default: break
}
}
connection.start(queue: .global())
do {
try await sendReport(reportType: reportType, reportURL: reportURL, reportDate: reportDate, via: socket)
cancelConnection()
exportComplete = true
} catch {
alert = AlertState(action: "export report", error: error)
reset()
}
}
private nonisolated func sendReport(reportType: ReportType, reportURL: URL, reportDate: Date, via socket: NWSocket) async throws {
let files = try collectFiles(in: reportURL)
guard !files.isEmpty else {
throw ReportTransferError("Report is empty")
}
let totalBytes = files.reduce(0) { $0 + $1.size }
let manifest = ReportTransferManifest(
reportType: reportType,
timestamp: reportDate.timeIntervalSince1970,
totalBytes: totalBytes,
files: files.map { ReportTransferManifestFile(name: $0.name, size: $0.size) }
)
try await socket.write(ReportTransferMessage.encodeReport(manifest))
for file in files {
try await streamFile(file, via: socket)
}
try await socket.write(ReportTransferMessage.encodeComplete())
let response = try await socket.read()
guard let responseType = ReportTransferMessage.decodeType(response) else {
throw NWSocketError.connectionClosed
}
switch responseType {
case .ack:
break
case .error:
throw ReportTransferError(ReportTransferMessage.decodeError(response))
default:
throw NWSocketError.connectionClosed
}
}
private nonisolated func collectFiles(in reportURL: URL) throws -> [StreamedReportFile] {
let fm = FileManager.default
let fileURLs = try fm.contentsOfDirectory(
at: reportURL,
includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey],
options: .skipsHiddenFiles
)
var files: [StreamedReportFile] = []
for fileURL in fileURLs.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) {
let values = try fileURL.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey])
guard values.isRegularFile == true else {
continue
}
let size = UInt64(values.fileSize ?? 0)
files.append(StreamedReportFile(name: fileURL.lastPathComponent, fileURL: fileURL, size: size))
}
return files
}
private nonisolated func streamFile(_ file: StreamedReportFile, via socket: NWSocket) async throws {
let handle = try FileHandle(forReadingFrom: file.fileURL)
defer { try? handle.close() }
var remaining = file.size
while remaining > 0 {
let chunkSize = Int(min(UInt64(ReportTransferService.fileChunkSize), remaining))
guard let data = try handle.read(upToCount: chunkSize), !data.isEmpty else {
throw ReportTransferError("Failed to read report file")
}
try await socket.writeRaw(data)
remaining -= UInt64(data.count)
}
}
}
#endif
@@ -0,0 +1,136 @@
import Libbox
import Library
import SwiftUI
@MainActor
public struct NetworkQualityView: View {
@EnvironmentObject private var environments: ExtensionEnvironments
@StateObject private var viewModel = NetworkQualityViewModel()
public init() {}
private var downloadActive: Bool {
(viewModel.isRunning && !viewModel.serial && viewModel.phase >= LibboxNetworkQualityPhaseDownload && viewModel.phase < LibboxNetworkQualityPhaseDone)
|| viewModel.phase == LibboxNetworkQualityPhaseDownload
}
private func accuracyLabel(_ value: Int32) -> (label: String, color: Color) {
switch value {
case LibboxNetworkQualityAccuracyHigh:
return (String(localized: "Confidence High"), .green)
case LibboxNetworkQualityAccuracyMedium:
return (String(localized: "Confidence Medium"), .yellow)
default:
return (String(localized: "Confidence Low"), .red)
}
}
private var uploadActive: Bool {
(viewModel.isRunning && !viewModel.serial && viewModel.phase >= LibboxNetworkQualityPhaseDownload && viewModel.phase < LibboxNetworkQualityPhaseDone)
|| viewModel.phase == LibboxNetworkQualityPhaseUpload
}
@ViewBuilder
private func resultValue(_ value: String?, active: Bool, accuracy: (label: String, color: Color)? = nil) -> some View {
if let value {
HStack(spacing: 6) {
if viewModel.isRunning, active {
ProgressView()
.controlSize(.small)
}
Text(value)
if let accuracy {
Text(accuracy.label)
.font(.caption)
.foregroundColor(accuracy.color)
}
}
} else if viewModel.isRunning, active {
ProgressView()
.controlSize(.small)
} else {
Text(verbatim: "-")
}
}
public var body: some View {
FormView {
Section("Configuration") {
#if os(tvOS)
FormTextItem("URL", "link") {
Text(viewModel.configURL)
}
#else
FormItem("URL") {
TextField(text: $viewModel.configURL) {}
.multilineTextAlignment(.trailing)
.autocorrectionDisabled()
#if os(iOS)
.textInputAutocapitalization(.never)
.keyboardType(.URL)
#endif
}
#endif
Toggle("Serial", isOn: $viewModel.serial)
.disabled(viewModel.isRunning)
Toggle("HTTP/3", isOn: $viewModel.http3)
.disabled(viewModel.isRunning)
Picker("Max Runtime", selection: $viewModel.maxRuntime) {
ForEach(MaxRuntimeOption.allCases) { option in
Text(option.label).tag(option)
}
}
.disabled(viewModel.isRunning)
if let profile = environments.extensionProfile {
ToolOutboundSection(profile: profile, viewModel: viewModel)
}
}
Section("Action") {
if viewModel.isRunning {
FormButton {
viewModel.cancel()
} label: {
Label("Cancel Test", systemImage: "stop.fill")
}
} else {
FormButton {
viewModel.requestStartTest(vpnConnected: environments.extensionProfile?.status.isConnectedStrict == true)
} label: {
Label("Start Test", systemImage: "play.fill")
}
}
}
if viewModel.phase >= 0 {
Section("Results") {
FormTextItem("Idle Latency", "timer") {
resultValue(viewModel.idleLatencyMs > 0 ? "\(viewModel.idleLatencyMs) ms" : nil, active: viewModel.phase == LibboxNetworkQualityPhaseIdle)
}
FormTextItem("Download", "arrow.down.circle") {
resultValue(viewModel.downloadCapacity > 0 ? LibboxFormatBitrate(viewModel.downloadCapacity) : nil, active: downloadActive, accuracy: viewModel.phase == LibboxNetworkQualityPhaseDone ? accuracyLabel(viewModel.downloadCapacityAccuracy) : nil)
}
FormTextItem("Download RPM", "arrow.down.to.line") {
resultValue(viewModel.downloadRPM > 0 ? "\(viewModel.downloadRPM)" : nil, active: downloadActive, accuracy: viewModel.phase == LibboxNetworkQualityPhaseDone ? accuracyLabel(viewModel.downloadRPMAccuracy) : nil)
}
FormTextItem("Upload", "arrow.up.circle") {
resultValue(viewModel.uploadCapacity > 0 ? LibboxFormatBitrate(viewModel.uploadCapacity) : nil, active: uploadActive, accuracy: viewModel.phase == LibboxNetworkQualityPhaseDone ? accuracyLabel(viewModel.uploadCapacityAccuracy) : nil)
}
FormTextItem("Upload RPM", "arrow.up.to.line") {
resultValue(viewModel.uploadRPM > 0 ? "\(viewModel.uploadRPM)" : nil, active: uploadActive, accuracy: viewModel.phase == LibboxNetworkQualityPhaseDone ? accuracyLabel(viewModel.uploadRPMAccuracy) : nil)
}
}
}
}
.navigationTitle("Network Quality")
.task {
await viewModel.loadPreferences()
}
.alert($viewModel.alert)
.onDisappear {
if viewModel.isRunning {
viewModel.cancel()
}
}
}
}
@@ -0,0 +1,239 @@
import Foundation
import Libbox
import Library
import Network
import SwiftUI
public enum MaxRuntimeOption: Int, CaseIterable, Identifiable {
case thirty = 30
case sixty = 60
public var id: Int {
rawValue
}
public var label: String {
"\(rawValue)s"
}
}
@MainActor
public final class NetworkQualityViewModel: BaseViewModel, OutboundSelectable {
@Published public var phase: Int32 = -1
@Published public var idleLatencyMs: Int32 = 0
@Published public var downloadCapacity: Int64 = 0
@Published public var uploadCapacity: Int64 = 0
@Published public var downloadRPM: Int32 = 0
@Published public var uploadRPM: Int32 = 0
@Published public var downloadCapacityAccuracy: Int32 = 0
@Published public var uploadCapacityAccuracy: Int32 = 0
@Published public var downloadRPMAccuracy: Int32 = 0
@Published public var uploadRPMAccuracy: Int32 = 0
@Published public var isRunning = false
@Published public var selectedOutbound: String = ""
@Published public var configURL: String = LibboxNetworkQualityDefaultConfigURL {
didSet {
guard !isLoadingPreferences else { return }
saveConfigURLTask?.cancel()
saveConfigURLTask = Task {
try? await Task.sleep(nanoseconds: 300_000_000)
guard !Task.isCancelled else { return }
await SharedPreferences.nqConfigURL.set(configURL)
}
}
}
@Published public var serial: Bool = false {
didSet {
guard !isLoadingPreferences else { return }
Task {
await SharedPreferences.nqSerial.set(serial)
}
}
}
@Published public var http3: Bool = false {
didSet {
guard !isLoadingPreferences else { return }
Task {
await SharedPreferences.nqHttp3.set(http3)
}
}
}
@Published public var maxRuntime: MaxRuntimeOption = .thirty {
didSet {
guard !isLoadingPreferences else { return }
Task {
await SharedPreferences.nqMaxRuntime.set(maxRuntime.rawValue)
}
}
}
private var isLoadingPreferences = false
private var saveConfigURLTask: Task<Void, Never>?
private var standaloneTest: LibboxNetworkQualityTest?
private var runningTask: Task<Void, Never>?
public func loadPreferences() async {
isLoadingPreferences = true
let savedURL = await SharedPreferences.nqConfigURL.get()
if !savedURL.isEmpty {
configURL = savedURL
}
serial = await SharedPreferences.nqSerial.get()
http3 = await SharedPreferences.nqHttp3.get()
let savedRuntime = await SharedPreferences.nqMaxRuntime.get()
maxRuntime = MaxRuntimeOption(rawValue: savedRuntime) ?? .thirty
isLoadingPreferences = false
}
private func checkMeteredNetwork() async -> Bool {
await withCheckedContinuation { continuation in
let monitor = NWPathMonitor()
monitor.pathUpdateHandler = { path in
monitor.cancel()
continuation.resume(returning: path.isExpensive || path.usesInterfaceType(.cellular))
}
monitor.start(queue: DispatchQueue.global())
}
}
public func requestStartTest(vpnConnected: Bool) {
Task {
let isMetered = await checkMeteredNetwork()
if isMetered {
alert = AlertState(
title: String(localized: "Metered Connection"),
message: String(localized: "You're on a metered connection. This test will use a significant amount of data."),
primaryButton: .cancel(),
secondaryButton: .destructive(String(localized: "Continue")) { [weak self] in
self?.startTest(vpnConnected: vpnConnected)
}
)
} else {
startTest(vpnConnected: vpnConnected)
}
}
}
public func startTest(vpnConnected: Bool) {
phase = -1
idleLatencyMs = 0
downloadCapacity = 0
uploadCapacity = 0
downloadRPM = 0
uploadRPM = 0
downloadCapacityAccuracy = 0
uploadCapacityAccuracy = 0
downloadRPMAccuracy = 0
uploadRPMAccuracy = 0
isRunning = true
let configURL = configURL
let outboundTag = selectedOutbound
let serial = serial
let http3 = http3
let maxRuntimeSeconds = Int32(maxRuntime.rawValue)
if vpnConnected {
let handler = TestHandler(self)
runningTask = Task { [weak self] in
do {
try await Task.detached {
try LibboxNewStandaloneCommandClient()!.startNetworkQualityTest(configURL, outboundTag: outboundTag, serial: serial, maxRuntimeSeconds: maxRuntimeSeconds, http3: http3, handler: handler)
}.value
} catch {
guard let self else { return }
self.isRunning = false
self.alert = AlertState(action: "network quality test", error: error)
}
self?.runningTask = nil
}
} else {
let test = LibboxNewNetworkQualityTest()!
standaloneTest = test
let handler = TestHandler(self)
test.start(configURL, serial: serial, maxRuntimeSeconds: maxRuntimeSeconds, http3: http3, handler: handler)
}
}
fileprivate func applyMetrics(phase: Int32, idleLatencyMs: Int32, downloadCapacity: Int64, uploadCapacity: Int64, downloadRPM: Int32, uploadRPM: Int32, downloadCapacityAccuracy: Int32, uploadCapacityAccuracy: Int32, downloadRPMAccuracy: Int32, uploadRPMAccuracy: Int32) {
self.phase = phase
self.idleLatencyMs = idleLatencyMs
self.downloadCapacity = downloadCapacity
self.uploadCapacity = uploadCapacity
self.downloadRPM = downloadRPM
self.uploadRPM = uploadRPM
self.downloadCapacityAccuracy = downloadCapacityAccuracy
self.uploadCapacityAccuracy = uploadCapacityAccuracy
self.downloadRPMAccuracy = downloadRPMAccuracy
self.uploadRPMAccuracy = uploadRPMAccuracy
}
public func cancel() {
runningTask?.cancel()
runningTask = nil
standaloneTest?.cancel()
standaloneTest = nil
isRunning = false
}
private final class TestHandler: NSObject, LibboxNetworkQualityTestHandlerProtocol, @unchecked Sendable {
private weak var viewModel: NetworkQualityViewModel?
init(_ viewModel: NetworkQualityViewModel?) {
self.viewModel = viewModel
}
func onProgress(_ progress: LibboxNetworkQualityProgress?) {
guard let progress else { return }
let phase = progress.phase
let idleLatencyMs = progress.idleLatencyMs
let downloadCapacity = progress.downloadCapacity
let uploadCapacity = progress.uploadCapacity
let downloadRPM = progress.downloadRPM
let uploadRPM = progress.uploadRPM
let downloadCapacityAccuracy = progress.downloadCapacityAccuracy
let uploadCapacityAccuracy = progress.uploadCapacityAccuracy
let downloadRPMAccuracy = progress.downloadRPMAccuracy
let uploadRPMAccuracy = progress.uploadRPMAccuracy
DispatchQueue.main.async { [self] in
guard let viewModel, viewModel.isRunning else { return }
viewModel.applyMetrics(phase: phase, idleLatencyMs: idleLatencyMs, downloadCapacity: downloadCapacity, uploadCapacity: uploadCapacity, downloadRPM: downloadRPM, uploadRPM: uploadRPM, downloadCapacityAccuracy: downloadCapacityAccuracy, uploadCapacityAccuracy: uploadCapacityAccuracy, downloadRPMAccuracy: downloadRPMAccuracy, uploadRPMAccuracy: uploadRPMAccuracy)
}
}
func onResult(_ result: LibboxNetworkQualityResult?) {
guard let result else { return }
let idleLatencyMs = result.idleLatencyMs
let downloadCapacity = result.downloadCapacity
let uploadCapacity = result.uploadCapacity
let downloadRPM = result.downloadRPM
let uploadRPM = result.uploadRPM
let downloadCapacityAccuracy = result.downloadCapacityAccuracy
let uploadCapacityAccuracy = result.uploadCapacityAccuracy
let downloadRPMAccuracy = result.downloadRPMAccuracy
let uploadRPMAccuracy = result.uploadRPMAccuracy
DispatchQueue.main.async { [self] in
guard let viewModel, viewModel.isRunning else { return }
viewModel.applyMetrics(phase: LibboxNetworkQualityPhaseDone, idleLatencyMs: idleLatencyMs, downloadCapacity: downloadCapacity, uploadCapacity: uploadCapacity, downloadRPM: downloadRPM, uploadRPM: uploadRPM, downloadCapacityAccuracy: downloadCapacityAccuracy, uploadCapacityAccuracy: uploadCapacityAccuracy, downloadRPMAccuracy: downloadRPMAccuracy, uploadRPMAccuracy: uploadRPMAccuracy)
viewModel.isRunning = false
viewModel.runningTask = nil
viewModel.standaloneTest = nil
}
}
func onError(_ message: String?) {
DispatchQueue.main.async { [self] in
guard let viewModel, viewModel.isRunning else { return }
viewModel.isRunning = false
viewModel.runningTask = nil
viewModel.standaloneTest = nil
if let message {
viewModel.alert = AlertState(errorMessage: message)
}
}
}
}
}
@@ -0,0 +1,166 @@
import Library
import SwiftUI
@MainActor
public struct OOMReportDetailView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var environments: ExtensionEnvironments
@State private var alert: AlertState?
@State private var files: [OOMReportFile] = []
@State private var isLoading = true
#if os(macOS)
@State private var sharePresented = false
@State private var shareItemURL: URL?
#elseif os(tvOS)
@State private var showExport = false
#endif
let report: OOMReport
public init(report: OOMReport) {
self.report = report
}
private var manager: OOMReportManager {
environments.oomReportManager
}
#if !os(tvOS)
private func shareReport(includeConfig: Bool) async {
do {
let zipURL = try await createReportZip(
reportID: report.id, fileURL: report.fileURL,
cacheSubdirectory: ReportType.oom.directoryName, includeConfig: includeConfig
)
#if os(iOS)
presentShareSheet(zipURL)
#elseif os(macOS)
shareItemURL = zipURL
sharePresented = true
#endif
} catch {
alert = AlertState(action: "export OOM report", error: error)
}
}
#endif
public var body: some View {
FormView {
if !isLoading, !files.isEmpty {
Section("Files") {
ForEach(files) { file in
if file.kind == .metadata {
FormNavigationLink {
MetadataFormView(url: file.fileURL, title: file.displayName)
} label: {
Text(file.displayName)
}
} else if file.kind == .configContent {
FormNavigationLink {
ReportFileContentView(fileURL: file.fileURL, displayName: file.displayName)
} label: {
Text(file.displayName)
}
} else {
Text(file.displayName)
}
}
}
}
}
.overlay {
if isLoading {
ProgressView()
} else if files.isEmpty {
Text("Empty")
.foregroundStyle(.secondary)
}
}
.onAppear {
Task {
files = await manager.availableFiles(for: report)
manager.markAsRead(report)
isLoading = false
}
}
.alert($alert)
#if os(tvOS)
.navigationDestination(isPresented: $showExport) {
ExportReportView(reportType: .oom, reportURL: report.fileURL, reportDate: report.date)
.toolbar {
ToolbarItemGroup(placement: .topBarLeading) {
BackButton()
}
}
}
#elseif os(macOS)
.background(SharingServicePicker($sharePresented, $alert, $shareItemURL))
#endif
.toolbar {
if !isLoading, !files.isEmpty {
#if os(tvOS)
ToolbarItem(placement: .confirmationAction) {
Button {
showExport = true
} label: {
Image(systemName: "square.and.arrow.up")
}
}
ToolbarItem(placement: .confirmationAction) {
Button {
Task {
await manager.delete(report)
dismiss()
}
} label: {
Image(systemName: "trash.fill")
}
.tint(.red)
}
#else
if files.contains(where: { $0.kind == .configContent }) {
Menu {
Button {
Task {
await shareReport(includeConfig: false)
}
} label: {
Label("Share", systemImage: "square.and.arrow.up")
}
Button {
Task {
await shareReport(includeConfig: true)
}
} label: {
Label("Share With Configuration", systemImage: "square.and.arrow.up.on.square")
}
} label: {
Label("Share", systemImage: "square.and.arrow.up")
}
} else {
Button {
Task {
await shareReport(includeConfig: false)
}
} label: {
Label("Share", systemImage: "square.and.arrow.up")
}
}
Button(role: .destructive) {
Task {
await manager.delete(report)
dismiss()
}
} label: {
Label("Delete", systemImage: "trash.fill")
.foregroundStyle(.red)
}
.tint(.red)
#endif
}
}
.navigationTitle(report.date.formatted(date: .abbreviated, time: .shortened))
}
}
@@ -0,0 +1,246 @@
import Libbox
import Library
import SwiftUI
@MainActor
public struct OOMReportListView: View {
@EnvironmentObject private var environments: ExtensionEnvironments
@State private var isLoading = true
#if os(tvOS)
@State private var selectedReport: OOMReport?
#endif
#if os(macOS)
@State private var oomKillerEnabled = false
@State private var oomMemoryLimitMB = 50
@State private var oomKillerKillConnections = false
@State private var alert: AlertState?
#endif
public init() {}
private var manager: OOMReportManager {
environments.oomReportManager
}
public var body: some View {
FormView {
if !isLoading {
Section {
if manager.reports.isEmpty {
Text("Empty")
.foregroundStyle(.secondary)
} else {
ForEach(manager.reports) { report in
#if os(tvOS)
Button {
selectedReport = report
} label: {
reportLabel(report)
}
#else
FormNavigationLink {
OOMReportDetailView(report: report)
} label: {
reportLabel(report)
}
#endif
}
}
} header: {
Text("Reports")
} footer: {
#if os(macOS)
Text("When memory limit is enabled, you will receive a report if the service memory exceeds the limit. You can also manually trigger report collection.")
#else
Text("You will receive a report when the service runs out of memory. You can also manually trigger report collection.")
#endif
}
#if os(macOS)
Section {
FormToggle("Enable Memory Limit", """
Provide a soft memory limit for the service. The service will perform multiple processes to try to stay within this memory limit.
""", $oomKillerEnabled) { newValue in
await SharedPreferences.oomKillerEnabled.set(newValue)
await restartService()
}
if oomKillerEnabled {
Picker("Memory Limit", selection: $oomMemoryLimitMB) {
ForEach(Self.memoryLimitOptions, id: \.self) { value in
Text(LibboxFormatMemoryBytes(Int64(value) * 1024 * 1024))
.tag(value)
}
}
.onChange(of: oomMemoryLimitMB) { _ in
Task {
await SharedPreferences.oomMemoryLimitMB.set(oomMemoryLimitMB)
await restartService()
}
}
FormToggle("Kill Connections", """
Kill all connections to free memory when the service memory exceeds the limit.
""", $oomKillerKillConnections) { newValue in
await SharedPreferences.oomKillerKillConnections.set(newValue)
await restartService()
}
}
} header: {
Text("Settings")
}
#endif
}
}
.overlay {
if isLoading {
ProgressView()
}
}
.onAppear {
Task {
await manager.refresh()
#if os(macOS)
oomKillerEnabled = await SharedPreferences.oomKillerEnabled.get()
let storedLimit = await SharedPreferences.oomMemoryLimitMB.get()
if Self.memoryLimitOptions.contains(storedLimit) {
oomMemoryLimitMB = storedLimit
} else {
oomMemoryLimitMB = Self.memoryLimitOptions.first!
await SharedPreferences.oomMemoryLimitMB.set(oomMemoryLimitMB)
}
oomKillerKillConnections = await SharedPreferences.oomKillerKillConnections.get()
#endif
isLoading = false
}
}
.navigationTitle("OOM Report")
#if os(macOS)
.alert($alert)
#endif
#if os(tvOS)
.navigationDestination(item: $selectedReport) { report in
OOMReportDetailView(report: report)
.toolbar {
ToolbarItemGroup(placement: .topBarLeading) {
BackButton()
}
}
}
#endif
.toolbar {
#if os(tvOS)
if !manager.reports.isEmpty {
ToolbarItem(placement: .confirmationAction) {
Button {
Task {
await manager.deleteAll()
}
} label: {
Image(systemName: "trash.fill")
}
.tint(.red)
}
}
if let profile = environments.extensionProfile {
ToolbarItem(placement: .confirmationAction) {
OOMReportTriggerButton(manager: manager, profile: profile)
}
}
#else
if let profile = environments.extensionProfile {
OOMReportToolbarMenu(manager: manager, profile: profile)
} else if !manager.reports.isEmpty {
Menu {
Button(role: .destructive) {
Task {
await manager.deleteAll()
}
} label: {
Label("Delete All", systemImage: "trash.fill")
}
} label: {
Label("Others", systemImage: "line.3.horizontal.circle")
}
}
#endif
}
}
private func reportLabel(_ report: OOMReport) -> some View {
ReportLabel(date: report.date, isRead: report.isRead, origin: report.origin)
}
#if os(macOS)
private static let memoryLimitOptions = [50, 100, 200, 300, 500, 750, 1024]
private func restartService() async {
guard let profile = environments.extensionProfile, profile.status.isConnected else {
return
}
do {
try await profile.restart()
} catch {
alert = AlertState(action: "restart service", error: error)
}
}
#endif
}
#if os(tvOS)
private struct OOMReportTriggerButton: View {
let manager: OOMReportManager
@ObservedObject var profile: ExtensionProfile
@State private var alert: AlertState?
var body: some View {
Button {
triggerOOMReport(profile: profile, manager: manager, alert: &alert)
} label: {
Image(systemName: "memorychip")
}
.alert($alert)
}
}
#else
private struct OOMReportToolbarMenu: View {
let manager: OOMReportManager
@ObservedObject var profile: ExtensionProfile
@State private var alert: AlertState?
var body: some View {
Menu {
Button {
triggerOOMReport(profile: profile, manager: manager, alert: &alert)
} label: {
Label("Fetch Memory Report", systemImage: "memorychip")
}
if !manager.reports.isEmpty {
Button(role: .destructive) {
Task {
await manager.deleteAll()
}
} label: {
Label("Delete All", systemImage: "trash.fill")
}
}
} label: {
Label("Others", systemImage: "line.3.horizontal.circle")
}
.alert($alert)
}
}
#endif
@MainActor
private func triggerOOMReport(profile: ExtensionProfile, manager: OOMReportManager, alert: inout AlertState?) {
guard profile.status.isConnectedStrict else {
alert = AlertState(errorMessage: String(localized: "Service not started"))
return
}
try? LibboxNewStandaloneCommandClient()?.triggerOOMReport()
Task {
try? await Task.sleep(nanoseconds: NSEC_PER_SEC)
await manager.refresh()
}
}
@@ -0,0 +1,127 @@
import Libbox
import Library
import SwiftUI
@MainActor
public protocol OutboundSelectable: ObservableObject {
var selectedOutbound: String { get set }
var isRunning: Bool { get }
func cancel()
}
public struct ToolOutboundSection<VM: OutboundSelectable>: View {
@ObservedObject var profile: ExtensionProfile
@ObservedObject var viewModel: VM
public var body: some View {
Group {
if profile.status.isConnectedStrict {
FormNavigationLink {
OutboundPickerView(selectedOutbound: $viewModel.selectedOutbound)
} label: {
HStack {
Text("Outbound")
Spacer()
Text(viewModel.selectedOutbound.isEmpty ? String(localized: "Default") : viewModel.selectedOutbound)
.foregroundColor(.secondary)
.lineLimit(1)
}
}
}
}
.onChangeCompat(of: profile.status) { status in
if !status.isConnectedStrict {
if viewModel.isRunning {
viewModel.cancel()
}
viewModel.selectedOutbound = ""
}
}
}
}
@MainActor
public struct OutboundPickerView: View {
@Binding var selectedOutbound: String
@StateObject private var commandClient = CommandClient(.outbounds)
@State private var outbounds: [OutboundGroupItem] = []
@State private var searchText = ""
@Environment(\.dismiss) private var dismiss
private var filteredOutbounds: [OutboundGroupItem] {
if searchText.isEmpty {
return outbounds
}
return outbounds.filter { $0.tag.localizedCaseInsensitiveContains(searchText) }
}
public var body: some View {
List {
Button {
selectedOutbound = ""
dismiss()
} label: {
HStack {
Text("Default")
.foregroundStyle(.foreground)
Spacer()
if selectedOutbound.isEmpty {
Image(systemName: "checkmark")
.foregroundStyle(Color.accentColor)
}
}
}
#if os(macOS)
.buttonStyle(.plain)
#endif
ForEach(filteredOutbounds, id: \.tag) { item in
Button {
selectedOutbound = item.tag
dismiss()
} label: {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(item.tag)
.foregroundStyle(.foreground)
.lineLimit(1)
HStack {
Text(item.displayType)
.font(.caption)
.foregroundColor(.secondary)
Spacer(minLength: 0)
if item.urlTestDelay > 0 {
Text(item.delayString)
.font(.caption)
.foregroundColor(item.delayColor)
}
}
}
if selectedOutbound == item.tag {
Image(systemName: "checkmark")
.foregroundStyle(Color.accentColor)
}
}
}
#if os(macOS)
.buttonStyle(.plain)
#endif
}
}
#if os(iOS)
.searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always))
#else
.searchable(text: $searchText)
#endif
.navigationTitle("Outbound")
.onAppear {
commandClient.connect()
}
.onDisappear {
commandClient.disconnect()
}
.onReceive(commandClient.$outbounds) { goOutbounds in
guard let goOutbounds else { return }
outbounds = goOutbounds.map { OutboundGroupItem($0) }
}
}
}
@@ -0,0 +1,127 @@
import Libbox
import Library
import SwiftUI
#if canImport(UIKit)
import UIKit
#endif
struct ReportLabel: View {
let date: Date
let isRead: Bool
let origin: String?
var body: some View {
HStack(spacing: 8) {
Circle()
.fill(isRead ? .clear : .blue)
.frame(width: 10, height: 10)
VStack(alignment: .leading, spacing: 2) {
Text(date, format: .dateTime)
.fontWeight(isRead ? .regular : .semibold)
HStack(spacing: 4) {
Image(systemName: origin == ReportArchive.tvOSDeviceOrigin ? "appletv.fill" : Self.localDeviceIcon)
Text(origin == ReportArchive.tvOSDeviceOrigin ? "Apple TV" : "Local")
}
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
#if os(iOS)
private static let localDeviceIcon = "iphone"
#elseif os(macOS)
private static let localDeviceIcon = "desktopcomputer"
#elseif os(tvOS)
private static let localDeviceIcon = "appletv.fill"
#endif
}
@MainActor
struct ReportFileContentView: View {
@State private var content = ""
@State private var isLoading = true
let fileURL: URL
let displayName: String
var body: some View {
Group {
if isLoading {
ProgressView()
.onAppear {
Task {
content = await Self.loadContent(fileURL: fileURL)
isLoading = false
}
}
} else if content.isEmpty {
Text("Empty")
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
#if os(iOS)
ScrollView {
PlainTextView(content: content)
}
#else
PlainTextView(content: content)
#endif
}
}
.navigationTitle(displayName)
}
private nonisolated static func loadContent(fileURL: URL) async -> String {
await BlockingIO.run {
guard let data = try? Data(contentsOf: fileURL) else {
return ""
}
return String(data: data, encoding: .utf8) ?? ""
}
}
}
#if !os(tvOS)
@MainActor
func createReportZip(reportID: String, fileURL: URL, cacheSubdirectory: String, includeConfig: Bool) async throws -> URL {
try await BlockingIO.run {
let tempDir = FilePath.cacheDirectory.appendingPathComponent(cacheSubdirectory, isDirectory: true)
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let tempURL = tempDir.appendingPathComponent("\(reportID).zip")
try? FileManager.default.removeItem(at: tempURL)
let strippedURL = tempDir.appendingPathComponent(reportID, isDirectory: true)
try? FileManager.default.removeItem(at: strippedURL)
try FileManager.default.copyItem(at: fileURL, to: strippedURL)
try? FileManager.default.removeItem(at: strippedURL.appendingPathComponent(ReportArchive.readMarkerFileName))
if !includeConfig {
try? FileManager.default.removeItem(at: strippedURL.appendingPathComponent(ReportArchive.configFileName))
}
var error: NSError?
LibboxCreateZipArchive(strippedURL.path, tempURL.path, &error)
try? FileManager.default.removeItem(at: strippedURL)
if let error { throw error }
return tempURL
}
}
#if os(iOS)
@MainActor
func presentShareSheet(_ item: URL) {
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootViewController = windowScene.keyWindow?.rootViewController
else {
return
}
var topViewController = rootViewController
while let presented = topViewController.presentedViewController {
topViewController = presented
}
topViewController.present(
UIActivityViewController(activityItems: [item], applicationActivities: nil),
animated: true
)
}
#endif
#endif
@@ -0,0 +1,123 @@
import Libbox
import Library
import SwiftUI
@MainActor
public struct STUNTestView: View {
@EnvironmentObject private var environments: ExtensionEnvironments
@StateObject private var viewModel = STUNTestViewModel()
public init() {}
private func natMappingColor(_ value: Int32) -> Color {
switch value {
case LibboxNATMappingEndpointIndependent: .green
case LibboxNATMappingAddressDependent: .yellow
case LibboxNATMappingAddressAndPortDependent: .red
default: .primary
}
}
private func natFilteringColor(_ value: Int32) -> Color {
switch value {
case LibboxNATFilteringEndpointIndependent: .green
case LibboxNATFilteringAddressDependent: .yellow
case LibboxNATFilteringAddressAndPortDependent: .red
default: .primary
}
}
@ViewBuilder
private func resultValue(_ value: String?, active: Bool) -> some View {
if let value {
HStack(spacing: 6) {
if viewModel.isRunning, active {
ProgressView()
.controlSize(.small)
}
Text(value)
}
} else if viewModel.isRunning, active {
ProgressView()
.controlSize(.small)
} else {
Text(verbatim: "-")
}
}
public var body: some View {
FormView {
Section("Configuration") {
#if os(tvOS)
FormTextItem("Server", "server.rack") {
Text(viewModel.server)
}
#else
FormItem(String(localized: "Server")) {
TextField(text: $viewModel.server) {}
.multilineTextAlignment(.trailing)
.autocorrectionDisabled()
#if os(iOS)
.textInputAutocapitalization(.never)
.keyboardType(.URL)
#endif
}
#endif
if let profile = environments.extensionProfile {
ToolOutboundSection(profile: profile, viewModel: viewModel)
}
}
Section("Action") {
if viewModel.isRunning {
FormButton {
viewModel.cancel()
} label: {
Label("Cancel Test", systemImage: "stop.fill")
}
} else {
FormButton {
viewModel.startTest(vpnConnected: environments.extensionProfile?.status.isConnectedStrict == true)
} label: {
Label("Start Test", systemImage: "play.fill")
}
}
}
if viewModel.phase >= 0 {
Section("Results") {
FormTextItem("External Address", "network") {
resultValue(viewModel.externalAddr.isEmpty ? nil : viewModel.externalAddr, active: viewModel.phase == LibboxSTUNPhaseBinding)
}
FormTextItem("Latency", "timer") {
resultValue(viewModel.latencyMs > 0 ? "\(viewModel.latencyMs) ms" : nil, active: viewModel.phase == LibboxSTUNPhaseBinding)
}
if viewModel.phase == LibboxSTUNPhaseDone, !viewModel.natTypeSupported {
FormTextItem("NAT Type Detection", "exclamationmark.triangle") {
Text("Not supported by server")
}
} else {
FormTextItem("NAT Mapping", "arrow.left.arrow.right") {
resultValue(viewModel.natMapping > 0 ? LibboxFormatNATMapping(viewModel.natMapping) : nil, active: viewModel.phase == LibboxSTUNPhaseNATMapping)
.foregroundStyle(viewModel.natMapping > 0 ? natMappingColor(viewModel.natMapping) : .primary)
}
FormTextItem("NAT Filtering", "line.3.horizontal.decrease") {
resultValue(viewModel.natFiltering > 0 ? LibboxFormatNATFiltering(viewModel.natFiltering) : nil, active: viewModel.phase == LibboxSTUNPhaseNATFiltering)
.foregroundStyle(viewModel.natFiltering > 0 ? natFilteringColor(viewModel.natFiltering) : .primary)
}
}
}
}
}
.navigationTitle("STUN Test")
.task {
await viewModel.loadPreferences()
}
.alert($viewModel.alert)
.onDisappear {
if viewModel.isRunning {
viewModel.cancel()
}
}
}
}
@@ -0,0 +1,146 @@
import Foundation
import Libbox
import Library
import SwiftUI
@MainActor
public final class STUNTestViewModel: BaseViewModel, OutboundSelectable {
@Published public var phase: Int32 = -1
@Published public var externalAddr: String = ""
@Published public var latencyMs: Int32 = 0
@Published public var natMapping: Int32 = 0
@Published public var natFiltering: Int32 = 0
@Published public var natTypeSupported: Bool = false
@Published public var isRunning = false
@Published public var selectedOutbound: String = ""
@Published public var server: String = LibboxSTUNDefaultServer {
didSet {
guard !isLoadingPreferences else { return }
saveServerTask?.cancel()
saveServerTask = Task {
try? await Task.sleep(nanoseconds: 300_000_000)
guard !Task.isCancelled else { return }
await SharedPreferences.stunServer.set(server)
}
}
}
private var isLoadingPreferences = false
private var saveServerTask: Task<Void, Never>?
private var standaloneTest: LibboxSTUNTest?
private var runningTask: Task<Void, Never>?
public func loadPreferences() async {
isLoadingPreferences = true
let saved = await SharedPreferences.stunServer.get()
if !saved.isEmpty {
server = saved
}
isLoadingPreferences = false
}
public func startTest(vpnConnected: Bool) {
phase = -1
externalAddr = ""
latencyMs = 0
natMapping = 0
natFiltering = 0
natTypeSupported = false
isRunning = true
let server = server
let outboundTag = selectedOutbound
if vpnConnected {
let handler = TestHandler(self)
runningTask = Task { [weak self] in
do {
try await Task.detached {
try LibboxNewStandaloneCommandClient()!.startSTUNTest(server, outboundTag: outboundTag, handler: handler)
}.value
} catch {
guard let self else { return }
self.isRunning = false
self.alert = AlertState(action: "STUN test", error: error)
}
self?.runningTask = nil
}
} else {
let test = LibboxNewSTUNTest()!
standaloneTest = test
let handler = TestHandler(self)
test.start(server, handler: handler)
}
}
public func cancel() {
runningTask?.cancel()
runningTask = nil
standaloneTest?.cancel()
standaloneTest = nil
isRunning = false
}
private final class TestHandler: NSObject, LibboxSTUNTestHandlerProtocol, @unchecked Sendable {
private weak var viewModel: STUNTestViewModel?
init(_ viewModel: STUNTestViewModel?) {
self.viewModel = viewModel
}
func onProgress(_ progress: LibboxSTUNTestProgress?) {
guard let progress else { return }
let phase = progress.phase
let externalAddr = progress.externalAddr
let latencyMs = progress.latencyMs
let natMapping = progress.natMapping
let natFiltering = progress.natFiltering
DispatchQueue.main.async { [self] in
guard let viewModel, viewModel.isRunning else { return }
viewModel.phase = phase
if !externalAddr.isEmpty {
viewModel.externalAddr = externalAddr
}
if latencyMs > 0 {
viewModel.latencyMs = latencyMs
}
viewModel.natMapping = natMapping
viewModel.natFiltering = natFiltering
}
}
func onResult(_ result: LibboxSTUNTestResult?) {
guard let result else { return }
let externalAddr = result.externalAddr
let latencyMs = result.latencyMs
let natMapping = result.natMapping
let natFiltering = result.natFiltering
let natTypeSupported = result.natTypeSupported
DispatchQueue.main.async { [self] in
guard let viewModel, viewModel.isRunning else { return }
viewModel.phase = LibboxSTUNPhaseDone
viewModel.externalAddr = externalAddr
viewModel.latencyMs = latencyMs
viewModel.natMapping = natMapping
viewModel.natFiltering = natFiltering
viewModel.natTypeSupported = natTypeSupported
viewModel.isRunning = false
viewModel.runningTask = nil
viewModel.standaloneTest = nil
}
}
func onError(_ message: String?) {
DispatchQueue.main.async { [self] in
guard let viewModel, viewModel.isRunning else { return }
viewModel.isRunning = false
viewModel.runningTask = nil
viewModel.standaloneTest = nil
if let message {
viewModel.alert = AlertState(errorMessage: message)
}
}
}
}
}
@@ -0,0 +1,116 @@
import Library
import SwiftUI
@MainActor
public struct TailscaleEndpointView: View {
@ObservedObject var viewModel: TailscaleStatusViewModel
@Environment(\.dismiss) private var dismiss
@State private var showAuthURLQRCode = false
let endpointTag: String
public init(viewModel: TailscaleStatusViewModel, endpointTag: String) {
self.viewModel = viewModel
self.endpointTag = endpointTag
}
private var endpoint: TailscaleEndpointData? {
viewModel.endpoint(tag: endpointTag)
}
public var body: some View {
FormView {
if let endpoint {
Section("Status") {
FormTextItem("State", "power") {
HStack(spacing: 6) {
Image(systemName: "circle.fill")
.font(.system(size: 8))
.foregroundStyle(stateColor(endpoint.backendState))
Text(endpoint.backendState)
}
}
if !endpoint.networkName.isEmpty {
FormTextItem("Network", "network") {
Text(endpoint.networkName)
}
}
if !endpoint.magicDNSSuffix.isEmpty {
FormTextItem("MagicDNS", "globe") {
Text(endpoint.magicDNSSuffix)
}
}
if !endpoint.authURL.isEmpty {
if let url = URL(string: endpoint.authURL) {
#if !os(tvOS)
Link(destination: url) {
Label("Open Auth URL", systemImage: "arrow.up.forward.app")
}
#endif
Button {
showAuthURLQRCode = true
} label: {
Label("Open Auth URL as QR Code", systemImage: "qrcode")
}
}
}
}
if endpoint.backendState == "Running", let selfPeer = endpoint.selfPeer {
Section("This Device") {
peerLink(selfPeer, isSelf: true)
}
}
ForEach(endpoint.userGroups) { group in
Section {
ForEach(group.peers) { peer in
peerLink(peer, isSelf: false)
}
} header: {
Text(group.displayName.isEmpty ? group.loginName : group.displayName)
}
}
}
}
.navigationTitle(endpointTag)
.sheet(isPresented: $showAuthURLQRCode) {
if let endpoint {
URLQRCodeSheet(url: endpoint.authURL, title: String(localized: "Auth URL"))
}
}
.onChangeCompat(of: endpoint == nil) { isNil in
if isNil {
dismiss()
}
}
}
private func peerLink(_ peer: TailscalePeerData, isSelf: Bool) -> some View {
FormNavigationLink {
TailscalePeerView(peer: peer, endpointTag: endpointTag, isSelf: isSelf)
} label: {
HStack {
Image(systemName: "circle.fill")
.font(.system(size: 8))
.foregroundStyle(peer.online ? .green : Color(.systemGray))
VStack(alignment: .leading, spacing: 2) {
Text(peer.hostName)
if let firstIP = peer.tailscaleIPs.first {
Text(firstIP)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
}
private func stateColor(_ state: String) -> Color {
switch state {
case "Running": .green
case "NeedsLogin", "NeedsMachineAuth": .orange
case "Starting": .yellow
default: Color(.systemGray)
}
}
}
@@ -0,0 +1,224 @@
import Libbox
import Library
import SwiftUI
#if os(iOS) || os(tvOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
@MainActor
public struct TailscalePeerView: View {
let peer: TailscalePeerData
let endpointTag: String
let isSelf: Bool
@State private var copiedAddress: String?
@StateObject private var pingViewModel = TailscalePingViewModel()
public init(peer: TailscalePeerData, endpointTag: String, isSelf: Bool) {
self.peer = peer
self.endpointTag = endpointTag
self.isSelf = isSelf
}
public var body: some View {
FormView {
Section("Tailscale Addresses") {
if !peer.dnsName.isEmpty {
addressRow(LibboxFormatFQDN(peer.dnsName), label: "MagicDNS")
}
ForEach(Array(peer.tailscaleIPs.enumerated()), id: \.offset) { _, ip in
if ip.contains(":") {
addressRow(ip, label: "IPv6")
} else {
addressRow(ip, label: "IPv4")
}
}
}
if !isSelf, peer.online, let peerIP = peer.tailscaleIPs.first {
Section {
if pingViewModel.hasResult {
connectionTypeRow
}
if pingViewModel.isRunning, pingViewModel.hasResult {
pingChartView
}
if !pingViewModel.hasResult {
Text("No data")
.foregroundStyle(.secondary)
}
} header: {
HStack {
Text("Ping")
Spacer()
ActionIconButton(pingViewModel.isRunning ? "stop.fill" : "play.fill") {
if pingViewModel.isRunning {
pingViewModel.stop()
} else {
pingViewModel.start(endpointTag: endpointTag, peerIP: peerIP)
}
}
.textCase(nil)
}
}
}
if peer.keyExpiry > 0 || !peer.os.isEmpty || peer.exitNode {
Section("Details") {
if peer.keyExpiry > 0 {
FormTextItem("Key Expiry", "key") {
Text(keyExpiryText)
}
}
if !peer.os.isEmpty {
FormTextItem("OS", "desktopcomputer") {
Text(peer.os)
}
}
if peer.exitNode {
FormTextItem("Exit Node", "arrow.triangle.turn.up.right.diamond") {
Text("Active")
}
}
}
}
}
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .principal) {
VStack(spacing: 2) {
Text(peer.hostName)
.font(.headline)
HStack(spacing: 4) {
Image(systemName: "circle.fill")
.font(.system(size: 6))
.foregroundStyle(peer.online ? .green : Color(.systemGray))
Text(peer.online ? "Connected" : "Not Connected")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
.onDisappear {
if pingViewModel.isRunning {
pingViewModel.stop()
}
}
}
private var connectionTypeRow: some View {
HStack(spacing: 8) {
if pingViewModel.isDirect {
Image(systemName: "arrow.right")
.foregroundStyle(.green)
Text("Direct connection")
.foregroundStyle(.green)
} else {
Image(systemName: "arrow.triangle.2.circlepath")
.foregroundStyle(.orange)
Text("DERP-relayed connection")
.foregroundStyle(.orange)
}
Spacer()
Text(verbatim: "\(Int(pingViewModel.latencyMs)) ms")
.font(.headline)
}
}
private var pingChartView: some View {
#if os(tvOS)
let chartHeight: CGFloat = 160
let labelWidth: CGFloat = 80
#else
let chartHeight: CGFloat = 80
let labelWidth: CGFloat = 50
#endif
return HStack(alignment: .center) {
TrafficLineChart(
data: pingViewModel.latencyHistory,
lineColor: pingViewModel.isDirect ? .green : .blue,
chartHeight: chartHeight
)
VStack(alignment: .trailing, spacing: 0) {
let maxMs = max(Int((pingViewModel.latencyHistory.max() ?? 1) * 1.2), 1)
Text(verbatim: "\(maxMs)ms")
Spacer()
Text(verbatim: "\(maxMs * 2 / 3)ms")
Spacer()
Text(verbatim: "\(maxMs / 3)ms")
Spacer()
Text(verbatim: "0ms")
}
.font(.caption2)
.foregroundStyle(.secondary)
.frame(width: labelWidth)
}
.frame(height: chartHeight)
#if os(tvOS)
.padding(.vertical, 8)
#endif
}
private var keyExpiryText: String {
let date = Date(timeIntervalSince1970: TimeInterval(peer.keyExpiry))
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .full
return formatter.localizedString(for: date, relativeTo: Date())
}
private func addressRow(_ address: String, label: String) -> some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(address)
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
#if !os(tvOS)
Button {
copyToClipboard(address)
} label: {
if copiedAddress == address {
Image(systemName: "checkmark")
.foregroundStyle(.secondary)
} else {
Image(systemName: "doc.on.doc")
.foregroundStyle(.blue)
}
}
#if os(macOS)
.buttonStyle(.plain)
#endif
#endif
}
}
private func copyToClipboard(_ text: String) {
#if os(iOS)
UIPasteboard.general.string = text
let generator = UINotificationFeedbackGenerator()
generator.notificationOccurred(.success)
#elseif os(macOS)
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(text, forType: .string)
#endif
withAnimation {
copiedAddress = text
}
Task {
try? await Task.sleep(nanoseconds: NSEC_PER_SEC * 2)
withAnimation {
if copiedAddress == text {
copiedAddress = nil
}
}
}
}
}
@@ -0,0 +1,89 @@
import Foundation
import Libbox
import Library
import SwiftUI
@MainActor
public final class TailscalePingViewModel: BaseViewModel {
@Published public var isRunning = false
@Published public var latencyMs: Double = 0
@Published public var isDirect: Bool = false
@Published public var derpRegionCode: String = ""
@Published public var endpoint: String = ""
@Published public var hasResult = false
@Published public var latencyHistory: [CGFloat] = []
private let maxHistorySize = 30
private var commandClient: LibboxCommandClient?
private var runningTask: Task<Void, Never>?
public func start(endpointTag: String, peerIP: String) {
latencyHistory = []
hasResult = false
isRunning = true
let client = LibboxNewStandaloneCommandClient()!
commandClient = client
let handler = PingHandler(self)
runningTask = Task { [weak self] in
await Task.detached {
try? client.startTailscalePing(endpointTag, peerIP: peerIP, handler: handler)
}.value
self?.runningTask = nil
}
}
public func stop() {
runningTask?.cancel()
runningTask = nil
try? commandClient?.disconnect()
commandClient = nil
isRunning = false
}
fileprivate func appendLatency(_ ms: Double) {
latencyHistory.append(CGFloat(ms))
if latencyHistory.count > maxHistorySize {
latencyHistory.removeFirst()
}
}
private final class PingHandler: NSObject, LibboxTailscalePingHandlerProtocol, @unchecked Sendable {
private weak var viewModel: TailscalePingViewModel?
init(_ viewModel: TailscalePingViewModel?) {
self.viewModel = viewModel
}
func onPingResult(_ result: LibboxTailscalePingResult?) {
guard let result else { return }
let latencyMs = result.latencyMs
let isDirect = result.isDirect
let derpRegionCode = result.derpRegionCode
let endpoint = result.endpoint
let error = result.error
DispatchQueue.main.async { [self] in
guard let viewModel, viewModel.isRunning else { return }
if !error.isEmpty {
return
}
viewModel.latencyMs = latencyMs
viewModel.isDirect = isDirect
viewModel.derpRegionCode = derpRegionCode
viewModel.endpoint = endpoint
viewModel.hasResult = true
viewModel.appendLatency(latencyMs)
}
}
func onError(_: String?) {
DispatchQueue.main.async { [self] in
guard let viewModel, viewModel.isRunning else { return }
viewModel.isRunning = false
viewModel.commandClient = nil
viewModel.runningTask = nil
}
}
}
}
@@ -0,0 +1,178 @@
import Foundation
import Libbox
import Library
import SwiftUI
public struct TailscalePeerData: Identifiable {
public let id: String
public let hostName: String
public let dnsName: String
public let os: String
public let tailscaleIPs: [String]
public let online: Bool
public let exitNode: Bool
public let exitNodeOption: Bool
public let active: Bool
public let rxBytes: Int64
public let txBytes: Int64
public let keyExpiry: Int64
}
public struct TailscaleUserGroupData: Identifiable {
public let id: Int64
public let loginName: String
public let displayName: String
public let profilePicURL: String
public let peers: [TailscalePeerData]
}
public struct TailscaleEndpointData: Identifiable {
public let id: String
public let endpointTag: String
public let backendState: String
public let authURL: String
public let networkName: String
public let magicDNSSuffix: String
public let selfPeer: TailscalePeerData?
public let userGroups: [TailscaleUserGroupData]
}
@MainActor
public final class TailscaleStatusViewModel: BaseViewModel {
@Published public var endpoints: [TailscaleEndpointData] = []
@Published public var isSubscribed = false
private var runningTask: Task<Void, Never>?
public func subscribe() {
guard !isSubscribed else { return }
isSubscribed = true
let handler = StatusHandler(self)
runningTask = Task { [weak self] in
do {
try await Task.detached {
try LibboxNewStandaloneCommandClient()!.subscribeTailscaleStatus(handler)
}.value
} catch {
guard let self else { return }
self.isSubscribed = false
self.endpoints = []
}
self?.runningTask = nil
}
}
public func cancel() {
runningTask?.cancel()
runningTask = nil
isSubscribed = false
endpoints = []
}
public func endpoint(tag: String) -> TailscaleEndpointData? {
endpoints.first { $0.endpointTag == tag }
}
private final class StatusHandler: NSObject, LibboxTailscaleStatusHandlerProtocol, @unchecked Sendable {
private weak var viewModel: TailscaleStatusViewModel?
init(_ viewModel: TailscaleStatusViewModel?) {
self.viewModel = viewModel
}
func onStatusUpdate(_ status: LibboxTailscaleStatusUpdate?) {
guard let status else { return }
let endpoints = Self.convertUpdate(status)
DispatchQueue.main.async { [self] in
guard let viewModel, viewModel.isSubscribed else { return }
viewModel.endpoints = endpoints
}
}
func onError(_ message: String?) {
DispatchQueue.main.async { [self] in
guard let viewModel, viewModel.isSubscribed else { return }
viewModel.isSubscribed = false
viewModel.endpoints = []
if let message {
viewModel.alert = AlertState(errorMessage: message)
}
}
}
private static func convertUpdate(_ status: LibboxTailscaleStatusUpdate) -> [TailscaleEndpointData] {
var endpoints: [TailscaleEndpointData] = []
if let iterator = status.endpoints() {
while iterator.hasNext() {
if let endpoint = iterator.next() {
endpoints.append(convertEndpoint(endpoint))
}
}
}
return endpoints
}
private static func convertEndpoint(_ endpoint: LibboxTailscaleEndpointStatus) -> TailscaleEndpointData {
var userGroups: [TailscaleUserGroupData] = []
if let groupIterator = endpoint.userGroups() {
while groupIterator.hasNext() {
if let group = groupIterator.next() {
userGroups.append(convertUserGroup(group))
}
}
}
return TailscaleEndpointData(
id: endpoint.endpointTag,
endpointTag: endpoint.endpointTag,
backendState: endpoint.backendState,
authURL: endpoint.authURL,
networkName: endpoint.networkName,
magicDNSSuffix: endpoint.magicDNSSuffix,
selfPeer: endpoint.self_ != nil ? convertPeer(endpoint.self_!) : nil,
userGroups: userGroups
)
}
private static func convertUserGroup(_ group: LibboxTailscaleUserGroup) -> TailscaleUserGroupData {
var peers: [TailscalePeerData] = []
if let peerIterator = group.peers() {
while peerIterator.hasNext() {
if let peer = peerIterator.next() {
peers.append(convertPeer(peer))
}
}
}
return TailscaleUserGroupData(
id: group.userID,
loginName: group.loginName,
displayName: group.displayName,
profilePicURL: group.profilePicURL,
peers: peers
)
}
private static func convertPeer(_ peer: LibboxTailscalePeer) -> TailscalePeerData {
var ips: [String] = []
if let ipIterator = peer.tailscaleIPs() {
while ipIterator.hasNext() {
ips.append(ipIterator.next())
}
}
return TailscalePeerData(
id: peer.dnsName.isEmpty ? peer.hostName : peer.dnsName,
hostName: peer.hostName,
dnsName: peer.dnsName,
os: peer.os,
tailscaleIPs: ips,
online: peer.online,
exitNode: peer.exitNode,
exitNodeOption: peer.exitNodeOption,
active: peer.active,
rxBytes: peer.rxBytes,
txBytes: peer.txBytes,
keyExpiry: peer.keyExpiry
)
}
}
}
@@ -0,0 +1,165 @@
import Library
import NetworkExtension
import SwiftUI
@MainActor
public struct ToolsView: View {
@EnvironmentObject private var environments: ExtensionEnvironments
@StateObject private var viewModel = SettingViewModel()
@StateObject private var tailscaleViewModel = TailscaleStatusViewModel()
#if os(iOS)
@State private var showCrashReportList = false
@State private var showOOMReportList = false
#endif
public init() {}
public var body: some View {
FormView {
if !tailscaleViewModel.endpoints.isEmpty {
Section("Endpoints") {
ForEach(tailscaleViewModel.endpoints) { endpoint in
FormNavigationLink {
TailscaleEndpointView(viewModel: tailscaleViewModel, endpointTag: endpoint.endpointTag)
} label: {
if tailscaleViewModel.endpoints.count == 1 {
Label("Tailscale", systemImage: "point.3.filled.connected.trianglepath.dotted")
} else {
Label("Tailscale: \(endpoint.endpointTag)", systemImage: "point.3.filled.connected.trianglepath.dotted")
}
}
}
}
}
Section("Network") {
FormNavigationLink {
NetworkQualityView()
} label: {
Label("Network Quality", systemImage: "network")
}
FormNavigationLink {
STUNTestView()
} label: {
Label("STUN Test", systemImage: "arrow.triangle.swap")
}
}
Section("Debug") {
#if os(iOS)
NavigationLink(isActive: $showCrashReportList) {
CrashReportListView()
} label: {
Label("Crash Report", systemImage: "ladybug.fill")
.badge(environments.crashReportManager.unreadCount)
}
.onReceive(NotificationCenter.default.publisher(for: .reportReceived)) { notification in
Task {
try? await Task.sleep(nanoseconds: NSEC_PER_MSEC * 300)
if let reportType = notification.object as? ReportType {
switch reportType {
case .crash:
showCrashReportList = true
case .oom:
showOOMReportList = true
}
}
}
}
NavigationLink(isActive: $showOOMReportList) {
OOMReportListView()
} label: {
Label("OOM Report", systemImage: "memorychip")
.badge(environments.oomReportManager.unreadCount)
}
#else
FormNavigationLink {
CrashReportListView()
} label: {
#if os(tvOS)
HStack {
Label("Crash Report", systemImage: "ladybug.fill")
Spacer()
if environments.crashReportManager.unreadCount > 0 {
Text(verbatim: "\(environments.crashReportManager.unreadCount)")
.foregroundStyle(.secondary)
}
}
#else
Label("Crash Report", systemImage: "ladybug.fill")
.badge(environments.crashReportManager.unreadCount)
#endif
}
#endif
#if !os(iOS)
FormNavigationLink {
OOMReportListView()
} label: {
#if os(tvOS)
HStack {
Label("OOM Report", systemImage: "memorychip")
Spacer()
if environments.oomReportManager.unreadCount > 0 {
Text(verbatim: "\(environments.oomReportManager.unreadCount)")
.foregroundStyle(.secondary)
}
}
#else
Label("OOM Report", systemImage: "memorychip")
.badge(environments.oomReportManager.unreadCount)
#endif
}
#endif
FormTextItem("Taiwan Flag Available", "touchid") {
if viewModel.isLoading {
Text("Loading...")
.onAppear {
Task.detached {
await viewModel.checkTaiwanFlagAvailability()
}
}
} else {
Text(viewModel.taiwanFlagAvailable.toString())
}
}
}
}
.modifier(TailscaleStatusObserver(profile: environments.extensionProfile, viewModel: tailscaleViewModel))
.alert($tailscaleViewModel.alert)
}
}
private struct TailscaleStatusObserver: ViewModifier {
var profile: ExtensionProfile?
var viewModel: TailscaleStatusViewModel
func body(content: Content) -> some View {
if let profile {
content
.modifier(ActiveObserver(profile: profile, viewModel: viewModel))
} else {
content
}
}
private struct ActiveObserver: ViewModifier {
@ObservedObject var profile: ExtensionProfile
var viewModel: TailscaleStatusViewModel
func body(content: Content) -> some View {
content
.onChangeCompat(of: profile.status) { status in
if status.isConnectedStrict {
viewModel.subscribe()
} else {
viewModel.cancel()
}
}
.onAppear {
if profile.status.isConnectedStrict {
viewModel.subscribe()
}
}
}
}
}
+15 -171
View File
@@ -1,9 +1,5 @@
import Darwin
import Foundation
import os
private let PROC_PIDPATHINFO_MAXSIZE: Int32 = 4096
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "ConnectionOwnerLookup")
import Libbox
enum ConnectionOwnerLookup {
struct Result {
@@ -19,173 +15,21 @@ enum ConnectionOwnerLookup {
destinationAddress: String,
destinationPort: Int32
) -> Result? {
let sourceAddr = parseAddress(sourceAddress)
let destAddr = parseAddress(destinationAddress)
guard let sourceAddr, let destAddr else {
logger.error("find: failed to parse addresses")
var error: NSError?
guard let result = LibboxFindConnectionOwner(
ipProtocol,
sourceAddress,
sourcePort,
destinationAddress,
destinationPort,
&error
) else {
return nil
}
let pidCount = proc_listpids(UInt32(PROC_ALL_PIDS), 0, nil, 0)
guard pidCount > 0 else {
logger.error("find: no processes found")
return nil
}
let pidBufferSize = Int(pidCount) * MemoryLayout<pid_t>.size
let pids = UnsafeMutablePointer<pid_t>.allocate(capacity: Int(pidCount))
defer { pids.deallocate() }
let actualCount = proc_listpids(UInt32(PROC_ALL_PIDS), 0, pids, Int32(pidBufferSize))
guard actualCount > 0 else {
logger.error("find: failed to list processes")
return nil
}
let numPids = Int(actualCount) / MemoryLayout<pid_t>.size
for i in 0 ..< numPids {
let pid = pids[i]
if pid == 0 { continue }
if let result = checkProcessForConnection(
pid: pid,
ipProtocol: ipProtocol,
sourceAddr: sourceAddr,
sourcePort: UInt16(sourcePort),
destAddr: destAddr,
destPort: UInt16(destinationPort)
) {
return result
}
}
return nil
}
private static func checkProcessForConnection(
pid: pid_t,
ipProtocol: Int32,
sourceAddr: Data,
sourcePort: UInt16,
destAddr: Data,
destPort: UInt16
) -> Result? {
let bufferSize = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, nil, 0)
guard bufferSize > 0 else { return nil }
let fdBuffer = UnsafeMutableRawPointer.allocate(byteCount: Int(bufferSize), alignment: MemoryLayout<proc_fdinfo>.alignment)
defer { fdBuffer.deallocate() }
let actualSize = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, fdBuffer, bufferSize)
guard actualSize > 0 else { return nil }
let fdCount = Int(actualSize) / MemoryLayout<proc_fdinfo>.size
for i in 0 ..< fdCount {
let fd = fdBuffer.load(fromByteOffset: i * MemoryLayout<proc_fdinfo>.size, as: proc_fdinfo.self)
guard fd.proc_fdtype == PROX_FDTYPE_SOCKET else { continue }
var socketInfo = socket_fdinfo()
let socketInfoSize = Int32(MemoryLayout<socket_fdinfo>.size)
let result = proc_pidfdinfo(pid, fd.proc_fd, PROC_PIDFDSOCKETINFO, &socketInfo, socketInfoSize)
guard result == socketInfoSize else { continue }
let soi: in_sockinfo
if ipProtocol == IPPROTO_TCP {
guard socketInfo.psi.soi_kind == SOCKINFO_TCP else { continue }
soi = socketInfo.psi.soi_proto.pri_tcp.tcpsi_ini
} else if ipProtocol == IPPROTO_UDP {
guard socketInfo.psi.soi_kind == SOCKINFO_IN else { continue }
soi = socketInfo.psi.soi_proto.pri_in
} else {
continue
}
if matchesConnection(
socketInfo: soi,
sourceAddr: sourceAddr,
sourcePort: sourcePort,
destAddr: destAddr,
destPort: destPort
) {
return getProcessInfo(pid: pid)
}
}
return nil
}
private static func matchesConnection(
socketInfo: in_sockinfo,
sourceAddr: Data,
sourcePort: UInt16,
destAddr: Data,
destPort: UInt16
) -> Bool {
let localPort = UInt16(bigEndian: UInt16(truncatingIfNeeded: socketInfo.insi_lport))
let remotePort = UInt16(bigEndian: UInt16(truncatingIfNeeded: socketInfo.insi_fport))
guard localPort == sourcePort, remotePort == destPort else {
return false
}
var localAddr = socketInfo.insi_laddr
var remoteAddr = socketInfo.insi_faddr
let localData: Data
let remoteData: Data
if sourceAddr.count == 4 {
localData = Data(bytes: &localAddr.ina_46.i46a_addr4, count: 4)
remoteData = Data(bytes: &remoteAddr.ina_46.i46a_addr4, count: 4)
} else {
localData = Data(bytes: &localAddr.ina_6, count: 16)
remoteData = Data(bytes: &remoteAddr.ina_6, count: 16)
}
return localData == sourceAddr && remoteData == destAddr
}
private static func getProcessInfo(pid: pid_t) -> Result? {
let pathBuffer = UnsafeMutablePointer<CChar>.allocate(capacity: Int(PROC_PIDPATHINFO_MAXSIZE))
defer { pathBuffer.deallocate() }
let pathLength = proc_pidpath(pid, pathBuffer, UInt32(PROC_PIDPATHINFO_MAXSIZE))
let processPath = pathLength > 0 ? String(cString: pathBuffer) : ""
var info = proc_bsdinfo()
let infoSize = Int32(MemoryLayout<proc_bsdinfo>.size)
let result = proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &info, infoSize)
guard result == infoSize else { return nil }
let uid = Int32(info.pbi_uid)
let userName: String
if let pw = getpwuid(info.pbi_uid) {
userName = String(cString: pw.pointee.pw_name)
} else {
userName = String(uid)
}
return Result(userId: uid, userName: userName, processPath: processPath)
}
private static func parseAddress(_ address: String) -> Data? {
var addr4 = in_addr()
if inet_pton(AF_INET, address, &addr4) == 1 {
return Data(bytes: &addr4, count: 4)
}
var addr6 = in6_addr()
if inet_pton(AF_INET6, address, &addr6) == 1 {
return Data(bytes: &addr6, count: 16)
}
return nil
return Result(
userId: result.userId,
userName: result.userName,
processPath: result.processPath
)
}
}
+128
View File
@@ -27,6 +27,7 @@ class RootHelperService: NSObject {
private var pathMonitor: NWPathMonitor?
private var pendingNATFlush: DispatchWorkItem?
private var tunInterfaceName: String?
var pendingCrashLogs: [CrashLogFileResult] = []
func start() {
listener = NSXPCListener(machServiceName: AppConfiguration.rootHelperMachService)
@@ -142,6 +143,133 @@ extension RootHelperService: RootHelperProtocol {
reply(nil)
}
static func readCrashLogFiles() -> [CrashLogFileResult] {
var results: [CrashLogFileResult] = []
let crashLogSearchPaths: [(directory: String, fileNames: [String])] = [
(WorkingDirectoryManager.extensionWorkingDirectoryPath, [
"CrashReport-NetworkExtension.log",
"CrashReport-NetworkExtension.log.old",
]),
(WorkingDirectoryManager.helperWorkingDirectoryPath, [
"CrashReport-RootHelper.log",
"CrashReport-RootHelper.log.old",
]),
(WorkingDirectoryManager.extensionBasePath, [
"configuration.json",
]),
(WorkingDirectoryManager.helperBasePath, [
"configuration.json",
]),
]
for searchPath in crashLogSearchPaths {
for fileName in searchPath.fileNames {
let filePath = (searchPath.directory as NSString).appendingPathComponent(fileName)
guard FileManager.default.fileExists(atPath: filePath),
let content = try? String(contentsOfFile: filePath, encoding: .utf8),
!content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
else {
continue
}
let attrs = try? FileManager.default.attributesOfItem(atPath: filePath)
let modificationDate = (attrs?[.modificationDate] as? Date) ?? Date()
results.append(CrashLogFileResult(
fileName: fileName,
content: content,
modificationDate: modificationDate
))
try? FileManager.default.removeItem(atPath: filePath)
}
}
return results
}
func collectAllCrashArtifacts(reply: @escaping (CrashArtifactsResult?, NSError?) -> Void) {
let result = CrashArtifactsResult()
var crashLogs = pendingCrashLogs
pendingCrashLogs.removeAll()
crashLogs.append(contentsOf: Self.readCrashLogFiles())
result.crashLogs = crashLogs
result.helperNativeCrashData = NativeCrashReporter.loadAndPurgePendingCrashReportData()
let extensionReportURL = CrashReportArchive.pendingNativeCrashReportURL(
basePath: URL(fileURLWithPath: WorkingDirectoryManager.extensionNativeCrashBasePath, isDirectory: true),
bundleIdentifier: AppConfiguration.systemExtensionBundleID
)
if let data = try? Data(contentsOf: extensionReportURL), !data.isEmpty {
result.extensionNativeCrashData = data
try? FileManager.default.removeItem(at: extensionReportURL)
}
reply(result, nil)
}
func collectOOMReportArtifacts(reply: @escaping (OOMReportArtifactsResult?, NSError?) -> Void) {
let result = OOMReportArtifactsResult()
let oomReportsPath = WorkingDirectoryManager.extensionOOMReportsPath
let fm = FileManager.default
guard fm.fileExists(atPath: oomReportsPath),
let entries = try? fm.contentsOfDirectory(atPath: oomReportsPath)
else {
reply(result, nil)
return
}
for entry in entries {
let dirPath = (oomReportsPath as NSString).appendingPathComponent(entry)
var isDir: ObjCBool = false
guard fm.fileExists(atPath: dirPath, isDirectory: &isDir), isDir.boolValue else {
continue
}
guard let fileNames = try? fm.contentsOfDirectory(atPath: dirPath) else {
continue
}
var files: [OOMReportFileResult] = []
for fileName in fileNames {
let filePath = (dirPath as NSString).appendingPathComponent(fileName)
guard let data = fm.contents(atPath: filePath) else {
continue
}
files.append(OOMReportFileResult(name: fileName, data: data))
}
if !files.isEmpty {
result.reports.append(OOMReportDirectoryResult(directoryName: entry, files: files))
}
try? fm.removeItem(atPath: dirPath)
}
reply(result, nil)
}
func promoteOOMDraft(reply: @escaping (NSError?) -> Void) {
LibboxPromoteOOMDraftAt(WorkingDirectoryManager.extensionWorkingDirectoryPath)
reply(nil)
}
func triggerGoCrash(reply: @escaping (NSError?) -> Void) {
reply(nil)
LibboxTriggerGoPanic()
}
func triggerNativeCrash(reply: @escaping (NSError?) -> Void) {
reply(nil)
DispatchQueue.global().asyncAfter(deadline: .now() + .milliseconds(200)) {
fatalError("debug native crash")
}
}
func closeNeighborMonitor(reply: @escaping (NSError?) -> Void) {
logger.info("closeNeighborMonitor")
closeNeighborMonitorInternal()
+36 -4
View File
@@ -2,12 +2,44 @@ import Foundation
import Library
enum WorkingDirectoryManager {
private static var workingDirectoryPath: String {
"/var/root/Library/Containers/\(AppConfiguration.systemExtensionBundleID)/Data/Working"
static var extensionBasePath: String {
"/var/root/Library/Containers/\(AppConfiguration.systemExtensionBundleID)/Data"
}
static var extensionWorkingDirectoryPath: String {
(extensionBasePath as NSString).appendingPathComponent("Working")
}
static var tempDirectoryPath: String {
"/var/root/Library/Containers/\(AppConfiguration.systemExtensionBundleID)/Data/Temp"
}
static var helperBasePath: String {
"/var/root/Library/Containers/\(AppConfiguration.rootHelperBundleID)/Data"
}
static var helperWorkingDirectoryPath: String {
(helperBasePath as NSString).appendingPathComponent("Working")
}
static var helperTempDirectoryPath: String {
(helperBasePath as NSString).appendingPathComponent("Temp")
}
static var helperNativeCrashBasePath: String {
(helperBasePath as NSString).appendingPathComponent("NativeCrash")
}
static var extensionNativeCrashBasePath: String {
"/var/root/Library/Containers/\(AppConfiguration.systemExtensionBundleID)/Data/NativeCrash"
}
static var extensionOOMReportsPath: String {
(extensionWorkingDirectoryPath as NSString).appendingPathComponent("oom_reports")
}
static func getSize() -> Int64 {
let path = workingDirectoryPath
let path = extensionWorkingDirectoryPath
guard FileManager.default.fileExists(atPath: path) else {
return 0
}
@@ -28,7 +60,7 @@ enum WorkingDirectoryManager {
}
static func clean() throws {
let path = workingDirectoryPath
let path = extensionWorkingDirectoryPath
guard FileManager.default.fileExists(atPath: path) else {
return
}
+22
View File
@@ -1,5 +1,27 @@
import Foundation
import Libbox
import Library
LibboxPrepareCrashSignalHandlers()
NativeCrashReporter.installForCurrentProcess(
basePath: URL(fileURLWithPath: WorkingDirectoryManager.helperNativeCrashBasePath, isDirectory: true)
)
LibboxReinstallCrashSignalHandlers()
let pendingCrashLogs = RootHelperService.readCrashLogFiles()
let setupOptions = LibboxSetupOptions()
setupOptions.basePath = WorkingDirectoryManager.helperBasePath
setupOptions.workingPath = WorkingDirectoryManager.helperWorkingDirectoryPath
setupOptions.tempPath = WorkingDirectoryManager.helperTempDirectoryPath
setupOptions.crashReportSource = "RootHelper"
var setupError: NSError?
LibboxSetup(setupOptions, &setupError)
if let setupError {
NSLog("setup service error: \(setupError.localizedDescription)")
}
let service = RootHelperService()
service.pendingCrashLogs = pendingCrashLogs
service.start()
dispatchMain()
+5 -2
View File
@@ -7,7 +7,8 @@ public extension Profile {
if type != .remote {
return
}
let remoteContent = try await HTTPClient.getStringAsync(remoteURL)
let url = remoteURL
let remoteContent = try await HTTPClient.getStringAsync(url)
try await BlockingIO.run {
var error: NSError?
LibboxCheckConfig(remoteContent, &error)
@@ -15,7 +16,9 @@ public extension Profile {
throw error
}
}
lastUpdated = Date()
await MainActor.run {
lastUpdated = Date()
}
try await ProfileManager.update(self)
do {
let oldContent = try await readAsync()
+26 -24
View File
@@ -22,10 +22,6 @@ import Foundation
public enum SharedPreferences {
public static let selectedProfileID = Preference<Int64>("selected_profile_id", defaultValue: -1)
#if !os(macOS)
public static let ignoreMemoryLimit = Preference<Bool>("ignore_memory_limit", defaultValue: false)
#endif
#if os(iOS)
private static let excludeLocalNetworksByDefault = true
#elseif os(macOS)
@@ -43,7 +39,7 @@ public enum SharedPreferences {
#endif
public static func resetPacketTunnel() async {
#if os(macOS)
#if !os(tvOS)
let names = [
includeAllNetworks.name,
excludeAPNs.name,
@@ -52,24 +48,18 @@ public enum SharedPreferences {
enforceRoutes.name,
excludeDeviceCommunication.name,
]
#elseif os(tvOS)
let names = [ignoreMemoryLimit.name]
#else
let names = [
ignoreMemoryLimit.name,
includeAllNetworks.name,
excludeAPNs.name,
excludeLocalNetworks.name,
excludeCellularServices.name,
enforceRoutes.name,
excludeDeviceCommunication.name,
]
try? await batchDelete(names)
#endif
try? await batchDelete(names)
}
public static let maxLogLines = Preference<Int>("max_log_lines", defaultValue: 300)
#if os(macOS)
public static let oomKillerEnabled = Preference<Bool>("oom_killer_enabled", defaultValue: false)
public static let oomMemoryLimitMB = Preference<Int>("oom_memory_limit_mb", defaultValue: 50)
public static let oomKillerKillConnections = Preference<Bool>("oom_killer_kill_connections", defaultValue: false)
#endif
#if os(macOS)
public static let showMenuBarExtra = Preference<Bool>("show_menu_bar_extra", defaultValue: true)
public static let menuBarExtraInBackground = Preference<Bool>("menu_bar_extra_in_background", defaultValue: false)
@@ -121,18 +111,30 @@ public enum SharedPreferences {
try await batchDelete([alwaysOn.name, onDemandEnabled.name, onDemandRules.name])
}
// Update (macOS standalone)
#if os(macOS)
public static let checkUpdateEnabled = Preference<Bool>("check_update_enabled", defaultValue: false)
public static let updateCheckPrompted = Preference<Bool>("update_check_prompted", defaultValue: false)
public static let updateTrack = Preference<String>("update_track", defaultValue: "")
public static let cachedUpdateInfo = Preference<String>("cached_update_info", defaultValue: "")
public static let lastShownUpdateVersion = Preference<String>("last_shown_update_version", defaultValue: "")
#endif
// Core
public static let disableDeprecatedWarnings = Preference<Bool>("disable_deprecated_warnings", defaultValue: false)
// Tools
public static let nqConfigURL = Preference<String>("nq_config_url", defaultValue: "")
public static let nqSerial = Preference<Bool>("nq_serial", defaultValue: false)
public static let nqHttp3 = Preference<Bool>("nq_http3", defaultValue: false)
public static let nqMaxRuntime = Preference<Int>("nq_max_runtime", defaultValue: 30)
public static let stunServer = Preference<String>("stun_server", defaultValue: "")
// Dashboard
public static let enabledDashboardCards = Preference<[String]>("enabled_dashboard_cards", defaultValue: [])
public static let dashboardCardOrder = Preference<[String]>("dashboard_card_order", defaultValue: [])
#if DEBUG
public static let inDebug = true
#else
public static let inDebug = false
#endif
}
+17
View File
@@ -66,6 +66,7 @@ public class CommandClient: ObservableObject {
case log
case clashMode
case connections
case outbounds
}
private let connectionTypes: [ConnectionType]
@@ -88,6 +89,7 @@ public class CommandClient: ObservableObject {
}
@Published public var groups: [LibboxOutboundGroup]?
@Published public var outbounds: [LibboxOutboundGroupItem]?
@Published public var logList: [LogEntry]
@Published public var defaultLogLevel = 0
@Published public var selectedLogLevel: Int?
@@ -246,6 +248,8 @@ public class CommandClient: ObservableObject {
clientOptions.addCommand(LibboxCommandClashMode)
case .connections:
clientOptions.addCommand(LibboxCommandConnections)
case .outbounds:
clientOptions.addCommand(LibboxCommandOutbounds)
}
}
clientOptions.statusInterval = Int64(NSEC_PER_SEC)
@@ -384,6 +388,19 @@ public class CommandClient: ObservableObject {
}
}
func writeOutbounds(_ message: (any LibboxOutboundGroupItemIteratorProtocol)?) {
guard let message else { return }
guard isActiveConnection() else { return }
var newOutbounds: [LibboxOutboundGroupItem] = []
while message.hasNext() {
newOutbounds.append(message.next()!)
}
DispatchQueue.main.async { [self] in
guard isActiveConnection() else { return }
commandClient.outbounds = newOutbounds
}
}
func initializeClashMode(_ modeList: LibboxStringIteratorProtocol?, currentMode: String?) {
DispatchQueue.main.async { [self] in
guard isActiveConnection() else { return }
+32 -2
View File
@@ -1,3 +1,4 @@
import Combine
import Foundation
import SwiftUI
#if canImport(UIKit)
@@ -84,8 +85,15 @@ public struct AlertState: Equatable {
public init(errorMessage: String, dismiss: (() -> Void)? = nil) {
title = String(localized: "Error")
message = errorMessage
primaryButton = .default(String(localized: "Ok"), action: dismiss)
secondaryButton = nil
if Self.supportsErrorCopy {
primaryButton = .default(String(localized: "Copy")) {
Self.copyErrorMessage(errorMessage)
}
secondaryButton = .default(String(localized: "Ok"), action: dismiss)
} else {
primaryButton = .default(String(localized: "Ok"), action: dismiss)
secondaryButton = nil
}
onDismiss = nil
}
@@ -175,16 +183,36 @@ public struct ImportRemoteProfileRequest: Hashable, Identifiable {
@MainActor
public class ExtensionEnvironments: ObservableObject {
@Published public var commandClient = CommandClient([.log, .status, .groups, .clashMode])
public let crashReportManager = CrashReportManager()
public let oomReportManager = OOMReportManager()
public var totalUnreadReportCount: Int {
crashReportManager.unreadCount + oomReportManager.unreadCount
}
@Published public var extensionProfileLoading = true
@Published public var extensionProfile: ExtensionProfile?
@Published public var emptyProfiles = false
@Published public var pendingImportRemoteProfile: ImportRemoteProfileRequest?
public var logSearchText = ""
public var connectionSearchText = ""
public let profileUpdate = ObjectWillChangePublisher()
public let selectedProfileUpdate = ObjectWillChangePublisher()
public let openSettings = ObjectWillChangePublisher()
private var cancellables = Set<AnyCancellable>()
public init() {
crashReportManager.objectWillChange
.sink { [weak self] _ in
self?.objectWillChange.send()
}
.store(in: &cancellables)
oomReportManager.objectWillChange
.sink { [weak self] _ in
self?.objectWillChange.send()
}
.store(in: &cancellables)
if Variant.screenshotMode {
extensionProfileLoading = false
extensionProfile = .mock
@@ -195,6 +223,8 @@ public class ExtensionEnvironments: ObservableObject {
public func postReload() {
Task {
await reload()
await crashReportManager.refresh()
await oomReportManager.refresh()
}
}
@@ -1,12 +1,14 @@
import Foundation
import Libbox
import NetworkExtension
import os
import UserNotifications
#if os(macOS)
import CoreWLAN
#endif
public class ExtensionPlatformInterface: NSObject, LibboxPlatformInterfaceProtocol, LibboxCommandServerHandlerProtocol {
private static let logger = Logger(category: "ExtensionPlatformInterface")
private let tunnel: ExtensionProvider
private var networkSettings: NEPacketTunnelNetworkSettings?
@@ -146,6 +148,14 @@ public class ExtensionPlatformInterface: NSObject, LibboxPlatformInterfaceProtoc
ipv6Settings.includedRoutes = ipv6Routes
ipv6Settings.excludedRoutes = ipv6ExcludeRoutes
settings.ipv6Settings = ipv6Settings
let hasDefaultRoute = ipv4Routes.contains(where: {
$0.destinationAddress == "0.0.0.0" && $0.destinationSubnetMask == "0.0.0.0"
})
if !hasDefaultRoute {
dnsSettings.matchDomains = [""]
dnsSettings.matchDomainsNoSearch = true
}
}
if options.isHTTPProxyEnabled() {
@@ -441,11 +451,17 @@ public class ExtensionPlatformInterface: NSObject, LibboxPlatformInterfaceProtoc
}
}
public func triggerNativeCrash() throws {
DispatchQueue.global().asyncAfter(deadline: .now() + .milliseconds(200)) {
fatalError("debug native crash")
}
}
public func writeDebugMessage(_ message: String?) {
guard let message else {
return
}
tunnel.writeMessage(message)
Self.logger.debug("\(message, privacy: .public)")
}
func reset() {
+51 -3
View File
@@ -62,6 +62,9 @@ public class ExtensionProfile: ObservableObject {
self.connection = connection
self.status = connection.status
self.connectedDate = connection.connectedDate
if connection.status == .disconnected {
Self.schedulePromoteOOMDraft()
}
#if os(iOS)
if #available(iOS 16.0, *) {
if connection.status == .connected || connection.status == .disconnected {
@@ -73,6 +76,26 @@ public class ExtensionProfile: ObservableObject {
}
}
private static func schedulePromoteOOMDraft() {
Task.detached {
try? await Task.sleep(nanoseconds: 2 * NSEC_PER_SEC)
#if os(macOS)
if Variant.useSystemExtension {
guard HelperServiceManager.rootHelperStatus == .enabled else {
return
}
do {
try RootHelperClient.shared.promoteOOMDraft()
} catch {
logger.warning("promote OOM draft: \(error.localizedDescription)")
}
return
}
#endif
LibboxPromoteOOMDraft()
}
}
#if os(iOS)
@available(iOS 16.0, *)
private static func signalFileProviderChanges() {
@@ -114,6 +137,14 @@ public class ExtensionProfile: ObservableObject {
public func updateOnDemand(enabled: Bool, useDefaultRules: Bool) async throws {
guard let manager else { return }
manager.isOnDemandEnabled = enabled
if !enabled {
if let proto = manager.protocolConfiguration as? NETunnelProviderProtocol {
var config = proto.providerConfiguration ?? [:]
if config.removeValue(forKey: "wasOnDemandEnabled") != nil {
proto.providerConfiguration = config
}
}
}
await setOnDemandRules(useDefaultRules: useDefaultRules)
try await manager.saveToPreferences()
}
@@ -141,6 +172,12 @@ public class ExtensionProfile: ObservableObject {
manager.isOnDemandEnabled = true
await setOnDemandRules(useDefaultRules: alwaysOn)
}
if let proto = manager.protocolConfiguration as? NETunnelProviderProtocol {
var config = proto.providerConfiguration ?? [:]
if config.removeValue(forKey: "wasOnDemandEnabled") != nil {
proto.providerConfiguration = config
}
}
#if !os(tvOS)
if let protocolConfiguration = manager.protocolConfiguration {
let includeAllNetworks = await SharedPreferences.includeAllNetworks.get()
@@ -202,8 +239,10 @@ public class ExtensionProfile: ObservableObject {
let configContent = try await profile.readAsync()
options["configContent"] = NSString(string: configContent)
#if !os(macOS)
options["ignoreMemoryLimit"] = await NSNumber(value: SharedPreferences.ignoreMemoryLimit.get())
#if os(macOS)
options["oomKillerEnabled"] = await NSNumber(value: SharedPreferences.oomKillerEnabled.get())
options["oomMemoryLimitMB"] = await NSNumber(value: SharedPreferences.oomMemoryLimitMB.get())
options["oomKillerKillConnections"] = await NSNumber(value: SharedPreferences.oomKillerKillConnections.get())
#endif
options["systemProxyEnabled"] = await NSNumber(value: SharedPreferences.systemProxyEnabled.get())
options["excludeDefaultRoute"] = await NSNumber(value: SharedPreferences.excludeDefaultRoute.get())
@@ -239,6 +278,11 @@ public class ExtensionProfile: ObservableObject {
}
guard let manager else { return }
if manager.isOnDemandEnabled {
if let proto = manager.protocolConfiguration as? NETunnelProviderProtocol {
var config = proto.providerConfiguration ?? [:]
config["wasOnDemandEnabled"] = true
proto.providerConfiguration = config
}
manager.isOnDemandEnabled = false
try await manager.saveToPreferences()
}
@@ -270,7 +314,11 @@ public class ExtensionProfile: ObservableObject {
if managers.isEmpty {
return nil
}
return ExtensionProfile(managers[0])
let profile = ExtensionProfile(managers[0])
if profile.status == .disconnected {
schedulePromoteOOMDraft()
}
return profile
}
public static func install() async throws {
+31 -13
View File
@@ -80,6 +80,24 @@ open class ExtensionProvider: NEPacketTunnelProvider {
private var locationDelegate: stubLocationDelegate?
#endif
override public init() {
LibboxPrepareCrashSignalHandlers()
#if os(macOS)
if Variant.useSystemExtension {
NativeCrashReporter.installForCurrentProcess(
basePath: FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("NativeCrash")
)
} else {
NativeCrashReporter.installForCurrentProcess()
}
#else
NativeCrashReporter.installForCurrentProcess()
#endif
LibboxReinstallCrashSignalHandlers()
super.init()
}
override open func startTunnel(options startOptions: [String: NSObject]?) async throws {
let basePath: String
let workingPath: String
@@ -132,6 +150,8 @@ open class ExtensionProvider: NEPacketTunnelProvider {
options.tempPath = tempPath
options.logMaxLines = 3000
options.debug = Variant.inDebug
options.crashReportSource = "NetworkExtension"
#if os(tvOS)
if let port = effectiveOptions["commandServerPort"] as? NSNumber {
@@ -142,23 +162,21 @@ open class ExtensionProvider: NEPacketTunnelProvider {
}
#endif
#if os(macOS)
options.oomKillerEnabled = (effectiveOptions["oomKillerEnabled"] as? NSNumber)?.boolValue ?? false
let oomMemoryLimitMB = (effectiveOptions["oomMemoryLimitMB"] as? NSNumber)?.int64Value ?? 0
options.oomMemoryLimit = oomMemoryLimitMB * 1024 * 1024
options.oomKillerDisabled = !((effectiveOptions["oomKillerKillConnections"] as? NSNumber)?.boolValue ?? false)
#else
options.oomKillerEnabled = true
#endif
var setupError: NSError?
LibboxSetup(options, &setupError)
if let setupError {
throw ExtensionStartupError("(packet-tunnel) error: setup service: \(setupError.localizedDescription)")
}
let stderrPath = URL(fileURLWithPath: tempPath, isDirectory: true).appendingPathComponent("stderr.log").path
var stderrError: NSError?
LibboxRedirectStderr(stderrPath, &stderrError)
if let stderrError {
throw ExtensionStartupError("(packet-tunnel) redirect stderr error: \(stderrError.localizedDescription)")
}
#if !os(macOS)
let ignoreMemoryLimit = (effectiveOptions["ignoreMemoryLimit"] as? NSNumber)?.boolValue ?? false
LibboxSetMemoryLimit(!ignoreMemoryLimit)
#endif
LibboxPromoteOOMDraft()
var error: NSError?
commandServer = LibboxNewCommandServer(platformInterface, platformInterface, &error)
@@ -179,7 +197,6 @@ open class ExtensionProvider: NEPacketTunnelProvider {
}
#endif
writeMessage("(packet-tunnel): Here I stand")
do {
try await startService()
} catch {
@@ -190,6 +207,7 @@ open class ExtensionProvider: NEPacketTunnelProvider {
#endif
throw error
}
writeMessage("(packet-tunnel): Here I stand")
#if os(macOS)
if Variant.useSystemExtension {
xpcService.markServiceReady()
+34
View File
@@ -45,7 +45,41 @@ public class HTTPClient {
}
}
public func writeTo(_ url: String?, path: String, progress: ((Int64, Int64) -> Void)? = nil) throws {
#if DEBUG
precondition(!Thread.isMainThread, "HTTPClient.writeTo(...) must not be called on the main thread")
#endif
let request = client.newRequest()!
request.setUserAgent(HTTPClient.userAgent)
try request.setURL(url)
let response = try request.execute()
if let progress {
let handler = WriteToProgressHandler(progress)
try response.writeTo(withProgress: path, handler: handler)
} else {
try response.write(to: path)
}
}
public static func writeToAsync(_ url: String?, path: String, progress: ((Int64, Int64) -> Void)? = nil) async throws {
try await BlockingIO.run {
try HTTPClient().writeTo(url, path: path, progress: progress)
}
}
deinit {
client.close()
}
}
private class WriteToProgressHandler: NSObject, LibboxHTTPResponseWriteToProgressHandlerProtocol {
private let handler: (Int64, Int64) -> Void
init(_ handler: @escaping (Int64, Int64) -> Void) {
self.handler = handler
}
func update(_ progress: Int64, total: Int64) {
handler(progress, total)
}
}
+9
View File
@@ -48,6 +48,15 @@ public struct OutboundGroupItem: Codable, Hashable {
self.urlTestDelay = urlTestDelay
}
public init(_ item: LibboxOutboundGroupItem) {
self.init(
tag: item.tag,
type: item.type,
urlTestTime: Date(timeIntervalSince1970: Double(item.urlTestTime)),
urlTestDelay: UInt16(item.urlTestDelay)
)
}
public var displayType: String {
LibboxProxyDisplayType(type)
}
+183 -49
View File
@@ -60,6 +60,121 @@
}
}
@objc(CrashLogFileResult) public class CrashLogFileResult: NSObject, NSSecureCoding {
public static let supportsSecureCoding = true
@objc public var fileName: String
@objc public var content: String
@objc public var modificationDate: Date
public init(fileName: String, content: String, modificationDate: Date) {
self.fileName = fileName
self.content = content
self.modificationDate = modificationDate
}
public required init?(coder: NSCoder) {
fileName = coder.decodeObject(of: NSString.self, forKey: "fileName") as? String ?? ""
content = coder.decodeObject(of: NSString.self, forKey: "content") as? String ?? ""
modificationDate = coder.decodeObject(of: NSDate.self, forKey: "modificationDate") as? Date ?? Date()
}
public func encode(with coder: NSCoder) {
coder.encode(fileName as NSString, forKey: "fileName")
coder.encode(content as NSString, forKey: "content")
coder.encode(modificationDate as NSDate, forKey: "modificationDate")
}
}
@objc(CrashArtifactsResult) public class CrashArtifactsResult: NSObject, NSSecureCoding {
public static let supportsSecureCoding = true
@objc public var crashLogs: [CrashLogFileResult] = []
@objc public var helperNativeCrashData: Data?
@objc public var extensionNativeCrashData: Data?
override public init() {
super.init()
}
public required init?(coder: NSCoder) {
let logClasses = [NSArray.self, CrashLogFileResult.self] as [AnyClass]
crashLogs = coder.decodeObject(of: logClasses, forKey: "crashLogs") as? [CrashLogFileResult] ?? []
helperNativeCrashData = coder.decodeObject(of: NSData.self, forKey: "helperNativeCrashData") as? Data
extensionNativeCrashData = coder.decodeObject(of: NSData.self, forKey: "extensionNativeCrashData") as? Data
}
public func encode(with coder: NSCoder) {
coder.encode(crashLogs as NSArray, forKey: "crashLogs")
coder.encode(helperNativeCrashData as NSData?, forKey: "helperNativeCrashData")
coder.encode(extensionNativeCrashData as NSData?, forKey: "extensionNativeCrashData")
}
}
@objc(OOMReportFileResult) public class OOMReportFileResult: NSObject, NSSecureCoding {
public static let supportsSecureCoding = true
@objc public var name: String
@objc public var data: Data
public init(name: String, data: Data) {
self.name = name
self.data = data
}
public required init?(coder: NSCoder) {
name = coder.decodeObject(of: NSString.self, forKey: "name") as? String ?? ""
data = coder.decodeObject(of: NSData.self, forKey: "data") as? Data ?? Data()
}
public func encode(with coder: NSCoder) {
coder.encode(name as NSString, forKey: "name")
coder.encode(data as NSData, forKey: "data")
}
}
@objc(OOMReportDirectoryResult) public class OOMReportDirectoryResult: NSObject, NSSecureCoding {
public static let supportsSecureCoding = true
@objc public var directoryName: String
@objc public var files: [OOMReportFileResult] = []
public init(directoryName: String, files: [OOMReportFileResult]) {
self.directoryName = directoryName
self.files = files
}
public required init?(coder: NSCoder) {
directoryName = coder.decodeObject(of: NSString.self, forKey: "directoryName") as? String ?? ""
let fileClasses = [NSArray.self, OOMReportFileResult.self] as [AnyClass]
files = coder.decodeObject(of: fileClasses, forKey: "files") as? [OOMReportFileResult] ?? []
}
public func encode(with coder: NSCoder) {
coder.encode(directoryName as NSString, forKey: "directoryName")
coder.encode(files as NSArray, forKey: "files")
}
}
@objc(OOMReportArtifactsResult) public class OOMReportArtifactsResult: NSObject, NSSecureCoding {
public static let supportsSecureCoding = true
@objc public var reports: [OOMReportDirectoryResult] = []
override public init() {
super.init()
}
public required init?(coder: NSCoder) {
let reportClasses = [NSArray.self, OOMReportDirectoryResult.self] as [AnyClass]
reports = coder.decodeObject(of: reportClasses, forKey: "reports") as? [OOMReportDirectoryResult] ?? []
}
public func encode(with coder: NSCoder) {
coder.encode(reports as NSArray, forKey: "reports")
}
}
@objc public protocol RootHelperProtocol {
func findConnectionOwner(
ipProtocol: Int32,
@@ -76,6 +191,11 @@
func startNeighborMonitor(callbackEndpoint: NSXPCListenerEndpoint, reply: @escaping (NSError?) -> Void)
func closeNeighborMonitor(reply: @escaping (NSError?) -> Void)
func registerMyInterface(name: String, reply: @escaping (NSError?) -> Void)
func collectAllCrashArtifacts(reply: @escaping (CrashArtifactsResult?, NSError?) -> Void)
func collectOOMReportArtifacts(reply: @escaping (OOMReportArtifactsResult?, NSError?) -> Void)
func promoteOOMDraft(reply: @escaping (NSError?) -> Void)
func triggerGoCrash(reply: @escaping (NSError?) -> Void)
func triggerNativeCrash(reply: @escaping (NSError?) -> Void)
}
public enum RootHelperXPC {
@@ -87,6 +207,24 @@
argumentIndex: 0,
ofReply: true
)
let crashArtifactClasses = NSSet(array: [
CrashArtifactsResult.self, NSArray.self, CrashLogFileResult.self, NSData.self,
]) as! Set<AnyHashable>
interface.setClasses(
crashArtifactClasses,
for: #selector(RootHelperProtocol.collectAllCrashArtifacts(reply:)),
argumentIndex: 0,
ofReply: true
)
let oomArtifactClasses = NSSet(array: [
OOMReportArtifactsResult.self, NSArray.self, OOMReportDirectoryResult.self, OOMReportFileResult.self, NSData.self,
]) as! Set<AnyHashable>
interface.setClasses(
oomArtifactClasses,
for: #selector(RootHelperProtocol.collectOOMReportArtifacts(reply:)),
argumentIndex: 0,
ofReply: true
)
let endpointClasses = NSSet(array: [NSXPCListenerEndpoint.self]) as! Set<AnyHashable>
interface.setClasses(
endpointClasses,
@@ -141,10 +279,10 @@
return newConnection
}
private func performXPCCall<T>(
private func performXPCCallOptional<T>(
_ operation: String,
call: (RootHelperProtocol, @escaping (T?, NSError?) -> Void) -> Void
) throws -> T {
) throws -> T? {
let semaphore = DispatchSemaphore(value: 0)
var result: T?
var resultError: NSError?
@@ -184,13 +322,18 @@
throw error
}
guard let value = result else {
let error = NSError(domain: "RootHelper", code: -1, userInfo: [
return result
}
private func performXPCCall<T>(
_ operation: String,
call: (RootHelperProtocol, @escaping (T?, NSError?) -> Void) -> Void
) throws -> T {
guard let value: T = try performXPCCallOptional(operation, call: call) else {
throw NSError(domain: "RootHelper", code: -1, userInfo: [
NSLocalizedDescriptionKey: "\(operation) returned nil",
])
throw error
}
return value
}
@@ -287,51 +430,42 @@
}
}
public func collectAllCrashArtifacts() throws -> CrashArtifactsResult {
try performXPCCall("collectAllCrashArtifacts") { proxy, reply in
proxy.collectAllCrashArtifacts(reply: reply)
}
}
public func collectOOMReportArtifacts() throws -> OOMReportArtifactsResult {
try performXPCCall("collectOOMReportArtifacts") { proxy, reply in
proxy.collectOOMReportArtifacts(reply: reply)
}
}
public func promoteOOMDraft() throws {
try performXPCCallVoid("promoteOOMDraft") { proxy, reply in
proxy.promoteOOMDraft(reply: reply)
}
}
public func triggerGoCrash() throws {
try performXPCCallVoid("triggerGoCrash") { proxy, reply in
proxy.triggerGoCrash(reply: reply)
}
}
public func triggerNativeCrash() throws {
try performXPCCallVoid("triggerNativeCrash") { proxy, reply in
proxy.triggerNativeCrash(reply: reply)
}
}
public func getVersion() throws -> String {
let semaphore = DispatchSemaphore(value: 0)
var result: String?
var resultError: NSError?
let conn = getConnection()
guard let proxy = conn.remoteObjectProxyWithErrorHandler({ error in
logger.error("getVersion XPC error: \(error.localizedDescription)")
resultError = error as NSError
semaphore.signal()
}) as? RootHelperProtocol else {
connectionLock.lock()
connection = nil
connectionLock.unlock()
conn.invalidate()
throw NSError(domain: "RootHelper", code: -1, userInfo: [
NSLocalizedDescriptionKey: "Failed to get RootHelper proxy",
])
try performXPCCall("getVersion") { proxy, reply in
proxy.getVersion { version in
reply(version as String?, nil)
}
}
proxy.getVersion { version in
result = version
semaphore.signal()
}
let timeout = DispatchTime.now() + .seconds(5)
if semaphore.wait(timeout: timeout) == .timedOut {
let error = NSError(domain: "RootHelper", code: -1, userInfo: [
NSLocalizedDescriptionKey: "getVersion request timeout",
])
logger.error("getVersion: timeout")
throw error
}
if let error = resultError {
throw error
}
guard let value = result else {
throw NSError(domain: "RootHelper", code: -1, userInfo: [
NSLocalizedDescriptionKey: "getVersion returned nil",
])
}
return value
}
}
#endif
+7
View File
@@ -30,6 +30,13 @@ public enum AppConfiguration {
"\(packageName).system"
}
public static var packetTunnelBundleIDs: [String] {
if extensionBundleID == systemExtensionBundleID {
return [extensionBundleID]
}
return [extensionBundleID, systemExtensionBundleID]
}
public static var fileProviderDomainID: String {
"\(packageName).workingdir"
}
+258
View File
@@ -0,0 +1,258 @@
import Foundation
public struct CrashReportMetadata: Codable, Sendable {
public var source: String?
public var bundleIdentifier: String?
public var processName: String?
public var processPath: String?
public var startedAt: String?
public var appVersion: String?
public var appMarketingVersion: String?
public var coreVersion: String?
public var goVersion: String?
public var crashedAt: String?
public var signalName: String?
public var signalCode: String?
public var exceptionName: String?
public var exceptionReason: String?
public var deviceOrigin: String?
public init(
source: String? = nil,
bundleIdentifier: String? = nil,
processName: String? = nil,
processPath: String? = nil,
startedAt: String? = nil,
appVersion: String? = nil,
appMarketingVersion: String? = nil,
coreVersion: String? = nil,
goVersion: String? = nil,
crashedAt: String? = nil,
signalName: String? = nil,
signalCode: String? = nil,
exceptionName: String? = nil,
exceptionReason: String? = nil,
deviceOrigin: String? = nil
) {
self.source = source
self.bundleIdentifier = bundleIdentifier
self.processName = processName
self.processPath = processPath
self.startedAt = startedAt
self.appVersion = appVersion
self.appMarketingVersion = appMarketingVersion
self.coreVersion = coreVersion
self.goVersion = goVersion
self.crashedAt = crashedAt
self.signalName = signalName
self.signalCode = signalCode
self.exceptionName = exceptionName
self.exceptionReason = exceptionReason
self.deviceOrigin = deviceOrigin
}
}
public struct CrashReportArtifactContents {
public var goLog: String?
public var nativeLog: String?
public var configContent: String?
public init(goLog: String? = nil, nativeLog: String? = nil, configContent: String? = nil) {
self.goLog = goLog
self.nativeLog = nativeLog
self.configContent = configContent
}
public var isEmpty: Bool {
let goBody = goLog?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let nativeBody = nativeLog?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return goBody.isEmpty && nativeBody.isEmpty
}
}
public enum ReportArchive {
public static let readMarkerFileName = ".read"
public static let metadataFileName = "metadata.json"
public static let configFileName = "configuration.json"
public static let tvOSDeviceOrigin = "tvOS"
public static let timestampFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH-mm-ss"
formatter.timeZone = TimeZone(identifier: "UTC")
return formatter
}()
public static func parseArtifactDate(for artifactURL: URL) -> Date? {
let name = artifactURL.lastPathComponent
let components = name.components(separatedBy: "-")
let baseName: String
if components.count > 5, let suffix = components.last, Int(suffix) != nil {
baseName = components.dropLast().joined(separator: "-")
} else {
baseName = components.joined(separator: "-")
}
return timestampFormatter.date(from: baseName)
}
public static func nextAvailableArtifactURL(in directory: URL, for date: Date) -> URL {
let baseName = timestampFormatter.string(from: date)
var index = 0
while true {
let suffix = index == 0 ? "" : "-\(index)"
let artifactURL = directory.appendingPathComponent(baseName + suffix, isDirectory: true)
if !FileManager.default.fileExists(atPath: artifactURL.path) {
return artifactURL
}
index += 1
}
}
static func removeArtifact(at artifactURL: URL) {
try? FileManager.default.removeItem(at: artifactURL)
}
}
public enum CrashReportArchive {
static let pendingNativeCrashDirectoryName = "native_crash_pending"
static let pendingNativeCrashStorageDirectoryName = "com.plausiblelabs.crashreporter.data"
static let pendingNativeCrashReportFileName = "live_report.plcrash"
static let goLogFileName = "go.log"
static let nativeLogFileName = "native.log"
static var crashReportsDirectory: URL {
FilePath.workingDirectory.appendingPathComponent("crash_reports", isDirectory: true)
}
static var pendingNativeCrashBaseDirectory: URL {
FilePath.sharedDirectory.appendingPathComponent(pendingNativeCrashDirectoryName, isDirectory: true)
}
static func metadataURL(for artifactURL: URL) -> URL {
artifactURL.appendingPathComponent(ReportArchive.metadataFileName)
}
static func goLogURL(for artifactURL: URL) -> URL {
artifactURL.appendingPathComponent(goLogFileName)
}
static func nativeLogURL(for artifactURL: URL) -> URL {
artifactURL.appendingPathComponent(nativeLogFileName)
}
static func configURL(for artifactURL: URL) -> URL {
artifactURL.appendingPathComponent(ReportArchive.configFileName)
}
static func pendingNativeCrashReportURL(bundleIdentifier: String) -> URL {
pendingNativeCrashReportURL(basePath: pendingNativeCrashBaseDirectory, bundleIdentifier: bundleIdentifier)
}
public static func pendingNativeCrashReportURL(basePath: URL, bundleIdentifier: String) -> URL {
basePath
.appendingPathComponent(pendingNativeCrashStorageDirectoryName, isDirectory: true)
.appendingPathComponent(bundleIdentifier.replacingOccurrences(of: "/", with: "_"), isDirectory: true)
.appendingPathComponent(pendingNativeCrashReportFileName)
}
public static func writeArchivedReport(contents: CrashReportArtifactContents, date: Date, metadata: CrashReportMetadata) throws -> URL {
guard !contents.isEmpty else {
throw NSError(domain: "CrashReportArchive", code: 1, userInfo: [NSLocalizedDescriptionKey: "Empty crash report"])
}
let dir = crashReportsDirectory
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let artifactURL = nextAvailableArtifactURL(for: date)
try rewriteArchivedReport(at: artifactURL, contents: contents, metadata: metadata)
return artifactURL
}
static func rewriteArchivedReport(at artifactURL: URL, contents: CrashReportArtifactContents, metadata: CrashReportMetadata) throws {
guard !contents.isEmpty else {
throw NSError(domain: "CrashReportArchive", code: 1, userInfo: [NSLocalizedDescriptionKey: "Empty crash report"])
}
try FileManager.default.createDirectory(at: artifactURL, withIntermediateDirectories: true)
if let goLog = contents.goLog?.trimmingCharacters(in: .whitespacesAndNewlines), !goLog.isEmpty {
try goLog.write(to: goLogURL(for: artifactURL), atomically: true, encoding: .utf8)
} else {
try? FileManager.default.removeItem(at: goLogURL(for: artifactURL))
}
if let nativeLog = contents.nativeLog?.trimmingCharacters(in: .whitespacesAndNewlines), !nativeLog.isEmpty {
try nativeLog.write(to: nativeLogURL(for: artifactURL), atomically: true, encoding: .utf8)
} else {
try? FileManager.default.removeItem(at: nativeLogURL(for: artifactURL))
}
if let configContent = contents.configContent?.trimmingCharacters(in: .whitespacesAndNewlines), !configContent.isEmpty {
try configContent.write(to: configURL(for: artifactURL), atomically: true, encoding: .utf8)
} else {
try? FileManager.default.removeItem(at: configURL(for: artifactURL))
}
let metadataData = try metadataEncoder.encode(metadata)
try metadataData.write(to: metadataURL(for: artifactURL), options: .atomic)
}
public static func readMetadata(for artifactURL: URL) -> CrashReportMetadata? {
guard let data = try? Data(contentsOf: metadataURL(for: artifactURL)) else {
return nil
}
return try? JSONDecoder().decode(CrashReportMetadata.self, from: data)
}
public static func readContents(for artifactURL: URL) -> CrashReportArtifactContents {
let goLog = try? String(contentsOf: goLogURL(for: artifactURL), encoding: .utf8)
let nativeLog = try? String(contentsOf: nativeLogURL(for: artifactURL), encoding: .utf8)
let configContent = try? String(contentsOf: configURL(for: artifactURL), encoding: .utf8)
return CrashReportArtifactContents(goLog: goLog, nativeLog: nativeLog, configContent: configContent)
}
static func removeArtifact(at artifactURL: URL) {
ReportArchive.removeArtifact(at: artifactURL)
}
static func crashDate(for artifactURL: URL) -> Date? {
ReportArchive.parseArtifactDate(for: artifactURL)
}
static func iso8601String(from date: Date) -> String {
iso8601Formatter.string(from: date)
}
static func displayContent(for contents: CrashReportArtifactContents) -> String {
let goBody = contents.goLog?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let nativeBody = contents.nativeLog?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if nativeBody.isEmpty {
return goBody
}
if goBody.isEmpty {
return nativeBody
}
var sections: [String] = []
sections.append("===== Go Crash =====\n\n" + goBody)
sections.append("===== Native Crash =====\n\n" + nativeBody)
return sections.joined(separator: "\n\n")
}
private static func nextAvailableArtifactURL(for date: Date) -> URL {
ReportArchive.nextAvailableArtifactURL(in: crashReportsDirectory, for: date)
}
private static let iso8601Formatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime]
formatter.timeZone = TimeZone(secondsFromGMT: 0)
return formatter
}()
private static let metadataEncoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.outputFormatting = []
return encoder
}()
}
+726
View File
@@ -0,0 +1,726 @@
import CrashReporter
import Foundation
import Libbox
import os
import SwiftUI
private let logger = Logger(category: "CrashReportManager")
public struct CrashReport: Identifiable, Hashable, Sendable {
public let id: String
public let date: Date
public let fileURL: URL
public var isRead: Bool
public let origin: String?
}
public struct CrashReportFile: Identifiable, Hashable, Sendable {
public enum Kind: String, Sendable {
case goLog
case nativeLog
case metadata
case configContent
}
public let id: Kind
public let displayName: String
public let fileURL: URL
}
@MainActor
public class CrashReportManager: ObservableObject {
@Published public private(set) var reports: [CrashReport] = []
@Published public private(set) var unreadCount: Int = 0
public init() {}
public nonisolated func refresh() async {
let reports = await BlockingIO.run {
Self.archivePendingCrashLogs()
Self.importPendingNativeCrashReports()
Self.coalesceArchivedCrashReports()
return Self.scanCrashReports()
}
await MainActor.run {
self.reports = reports
self.unreadCount = reports.filter { !$0.isRead }.count
}
}
private nonisolated static func archivePendingCrashLogs() {
for source in ["NetworkExtension", "Application"] {
let url = FilePath.workingDirectory.appendingPathComponent("CrashReport-\(source).log")
let oldURL = FilePath.workingDirectory.appendingPathComponent("CrashReport-\(source).log.old")
archivePendingGoCrashLog(url, source: source)
archivePendingGoCrashLog(oldURL, source: source)
}
#if os(macOS)
if Variant.useSystemExtension {
collectAndArchiveCrashArtifactsViaHelper()
}
#endif
}
#if os(macOS)
private nonisolated static func collectAndArchiveCrashArtifactsViaHelper() {
guard HelperServiceManager.rootHelperStatus == .enabled else {
logger.debug("collectAndArchiveCrashArtifactsViaHelper: root helper not enabled, skipping")
return
}
let artifacts: CrashArtifactsResult
do {
artifacts = try RootHelperClient.shared.collectAllCrashArtifacts()
} catch {
logger.warning("collectAndArchiveCrashArtifactsViaHelper: \(error.localizedDescription)")
return
}
var configContent: String?
for crashLog in artifacts.crashLogs {
if crashLog.fileName == ReportArchive.configFileName {
let trimmed = crashLog.content.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
configContent = crashLog.content
}
continue
}
guard !crashLog.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
continue
}
let metadata: CrashReportMetadata
if crashLog.fileName.contains("RootHelper") {
metadata = CrashReportMetadataBuilder.normalized(
CrashReportMetadataBuilder.rootHelperGoMetadata(crashDate: crashLog.modificationDate),
content: crashLog.content
)
} else {
metadata = CrashReportMetadataBuilder.normalized(
CrashReportMetadataBuilder.systemExtensionGoMetadata(crashDate: crashLog.modificationDate),
content: crashLog.content
)
}
_ = try? CrashReportArchive.writeArchivedReport(
contents: CrashReportArtifactContents(goLog: crashLog.content, configContent: configContent),
date: crashLog.modificationDate,
metadata: metadata
)
}
for (data, source) in [
(artifacts.extensionNativeCrashData, "NetworkExtension"),
(artifacts.helperNativeCrashData, "RootHelper"),
] {
guard let data, !data.isEmpty else {
continue
}
do {
let crashReport = try PLCrashReport(data: data)
guard let text = PLCrashReportTextFormatter.stringValue(for: crashReport, with: PLCrashReportTextFormatiOS),
!text.isEmpty
else {
continue
}
let crashDate = crashReport.systemInfo.timestamp ?? Date()
let metadata = CrashReportMetadataBuilder.nativeMetadata(for: crashReport, content: text, source: source)
_ = try CrashReportArchive.writeArchivedReport(
contents: CrashReportArtifactContents(nativeLog: text),
date: crashDate,
metadata: metadata
)
} catch {
continue
}
}
}
#endif
private nonisolated static func archivePendingGoCrashLog(_ url: URL, source: String) {
guard let content = try? String(contentsOf: url, encoding: .utf8),
!content.isEmpty else { return }
guard !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
try? FileManager.default.removeItem(at: url)
return
}
let attrs = try? FileManager.default.attributesOfItem(atPath: url.path)
let crashDate = (attrs?[.modificationDate] as? Date) ?? Date()
let metadata = CrashReportMetadataBuilder.normalized(
CrashReportMetadataBuilder.goMetadata(source: source, crashDate: crashDate),
content: content
)
let configContent = readAndCleanConfigSnapshot()
do {
_ = try CrashReportArchive.writeArchivedReport(
contents: CrashReportArtifactContents(goLog: content, configContent: configContent),
date: crashDate,
metadata: metadata
)
try? FileManager.default.removeItem(at: url)
} catch {
return
}
}
private nonisolated static func readAndCleanConfigSnapshot() -> String? {
let url = FilePath.workingDirectory.appendingPathComponent(ReportArchive.configFileName)
guard let content = try? String(contentsOf: url, encoding: .utf8),
!content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
else {
return nil
}
try? FileManager.default.removeItem(at: url)
return content
}
private nonisolated static func scanCrashReports() -> [CrashReport] {
let dir = CrashReportArchive.crashReportsDirectory
guard let files = try? FileManager.default.contentsOfDirectory(
at: dir, includingPropertiesForKeys: [.contentModificationDateKey, .isDirectoryKey],
options: .skipsHiddenFiles
) else {
return []
}
return files
.filter {
(try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
}
.compactMap { url -> CrashReport? in
let date = CrashReportArchive.crashDate(for: url)
?? (try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate)
?? Date.distantPast
let origin = CrashReportArchive.readMetadata(for: url)?.deviceOrigin
return CrashReport(
id: url.lastPathComponent,
date: date,
fileURL: url,
isRead: FileManager.default.fileExists(atPath: url.appendingPathComponent(ReportArchive.readMarkerFileName).path),
origin: origin
)
}
.sorted { $0.date > $1.date }
}
private nonisolated static func importPendingNativeCrashReports() {
var pendingReports: [(bundleIdentifier: String, source: String)] = []
for bundleIdentifier in AppConfiguration.packetTunnelBundleIDs {
pendingReports.append((bundleIdentifier, "NetworkExtension"))
}
if let appBundleIdentifier = Bundle.main.bundleIdentifier {
pendingReports.append((appBundleIdentifier, "Application"))
}
for (bundleIdentifier, source) in pendingReports {
let reportURL = CrashReportArchive.pendingNativeCrashReportURL(bundleIdentifier: bundleIdentifier)
guard let data = try? Data(contentsOf: reportURL), !data.isEmpty else {
continue
}
do {
let crashReport = try PLCrashReport(data: data)
guard let text = PLCrashReportTextFormatter.stringValue(for: crashReport, with: PLCrashReportTextFormatiOS),
!text.isEmpty
else {
continue
}
let attrs = try? FileManager.default.attributesOfItem(atPath: reportURL.path)
let crashDate = crashReport.systemInfo.timestamp
?? (attrs?[.modificationDate] as? Date)
?? Date()
let metadata = CrashReportMetadataBuilder.nativeMetadata(for: crashReport, content: text, source: source)
let configContent = readAndCleanConfigSnapshot()
_ = try CrashReportArchive.writeArchivedReport(
contents: CrashReportArtifactContents(nativeLog: text, configContent: configContent),
date: crashDate,
metadata: metadata
)
try? FileManager.default.removeItem(at: reportURL)
} catch {
continue
}
}
}
private nonisolated static func coalesceArchivedCrashReports() {
let records = loadArchivedReportRecords()
let goOnlyRecords = records.filter { $0.contents.goLog != nil && $0.contents.nativeLog == nil }
let nativeOnlyRecords = records.filter { $0.contents.nativeLog != nil && $0.contents.goLog == nil }
guard !goOnlyRecords.isEmpty, !nativeOnlyRecords.isEmpty else {
return
}
var usedGoReportURLs: Set<URL> = []
for nativeRecord in nativeOnlyRecords {
guard let goRecord = matchingGoReport(for: nativeRecord, among: goOnlyRecords, excluding: usedGoReportURLs) else {
continue
}
let mergedMetadata = CrashReportMetadataBuilder.mergedMetadata(
go: goRecord.metadata,
goContent: goRecord.contents.goLog ?? "",
native: nativeRecord.metadata,
nativeContent: nativeRecord.contents.nativeLog ?? ""
)
let mergedContents = CrashReportArtifactContents(
goLog: goRecord.contents.goLog,
nativeLog: nativeRecord.contents.nativeLog,
configContent: goRecord.contents.configContent ?? nativeRecord.contents.configContent
)
do {
try CrashReportArchive.rewriteArchivedReport(
at: goRecord.reportURL,
contents: mergedContents,
metadata: mergedMetadata
)
CrashReportArchive.removeArtifact(at: nativeRecord.reportURL)
usedGoReportURLs.insert(goRecord.reportURL)
} catch {
continue
}
}
}
public nonisolated func availableFiles(for report: CrashReport) async -> [CrashReportFile] {
await BlockingIO.run {
let fm = FileManager.default
var files: [CrashReportFile] = []
let metadataURL = CrashReportArchive.metadataURL(for: report.fileURL)
if fm.fileExists(atPath: metadataURL.path) {
files.append(CrashReportFile(id: .metadata, displayName: "Metadata", fileURL: metadataURL))
}
let nativeURL = CrashReportArchive.nativeLogURL(for: report.fileURL)
if fm.fileExists(atPath: nativeURL.path) {
files.append(CrashReportFile(id: .nativeLog, displayName: "Crash Report", fileURL: nativeURL))
}
let goURL = CrashReportArchive.goLogURL(for: report.fileURL)
if fm.fileExists(atPath: goURL.path) {
files.append(CrashReportFile(id: .goLog, displayName: "Go Crash Log", fileURL: goURL))
}
let configURL = CrashReportArchive.configURL(for: report.fileURL)
if fm.fileExists(atPath: configURL.path) {
files.append(CrashReportFile(id: .configContent, displayName: "Configuration", fileURL: configURL))
}
return files
}
}
public func markAsRead(_ report: CrashReport) {
FileManager.default.createFile(atPath: report.fileURL.appendingPathComponent(ReportArchive.readMarkerFileName).path, contents: nil)
if let idx = reports.firstIndex(where: { $0.id == report.id }), !reports[idx].isRead {
reports[idx].isRead = true
unreadCount = max(0, unreadCount - 1)
}
}
public nonisolated func delete(_ report: CrashReport) async {
await BlockingIO.run {
CrashReportArchive.removeArtifact(at: report.fileURL)
}
await MainActor.run {
let wasUnread = reports.first { $0.id == report.id }.map { !$0.isRead } ?? false
reports.removeAll { $0.id == report.id }
if wasUnread {
unreadCount = max(0, unreadCount - 1)
}
}
}
public nonisolated func deleteAll() async {
let dir = CrashReportArchive.crashReportsDirectory
await BlockingIO.run {
try? FileManager.default.removeItem(at: dir)
}
await MainActor.run {
reports.removeAll()
unreadCount = 0
}
}
private nonisolated static func loadArchivedReportRecords() -> [ArchivedCrashReportRecord] {
let dir = CrashReportArchive.crashReportsDirectory
guard let reportURLs = try? FileManager.default.contentsOfDirectory(
at: dir,
includingPropertiesForKeys: [.contentModificationDateKey, .isDirectoryKey],
options: .skipsHiddenFiles
) else {
return []
}
return reportURLs
.filter {
(try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
}
.compactMap { reportURL in
let contents = CrashReportArchive.readContents(for: reportURL)
guard let metadata = CrashReportArchive.readMetadata(for: reportURL),
!contents.isEmpty
else {
return nil
}
let date = CrashReportArchive.crashDate(for: reportURL)
?? (try? reportURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate)
?? Date.distantPast
return ArchivedCrashReportRecord(
reportURL: reportURL,
date: date,
contents: contents,
metadata: metadata
)
}
}
private nonisolated static func matchingGoReport(
for nativeRecord: ArchivedCrashReportRecord,
among goRecords: [ArchivedCrashReportRecord],
excluding excludedReportURLs: Set<URL>
) -> ArchivedCrashReportRecord? {
goRecords
.filter { !excludedReportURLs.contains($0.reportURL) }
.filter { canMerge($0, nativeRecord) }
.min { lhs, rhs in
abs(lhs.date.timeIntervalSince(nativeRecord.date)) < abs(rhs.date.timeIntervalSince(nativeRecord.date))
}
}
private nonisolated static func canMerge(_ goRecord: ArchivedCrashReportRecord, _ nativeRecord: ArchivedCrashReportRecord) -> Bool {
if let goSource = goRecord.metadata.source,
let nativeSource = nativeRecord.metadata.source,
goSource != nativeSource
{
return false
}
if let goBundleIdentifier = goRecord.metadata.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
!goBundleIdentifier.isEmpty,
let nativeBundleIdentifier = nativeRecord.metadata.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
!nativeBundleIdentifier.isEmpty,
goBundleIdentifier != nativeBundleIdentifier
{
return false
}
return abs(goRecord.date.timeIntervalSince(nativeRecord.date)) <= 10
}
}
private struct ArchivedCrashReportRecord {
let reportURL: URL
let date: Date
let contents: CrashReportArtifactContents
let metadata: CrashReportMetadata
}
enum CrashReportMetadataBuilder {
static func goMetadata(source: String, crashDate: Date) -> CrashReportMetadata {
CrashReportMetadata(
source: source,
crashedAt: CrashReportArchive.iso8601String(from: crashDate)
)
}
#if os(macOS)
static func systemExtensionGoMetadata(crashDate: Date) -> CrashReportMetadata {
CrashReportMetadata(
source: "NetworkExtension",
bundleIdentifier: AppConfiguration.systemExtensionBundleID,
crashedAt: CrashReportArchive.iso8601String(from: crashDate)
)
}
static func rootHelperGoMetadata(crashDate: Date) -> CrashReportMetadata {
CrashReportMetadata(
source: "RootHelper",
bundleIdentifier: AppConfiguration.rootHelperBundleID,
crashedAt: CrashReportArchive.iso8601String(from: crashDate)
)
}
#endif
static func mergedMetadata(
go: CrashReportMetadata,
goContent: String,
native: CrashReportMetadata,
nativeContent: String
) -> CrashReportMetadata {
normalized(
CrashReportMetadata(
source: firstNonEmpty(native.source, go.source),
bundleIdentifier: firstNonEmpty(native.bundleIdentifier, go.bundleIdentifier),
processName: firstNonEmpty(native.processName, go.processName),
processPath: firstNonEmpty(native.processPath, go.processPath),
startedAt: firstNonEmpty(native.startedAt, go.startedAt),
appVersion: firstNonEmpty(go.appVersion, native.appVersion),
appMarketingVersion: firstNonEmpty(go.appMarketingVersion, native.appMarketingVersion),
coreVersion: firstNonEmpty(go.coreVersion, native.coreVersion),
goVersion: firstNonEmpty(go.goVersion, native.goVersion),
crashedAt: earliestTimestamp(go.crashedAt, native.crashedAt),
signalName: firstNonEmpty(native.signalName, go.signalName),
signalCode: firstNonEmpty(native.signalCode, go.signalCode),
exceptionName: firstNonEmpty(go.exceptionName, native.exceptionName),
exceptionReason: firstNonEmpty(go.exceptionReason, native.exceptionReason)
),
content: CrashReportArchive.displayContent(for: CrashReportArtifactContents(goLog: goContent, nativeLog: nativeContent))
)
}
static func nativeMetadata(for crashReport: PLCrashReport, content: String, source: String) -> CrashReportMetadata {
let processInfo = crashReport.hasProcessInfo ? crashReport.processInfo : nil
return normalized(
CrashReportMetadata(
source: source,
bundleIdentifier: crashReport.applicationInfo.applicationIdentifier,
processName: processInfo?.processName,
processPath: processInfo?.processPath,
startedAt: processInfo?.processStartTime.map(CrashReportArchive.iso8601String(from:)),
appVersion: crashReport.applicationInfo.applicationVersion,
appMarketingVersion: crashReport.applicationInfo.applicationMarketingVersion,
crashedAt: crashReport.systemInfo.timestamp.map(CrashReportArchive.iso8601String(from:)),
signalName: crashReport.signalInfo.name,
signalCode: crashReport.signalInfo.code,
exceptionName: crashReport.hasExceptionInfo ? crashReport.exceptionInfo.exceptionName : nil,
exceptionReason: crashReport.hasExceptionInfo ? crashReport.exceptionInfo.exceptionReason : nil
),
content: content
)
}
static func normalized(_ metadata: CrashReportMetadata, content: String? = nil) -> CrashReportMetadata {
let bundleIdentifier = normalizedString(metadata.bundleIdentifier)
let processBundle = bundleIdentifier.flatMap(bundle(for:))
let processPath = firstNonEmpty(
metadata.processPath,
normalizedString(processBundle?.executableURL?.path)
)
let executableNameFromPath = processPath.flatMap {
normalizedString(URL(fileURLWithPath: $0).lastPathComponent)
}
let appBundle = containingAppBundle(for: processBundle) ?? currentAppBundle()
let parsedDetails = parseCrashDetails(from: content)
return CrashReportMetadata(
source: metadata.source,
bundleIdentifier: bundleIdentifier,
processName: firstNonEmpty(
metadata.processName,
normalizedString(processBundle?.executableURL?.lastPathComponent),
executableNameFromPath,
bundleIdentifier
),
processPath: processPath,
startedAt: normalizedString(metadata.startedAt),
appVersion: firstNonEmpty(bundleBuildVersion(appBundle), metadata.appVersion),
appMarketingVersion: firstNonEmpty(bundleMarketingVersion(appBundle), metadata.appMarketingVersion),
coreVersion: firstNonEmpty(metadata.coreVersion, normalizedString(LibboxVersion())),
goVersion: firstNonEmpty(metadata.goVersion, normalizedString(LibboxGoVersion())),
crashedAt: normalizedString(metadata.crashedAt),
signalName: firstNonEmpty(metadata.signalName, parsedDetails.signalName),
signalCode: firstNonEmpty(metadata.signalCode, parsedDetails.signalCode),
exceptionName: firstNonEmpty(metadata.exceptionName, parsedDetails.exceptionName),
exceptionReason: firstNonEmpty(metadata.exceptionReason, parsedDetails.exceptionReason)
)
}
private static func bundle(for bundleIdentifier: String) -> Bundle? {
if Bundle.main.bundleIdentifier == bundleIdentifier {
return Bundle.main
}
return discoveredBundles[bundleIdentifier]
}
private static func currentAppBundle() -> Bundle {
containingAppBundle(for: Bundle.main) ?? Bundle.main
}
private static func containingAppBundle(for bundle: Bundle?) -> Bundle? {
guard let bundle else {
return nil
}
var currentURL = bundle.bundleURL
while currentURL.path != "/" {
if currentURL.pathExtension.lowercased() == "app" {
return Bundle(url: currentURL)
}
let parentURL = currentURL.deletingLastPathComponent()
if parentURL == currentURL {
break
}
currentURL = parentURL
}
return nil
}
private static func bundleBuildVersion(_ bundle: Bundle?) -> String? {
normalizedString(bundle?.infoDictionary?["CFBundleVersion"] as? String)
}
private static func bundleMarketingVersion(_ bundle: Bundle?) -> String? {
normalizedString(bundle?.infoDictionary?["CFBundleShortVersionString"] as? String)
}
private static func normalizedString(_ value: String?) -> String? {
guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines),
!trimmed.isEmpty,
trimmed != "unknown"
else {
return nil
}
return trimmed
}
private static func firstNonEmpty(_ values: String?...) -> String? {
for value in values {
if let value = normalizedString(value) {
return value
}
}
return nil
}
private static let iso8601Formatter = ISO8601DateFormatter()
private static func earliestTimestamp(_ values: String?...) -> String? {
let timestamps = values.compactMap { value -> (String, Date)? in
guard let value = normalizedString(value),
let date = iso8601Formatter.date(from: value)
else {
return nil
}
return (value, date)
}
if let earliest = timestamps.min(by: { $0.1 < $1.1 }) {
return earliest.0
}
for value in values {
if let value = normalizedString(value) {
return value
}
}
return nil
}
private static let discoveredBundles: [String: Bundle] = {
var bundles: [String: Bundle] = [:]
func addBundle(_ bundle: Bundle?) {
guard let bundle,
let bundleIdentifier = bundle.bundleIdentifier
else {
return
}
bundles[bundleIdentifier] = bundle
}
let appBundle = currentAppBundle()
addBundle(appBundle)
addBundle(Bundle.main)
guard let enumerator = FileManager.default.enumerator(
at: appBundle.bundleURL,
includingPropertiesForKeys: nil,
options: [.skipsHiddenFiles]
) else {
return bundles
}
let bundleExtensions: Set = ["app", "appex", "systemextension"]
for case let url as URL in enumerator {
let pathExtension = url.pathExtension.lowercased()
guard bundleExtensions.contains(pathExtension) else {
continue
}
addBundle(Bundle(url: url))
enumerator.skipDescendants()
}
return bundles
}()
private struct ParsedCrashDetails {
var signalName: String?
var signalCode: String?
var exceptionName: String?
var exceptionReason: String?
}
private static func parseCrashDetails(from content: String?) -> ParsedCrashDetails {
guard let content else {
return ParsedCrashDetails()
}
var details = ParsedCrashDetails()
for rawLine in content.split(separator: "\n", omittingEmptySubsequences: false) {
let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines)
guard !line.isEmpty else {
continue
}
if details.exceptionReason == nil {
if line.hasPrefix("panic: ") {
details.exceptionName = "panic"
details.exceptionReason = normalizedString(String(line.dropFirst("panic: ".count)))
} else if line.hasPrefix("fatal error: ") {
details.exceptionName = "fatal error"
details.exceptionReason = normalizedString(String(line.dropFirst("fatal error: ".count)))
}
}
if details.signalName == nil,
let parsedSignal = parseSignal(from: line)
{
details.signalName = parsedSignal.name
details.signalCode = parsedSignal.code
}
if details.exceptionReason != nil,
details.signalName != nil
{
break
}
}
return details
}
private static func parseSignal(from line: String) -> (name: String?, code: String?)? {
let signalSection: Substring
if let range = line.range(of: "[signal ") {
signalSection = line[range.upperBound...]
} else if line.hasPrefix("signal ") {
signalSection = line.dropFirst("signal ".count)
} else {
return nil
}
let signalName = normalizedString(
String(signalSection.prefix { character in
character != ":" && character != "]" && !character.isWhitespace
})
)
guard signalName != nil else {
return nil
}
var signalCode: String?
if let codeRange = signalSection.range(of: " code=") {
let codeSection = signalSection[codeRange.upperBound...]
signalCode = normalizedString(
String(codeSection.prefix { character in
character != "]" && !character.isWhitespace
})
)
}
return (signalName, signalCode)
}
}
+82
View File
@@ -0,0 +1,82 @@
import CrashReporter
import Foundation
import os
public enum NativeCrashReporter {
private static let logger = Logger(category: "NativeCrashReporter")
private static let installLock = NSLock()
private static var reporter: PLCrashReporter?
public static func installForCurrentProcess(basePath: URL? = nil) {
installLock.lock()
defer {
installLock.unlock()
}
guard reporter == nil else {
return
}
let crashBasePath = basePath ?? CrashReportArchive.pendingNativeCrashBaseDirectory
do {
try FileManager.default.createDirectory(at: crashBasePath, withIntermediateDirectories: true)
let config = PLCrashReporterConfig(
signalHandlerType: .BSD,
symbolicationStrategy: [],
basePath: crashBasePath.path
)
guard let crashReporter = PLCrashReporter(configuration: config) else {
logger.warning("Failed to create PLCrashReporter instance")
return
}
try crashReporter.enableAndReturnError()
reporter = crashReporter
} catch {
logger.warning("Failed to enable native crash reporting: \(error.localizedDescription)")
}
}
public static func loadAndPurgePendingCrashReportData() -> Data? {
installLock.lock()
guard let reporter else {
installLock.unlock()
return nil
}
installLock.unlock()
guard reporter.hasPendingCrashReport() else {
return nil
}
let data = try? reporter.loadPendingCrashReportDataAndReturnError()
reporter.purgePendingCrashReport()
return data
}
public static func archiveLiveReportForCurrentProcess() {
installLock.lock()
guard let reporter else {
installLock.unlock()
return
}
installLock.unlock()
do {
let data = try reporter.generateLiveReportAndReturnError()
let crashReport = try PLCrashReport(data: data)
guard let text = PLCrashReportTextFormatter.stringValue(for: crashReport, with: PLCrashReportTextFormatiOS),
!text.isEmpty
else {
return
}
let crashDate = crashReport.systemInfo.timestamp ?? Date()
_ = try CrashReportArchive.writeArchivedReport(
contents: CrashReportArtifactContents(nativeLog: text),
date: crashDate,
metadata: CrashReportMetadataBuilder.nativeMetadata(for: crashReport, content: text, source: "Application")
)
} catch {
logger.warning("Failed to archive live native crash report: \(error.localizedDescription)")
}
}
}
+60
View File
@@ -0,0 +1,60 @@
import Foundation
public struct OOMReportMetadata: Codable, Sendable {
public var source: String?
public var bundleIdentifier: String?
public var processName: String?
public var processPath: String?
public var startedAt: String?
public var appVersion: String?
public var appMarketingVersion: String?
public var coreVersion: String?
public var goVersion: String?
public var recordedAt: String?
public var memoryUsage: String?
public var availableMemory: String?
public var deviceOrigin: String?
}
public enum OOMReportArchive {
static var reportsDirectory: URL {
FilePath.workingDirectory.appendingPathComponent("oom_reports", isDirectory: true)
}
static func metadataURL(for artifactURL: URL) -> URL {
artifactURL.appendingPathComponent(ReportArchive.metadataFileName)
}
static func configURL(for artifactURL: URL) -> URL {
artifactURL.appendingPathComponent(ReportArchive.configFileName)
}
public static func readMetadata(for artifactURL: URL) -> OOMReportMetadata? {
guard let data = try? Data(contentsOf: metadataURL(for: artifactURL)) else {
return nil
}
return try? JSONDecoder().decode(OOMReportMetadata.self, from: data)
}
static func profileFiles(for artifactURL: URL) -> [URL] {
guard let files = try? FileManager.default.contentsOfDirectory(
at: artifactURL,
includingPropertiesForKeys: [.fileSizeKey],
options: .skipsHiddenFiles
) else {
return []
}
let excluded: Set<String> = [ReportArchive.metadataFileName, ReportArchive.configFileName]
return files
.filter { !excluded.contains($0.lastPathComponent) }
.sorted { $0.lastPathComponent < $1.lastPathComponent }
}
static func removeArtifact(at artifactURL: URL) {
ReportArchive.removeArtifact(at: artifactURL)
}
static func reportDate(for artifactURL: URL) -> Date? {
ReportArchive.parseArtifactDate(for: artifactURL)
}
}
+164
View File
@@ -0,0 +1,164 @@
import Foundation
import os
import SwiftUI
private let logger = Logger(category: "OOMReportManager")
public struct OOMReport: Identifiable, Hashable, Sendable {
public let id: String
public let date: Date
public let fileURL: URL
public var isRead: Bool
public let origin: String?
}
public struct OOMReportFile: Identifiable, Hashable, Sendable {
public enum Kind: String, Sendable {
case metadata
case configContent
case profile
}
public let id: String
public let kind: Kind
public let displayName: String
public let fileURL: URL
}
@MainActor
public class OOMReportManager: ObservableObject {
@Published public private(set) var reports: [OOMReport] = []
@Published public private(set) var unreadCount: Int = 0
public init() {}
public nonisolated func refresh() async {
let reports = await BlockingIO.run {
#if os(macOS)
if Variant.useSystemExtension {
Self.collectAndArchiveOOMReportsViaHelper()
}
#endif
return Self.scanReports()
}
await MainActor.run {
self.reports = reports
self.unreadCount = reports.filter { !$0.isRead }.count
}
}
private nonisolated static func scanReports() -> [OOMReport] {
let dir = OOMReportArchive.reportsDirectory
guard let files = try? FileManager.default.contentsOfDirectory(
at: dir, includingPropertiesForKeys: [.contentModificationDateKey, .isDirectoryKey],
options: .skipsHiddenFiles
) else {
return []
}
return files
.filter {
(try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
}
.compactMap { url -> OOMReport? in
let date = OOMReportArchive.reportDate(for: url)
?? (try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate)
?? Date.distantPast
let origin = OOMReportArchive.readMetadata(for: url)?.deviceOrigin
return OOMReport(
id: url.lastPathComponent,
date: date,
fileURL: url,
isRead: FileManager.default.fileExists(atPath: url.appendingPathComponent(ReportArchive.readMarkerFileName).path),
origin: origin
)
}
.sorted { $0.date > $1.date }
}
public nonisolated func availableFiles(for report: OOMReport) async -> [OOMReportFile] {
await BlockingIO.run {
let fm = FileManager.default
var files: [OOMReportFile] = []
let metadataURL = OOMReportArchive.metadataURL(for: report.fileURL)
if fm.fileExists(atPath: metadataURL.path) {
files.append(OOMReportFile(id: "metadata", kind: .metadata, displayName: "Metadata", fileURL: metadataURL))
}
let configURL = OOMReportArchive.configURL(for: report.fileURL)
if fm.fileExists(atPath: configURL.path) {
files.append(OOMReportFile(id: "config", kind: .configContent, displayName: "Configuration", fileURL: configURL))
}
for profileURL in OOMReportArchive.profileFiles(for: report.fileURL) {
let name = profileURL.lastPathComponent
files.append(OOMReportFile(id: name, kind: .profile, displayName: name, fileURL: profileURL))
}
return files
}
}
public func markAsRead(_ report: OOMReport) {
FileManager.default.createFile(atPath: report.fileURL.appendingPathComponent(ReportArchive.readMarkerFileName).path, contents: nil)
if let idx = reports.firstIndex(where: { $0.id == report.id }), !reports[idx].isRead {
reports[idx].isRead = true
unreadCount = max(0, unreadCount - 1)
}
}
public nonisolated func delete(_ report: OOMReport) async {
await BlockingIO.run {
OOMReportArchive.removeArtifact(at: report.fileURL)
}
await MainActor.run {
let wasUnread = reports.first { $0.id == report.id }.map { !$0.isRead } ?? false
reports.removeAll { $0.id == report.id }
if wasUnread {
unreadCount = max(0, unreadCount - 1)
}
}
}
public nonisolated func deleteAll() async {
let dir = OOMReportArchive.reportsDirectory
await BlockingIO.run {
try? FileManager.default.removeItem(at: dir)
}
await MainActor.run {
reports.removeAll()
unreadCount = 0
}
}
#if os(macOS)
private nonisolated static func collectAndArchiveOOMReportsViaHelper() {
guard HelperServiceManager.rootHelperStatus == .enabled else {
return
}
let artifacts: OOMReportArtifactsResult
do {
artifacts = try RootHelperClient.shared.collectOOMReportArtifacts()
} catch {
logger.warning("collectOOMReportArtifacts: \(error.localizedDescription)")
return
}
let reportsDir = OOMReportArchive.reportsDirectory
for report in artifacts.reports {
let destURL = reportsDir.appendingPathComponent(report.directoryName, isDirectory: true)
do {
try FileManager.default.createDirectory(at: destURL, withIntermediateDirectories: true)
for file in report.files {
let fileURL = destURL.appendingPathComponent(file.name)
try file.data.write(to: fileURL, options: .atomic)
}
} catch {
logger.warning("write OOM report \(report.directoryName): \(error.localizedDescription)")
}
}
}
#endif
}
+7 -1
View File
@@ -16,7 +16,13 @@ public enum Variant {
public static let applicationName = "SFT"
#endif
public static var isBeta = LibboxVersion().contains("-")
public static let isBeta = LibboxVersion().contains("-")
#if DEBUG
public static let inDebug = true
#else
public static let inDebug = false
#endif
#if os(iOS)
public static var debugNoIOS26 = false
+181
View File
@@ -0,0 +1,181 @@
import Darwin
import Foundation
import Libbox
public enum GitHubUpdateChecker {
private static let releasesURL = "https://api.github.com/repos/SagerNet/sing-box/releases"
private static let releasesPerPage = 100
private static let minimumSemver = "0.0.0-0"
public static func checkAsync(track: UpdateTrack, force: Bool = false) async throws -> UpdateInfo? {
try await BlockingIO.run {
try check(track: track, force: force)
}
}
public static func check(track: UpdateTrack, force: Bool = false) throws -> UpdateInfo? {
let client = HTTPClient()
guard let releases = try fetchReleases(client: client, track: track) else {
return nil
}
let currentVersion = Bundle.main.version
var bestRelease: GitHubRelease?
var bestVersion: String?
var bestAsset: GitHubAsset?
for release in releases {
if release.draft { continue }
if track == .stable, release.prerelease { continue }
guard let pkgAsset = findPKGAsset(in: release.assets) else { continue }
let version = release.tagName.hasPrefix("v")
? String(release.tagName.dropFirst())
: release.tagName
guard shouldIncludeRelease(
version: version,
currentVersion: currentVersion,
track: track,
force: force
) else { continue }
if let best = bestVersion {
guard LibboxCompareSemver(version, best) else { continue }
}
bestRelease = release
bestVersion = version
bestAsset = pkgAsset
}
guard let release = bestRelease,
let version = bestVersion,
let pkgAsset = bestAsset
else {
return nil
}
return UpdateInfo(
versionName: version,
releaseURL: release.htmlURL,
downloadURL: pkgAsset.browserDownloadURL,
releaseNotes: release.body,
isPrerelease: release.prerelease,
fileSize: pkgAsset.size
)
}
private static func findPKGAsset(in assets: [GitHubAsset]) -> GitHubAsset? {
let pkgAssets = assets.filter { $0.name.hasSuffix(".pkg") }
let preferred = preferredPKGVariant()
if let match = pkgAssets.first(where: { $0.name.contains(preferred) }) {
return match
}
if let universal = pkgAssets.first(where: { $0.name.contains("Universal") }) {
return universal
}
return pkgAssets.first
}
private static func preferredPKGVariant() -> String {
if let hostSupportsArm64 = hostSupportsArm64() {
return hostSupportsArm64 ? "Apple" : "Intel"
}
#if arch(arm64)
return "Apple"
#else
return "Intel"
#endif
}
private static func hostSupportsArm64() -> Bool? {
var value: Int32 = 0
var size = MemoryLayout.size(ofValue: value)
let result = withUnsafeMutablePointer(to: &value) {
sysctlbyname("hw.optional.arm64", $0, &size, nil, 0)
}
guard result == 0 else {
return nil
}
return value != 0
}
private static func shouldIncludeRelease(
version: String,
currentVersion: String,
track: UpdateTrack,
force: Bool
) -> Bool {
guard isValidSemver(version) else {
return false
}
if force || LibboxCompareSemver(version, currentVersion) {
return true
}
return track == .stable && isValidPrereleaseSemver(currentVersion)
}
private static func isValidSemver(_ version: String) -> Bool {
let trimmedVersion = version.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmedVersion == minimumSemver || LibboxCompareSemver(trimmedVersion, minimumSemver)
}
private static func isValidPrereleaseSemver(_ version: String) -> Bool {
let trimmedVersion = version.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmedVersion.contains("-") && isValidSemver(trimmedVersion)
}
private static func fetchReleases(client: HTTPClient, track: UpdateTrack) throws -> [GitHubRelease]? {
var allReleases: [GitHubRelease] = []
var page = 1
while true {
let releasesJSON = try client.getString("\(releasesURL)?per_page=\(releasesPerPage)&page=\(page)")
guard let data = releasesJSON.data(using: .utf8) else {
return nil
}
let pageReleases = try JSONDecoder().decode([GitHubRelease].self, from: data)
allReleases.append(contentsOf: pageReleases)
if track != .stable || pageReleases.count < releasesPerPage {
return allReleases
}
page += 1
}
}
}
private struct GitHubRelease: Decodable {
let tagName: String
let htmlURL: String
let body: String?
let draft: Bool
let prerelease: Bool
let assets: [GitHubAsset]
enum CodingKeys: String, CodingKey {
case tagName = "tag_name"
case htmlURL = "html_url"
case body
case draft
case prerelease
case assets
}
}
private struct GitHubAsset: Decodable {
let name: String
let browserDownloadURL: String
let size: Int64
enum CodingKeys: String, CodingKey {
case name
case browserDownloadURL = "browser_download_url"
case size
}
}
+45
View File
@@ -0,0 +1,45 @@
#if os(macOS)
import Foundation
public enum PKGDownloader {
public static func download(
from url: String,
expectedSize: Int64,
progress: @escaping (Double) -> Void
) async throws -> URL {
let updatesDir = FilePath.cacheDirectory.appendingPathComponent("updates", isDirectory: true)
try FileManager.default.createDirectory(at: updatesDir, withIntermediateDirectories: true)
let filename = URL(string: url)!.lastPathComponent
let destination = updatesDir.appendingPathComponent(filename)
if let attrs = try? FileManager.default.attributesOfItem(atPath: destination.path),
let fileSize = attrs[.size] as? Int64,
expectedSize > 0, fileSize == expectedSize
{
progress(1.0)
return destination
}
// Clean old PKG files
if let contents = try? FileManager.default.contentsOfDirectory(at: updatesDir, includingPropertiesForKeys: nil) {
for file in contents where file.pathExtension == "pkg" && file.lastPathComponent != filename {
try? FileManager.default.removeItem(at: file)
}
}
try? FileManager.default.removeItem(at: destination)
var lastReported = 0.0
try await HTTPClient.writeToAsync(url, path: destination.path) { bytesWritten, totalBytes in
guard totalBytes > 0 else { return }
let current = Double(bytesWritten) / Double(totalBytes)
guard current - lastReported >= 0.01 || current >= 1.0 else { return }
lastReported = current
progress(current)
}
return destination
}
}
#endif
+181
View File
@@ -0,0 +1,181 @@
#if os(macOS)
import Foundation
import Security
public enum PKGInstaller {
private static let installerExitStatusMarker = "__PKG_INSTALLER_EXIT_STATUS__="
private typealias ExecuteWithPrivilegesFunc = @convention(c) (
AuthorizationRef,
UnsafePointer<CChar>,
AuthorizationFlags,
UnsafePointer<UnsafeMutablePointer<CChar>?>,
UnsafeMutablePointer<UnsafeMutablePointer<FILE>?>?
) -> OSStatus
public static func authorize() throws -> AuthorizationRef {
var authRef: AuthorizationRef?
var status = AuthorizationCreate(nil, nil, [], &authRef)
guard status == errAuthorizationSuccess, let authRef else {
throw PKGInstallerError.authorizationFailed
}
let rightName = kAuthorizationRightExecute
var item = AuthorizationItem(name: rightName, valueLength: 0, value: nil, flags: 0)
withUnsafeMutablePointer(to: &item) { itemPtr in
var rights = AuthorizationRights(count: 1, items: itemPtr)
let flags: AuthorizationFlags = [.interactionAllowed, .extendRights, .preAuthorize]
status = AuthorizationCopyRights(authRef, &rights, nil, flags, nil)
}
guard status == errAuthorizationSuccess else {
if status == errAuthorizationCanceled {
AuthorizationFree(authRef, [])
throw PKGInstallerError.authorizationCancelled
}
AuthorizationFree(authRef, [])
throw PKGInstallerError.authorizationFailed
}
return authRef
}
public static func install(pkgPath: String, authorization authRef: AuthorizationRef) throws {
defer { AuthorizationFree(authRef, []) }
guard let sym = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "AuthorizationExecuteWithPrivileges") else {
throw PKGInstallerError.authorizationFailed
}
let executeWithPrivileges = unsafeBitCast(sym, to: ExecuteWithPrivilegesFunc.self)
let escapedPkgPath = shellQuote(pkgPath)
let command = "/usr/sbin/installer -pkg \(escapedPkgPath) -target / 2>&1; status=$?; printf '\\n\(installerExitStatusMarker)%d\\n' \"$status\"; exit \"$status\""
let tool = "/bin/sh"
var cArgs: [UnsafeMutablePointer<CChar>?] = [
strdup("-c"), strdup(command), nil,
]
defer { for i in 0 ..< cArgs.count - 1 {
free(cArgs[i])
} }
var pipe: UnsafeMutablePointer<FILE>?
let status = executeWithPrivileges(authRef, tool, [], &cArgs, &pipe)
guard status == errAuthorizationSuccess else {
throw PKGInstallerError.authorizationFailed
}
let output = pipe.map(readOutput(from:)) ?? ""
let (exitStatus, installerOutput) = parseInstallerOutput(output)
guard let exitStatus else {
throw PKGInstallerError.installationFailed(installerOutput.isEmpty ? "Installer exited without reporting a status" : installerOutput)
}
guard exitStatus == 0 else {
if installerOutput.isEmpty {
throw PKGInstallerError.installationFailed("Installer failed with exit status \(exitStatus)")
}
throw PKGInstallerError.installationFailed(installerOutput)
}
}
public static func scheduleInstalledApplicationRelaunch() throws {
guard let appPath = findInstalledAppPath() else {
throw PKGInstallerError.relaunchFailed("Installed app not found in /Applications")
}
let escapedApp = appPath.replacingOccurrences(of: "'", with: "'\\''")
let processID = ProcessInfo.processInfo.processIdentifier
let command = "while kill -0 \(processID) 2>/dev/null; do sleep 1; done; open '\(escapedApp)' >/dev/null 2>&1"
let process = Process()
process.executableURL = URL(filePath: "/bin/sh")
process.arguments = ["-c", command]
if let nullHandle = FileHandle(forWritingAtPath: "/dev/null") {
process.standardOutput = nullHandle
process.standardError = nullHandle
}
do {
try process.run()
} catch {
throw PKGInstallerError.relaunchFailed(error.localizedDescription)
}
}
private static func findInstalledAppPath() -> String? {
if let bundleID = Bundle.main.bundleIdentifier,
let contents = try? FileManager.default.contentsOfDirectory(atPath: "/Applications")
{
for item in contents where item.hasSuffix(".app") {
let path = "/Applications/\(item)"
if let bundle = Bundle(path: path), bundle.bundleIdentifier == bundleID {
return path
}
}
}
let fallback = "/Applications/\(Bundle.main.bundleURL.lastPathComponent)"
if FileManager.default.fileExists(atPath: fallback) {
return fallback
}
return nil
}
private static func shellQuote(_ value: String) -> String {
"'\(value.replacingOccurrences(of: "'", with: "'\\''"))'"
}
private static func readOutput(from pipe: UnsafeMutablePointer<FILE>) -> String {
defer { fclose(pipe) }
var data = Data()
let bufferSize = 4096
let buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: bufferSize)
defer { buffer.deallocate() }
while true {
let count = fread(buffer, 1, bufferSize, pipe)
if count > 0 {
data.append(buffer, count: count)
}
if count < bufferSize {
if feof(pipe) != 0 || ferror(pipe) != 0 {
break
}
}
}
return String(decoding: data, as: UTF8.self)
}
private static func parseInstallerOutput(_ output: String) -> (Int32?, String) {
let trimmedOutput = output.trimmingCharacters(in: .whitespacesAndNewlines)
guard let markerRange = output.range(of: installerExitStatusMarker, options: .backwards) else {
return (nil, trimmedOutput)
}
let statusText = output[markerRange.upperBound...].prefix { $0.isNumber || $0 == "-" }
let installerOutput = String(output[..<markerRange.lowerBound]).trimmingCharacters(in: .whitespacesAndNewlines)
return (Int32(String(statusText)), installerOutput)
}
}
public enum PKGInstallerError: LocalizedError {
case authorizationFailed
case authorizationCancelled
case installationFailed(String)
case relaunchFailed(String)
public var errorDescription: String? {
switch self {
case .authorizationFailed:
return "Authorization failed"
case .authorizationCancelled:
return "Authorization cancelled"
case let .installationFailed(message):
return message
case let .relaunchFailed(message):
return message
}
}
}
#endif
+26
View File
@@ -0,0 +1,26 @@
import Foundation
public struct UpdateInfo: Codable {
public let versionName: String
public let releaseURL: String
public let downloadURL: String
public let releaseNotes: String?
public let isPrerelease: Bool
public let fileSize: Int64
public init(
versionName: String,
releaseURL: String,
downloadURL: String,
releaseNotes: String?,
isPrerelease: Bool,
fileSize: Int64
) {
self.versionName = versionName
self.releaseURL = releaseURL
self.downloadURL = downloadURL
self.releaseNotes = releaseNotes
self.isPrerelease = isPrerelease
self.fileSize = fileSize
}
}
+26
View File
@@ -0,0 +1,26 @@
import Foundation
public enum UpdateTrack: String, Codable, CaseIterable {
case stable
case beta
public static var defaultForCurrentBuild: Self {
Bundle.main.version.contains("-") ? .beta : .stable
}
public static func resolved(from rawValue: String) -> Self {
guard !rawValue.isEmpty else {
return defaultForCurrentBuild
}
return Self(rawValue: rawValue) ?? defaultForCurrentBuild
}
public func allows(_ updateInfo: UpdateInfo) -> Bool {
switch self {
case .stable:
return !updateInfo.isPrerelease
case .beta:
return true
}
}
}
+2475 -547
View File
File diff suppressed because it is too large Load Diff
+14 -3
View File
@@ -7,14 +7,25 @@ import UserNotifications
open class ApplicationDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate {
public func applicationDidFinishLaunching(_: Notification) {
LibboxPrepareCrashSignalHandlers()
NativeCrashReporter.installForCurrentProcess()
LibboxReinstallCrashSignalHandlers()
NSLog("Here I stand")
let options = LibboxSetupOptions()
options.basePath = FilePath.sharedDirectory.relativePath
options.workingPath = FilePath.workingDirectory.relativePath
options.tempPath = FilePath.cacheDirectory.relativePath
options.crashReportSource = "Application"
var error: NSError?
LibboxSetup(options, &error)
LibboxSetLocale(Locale.current.identifier)
if let error {
NSLog("setup service error: \(error.localizedDescription)")
}
var localeError: NSError?
LibboxSetLocale(Locale.current.identifier, &localeError)
if let localeError {
NSLog("failed to set locale: \(localeError)")
}
let notificationCenter = UNUserNotificationCenter.current()
notificationCenter.setNotificationCategories([
UNNotificationCategory(
@@ -32,7 +43,7 @@ open class ApplicationDelegate: NSObject, NSApplicationDelegate, UNUserNotificat
event?.eventID == kAEOpenApplication &&
event?.paramDescriptor(forKeyword: keyAEPropData)?.enumCodeValue == keyAELaunchedAsLogInItem
let shouldShowWindow = Variant.screenshotMode ||
SharedPreferences.inDebug ||
Variant.inDebug ||
!launchedAsLogInItem ||
!SharedPreferences.showMenuBarExtra.getBlocking() ||
!SharedPreferences.menuBarExtraInBackground.getBlocking()
@@ -74,7 +85,7 @@ open class ApplicationDelegate: NSObject, NSApplicationDelegate, UNUserNotificat
}
public func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool {
SharedPreferences.inDebug || !SharedPreferences.menuBarExtraInBackground.getBlocking()
Variant.inDebug || !SharedPreferences.menuBarExtraInBackground.getBlocking()
}
public func applicationShouldHandleReopen(_: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
+40 -23
View File
@@ -40,31 +40,40 @@ private extension NSColor {
}
}
private func makeTheme() -> EditorTheme {
EditorTheme(
text: .init(color: NSColor.labelColor.forEditor),
insertionPoint: NSColor.labelColor.forEditor,
invisibles: .init(color: NSColor.tertiaryLabelColor.forEditor),
background: NSColor.textBackgroundColor.forEditor,
lineHighlight: NSColor.quaternaryLabelColor.forEditor,
selection: NSColor.selectedTextBackgroundColor.forEditor,
keywords: .init(color: NSColor.systemPurple.forEditor),
commands: .init(color: NSColor.systemCyan.forEditor),
types: .init(color: NSColor.systemCyan.forEditor),
attributes: .init(color: NSColor.systemCyan.forEditor),
variables: .init(color: NSColor.labelColor.forEditor),
values: .init(color: NSColor.systemOrange.forEditor),
numbers: .init(color: NSColor.systemOrange.forEditor),
strings: .init(color: NSColor.systemGreen.forEditor),
characters: .init(color: NSColor.systemGreen.forEditor),
comments: .init(color: NSColor.secondaryLabelColor.forEditor)
)
private func makeTheme(for colorScheme: ColorScheme) -> EditorTheme {
var theme: EditorTheme!
let build = {
theme = EditorTheme(
text: .init(color: NSColor.labelColor.forEditor),
insertionPoint: NSColor.labelColor.forEditor,
invisibles: .init(color: NSColor.tertiaryLabelColor.forEditor),
background: NSColor.textBackgroundColor.forEditor,
lineHighlight: NSColor.quaternaryLabelColor.forEditor,
selection: NSColor.selectedTextBackgroundColor.forEditor,
keywords: .init(color: NSColor.systemPurple.forEditor),
commands: .init(color: NSColor.systemCyan.forEditor),
types: .init(color: NSColor.systemCyan.forEditor),
attributes: .init(color: NSColor.systemCyan.forEditor),
variables: .init(color: NSColor.labelColor.forEditor),
values: .init(color: NSColor.systemOrange.forEditor),
numbers: .init(color: NSColor.systemOrange.forEditor),
strings: .init(color: NSColor.systemGreen.forEditor),
characters: .init(color: NSColor.systemGreen.forEditor),
comments: .init(color: NSColor.secondaryLabelColor.forEditor)
)
}
if let appearance = NSAppearance(named: colorScheme == .dark ? .darkAqua : .aqua) {
appearance.performAsCurrentDrawingAppearance(build)
} else {
build()
}
return theme
}
private func makeConfiguration(isEditable: Bool) -> SourceEditorConfiguration {
private func makeConfiguration(isEditable: Bool, colorScheme: ColorScheme) -> SourceEditorConfiguration {
SourceEditorConfiguration(
appearance: .init(
theme: makeTheme(),
theme: makeTheme(for: colorScheme),
font: .monospacedSystemFont(ofSize: 14, weight: .regular),
lineHeightMultiple: 1.3,
wrapLines: false
@@ -85,6 +94,8 @@ struct CodeEditTextView: NSViewRepresentable {
let isEditable: Bool
let editorController: CodeEditEditorController?
@Environment(\.colorScheme) private var colorScheme
init(text: Binding<String>, isEditable: Bool, editorController: CodeEditEditorController? = nil) {
_text = text
self.isEditable = isEditable
@@ -95,7 +106,7 @@ struct CodeEditTextView: NSViewRepresentable {
let controller = TextViewController(
string: text,
language: .json,
configuration: makeConfiguration(isEditable: isEditable),
configuration: makeConfiguration(isEditable: isEditable, colorScheme: colorScheme),
cursorPositions: []
)
controller.loadView()
@@ -115,6 +126,7 @@ struct CodeEditTextView: NSViewRepresentable {
])
context.coordinator.controller = controller
context.coordinator.lastColorScheme = colorScheme
context.coordinator.setupObservation()
editorController?.controller = controller
Task { @MainActor in
@@ -133,7 +145,11 @@ struct CodeEditTextView: NSViewRepresentable {
controller.language = .json
}
if controller.configuration.behavior.isEditable != isEditable {
controller.configuration = makeConfiguration(isEditable: isEditable)
controller.configuration.behavior.isEditable = isEditable
}
if context.coordinator.lastColorScheme != colorScheme {
context.coordinator.lastColorScheme = colorScheme
controller.configuration.appearance.theme = makeTheme(for: colorScheme)
}
editorController?.controller = controller
}
@@ -144,6 +160,7 @@ struct CodeEditTextView: NSViewRepresentable {
class Coordinator: NSObject {
var controller: TextViewController?
var lastColorScheme: ColorScheme?
@Binding var text: String
private var observation: NSObjectProtocol?
private weak var editorController: CodeEditEditorController?
+8 -1
View File
@@ -21,7 +21,9 @@ struct EditProfileContentWindow: View {
var body: some View {
Group {
if viewModel.isLoading {
if context == nil {
Color.clear
} else if viewModel.isLoading {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.task {
@@ -37,8 +39,13 @@ struct EditProfileContentWindow: View {
.frame(minWidth: 600, minHeight: 400)
.background(WindowAccessor { window in
guard let window else { return }
if context == nil {
window.close()
return
}
if windowState.window == nil {
windowState.window = window
window.isRestorable = false
windowState.onClose = { [weak viewModel] in
viewModel?.reset()
}
+71
View File
@@ -9,7 +9,9 @@ public struct MacApplication: Scene {
@State private var showMenuBarExtra = false
@State private var menuBarExtraSpeedMode = MenuBarExtraSpeedMode.enabled.rawValue
@StateObject private var environments = ExtensionEnvironments()
@StateObject private var updateManager = UpdateManager()
@State private var statusBarController: StatusBarController?
@State private var showUpdateCheckPrompt = false
private let profileEditor: (Binding<String>, Bool) -> AnyView = { text, isEditable in
AnyView(ProfileEditorWrapperView(text: text, isEditable: isEditable))
@@ -27,6 +29,32 @@ public struct MacApplication: Scene {
.environment(\.showMenuBarExtra, $showMenuBarExtra)
.environment(\.menuBarExtraSpeedMode, $menuBarExtraSpeedMode)
.environmentObject(environments)
.environmentObject(updateManager)
.alert(
"Check Update",
isPresented: $showUpdateCheckPrompt
) {
Button("Ok") {
Task {
await SharedPreferences.updateCheckPrompted.set(true)
await SharedPreferences.checkUpdateEnabled.set(true)
await runAutomaticUpdateCheck()
}
}
Button("No, thanks", role: .cancel) {
Task {
await SharedPreferences.updateCheckPrompted.set(true)
}
}
} message: {
Text("Would you like to enable automatic update checking from **GitHub**?")
}
.sheet(isPresented: $updateManager.isUpdateSheetPresented, onDismiss: {
updateManager.dismissUpdateSheet()
}) {
UpdateSheet(updateManager: updateManager)
.environmentObject(environments)
}
.onChangeCompat(of: showMenuBarExtra) { newValue in
statusBarController?.updateVisibility(newValue)
Task {
@@ -81,6 +109,49 @@ public struct MacApplication: Scene {
statusBarController = StatusBarController(environments: environments)
statusBarController?.updateVisibility(showMenuBarExtra)
statusBarController?.updateSpeedMode(menuBarExtraSpeedMode)
if Variant.useSystemExtension {
let shouldPresentCachedUpdate = await updateManager.loadCachedUpdate()
let checkUpdateEnabled = await SharedPreferences.checkUpdateEnabled.get()
let prompted = await SharedPreferences.updateCheckPrompted.get()
if !prompted {
showUpdateCheckPrompt = true
} else if checkUpdateEnabled {
if shouldPresentCachedUpdate {
await presentUpdateSheet()
}
Task {
await runAutomaticUpdateCheck()
}
}
}
}
private func runAutomaticUpdateCheck() async {
let shouldPresent = await updateManager.checkForUpdate(presentIfFound: true, showsAlertOnFailure: false)
if shouldPresent {
await presentUpdateSheet()
}
}
private func presentUpdateSheet() async {
guard updateManager.updateInfo != nil else { return }
await openMainWindowIfNeeded()
await updateManager.showUpdateSheet()
}
private func openMainWindowIfNeeded() async {
let mainWindow = NSApp.windows.first(where: { $0.identifier?.rawValue == "main" })
let shouldActivate = NSApp.activationPolicy() == .accessory || !(mainWindow?.isVisible ?? false) || !NSApp.isActive
guard shouldActivate else { return }
NSApp.setActivationPolicy(.regular)
mainWindow?.makeKeyAndOrderFront(nil)
if let dockApp = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.dock").first {
dockApp.activate()
try? await Task.sleep(for: .milliseconds(100))
}
NSApp.activate(ignoringOtherApps: true)
}
private func hide(closeApp: Bool) {
+3
View File
@@ -26,10 +26,12 @@ private struct SidebarContentView: View {
}
ForEach(NavigationPage.macosDefaultPages, id: \.self) { it in
it.label
.badge(it == .tools ? environments.totalUnreadReportCount : 0)
}
} else {
ForEach(NavigationPage.allCases.filter { $0.visible(profile) }, id: \.self) { it in
it.label
.badge(it == .tools ? environments.totalUnreadReportCount : 0)
}
}
}
@@ -95,6 +97,7 @@ public struct SidebarView: View {
List(selection: $localSelection) {
ForEach(NavigationPage.allCases.filter { $0.visible(nil) }, id: \.self) { it in
it.label
.badge(it == .tools ? environments.totalUnreadReportCount : 0)
}
}
.listStyle(.sidebar)
+25 -3
View File
@@ -9,16 +9,28 @@ import UserNotifications
class ApplicationDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
private var profileServer: ProfileServer?
private var reportTransferServer: ReportTransferServer?
func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
LibboxPrepareCrashSignalHandlers()
NativeCrashReporter.installForCurrentProcess()
LibboxReinstallCrashSignalHandlers()
NSLog("Here I stand")
let options = LibboxSetupOptions()
options.basePath = FilePath.sharedDirectory.relativePath
options.workingPath = FilePath.workingDirectory.relativePath
options.tempPath = FilePath.cacheDirectory.relativePath
var error: NSError?
LibboxSetup(options, &error)
LibboxSetLocale(Locale.current.identifier)
options.crashReportSource = "Application"
var setupError: NSError?
LibboxSetup(options, &setupError)
if let setupError {
NSLog("setup service error: \(setupError.localizedDescription)")
}
var localeError: NSError?
LibboxSetLocale(Locale.current.identifier, &localeError)
if let localeError {
NSLog("failed to set locale: \(localeError)")
}
let notificationCenter = UNUserNotificationCenter.current()
notificationCenter.setNotificationCategories([
UNNotificationCategory(
@@ -77,6 +89,16 @@ class ApplicationDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCe
} catch {
NSLog("setup profile server error: \(error.localizedDescription)")
}
do {
let reportTransferServer = try ReportTransferServer()
reportTransferServer.start()
await MainActor.run {
self.reportTransferServer = reportTransferServer
}
NSLog("started report transfer server")
} catch {
NSLog("setup report transfer server error: \(error.localizedDescription)")
}
registerFileProviderDomain()
}
}
+4
View File
@@ -58,6 +58,10 @@
<key>NSApplicationServiceIdentifier</key>
<string>sing-box:profile</string>
</dict>
<dict>
<key>NSApplicationServiceIdentifier</key>
<string>sing-box:report-transfer</string>
</dict>
</array>
</dict>
<key>NSUbiquitousContainers</key>
+18 -11
View File
@@ -73,6 +73,7 @@ struct MainView: View {
}
.tag(page)
.tabItem { page.label }
.badge(page == .tools ? environments.totalUnreadReportCount : 0)
}
}
}
@@ -176,6 +177,13 @@ struct MainView: View {
environments.connect()
}
}
.onReceive(NotificationCenter.default.publisher(for: .reportReceived)) { _ in
Task {
await environments.crashReportManager.refresh()
await environments.oomReportManager.refresh()
selection = .tools
}
}
.environment(\.selection, $selection)
.environment(\.importProfile, $importProfile)
.environment(\.importRemoteProfile, $importRemoteProfile)
@@ -195,29 +203,28 @@ struct MainView: View {
@ObservedObject var profile: ExtensionProfile
var body: some View {
Text(statusText)
statusText
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
.fixedSize()
}
private var statusText: String {
private var statusText: Text {
switch profile.status {
case .invalid:
return String(localized: "Invalid")
case .disconnected:
return String(localized: "Stopped")
return Text("Stopped")
case .connecting:
return String(localized: "Starting")
return Text("Starting")
case .connected:
return String(localized: "Started")
return Text("Started")
case .reasserting:
return String(localized: "Reasserting")
return Text("Reasserting")
case .disconnecting:
return String(localized: "Stopping")
@unknown default:
return String(localized: "Unknown")
return Text("Stopping")
default:
return Text("Unknown")
.foregroundColor(.red)
}
}
}
@@ -7,12 +7,11 @@ import MacLibrary
class StandaloneApplicationDelegate: ApplicationDelegate {
func applicationWillFinishLaunching(_: Notification) {
Variant.useSystemExtension = true
Variant.isBeta = false
LibboxSetXPCDialer(CommandXPCDialer.shared)
UserServiceEndpointPublisher.shared.start()
Task {
await setupSystemExtension()
await HelperServiceManager.updateRootHelperIfNeeded()
UserServiceEndpointPublisher.shared.start()
}
}
+2
View File
@@ -30,5 +30,7 @@
<true/>
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
</dict>
</plist>
+12 -1
View File
@@ -6,6 +6,9 @@ import UIKit
class ApplicationDelegate: NSObject, UIApplicationDelegate {
func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
LibboxPrepareCrashSignalHandlers()
NativeCrashReporter.installForCurrentProcess()
LibboxReinstallCrashSignalHandlers()
NSLog("Here I stand")
let options = LibboxSetupOptions()
options.basePath = FilePath.sharedDirectory.relativePath
@@ -28,9 +31,17 @@ class ApplicationDelegate: NSObject, UIApplicationDelegate {
}
options.commandServerListenPort = port
options.commandServerSecret = secret
options.crashReportSource = "Application"
var error: NSError?
LibboxSetup(options, &error)
LibboxSetLocale(Locale.current.identifier)
if let error {
NSLog("setup service error: \(error.localizedDescription)")
}
var localeError: NSError?
LibboxSetLocale(Locale.current.identifier, &localeError)
if let localeError {
NSLog("failed to set locale: \(localeError)")
}
setup()
return true
}
+11
View File
@@ -42,6 +42,17 @@
<key>NSApplicationServiceUsageDescription</key>
<string>Import sing-box profile from other devices</string>
</dict>
<dict>
<key>NSApplicationServiceIdentifier</key>
<string>sing-box:report-transfer</string>
<key>NSApplicationServicePlatformSupport</key>
<array>
<string>iOS</string>
<string>iPadOS</string>
</array>
<key>NSApplicationServiceUsageDescription</key>
<string>Export crash reports to other devices</string>
</dict>
</array>
</dict>
<key>UIBackgroundModes</key>
+11 -1
View File
@@ -28,7 +28,17 @@ struct MainView: View {
.focusSection()
}
.tag(page)
.tabItem { page.label }
.tabItem {
if page == .tools, environments.totalUnreadReportCount > 0 {
Label {
Text(verbatim: "\(page.title) (\(environments.totalUnreadReportCount))")
} icon: {
Image(systemName: "terminal.fill")
}
} else {
page.label
}
}
}
}
.onAppear {
+19 -1
View File
@@ -51,8 +51,17 @@ enum WidgetTunnelControl {
if started {
if manager.isEnabled == false {
manager.isEnabled = true
try await manager.saveToPreferences()
}
if let proto = manager.protocolConfiguration as? NETunnelProviderProtocol,
let config = proto.providerConfiguration,
config["wasOnDemandEnabled"] as? Bool == true
{
var newConfig = config
newConfig.removeValue(forKey: "wasOnDemandEnabled")
proto.providerConfiguration = newConfig
manager.isOnDemandEnabled = true
}
try await manager.saveToPreferences()
do {
try manager.connection.startVPNTunnel()
} catch {
@@ -60,6 +69,15 @@ enum WidgetTunnelControl {
throw error
}
} else {
if manager.isOnDemandEnabled {
if let proto = manager.protocolConfiguration as? NETunnelProviderProtocol {
var config = proto.providerConfiguration ?? [:]
config["wasOnDemandEnabled"] = true
proto.providerConfiguration = config
}
manager.isOnDemandEnabled = false
try await manager.saveToPreferences()
}
manager.connection.stopVPNTunnel()
}
}
+48 -14
View File
@@ -15,11 +15,13 @@
3A3AA7FF2A4EFDB3002F78AB /* Library.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AEC211D2A459B4700A63465 /* Library.framework */; };
3A3DEBEB2A4FFE2D00373BF4 /* AppIntents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3A3DEBE62A4FFA6000373BF4 /* AppIntents.framework */; };
3A4A020D2B53E3DC004EFB87 /* QRCode in Frameworks */ = {isa = PBXBuildFile; productRef = 3A4A020C2B53E3DC004EFB87 /* QRCode */; };
3A4CA8CC2F75381F009C36CA /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 3A4CA8CB2F75381F009C36CA /* MarkdownUI */; };
3A4EAD1B2A4FEB02005435B3 /* Library.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AEC211D2A459B4700A63465 /* Library.framework */; };
3A4EAD372A4FEC20005435B3 /* ApplicationLibrary.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3A4EAD102A4FEAE6005435B3 /* ApplicationLibrary.framework */; };
3A4FB1572A73467F007012B9 /* Library.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AEC211D2A459B4700A63465 /* Library.framework */; };
3A4FB1582A73467F007012B9 /* Library.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3AEC211D2A459B4700A63465 /* Library.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
3A4FB15C2A73468C007012B9 /* ApplicationLibrary.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3A4EAD102A4FEAE6005435B3 /* ApplicationLibrary.framework */; };
3A5AA1BA2F7DB10900BA2A0D /* CrashReporter in Frameworks */ = {isa = PBXBuildFile; productRef = 3A5AA1B92F7DB10900BA2A0D /* CrashReporter */; };
3A5F26C82A503D4A00C27EDF /* Library.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AEC211D2A459B4700A63465 /* Library.framework */; };
3A5F26C92A503D4A00C27EDF /* Library.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3AEC211D2A459B4700A63465 /* Library.framework */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
3A648D542A4EF4C700D95A12 /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AF342B12A4AA520002B34AC /* NetworkExtension.framework */; };
@@ -634,6 +636,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
3A4CA8CC2F75381F009C36CA /* MarkdownUI in Frameworks */,
3A4EAD1B2A4FEB02005435B3 /* Library.framework in Frameworks */,
3A4A020D2B53E3DC004EFB87 /* QRCode in Frameworks */,
);
@@ -741,6 +744,7 @@
3A017F922A4AB2E4009149FA /* GRDB in Frameworks */,
3AFE19402EF5677100F61E06 /* SystemConfiguration.framework in Frameworks */,
3A76504C2A4F08BA003945C5 /* Libbox.xcframework in Frameworks */,
3A5AA1BA2F7DB10900BA2A0D /* CrashReporter in Frameworks */,
3A7E90382A46778E00D53052 /* BinaryCodable in Frameworks */,
3AF3A3D22B2207F3001FD7C1 /* libresolv.tbd in Frameworks */,
);
@@ -922,6 +926,7 @@
name = ApplicationLibrary;
packageProductDependencies = (
3A4A020C2B53E3DC004EFB87 /* QRCode */,
3A4CA8CB2F75381F009C36CA /* MarkdownUI */,
);
productName = ApplicationLibrary;
productReference = 3A4EAD102A4FEAE6005435B3 /* ApplicationLibrary.framework */;
@@ -1202,6 +1207,7 @@
packageProductDependencies = (
3A7E90372A46778E00D53052 /* BinaryCodable */,
3A017F912A4AB2E4009149FA /* GRDB */,
3A5AA1B92F7DB10900BA2A0D /* CrashReporter */,
);
productName = Library;
productReference = 3AEC211D2A459B4700A63465 /* Library.framework */;
@@ -1368,6 +1374,8 @@
3A2E87F02ED5A91100644195 /* XCLocalSwiftPackageReference "Frameworks/Runestone" */,
3A2E87F92ED5ABCF00644195 /* XCLocalSwiftPackageReference "Frameworks/TreeSitterJSON5" */,
3ACE5E012EE1A91100644196 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */,
3A4CA8CA2F75381F009C36CA /* XCRemoteSwiftPackageReference "swift-markdown-ui" */,
3A5AA1B82F7DB10900BA2A0D /* XCRemoteSwiftPackageReference "plcrashreporter" */,
);
productRefGroup = 3AEC20C72A45991900A63465 /* Products */;
projectDirPath = "";
@@ -2235,7 +2243,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = "1.13.1";
MARKETING_VERSION = "1.14.0";
PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt;
PRODUCT_NAME = "sing-box";
SDKROOT = appletvos;
@@ -2269,7 +2277,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = "1.13.1";
MARKETING_VERSION = "1.14.0";
PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt;
PRODUCT_NAME = "sing-box";
SDKROOT = appletvos;
@@ -2662,13 +2670,13 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = "1.13.1";
MARKETING_VERSION = "1.14.0";
OTHER_CODE_SIGN_FLAGS = "--deep";
PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt;
PRODUCT_NAME = "sing-box";
PROVISIONING_PROFILE_SPECIFIER = "";
SDKROOT = iphoneos;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
@@ -2704,13 +2712,13 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = "1.13.1";
MARKETING_VERSION = "1.14.0";
OTHER_CODE_SIGN_FLAGS = "--deep";
PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt;
PRODUCT_NAME = "sing-box";
PROVISIONING_PROFILE_SPECIFIER = "";
SDKROOT = iphoneos;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
@@ -2744,14 +2752,14 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = "1.13.1";
MARKETING_VERSION = "1.14.0";
OTHER_CODE_SIGN_FLAGS = "";
PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt;
PRODUCT_NAME = "sing-box";
PROVISIONING_PROFILE_SPECIFIER = "";
REEXPORTED_LIBRARY_PATHS = "";
SDKROOT = macosx;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
};
name = Debug;
@@ -2783,14 +2791,14 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = "1.13.1";
MARKETING_VERSION = "1.14.0";
OTHER_CODE_SIGN_FLAGS = "";
PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt;
PRODUCT_NAME = "sing-box";
PROVISIONING_PROFILE_SPECIFIER = "";
REEXPORTED_LIBRARY_PATHS = "";
SDKROOT = macosx;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
};
name = Release;
@@ -2925,7 +2933,7 @@
"@executable_path/../../../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = "1.13.1";
MARKETING_VERSION = "1.14.0-alpha.17";
PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.system;
PRODUCT_NAME = "$(inherited)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -2973,7 +2981,7 @@
"@executable_path/../../../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = "1.13.1";
MARKETING_VERSION = "1.14.0-alpha.17";
PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.system;
PRODUCT_NAME = "$(inherited)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -3016,7 +3024,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = "1.13.1";
MARKETING_VERSION = "1.14.0-alpha.17";
PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.standalone;
PRODUCT_NAME = SFM;
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -3058,7 +3066,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = "1.13.1";
MARKETING_VERSION = "1.14.0-alpha.17";
PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.standalone;
PRODUCT_NAME = SFM;
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -3342,6 +3350,22 @@
minimumVersion = 17.0.0;
};
};
3A4CA8CA2F75381F009C36CA /* XCRemoteSwiftPackageReference "swift-markdown-ui" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/gonzalezreal/swift-markdown-ui";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.4.1;
};
};
3A5AA1B82F7DB10900BA2A0D /* XCRemoteSwiftPackageReference "plcrashreporter" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/microsoft/plcrashreporter.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 1.12.2;
};
};
3A7E90362A46778E00D53052 /* XCRemoteSwiftPackageReference "BinaryCodable" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/christophhagen/BinaryCodable";
@@ -3381,6 +3405,16 @@
package = 3A4A020B2B53E3DC004EFB87 /* XCRemoteSwiftPackageReference "qrcode" */;
productName = QRCode;
};
3A4CA8CB2F75381F009C36CA /* MarkdownUI */ = {
isa = XCSwiftPackageProductDependency;
package = 3A4CA8CA2F75381F009C36CA /* XCRemoteSwiftPackageReference "swift-markdown-ui" */;
productName = MarkdownUI;
};
3A5AA1B92F7DB10900BA2A0D /* CrashReporter */ = {
isa = XCSwiftPackageProductDependency;
package = 3A5AA1B82F7DB10900BA2A0D /* XCRemoteSwiftPackageReference "plcrashreporter" */;
productName = CrashReporter;
};
3A7E90372A46778E00D53052 /* BinaryCodable */ = {
isa = XCSwiftPackageProductDependency;
package = 3A7E90362A46778E00D53052 /* XCRemoteSwiftPackageReference "BinaryCodable" */;
@@ -1,5 +1,5 @@
{
"originHash" : "1e51842f566cb5b99c8ac7575b8910b65467e19dda2da9562c06bf365e78c2e9",
"originHash" : "a5ae9234a9bc428c00d8f7d82f435f7ccec7ff57efcb585b429182b0f1b5f9a4",
"pins" : [
{
"identity" : "binarycodable",
@@ -55,6 +55,24 @@
"version" : "6.29.3"
}
},
{
"identity" : "networkimage",
"kind" : "remoteSourceControl",
"location" : "https://github.com/gonzalezreal/NetworkImage",
"state" : {
"revision" : "2849f5323265386e200484b0d0f896e73c3411b9",
"version" : "6.0.1"
}
},
{
"identity" : "plcrashreporter",
"kind" : "remoteSourceControl",
"location" : "https://github.com/microsoft/plcrashreporter.git",
"state" : {
"revision" : "0254f941c646b1ed17b243654723d0f071e990d0",
"version" : "1.12.2"
}
},
{
"identity" : "qrcode",
"kind" : "remoteSourceControl",
@@ -73,6 +91,15 @@
"version" : "2.0.0"
}
},
{
"identity" : "swift-cmark",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-cmark",
"state" : {
"revision" : "5d9bdaa4228b381639fff09403e39a04926e2dbe",
"version" : "0.7.1"
}
},
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
@@ -82,6 +109,15 @@
"version" : "1.3.0"
}
},
{
"identity" : "swift-markdown-ui",
"kind" : "remoteSourceControl",
"location" : "https://github.com/gonzalezreal/swift-markdown-ui",
"state" : {
"revision" : "5f613358148239d0292c0cef674a3c2314737f9e",
"version" : "2.4.1"
}
},
{
"identity" : "swift-qrcode-generator",
"kind" : "remoteSourceControl",