diff --git a/Asspp.xcodeproj/project.pbxproj b/Asspp.xcodeproj/project.pbxproj index 4537830..ef735d3 100644 --- a/Asspp.xcodeproj/project.pbxproj +++ b/Asspp.xcodeproj/project.pbxproj @@ -365,7 +365,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 20; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = HYSP93WM39; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_INCOMING_NETWORK_CONNECTIONS = YES; @@ -419,7 +419,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 20; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = HYSP93WM39; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_INCOMING_NETWORK_CONNECTIONS = YES; diff --git a/Asspp/App/Localizable.xcstrings b/Asspp/App/Localizable.xcstrings index 393c283..c94c7f1 100644 --- a/Asspp/App/Localizable.xcstrings +++ b/Asspp/App/Localizable.xcstrings @@ -33,6 +33,19 @@ } } }, + "%@\n%@ %@(%@)" : { + "comment" : "A sublabel displaying the model, OS version, and build number of a device.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@\n%2$@ %3$@(%4$@)" + } + } + }, + "shouldTranslate" : false + }, "%@ - %@" : { "localizations" : { "en" : { @@ -919,9 +932,6 @@ } } } - }, - "Finder opened." : { - }, "Grant local network permission to install apps and communicate with system services." : { "localizations" : { @@ -1159,6 +1169,24 @@ } } }, + "Installed Version: %@ (%@)" : { + "comment" : "A text displaying the installed version of a specific app. The first argument is the name of the app. The second argument is the version string of the installed app. The third argument is the bundle version string of the installed app.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Installed Version: %1$@ (%2$@)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已安装版本:%1$@ (%2$@)" + } + } + } + }, "Keyword" : { "localizations" : { "en" : { @@ -1461,9 +1489,6 @@ } } } - }, - "Path copied to clipboard." : { - }, "Pause" : { "localizations" : { @@ -1609,6 +1634,18 @@ } } }, + "Refresh Devices" : { + "comment" : "A button to refresh the list of connected devices.", + "isCommentAutoGenerated" : true, + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "刷新设备" + } + } + } + }, "Region" : { }, @@ -2152,9 +2189,6 @@ } } } - }, - "Use Finder to inspect the IPA on macOS." : { - }, "Version %@" : { "comment" : "A label displaying the version and release date of an app. The first argument is the version of the app. The second argument is the release date of the app.", diff --git a/Asspp/Asspp.entitlements b/Asspp/Asspp.entitlements index bc93ea3..9563ba2 100644 --- a/Asspp/Asspp.entitlements +++ b/Asspp/Asspp.entitlements @@ -2,6 +2,10 @@ + com.apple.security.temporary-exception.files.absolute-path.read-write + + /private/tmp/ + keychain-access-groups $(AppIdentifierPrefix)wiki.qaq.Asspp diff --git a/Asspp/Backend/DeviceCTL/DeviceCTL.swift b/Asspp/Backend/DeviceCTL/DeviceCTL.swift new file mode 100644 index 0000000..376b4cd --- /dev/null +++ b/Asspp/Backend/DeviceCTL/DeviceCTL.swift @@ -0,0 +1,287 @@ +// +// DeviceCTL.swift +// Asspp +// +// Created by luca on 09.10.2025. +// + +#if os(macOS) + import AnyCodable + import Foundation + + enum DeviceCTL { + private static let executablePath = "/Library/Developer/PrivateFrameworks/CoreDevice.framework/Versions/A/Resources/bin/devicectl" + + private static func run(_ args: [String], process: Process = Process()) -> Bool { + do { + process.executableURL = URL(filePath: executablePath) + process.arguments = args + try process.run() + process.waitUntilExit() + guard + process.terminationReason == .exit + else { + logger.error("devicectl exited with error") + return false + } + // skip checking status to read error from devicectl + return true + } catch { + logger.error("devicectl exited with error: \(error)") + return false + } + } + + private static func getJson(_ args: [String], process: Process = Process()) -> T? { + let tempo = FileManager.default.temporaryDirectory + let filename = UUID().uuidString + let temporaryJsonFile = tempo + .appendingPathComponent(filename) + .appendingPathExtension("json") + defer { + try? FileManager.default.removeItem(at: temporaryJsonFile) + } + let arguments = args + [ + "-j", "tmp/\(filename).json", // will be prefixed with sandbox path by system + "-q", + ] + guard run(arguments, process: process) else { + return nil + } + do { + let jsonData = try Data(contentsOf: temporaryJsonFile) + return try JSONDecoder().decode(T.self, from: jsonData) + } catch { + logger.error("devicectl exited with error: \(error)") + return nil + } + } + } + + // MARK: - interface + + extension DeviceCTL { + @concurrent + static func listDevices() async throws -> [Device] { + guard let result: AnyCodable = getJson(["list", "devices"]) else { + return [] + } + if let error = NSError(codable: result["error"]) { + throw error + } + let devices = result["result"]["devices"].asArray().compactMap(Device.init(_:)) + return devices + } + + static func install(ipa: URL, to device: Device, process: Process) async throws { + let arguments = [ + "device", "install", "app", + "-d", device.id, + ipa.path, + ] + guard let codable: AnyCodable = getJson(arguments, process: process) else { + return + } + + if let error = NSError(codable: codable["error"]) { + throw error + } + } + + @concurrent + static func listApps(for device: Device, bundleID: String? = nil) async throws -> [App] { + var arguments: [String] = "device info apps --include-all-apps -d \(device.id)".split(separator: " ").map(String.init(_:)) + if let bundleID { + arguments.append(contentsOf: [ + "--bundle-id", bundleID, + ]) + } + guard let result: AnyCodable = getJson(arguments) else { + return [] + } + if let error = NSError(codable: result["error"]) { + throw error + } + let apps = result["result"]["apps"].asArray().compactMap(App.init(_:)) + return apps + } + /* + @concurrent + static func getAppIcon(for app: App, device: Device, update: Bool = false) async throws -> URL { + let filename = "icon_\(device.id)" + let packageFolder = PackageManifest.packageLocation(for: app.id, version: app.version) + try? FileManager.default.createDirectory(at: packageFolder, withIntermediateDirectories: true) + let iconFile = packageFolder + .appendingPathComponent(filename) + .appendingPathExtension("png") + guard update || !FileManager.default.fileExists(atPath: iconFile.path) else { + return iconFile + } + let arguments = [ + "device", "info", "appIcon", + "-d", device.id, + "--allow-placeholder", "false", + "--app-bundle-id", app.bundleIdentifier, + "--width", "1024", "--height", "1024", + "--scale", "1", + "--destination", iconFile.path, // will NOT be prefixed + ] + guard let codable: AnyCodable = getJson(arguments) else { + return iconFile + } + + if let error = NSError(codable: codable["error"]) { + throw error + } + return iconFile + } + */ + } + + extension NSError { + convenience init?(codable: AnyCodable) { + guard + let code = codable["code"].value as? Int, + let domain = codable["domain"].value as? String + else { + return nil + } + self.init(domain: domain, code: code, userInfo: [ + NSLocalizedDescriptionKey: codable["userInfo"][NSLocalizedDescriptionKey]["string"].value, + NSUnderlyingErrorKey: NSError(codable: codable["userInfo"]["NSUnderlyingError"]["error"]) as Any, + ]) + } + } + + // MARK: - list devices + + extension DeviceCTL { + enum DeviceType: String { + case iPhone, iPad, appleWatch // unsure of vision pro + } + + struct Device: Identifiable, Hashable, Sendable { + init(id: String, name: String, model: String, type: DeviceCTL.DeviceType, osVersionNumber: String, osBuildUpdate: String, lastConnectionDate: String) { + self.id = id + self.name = name + self.model = model + self.type = type + self.osVersionNumber = osVersionNumber + self.osBuildUpdate = osBuildUpdate + self.lastConnectionDate = ISO8601DateFormatter().date(from: lastConnectionDate) ?? Date() + } + + let id: String + let name: String + let model: String + let type: DeviceType + let osVersionNumber: String // 26.0 + let osBuildUpdate: String // 23A341 + let lastConnectionDate: Date + + init?(_ codable: AnyCodable) { + let deviceProperties = codable["deviceProperties"] + let connectionProperties = codable["connectionProperties"] + let hardwareProperties = codable["hardwareProperties"] + guard + connectionProperties["tunnelState"] != "unavailable", + connectionProperties["pairingState"] == "paired", + let lastConnectionDate = connectionProperties["lastConnectionDate"].value as? String, + let id = codable["identifier"].value as? String, + let name = deviceProperties["name"].value as? String, + let model = hardwareProperties["marketingName"].value as? String, + let type = (hardwareProperties["deviceType"].value as? String).flatMap(DeviceType.init(rawValue:)), + let osVersionNumber = deviceProperties["osVersionNumber"].value as? String, + let osBuildUpdate = deviceProperties["osBuildUpdate"].value as? String + else { + return nil + } + self.init(id: id, name: name, model: model, type: type, osVersionNumber: osVersionNumber, osBuildUpdate: osBuildUpdate, lastConnectionDate: lastConnectionDate) + } + } + } + + // MARK: - device info apps --include-all-apps + + extension DeviceCTL { + struct App: Identifiable, Hashable, Codable, Sendable { + let id: String + let name: String + let bundleIdentifier: String + let version: String + let bundleVersion: String + let appClip: Bool + let builtByDeveloper: Bool + let defaultApp: Bool + let hidden: Bool + let internalApp: Bool + let removable: Bool + let url: String + + init(id: String, name: String, bundleIdentifier: String, version: String, bundleVersion: String, appClip: Bool, builtByDeveloper: Bool, defaultApp: Bool, hidden: Bool, internalApp: Bool, removable: Bool, url: String) { + self.id = id + self.name = name + self.bundleIdentifier = bundleIdentifier + self.version = version + self.bundleVersion = bundleVersion + self.appClip = appClip + self.builtByDeveloper = builtByDeveloper + self.defaultApp = defaultApp + self.hidden = hidden + self.internalApp = internalApp + self.removable = removable + self.url = url + } + + init?(_ codable: AnyCodable) { + guard + let bundleIdentifier = codable["bundleIdentifier"].value as? String, + let name = codable["name"].value as? String, + let version = codable["version"].value as? String, + let bundleVersion = codable["bundleVersion"].value as? String, + let appClip = codable["appClip"].value as? Bool, + let builtByDeveloper = codable["builtByDeveloper"].value as? Bool, + let defaultApp = codable["defaultApp"].value as? Bool, + let hidden = codable["hidden"].value as? Bool, + let internalApp = codable["internalApp"].value as? Bool, + let removable = codable["removable"].value as? Bool, + let url = codable["url"].value as? String + else { + return nil + } + self.init( + id: bundleIdentifier, + name: name, + bundleIdentifier: bundleIdentifier, + version: version, + bundleVersion: bundleVersion, + appClip: appClip, + builtByDeveloper: builtByDeveloper, + defaultApp: defaultApp, + hidden: hidden, + internalApp: internalApp, + removable: removable, + url: url + ) + } + } + } + + private extension AnyCodable { + subscript(key: String) -> AnyCodable { + guard let dictionary = value as? [String: Any] else { + return AnyCodable(nilLiteral: ()) + } + return AnyCodable(dictionary[key]) + } + + func decodeAs(as type: T.Type) throws -> T? { + let data = try JSONEncoder().encode(self) + return try JSONDecoder().decode(type.self, from: data) + } + + func asArray() -> [AnyCodable] { + (value as? [Any])?.map(AnyCodable.init(_:)) ?? [] + } + } +#endif diff --git a/Asspp/Backend/DeviceCTL/DeviceManager.swift b/Asspp/Backend/DeviceCTL/DeviceManager.swift new file mode 100644 index 0000000..24808fd --- /dev/null +++ b/Asspp/Backend/DeviceCTL/DeviceManager.swift @@ -0,0 +1,101 @@ +// +// DeviceManager.swift +// Asspp +// +// Created by luca on 09.10.2025. +// + +#if os(macOS) + import ApplePackage + import Foundation + + @Observable + class DeviceManager { + static let this = DeviceManager() + + var hint: Hint? + + var installingProcess: Process? + var devices = [DeviceCTL.Device]() + var selectedDeviceID: String? + + var selectedDevice: DeviceCTL.Device? { + devices.first(where: { $0.id == selectedDeviceID }) + } + + func loadDevices() async { + resetError() + do { + devices = try await DeviceCTL.listDevices() + .filter { [.iPad, .iPhone].contains($0.type) } + .sorted(by: { $0.lastConnectionDate > $1.lastConnectionDate }) + if selectedDeviceID == nil { + selectedDeviceID = devices.first?.id + } + } catch { + updateError(error) + } + } + + func install(ipa: URL, to device: DeviceCTL.Device) async -> Bool { + resetError() + let process = Process() + installingProcess = process + defer { installingProcess = nil } + do { + try await DeviceCTL.install(ipa: ipa, to: device, process: process) + return true + } catch { + updateError(error) + return false + } + } + + func loadApps(for device: DeviceCTL.Device, bundleID: String? = nil) async -> [DeviceCTL.App] { + resetError() + do { + let apps = try await DeviceCTL.listApps(for: device, bundleID: bundleID) + .filter { !$0.hidden && !$0.internalApp && !$0.appClip && $0.removable } + return apps + } catch { + updateError(error) + return [] + } + } + + private func resetError() { + hint = nil + } + + private func updateError(_ error: Error) { + let errorMessages = [error.localizedDescription] + (error as NSError).underlyingErrors.enumerated().map { i, e in + Array(repeating: " ", count: i).joined() + "▸" + e.localizedDescription + } + hint = .init(message: errorMessages.joined(separator: "\n"), color: .red) + } + } + + extension DeviceCTL.DeviceType { + var symbol: String { + switch self { + case .iPhone: + return "iphone" + case .iPad: + return "ipad" + case .appleWatch: + return "applewatch" + } + } + + var osVersionPrefix: String { + switch self { + case .iPhone: + return "iOS" + case .iPad: + return "iPadOS" + case .appleWatch: + return "watchOS" + } + } + } +#endif diff --git a/Asspp/Interface/Download/DeviceCTLInstallSection.swift b/Asspp/Interface/Download/DeviceCTLInstallSection.swift new file mode 100644 index 0000000..cd41353 --- /dev/null +++ b/Asspp/Interface/Download/DeviceCTLInstallSection.swift @@ -0,0 +1,122 @@ +// +// DeviceCTLInstallSection.swift +// Asspp +// +// Created by luca on 09.10.2025. +// + +#if os(macOS) + import ApplePackage + import SwiftUI + + struct DeviceCTLInstallSection: View { + @State var dm = DeviceManager.this + @State var installed: DeviceCTL.App? + @State var isLoading = false + @State var wiggle: Bool = false + @State var installSuccess = false + let ipaFile: URL + let software: Software + var body: some View { + if !dm.devices.isEmpty { + section + } + } + + var section: some View { + Section { + installerContent + } header: { + HStack { + Label("Control", systemImage: installSuccess ? "checkmark" : dm.selectedDevice?.type.symbol ?? "") + .contentTransition(.symbolEffect(.replace)) + Spacer() + + if dm.installingProcess != nil || isLoading { + ProgressView() + .progressViewStyle(.circular) + .controlSize(.small) + } + if !dm.devices.isEmpty { + Button { + wiggle.toggle() + reloadDevice() + } label: { + Label("Refresh Devices", systemImage: "arrow.clockwise") + .symbolEffect(.wiggle, options: .nonRepeating, value: wiggle) + } + .buttonStyle(.borderless) + .disabled(isLoading || dm.installingProcess != nil) + } + } + } footer: { + VStack { + if let installed { + Text("Installed Version: \(installed.version) (\(installed.bundleVersion))") + } + + if let hint = dm.hint { + Text(hint.message) + .foregroundColor(hint.color) + } + } + } + } + + var installerContent: some View { + Picker(selection: $dm.selectedDeviceID) { + ForEach(dm.devices) { d in + // Using an empty Button as the Picker row to set subtitles + Button {} label: { + Text(d.name) + Text(String(localized: "\(d.model)\n\(d.type.osVersionPrefix) \(d.osVersionNumber)(\(d.osBuildUpdate))")) + } + .tag(d.id) + } + } label: { + HStack { + Button(dm.installingProcess != nil ? "Cancel" : "Install") { + installOrStop() + } + .disabled(dm.selectedDevice == nil || isLoading || dm.hint?.isRed == true) + } + } + .task(id: dm.selectedDevice?.id) { + await fetchInstalledApp() + } + } + + func installOrStop() { + if let process = dm.installingProcess { + process.terminate() + return + } + guard let device = dm.selectedDevice else { return } + Task { + installSuccess = await dm.install(ipa: ipaFile, to: device) + await fetchInstalledApp() + try? await Task.sleep(for: .seconds(1)) + installSuccess = false // hide symbol + } + } + + func fetchInstalledApp() async { + guard let device = dm.selectedDevice else { + return + } + installed = nil + isLoading = true + installed = await dm.loadApps(for: device, bundleID: software.bundleID).first + isLoading = false + } + + func reloadDevice() { + Task { + isLoading = true + installed = nil + await dm.loadDevices() + await fetchInstalledApp() + } + } + } +#endif diff --git a/Asspp/Interface/Download/PackageView.swift b/Asspp/Interface/Download/PackageView.swift index 8da8a94..f7e560a 100644 --- a/Asspp/Interface/Download/PackageView.swift +++ b/Asspp/Interface/Download/PackageView.swift @@ -28,7 +28,7 @@ struct PackageView: View { @State var error: String = "" #endif #if os(macOS) - @State private var macControlMessage = "" + @State private var copied = false #endif @StateObject var vm = AppStore.this @@ -45,6 +45,9 @@ struct PackageView: View { } if pkg.completed { + #if os(macOS) + DeviceCTLInstallSection(ipaFile: url, software: pkg.package.software) + #endif #if os(iOS) Section { Button("Install") { @@ -81,29 +84,41 @@ struct PackageView: View { .foregroundStyle(.red) } } - #elseif os(macOS) - Section { - Button("Show in Finder") { - NSWorkspace.shared.activateFileViewerSelecting([url]) - macControlMessage = String(localized: "Finder opened.") - } - Button("Copy File Path") { - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - pasteboard.setString(url.path, forType: .string) - macControlMessage = String(localized: "Path copied to clipboard.") - } - } header: { - Text("Control") - } footer: { - Text(macControlMessage.isEmpty ? String(localized: "Use Finder to inspect the IPA on macOS.") : macControlMessage) - } #endif Section { NavigationLink("Content Viewer") { FileListView(packageURL: pkg.targetLocation) } + #if os(macOS) + HStack { + Button { + NSWorkspace.shared.activateFileViewerSelecting([url]) + } label: { + Text(url.path) + .multilineTextAlignment(.leading) + } + .help("Show in Finder") + .buttonStyle(.borderless) + .tint(.accentColor) + Spacer() + Button { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(url.path, forType: .string) + copied = true + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + copied = false + } + } label: { + Image(systemName: copied ? "checkmark" : "document.on.document") + .contentTransition(.symbolEffect(.replace)) + } + .help("Copy File Path") + .buttonStyle(.borderless) + .tint(.accentColor) + } + #endif } header: { Text("Analysis") } footer: { @@ -162,5 +177,10 @@ struct PackageView: View { } } .navigationTitle(pkg.package.software.name) + .task { + #if os(macOS) + await DeviceManager.this.loadDevices() + #endif + } } } diff --git a/Asspp/Interface/Search/ProductVersionView.swift b/Asspp/Interface/Search/ProductVersionView.swift index c66ffa1..5074a32 100644 --- a/Asspp/Interface/Search/ProductVersionView.swift +++ b/Asspp/Interface/Search/ProductVersionView.swift @@ -14,8 +14,7 @@ struct ProductVersionView: View { @StateObject var dvm = Downloads.this @State var obtainDownloadURL: Bool = false - @State var hint: String = "" - @State var hintColor: Color? + @State var hint: Hint? var body: some View { if let req = dvm.downloadRequest(forArchive: package) { @@ -41,9 +40,9 @@ struct ProductVersionView: View { } } - if !hint.isEmpty { - Text(hint) - .foregroundColor(hintColor) + if let hint { + Text(hint.message) + .foregroundColor(hint.color) } } } @@ -59,14 +58,12 @@ struct ProductVersionView: View { await MainActor.run { obtainDownloadURL = false - hint = String(localized: "Download Requested") - hintColor = nil + hint = Hint(message: String(localized: "Download Requested"), color: nil) } } catch { DispatchQueue.main.async { obtainDownloadURL = false - hint = String(localized: "Unable to retrieve download url, please try again later.") + "\n" + error.localizedDescription - hintColor = .red + hint = Hint(message: String(localized: "Unable to retrieve download url, please try again later.") + "\n" + error.localizedDescription, color: .red) } } } diff --git a/Asspp/Interface/Search/ProductView.swift b/Asspp/Interface/Search/ProductView.swift index a3e14e8..ea8adf5 100644 --- a/Asspp/Interface/Search/ProductView.swift +++ b/Asspp/Interface/Search/ProductView.swift @@ -37,8 +37,7 @@ struct ProductView: View { @State var licenseHint: String = "" @State var acquiringLicense = false @State var showLicenseAlert = false - @State var hint: String = "" - @State var hintColor: Color? + @State var hint: Hint? let sizeFormatter: ByteCountFormatter = { let formatter = ByteCountFormatter() @@ -188,11 +187,11 @@ struct ProductView: View { } header: { Text("Download") } footer: { - if hint.isEmpty { - Text("Package can be installed later in download page.") + if let hint { + Text(hint.message) + .foregroundColor(hint.color) } else { - Text(hint) - .foregroundColor(hintColor) + Text("Package can be installed later in download page.") } } } @@ -205,8 +204,7 @@ struct ProductView: View { try await dvm.startDownload(for: archive.package, accountID: account.id) await MainActor.run { obtainDownloadURL = false - hint = String(localized: "Download Requested") - hintColor = nil + hint = Hint(message: String(localized: "Download Requested"), color: nil) showDownloadPage = true } } catch ApplePackageError.licenseRequired where archive.package.software.price == 0 && !acquiringLicense { @@ -217,8 +215,7 @@ struct ProductView: View { } catch { DispatchQueue.main.async { obtainDownloadURL = false - hint = String(localized: "Unable to retrieve download url, please try again later.") + "\n" + error.localizedDescription - hintColor = .red + hint = Hint(message: String(localized: "Unable to retrieve download url, please try again later.") + "\n" + error.localizedDescription, color: .red) } } } diff --git a/Asspp/Interface/Utils/Hint.swift b/Asspp/Interface/Utils/Hint.swift new file mode 100644 index 0000000..6b81094 --- /dev/null +++ b/Asspp/Interface/Utils/Hint.swift @@ -0,0 +1,19 @@ +// +// Hint.swift +// Asspp +// +// Created by luca on 09.10.2025. +// + +import SwiftUI + +struct Hint: Equatable, Hashable { + let message: String + let color: Color? +} + +extension Hint { + var isRed: Bool { + color == .red + } +}