From 6a383d5e470f489d3e35aa186ba315fcfade1045 Mon Sep 17 00:00:00 2001 From: Lars <134181853+bo2themax@users.noreply.github.com> Date: Thu, 9 Oct 2025 21:43:33 +0200 Subject: [PATCH 1/8] Reuse Hint --- .../Interface/Search/ProductVersionView.swift | 15 ++++++--------- Asspp/Interface/Search/ProductView.swift | 17 +++++++---------- Asspp/Interface/Utils/Hint.swift | 19 +++++++++++++++++++ 3 files changed, 32 insertions(+), 19 deletions(-) create mode 100644 Asspp/Interface/Utils/Hint.swift 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 + } +} From 7533b2a8ddb2e3f161becde312e5084b76ba0d7e Mon Sep 17 00:00:00 2001 From: Lars <134181853+bo2themax@users.noreply.github.com> Date: Thu, 9 Oct 2025 22:38:38 +0200 Subject: [PATCH 2/8] Move finder operation to Analysis --- Asspp/App/Localizable.xcstrings | 9 ---- Asspp/Interface/Download/PackageView.swift | 48 ++++++++++++++-------- 2 files changed, 30 insertions(+), 27 deletions(-) diff --git a/Asspp/App/Localizable.xcstrings b/Asspp/App/Localizable.xcstrings index 393c283..40df1ec 100644 --- a/Asspp/App/Localizable.xcstrings +++ b/Asspp/App/Localizable.xcstrings @@ -919,9 +919,6 @@ } } } - }, - "Finder opened." : { - }, "Grant local network permission to install apps and communicate with system services." : { "localizations" : { @@ -1461,9 +1458,6 @@ } } } - }, - "Path copied to clipboard." : { - }, "Pause" : { "localizations" : { @@ -2152,9 +2146,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/Interface/Download/PackageView.swift b/Asspp/Interface/Download/PackageView.swift index 8da8a94..22ed9ec 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 @@ -81,29 +81,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: { From 48e4817372d2a508ca2cba15f33b5628dfdc6f3a Mon Sep 17 00:00:00 2001 From: Lars <134181853+bo2themax@users.noreply.github.com> Date: Thu, 9 Oct 2025 22:42:19 +0200 Subject: [PATCH 3/8] Install apps with `devicectl` on macOS --- Asspp.xcodeproj/project.pbxproj | 4 +- Asspp/App/Localizable.xcstrings | 43 +++ Asspp/Asspp.entitlements | 4 + Asspp/Backend/DeviceCTL/DeviceCTL.swift | 287 ++++++++++++++++++ Asspp/Backend/DeviceCTL/DeviceManager.swift | 99 ++++++ .../Download/DeviceCTLInstallSection.swift | 116 +++++++ Asspp/Interface/Download/PackageView.swift | 8 + 7 files changed, 559 insertions(+), 2 deletions(-) create mode 100644 Asspp/Backend/DeviceCTL/DeviceCTL.swift create mode 100644 Asspp/Backend/DeviceCTL/DeviceManager.swift create mode 100644 Asspp/Interface/Download/DeviceCTLInstallSection.swift 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 40df1ec..705b49b 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" : { @@ -888,6 +901,24 @@ } } }, + "Existing Version: %@ (%@)" : { + "comment" : "A text displaying the version and bundle ID of the installed app.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Existing Version: %1$@ (%2$@)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已安装版本:%1$@ (%2$@)" + } + } + } + }, "Failed" : { "localizations" : { "en" : { @@ -1603,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" : { }, 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..e54fa7a --- /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", // with 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, // with 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..f5f106c --- /dev/null +++ b/Asspp/Backend/DeviceCTL/DeviceManager.swift @@ -0,0 +1,99 @@ +// +// 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 { + resetError() + let process = Process() + installingProcess = process + defer { installingProcess = nil } + do { + try await DeviceCTL.install(ipa: ipa, to: device, process: process) + } catch { + updateError(error) + } + } + + 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..97aff5d --- /dev/null +++ b/Asspp/Interface/Download/DeviceCTLInstallSection.swift @@ -0,0 +1,116 @@ +// +// 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 + let ipaFile: URL + let software: Software + var body: some View { + if !dm.devices.isEmpty { + section + } + } + + var section: some View { + Section { + installerContent + } header: { + HStack { + Text("Control") + Spacer() + 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("Existing 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 + Button {} label: { // little hack to show rich menu + 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) + + if dm.installingProcess != nil || isLoading { + ProgressView() + .progressViewStyle(.circular) + .controlSize(.small) + } + } + } + .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 { + await dm.install(ipa: ipaFile, to: device) + } + } + + 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 22ed9ec..f7e560a 100644 --- a/Asspp/Interface/Download/PackageView.swift +++ b/Asspp/Interface/Download/PackageView.swift @@ -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") { @@ -174,5 +177,10 @@ struct PackageView: View { } } .navigationTitle(pkg.package.software.name) + .task { + #if os(macOS) + await DeviceManager.this.loadDevices() + #endif + } } } From 6fb18b387721bccbeaa6c1e4cba859109b7966e4 Mon Sep 17 00:00:00 2001 From: Lars <134181853+bo2themax@users.noreply.github.com> Date: Thu, 9 Oct 2025 23:06:16 +0200 Subject: [PATCH 4/8] Add install success hint --- Asspp/Backend/DeviceCTL/DeviceManager.swift | 4 +++- .../Download/DeviceCTLInstallSection.swift | 21 ++++++++++++------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/Asspp/Backend/DeviceCTL/DeviceManager.swift b/Asspp/Backend/DeviceCTL/DeviceManager.swift index f5f106c..24808fd 100644 --- a/Asspp/Backend/DeviceCTL/DeviceManager.swift +++ b/Asspp/Backend/DeviceCTL/DeviceManager.swift @@ -37,15 +37,17 @@ } } - func install(ipa: URL, to device: DeviceCTL.Device) async { + 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 } } diff --git a/Asspp/Interface/Download/DeviceCTLInstallSection.swift b/Asspp/Interface/Download/DeviceCTLInstallSection.swift index 97aff5d..3fa8434 100644 --- a/Asspp/Interface/Download/DeviceCTLInstallSection.swift +++ b/Asspp/Interface/Download/DeviceCTLInstallSection.swift @@ -14,6 +14,7 @@ @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 { @@ -27,8 +28,15 @@ installerContent } header: { HStack { - Text("Control") + 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() @@ -70,12 +78,6 @@ installOrStop() } .disabled(dm.selectedDevice == nil || isLoading || dm.hint?.isRed == true) - - if dm.installingProcess != nil || isLoading { - ProgressView() - .progressViewStyle(.circular) - .controlSize(.small) - } } } .task(id: dm.selectedDevice?.id) { @@ -90,7 +92,10 @@ } guard let device = dm.selectedDevice else { return } Task { - await dm.install(ipa: ipaFile, to: device) + installSuccess = await dm.install(ipa: ipaFile, to: device) + await fetchInstalledApp() + try? await Task.sleep(for: .seconds(1)) + installSuccess = false // hide symbol } } From 8cbc8f0845c5fc95ec59616b1838bd3e2baf0824 Mon Sep 17 00:00:00 2001 From: Lars <134181853+bo2themax@users.noreply.github.com> Date: Thu, 9 Oct 2025 23:09:47 +0200 Subject: [PATCH 5/8] Update texts --- Asspp/App/Localizable.xcstrings | 36 +++++++++---------- .../Download/DeviceCTLInstallSection.swift | 2 +- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/Asspp/App/Localizable.xcstrings b/Asspp/App/Localizable.xcstrings index 705b49b..c94c7f1 100644 --- a/Asspp/App/Localizable.xcstrings +++ b/Asspp/App/Localizable.xcstrings @@ -901,24 +901,6 @@ } } }, - "Existing Version: %@ (%@)" : { - "comment" : "A text displaying the version and bundle ID of the installed app.", - "isCommentAutoGenerated" : true, - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Existing Version: %1$@ (%2$@)" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "已安装版本:%1$@ (%2$@)" - } - } - } - }, "Failed" : { "localizations" : { "en" : { @@ -1187,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" : { diff --git a/Asspp/Interface/Download/DeviceCTLInstallSection.swift b/Asspp/Interface/Download/DeviceCTLInstallSection.swift index 3fa8434..c8f28a5 100644 --- a/Asspp/Interface/Download/DeviceCTLInstallSection.swift +++ b/Asspp/Interface/Download/DeviceCTLInstallSection.swift @@ -52,7 +52,7 @@ } footer: { VStack { if let installed { - Text("Existing Version: \(installed.version) (\(installed.bundleVersion))") + Text("Installed Version: \(installed.version) (\(installed.bundleVersion))") } if let hint = dm.hint { From 880dfec04604cd0f0f93b7be00da15b51d9ed20e Mon Sep 17 00:00:00 2001 From: Xiangbao Meng <134181853+bo2themax@users.noreply.github.com> Date: Fri, 10 Oct 2025 00:03:29 +0200 Subject: [PATCH 6/8] Update DeviceCTL.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Asspp/Backend/DeviceCTL/DeviceCTL.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Asspp/Backend/DeviceCTL/DeviceCTL.swift b/Asspp/Backend/DeviceCTL/DeviceCTL.swift index e54fa7a..5093c84 100644 --- a/Asspp/Backend/DeviceCTL/DeviceCTL.swift +++ b/Asspp/Backend/DeviceCTL/DeviceCTL.swift @@ -42,7 +42,7 @@ try? FileManager.default.removeItem(at: temporaryJsonFile) } let arguments = args + [ - "-j", "tmp/\(filename).json", // with be prefixed with sandbox path by system + "-j", "tmp/\(filename).json", // will be prefixed with sandbox path by system "-q", ] guard run(arguments, process: process) else { From 4e85ccfdcbc9e2695a479d3e358bcd4c21ca7ad7 Mon Sep 17 00:00:00 2001 From: Xiangbao Meng <134181853+bo2themax@users.noreply.github.com> Date: Fri, 10 Oct 2025 00:03:41 +0200 Subject: [PATCH 7/8] Update DeviceCTL.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Asspp/Backend/DeviceCTL/DeviceCTL.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Asspp/Backend/DeviceCTL/DeviceCTL.swift b/Asspp/Backend/DeviceCTL/DeviceCTL.swift index 5093c84..376b4cd 100644 --- a/Asspp/Backend/DeviceCTL/DeviceCTL.swift +++ b/Asspp/Backend/DeviceCTL/DeviceCTL.swift @@ -124,7 +124,7 @@ "--app-bundle-id", app.bundleIdentifier, "--width", "1024", "--height", "1024", "--scale", "1", - "--destination", iconFile.path, // with NOT be prefixed + "--destination", iconFile.path, // will NOT be prefixed ] guard let codable: AnyCodable = getJson(arguments) else { return iconFile From 07b8bdf3585c0c133ac9d251bff962a4bff77b29 Mon Sep 17 00:00:00 2001 From: Xiangbao Meng <134181853+bo2themax@users.noreply.github.com> Date: Fri, 10 Oct 2025 00:05:56 +0200 Subject: [PATCH 8/8] Update DeviceCTLInstallSection.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Asspp/Interface/Download/DeviceCTLInstallSection.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Asspp/Interface/Download/DeviceCTLInstallSection.swift b/Asspp/Interface/Download/DeviceCTLInstallSection.swift index c8f28a5..cd41353 100644 --- a/Asspp/Interface/Download/DeviceCTLInstallSection.swift +++ b/Asspp/Interface/Download/DeviceCTLInstallSection.swift @@ -66,7 +66,8 @@ var installerContent: some View { Picker(selection: $dm.selectedDeviceID) { ForEach(dm.devices) { d in - Button {} label: { // little hack to show rich menu + // 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))")) }