diff --git a/Asspp.xcodeproj/project.pbxproj b/Asspp.xcodeproj/project.pbxproj index 9deb147..c83779c 100644 --- a/Asspp.xcodeproj/project.pbxproj +++ b/Asspp.xcodeproj/project.pbxproj @@ -19,14 +19,12 @@ 50D44D3F2C3FA59500CF6A69 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 50D44D3E2C3FA59500CF6A69 /* Kingfisher */; }; 50D44D512C3FC85F00CF6A69 /* AnyCodable in Frameworks */ = {isa = PBXBuildFile; productRef = 50D44D502C3FC85F00CF6A69 /* AnyCodable */; }; 50E28B812C40BA52007891E0 /* ColorfulX in Frameworks */ = {isa = PBXBuildFile; productRef = 50E28B802C40BA52007891E0 /* ColorfulX */; }; - 50E6C5682C40CB7500460DB5 /* Certificates in Resources */ = {isa = PBXBuildFile; fileRef = 50E6C5672C40CB7500460DB5 /* Certificates */; }; 80CD64ED2C42AA2C0073E206 /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = 80CD64EC2C42AA2C0073E206 /* KeychainAccess */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ 507952E92E78740900954B8A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 50D44D022C3F91F700CF6A69 /* Asspp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Asspp.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 50E6C5672C40CB7500460DB5 /* Certificates */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Certificates; path = Resources/Certificates; sourceTree = SOURCE_ROOT; }; 80CD64EA2C42A7E80073E206 /* Asspp.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Asspp.entitlements; sourceTree = ""; }; /* End PBXFileReference section */ @@ -90,20 +88,19 @@ name = Products; sourceTree = ""; }; - 50D44D042C3F91F700CF6A69 /* Asspp */ = { - isa = PBXGroup; - children = ( - 507952E92E78740900954B8A /* Info.plist */, - 80CD64EA2C42A7E80073E206 /* Asspp.entitlements */, - 5081AB832E77CD1800F6E9F0 /* App */, - 5081AB952E77CD1800F6E9F0 /* Backend */, - 5081AB982E77CD1800F6E9F0 /* Extension */, - 5081ABAF2E77CD1800F6E9F0 /* Interface */, - 50E6C5672C40CB7500460DB5 /* Certificates */, - ); - path = Asspp; - sourceTree = ""; - }; + 50D44D042C3F91F700CF6A69 /* Asspp */ = { + isa = PBXGroup; + children = ( + 507952E92E78740900954B8A /* Info.plist */, + 80CD64EA2C42A7E80073E206 /* Asspp.entitlements */, + 5081AB832E77CD1800F6E9F0 /* App */, + 5081AB952E77CD1800F6E9F0 /* Backend */, + 5081AB982E77CD1800F6E9F0 /* Extension */, + 5081ABAF2E77CD1800F6E9F0 /* Interface */, + ); + path = Asspp; + sourceTree = ""; + }; 50D44D172C3F92A100CF6A69 /* Frameworks */ = { isa = PBXGroup; children = ( @@ -200,14 +197,13 @@ /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ - 50D44D002C3F91F700CF6A69 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 50E6C5682C40CB7500460DB5 /* Certificates in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; + 50D44D002C3F91F700CF6A69 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ diff --git a/Asspp/App/Localizable.xcstrings b/Asspp/App/Localizable.xcstrings index 207a584..2556a19 100644 --- a/Asspp/App/Localizable.xcstrings +++ b/Asspp/App/Localizable.xcstrings @@ -2375,7 +2375,7 @@ } } }, - "Your accounts are saved in your Keychain and will be synced across devices with the same iCloud account signed in." : { + "Your accounts are saved in your Keychain and will be synced across devices with the same iCloud account signed in." : { "localizations" : { "en" : { "stringUnit" : { @@ -2389,6 +2389,406 @@ "value" : "您的账户保存在钥匙串中,并将在登录相同 iCloud 账户的设备之间同步。" } } + }, + "QR Installer" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "QR Installer" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "二维码安装" + } + } + } + }, + "Step" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Step" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "步骤" + } + } + } + }, + "Certificate" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Certificate" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "证书" + } + } + } + }, + "Install" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Install" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "安装" + } + } + } + }, + "Suggested" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suggested" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "建议" + } + } + } + }, + "Manual" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manual" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "手动" + } + } + } + }, + "Select Host" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Select Host" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择主机" + } + } + } + }, + "Mode" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mode" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "模式" + } + } + } + }, + "No network addresses detected. Make sure Wi-Fi or Ethernet is connected." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No network addresses detected. Make sure Wi-Fi or Ethernet is connected." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未检测到网络地址。请确认已连接 Wi-Fi 或以太网。" + } + } + } + }, + "Refresh Hosts" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Refresh Hosts" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "刷新主机" + } + } + } + }, + "Hostname or IP" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hostname or IP" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "主机名或 IP" + } + } + } + }, + "Enter an address reachable from the device you plan to install on." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enter an address reachable from the device you plan to install on." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请输入目标设备可以访问的地址。" + } + } + } + }, + "Step 1: Install Certificate" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Step 1: Install Certificate" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "步骤一:安装证书" + } + } + } + }, + "Scan this code with the target device to download the installer certificate, then mark it as trusted in Settings." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scan this code with the target device to download the installer certificate, then mark it as trusted in Settings." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用目标设备扫描此二维码以下载安装证书,并在“设置”中将其标记为已信任。" + } + } + } + }, + "Current host" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Current host" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "当前主机" + } + } + } + }, + "Open" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "打开" + } + } + } + }, + "Copy" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copy" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "复制" + } + } + } + }, + "After installing, go to Settings → General → About → Certificate Trust Settings and enable full trust for the certificate." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "After installing, go to Settings → General → About → Certificate Trust Settings and enable full trust for the certificate." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "安装后,请前往“设置”→“通用”→“关于本机”→“证书信任设置”,并启用对此证书的完全信任。" + } + } + } + }, + "Step 2: Install Application" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Step 2: Install Application" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "步骤二:安装应用" + } + } + } + }, + "Waiting for device to scan the QR code" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Waiting for device to scan the QR code" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "等待设备扫描二维码" + } + } + } + }, + "Sending manifest…" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sending manifest…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在发送 manifest…" + } + } + } + }, + "Transferring payload…" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transferring payload…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在传输安装包…" + } + } + } + }, + "Install completed" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Install completed" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "安装完成" + } + } + } + }, + "Keep this screen visible while the install is in progress." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keep this screen visible while the install is in progress." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "安装过程中请保持此屏幕处于显示状态。" + } + } + } + }, + "QR code" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "QR code" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "二维码" + } + } + } } } }, diff --git a/Asspp/App/main.swift b/Asspp/App/main.swift index 532273d..3d2636b 100644 --- a/Asspp/App/main.swift +++ b/Asspp/App/main.swift @@ -69,9 +69,19 @@ _ = ProcessInfo.processInfo.hostName DiggerManager.shared.maxConcurrentTasksCount = 3 DiggerManager.shared.startDownloadImmediately = true +do { + try InstallerCertificates.bootstrap() +} catch { + logger.error("Failed to bootstrap installer certificates: \(String(describing: error))") +} + #if os(iOS) Task.detached { - _ = try await Installer(certificateAtPath: Installer.ca.path) + do { + _ = try await Installer(certificateAtPath: InstallerCertificates.caFileURL.path) + } catch { + logger.error("Failed to start CA installer: \(String(describing: error))") + } } #endif diff --git a/Asspp/Backend/Installer/Installer+App.swift b/Asspp/Backend/Installer/Installer+App.swift index be62561..c46beb0 100644 --- a/Asspp/Backend/Installer/Installer+App.swift +++ b/Asspp/Backend/Installer/Installer+App.swift @@ -21,7 +21,7 @@ extension Installer { app.threadPool = .init(numberOfThreads: 1) if secured { app.http.server.configuration.tlsConfiguration = try Self.setupTLS() } - app.http.server.configuration.hostname = Self.sni + app.http.server.configuration.hostname = nil app.http.server.configuration.tcpNoDelay = true app.http.server.configuration.address = .hostname("0.0.0.0", port: port) diff --git a/Asspp/Backend/Installer/Installer+Compute.swift b/Asspp/Backend/Installer/Installer+Compute.swift index 60bedeb..593b034 100644 --- a/Asspp/Backend/Installer/Installer+Compute.swift +++ b/Asspp/Backend/Installer/Installer+Compute.swift @@ -9,84 +9,72 @@ import ApplePackage import Foundation extension Installer { - var plistEndpoint: URL { - var comps = URLComponents() - comps.scheme = "https" - comps.host = Self.sni - comps.path = "/\(id).plist" - comps.port = port - return comps.url! + var plistPath: String { "/\(id).plist" } + + var payloadPath: String { "/\(id).ipa" } + + var displayImageSmallPath: String { "/app57x57.png" } + + var displayImageLargePath: String { "/app512x512.png" } + + func plistEndpoint(for host: InstallerHost) -> URL { + host.httpsURL(path: plistPath) } - var payloadEndpoint: URL { - var comps = URLComponents() - comps.scheme = "https" - comps.host = Self.sni - comps.path = "/\(id).ipa" - comps.port = port - return comps.url! + func payloadEndpoint(for host: InstallerHost) -> URL { + host.httpsURL(path: payloadPath) } - var iTunesLink: URL { + func iTunesLink(for host: InstallerHost) -> URL { + let manifestURL = plistEndpoint(for: host) var comps = URLComponents() comps.scheme = "itms-services" comps.path = "/" comps.queryItems = [ URLQueryItem(name: "action", value: "download-manifest"), - URLQueryItem(name: "url", value: plistEndpoint.absoluteString), + URLQueryItem(name: "url", value: manifestURL.absoluteString), ] - comps.port = port return comps.url! } - var displayImageSmallEndpoint: URL { - var comps = URLComponents() - comps.scheme = "https" - comps.host = Self.sni - comps.path = "/app57x57.png" - comps.port = port - return comps.url! + func displayImageSmallEndpoint(for host: InstallerHost) -> URL { + host.httpsURL(path: displayImageSmallPath) } var displayImageSmallData: Data { createWhite(57) } - var displayImageLargeEndpoint: URL { - var comps = URLComponents() - comps.scheme = "https" - comps.host = Self.sni - comps.path = "/app512x512.png" - comps.port = port - return comps.url! + func displayImageLargeEndpoint(for host: InstallerHost) -> URL { + host.httpsURL(path: displayImageLargePath) } var displayImageLargeData: Data { createWhite(512) } - var indexHtml: String { + func indexHTML(for host: InstallerHost) -> String { """ - + """ } - var installManifest: [String: Any] { + func installManifest(for host: InstallerHost) -> [String: Any] { [ "items": [ [ "assets": [ [ "kind": "software-package", - "url": payloadEndpoint.absoluteString, + "url": payloadEndpoint(for: host).absoluteString, ], [ "kind": "display-image", - "url": displayImageSmallEndpoint.absoluteString, + "url": displayImageSmallEndpoint(for: host).absoluteString, ], [ "kind": "full-size-image", - "url": displayImageLargeEndpoint.absoluteString, + "url": displayImageLargeEndpoint(for: host).absoluteString, ], ], "metadata": [ @@ -100,11 +88,15 @@ extension Installer { ] } - var installManifestData: Data { + func installManifestData(for host: InstallerHost) -> Data { (try? PropertyListSerialization.data( - fromPropertyList: installManifest, + fromPropertyList: installManifest(for: host), format: .xml, options: .zero )) ?? .init() } + + func availableHosts() -> [InstallerHost] { + InstallerHostResolver.availableHosts(port: port) + } } diff --git a/Asspp/Backend/Installer/Installer+TLS.swift b/Asspp/Backend/Installer/Installer+TLS.swift index 980ed8c..a0e3303 100644 --- a/Asspp/Backend/Installer/Installer+TLS.swift +++ b/Asspp/Backend/Installer/Installer+TLS.swift @@ -6,42 +6,34 @@ // import Foundation +import NIOCore import NIOSSL -import NIOTLS import Vapor extension Installer { - static let sni = "app.localhost.qaq.wiki" - static let pem = Bundle.main.url( - forResource: "localhost.qaq.wiki-key", - withExtension: "pem", - subdirectory: "Certificates/localhost.qaq.wiki" - ) - static let crt = Bundle.main.url( - forResource: "localhost.qaq.wiki", - withExtension: "pem", - subdirectory: "Certificates/localhost.qaq.wiki" - ) - static let ca = Bundle.main.url( - forResource: "rootCA", - withExtension: "pem", - subdirectory: "Certificates/localhost.qaq.wiki" - )! - - static var caURL: URL = .init(fileURLWithPath: "/tmp/") - static var caInstaller: Installer? + static let sni = InstallerCertificates.defaultServerName static func setupTLS() throws -> TLSConfiguration { - guard let crt, let pem else { - throw NSError(domain: "Installer", code: 0, userInfo: [ - NSLocalizedDescriptionKey: "Failed to load ssl certificates", - ]) - } - return try TLSConfiguration.makeServerConfiguration( - certificateChain: NIOSSLCertificate - .fromPEMFile(crt.path) - .map { NIOSSLCertificateSource.certificate($0) }, - privateKey: NIOSSLPrivateKeySource.privateKey(NIOSSLPrivateKey(file: pem.path, format: .pem)) + let defaultBundle = try InstallerCertificates.certificateBundle(for: InstallerCertificates.defaultServerName) + + var configuration = TLSConfiguration.makeServerConfiguration( + certificateChain: defaultBundle.chain.map { .certificate($0) }, + privateKey: .privateKey(defaultBundle.privateKey) ) + + configuration.sslContextCallback = { values, promise in + let requestedHost = values.serverHostname + do { + let bundle = try InstallerCertificates.certificateBundle(for: requestedHost) + var override = NIOSSLContextConfigurationOverride() + override.certificateChain = bundle.chain.map { .certificate($0) } + override.privateKey = .privateKey(bundle.privateKey) + promise.succeed(override) + } catch { + promise.fail(error) + } + } + + return configuration } } diff --git a/Asspp/Backend/Installer/Installer.swift b/Asspp/Backend/Installer/Installer.swift index e1d7154..ca92954 100644 --- a/Asspp/Backend/Installer/Installer.swift +++ b/Asspp/Backend/Installer/Installer.swift @@ -17,6 +17,7 @@ class Installer: Identifiable, ObservableObject, @unchecked Sendable { let app: Application let archive: AppStore.AppPackage let port = Int.random(in: 4000 ... 8000) + static var caInstaller: Installer? enum Status { case ready @@ -38,6 +39,11 @@ class Installer: Identifiable, ObservableObject, @unchecked Sendable { app.get("*") { [weak self] req in guard let self else { return Response(status: .badGateway) } + let hostContext = InstallerHostResolver.requestHost( + from: req, + defaultHost: InstallerCertificates.defaultServerName, + port: self.port + ) switch req.url.path { case "/ping": @@ -45,28 +51,28 @@ class Installer: Identifiable, ObservableObject, @unchecked Sendable { case "/", "/index.html": return Response(status: .ok, version: req.version, headers: [ "Content-Type": "text/html", - ], body: .init(string: indexHtml)) - case plistEndpoint.path: + ], body: .init(string: indexHTML(for: hostContext))) + case plistPath: await MainActor.run { self.status = .sendingManifest } - logger.info("sending manifest for installer id: \(self.id)") + logger.info("sending manifest for installer id: \(self.id) host: \(hostContext.host)") return Response(status: .ok, version: req.version, headers: [ "Content-Type": "text/xml", - ], body: .init(data: installManifestData)) - case displayImageSmallEndpoint.path: + ], body: .init(data: installManifestData(for: hostContext))) + case displayImageSmallPath: await MainActor.run { self.status = .sendingManifest } - logger.info("sending small display image for installer id: \(self.id)") + logger.info("sending small display image for installer id: \(self.id) host: \(hostContext.host)") return Response(status: .ok, version: req.version, headers: [ "Content-Type": "image/png", ], body: .init(data: displayImageSmallData)) - case displayImageLargeEndpoint.path: + case displayImageLargePath: await MainActor.run { self.status = .sendingManifest } - logger.info("sending large display image for installer id: \(self.id)") + logger.info("sending large display image for installer id: \(self.id) host: \(hostContext.host)") return Response(status: .ok, version: req.version, headers: [ "Content-Type": "image/png", ], body: .init(data: displayImageLargeData)) - case payloadEndpoint.path: + case payloadPath: await MainActor.run { self.status = .sendingPayload } - logger.info("starting payload transfer for installer id: \(self.id)") + logger.info("starting payload transfer for installer id: \(self.id) host: \(hostContext.host)") let result = try await req.fileio.asyncStreamFile( at: packagePath.path, @@ -84,17 +90,17 @@ class Installer: Identifiable, ObservableObject, @unchecked Sendable { return result default: // 404 - logger.warning("unknown request path: \(req.url.path) for installer id: \(self.id)") + logger.warning("unknown request path: \(req.url.path) for installer id: \(self.id) host: \(hostContext.host)") return Response(status: .notFound) } } try app.server.start() - logger.info("installer init at port \(port) for sni \(Self.sni)") + logger.info("installer init at port \(port) with default host \(InstallerCertificates.defaultServerName)") } - // avoid misleading name, default parm Installer.ca is not used init(certificateAtPath: String) async throws { + try InstallerCertificates.bootstrap() precondition(Installer.caInstaller == nil) let id: UUID = .init() @@ -126,13 +132,7 @@ class Installer: Identifiable, ObservableObject, @unchecked Sendable { ) } - var comps = URLComponents() - comps.scheme = "http" - comps.host = "localhost" - comps.port = port - comps.path = "/ca.crt" - let url = comps.url! - Installer.caURL = url + InstallerCertificates.updateDownloadServer(port: port) try app.server.start() @@ -151,6 +151,10 @@ class Installer: Identifiable, ObservableObject, @unchecked Sendable { try await self.app.asyncShutdown() withExtendedLifetime(self) { _ in } withExtendedLifetime(self.app) { _ in } + if Self.caInstaller === self { + InstallerCertificates.resetDownloadURL() + Self.caInstaller = nil + } } } } diff --git a/Asspp/Backend/Installer/InstallerCertificates.swift b/Asspp/Backend/Installer/InstallerCertificates.swift new file mode 100644 index 0000000..15d4553 --- /dev/null +++ b/Asspp/Backend/Installer/InstallerCertificates.swift @@ -0,0 +1,397 @@ +// +// InstallerCertificates.swift +// Asspp +// +// Created by GPT-5 Codex on 2025/11/06. +// + +import Crypto +import Foundation +import NIOConcurrencyHelpers +import NIOCore +import NIOSSL +import SwiftASN1 +import X509 + +enum InstallerCertificates { + struct ServerCertificateBundle { + let chain: [NIOSSLCertificate] + let privateKey: NIOSSLPrivateKey + } + + private struct CAContext { + let certificate: Certificate + let privateKey: Certificate.PrivateKey + let niosslCertificate: NIOSSLCertificate + let niosslPrivateKey: NIOSSLPrivateKey + let subjectKeyIdentifier: SubjectKeyIdentifier + } + + private static let lock = NIOLock() + private static var cachedCA: CAContext? + private static var cachedBundles: [String: ServerCertificateBundle] = [:] + private static var downloadURL: URL? + private static var caServerPort: Int? + + private static let fileManager = FileManager.default + + static let baseDirectory: URL = documentsDirectory + .appendingPathComponent("Certificates", isDirectory: true) + private static let caDirectory: URL = baseDirectory + private static let hostsDirectory: URL = baseDirectory.appendingPathComponent("Hosts", isDirectory: true) + + private static let caCertificateFilename = "InstallerRootCA.pem" + private static let caPrivateKeyFilename = "InstallerRootCA.key" + + private static let leafCertificateFilename = "leaf.pem" + private static let leafPrivateKeyFilename = "leaf.key" + + static let defaultServerName = "asspp.local" + + static var caFileURL: URL { + caDirectory.appendingPathComponent(caCertificateFilename, isDirectory: false) + } + + private static var caPrivateKeyURL: URL { + caDirectory.appendingPathComponent(caPrivateKeyFilename, isDirectory: false) + } + + static var caURL: URL { + lock.withLock { + downloadURL ?? caFileURL + } + } + + static let caDownloadPath = "/ca.crt" + + static func bootstrap() throws { + try lock.withLockVoid { + try ensureDirectories() + if cachedCA == nil { + if fileManager.fileExists(atPath: caFileURL.path), + fileManager.fileExists(atPath: caPrivateKeyURL.path) + { + cachedCA = try loadCAFromDisk() + } else { + cachedCA = try generateCA() + } + } + } + } + + static func updateDownloadServer(port: Int) { + lock.withLockVoid { + caServerPort = port + var components = URLComponents() + components.scheme = "http" + components.host = "localhost" + components.port = port + components.path = caDownloadPath + downloadURL = components.url + } + } + + static func caDownloadURL(for host: InstallerHost) -> URL? { + lock.withLock { + guard let port = caServerPort else { return downloadURL } + var components = URLComponents() + components.scheme = "http" + components.host = host.encodedHost + components.port = port + components.path = caDownloadPath + return components.url + } + } + + static func resetDownloadURL() { + lock.withLockVoid { + downloadURL = nil + caServerPort = nil + } + } + + static func certificateBundle(for host: String?) throws -> ServerCertificateBundle { + try bootstrap() + + return try lock.withLock { + let host = (host?.isEmpty == false ? host! : defaultServerName) + let sanitizedHost = sanitize(host) + + if let cached = cachedBundles[sanitizedHost] { + return cached + } + + let bundle: ServerCertificateBundle + if fileManager.fileExists(atPath: leafCertificateURL(for: sanitizedHost).path), + fileManager.fileExists(atPath: leafPrivateKeyURL(for: sanitizedHost).path) + { + bundle = try loadLeafFromDisk(sanitizedHost: sanitizedHost) + } else { + bundle = try generateLeaf(for: host, sanitizedHost: sanitizedHost) + } + + cachedBundles[sanitizedHost] = bundle + return bundle + } + } + + static func cachedLeafHosts() -> [String] { + lock.withLock { + Array(cachedBundles.keys) + } + } + + static func flushCaches() { + lock.withLockVoid { + cachedBundles.removeAll() + cachedCA = nil + } + } + + // MARK: - Private helpers + + private static func ensureDirectories() throws { + if !fileManager.fileExists(atPath: caDirectory.path) { + try fileManager.createDirectory(at: caDirectory, withIntermediateDirectories: true) + } + if !fileManager.fileExists(atPath: hostsDirectory.path) { + try fileManager.createDirectory(at: hostsDirectory, withIntermediateDirectories: true) + } + } + + private static func loadCAFromDisk() throws -> CAContext { + let certificatePEM = try String(contentsOf: caFileURL, encoding: .utf8) + let certificateDocument = try PEMDocument(pemString: certificatePEM) + let certificate = try Certificate(derEncoded: certificateDocument.derBytes) + + let privateKeyPEM = try String(contentsOf: caPrivateKeyURL, encoding: .utf8) + let privateKey = try Certificate.PrivateKey(pemEncoded: privateKeyPEM) + + guard let niosslCertificate = try NIOSSLCertificate.fromPEMFile(caFileURL.path).first else { + throw NSError(domain: "InstallerCertificates", code: -1, userInfo: [ + NSLocalizedDescriptionKey: "Failed to load CA certificate from disk", + ]) + } + let niosslPrivateKey = try NIOSSLPrivateKey(file: caPrivateKeyURL.path, format: .pem) + + let subjectKeyIdentifier = SubjectKeyIdentifier(hash: certificate.publicKey) + + return CAContext( + certificate: certificate, + privateKey: privateKey, + niosslCertificate: niosslCertificate, + niosslPrivateKey: niosslPrivateKey, + subjectKeyIdentifier: subjectKeyIdentifier + ) + } + + private static func generateCA() throws -> CAContext { + let swiftKey = P256.Signing.PrivateKey() + let privateKey = Certificate.PrivateKey(swiftKey) + + let subjectName = DistinguishedName { + CommonName("Asspp Local Installer Root CA") + OrganizationName("Asspp") + } + + let now = Date() + let notBefore = now.addingTimeInterval(-3600) + let notAfter = now.addingTimeInterval(60 * 60 * 24 * 365 * 20) + + let subjectKeyIdentifier = SubjectKeyIdentifier(hash: privateKey.publicKey) + + let extensions = try Certificate.Extensions { + Critical(BasicConstraints.isCertificateAuthority(maxPathLength: nil)) + Critical(KeyUsage(keyCertSign: true, cRLSign: true)) + subjectKeyIdentifier + } + + let certificate = try Certificate( + version: .v3, + serialNumber: .init(), + publicKey: privateKey.publicKey, + notValidBefore: notBefore, + notValidAfter: notAfter, + issuer: subjectName, + subject: subjectName, + signatureAlgorithm: .ecdsaWithSHA256, + extensions: extensions, + issuerPrivateKey: privateKey + ) + + let certificateDocument = certificate.pemRepresentation + try certificateDocument.pemString.write(to: caFileURL, atomically: true, encoding: .utf8) + + let privateKeyDocument = try privateKey.serializeAsPEM() + try privateKeyDocument.pemString.write(to: caPrivateKeyURL, atomically: true, encoding: .utf8) + + guard let niosslCertificate = try NIOSSLCertificate.fromPEMFile(caFileURL.path).first else { + throw NSError(domain: "InstallerCertificates", code: -2, userInfo: [ + NSLocalizedDescriptionKey: "Failed to materialize generated CA certificate", + ]) + } + let niosslPrivateKey = try NIOSSLPrivateKey(file: caPrivateKeyURL.path, format: .pem) + + return CAContext( + certificate: certificate, + privateKey: privateKey, + niosslCertificate: niosslCertificate, + niosslPrivateKey: niosslPrivateKey, + subjectKeyIdentifier: subjectKeyIdentifier + ) + } + + private static func loadLeafFromDisk(sanitizedHost: String) throws -> ServerCertificateBundle { + let certificatePath = leafCertificateURL(for: sanitizedHost).path + let privateKeyPath = leafPrivateKeyURL(for: sanitizedHost).path + + let certificates = try NIOSSLCertificate.fromPEMFile(certificatePath) + guard let leafCertificate = certificates.first else { + throw NSError(domain: "InstallerCertificates", code: -3, userInfo: [ + NSLocalizedDescriptionKey: "Leaf certificate chain is empty", + ]) + } + + guard let caContext = cachedCA else { + throw NSError(domain: "InstallerCertificates", code: -4, userInfo: [ + NSLocalizedDescriptionKey: "CA context unavailable", + ]) + } + + let privateKey = try NIOSSLPrivateKey(file: privateKeyPath, format: .pem) + + let chain = [leafCertificate, caContext.niosslCertificate] + + return ServerCertificateBundle(chain: chain, privateKey: privateKey) + } + + private static func generateLeaf(for host: String, sanitizedHost: String) throws -> ServerCertificateBundle { + guard let caContext = cachedCA else { + throw NSError(domain: "InstallerCertificates", code: -5, userInfo: [ + NSLocalizedDescriptionKey: "CA context unavailable", + ]) + } + + let swiftKey = P256.Signing.PrivateKey() + let leafPrivateKey = Certificate.PrivateKey(swiftKey) + + let subjectName = DistinguishedName { + CommonName(host) + } + + let notBefore = Date().addingTimeInterval(-3600) + let notAfter = Date().addingTimeInterval(60 * 60 * 24 * 365) + + let authorityKeyIdentifier = AuthorityKeyIdentifier( + keyIdentifier: caContext.subjectKeyIdentifier.keyIdentifier, + authorityCertIssuer: [GeneralName.directoryName(caContext.certificate.subject)], + authorityCertSerialNumber: caContext.certificate.serialNumber + ) + + let subjectKeyIdentifier = SubjectKeyIdentifier(hash: leafPrivateKey.publicKey) + + let subjectAlternativeNames = SubjectAlternativeNames(buildSubjectAlternativeNames(for: host)) + + let extensions = try Certificate.Extensions { + Critical(BasicConstraints.notCertificateAuthority) + Critical(KeyUsage(digitalSignature: true, keyEncipherment: true)) + try ExtendedKeyUsage([.serverAuth]) + subjectAlternativeNames + authorityKeyIdentifier + subjectKeyIdentifier + } + + let certificate = try Certificate( + version: .v3, + serialNumber: .init(), + publicKey: leafPrivateKey.publicKey, + notValidBefore: notBefore, + notValidAfter: notAfter, + issuer: caContext.certificate.subject, + subject: subjectName, + signatureAlgorithm: .ecdsaWithSHA256, + extensions: extensions, + issuerPrivateKey: caContext.privateKey + ) + + let certificateDocument = certificate.pemRepresentation + let certificateURL = leafCertificateURL(for: sanitizedHost) + try ensureLeafContainerExists(for: sanitizedHost) + try certificateDocument.pemString.write(to: certificateURL, atomically: true, encoding: .utf8) + + let privateKeyDocument = try leafPrivateKey.serializeAsPEM() + let privateKeyURL = leafPrivateKeyURL(for: sanitizedHost) + try privateKeyDocument.pemString.write(to: privateKeyURL, atomically: true, encoding: .utf8) + + let niosslLeafCertificate = try NIOSSLCertificate.fromPEMFile(certificateURL.path).first ?? { + throw NSError(domain: "InstallerCertificates", code: -6, userInfo: [ + NSLocalizedDescriptionKey: "Failed to materialize generated leaf certificate", + ]) + }() + let niosslPrivateKey = try NIOSSLPrivateKey(file: privateKeyURL.path, format: .pem) + + let chain = [niosslLeafCertificate, caContext.niosslCertificate] + + return ServerCertificateBundle(chain: chain, privateKey: niosslPrivateKey) + } + + private static func ensureLeafContainerExists(for sanitizedHost: String) throws { + let directory = leafDirectory(for: sanitizedHost) + if !fileManager.fileExists(atPath: directory.path) { + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + } + } + + private static func leafDirectory(for sanitizedHost: String) -> URL { + hostsDirectory.appendingPathComponent(sanitizedHost, isDirectory: true) + } + + private static func leafCertificateURL(for sanitizedHost: String) -> URL { + leafDirectory(for: sanitizedHost).appendingPathComponent(leafCertificateFilename, isDirectory: false) + } + + private static func leafPrivateKeyURL(for sanitizedHost: String) -> URL { + leafDirectory(for: sanitizedHost).appendingPathComponent(leafPrivateKeyFilename, isDirectory: false) + } + + private static func sanitize(_ host: String) -> String { + let lowercase = host.lowercased() + let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789-_.") + var scalars: [Character] = [] + scalars.reserveCapacity(lowercase.count) + + for scalar in lowercase.unicodeScalars { + if allowed.contains(scalar) { + scalars.append(Character(scalar)) + } else { + scalars.append("-") + } + } + + var sanitized = String(scalars).trimmingCharacters(in: CharacterSet(charactersIn: ".-_")) + if sanitized.isEmpty { + let digest = SHA256.hash(data: Data(lowercase.utf8)) + sanitized = digest.prefix(8).map { String(format: "%02x", $0) }.joined() + } + return sanitized + } + + private static func buildSubjectAlternativeNames(for host: String) throws -> [GeneralName] { + if let address = try? SocketAddress(ipAddress: host, port: 0) { + switch address { + case .v4(let address): + var addr = address.address.sin_addr + let bytes = withUnsafeBytes(of: &addr) { Array($0) } + return [.ipAddress(ASN1OctetString(contentBytes: bytes))] + case .v6(let address): + var addr = address.address.sin6_addr + let bytes = withUnsafeBytes(of: &addr) { Array($0) } + return [.ipAddress(ASN1OctetString(contentBytes: bytes))] + case .unixDomainSocket: + break + } + } + + return [.dnsName(host)] + } +} diff --git a/Asspp/Backend/Installer/InstallerHost.swift b/Asspp/Backend/Installer/InstallerHost.swift new file mode 100644 index 0000000..a348117 --- /dev/null +++ b/Asspp/Backend/Installer/InstallerHost.swift @@ -0,0 +1,202 @@ +// +// InstallerHost.swift +// Asspp +// +// Created by GPT-5 Codex on 2025/11/06. +// + +import Foundation +import Vapor + +#if canImport(Darwin) + import Darwin +#elseif canImport(Glibc) + import Glibc +#endif + +struct InstallerHost: Identifiable, Hashable { + let host: String + let encodedHost: String + let port: Int + let interfaceName: String? + let isLoopback: Bool + let isIPv6: Bool + + var id: String { + "\(encodedHost)#\(port)#\(interfaceName ?? "-")" + } + + var displayName: String { + if let interfaceName { + return "\(host) (\(interfaceName))" + } + return host + } + + var authority: String { + if port == 0 { + return encodedHost + } + return "\(encodedHost):\(port)" + } + + init(host: String, port: Int, interfaceName: String? = nil, isLoopback: Bool = false, isIPv6: Bool? = nil) { + self.host = host + self.port = port + self.interfaceName = interfaceName + let resolvedIPv6 = isIPv6 ?? host.contains(":") + self.isIPv6 = resolvedIPv6 + let loopback = isLoopback || InstallerHost.isLoopbackHost(host) + self.isLoopback = loopback + self.encodedHost = InstallerHost.encode(host: host) + } + + func url(scheme: String, path: String, queryItems: [URLQueryItem]? = nil) -> URL { + var components = URLComponents() + components.scheme = scheme + components.host = encodedHost + components.port = port + components.path = path + if let queryItems { components.queryItems = queryItems } + guard let url = components.url else { + preconditionFailure("Failed to construct URL for host: \(host), path: \(path)") + } + return url + } + + func httpsURL(path: String, queryItems: [URLQueryItem]? = nil) -> URL { + url(scheme: "https", path: path, queryItems: queryItems) + } + + static func encode(host: String) -> String { + if host.contains("%"), !host.contains("%25") { + return host.replacingOccurrences(of: "%", with: "%25") + } + return host + } + + static func isLoopbackHost(_ host: String) -> Bool { + switch host { + case "localhost", "127.0.0.1", "::1": + return true + default: + return false + } + } +} + +enum InstallerHostResolver { + static func requestHost(from request: Request, defaultHost: String, port: Int) -> InstallerHost { + if let header = request.headers.first(name: .host), + let parsed = parseHostHeader(header, fallbackPort: port) + { + return parsed + } + + if let urlHost = request.url.host { + return InstallerHost(host: urlHost, port: request.url.port ?? port) + } + + return InstallerHost(host: defaultHost, port: port) + } + + static func availableHosts(port: Int) -> [InstallerHost] { + var pointer: UnsafeMutablePointer? + guard getifaddrs(&pointer) == 0, let start = pointer else { + return fallbackHosts(port: port) + } + defer { freeifaddrs(start) } + + var hosts: [InstallerHost] = [] + var seen: Set = [] + + var cursor: UnsafeMutablePointer? = start + while let value = cursor { + defer { cursor = value.pointee.ifa_next } + guard let addressPointer = value.pointee.ifa_addr else { continue } + + let family = Int32(addressPointer.pointee.sa_family) + if family == AF_INET { + var address = addressPointer.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { $0.pointee } + if address.sin_addr.s_addr == INADDR_ANY { continue } + let hostString = ipv4String(from: &address.sin_addr) + if hostString.isEmpty { continue } + if seen.insert(hostString).inserted { + let interface = String(cString: value.pointee.ifa_name) + let loopback = (value.pointee.ifa_flags & UInt32(IFF_LOOPBACK)) != 0 + hosts.append(InstallerHost(host: hostString, port: port, interfaceName: interface, isLoopback: loopback, isIPv6: false)) + } + } else if family == AF_INET6 { + var address = addressPointer.withMemoryRebound(to: sockaddr_in6.self, capacity: 1) { $0.pointee } + if isZero(address.sin6_addr) { continue } + var scopeName: String? + if address.sin6_scope_id != 0 { + scopeName = String(cString: value.pointee.ifa_name) + } + var hostString = ipv6String(from: &address.sin6_addr) + if hostString.isEmpty { continue } + if let scopeName { + hostString += "%" + scopeName + } + if seen.insert(hostString).inserted { + let interface = String(cString: value.pointee.ifa_name) + let loopback = (value.pointee.ifa_flags & UInt32(IFF_LOOPBACK)) != 0 || hostString == "::1" + hosts.append(InstallerHost(host: hostString, port: port, interfaceName: interface, isLoopback: loopback, isIPv6: true)) + } + } + } + + hosts.append(contentsOf: fallbackHosts(port: port).filter { seen.insert($0.host).inserted }) + + return hosts.sorted(by: { lhs, rhs in + if lhs.isLoopback != rhs.isLoopback { + return rhs.isLoopback // prefer non-loopback first + } + if lhs.isIPv6 != rhs.isIPv6 { + return !lhs.isIPv6 // prefer IPv4 + } + return lhs.host < rhs.host + }) + } + + private static func fallbackHosts(port: Int) -> [InstallerHost] { + var results: [InstallerHost] = [] + results.append(InstallerHost(host: "localhost", port: port, interfaceName: nil, isLoopback: true, isIPv6: false)) + let defaultHost = InstallerCertificates.defaultServerName + if defaultHost != "localhost" { + results.append(InstallerHost(host: defaultHost, port: port)) + } + return results + } + + private static func parseHostHeader(_ header: String, fallbackPort: Int) -> InstallerHost? { + guard let components = URLComponents(string: "https://\(header)") else { + return nil + } + guard let host = components.host else { return nil } + let port = components.port ?? fallbackPort + return InstallerHost(host: host, port: port) + } + + private static func ipv4String(from address: inout in_addr) -> String { + var buffer = [CChar](repeating: 0, count: Int(INET_ADDRSTRLEN)) + guard inet_ntop(AF_INET, &address, &buffer, socklen_t(INET_ADDRSTRLEN)) != nil else { + return "" + } + return String(cString: buffer) + } + + private static func ipv6String(from address: inout in6_addr) -> String { + var buffer = [CChar](repeating: 0, count: Int(INET6_ADDRSTRLEN)) + guard inet_ntop(AF_INET6, &address, &buffer, socklen_t(INET6_ADDRSTRLEN)) != nil else { + return "" + } + return String(cString: buffer) + } + + private static func isZero(_ address: in6_addr) -> Bool { + withUnsafeBytes(of: address) { buffer in + !buffer.contains { $0 != 0 } + } + } +} diff --git a/Asspp/Interface/Download/InstallerView.swift b/Asspp/Interface/Download/InstallerView.swift index d84a0ee..fd5a044 100644 --- a/Asspp/Interface/Download/InstallerView.swift +++ b/Asspp/Interface/Download/InstallerView.swift @@ -12,81 +12,318 @@ struct InstallerView: View { @StateObject var installer: Installer - var icon: String { + private enum HostMode: String, CaseIterable, Identifiable { + case automatic + case manual + + var id: String { rawValue } + var title: String { + switch self { + case .automatic: String(localized: "Suggested") + case .manual: String(localized: "Manual") + } + } + } + + private enum Step: Int, CaseIterable, Identifiable { + case certificate + case install + + var id: Int { rawValue } + var title: String { + switch self { + case .certificate: String(localized: "Certificate") + case .install: String(localized: "Install") + } + } + } + + @State private var hostMode: HostMode = .automatic + @State private var hostOptions: [InstallerHost] = [] + @State private var selectedHostID: InstallerHost.ID? + @State private var manualHost: String = "" + @State private var currentStep: Step = .certificate + + private var defaultHost: InstallerHost { + InstallerHost(host: InstallerCertificates.defaultServerName, port: installer.port) + } + + private var manualHostContext: InstallerHost? { + let trimmed = manualHost.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return InstallerHost(host: trimmed, port: installer.port) + } + + private var effectiveHost: InstallerHost { + switch hostMode { + case .automatic: + if let id = selectedHostID, + let host = hostOptions.first(where: { $0.id == id }) + { + return host + } + return hostOptions.first ?? defaultHost + case .manual: + return manualHostContext ?? defaultHost + } + } + + private var certificateURLString: String { + if let url = InstallerCertificates.caDownloadURL(for: effectiveHost) ?? InstallerCertificates.caURL { + return url.absoluteString + } + return "" + } + + private var installURLString: String { + installer.iTunesLink(for: effectiveHost).absoluteString + } + + private var statusIcon: String { switch installer.status { case .ready: - "app.gift" + return "app.gift" case .sendingManifest: - "paperplane.fill" + return "paperplane.fill" case .sendingPayload: - "paperplane.fill" - case let .completed(result): - switch result { - case .success: - "app.badge.checkmark" - case .failure: - "exclamationmark.triangle.fill" - } + return "paperplane.fill" + case .completed(.success): + return "app.badge.checkmark" + case .completed(.failure): + return "exclamationmark.triangle.fill" case .broken: - "exclamationmark.triangle.fill" + return "exclamationmark.triangle.fill" } } - var text: String { + private var statusText: String { switch installer.status { - case .ready: String(localized: "Ready To Install") - case .sendingManifest: String(localized: "Sending Manifest...") - case .sendingPayload: String(localized: "Sending Payload...") + case .ready: + return String(localized: "Waiting for device to scan the QR code") + case .sendingManifest: + return String(localized: "Sending manifest…") + case .sendingPayload: + return String(localized: "Transferring payload…") case let .completed(result): switch result { case .success: - String(localized: "Install Completed") - case let .failure(failure): - failure.localizedDescription + return String(localized: "Install completed") + case let .failure(error): + return error.localizedDescription } case let .broken(error): - error.localizedDescription + return error.localizedDescription } } var body: some View { - ZStack { - VStack(spacing: 32) { - ForEach([icon], id: \.self) { icon in - Image(systemName: icon) - .font(.system(.largeTitle, design: .rounded)) - .transition(.opacity.combined(with: .scale)) + NavigationStack { + ScrollView { + VStack(spacing: 24) { + stepPicker + hostPicker + Divider() + switch currentStep { + case .certificate: + certificateStep + case .install: + installStep + } } - ForEach([text], id: \.self) { text in - Text(text) - .font(.system(.body, design: .rounded)) - .transition(.opacity) + .padding(24) + } + .navigationTitle(String(localized: "QR Installer")) + .navigationBarTitleDisplayMode(.inline) + } + .onAppear { + refreshHosts() + } + .onDisappear { + installer.destroy() + } + .animation(.spring(response: 0.45, dampingFraction: 0.85), value: currentStep) + .animation(.spring(response: 0.45, dampingFraction: 0.9), value: hostMode) + } + + private var stepPicker: some View { + Picker(String(localized: "Step"), selection: $currentStep) { + ForEach(Step.allCases) { step in + Text(step.title).tag(step) + } + } + .pickerStyle(.segmented) + .accessibilityLabel(Text("Installer step")) + } + + private var hostPicker: some View { + VStack(alignment: .leading, spacing: 12) { + Text(String(localized: "Select Host")) + .font(.headline) + Picker(String(localized: "Mode"), selection: $hostMode) { + ForEach(HostMode.allCases) { mode in + Text(mode.title).tag(mode) } } - .contentShape(Rectangle()) - .onTapGesture { - if case .ready = installer.status { - UIApplication.shared.open(installer.iTunesLink) + .pickerStyle(.segmented) + + switch hostMode { + case .automatic: + VStack(alignment: .leading, spacing: 8) { + if hostOptions.isEmpty { + Text(String(localized: "No network addresses detected. Make sure Wi-Fi or Ethernet is connected.")) + .font(.footnote) + .foregroundStyle(.secondary) + } else { + Picker(String(localized: "Host"), selection: Binding( + get: { selectedHostID ?? hostOptions.first?.id }, + set: { selectedHostID = $0 } + )) { + ForEach(hostOptions) { host in + Text(host.displayName).tag(Optional(host.id)) + } + } + .pickerStyle(.menu) + } + Button { + refreshHosts() + } label: { + Label(String(localized: "Refresh Hosts"), systemImage: "arrow.clockwise") + } + .buttonStyle(.bordered) + } + case .manual: + VStack(alignment: .leading, spacing: 8) { + TextField(String(localized: "Hostname or IP"), text: $manualHost) + .textContentType(.URL) + .keyboardType(.URL) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + Text(String(localized: "Enter an address reachable from the device you plan to install on.")) + .font(.footnote) + .foregroundStyle(.secondary) } } - .onAppear { - if case .ready = installer.status { - UIApplication.shared.open(installer.iTunesLink) + } + } + + private var certificateStep: some View { + VStack(spacing: 20) { + Text(String(localized: "Step 1: Install Certificate")) + .font(.title3.weight(.semibold)) + .frame(maxWidth: .infinity, alignment: .leading) + Text(String(localized: "Scan this code with the target device to download the installer certificate, then mark it as trusted in Settings.")) + .frame(maxWidth: .infinity, alignment: .leading) + .foregroundStyle(.secondary) + Text("\(String(localized: \"Current host\")): \(effectiveHost.host)") + .font(.footnote) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + QRCodeView(value: certificateURLString) + .frame(maxWidth: 260, maxHeight: 260) + .padding(16) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 24, style: .continuous)) + VStack(alignment: .leading, spacing: 8) { + Text(certificateURLString) + .font(.footnote.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + HStack { + Button { + openURL(string: certificateURLString) + } label: { + Label(String(localized: "Open"), systemImage: "safari") + } + Button { + copyToPasteboard(certificateURLString) + } label: { + Label(String(localized: "Copy"), systemImage: "doc.on.doc") + } } + .buttonStyle(.bordered) } - VStack { - Text("Grant local network permission to install apps and communicate with system services.") + VStack(alignment: .leading, spacing: 4) { + Text(String(localized: "After installing, go to Settings → General → About → Certificate Trust Settings and enable full trust for the certificate.")) + .font(.footnote) + .foregroundStyle(.secondary) } - .font(.system(.footnote, design: .rounded)) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) - .padding(32) } - .animation(.spring, value: text) - .animation(.spring, value: icon) - .onDisappear { - installer.destroy() + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var installStep: some View { + VStack(spacing: 20) { + Text(String(localized: "Step 2: Install Application")) + .font(.title3.weight(.semibold)) + .frame(maxWidth: .infinity, alignment: .leading) + statusBadge + .frame(maxWidth: .infinity, alignment: .leading) + Text("\(String(localized: \"Current host\")): \(effectiveHost.host)") + .font(.footnote) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + QRCodeView(value: installURLString) + .frame(maxWidth: 260, maxHeight: 260) + .padding(16) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 24, style: .continuous)) + VStack(alignment: .leading, spacing: 8) { + Text(installURLString) + .font(.footnote.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + HStack { + Button { + openURL(string: installURLString) + } label: { + Label(String(localized: "Open"), systemImage: "safari") + } + Button { + copyToPasteboard(installURLString) + } label: { + Label(String(localized: "Copy"), systemImage: "doc.on.doc") + } + } + .buttonStyle(.bordered) + } + VStack(alignment: .leading, spacing: 4) { + Text(String(localized: "Keep this screen visible while the install is in progress.")) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var statusBadge: some View { + HStack(spacing: 12) { + Image(systemName: statusIcon) + .symbolVariant(.fill) + .font(.system(size: 28, weight: .semibold, design: .rounded)) + .foregroundStyle(.accent) + .frame(width: 44, height: 44) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + Text(statusText) + .font(.body) + .foregroundStyle(.primary) + } + } + + private func refreshHosts() { + let hosts = installer.availableHosts() + hostOptions = hosts + if selectedHostID == nil { + selectedHostID = hosts.first?.id + } else if let selected = selectedHostID, !hosts.contains(where: { $0.id == selected }) { + selectedHostID = hosts.first?.id } } + + private func openURL(string: String) { + guard let url = URL(string: string) else { return } + UIApplication.shared.open(url) + } + + private func copyToPasteboard(_ string: String) { + UIPasteboard.general.string = string + } } #endif diff --git a/Asspp/Interface/Setting/SettingView.swift b/Asspp/Interface/Setting/SettingView.swift index f69321d..423e619 100644 --- a/Asspp/Interface/Setting/SettingView.swift +++ b/Asspp/Interface/Setting/SettingView.swift @@ -71,7 +71,7 @@ struct SettingView: View { #if canImport(UIKit) Section { Button("Install Certificate") { - UIApplication.shared.open(Installer.caURL) + UIApplication.shared.open(InstallerCertificates.caURL) } } header: { Text("SSL") @@ -83,7 +83,7 @@ struct SettingView: View { #if canImport(AppKit) && !canImport(UIKit) Section { Button("Show Certificate in Finder") { - NSWorkspace.shared.activateFileViewerSelecting([Installer.ca]) + NSWorkspace.shared.activateFileViewerSelecting([InstallerCertificates.caFileURL]) } } header: { Text("SSL") diff --git a/Asspp/Interface/Utils/QRCodeView.swift b/Asspp/Interface/Utils/QRCodeView.swift new file mode 100644 index 0000000..da9cda8 --- /dev/null +++ b/Asspp/Interface/Utils/QRCodeView.swift @@ -0,0 +1,48 @@ +// +// QRCodeView.swift +// Asspp +// +// Created by GPT-5 Codex on 2025/11/06. +// + +import CoreImage.CIFilterBuiltins +import SwiftUI +import UIKit + +struct QRCodeView: View { + private static let context = CIContext() + + let value: String + var body: some View { + Group { + if let image = generateImage(from: value) { + Image(uiImage: image) + .interpolation(.none) + .resizable() + .scaledToFit() + } else { + ZStack { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color.secondary.opacity(0.2)) + Image(systemName: "qrcode") + .font(.system(size: 32, weight: .medium, design: .rounded)) + .foregroundStyle(.secondary) + } + } + } + .accessibilityLabel(Text("QR code")) + } + + private func generateImage(from string: String) -> UIImage? { + guard !string.isEmpty else { return nil } + let data = Data(string.utf8) + let filter = CIFilter.qrCodeGenerator() + filter.setValue(data, forKey: "inputMessage") + filter.setValue("H", forKey: "inputCorrectionLevel") + guard let outputImage = filter.outputImage else { return nil } + let scaleTransform = CGAffineTransform(scaleX: 12, y: 12) + let scaledImage = outputImage.transformed(by: scaleTransform) + guard let cgImage = QRCodeView.context.createCGImage(scaledImage, from: scaledImage.extent) else { return nil } + return UIImage(cgImage: cgImage) + } +} diff --git a/Resources/Certificates/localhost.qaq.wiki/localhost.qaq.wiki-key.pem b/Resources/Certificates/localhost.qaq.wiki/localhost.qaq.wiki-key.pem deleted file mode 100644 index 6fa8fb1..0000000 --- a/Resources/Certificates/localhost.qaq.wiki/localhost.qaq.wiki-key.pem +++ /dev/null @@ -1,28 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDDBuFL8ckoApwd -jt3pmurLEf+rjVh3JMT7aANnjJfbPXATRZTpPzjnwlYeZTlv6pR5xkO668RfD1Fw -FDjez2/LVxrPi6GseoO1Or6aRjxwyC4G27AN+EPY1K90U5l12JNthvgl5HOVlpJg -uFWmjnAHg3zGakUaNilXesbOcb4BwzswW8viAiPjT1PKZQxP38wK6t0zbmHumvWd -jHL2pDdkXV0zkybVzcLXfIQYj8d8PzspPmG7gHa9S3XWuR2kMUt5HJcb7PmxaOpp -JfeH9uUmAVD9a2RHeYWkF+kra3SEx881EEkoC7gC1JTzDyGDCl5+gh8j8xZ7ipYq -bk981Fi9AgMBAAECggEBALFPDP/dx9OPY93fqNOc28BaEccnT3mqvUgQjZ/UC6/w -kHeE+FiC+TqDzYX+45Rjgi+gWK2Vckg2hgTO7dFsaKhG/gIZYZiNHjdjPCzIlGS5 -Kldj3hElWJHG8+lfpBKnH4ohfnP82jTy2wwCKyStNR8vpP5DWaX1/eohnqPMd0Hj -Yn//oqMlxP+Q9zsNz4vHqsnjuJU/UAkqIE03uwwIBb7S4dBLciBkrKtswnFdHXYV -BMI67iYb66nVM1Bzyzs5SfZzkfY+OuzO5RirbJK0jYo+p0Mfi4gEz7OLb2ygJUNM -qnEikIFggTAR6xMFu5Z6+GawLDRqGutjK1ZYuJj3ASECgYEA/1MD3eJZ81N0XPTG -Yq9E1njldwnuSjHVYiGUCIAr5uyKkb/TV2xQm8WuT6k4KJoIvHxUYyXZCpFdWEhr -kU4W5wxDYovZpdyK0h9lUuwzWIYV9erMzWFKEOVMqLEQZv2AOhebYRfbB4IZ4T7Y -h3gZ20Oq9XeVbeG1uqrK4prywHkCgYEAw4sDSQ019iqYTNaEf5vs6/K1PJoYefic -9YMpoacW1NTzxsSm2ZDBdWLf9FAiWmEDpE2pTwG+6Ikgb41jJe/nVfoiGBlexhWo -85fkJxs4RaNpXuf8YlZadPZ/csgMHhXY6MpxhKEDYVHWJ/oSRIYTWXBhn4ZHtMm8 -xJmlH2ihcWUCgYA0Mm558ApfmlIRpuDfz+EMz45ptZgHhwSOmTrAOiO/g+AYR9UW -7EfWcKVgF8IpWsQqdGh0FIq4hFtG7xc+j25TMcic3uZR6DJhHpYCS9N7Z34Z1jSk -UB1qMtZnLjuiMnUCa00SnmPitxA/Yi+2EjGHB+BtalMcMaaLUNtFVFQIUQKBgE8x -h50YWphd29ySrIq57ZQJcdkfV3Zed8L+4ed0Mkz0Cd6gWiqW86LE7oqVwbP0wnLS -eRVkUZr/nkFPc64zoO8zJIe6DqYOs6QkCeTEo1+gtNYZAiAZdP0Vr7wexLmSg4yU -ILFkPGg3qpH6N1fFLST13LOswHG5mlfNGhDEYU35AoGARQBrVV1YXiGzibEFyhXY -WIzYOLMDTsJbYERIz4FC3/kjgd2JerO7DR4ldGpUo/xZ0OxDogZHbv2yctHiXU/a -/bVMal9ersx7jK9x7bZ9JG4e9ceyzL5o1MQ4BjciYznSjPwWtyZDpREkEj5zw9z7 -63z1udfYtKbl/RRLSrDmJ+8= ------END PRIVATE KEY----- diff --git a/Resources/Certificates/localhost.qaq.wiki/localhost.qaq.wiki.pem b/Resources/Certificates/localhost.qaq.wiki/localhost.qaq.wiki.pem deleted file mode 100644 index 14bf849..0000000 --- a/Resources/Certificates/localhost.qaq.wiki/localhost.qaq.wiki.pem +++ /dev/null @@ -1,25 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIEKDCCApCgAwIBAgIQMQbC+oOhNKx7ks6defoIXzANBgkqhkiG9w0BAQsFADBd -MR4wHAYDVQQKExVta2NlcnQgZGV2ZWxvcG1lbnQgQ0ExGTAXBgNVBAsMEHFhcUBr -ZXNpYWkubG9jYWwxIDAeBgNVBAMMF21rY2VydCBxYXFAa2VzaWFpLmxvY2FsMB4X -DTI1MDkxNTE1Mzc1MVoXDTI3MTIxNTE1Mzc1MVowRDEnMCUGA1UEChMebWtjZXJ0 -IGRldmVsb3BtZW50IGNlcnRpZmljYXRlMRkwFwYDVQQLDBBxYXFAa2VzaWFpLmxv -Y2FsMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwwbhS/HJKAKcHY7d -6ZrqyxH/q41YdyTE+2gDZ4yX2z1wE0WU6T8458JWHmU5b+qUecZDuuvEXw9RcBQ4 -3s9vy1caz4uhrHqDtTq+mkY8cMguBtuwDfhD2NSvdFOZddiTbYb4JeRzlZaSYLhV -po5wB4N8xmpFGjYpV3rGznG+AcM7MFvL4gIj409TymUMT9/MCurdM25h7pr1nYxy -9qQ3ZF1dM5Mm1c3C13yEGI/HfD87KT5hu4B2vUt11rkdpDFLeRyXG+z5sWjqaSX3 -h/blJgFQ/WtkR3mFpBfpK2t0hMfPNRBJKAu4AtSU8w8hgwpefoIfI/MWe4qWKm5P -fNRYvQIDAQABo30wezAOBgNVHQ8BAf8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUH -AwEwHwYDVR0jBBgwFoAUCg/G+waBCXwrN9pxWY4Mjq3wMokwMwYDVR0RBCwwKoIS -bG9jYWxob3N0LnFhcS53aWtpghQqLmxvY2FsaG9zdC5xYXEud2lraTANBgkqhkiG -9w0BAQsFAAOCAYEAl26Hn2nSpDHpYt6uHx8w1AdPAPSj1KBNrMYM0bUulSriMrui -eBHJCDmg9vwp82lkLHqDDpUEbyBg3mVTQmEG8ixiunOi67x3YlJSEUKqBecxsQLY -BnIlWFJcox7fMRoTaKaiHtfIdJ6Of6NLs6vhYTMte71pwj7V3nsZ+BmST1ZYGEX+ -LoZYBFsnaKPM13bxhLoGwjTGOQK6M2bp6yHbQajl7objFqzjYNRt1yLR6/rCG9kp -h8wa2Ivxr2EAu24JJDKYLD+UGYHP6KGSR57hjHQnCTd6uefQVODEkhMog1wuyE+s -mL0RyQbZVVd3jA75U42FW0Ba8XVjJEEdDhL7l9yFeKmkMrU5dlrydeoPtWDRQH06 -IA92hmFNjKuQZVAH5m7gIujvXuB78lUcFTQLtKNF5U0nFiK794BkZEWX5n90eh2v -cASzSF0ObF8Q61j/KiSd5ZMX0yFa+V6S7awnrI6axXK88OkBCPiKe+eebcCu3myO -3dp1ZYY8WtnUom7s ------END CERTIFICATE----- diff --git a/Resources/Certificates/localhost.qaq.wiki/rootCA-key.pem b/Resources/Certificates/localhost.qaq.wiki/rootCA-key.pem deleted file mode 100644 index b30a166..0000000 --- a/Resources/Certificates/localhost.qaq.wiki/rootCA-key.pem +++ /dev/null @@ -1,40 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIIG/gIBADANBgkqhkiG9w0BAQEFAASCBugwggbkAgEAAoIBgQCwKoQDbHXHn7/T -BLme3FjZT94tX2Rv+kpV5rqHvJpO9GXCN65X6ERjcEzywZCgCDk62D/lUbBfKFt6 -icrMmMx1OBNFJeY6IV2dhVXG/aJ3lp6ejVAOO4R4xRhRP+Ms+pLWs5+gNTwccGOv -bErxOcToSfaf9joSw5RMIRIiz9UtrdtJaN7xJP3Kfai7cjKVbeoR6JMri+U4T95K -ggKJhtpQhSfKlTg6uvUoe/52WLZ3yLat+LiAsRzFmSM4w4QZxcTsnzovr9Mw0ARC -Kyx/LmsCzOUk7vskm6nY0NK7cXIrG27jYp6YJK6biBJUBQazQnbAWwZKbly2XzuP -ONmc1CVMyanFBNjRUT/Sxi4B1RVmm58snsrGMvm9LTx+kiGmAa06DASRqtlna7Rj -gi5wisC0kd8IsOZENvrdKYDKvcA2tVVDpFwBowIpCjEHoUJFPssRgvduF/vLBmru -lgwxeunrpGmKVscLx0uq/JIiqZiC4EMHklaHZp96DHwXKAHByncCAwEAAQKCAYA0 -7XEgPjPcuAg/9Al4yyb+k1pjM2fPpNk0mc+s/GM4GYGBKo1859G0NodH4BP9oLrh -DRuVYEenL07csA14DuhOx3wyqEJVcLTZoLe2w3lOiLg7VqWYwRT52+2Ea7drShVX -4DuzPItE2Bd7GwQhGYQpbh9ky+uCu7QQc1cpkluwD630jkd9F+oQLlTFjHfaIV3T -55Zf3PYPRx+4LvtoKlXpHzhnSxHLw2Pot+gp0llv/6Oy/SkhsRS94VaNtWA5Bgu2 -MkSIHCc9sho/Nebb/mD4YQpUwq4EhklLRRTW7KryGmJXYTUSBlDPmn9UnZs5URWg -EtbNEPxAkz/iHTJtplOyG8tPkI989vokzEnhfR+ruPgr6SKLrdGO8YKnkrzI9tn0 -yrmPYM49rJpINJQmyjahAAaup8MMt/TCRnm/APCzrRFLuT25UICvkOVtgR2zgC5P -y9dtuGWi2YFXo1XfAUzP9ySw/87z7otYJgouqpZRWKjj865YSOYzhJTJU2IPZeEC -gcEAxDtec5iDPwvA6wqwKXlakkt/q6MCYk73qYmwVwrehKqybDPoYF9bCUr5ggf6 -vx1g3n5Qk6BbKw6EaDETOda318mRurcUAaEURdzwPj/mTjbncfVe0qwl1B6odRW9 -LoXqnAumQ7+QrWdeMt8KNas9LnoJSkl/pkTMX0c9xQJCHrZpI/5JwZ4H220ihcCC -/fCaNeMO02UdpGysQ7m9S6p/TNBXrxgL4XLb1tSXqCX5jbH8n+gBbeyYihJ0pRXu -AH7pAoHBAOXSkeGUG/4Fyp37t2pCdM0nd/buKynJq0YNOWD5hYvmZOqXARCKSeYv -fDcndbIydv0U0CJoeRX++13h/EetS3B7gw0WeP4Y+Vdqzj1k2vA8hLMhQ4gubWV+ -Xt9rvu9qCklGT98QNp9NBAkt8O8lwWvY9rTu2sQu9SBBvqXMWKrWhujTyrVeRa9j -8OkhAtORQVFxROu5+ZgC0hOriGl6c+m+ZpKPXf/zjxNMsMAxOwC9SehLhX3btte0 -piS1l6HiXwKBwQCrSLsgIY7mWCcTwqeT/BZfvkD5m3b4Qr+RH9tpjtY+bRUCt6ht -fq5jBSuXIcnwSf+FFjLVOVJ62dgfbj6+7LpaQ9rNZQK0jVq5xKl7XKF1whzx7uDO -+W6nof8e/FO+qSvo+44scqkhgynJM8CgqhDkYad8TX17r2/I9tFnBpqaXlSWE19j -/sIS+Ub6AtkUOZER2ljVktx16lnPX/BofLFTZkQzVMUCDBTyh2y9XDBhS2wcVBHz -VmKrmoJwmxy9GAkCgcAZjN13DLZH+XDbO37aq2S84iRuKXBXBvqpoRK9+z+jnAug -IQWXRyiMFL9kiliFZSLhZAz3plG/5kcf2t3nQhbe/HxHWjY9WZ1u4IrpPGsyxiei -mpIdc7vpyrDVee8SQuG937lFfVH1R+So+JiOnc3xJX/YAa4EnYvW+DgvypYY+Byr -idBBdaedpijEYk0kJEP0v/j/k/9xrO6aDTnvMBPu6qIQrSuwJu3DIYWM+Gws2t56 -mtrOcTzUolrOPfoLn1UCgcEAmAfoAQ/L5Xpn/zdT2EI2iG8Uvuu7Vco828lhqdV9 -l38Ht5m1+rZ4ahrToUonlWRJFG39UpiJXsNT0F5oobpncJ1cYunp2iEWxdjbOJdI -5IkbZrPvRo359mcC2OQ8tGp2R9Jmuw9fQ4unGDvlsx7YOm0BsYcAOVZ0uCbyOlf7 -UrfVStb8a/lpLdwqx5QAE9Ud1MIgCmlZixFddZlf8nZkubXjbzGn29ENci4Fy5eN -a7kvzebvnXDimtuA8f1JDSMo ------END PRIVATE KEY----- diff --git a/Resources/Certificates/localhost.qaq.wiki/rootCA.pem b/Resources/Certificates/localhost.qaq.wiki/rootCA.pem deleted file mode 100644 index c4626b1..0000000 --- a/Resources/Certificates/localhost.qaq.wiki/rootCA.pem +++ /dev/null @@ -1,27 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIEiTCCAvGgAwIBAgIQEyAaeQIa3nhJBBk9QktD5jANBgkqhkiG9w0BAQsFADBd -MR4wHAYDVQQKExVta2NlcnQgZGV2ZWxvcG1lbnQgQ0ExGTAXBgNVBAsMEHFhcUBr -ZXNpYWkubG9jYWwxIDAeBgNVBAMMF21rY2VydCBxYXFAa2VzaWFpLmxvY2FsMB4X -DTI1MDkxNTE1MzMwOVoXDTM1MDkxNTE1MzMwOVowXTEeMBwGA1UEChMVbWtjZXJ0 -IGRldmVsb3BtZW50IENBMRkwFwYDVQQLDBBxYXFAa2VzaWFpLmxvY2FsMSAwHgYD -VQQDDBdta2NlcnQgcWFxQGtlc2lhaS5sb2NhbDCCAaIwDQYJKoZIhvcNAQEBBQAD -ggGPADCCAYoCggGBALAqhANsdcefv9MEuZ7cWNlP3i1fZG/6SlXmuoe8mk70ZcI3 -rlfoRGNwTPLBkKAIOTrYP+VRsF8oW3qJysyYzHU4E0Ul5johXZ2FVcb9oneWnp6N -UA47hHjFGFE/4yz6ktazn6A1PBxwY69sSvE5xOhJ9p/2OhLDlEwhEiLP1S2t20lo -3vEk/cp9qLtyMpVt6hHokyuL5ThP3kqCAomG2lCFJ8qVODq69Sh7/nZYtnfItq34 -uICxHMWZIzjDhBnFxOyfOi+v0zDQBEIrLH8uawLM5STu+ySbqdjQ0rtxcisbbuNi -npgkrpuIElQFBrNCdsBbBkpuXLZfO4842ZzUJUzJqcUE2NFRP9LGLgHVFWabnyye -ysYy+b0tPH6SIaYBrToMBJGq2WdrtGOCLnCKwLSR3wiw5kQ2+t0pgMq9wDa1VUOk -XAGjAikKMQehQkU+yxGC924X+8sGau6WDDF66eukaYpWxwvHS6r8kiKpmILgQweS -Vodmn3oMfBcoAcHKdwIDAQABo0UwQzAOBgNVHQ8BAf8EBAMCAgQwEgYDVR0TAQH/ -BAgwBgEB/wIBADAdBgNVHQ4EFgQUCg/G+waBCXwrN9pxWY4Mjq3wMokwDQYJKoZI -hvcNAQELBQADggGBAJUf2dSK3z1VfDU4vj1BudaMz+lGToSz7zMre5SB5osMPa6I -+vdkd4IEv/LCMZfMcC6tZsFFewaJxsQ2khfL34giyCYhuRHRqMqUe4GSOZYBnmqk -Pq+wfg65WhD79ECgxprE1HV9jp8XlmDDUKUAfg25lkISsMFPHvLOcfBjnRa9LUbx -O4sbCwSEn12h1eIEAGX3CUOxyqvBYG8sh4+LmcEMGMqja7Q5+XRhBgW1B4xesUa+ -tYb0oH0FSnq4vAyfnOsJyk58xxlb7iK729GwtLKW4ljEI6PH+reNYwVdILqIYI2R -97281U2WIB4ZHwi3QQRP422smCTsedRCRVGxfdywm2roZVJqfPmqqsYVdkpeVkN7 -6XWH8mM2VoQlK+FbDpul+q71JpizUvHmFAklMB0CTnHudg2uRz18F5F1bANfvlE4 -JgUvVbM8HmrD9eGH+/KgoGp/yGlnRYHwSspf/McUs4Qu90DLQ2c4CIDroCYk0YrX -pzqJn1DksZBrTv3ZJg== ------END CERTIFICATE----- diff --git a/Resources/i18n/zh-Hans/README.md b/Resources/i18n/zh-Hans/README.md index ff9498d..98854f2 100644 --- a/Resources/i18n/zh-Hans/README.md +++ b/Resources/i18n/zh-Hans/README.md @@ -25,12 +25,12 @@ ### 前提条件 - [iOS App Signer](https://dantheman827.github.io/ios-app-signer/) -- 用于 `app.localhost.qaq.wiki` 的自签名证书(在“设置”中安装) +- 使用安装向导生成的二维码下载证书,并在“设置 → 通用 → 关于本机 → 证书信任设置”中启用完全信任 ### 问题排查 - 对于类似 [#1](https://github.com/Lakr233/Asspp/issues/1) 的问题,请使用提供的签名工具。 -- 如果安装失败,请确保证书已正确安装。 +- 如果安装失败,请确认目标设备已扫描证书二维码并在系统设置中启用信任。 - 如果应用崩溃或退出,请确认您已登录 App Store 账户,并且您的设备系统版本受支持。 ### 安装方式对比