diff --git a/Package.resolved b/Package.resolved index 975a5f6..23e1d3d 100644 --- a/Package.resolved +++ b/Package.resolved @@ -14,8 +14,26 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/OpenSwiftUIProject/OpenGraph.git", "state" : { - "revision" : "a236ef8724918dc7cd3e94e0fa492852ee0e1165", - "version" : "0.0.1" + "revision" : "f88a3c15c0385d580d61b43d8af32923ef2667f6", + "version" : "0.0.3" + } + }, + { + "identity" : "socket", + "kind" : "remoteSourceControl", + "location" : "https://github.com/OpenSwiftUIProject/Socket.git", + "state" : { + "revision" : "e81b4bd0415060e89decb90461cfc117f0e6d8d0", + "version" : "0.3.3" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system", + "state" : { + "revision" : "025bcb1165deab2e20d4eaba79967ce73013f496", + "version" : "1.2.1" } } ], diff --git a/Package.swift b/Package.swift index 3ba7385..c87ece4 100644 --- a/Package.swift +++ b/Package.swift @@ -17,15 +17,10 @@ let package = Package( .library(name: "AGDebugKit", targets: ["AGDebugKit"]), ], dependencies: [ - .package(url: "https://github.com/OpenSwiftUIProject/OpenGraph.git", from: "0.0.1"), + .package(url: "https://github.com/OpenSwiftUIProject/OpenGraph.git", from: "0.0.3"), + .package(url: "https://github.com/OpenSwiftUIProject/Socket.git", from: "0.3.3"), ], targets: [ - .executableTarget( - name: "DemoApp", - dependencies: [ - "AGDebugKit" - ] - ), .target( name: "AGDebugKit", dependencies: [ @@ -35,6 +30,21 @@ let package = Package( .enableExperimentalFeature("AccessLevelOnImport"), ] ), + // A demo app showing how to use AGDebugKit + .executableTarget( + name: "DemoApp", + dependencies: [ + "AGDebugKit", + ] + ), + // A client sending command to AGDebugServer + .executableTarget( + name: "DebugClient", + dependencies: [ + "AGDebugKit", + .product(name: "Socket", package: "Socket"), + ] + ), .testTarget( name: "AGDebugKitTests", dependencies: ["AGDebugKit"] diff --git a/README.md b/README.md index aff4e22..9c8f69b 100644 --- a/README.md +++ b/README.md @@ -2,20 +2,16 @@ A package to get debug information from the private AttributeGraph framework behind SwiftUI. -For visualization the JSON result, see [GraphConverter](https://github.com/OpenSwiftUIProject/GraphConverter) +If you need JSON result visualization, you can refer to [GraphConverter](https://github.com/OpenSwiftUIProject/GraphConverter) -For SwiftUI debug information, see [SwiftUIViewDebug](https://github.com/OpenSwiftUIProject/SwiftUIViewDebug) +If you need SwiftUI debug information, you can refer to [SwiftUIViewDebug](https://github.com/OpenSwiftUIProject/SwiftUIViewDebug) ## Example -![Demo App](Resources/demo_app.png) +![Demo App Console](Resources/demo_app.png) -![Demo JSON](Resources/demo_json.png) +![Demo App Screenshot](Resources/demo_app_2.png) -## TODO +![Demo JSON](Resources/demo_json.png) -- [ ] AGDescriptionFormat -- [ ] AGGraphGetAttributeGraph -- [ ] AGGraphDescription -- [ ] AGGraphCreate -- [ ] AGDebugServer +![Debug Client Screenshot](Resources/debug_client.png) diff --git a/Resources/debug_client.png b/Resources/debug_client.png new file mode 100644 index 0000000..afc706b Binary files /dev/null and b/Resources/debug_client.png differ diff --git a/Resources/demo_app_2.png b/Resources/demo_app_2.png new file mode 100644 index 0000000..8824f66 Binary files /dev/null and b/Resources/demo_app_2.png differ diff --git a/Sources/AGDebugKit/AGDebugKit.swift b/Sources/AGDebugKit/AGDebugKit.swift deleted file mode 100644 index ac29b82..0000000 --- a/Sources/AGDebugKit/AGDebugKit.swift +++ /dev/null @@ -1,13 +0,0 @@ -private import AttributeGraph - -/// Archive the current SwiftUI graph state to temporary directory -/// -/// After calling the method, you will see the following message on Xcode console: -/// -/// Wrote graph data to "`$NSTemporaryDirectory()`+`name`". -/// -/// You can then consume the exported JSON file directly or via -/// [GraphConverter](https://github.com/OpenSwiftUIProject/GraphConverter) -public func archiveGraph(name: String) { - name.withCString { graphArchiveJSON($0) } -} diff --git a/Sources/AGDebugKit/DebugServer/DebugServer.swift b/Sources/AGDebugKit/DebugServer/DebugServer.swift new file mode 100644 index 0000000..bf4db9c --- /dev/null +++ b/Sources/AGDebugKit/DebugServer/DebugServer.swift @@ -0,0 +1,45 @@ +// +// DebugServer.swift +// +// +// Created by Kyle on 2024/1/21. +// + +private import AttributeGraph +import Foundation + +public final class DebugServer { + private var server: UnsafeRawPointer? + + public static let shared = DebugServer() + + public func start(_ mode: Mode = .local) { + server = debugServerStart(mode.rawValue) + } + + public func stop() { + debugServerStop() + server = nil + } + + public func run(timeout: Int) { + guard let _ = server else { return } + debugServerRun(timeout) + } + + public var url: URL? { + guard let _ = server, + let url = debugServerCopyURL() as? URL + else { return nil } + return url + } + + /// A Bool value indicating whether the server has been started successfully + /// + /// To make AttributeGraph start debugServer successfully, we need to pass its internal diagnostics check. + /// In debug mode, add a symbolic breakpoint on `_ZN2AG11DebugServer5startEj`, run `start(_:)` and + /// executable `reg write w0 1` after `os_variant_has_internal_diagnostics` call. + public var startSuccess: Bool { + server != nil + } +} diff --git a/Sources/AGDebugKit/DebugServer/DebugServerCommand.swift b/Sources/AGDebugKit/DebugServer/DebugServerCommand.swift new file mode 100644 index 0000000..19a2cda --- /dev/null +++ b/Sources/AGDebugKit/DebugServer/DebugServerCommand.swift @@ -0,0 +1,18 @@ +// +// DebugServerCommand.swift +// +// +// Created by Kyle on 2024/1/22. +// + +extension DebugServer { + public enum Command: String, CaseIterable, Hashable, Identifiable { + case graphDescription = "graph/description" + case profilerStart = "profiler/start" + case profilerStop = "profiler/stop" + case profilerReset = "profiler/reset" + case profilerMark = "profiler/mark" + + public var id: String { rawValue } + } +} diff --git a/Sources/AGDebugKit/DebugServer/DebugServerMessageHeader.swift b/Sources/AGDebugKit/DebugServer/DebugServerMessageHeader.swift new file mode 100644 index 0000000..9a7c7db --- /dev/null +++ b/Sources/AGDebugKit/DebugServer/DebugServerMessageHeader.swift @@ -0,0 +1,23 @@ +// +// DebugServerMessageHeader.swift +// +// +// Created by Kyle on 2024/1/22. +// + +extension DebugServer { + public struct MessageHeader: Codable { + public var token: UInt32 + public var reserved: UInt32 + public var length: UInt32 + public var reserved2: UInt32 + public init(token: UInt32, length: UInt32) { + self.token = token + self.reserved = 0 + self.length = length + self.reserved2 = 0 + } + + public static var size: Int { MemoryLayout.size } + } +} diff --git a/Sources/AGDebugKit/DebugServer/DebugServerMode.swift b/Sources/AGDebugKit/DebugServer/DebugServerMode.swift new file mode 100644 index 0000000..1d22f28 --- /dev/null +++ b/Sources/AGDebugKit/DebugServer/DebugServerMode.swift @@ -0,0 +1,24 @@ +// +// DebugServerMode.swift +// +// +// Created by Kyle on 2024/1/22. +// + +extension DebugServer { + /// The run mode of DebugServer + /// + public struct Mode: RawRepresentable, Hashable { + public let rawValue: UInt + + public init(rawValue: UInt) { + self.rawValue = rawValue + } + + /// Localhost mode: example host is `127.0.0.1` + public static let local = Mode(rawValue: 1) + + /// Network mode: example host is `192.168.8.230` + public static let network = Mode(rawValue: 3) + } +} diff --git a/Sources/AGDebugKit/Graph.swift b/Sources/AGDebugKit/Graph.swift new file mode 100644 index 0000000..54960a9 --- /dev/null +++ b/Sources/AGDebugKit/Graph.swift @@ -0,0 +1,52 @@ +// +// Graph.swift +// +// +// Created by Kyle on 2024/1/21. +// + +private import AttributeGraph +import Foundation + +/// A wrapper class for AGGraph +public final class Graph { + let graph: UnsafeRawPointer? + + public init() { + graph = nil + } + + public init(_ pointer: UnsafeRawPointer?) { + graph = graphCreateShared(pointer) + } + + public var dict: NSDictionary? { + let options = ["format": "graph/dict"] as NSDictionary + guard let description = graphDescription(options: options) else { + return nil + } + return Unmanaged.fromOpaque(description).takeUnretainedValue() + } + + public var dot: String? { + let options = ["format": "graph/dot"] as NSDictionary + guard let graph, + let description = graphDescription(graph, options: options) + else { + return nil + } + return Unmanaged.fromOpaque(description).takeUnretainedValue() as String + } + + /// Archive the current AGGraph's state to temporary directory + /// + /// After calling the method, you will see the following message on Xcode console: + /// + /// Wrote graph data to "`$NSTemporaryDirectory()`+`name`". + /// + /// You can then consume the exported JSON file directly or via + /// [GraphConverter](https://github.com/OpenSwiftUIProject/GraphConverter) + public static func archiveGraph(name: String) { + name.withCString { graphArchiveJSON($0) } + } +} diff --git a/Sources/DebugClient/ContentView.swift b/Sources/DebugClient/ContentView.swift new file mode 100644 index 0000000..f77db87 --- /dev/null +++ b/Sources/DebugClient/ContentView.swift @@ -0,0 +1,205 @@ +// +// ContentView.swift +// +// +// Created by Kyle on 2024/1/21. +// + +import AGDebugKit +import os.log +import Socket +import SwiftUI + +@available(macOS 14.0, *) +struct ContentView: View { + private let logger = Logger(subsystem: "org.OpenSwiftUIProject.AGDebugKit", category: "DebugClient") + + @State private var started = false + @State private var selectedMode: Mode = .local + @State private var timeout = 1 + + @State private var host = "" + @State private var port: UInt16 = 0 + + @State private var socket: Socket? + private var connectServerDisable: Bool { + IPv4Address(rawValue: host) == nil || port == 0 || !started + } + + @State private var token = 0 + private var serverIODisable: Bool { + socket == nil || token == 0 + } + + @State private var selectedCommand: Command = .graphDescription + @State private var commandLocked = false + + @State private var output = "" + @State private var outputDate: Date? + + var body: some View { + Form { + Section { + Text("Status: \(started.description) ") + Text("⏺").foregroundStyle(started ? .green : .red) + HStack { + Button { + DebugServer.shared.start(selectedMode) + started = DebugServer.shared.startSuccess + if started, + let url = DebugServer.shared.url, + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) { + if let host = components.host { + self.host = host + } + if let port = components.port { + self.port = UInt16(port) + } + if let queryItems = components.queryItems, + let tokenItem = queryItems.first(where: { $0.name == "token" }), + let tokenValue = tokenItem.value, + let token = Int(tokenValue) { + self.token = token + } + } + } label: { + Text("Start debug server") + } + Spacer() + Picker(selection: $selectedMode) { + Text("local").tag(Mode.local) + Text("network").tag(Mode.network) + } label: { + Text("Mode") + } + .pickerStyle(.segmented) + .disabled(started) + } + HStack { + Button("Run debug server") { + DebugServer.shared.run(timeout: timeout) + } + Spacer() + Stepper("Timeout \(timeout)", value: $timeout) + } + + Button("Stop debug server") { + DebugServer.shared.stop() + started = DebugServer.shared.startSuccess + } + } + Section { + TextField("Host", text: $host) + TextField("Port", value: $port, formatter: NumberFormatter()) + Button("Connect server") { + Task { try await connectServer() } + } + .disabled(connectServerDisable) + } + Section { + TextField("Token", value: $token, formatter: NumberFormatter()) + Picker(selection: $selectedCommand) { + ForEach(Command.allCases) { command in + Text(command.rawValue).tag(command) + } + } label: { + Text("Command") + } + .pickerStyle(.segmented) + .disabled(commandLocked) + Button("Write Data") { + Task { + try await writeData() + commandLocked = true + } + } + .disabled(serverIODisable) + Button("Read Data") { + Task { + try await readData() + commandLocked = false + } + } + .disabled(serverIODisable) + } + Section { + Text(output) + .multilineTextAlignment(.leading) + } footer: { + if let outputDate { + Text("\(outputDate, format: .dateTime)") + } + } + } + .buttonStyle(.bordered) + .formStyle(.grouped) + } + + func connectServer() async throws { + guard let addr = IPv4Address(rawValue: host) else { + return + } + let socket = try await Socket(IPv4Protocol.tcp) + self.socket = socket + do { + try await socket.connect(to: IPv4SocketAddress(address: addr, port: port)) + } catch { + logger.error("\(error.localizedDescription, privacy: .public)") + throw error + } + } + + typealias Mode = DebugServer.Mode + typealias Header = DebugServer.MessageHeader + typealias Command = DebugServer.Command + + /// "graph/description" command is the same as `Graph().dict` + func writeData(command: Command = .graphDescription) async throws { + guard let socket else { return } + let command = ["command": command.rawValue] + let commandData = try JSONSerialization.data(withJSONObject: command) + let size = commandData.count + + let header = Header(token: UInt32(token), length: UInt32(size)) + let headerData = withUnsafePointer(to: header) { + Data(bytes: UnsafeRawPointer($0), count: Header.size) + } + do { + let byteCount = try await socket.write(headerData) + logger.info("Send: \(byteCount, privacy: .public) bytes") + } catch { + logger.error("\(error.localizedDescription, privacy: .public)") + throw error + } + do { + let byteCount = try await socket.write(commandData) + logger.info("Send: \(byteCount, privacy: .public) bytes") + } catch { + logger.error("\(error.localizedDescription, privacy: .public)") + throw error + } + } + + func readData() async throws { + guard let socket else { return } + let headerData = try await socket.read(Header.size) + + let header = headerData.withUnsafeBytes { pointer in + pointer.baseAddress! + .assumingMemoryBound(to: Header.self) + .pointee + } + guard header.token == token else { + logger.error("Token mismatch: header's token-\(header.token, privacy: .public) token-\(token)") + return + } + let size = header.length + let dictionaryData = try await socket.read(Int(size)) + let dict = try JSONSerialization.jsonObject(with: dictionaryData) as? NSDictionary + if let dict { + let dictDescription = dict.description + logger.info("Received: \(dictDescription)") + output = dictDescription + outputDate = Date.now + } + } +} diff --git a/Sources/DebugClient/DebugClientApp.swift b/Sources/DebugClient/DebugClientApp.swift new file mode 100644 index 0000000..f4af04c --- /dev/null +++ b/Sources/DebugClient/DebugClientApp.swift @@ -0,0 +1,27 @@ +// +// DebugClientApp.swift +// +// +// Created by Kyle on 2024/1/21. +// + +import SwiftUI + +@main +@available(macOS 14.0, *) +struct DebugClientApp: App { + init() { + // Fixing the App Activation from: https://www.alwaysrightinstitute.com/tows + DispatchQueue.main.async { + NSApp.setActivationPolicy(.regular) + NSApp.activate(ignoringOtherApps: true) + NSApp.windows.first?.makeKeyAndOrderFront(nil) + } + } + + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/Sources/DemoApp/AGDebugModifier.swift b/Sources/DemoApp/AGDebugModifier.swift new file mode 100644 index 0000000..3d01afc --- /dev/null +++ b/Sources/DemoApp/AGDebugModifier.swift @@ -0,0 +1,115 @@ +// +// AGDebugModifier.swift +// +// +// Created by Kyle on 2024/1/21. +// + +import AGDebugKit +import SwiftUI + +@available(macOS 13, *) +struct AGDebugItem: Equatable, Identifiable { + var url: URL + + init(name: String) { + url = URL(filePath: NSTemporaryDirectory().appending("\(name).json")) + } + + var id: String { url.absoluteString } +} + +@available(macOS 14, *) +struct AGDebugModifier: ViewModifier { + @State private var showInspector = false + @State private var items: [AGDebugItem] = [] + + func body(content: Content) -> some View { + content + .toolbar { + Button { + let item = AGDebugItem(name: Date.now.ISO8601Format()) + Graph.archiveGraph(name: item.url.lastPathComponent) + items.append(item) + } label: { + Image(systemName: "doc.badge.plus") + } + Button { + showInspector.toggle() + } label: { + Image(systemName: "sidebar.trailing") + } + } + .inspector(isPresented: $showInspector) { + inspectorView + } + } + + private var inspectorView: some View { + List { + ForEach($items) { $item in + VStack(alignment: .leading) { + Text(item.url.lastPathComponent) + Text(item.url.absoluteString) + .font(.footnote) + .foregroundStyle(.secondary) + } + .contextMenu { + Button { + openAction(item.url) + } label: { + Text("Open") + } + Button { + moveAction(item.url) + } label: { + Text("Move") + } + Button(role: .destructive) { + try? deleteAction(item.url) + } label: { + Text("Delete") + } + } + .fileMover(isPresented: $moving, file: moveURL) { result in + switch result { + case let .success(file): + item.url = file + print(file.absoluteString) + case let .failure(error): + print(error.localizedDescription) + } + } + } + } + } + + // MARK: - Open + + private func openAction(_ url: URL) { + _ = NSWorkspace.shared.open(url) + } + + // MARK: - Move + + @State private var moving = false + @State private var moveURL: URL? + private func moveAction(_ url: URL) { + moveURL = url + moving = true + } + + // MARK: - Delete + + private func deleteAction(_ url: URL) throws { + try FileManager.default.removeItem(at: url) + items.removeAll { $0.url == url } + } +} + +extension View { + @available(macOS 14, *) + func agDebug() -> some View { + modifier(AGDebugModifier()) + } +} diff --git a/Sources/DemoApp/ContentView.swift b/Sources/DemoApp/ContentView.swift new file mode 100644 index 0000000..a3c4183 --- /dev/null +++ b/Sources/DemoApp/ContentView.swift @@ -0,0 +1,18 @@ +// +// ContentView.swift +// +// +// Created by Kyle on 2024/1/21. +// + +import SwiftUI + +struct ContentView: View { + var body: some View { + Text("Hello, World!") + } +} + +#Preview { + ContentView() +} diff --git a/Sources/DemoApp/DemoApp.swift b/Sources/DemoApp/DemoApp.swift index 6bd78ca..68826a1 100644 --- a/Sources/DemoApp/DemoApp.swift +++ b/Sources/DemoApp/DemoApp.swift @@ -1,6 +1,6 @@ // // SwiftUIView.swift -// +// // // Created by Kyle on 2024/1/18. // @@ -9,18 +9,34 @@ import SwiftUI import AGDebugKit @main -@available(macOS 11.0, *) +@available(macOS 14.0, *) struct DemoApp: App { init() { - archiveGraph(name: "init.json") + // Fixing the App Activation from: https://www.alwaysrightinstitute.com/tows + DispatchQueue.main.async { + NSApp.setActivationPolicy(.regular) + NSApp.activate(ignoringOtherApps: true) + NSApp.windows.first?.makeKeyAndOrderFront(nil) + } + + // Demo test code + let emptyGraph = Graph() + if let dict = emptyGraph.dict { + print(dict) + } + let defaultGraph = Graph(nil) + if let dict = defaultGraph.dict { + print(dict) + } + if let dot = defaultGraph.dot { + print(dot) + } } var body: some Scene { WindowGroup { - Text("Demo") - .onTapGesture { - archiveGraph(name: "demo.json") - } + ContentView() + .agDebug() } } } diff --git a/Tests/AGDebugKitTests/AGDebugKitTests.swift b/Tests/AGDebugKitTests/AGDebugKitTests.swift deleted file mode 100644 index 67b07e1..0000000 --- a/Tests/AGDebugKitTests/AGDebugKitTests.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// AGDebugKitTests.swift -// -// -// Created by Kyle on 2024/1/18. -// - -import XCTest -import AGDebugKit - -final class AGDebugKitTests: XCTestCase { - func testExample() throws { - archiveGraph(name: "test.json") - } -} diff --git a/Tests/AGDebugKitTests/DebugServerTests.swift b/Tests/AGDebugKitTests/DebugServerTests.swift new file mode 100644 index 0000000..a04165c --- /dev/null +++ b/Tests/AGDebugKitTests/DebugServerTests.swift @@ -0,0 +1,22 @@ +// +// DebugServerTests.swift +// +// +// Created by Kyle on 2024/1/21. +// + +import XCTest +import AGDebugKit + +final class DebugServerTests: XCTestCase { + func testExample() throws { + let server = DebugServer.shared + XCTAssertNil(server.url) + server.start() + // Need workaround internal_diagnostics check + // breakpoint on _ZN2AG11DebugServer5startEj +// let url = try XCTUnwrap(server.url) +// print(url.absoluteString) + server.stop() + } +} diff --git a/Tests/AGDebugKitTests/GraphTests.swift b/Tests/AGDebugKitTests/GraphTests.swift new file mode 100644 index 0000000..e1fb8c0 --- /dev/null +++ b/Tests/AGDebugKitTests/GraphTests.swift @@ -0,0 +1,15 @@ +// +// GraphTests.swift +// +// +// Created by Kyle on 2024/1/18. +// + +import XCTest +import AGDebugKit + +final class GraphTests: XCTestCase { + func testArchiveGraph() throws { + Graph.archiveGraph(name: "test.json") + } +}