diff --git a/crates/clash-verge-widget/native/Bridge.swift b/crates/clash-verge-widget/native/Bridge.swift index 0569ea3ec..ace918c3e 100644 --- a/crates/clash-verge-widget/native/Bridge.swift +++ b/crates/clash-verge-widget/native/Bridge.swift @@ -1,30 +1,35 @@ +import Darwin import Foundation import Security import WidgetKit -import Darwin let widgetKind = "VergeRoutingWidget" let hostID = "io.github.clash-verge-rev.clash-verge-rev" func socketPath() -> String? { - guard let group = Bundle.main.object(forInfoDictionaryKey: "VergeWidgetGroup") as? String, - let container = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: group) else { return nil } + guard let group = Bundle.main.object(forInfoDictionaryKey: "VergeWidgetGroup") as? String else { return nil } + guard let container = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: group) else { + return nil + } return container.appendingPathComponent("verge-widget.sock").path } -func authenticPeer(_ fd: Int32, identifier: String) -> Bool { +func authenticPeer(_ descriptor: Int32, identifier: String) -> Bool { var token = audit_token_t() var size = socklen_t(MemoryLayout.size(ofValue: token)) - guard getsockopt(fd, SOL_LOCAL, LOCAL_PEERTOKEN, &token, &size) == 0, + guard getsockopt(descriptor, SOL_LOCAL, LOCAL_PEERTOKEN, &token, &size) == 0, size == MemoryLayout.size(ofValue: token) else { return false } let data = withUnsafeBytes(of: token) { Data($0) } var peer: SecCode? - guard SecCodeCopyGuestWithAttributes(nil, [kSecGuestAttributeAudit: data] as CFDictionary, [], &peer) == errSecSuccess, + let attributes = [kSecGuestAttributeAudit: data] as CFDictionary + guard SecCodeCopyGuestWithAttributes(nil, attributes, [], &peer) == errSecSuccess, let peer, let team = Bundle.main.object(forInfoDictionaryKey: "VergeWidgetTeam") as? String, team.range(of: "^[A-Z0-9]{10}$", options: .regularExpression) != nil else { return false } var requirement: SecRequirement? - let expression = "anchor apple generic and certificate leaf[subject.OU] = \"\(team)\" and identifier \"\(identifier)\"" + let expression = """ + anchor apple generic and certificate leaf[subject.OU] = "\(team)" and identifier "\(identifier)" + """ guard SecRequirementCreateWithString(expression as CFString, [], &requirement) == errSecSuccess, let requirement else { return false } return SecCodeCheckValidity(peer, [], requirement) == errSecSuccess @@ -37,8 +42,8 @@ public func vergeWidgetPath() -> UnsafeMutablePointer? { } @_cdecl("verge_widget_authenticate") -public func vergeWidgetAuthenticate(_ fd: Int32) -> Bool { - authenticPeer(fd, identifier: hostID + ".widget") +public func vergeWidgetAuthenticate(_ descriptor: Int32) -> Bool { + authenticPeer(descriptor, identifier: hostID + ".widget") } @_cdecl("verge_widget_reload") diff --git a/crates/clash-verge-widget/native/RoutingView.swift b/crates/clash-verge-widget/native/RoutingView.swift index 383ec232b..b587f6942 100644 --- a/crates/clash-verge-widget/native/RoutingView.swift +++ b/crates/clash-verge-widget/native/RoutingView.swift @@ -6,14 +6,14 @@ struct RoutingView: View { let entry: Entry @Environment(\.widgetFamily) private var widgetFamily #if DEBUG - var previewFamily: WidgetFamily? = nil + var previewFamily: WidgetFamily? private var family: WidgetFamily { previewFamily ?? widgetFamily } #else private var family: WidgetFamily { widgetFamily } #endif @Environment(\.widgetRenderingMode) private var systemRenderingMode #if DEBUG - var previewRenderingMode: WidgetRenderingMode? = nil + var previewRenderingMode: WidgetRenderingMode? var previewMaterial = "automatic" var previewIncreaseContrast = false private var renderingMode: WidgetRenderingMode { previewRenderingMode ?? systemRenderingMode } @@ -24,8 +24,8 @@ struct RoutingView: View { @Environment(\.accessibilityReduceMotion) private var systemReduceMotion @Environment(\.accessibilityReduceTransparency) private var systemReduceTransparency #if DEBUG - var previewReduceMotion: Bool? = nil - var previewReduceTransparency: Bool? = nil + var previewReduceMotion: Bool? + var previewReduceTransparency: Bool? private var reduceMotion: Bool { previewReduceMotion ?? systemReduceMotion } private var reduceTransparency: Bool { previewReduceTransparency ?? systemReduceTransparency } #else @@ -65,7 +65,9 @@ struct RoutingView: View { return colorScheme == .dark ? .black : .white } private var locale: Locale { - Locale(identifier: entry.status.language.map { ["jp": "ja", "zh": "zh-Hans", "zhtw": "zh-Hant"][$0] ?? $0 } ?? Locale.current.identifier) + let aliases = ["jp": "ja", "zh": "zh-Hans", "zhtw": "zh-Hant"] + let identifier = entry.status.language.map { aliases[$0] ?? $0 } ?? Locale.current.identifier + return Locale(identifier: identifier) } private var rtl: Bool { locale.language.characterDirection == .rightToLeft } @@ -155,7 +157,10 @@ struct RoutingView: View { ForEach(orderedModes, id: \.self) { mode in let selected = entry.status.mode == mode let label = Text(text("tray." + mode)) - .font(.system(size: family == .systemSmall ? 12 : 13, weight: selected ? .semibold : .regular)) + .font(.system( + size: family == .systemSmall ? 12 : 13, + weight: selected ? .semibold : .regular + )) .lineLimit(1) .frame(maxWidth: .infinity) .frame(height: 30) @@ -177,7 +182,10 @@ struct RoutingView: View { .frame(height: 30) HStack(spacing: 8) { control(text("tray.tooltip.tun"), action: "tun", value: entry.status.tun, icon: "network") - control(text("tray.systemProxy"), action: "proxy", value: entry.status.proxy, icon: "shield.lefthalf.filled") + control( + text("tray.systemProxy"), action: "proxy", + value: entry.status.proxy, icon: "shield.lefthalf.filled" + ) } if let error = entry.status.error { Text(text(error)).font(.caption2).foregroundStyle(ink.opacity(0.8)).lineLimit(2) diff --git a/crates/clash-verge-widget/native/StylePreviews.swift b/crates/clash-verge-widget/native/StylePreviews.swift index a329b819e..1f19ea69c 100644 --- a/crates/clash-verge-widget/native/StylePreviews.swift +++ b/crates/clash-verge-widget/native/StylePreviews.swift @@ -73,7 +73,7 @@ private struct RoutingStylePreview: View { case "light": Color(white: 0.92) case "dark": Color(white: 0.1) case "pattern": - Canvas { context, size in + Canvas { context, _ in for row in 0..<8 { for column in 0..<14 { let rect = CGRect(x: CGFloat(column) * 32, y: CGFloat(row) * 32, width: 32, height: 32) @@ -90,9 +90,15 @@ private struct RoutingStylePreview: View { VStack(spacing: 12) { ZStack { background - RoutingView(entry: Entry(date: .now, status: sample), previewFamily: medium ? .systemMedium : .systemSmall, - previewRenderingMode: renderingOverride, previewMaterial: material, previewIncreaseContrast: increaseContrast, - previewReduceMotion: reduceMotion, previewReduceTransparency: reduceTransparency) + RoutingView( + entry: Entry(date: .now, status: sample), + previewFamily: medium ? .systemMedium : .systemSmall, + previewRenderingMode: renderingOverride, + previewMaterial: material, + previewIncreaseContrast: increaseContrast, + previewReduceMotion: reduceMotion, + previewReduceTransparency: reduceTransparency + ) .environment(\.colorScheme, dark ? .dark : .light) .allowsHitTesting(false) } @@ -162,8 +168,11 @@ private struct RoutingStylePreview: View { } Toggle("Reduce transparency", isOn: $reduceTransparency) Toggle("Reduce motion", isOn: $reduceMotion) - Text("Style only. Controls change sample data; no proxy requests. Desktop WidgetKit compositing is not simulated.") - .font(.caption).foregroundStyle(.secondary) + Text( + "Style only. Controls change sample data; no proxy requests. " + + "Desktop WidgetKit compositing is not simulated." + ) + .font(.caption).foregroundStyle(.secondary) } .padding(4) } @@ -187,6 +196,9 @@ private struct RoutingStylePreview: View { } #Preview("中文 · Unknown · Offline") { - RoutingStylePreview(medium: true, dark: true, language: "zh", mode: "unknown", tun: "unknown", proxy: "unknown", error: "unavailable") + RoutingStylePreview( + medium: true, dark: true, language: "zh", mode: "unknown", + tun: "unknown", proxy: "unknown", error: "unavailable" + ) } #endif diff --git a/crates/clash-verge-widget/native/Widget.swift b/crates/clash-verge-widget/native/Widget.swift index 7c1aeb562..4167e59b8 100644 --- a/crates/clash-verge-widget/native/Widget.swift +++ b/crates/clash-verge-widget/native/Widget.swift @@ -46,7 +46,8 @@ struct Provider: TimelineProvider { let status = exchange() let now = Date() let unavailable = status.mode == nil || status.tun == nil || status.proxy == nil - completion(Timeline(entries: [Entry(date: now, status: status)], policy: .after(now.addingTimeInterval(unavailable ? 60 : 900)))) + let refresh = now.addingTimeInterval(unavailable ? 60 : 900) + completion(Timeline(entries: [Entry(date: now, status: status)], policy: .after(refresh))) } } } diff --git a/crates/clash-verge-widget/native/WidgetClient.swift b/crates/clash-verge-widget/native/WidgetClient.swift index cee1770cd..fa7ab3490 100644 --- a/crates/clash-verge-widget/native/WidgetClient.swift +++ b/crates/clash-verge-widget/native/WidgetClient.swift @@ -1,5 +1,5 @@ -import Foundation import Darwin +import Foundation struct Status: Codable { var mode: String? @@ -11,10 +11,71 @@ struct Status: Codable { func localized(_ key: String, language: String? = nil) -> String { let code = language.map { ["jp": "ja", "zh": "zh-Hans", "zhtw": "zh-Hant"][$0] ?? $0 } - let bundle = code.flatMap { Bundle.main.path(forResource: $0, ofType: "lproj") }.flatMap(Bundle.init(path:)) ?? .main + let path = code.flatMap { Bundle.main.path(forResource: $0, ofType: "lproj") } + let bundle = path.flatMap(Bundle.init(path:)) ?? .main return bundle.localizedString(forKey: key, value: nil, table: nil) } +private func connectSocket(_ path: String) -> Int32? { + let descriptor = socket(AF_UNIX, SOCK_STREAM, 0) + guard descriptor >= 0 else { return nil } + var timeout = timeval(tv_sec: 8, tv_usec: 0) + var noSignal: Int32 = 1 + setsockopt(descriptor, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size(ofValue: timeout))) + setsockopt(descriptor, SOL_SOCKET, SO_SNDTIMEO, &timeout, socklen_t(MemoryLayout.size(ofValue: timeout))) + setsockopt(descriptor, SOL_SOCKET, SO_NOSIGPIPE, &noSignal, socklen_t(MemoryLayout.size(ofValue: noSignal))) + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + address.sun_len = UInt8(MemoryLayout.size) + path.withCString { source in + withUnsafeMutableBytes(of: &address.sun_path) { bytes in + bytes.copyMemory(from: UnsafeRawBufferPointer(start: source, count: path.utf8.count + 1)) + } + } + let connected = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + connect(descriptor, $0, socklen_t(MemoryLayout.size)) + } + } + guard connected == 0 else { + close(descriptor) + return nil + } + return descriptor +} + +private func sendRequest(_ data: Data, descriptor: Int32) -> Bool { + data.withUnsafeBytes { bytes in + var offset = 0 + while offset < bytes.count { + let count = write(descriptor, bytes.baseAddress!.advanced(by: offset), bytes.count - offset) + if count < 0 && errno == EINTR { continue } + guard count > 0 else { return false } + offset += count + } + return true + } +} + +private func readResponse(descriptor: Int32) -> Status? { + var response = Data(count: 4096) + var received = 0 + while received < 4096 { + let count = response.withUnsafeMutableBytes { bytes in + read(descriptor, bytes.baseAddress!.advanced(by: received), bytes.count - received) + } + if count < 0 && errno == EINTR { continue } + guard count > 0 else { return nil } + let end = received + count + if let newline = response[received.. Status { #if PREVIEW_HOST return Status(mode: "rule", tun: true, proxy: false, language: "en") @@ -28,59 +89,16 @@ func exchange(action: String = "status", value: String? = nil) -> Status { guard let path = socketPath(), path.utf8.count < 104 else { return unavailable("widget.unavailable") } - let fd = socket(AF_UNIX, SOCK_STREAM, 0) - guard fd >= 0 else { return unavailable("widget.unavailable") } - defer { close(fd) } - var timeout = timeval(tv_sec: 8, tv_usec: 0) - var noSignal: Int32 = 1 - setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size(ofValue: timeout))) - setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, socklen_t(MemoryLayout.size(ofValue: timeout))) - setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &noSignal, socklen_t(MemoryLayout.size(ofValue: noSignal))) - var address = sockaddr_un() - address.sun_family = sa_family_t(AF_UNIX) - address.sun_len = UInt8(MemoryLayout.size) - path.withCString { source in - withUnsafeMutableBytes(of: &address.sun_path) { bytes in - bytes.copyMemory(from: UnsafeRawBufferPointer(start: source, count: path.utf8.count + 1)) - } - } - let connected = withUnsafePointer(to: &address) { pointer in - pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { - connect(fd, $0, socklen_t(MemoryLayout.size)) - } - } - guard connected == 0 else { return unavailable("widget.unavailable") } - guard authenticPeer(fd, identifier: hostID) else { return unavailable("widget.unavailable") } + guard let descriptor = connectSocket(path) else { return unavailable("widget.unavailable") } + defer { close(descriptor) } + guard authenticPeer(descriptor, identifier: hostID) else { return unavailable("widget.unavailable") } var request = ["action": action] if let value { request["value"] = value } - guard var data = try? JSONSerialization.data(withJSONObject: request) else { return unavailable("widget.failed") } + guard var data = try? JSONSerialization.data(withJSONObject: request) else { + return unavailable("widget.failed") + } data.append(10) - let sent = data.withUnsafeBytes { bytes in - var offset = 0 - while offset < bytes.count { - let count = write(fd, bytes.baseAddress!.advanced(by: offset), bytes.count - offset) - if count < 0 && errno == EINTR { continue } - guard count > 0 else { return false } - offset += count - } - return true - } - guard sent else { return unavailable("widget.failed") } - var response = Data(count: 4096) - var received = 0 - while received < 4096 { - let count = response.withUnsafeMutableBytes { bytes in - read(fd, bytes.baseAddress!.advanced(by: received), bytes.count - received) - } - if count < 0 && errno == EINTR { continue } - guard count > 0 else { return unavailable("widget.failed") } - let end = received + count - if let newline = response[received..