From fa4732724027f614c620c34b0e093c96cbc4ac38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1?= Date: Mon, 26 Oct 2026 08:26:05 +0100 Subject: [PATCH 1/2] feat: add IPA Control SwiftUI client under apps/ --- .../IPAControl.xcodeproj/project.pbxproj | 125 +++++++++++ .../xcschemes/IPAControl.xcscheme | 23 ++ apps/IPA-Control/IPAControl/ContentView.swift | 209 ++++++++++++++++++ .../IPAControl/IPAControlApp.swift | 13 ++ .../IPAControl/IPAControlModel.swift | 137 ++++++++++++ apps/IPA-Control/IPAControl/Models.swift | 64 ++++++ apps/IPA-Control/README.md | 37 ++++ apps/README.md | 12 + 8 files changed, 620 insertions(+) create mode 100644 apps/IPA-Control/IPAControl.xcodeproj/project.pbxproj create mode 100644 apps/IPA-Control/IPAControl.xcodeproj/xcshareddata/xcschemes/IPAControl.xcscheme create mode 100644 apps/IPA-Control/IPAControl/ContentView.swift create mode 100644 apps/IPA-Control/IPAControl/IPAControlApp.swift create mode 100644 apps/IPA-Control/IPAControl/IPAControlModel.swift create mode 100644 apps/IPA-Control/IPAControl/Models.swift create mode 100644 apps/IPA-Control/README.md create mode 100644 apps/README.md diff --git a/apps/IPA-Control/IPAControl.xcodeproj/project.pbxproj b/apps/IPA-Control/IPAControl.xcodeproj/project.pbxproj new file mode 100644 index 0000000..7fb8c05 --- /dev/null +++ b/apps/IPA-Control/IPAControl.xcodeproj/project.pbxproj @@ -0,0 +1,125 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = {}; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + A00000000000000000000021 /* IPAControlApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000011 /* IPAControlApp.swift */; }; + A00000000000000000000022 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000012 /* ContentView.swift */; }; + A00000000000000000000023 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000013 /* Models.swift */; }; + A00000000000000000000024 /* IPAControlModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000014 /* IPAControlModel.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + A00000000000000000000010 /* IPAControl.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = IPAControl.app; sourceTree = BUILT_PRODUCTS_DIR; }; + A00000000000000000000011 /* IPAControlApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPAControlApp.swift; sourceTree = ""; }; + A00000000000000000000012 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + A00000000000000000000013 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; }; + A00000000000000000000014 /* IPAControlModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPAControlModel.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + A00000000000000000000031 /* Frameworks */ = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + A00000000000000000000001 = { + isa = PBXGroup; + children = ( + A00000000000000000000002 /* IPAControl */, + A00000000000000000000003 /* Products */, + ); + sourceTree = ""; + }; + A00000000000000000000002 /* IPAControl */ = { + isa = PBXGroup; + children = ( + A00000000000000000000011 /* IPAControlApp.swift */, + A00000000000000000000012 /* ContentView.swift */, + A00000000000000000000013 /* Models.swift */, + A00000000000000000000014 /* IPAControlModel.swift */, + ); + path = IPAControl; + sourceTree = ""; + }; + A00000000000000000000003 /* Products */ = { + isa = PBXGroup; + children = (A00000000000000000000010 /* IPAControl.app */,); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + A00000000000000000000040 /* IPAControl */ = { + isa = PBXNativeTarget; + buildConfigurationList = A00000000000000000000051 /* Build configuration list for PBXNativeTarget "IPAControl" */; + buildPhases = ( + A00000000000000000000032 /* Sources */, + A00000000000000000000031 /* Frameworks */, + A00000000000000000000033 /* Resources */, + ); + buildRules = (); + dependencies = (); + name = IPAControl; + productName = IPAControl; + productReference = A00000000000000000000010 /* IPAControl.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + A00000000000000000000041 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastUpgradeCheck = 1600; + TargetAttributes = {A00000000000000000000040 = {CreatedOnToolsVersion = 16.0; }; }; + }; + buildConfigurationList = A00000000000000000000050 /* Build configuration list for PBXProject */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = (en, Base,); + mainGroup = A00000000000000000000001; + productRefGroup = A00000000000000000000003 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = (A00000000000000000000040 /* IPAControl */,); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + A00000000000000000000033 /* Resources */ = {isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + A00000000000000000000032 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A00000000000000000000021 /* IPAControlApp.swift in Sources */, + A00000000000000000000022 /* ContentView.swift in Sources */, + A00000000000000000000023 /* Models.swift in Sources */, + A00000000000000000000024 /* IPAControlModel.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + A00000000000000000000061 /* Debug */ = {isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; GCC_C_LANGUAGE_STANDARD = gnu17; GCC_OPTIMIZATION_LEVEL = 0; GCC_PREPROCESSOR_DEFINITIONS = ("DEBUG=1", "$(inherited)",); IPHONEOS_DEPLOYMENT_TARGET = 17.0; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; name = Debug; }; + A00000000000000000000062 /* Release */ = {isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; COPY_PHASE_STRIP = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; GCC_C_LANGUAGE_STANDARD = gnu17; IPHONEOS_DEPLOYMENT_TARGET = 17.0; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; }; name = Release; }; + A00000000000000000000063 /* Debug */ = {isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = "IPA Control"; INFOPLIST_KEY_UILaunchScreen_Generation = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ("$(inherited)", "@executable_path/Frameworks",); MARKETING_VERSION = 0.1.0; PRODUCT_BUNDLE_IDENTIFIER = cz.caseycz.ipacontrol; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; }; name = Debug; }; + A00000000000000000000064 /* Release */ = {isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = "IPA Control"; INFOPLIST_KEY_UILaunchScreen_Generation = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ("$(inherited)", "@executable_path/Frameworks",); MARKETING_VERSION = 0.1.0; PRODUCT_BUNDLE_IDENTIFIER = cz.caseycz.ipacontrol; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; }; name = Release; }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + A00000000000000000000050 /* Build configuration list for PBXProject */ = {isa = XCConfigurationList; buildConfigurations = (A00000000000000000000061 /* Debug */, A00000000000000000000062 /* Release */,); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + A00000000000000000000051 /* Build configuration list for PBXNativeTarget "IPAControl" */ = {isa = XCConfigurationList; buildConfigurations = (A00000000000000000000063 /* Debug */, A00000000000000000000064 /* Release */,); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; +/* End XCConfigurationList section */ + }; + rootObject = A00000000000000000000041 /* Project object */; +} diff --git a/apps/IPA-Control/IPAControl.xcodeproj/xcshareddata/xcschemes/IPAControl.xcscheme b/apps/IPA-Control/IPAControl.xcodeproj/xcshareddata/xcschemes/IPAControl.xcscheme new file mode 100644 index 0000000..af0c053 --- /dev/null +++ b/apps/IPA-Control/IPAControl.xcodeproj/xcshareddata/xcschemes/IPAControl.xcscheme @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/IPA-Control/IPAControl/ContentView.swift b/apps/IPA-Control/IPAControl/ContentView.swift new file mode 100644 index 0000000..bdba37a --- /dev/null +++ b/apps/IPA-Control/IPAControl/ContentView.swift @@ -0,0 +1,209 @@ +import SwiftUI + +struct ContentView: View { + var body: some View { + TabView { + MyAppsView() + .tabItem { Label("Moje aplikace", systemImage: "square.stack.3d.up") } + SourcesView() + .tabItem { Label("Zdroje", systemImage: "square.grid.2x2") } + SettingsView() + .tabItem { Label("Nastavení", systemImage: "gearshape") } + } + .tint(.indigo) + } +} + +private struct MyAppsView: View { + var body: some View { + NavigationStack { + List { + Section { + ContentUnavailableView( + "Zatím žádné spravované aplikace", + systemImage: "app.badge.checkmark", + description: Text("Seznam bude dostupný po propojení podpisové a instalační služby. iOS běžným aplikacím nedovoluje zjistit úplný seznam nainstalovaných aplikací.") + ) + } + Section("Bezplatný Apple účet") { + LabeledContent("Současně aktivní aplikace", value: "max. 3") + LabeledContent("Registrovaná App ID", value: "max. 10 / 7 dní") + LabeledContent("Platnost profilu", value: "7 dní") + } + Section { + Label("Obnovení podpisu bude dostupné po připojení serveru a ověření párování iPhonu.", systemImage: "info.circle") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .navigationTitle("IPA Control") + } + } +} + +private struct SourcesView: View { + @EnvironmentObject private var model: IPAControlModel + @State private var showingAddSource = false + @State private var sourceURL = "" + @State private var errorMessage: String? + @State private var adding = false + + var body: some View { + NavigationStack { + List { + Section("Moje zdroje") { + if model.sources.isEmpty { + Text("Zatím nemáš žádný zdroj. Přidej adresu JSON ve formátu AltStore Classic.") + .foregroundStyle(.secondary) + } + ForEach(model.sources) { source in + VStack(alignment: .leading, spacing: 4) { + Text(source.name).font(.headline) + Text(source.url.absoluteString) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + .onDelete { offsets in + let removed = offsets.map { model.sources[$0] } + for source in removed { model.removeSource(source) } + } + } + Section("Katalog") { + if model.catalog.isEmpty { + Text("Přidané aplikace se zobrazí po načtení JSON zdrojů.") + .foregroundStyle(.secondary) + } + ForEach(model.catalog) { item in + VStack(alignment: .leading, spacing: 4) { + Text(item.app.name).font(.headline) + HStack { + Text(item.sourceName) + if let version = item.app.latestVersion { + Text("· v\(version.version)") + } + } + .font(.caption) + .foregroundStyle(.secondary) + Text(item.app.bundleIdentifier) + .font(.caption2) + .foregroundStyle(.tertiary) + .textSelection(.enabled) + } + .padding(.vertical, 3) + } + } + } + .navigationTitle("Zdroje") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { showingAddSource = true } label: { Image(systemName: "plus") } + } + ToolbarItem(placement: .topBarLeading) { + Button { + Task { await refresh() } + } label: { + if model.isLoading { ProgressView() } + else { Image(systemName: "arrow.clockwise") } + } + .disabled(model.isLoading) + } + } + .task { await refresh() } + .alert("Chyba", isPresented: Binding( + get: { errorMessage != nil }, + set: { if !$0 { errorMessage = nil } } + )) { Button("OK", role: .cancel) {} } message: { Text(errorMessage ?? "") } + .sheet(isPresented: $showingAddSource) { + NavigationStack { + Form { + Section("Adresa JSON") { + TextField("https://example.com/source.json", text: $sourceURL) + .textInputAutocapitalization(.never) + .keyboardType(.URL) + .autocorrectionDisabled() + } + Section { + Text("Podporujeme HTTPS zdroje kompatibilní s AltStore Classic.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .navigationTitle("Přidat zdroj") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Zrušit") { showingAddSource = false } + } + ToolbarItem(placement: .confirmationAction) { + Button(adding ? "Načítám…" : "Přidat") { + adding = true + Task { + do { + try await model.addSource(sourceURL) + sourceURL = "" + showingAddSource = false + } catch { + errorMessage = error.localizedDescription + } + adding = false + } + } + .disabled(adding || sourceURL.isEmpty) + } + } + } + } + } + } + + private func refresh() async { + do { try await model.refreshSources() } + catch { errorMessage = error.localizedDescription } + } +} + +private struct SettingsView: View { + @EnvironmentObject private var model: IPAControlModel + @State private var errorMessage: String? + @State private var testing = false + + var body: some View { + NavigationStack { + Form { + Section("Náš server") { + TextField("https://server.example.com", text: $model.serverURLString) + .textInputAutocapitalization(.never) + .keyboardType(.URL) + .autocorrectionDisabled() + LabeledContent("Stav", value: model.serverStatus) + Button(testing ? "Ověřuji…" : "Ověřit připojení") { + testing = true + Task { + do { try await model.checkServer() } + catch { errorMessage = error.localizedDescription } + testing = false + } + } + .disabled(testing) + } + Section("Budoucí funkce") { + Label("Podepisování IPA", systemImage: "signature") + Label("Obnovení profilů", systemImage: "arrow.clockwise.circle") + Label("Párování iPhonu", systemImage: "iphone.gen3") + } + Section("O aplikaci") { + LabeledContent("Verze", value: "0.1.0") + Text("První testovací klient. V této verzi neprobíhá instalace ani podepisování aplikací.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .navigationTitle("Nastavení") + .alert("Chyba", isPresented: Binding( + get: { errorMessage != nil }, + set: { if !$0 { errorMessage = nil } } + )) { Button("OK", role: .cancel) {} } message: { Text(errorMessage ?? "") } + } + } +} diff --git a/apps/IPA-Control/IPAControl/IPAControlApp.swift b/apps/IPA-Control/IPAControl/IPAControlApp.swift new file mode 100644 index 0000000..b800ba7 --- /dev/null +++ b/apps/IPA-Control/IPAControl/IPAControlApp.swift @@ -0,0 +1,13 @@ +import SwiftUI + +@main +struct IPAControlApp: App { + @StateObject private var model = IPAControlModel() + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(model) + } + } +} diff --git a/apps/IPA-Control/IPAControl/IPAControlModel.swift b/apps/IPA-Control/IPAControl/IPAControlModel.swift new file mode 100644 index 0000000..30957d9 --- /dev/null +++ b/apps/IPA-Control/IPAControl/IPAControlModel.swift @@ -0,0 +1,137 @@ +import Foundation +import SwiftUI + +@MainActor +final class IPAControlModel: ObservableObject { + @Published private(set) var sources: [SavedSource] = [] + @Published private(set) var catalog: [CatalogApp] = [] + @Published var serverURLString: String = "" { + didSet { UserDefaults.standard.set(serverURLString, forKey: Keys.serverURL) } + } + @Published private(set) var serverStatus: String = "Neověřeno" + @Published private(set) var isLoading = false + + private enum Keys { + static let sources = "ipaControl.sources.v1" + static let serverURL = "ipaControl.serverURL.v1" + } + + init() { + if let data = UserDefaults.standard.data(forKey: Keys.sources), + let decoded = try? JSONDecoder().decode([SavedSource].self, from: data) { + sources = decoded + } + serverURLString = UserDefaults.standard.string(forKey: Keys.serverURL) ?? "" + } + + static func httpsURL(from raw: String) throws -> URL { + guard let url = URL(string: raw.trimmingCharacters(in: .whitespacesAndNewlines)), + url.scheme?.lowercased() == "https", + let host = url.host, !host.isEmpty, + url.user == nil, url.password == nil else { + throw ClientError.invalidURL + } + return url + } + + private static func fetchSource(_ url: URL) async throws -> AltStoreSource { + var request = URLRequest(url: url) + request.timeoutInterval = 15 + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { throw ClientError.invalidURL } + guard (200...299).contains(http.statusCode) else { throw ClientError.networkStatus(http.statusCode) } + guard data.count <= 3_000_000 else { throw ClientError.oversizedSource } + + let result = try JSONDecoder().decode(AltStoreSource.self, from: data) + guard !result.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw ClientError.emptySource + } + let ids = result.apps.map(\.bundleIdentifier) + guard Set(ids).count == ids.count else { throw ClientError.duplicateBundleID } + return result + } + + func addSource(_ rawURL: String) async throws { + let url = try Self.httpsURL(from: rawURL) + guard !sources.contains(where: { $0.url == url }) else { throw ClientError.duplicateSource } + let decoded = try await Self.fetchSource(url) + sources.append(SavedSource(url: url, name: decoded.name)) + persistSources() + rebuildCatalog(for: sources.last!.id, name: decoded.name, apps: decoded.apps) + } + + func removeSource(_ source: SavedSource) { + sources.removeAll { $0.id == source.id } + catalog.removeAll { $0.sourceID == source.id } + persistSources() + } + + func refreshSources() async throws { + guard !isLoading else { return } + isLoading = true + defer { isLoading = false } + + var freshCatalog: [CatalogApp] = [] + var refreshedSources: [SavedSource] = [] + var firstError: Error? + + for source in sources { + do { + let manifest = try await Self.fetchSource(source.url) + refreshedSources.append(SavedSourceRecord.keepID(source, name: manifest.name)) + freshCatalog.append(contentsOf: manifest.apps.map { + CatalogApp(sourceID: source.id, sourceName: manifest.name, app: $0) + }) + } catch { + refreshedSources.append(source) + if firstError == nil { firstError = error } + } + } + sources = refreshedSources + catalog = freshCatalog + persistSources() + if let firstError { throw firstError } + } + + func checkServer() async throws { + let base = try Self.httpsURL(from: serverURLString) + let url = base.appendingPathComponent("health") + var request = URLRequest(url: url) + request.timeoutInterval = 10 + let (_, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { throw ClientError.invalidURL } + guard http.statusCode == 200 else { + serverStatus = "Nedostupný (HTTP \(http.statusCode))" + throw ClientError.networkStatus(http.statusCode) + } + serverStatus = "Dostupný" + } + + private func rebuildCatalog(for id: UUID, name: String, apps: [AltStoreApp]) { + catalog.removeAll { $0.sourceID == id } + catalog.append(contentsOf: apps.map { CatalogApp(sourceID: id, sourceName: name, app: $0) }) + } + + private func persistSources() { + if let encoded = try? JSONEncoder().encode(sources) { + UserDefaults.standard.set(encoded, forKey: Keys.sources) + } + } +} + +private enum SavedSourceRecord { + static func keepID(_ old: SavedSource, name: String) -> SavedSource { + // Retain stable source IDs when refreshing the manifest name. + SavedSource(id: old.id, url: old.url, name: name) + } +} + +private extension SavedSource { + init(id: UUID, url: URL, name: String) { + self.id = id + self.url = url + self.name = name + } +} diff --git a/apps/IPA-Control/IPAControl/Models.swift b/apps/IPA-Control/IPAControl/Models.swift new file mode 100644 index 0000000..318449b --- /dev/null +++ b/apps/IPA-Control/IPAControl/Models.swift @@ -0,0 +1,64 @@ +import Foundation + +struct AltStoreSource: Decodable { + let name: String + let apps: [AltStoreApp] +} + +struct AltStoreApp: Decodable, Identifiable { + let name: String + let bundleIdentifier: String + let developerName: String? + let localizedDescription: String? + let iconURL: URL? + let versions: [AltStoreVersion]? + + var id: String { bundleIdentifier } + var latestVersion: AltStoreVersion? { versions?.first } +} + +struct AltStoreVersion: Decodable { + let version: String + let buildVersion: String? + let downloadURL: URL? +} + +struct SavedSource: Identifiable, Codable { + let id: UUID + let url: URL + let name: String + + init(url: URL, name: String) { + self.id = UUID() + self.url = url + self.name = name + } +} + +struct CatalogApp: Identifiable { + let sourceID: UUID + let sourceName: String + let app: AltStoreApp + + var id: String { sourceID.uuidString + ":" + app.bundleIdentifier } +} + +enum ClientError: LocalizedError { + case invalidURL + case networkStatus(Int) + case emptySource + case duplicateSource + case duplicateBundleID + case oversizedSource + + var errorDescription: String? { + switch self { + case .invalidURL: return "Použij platnou HTTPS adresu." + case .networkStatus(let code): return "Server odpověděl HTTP \(code)." + case .emptySource: return "Zdroj neobsahuje platný název." + case .duplicateSource: return "Tento zdroj už je přidaný." + case .duplicateBundleID: return "Zdroj obsahuje duplicitní Bundle ID." + case .oversizedSource: return "Soubor zdroje je příliš velký." + } + } +} diff --git a/apps/IPA-Control/README.md b/apps/IPA-Control/README.md new file mode 100644 index 0000000..b5bdb8b --- /dev/null +++ b/apps/IPA-Control/README.md @@ -0,0 +1,37 @@ +# IPA Control (první testovací verze) + +Nativní iOS aplikace ve SwiftUI. Je to první klient našeho budoucího osobního systému pro instalaci a obnovení IPA. + +## Co funguje v této verzi + +- Přidání a odstranění HTTPS URL zdrojů ve formátu AltStore Classic JSON. +- Načtení názvu zdroje, aplikací, verzí a odkazů na IPA. +- Uložení přidaných zdrojů a adresy vlastního serveru v zařízení. +- Test dostupnosti serveru přes `GET /health` (HTTP 200). +- Připravená záložka „Moje aplikace“ a přehled limitů free Apple účtu. + +## Co zatím záměrně nefunguje + +- Podepisování a instalace IPA. +- Obnova provisioning profilů. +- Zjišťování všech nainstalovaných aplikací nebo jejich skutečné platnosti. + +Tyto funkce vyžadují samostatnou podpisovou službu a prověřený mechanismus instalace přes spárovaný iPhone. Tlačítka jsou proto označena jako nedostupná; klient nevytváří falešný dojem instalace. + +## Sestavení + +Projekt `IPAControl.xcodeproj` obsahuje scheme `IPAControl` (automaticky sdílené podle projektu). Lze jej sestavit přes `CaseyCZ/iOS-IPA-Builder` s: + +- `source_repo`: `CaseyCZ/iOS-IPA-Builder` +- `project_path`: `apps/IPA-Control` +- `project_type`: `xcode` nebo `auto` +- `xcode_runner`: `macos-latest` +- `build_mode`: `unsigned` +- `scheme`: `IPAControl` (je-li vyžadováno) +- `output_repo`: existující cílový repozitář s odpovídajícími právy `OUTPUT_TOKEN` (lze použít i tento repozitář, pokud to chceš veřejně publikovat) + +Výstupem je **nepodepsané IPA**. První instalace stále vyžaduje funkční sideloadovací nástroj přes Windows + USB-C; samotné nahrání na web neumožní instalaci zdarma podepsané aplikace. + +## Bezpečnost + +V aplikaci nejsou uložená Apple ID hesla, soukromé klíče ani párovací soubory. Přijímá jen HTTPS zdroje a serverové URL. Zdroje JSON jsou nedůvěryhodný obsah a aplikace z nich nic automaticky neinstaluje. diff --git a/apps/README.md b/apps/README.md new file mode 100644 index 0000000..0a48ffd --- /dev/null +++ b/apps/README.md @@ -0,0 +1,12 @@ +# Aplikace + +Zdrojové kódy aplikací spravované spolu s IPA Builderem. + +| Aplikace | Umístění | Stav | +| --- | --- | --- | +| IPA Control | [`IPA-Control/`](IPA-Control/) | První testovací klient, bez podepisování a instalace | + +Builder zůstává samostatným workflow v `.github/workflows/build-ios.yml`. +Pro sestavení IPA Control vyber `source_repo = CaseyCZ/iOS-IPA-Builder`, `project_path = apps/IPA-Control` a `build_mode = unsigned`. + +Sestavení nepodepsaného IPA nespotřebuje žádný provisioning profil ani App ID. Test skutečné instalace bude zvláštním, vědomě spuštěným krokem. From 88069741184e63baae0f5cb2a7c3eb75bd46df0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1?= Date: Mon, 26 Oct 2026 08:44:56 +0100 Subject: [PATCH 2/2] feat: add IPA Control page to existing GitHub Pages site --- docs/index.html | 27 +++++++++ docs/ipa-control.html | 132 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 docs/ipa-control.html diff --git a/docs/index.html b/docs/index.html index 2e187d1..1601b7a 100644 --- a/docs/index.html +++ b/docs/index.html @@ -78,6 +78,7 @@ Build modes How it works Supported + IPA Control