refactor: Fix macOS standalone application

This commit is contained in:
世界
2026-01-05 01:21:24 +08:00
parent ef1c924c64
commit 59317f3c79
66 changed files with 2799 additions and 585 deletions
+191
View File
@@ -0,0 +1,191 @@
import Darwin
import Foundation
import os
private let PROC_PIDPATHINFO_MAXSIZE: Int32 = 4096
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "ConnectionOwnerLookup")
enum ConnectionOwnerLookup {
struct Result {
let userId: Int32
let userName: String
let processPath: String
}
static func find(
ipProtocol: Int32,
sourceAddress: String,
sourcePort: Int32,
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")
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
}
}
+28
View File
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>BasePackageIdentifier</key>
<string>$(BASE_PACKAGE_IDENTIFIER)</string>
<key>AppGroupIdentifier</key>
<string>$(APP_GROUP_IDENTIFIER)</string>
</dict>
</plist>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>io.nekohasekai.sfavt.helper</string>
<key>BundleProgram</key>
<string>Contents/Helpers/RootHelper</string>
<key>MachServices</key>
<dict>
<key>287TTNZF8L.io.nekohasekai.sfavt.helper</key>
<true/>
</dict>
<key>AssociatedBundleIdentifiers</key>
<array>
<string>io.nekohasekai.sfavt.standalone</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
</dict>
</plist>
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<false/>
<key>com.apple.security.application-groups</key>
<array>
<string>$(TeamIdentifierPrefix)$(BASE_PACKAGE_IDENTIFIER)</string>
</array>
</dict>
</plist>
+109
View File
@@ -0,0 +1,109 @@
import Foundation
import Library
import os
private let logger = Logger(category: "RootHelper")
class RootHelperService: NSObject {
private var listener: NSXPCListener?
func start() {
setupLogging()
startXPCListener()
}
private func setupLogging() {
let basePath = "/var/log/sing-box"
try? FileManager.default.createDirectory(atPath: basePath, withIntermediateDirectories: true)
let logPath = basePath + "/roothelper.log"
freopen(logPath, "a", stderr)
}
private func startXPCListener() {
let machServiceName = getMachServiceName()
listener = NSXPCListener(machServiceName: machServiceName)
listener?.delegate = self
listener?.resume()
}
private func getMachServiceName() -> String {
if let identifier = Bundle.main.object(forInfoDictionaryKey: "AppGroupIdentifier") as? String {
return "\(identifier).helper"
}
fatalError("Missing AppGroupIdentifier in Info.plist")
}
}
extension RootHelperService: NSXPCListenerDelegate {
func listener(_: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool {
let allowedBundleIDs = [
AppConfiguration.systemExtensionBundleID,
AppConfiguration.packageName + ".standalone",
]
guard XPCConnectionValidator.validateConnection(
newConnection,
teamID: AppConfiguration.teamID,
allowedBundleIDs: allowedBundleIDs
) else {
let info = XPCConnectionValidator.getConnectionInfo(newConnection)
logger.warning("Rejected XPC connection: pid=\(info.pid), bundleID=\(info.bundleID ?? "unknown"), teamID=\(info.teamID ?? "unknown")")
return false
}
let exportedInterface = NSXPCInterface(with: RootHelperProtocol.self)
RootHelperXPC.configureInterface(exportedInterface)
newConnection.exportedInterface = exportedInterface
newConnection.exportedObject = self
newConnection.resume()
return true
}
}
extension RootHelperService: RootHelperProtocol {
func findConnectionOwner(
ipProtocol: Int32,
sourceAddress: String,
sourcePort: Int32,
destinationAddress: String,
destinationPort: Int32,
reply: @escaping (ConnectionOwnerResult?, NSError?) -> Void
) {
guard let result = ConnectionOwnerLookup.find(
ipProtocol: ipProtocol,
sourceAddress: sourceAddress,
sourcePort: sourcePort,
destinationAddress: destinationAddress,
destinationPort: destinationPort
) else {
let error = NSError(domain: "RootHelper", code: -1, userInfo: [
NSLocalizedDescriptionKey: "Connection owner not found",
])
logger.error("findConnectionOwner: \(error.localizedDescription)")
reply(nil, error)
return
}
let ownerResult = ConnectionOwnerResult(
userId: result.userId,
userName: result.userName,
processPath: result.processPath
)
reply(ownerResult, nil)
}
func getWorkingDirectorySize(reply: @escaping (Int64, NSError?) -> Void) {
let size = WorkingDirectoryManager.getSize()
reply(size, nil)
}
func cleanWorkingDirectory(reply: @escaping (NSError?) -> Void) {
do {
try WorkingDirectoryManager.clean()
reply(nil)
} catch {
logger.error("cleanWorkingDirectory error: \(error.localizedDescription)")
reply(error as NSError)
}
}
}
@@ -0,0 +1,42 @@
import Foundation
import Library
enum WorkingDirectoryManager {
private static var workingDirectoryPath: String {
"/var/root/Library/Containers/\(AppConfiguration.systemExtensionBundleID)/Data/Working"
}
static func getSize() -> Int64 {
let path = workingDirectoryPath
guard FileManager.default.fileExists(atPath: path) else {
return 0
}
var totalSize: Int64 = 0
let enumerator = FileManager.default.enumerator(atPath: path)
while let file = enumerator?.nextObject() as? String {
let filePath = (path as NSString).appendingPathComponent(file)
if let attrs = try? FileManager.default.attributesOfItem(atPath: filePath),
let size = attrs[.size] as? Int64
{
totalSize += size
}
}
return totalSize
}
static func clean() throws {
let path = workingDirectoryPath
guard FileManager.default.fileExists(atPath: path) else {
return
}
let contents = try FileManager.default.contentsOfDirectory(atPath: path)
for item in contents {
let itemPath = (path as NSString).appendingPathComponent(item)
try FileManager.default.removeItem(atPath: itemPath)
}
}
}
+5
View File
@@ -0,0 +1,5 @@
import Foundation
let service = RootHelperService()
service.start()
dispatchMain()