feat(ios): add native app foundation and unsigned ipa build

This commit is contained in:
chick
2026-09-09 00:05:51 +08:00
parent fda3e11f1e
commit 54eb8dcb86
29 changed files with 1680 additions and 0 deletions
+1
View File
@@ -9,4 +9,5 @@ apps/web/dist/
.hermes/
.local-run/
artifacts/
build/
data/
+1
View File
@@ -0,0 +1 @@
.build/
+21
View File
@@ -0,0 +1,21 @@
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "MultiSimAdmin",
platforms: [.iOS(.v16), .macOS(.v13)],
products: [
.executable(name: "MultiSimAdmin", targets: ["MultiSimAdmin"])
],
dependencies: [],
targets: [
.executableTarget(
name: "MultiSimAdmin",
exclude: ["Resources/Info.plist"]
),
.testTarget(
name: "MultiSimAdminTests",
dependencies: ["MultiSimAdmin"]
)
]
)
@@ -0,0 +1,6 @@
public enum AppMetadata {
public static let name = "MultiSimAdmin"
public static let bundleIdentifier = "fun.chick.multi-simadmin"
public static let marketingVersion = "0.1.0"
public static let buildVersion = "1"
}
@@ -0,0 +1,147 @@
import SwiftUI
struct FleetView: View {
@StateObject private var model: FleetViewModel
@Environment(\.refresh) private var refreshEnvironment
init(model: FleetViewModel? = nil) {
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeFleetModel())
}
var body: some View {
NavigationStack {
ZStack {
AITheme.canvas.ignoresSafeArea()
ScrollView {
LazyVStack(spacing: 12) {
searchField
if model.rows.isEmpty && !model.isLoading {
AICard {
VStack(spacing: 10) {
Image(systemName: "server.rack")
.font(.system(size: 28))
.foregroundStyle(AITheme.muted)
Text("暂无实例")
.font(.headline)
Text("添加 SimAdmin 实例后即可开始管理。")
.font(.subheadline)
.foregroundStyle(AITheme.muted)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 18)
}
}
ForEach(model.rows) { row in
instanceCard(row)
}
}
.padding(16)
}
}
.navigationTitle("节点总览")
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
Button {
model.selectionMode.toggle()
model.selectedIDs.removeAll()
} label: {
Label("批量选择", systemImage: model.selectionMode ? "xmark" : "checklist")
}
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular))
Button {
model.showingForm = true
} label: {
Label("添加实例", systemImage: "plus")
}
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular))
}
}
.refreshable {
await model.refresh()
}
.task {
await model.refresh()
}
.sheet(isPresented: $model.showingForm) {
InstanceFormView(model: model)
}
}
}
private var searchField: some View {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundStyle(AITheme.muted)
TextField("搜索实例或地址", text: $model.searchText)
.textFieldStyle(.plain)
.onSubmit {
Task { await model.refresh() }
}
}
.padding(.horizontal, 14)
.frame(minHeight: AITheme.buttonHeight)
.background(AITheme.surface)
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius))
.overlay {
RoundedRectangle(cornerRadius: AITheme.buttonRadius)
.strokeBorder(AITheme.line)
}
}
private func instanceCard(_ row: FleetViewModel.Row) -> some View {
AICard {
HStack(alignment: .top, spacing: 12) {
if model.selectionMode {
Button {
toggle(row.id)
} label: {
Image(systemName: model.selectedIDs.contains(row.id)
? "checkmark.circle.fill" : "circle")
.font(.system(size: 20))
.foregroundStyle(model.selectedIDs.contains(row.id) ? AITheme.primary : AITheme.muted)
}
.buttonStyle(.plain)
.frame(width: 32, height: 32)
}
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Text(row.instance.name)
.font(.headline)
.foregroundStyle(AITheme.ink)
Spacer()
AITag(row.status.label, tint: statusColor(row.status))
}
Text(row.instance.origin.absoluteString)
.font(.subheadline)
.foregroundStyle(AITheme.muted)
if !row.instance.tags.isEmpty {
HStack(spacing: 6) {
ForEach(row.instance.tags, id: \.self) { tag in
AITag(tag)
}
}
}
}
}
}
}
private func statusColor(_ status: ConnectionStatus) -> Color {
switch status {
case .online: return AITheme.success
case .offline: return AITheme.danger
case .authRequired: return AITheme.warning
case .degraded: return AITheme.warning
case .unknown: return AITheme.muted
}
}
private func toggle(_ id: UUID) {
if model.selectedIDs.contains(id) {
model.selectedIDs.remove(id)
} else {
model.selectedIDs.insert(id)
}
}
}
@@ -0,0 +1,87 @@
import Foundation
@MainActor
final class FleetViewModel: ObservableObject {
struct Row: Equatable, Identifiable {
let id: UUID
var instance: Instance
var status: ConnectionStatus
var testing: Bool
}
@Published var rows: [Row] = []
@Published var searchText = ""
@Published var selectedIDs: Set<UUID> = []
@Published var selectionMode = false
@Published var isLoading = false
@Published var errorMessage: String?
@Published var showingForm = false
private let repository: any InstanceRepository
private let probe: ConnectionProbe
private let credentials: any CredentialStore
init(
repository: any InstanceRepository,
probe: ConnectionProbe,
credentials: any CredentialStore
) {
self.repository = repository
self.probe = probe
self.credentials = credentials
}
func refresh() async {
isLoading = true
errorMessage = nil
do {
let query = InstanceQuery(search: searchText.isEmpty ? nil : searchText)
let instances = try await repository.list(query)
rows = instances.map {
Row(id: $0.id, instance: $0, status: .unknown, testing: true)
}
await withTaskGroup(of: Void.self) { group in
for instance in instances {
group.addTask { [probe] in
let result = try? await probe.test(instance)
await self.applyProbe(instance.id, result)
}
}
}
} catch {
errorMessage = "无法刷新节点列表"
}
isLoading = false
}
func add(_ input: InstanceInput) async throws {
let created = try await repository.create(input)
if let password = input.password, !password.isEmpty {
try credentials.set(instanceID: created.id, password: password)
}
showingForm = false
await refresh()
}
func delete(_ ids: [UUID]) async {
for id in ids {
if let row = rows.first(where: { $0.id == id }) {
do {
try await repository.delete(id, expectedRevision: row.instance.configRevision)
try credentials.remove(instanceID: id)
} catch {
errorMessage = "删除 \(row.instance.name) 失败"
}
}
}
selectedIDs.removeAll()
selectionMode = false
await refresh()
}
private func applyProbe(_ id: UUID, _ result: ConnectionResult?) {
guard let index = rows.firstIndex(where: { $0.id == id }) else { return }
rows[index].status = result?.status ?? .unknown
rows[index].testing = false
}
}
@@ -0,0 +1,98 @@
import SwiftUI
struct InstanceFormView: View {
@Environment(\.dismiss) private var dismiss
@ObservedObject var model: FleetViewModel
@State private var name = ""
@State private var originText = ""
@State private var tagsText = ""
@State private var password = ""
@State private var validationMessage: String?
var body: some View {
NavigationStack {
ZStack {
AITheme.canvas.ignoresSafeArea()
ScrollView {
VStack(spacing: 12) {
AICard {
VStack(alignment: .leading, spacing: 14) {
field("名称", text: $name, placeholder: "Home Node")
field("地址", text: $originText, placeholder: "http://192.168.1.10:3000")
field("标签", text: $tagsText, placeholder: "home, primary")
SecureField("实例密码(可选)", text: $password)
.textContentType(.password)
.padding(.horizontal, 12)
.frame(minHeight: 38)
.background(AITheme.canvas)
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius))
}
}
if let validationMessage {
Text(validationMessage)
.font(.subheadline)
.foregroundStyle(AITheme.danger)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.padding(16)
}
}
.navigationTitle("添加实例")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("取消") { dismiss() }
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular))
}
ToolbarItem(placement: .confirmationAction) {
Button("保存") {
save()
}
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular))
}
}
}
}
private func field(_ title: String, text: Binding<String>, placeholder: String) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.subheadline.weight(.semibold))
.foregroundStyle(AITheme.ink)
TextField(placeholder, text: text)
.textFieldStyle(.plain)
.padding(.horizontal, 12)
.frame(minHeight: 38)
.background(AITheme.canvas)
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius))
}
}
private func save() {
guard let origin = URL(string: originText.trimmingCharacters(in: .whitespacesAndNewlines)),
origin.scheme == "http" || origin.scheme == "https",
origin.host != nil
else {
validationMessage = "请输入有效的 HTTP 或 HTTPS 根地址。"
return
}
let tags = tagsText
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
let input = InstanceInput(
name: name,
origin: origin,
tags: tags,
password: password.isEmpty ? nil : password
)
Task {
do {
try await model.add(input)
dismiss()
} catch {
validationMessage = "保存失败,请检查地址是否重复。"
}
}
}
}
@@ -0,0 +1,107 @@
import Foundation
public enum RepositoryError: Error, Equatable, Sendable {
case invalidOrigin
case duplicateOrigin
case revisionConflict
case notFound
case databaseFailed
}
public struct Instance: Codable, Equatable, Identifiable, Sendable {
public let id: UUID
public var name: String
public var origin: URL
public var tags: [String]
public var groupId: UUID?
public var credentialConfigured: Bool
public var configRevision: Int
public let createdAt: Date
public var updatedAt: Date
public init(
id: UUID,
name: String,
origin: URL,
tags: [String] = [],
groupId: UUID? = nil,
credentialConfigured: Bool = false,
configRevision: Int = 1,
createdAt: Date,
updatedAt: Date
) {
self.id = id
self.name = name
self.origin = origin
self.tags = tags
self.groupId = groupId
self.credentialConfigured = credentialConfigured
self.configRevision = configRevision
self.createdAt = createdAt
self.updatedAt = updatedAt
}
}
public struct InstanceInput: Sendable {
public var name: String
public var origin: URL
public var tags: [String]
public var groupId: UUID?
public var password: String?
public init(
name: String,
origin: URL,
tags: [String] = [],
groupId: UUID? = nil,
password: String? = nil
) {
self.name = name
self.origin = origin
self.tags = tags
self.groupId = groupId
self.password = password
}
}
public struct InstancePatch: Sendable {
public var name: String?
public var origin: URL?
public var tags: [String]?
public var groupId: UUID?
public var password: String?
public init(
name: String? = nil,
origin: URL? = nil,
tags: [String]? = nil,
groupId: UUID? = nil,
password: String? = nil
) {
self.name = name
self.origin = origin
self.tags = tags
self.groupId = groupId
self.password = password
}
}
public struct InstanceQuery: Sendable {
public var search: String?
public var tags: [String]?
public var groupId: UUID?
public init(search: String? = nil, tags: [String]? = nil, groupId: UUID? = nil) {
self.search = search
self.tags = tags
self.groupId = groupId
}
}
public protocol InstanceRepository: Sendable {
func list(_ query: InstanceQuery) async throws -> [Instance]
func get(_ id: UUID) async throws -> Instance?
func create(_ input: InstanceInput) async throws -> Instance
func update(_ id: UUID, expectedRevision: Int, _ patch: InstancePatch) async throws -> Instance
func delete(_ id: UUID, expectedRevision: Int) async throws
}
@@ -0,0 +1,32 @@
import SwiftUI
@main
struct MultiSimAdminApp: App {
var body: some Scene {
WindowGroup {
TabView {
FleetView()
.tabItem {
Label("节点", systemImage: "server.rack")
}
Text("消息")
.tabItem {
Label("消息", systemImage: "message")
}
Text("通知")
.tabItem {
Label("通知", systemImage: "bell")
}
Text("日志")
.tabItem {
Label("日志", systemImage: "doc.text")
}
Text("设置")
.tabItem {
Label("设置", systemImage: "gearshape")
}
}
.tint(AITheme.primary)
}
}
}
@@ -0,0 +1,186 @@
import Foundation
public struct FileInstanceRepository: InstanceRepository {
private struct Store: Codable {
var instances: [Instance] = []
}
private let location: URL
private let encoder: JSONEncoder
private let decoder: JSONDecoder
private let queue = DispatchQueue(label: "fun.chick.multi-simadmin.instance-repository")
public init(location: URL) {
self.location = location
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
self.encoder = encoder
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
self.decoder = decoder
}
public func list(_ query: InstanceQuery) async throws -> [Instance] {
try await run { store in
var instances = store.instances
if let search = query.search?.trimmingCharacters(in: .whitespacesAndNewlines),
!search.isEmpty {
instances = instances.filter {
$0.name.localizedCaseInsensitiveContains(search)
|| $0.origin.absoluteString.localizedCaseInsensitiveContains(search)
}
}
if let tags = query.tags, !tags.isEmpty {
instances = instances.filter { !Set($0.tags).isDisjoint(with: tags) }
}
if let groupId = query.groupId {
instances = instances.filter { $0.groupId == groupId }
}
return instances.sorted { $0.name.localizedCompare($1.name) == .orderedAscending }
}
}
public func get(_ id: UUID) async throws -> Instance? {
try await run { $0.instances.first { $0.id == id } }
}
public func create(_ input: InstanceInput) async throws -> Instance {
try await run { store in
guard !input.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw RepositoryError.invalidOrigin
}
let origin = try Self.normalizedOrigin(input.origin)
guard !store.instances.contains(where: { $0.origin == origin }) else {
throw RepositoryError.duplicateOrigin
}
let now = Date()
let instance = Instance(
id: UUID(),
name: input.name.trimmingCharacters(in: .whitespacesAndNewlines),
origin: origin,
tags: Self.normalizedTags(input.tags),
groupId: input.groupId,
credentialConfigured: !(input.password ?? "").isEmpty,
createdAt: now,
updatedAt: now
)
store.instances.append(instance)
return instance
}
}
public func update(_ id: UUID, expectedRevision: Int, _ patch: InstancePatch) async throws -> Instance {
try await run { store in
guard let index = store.instances.firstIndex(where: { $0.id == id }) else {
throw RepositoryError.notFound
}
guard store.instances[index].configRevision == expectedRevision else {
throw RepositoryError.revisionConflict
}
var instance = store.instances[index]
if let name = patch.name {
let normalized = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalized.isEmpty else { throw RepositoryError.invalidOrigin }
instance.name = normalized
}
if let origin = patch.origin {
let normalized = try Self.normalizedOrigin(origin)
guard !store.instances.contains(where: { $0.id != id && $0.origin == normalized }) else {
throw RepositoryError.duplicateOrigin
}
instance.origin = normalized
}
if let tags = patch.tags {
instance.tags = Self.normalizedTags(tags)
}
if patch.groupId != nil || instance.groupId != nil {
instance.groupId = patch.groupId
}
if let password = patch.password {
instance.credentialConfigured = !password.isEmpty
}
instance.updatedAt = Date()
instance.configRevision += 1
store.instances[index] = instance
return instance
}
}
public func delete(_ id: UUID, expectedRevision: Int) async throws {
try await run { store in
guard let index = store.instances.firstIndex(where: { $0.id == id }) else {
throw RepositoryError.notFound
}
guard store.instances[index].configRevision == expectedRevision else {
throw RepositoryError.revisionConflict
}
store.instances.remove(at: index)
}
}
private func run<T: Sendable>(_ transform: @escaping @Sendable (inout Store) throws -> T) async throws -> T {
try await withCheckedThrowingContinuation { continuation in
queue.async {
do {
var store = try self.readStore()
let output = try transform(&store)
try self.writeStore(store)
continuation.resume(returning: output)
} catch {
continuation.resume(throwing: error)
}
}
}
}
private func readStore() throws -> Store {
guard FileManager.default.fileExists(atPath: location.path) else {
return Store()
}
do {
return try decoder.decode(Store.self, from: Data(contentsOf: location))
} catch {
throw RepositoryError.databaseFailed
}
}
private func writeStore(_ store: Store) throws {
do {
let directory = location.deletingLastPathComponent()
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try encoder.encode(store).write(to: location, options: [.atomic])
} catch {
throw RepositoryError.databaseFailed
}
}
private static func normalizedOrigin(_ url: URL) throws -> URL {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
components.scheme == "http" || components.scheme == "https",
let host = components.host, !host.isEmpty
else {
throw RepositoryError.invalidOrigin
}
guard components.path.isEmpty || components.path == "/" else {
throw RepositoryError.invalidOrigin
}
var normalized = components
normalized.path = ""
normalized.query = nil
normalized.fragment = nil
guard let result = normalized.url else {
throw RepositoryError.invalidOrigin
}
return result
}
private static func normalizedTags(_ tags: [String]) -> [String] {
var seen = Set<String>()
return tags
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty && seen.insert($0.lowercased()).inserted }
}
}
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key><string>zh_CN</string>
<key>CFBundleDisplayName</key><string>MultiSimAdmin</string>
<key>CFBundleExecutable</key><string>MultiSimAdmin</string>
<key>CFBundleIdentifier</key><string>fun.chick.multi-simadmin</string>
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
<key>CFBundleName</key><string>MultiSimAdmin</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>0.1.0</string>
<key>CFBundleVersion</key><string>1</string>
<key>LSRequiresIPhoneOS</key><true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key><true/>
</dict>
<key>NSPrincipalClass</key><string>UIApplication</string>
<key>UILaunchScreen</key>
<dict/>
<key>UIRequiredDeviceCapabilities</key>
<array><string>arm64</string></array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
@@ -0,0 +1,62 @@
import Foundation
public protocol SimAdminClientProtocol: Sendable {
func send(_ route: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse
}
extension SimAdminClient: SimAdminClientProtocol {}
public enum ConnectionStatus: String, Equatable, Sendable {
case online
case offline
case authRequired
case degraded
case unknown
public var label: String {
switch self {
case .online: return "在线"
case .offline: return "离线"
case .authRequired: return "需登录"
case .degraded: return "降级"
case .unknown: return "未知"
}
}
}
public struct ConnectionResult: Equatable, Sendable {
public let status: ConnectionStatus
public let checkedAt: Date
public init(status: ConnectionStatus, checkedAt: Date) {
self.status = status
self.checkedAt = checkedAt
}
}
public struct ConnectionProbe: Sendable {
private let client: any SimAdminClientProtocol
public init(client: any SimAdminClientProtocol) {
self.client = client
}
public func test(_ instance: Instance) async throws -> ConnectionResult {
do {
let response = try await client.send(.health, baseURL: instance.origin, cookie: nil)
if response.status == 401 || response.status == 403 {
return ConnectionResult(status: .authRequired, checkedAt: Date())
}
return ConnectionResult(status: .online, checkedAt: Date())
} catch {
switch error {
case UpstreamError.unauthorized:
return ConnectionResult(status: .authRequired, checkedAt: Date())
case UpstreamError.timeout, is URLError:
return ConnectionResult(status: .offline, checkedAt: Date())
default:
return ConnectionResult(status: .degraded, checkedAt: Date())
}
}
}
}
@@ -0,0 +1,17 @@
import Foundation
enum DefaultContainer {
static func documentFileURL(_ name: String) -> URL {
let directory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
return directory.appendingPathComponent(name)
}
@MainActor
static func makeFleetModel() -> FleetViewModel {
FleetViewModel(
repository: FileInstanceRepository(location: documentFileURL("instances.json")),
probe: ConnectionProbe(client: SimAdminClient()),
credentials: KeychainCredentialStore()
)
}
}
@@ -0,0 +1,62 @@
import Foundation
public struct SessionMetadata: Equatable, Sendable {
public let instanceID: UUID
public let authenticated: Bool
public let checkedAt: Date
public init(instanceID: UUID, authenticated: Bool, checkedAt: Date) {
self.instanceID = instanceID
self.authenticated = authenticated
self.checkedAt = checkedAt
}
}
public struct InstanceSessionService: Sendable {
private let store: InstanceSessionStore
private let client: any SimAdminClientProtocol
public init(store: InstanceSessionStore, client: any SimAdminClientProtocol) {
self.store = store
self.client = client
}
public func login(_ instance: Instance, password: String) async throws -> SessionMetadata {
do {
let response = try await client.send(
.authLogin(password: password),
baseURL: instance.origin,
cookie: nil
)
if let cookie = Self.sessionCookie(response.setCookie) {
await store.set(instance.id, origin: instance.origin, cookie: cookie)
return SessionMetadata(instanceID: instance.id, authenticated: true, checkedAt: Date())
}
} catch {
// Rejected credentials fall through to the same explicit unauthenticated state.
}
await store.clear(instance.id)
return SessionMetadata(instanceID: instance.id, authenticated: false, checkedAt: Date())
}
public func logout(_ instance: Instance) async throws -> SessionMetadata {
if let cookie = await store.cookie(for: instance.id, origin: instance.origin) {
_ = try? await client.send(.authLogout, baseURL: instance.origin, cookie: cookie)
}
await store.clear(instance.id)
return SessionMetadata(instanceID: instance.id, authenticated: false, checkedAt: Date())
}
public func cookie(for instance: Instance) async -> String? {
await store.cookie(for: instance.id, origin: instance.origin)
}
static func sessionCookie(_ header: String?) -> String? {
guard let header,
let range = header.range(of: "simadmin_session=([^;\\s,]+)", options: .regularExpression)
else {
return nil
}
return String(header[range])
}
}
@@ -0,0 +1,61 @@
import Foundation
import Security
public protocol CredentialStore: Sendable {
func get(instanceID: UUID) throws -> String?
func set(instanceID: UUID, password: String) throws
func remove(instanceID: UUID) throws
}
public struct KeychainCredentialStore: CredentialStore {
private let service = "fun.chick.multi-simadmin.instance-password"
public init() {}
public func get(instanceID: UUID) throws -> String? {
var query = baseQuery(instanceID)
query[kSecReturnData as String] = true
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status == errSecSuccess else {
if status == errSecItemNotFound { return nil }
throw CredentialStoreError.keychainFailed
}
guard let data = item as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
public func set(instanceID: UUID, password: String) throws {
try self.remove(instanceID: instanceID)
let attributes: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: instanceID.uuidString,
kSecValueData as String: Data(password.utf8),
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
]
let status = SecItemAdd(attributes as CFDictionary, nil)
guard status == errSecSuccess else {
throw CredentialStoreError.keychainFailed
}
}
public func remove(instanceID: UUID) throws {
let status = SecItemDelete(baseQuery(instanceID) as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw CredentialStoreError.keychainFailed
}
}
private func baseQuery(_ instanceID: UUID) -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: instanceID.uuidString
]
}
}
public enum CredentialStoreError: Error, Equatable {
case keychainFailed
}
@@ -0,0 +1,59 @@
import SwiftUI
public struct AIButtonStyle: ButtonStyle {
public enum Kind: Hashable {
case primary
case secondary
case danger
var foregroundColor: Color {
switch self {
case .primary, .danger: return .white
case .secondary: return AITheme.ink
}
}
var backgroundColor: Color {
switch self {
case .primary: return AITheme.primary
case .secondary: return AITheme.surface
case .danger: return AITheme.danger
}
}
}
public enum Size: Hashable {
case compact
case regular
case large
}
private let kind: Kind
private let size: Size
public init(kind: Kind = .secondary, size: Size = .regular) {
self.kind = kind
self.size = size
}
public func makeBody(configuration: Configuration) -> some View {
let height: CGFloat = switch size {
case .compact: AITheme.compactButtonHeight
case .large: AITheme.largeButtonHeight
case .regular: AITheme.buttonHeight
}
configuration.label
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(kind.foregroundColor)
.frame(minWidth: 44, minHeight: height)
.padding(.horizontal, 14)
.background(kind.backgroundColor)
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius))
.overlay {
RoundedRectangle(cornerRadius: AITheme.buttonRadius)
.strokeBorder(kind == .secondary ? AITheme.line : .clear)
}
.opacity(configuration.isPressed ? 0.78 : 1)
}
}
@@ -0,0 +1,57 @@
import SwiftUI
public struct AICard<Content: View>: View {
private let content: Content
public init(@ViewBuilder content: () -> Content) {
self.content = content()
}
public var body: some View {
content
.padding(16)
.background(AITheme.surface)
.clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius))
.overlay {
RoundedRectangle(cornerRadius: AITheme.cardRadius)
.strokeBorder(AITheme.line)
}
}
}
public struct AITag: View {
private let text: String
private let tint: Color
public init(_ text: String, tint: Color = AITheme.primary) {
self.text = text
self.tint = tint
}
public var body: some View {
Text(text)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(tint)
.padding(.horizontal, 9)
.frame(minHeight: 26)
.background(tint.opacity(0.10))
.clipShape(Capsule())
}
}
public struct AIBrandBar: View {
public init() {}
public var body: some View {
HStack(spacing: 10) {
Image(systemName: "antenna.radiowaves.left.and.right")
Text(AppMetadata.name)
.font(.headline)
Spacer()
}
.foregroundStyle(.white)
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color(red: 0.075, green: 0.118, blue: 0.188))
}
}
@@ -0,0 +1,21 @@
import SwiftUI
public enum AITheme {
public static let canvas = Color(red: 0.953, green: 0.961, blue: 0.973)
public static let surface = Color.white
public static let ink = Color(red: 0.110, green: 0.153, blue: 0.220)
public static let muted = Color(red: 0.435, green: 0.486, blue: 0.561)
public static let line = Color(red: 0.878, green: 0.898, blue: 0.922)
public static let primary = Color(red: 0.067, green: 0.467, blue: 0.431)
public static let danger = Color(red: 0.714, green: 0.302, blue: 0.282)
public static let warning = Color(red: 0.722, green: 0.471, blue: 0.067)
public static let success = Color(red: 0.082, green: 0.584, blue: 0.439)
public static let buttonHeight: CGFloat = 38
public static let compactButtonHeight: CGFloat = 34
public static let largeButtonHeight: CGFloat = 42
public static let iconButtonSize: CGFloat = 40
public static let buttonRadius: CGFloat = 10
public static let cardRadius: CGFloat = 14
}
@@ -0,0 +1,20 @@
import Foundation
public actor InstanceSessionStore {
private var cookies: [UUID: (origin: URL, cookie: String)] = [:]
public init() {}
public func cookie(for id: UUID, origin: URL) -> String? {
guard let value = cookies[id], value.origin == origin else { return nil }
return value.cookie
}
public func set(_ id: UUID, origin: URL, cookie: String) {
cookies[id] = (origin, cookie)
}
public func clear(_ id: UUID) {
cookies.removeValue(forKey: id)
}
}
@@ -0,0 +1,71 @@
import Foundation
public struct UpstreamResponse: Equatable, Sendable {
public let status: Int
public let data: Data
public let setCookie: String?
public init(status: Int, data: Data, setCookie: String? = nil) {
self.status = status
self.data = data
self.setCookie = setCookie
}
}
public struct SimAdminClient: Sendable {
private let session: URLSession
private let maximumBytes: Int
public init(session: URLSession = .shared, maximumBytes: Int = 262_144) {
self.session = session
self.maximumBytes = maximumBytes
}
public func send(
_ route: UpstreamRoute,
baseURL: URL,
cookie: String?
) async throws -> UpstreamResponse {
guard baseURL.scheme == "http" || baseURL.scheme == "https",
baseURL.path.isEmpty || baseURL.path == "/"
else {
throw UpstreamError.invalidRequest
}
guard let url = URL(string: baseURL.absoluteString + route.path) else {
throw UpstreamError.invalidRequest
}
var request = URLRequest(url: url, timeoutInterval: 30)
request.httpMethod = route.method
request.httpBody = route.body
request.setValue("application/json", forHTTPHeaderField: "accept")
if route.body != nil {
request.setValue("application/json", forHTTPHeaderField: "content-type")
}
if let cookie {
request.setValue(cookie, forHTTPHeaderField: "cookie")
}
do {
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse, data.count <= maximumBytes else {
throw UpstreamError.invalidResponse
}
switch http.statusCode {
case 200...299:
break
case 401: throw UpstreamError.unauthorized
case 403: throw UpstreamError.forbidden
case 404, 405: throw UpstreamError.notSupported
default: throw UpstreamError.transportFailed
}
let setCookie = http.allHeaderFields["Set-Cookie"] as? String
return UpstreamResponse(status: http.statusCode, data: data, setCookie: setCookie)
} catch let error as UpstreamError {
throw error
} catch let error as URLError where error.code == .timedOut {
throw UpstreamError.timeout
} catch {
throw UpstreamError.transportFailed
}
}
}
@@ -0,0 +1,70 @@
import Foundation
public enum UpstreamRoute: Equatable, Sendable {
case health
case device
case sim
case network
case signalStrength
case connectivity
case smsList(limit: Int, offset: Int, direction: String?)
case smsSend(phoneNumber: String, content: String)
case smsBatchDelete(ids: [Int])
case authLogin(password: String)
case authLogout
public var method: String {
switch self {
case .health, .device, .sim, .network, .signalStrength, .connectivity, .smsList:
return "GET"
case .smsSend, .smsBatchDelete, .authLogin, .authLogout:
return "POST"
}
}
public var path: String {
switch self {
case .health: return "/api/health"
case .device: return "/api/device"
case .sim: return "/api/sim"
case .network: return "/api/network"
case .signalStrength: return "/api/network/signal-strength"
case .connectivity: return "/api/connectivity"
case .smsList(let limit, let offset, let direction):
var items = ["limit=\(limit)", "offset=\(offset)"]
if let direction {
items.append("direction=\(direction)")
}
return "/api/sms/list?\(items.joined(separator: "&"))"
case .smsSend: return "/api/sms/send"
case .smsBatchDelete: return "/api/sms/batch-delete"
case .authLogin: return "/api/auth/login"
case .authLogout: return "/api/auth/logout"
}
}
public var body: Data? {
switch self {
case .smsSend(let phoneNumber, let content):
return try? JSONSerialization.data(
withJSONObject: ["phone_number": phoneNumber, "content": content]
)
case .smsBatchDelete(let ids):
return try? JSONSerialization.data(withJSONObject: ["ids": ids])
case .authLogin(let password):
return try? JSONSerialization.data(withJSONObject: ["password": password])
default:
return nil
}
}
}
public enum UpstreamError: Error, Equatable {
case invalidRequest
case unauthorized
case forbidden
case notSupported
case timeout
case transportFailed
case invalidResponse
}
@@ -0,0 +1,23 @@
import SwiftUI
import XCTest
@testable import MultiSimAdmin
final class AIThemeTests: XCTestCase {
func testControlDimensionsMatchWebScale() {
XCTAssertEqual(AITheme.buttonHeight, 38, accuracy: 0.1)
XCTAssertEqual(AITheme.compactButtonHeight, 34, accuracy: 0.1)
XCTAssertEqual(AITheme.largeButtonHeight, 42, accuracy: 0.1)
XCTAssertEqual(AITheme.buttonRadius, 10, accuracy: 0.1)
XCTAssertEqual(AITheme.iconButtonSize, 40, accuracy: 0.1)
}
func testButtonKindsHaveDistinctBaseColors() {
let colors = [
AIButtonStyle.Kind.primary,
.secondary,
.danger
].map(\.backgroundColor)
XCTAssertEqual(Set(colors).count, 3)
}
}
@@ -0,0 +1,12 @@
import XCTest
@testable import MultiSimAdmin
final class AppMetadataTests: XCTestCase {
func testMetadataUsesIPAFoundationContract() {
XCTAssertEqual(AppMetadata.name, "MultiSimAdmin")
XCTAssertEqual(AppMetadata.bundleIdentifier, "fun.chick.multi-simadmin")
XCTAssertEqual(AppMetadata.marketingVersion, "0.1.0")
XCTAssertEqual(AppMetadata.buildVersion, "1")
}
}
@@ -0,0 +1,45 @@
import XCTest
@testable import MultiSimAdmin
final class ConnectionProbeTests: XCTestCase {
func testProbeMapsUpstreamResults() async {
let unauthorized = ConnectionProbe(client: MockClient(status: 401))
let online = ConnectionProbe(client: MockClient(status: 200))
let unavailable = ConnectionProbe(client: MockClient(status: 0))
let instance = Self.instance()
let auth = try? await unauthorized.test(instance)
let reachable = try? await online.test(instance)
let offline = try? await unavailable.test(instance)
XCTAssertEqual(auth?.status, .authRequired)
XCTAssertEqual(reachable?.status, .online)
XCTAssertEqual(offline?.status, .offline)
}
private static func instance() -> Instance {
Instance(
id: UUID(),
name: "Probe",
origin: URL(string: "http://192.168.1.20:3000")!,
tags: [],
groupId: nil,
credentialConfigured: false,
configRevision: 1,
createdAt: Date(),
updatedAt: Date()
)
}
private struct MockClient: SimAdminClientProtocol {
let status: Int
func send(_: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse {
if status == 0 {
throw UpstreamError.timeout
}
return UpstreamResponse(status: status, data: Data("{}".utf8))
}
}
}
@@ -0,0 +1,133 @@
import XCTest
@testable import MultiSimAdmin
@MainActor
final class FleetViewModelTests: XCTestCase {
func testRefreshSortsByNameAndReportsStatus() async throws {
let repository = MemoryInstanceRepository()
let model = FleetViewModel(
repository: repository,
probe: ConnectionProbe(client: AlwaysOnlineClient()),
credentials: NoopCredentialStore()
)
try await model.add(InstanceInput(
name: "Zulu",
origin: URL(string: "http://192.168.1.30:3000")!,
tags: ["lab"],
password: nil
))
try await model.add(InstanceInput(
name: "Alpha",
origin: URL(string: "http://192.168.1.20:3000")!,
tags: [],
password: nil
))
await model.refresh()
XCTAssertEqual(model.rows.map(\.instance.name), ["Alpha", "Zulu"])
XCTAssertEqual(model.rows.map(\.status), [.online, .online])
}
func testAddValidatesPasswordAndDeleteRemovesCredential() async throws {
let repository = MemoryInstanceRepository()
let credentials = NoopCredentialStore()
let model = FleetViewModel(
repository: repository,
probe: ConnectionProbe(client: AlwaysOnlineClient()),
credentials: credentials
)
try await model.add(InstanceInput(
name: "Secret",
origin: URL(string: "http://192.168.1.40:3000")!,
password: "pass"
))
let row = try XCTUnwrap(model.rows.first)
XCTAssertTrue(row.instance.credentialConfigured)
XCTAssertEqual(credentials.saved[row.instance.id], "pass")
await model.delete([row.instance.id])
XCTAssertTrue(model.rows.isEmpty)
XCTAssertNil(credentials.saved[row.instance.id])
}
}
struct AlwaysOnlineClient: SimAdminClientProtocol {
func send(_: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse {
UpstreamResponse(status: 200, data: Data("{}".utf8))
}
}
final class NoopCredentialStore: CredentialStore {
private let lock = NSLock()
private var savedStorage: [UUID: String] = [:]
private var removedStorage: [UUID] = []
var saved: [UUID: String] {
lock.withLock { savedStorage }
}
var removed: [UUID] {
lock.withLock { removedStorage }
}
func get(instanceID: UUID) throws -> String? {
saved[instanceID]
}
func set(instanceID: UUID, password: String) throws {
lock.withLock { savedStorage[instanceID] = password }
}
func remove(instanceID: UUID) throws {
lock.withLock {
savedStorage[instanceID] = nil
removedStorage.append(instanceID)
}
}
}
final class MemoryInstanceRepository: InstanceRepository {
private let lock = NSLock()
private var itemsStorage: [Instance] = []
private var items: [Instance] {
get { lock.withLock { itemsStorage } }
set { lock.withLock { itemsStorage = newValue } }
}
func list(_: InstanceQuery) async throws -> [Instance] {
items.sorted { $0.name.localizedCompare($1.name) == .orderedAscending }
}
func get(_ id: UUID) async throws -> Instance? {
items.first { $0.id == id }
}
func create(_ input: InstanceInput) async throws -> Instance {
let now = Date()
let instance = Instance(
id: UUID(),
name: input.name,
origin: input.origin,
tags: input.tags,
groupId: input.groupId,
credentialConfigured: !(input.password ?? "").isEmpty,
configRevision: 1,
createdAt: now,
updatedAt: now
)
items.append(instance)
return instance
}
func update(_: UUID, expectedRevision: Int, _: InstancePatch) async throws -> Instance {
throw RepositoryError.notFound
}
func delete(_ id: UUID, expectedRevision: Int) async throws {
items.removeAll { $0.id == id }
}
}
@@ -0,0 +1,110 @@
import XCTest
@testable import MultiSimAdmin
final class InstanceRepositoryTests: XCTestCase {
private var location: URL!
override func setUp() {
super.setUp()
location = FileManager.default.temporaryDirectory
.appendingPathComponent("multi-simadmin-\(UUID().uuidString).json")
}
override func tearDown() {
try? FileManager.default.removeItem(at: location)
super.tearDown()
}
func testCreateListUpdateAndDeleteInstance() async throws {
let repository = FileInstanceRepository(location: location)
let input = InstanceInput(
name: "Lab Alpha",
origin: URL(string: "http://192.168.3.55:3000/")!,
tags: ["primary", "lab"],
groupId: nil,
password: "secret"
)
let created = try await repository.create(input)
XCTAssertEqual(created.name, "Lab Alpha")
XCTAssertEqual(created.origin.absoluteString, "http://192.168.3.55:3000")
XCTAssertEqual(created.tags, ["primary", "lab"])
XCTAssertEqual(created.configRevision, 1)
XCTAssertTrue(created.credentialConfigured)
let patch = InstancePatch(name: "Alpha", tags: ["lab"])
let updated = try await repository.update(created.id, expectedRevision: 1, patch)
XCTAssertEqual(updated.name, "Alpha")
XCTAssertEqual(updated.tags, ["lab"])
XCTAssertEqual(updated.configRevision, 2)
XCTAssertTrue(updated.credentialConfigured)
try await repository.delete(updated.id, expectedRevision: 2)
let afterDelete = try await repository.list(InstanceQuery())
XCTAssertTrue(afterDelete.isEmpty)
}
func testRejectsInvalidOriginDuplicateOriginAndStaleRevision() async throws {
let repository = FileInstanceRepository(location: location)
let origin = URL(string: "http://192.168.1.20:3000")!
let input = InstanceInput(name: "One", origin: origin, tags: [], groupId: nil, password: nil)
let first = try await repository.create(input)
do {
let nested = InstanceInput(
name: "Nested",
origin: URL(string: "http://192.168.1.20:3000/admin")!,
tags: [],
groupId: nil,
password: nil
)
_ = try await repository.create(nested)
XCTFail("Expected nested path rejection")
} catch let error as RepositoryError {
XCTAssertEqual(error, .invalidOrigin)
}
do {
_ = try await repository.create(input)
XCTFail("Expected duplicate-origin failure")
} catch let error as RepositoryError {
XCTAssertEqual(error, .duplicateOrigin)
}
do {
_ = try await repository.update(first.id, expectedRevision: 99, InstancePatch(name: "New"))
XCTFail("Expected stale-revision failure")
} catch let error as RepositoryError {
XCTAssertEqual(error, .revisionConflict)
}
}
func testListFiltersBySearchTagAndGroup() async throws {
let repository = FileInstanceRepository(location: location)
let groupId = UUID()
_ = try await repository.create(InstanceInput(
name: "Lab Alpha",
origin: URL(string: "http://192.168.1.10:3000")!,
tags: ["production"],
groupId: groupId,
password: nil
))
_ = try await repository.create(InstanceInput(
name: "Home Beta",
origin: URL(string: "http://192.168.1.20:3000")!,
tags: ["lab"],
groupId: nil,
password: nil
))
let search = try await repository.list(InstanceQuery(search: "alpha"))
XCTAssertEqual(search.map(\.name), ["Lab Alpha"])
let tagged = try await repository.list(InstanceQuery(tags: ["lab"]))
XCTAssertEqual(tagged.map(\.name), ["Home Beta"])
let grouped = try await repository.list(InstanceQuery(groupId: groupId))
XCTAssertEqual(grouped.map(\.name), ["Lab Alpha"])
let sorted = try await repository.list(InstanceQuery())
XCTAssertEqual(sorted.map(\.name), ["Home Beta", "Lab Alpha"])
}
}
@@ -0,0 +1,50 @@
import XCTest
@testable import MultiSimAdmin
final class InstanceSessionServiceTests: XCTestCase {
func testLoginStoresAndClearsSessionCookie() async throws {
let store = InstanceSessionStore()
let client = MockSessionClient(status: 204)
let service = InstanceSessionService(store: store, client: client)
let instance = Self.instance()
let result = try await service.login(instance, password: "secret")
XCTAssertTrue(result.authenticated)
let cookie = await store.cookie(for: instance.id, origin: instance.origin)
XCTAssertEqual(cookie, "simadmin_session=token")
let rejected = try await service.login(instance, password: "bad")
XCTAssertFalse(rejected.authenticated)
let removed = await store.cookie(for: instance.id, origin: instance.origin)
XCTAssertEqual(removed, nil)
}
private static func instance() -> Instance {
Instance(
id: UUID(),
name: "Session",
origin: URL(string: "http://192.168.1.20:3000")!,
configRevision: 1,
createdAt: Date(),
updatedAt: Date()
)
}
private struct MockSessionClient: SimAdminClientProtocol {
let status: Int
func send(
_ route: UpstreamRoute,
baseURL: URL,
cookie: String?
) async throws -> UpstreamResponse {
guard case let .authLogin(submitted) = route else {
return UpstreamResponse(status: 200, data: Data("{}.utf8".utf8), setCookie: nil)
}
let authorized = submitted == "secret"
let setCookie = authorized ? "simadmin_session=token; Path=/" : nil
return UpstreamResponse(status: authorized ? status : 200, data: Data("{}".utf8), setCookie: setCookie)
}
}
}
@@ -0,0 +1,54 @@
import XCTest
@testable import MultiSimAdmin
final class UpstreamRouteTests: XCTestCase {
func testKnownRoutesUseExactPathsMethodsAndPayloads() throws {
XCTAssertEqual(UpstreamRoute.health.method, "GET")
XCTAssertEqual(UpstreamRoute.health.path, "/api/health")
XCTAssertEqual(UpstreamRoute.device.path, "/api/device")
XCTAssertEqual(UpstreamRoute.sim.path, "/api/sim")
XCTAssertEqual(UpstreamRoute.network.path, "/api/network")
XCTAssertEqual(UpstreamRoute.signalStrength.path, "/api/network/signal-strength")
XCTAssertEqual(UpstreamRoute.smsList(limit: 100, offset: 0, direction: nil).path,
"/api/sms/list?limit=100&offset=0")
XCTAssertEqual(UpstreamRoute.smsList(limit: 20, offset: 2, direction: "outgoing").path,
"/api/sms/list?limit=20&offset=2&direction=outgoing")
let login = UpstreamRoute.authLogin(password: "secret")
XCTAssertEqual(login.method, "POST")
XCTAssertEqual(login.path, "/api/auth/login")
let loginObject = try XCTUnwrap(JSONSerialization.jsonObject(with: XCTUnwrap(login.body)) as? [String: String])
XCTAssertEqual(loginObject, ["password": "secret"])
let delete = UpstreamRoute.smsBatchDelete(ids: [2, 1])
XCTAssertEqual(delete.method, "POST")
XCTAssertEqual(delete.path, "/api/sms/batch-delete")
let deleteObject = try XCTUnwrap(
JSONSerialization.jsonObject(with: XCTUnwrap(delete.body)) as? [String: [Int]]
)
XCTAssertEqual(deleteObject, ["ids": [2, 1]])
}
func testSessionStoreIsIsolatedByInstanceAndOrigin() async {
let store = InstanceSessionStore()
let origin = URL(string: "http://192.168.1.10:3000")!
await store.set(UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, origin: origin, cookie: "a")
await store.set(UUID(uuidString: "00000000-0000-0000-0000-000000000002")!, origin: origin, cookie: "b")
let sameID = URL(string: "https://other.example")!
await store.set(UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, origin: sameID, cookie: "c")
await store.clear(UUID(uuidString: "00000000-0000-0000-0000-000000000001")!)
let first = await store.cookie(
for: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!,
origin: origin
)
let second = await store.cookie(
for: UUID(uuidString: "00000000-0000-0000-0000-000000000002")!,
origin: origin
)
XCTAssertEqual(first, nil)
XCTAssertEqual(second, "b")
}
}
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
IOS_ROOT="$ROOT/apps/ios"
BUILD_ROOT="$ROOT/build/ipa"
APP_NAME="MultiSimAdmin"
SDK_PATH="$(xcrun --sdk iphoneos --show-sdk-path)"
cd "$IOS_ROOT"
swift build --configuration release --product "$APP_NAME" \
--triple arm64-apple-ios16.0 --sdk "$SDK_PATH"
rm -rf "$BUILD_ROOT"
mkdir -p "$BUILD_ROOT/Payload/$APP_NAME.app"
cp ".build/arm64-apple-ios/release/$APP_NAME" \
"$BUILD_ROOT/Payload/$APP_NAME.app/$APP_NAME"
cp "Sources/$APP_NAME/Resources/Info.plist" \
"$BUILD_ROOT/Payload/$APP_NAME.app/Info.plist"
plutil -lint "$BUILD_ROOT/Payload/$APP_NAME.app/Info.plist"
cd "$BUILD_ROOT"
zip -qry "$APP_NAME.ipa" Payload
shasum -a 256 "$APP_NAME.ipa" > "$APP_NAME.ipa.sha256"
printf 'IPA: %s/%s.ipa\n' "$BUILD_ROOT" "$APP_NAME"
printf 'Size: %s bytes\n' "$(stat -f %z "$APP_NAME.ipa")"
printf 'SHA-256: '
cat "$APP_NAME.ipa.sha256"