diff --git a/StikDebug.xcodeproj/project.pbxproj b/StikDebug.xcodeproj/project.pbxproj index 57202a9..7e14489 100644 --- a/StikDebug.xcodeproj/project.pbxproj +++ b/StikDebug.xcodeproj/project.pbxproj @@ -8,15 +8,15 @@ /* Begin PBXBuildFile section */ 17C744F02E20BED000834F17 /* Pipify in Frameworks */ = {isa = PBXBuildFile; productRef = 17C744EF2E20BED000834F17 /* Pipify */; }; + 6874D2722E6B4A5E00B18184 /* ZSignApple in Frameworks */ = {isa = PBXBuildFile; productRef = 6874D2712E6B4A5E00B18184 /* ZSignApple */; }; 68D569BE2E1B415700A5BA36 /* CodeEditorView in Frameworks */ = {isa = PBXBuildFile; productRef = 68D569BD2E1B415700A5BA36 /* CodeEditorView */; }; 68D569C02E1B415700A5BA36 /* LanguageSupport in Frameworks */ = {isa = PBXBuildFile; productRef = 68D569BF2E1B415700A5BA36 /* LanguageSupport */; }; + 68E714E62E6AA2B00025610F /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = 68E714E52E6AA2B00025610F /* ZIPFoundation */; }; DC139F6E2DE97EA400F63846 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DC139F6D2DE97EA400F63846 /* WidgetKit.framework */; }; DC139F702DE97EA400F63846 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DC139F6F2DE97EA400F63846 /* SwiftUI.framework */; }; DC139F812DE97EA600F63846 /* DebugWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = DC139F6C2DE97EA400F63846 /* DebugWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - DC7E2F1C2E38D01E00725B2C /* ZSignApple in Frameworks */ = {isa = PBXBuildFile; productRef = DC7E2F1B2E38D01E00725B2C /* ZSignApple */; }; DCA690122DAF660E007C91A8 /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DCA690112DAF660E007C91A8 /* NetworkExtension.framework */; }; DCA6901A2DAF660E007C91A8 /* TunnelProv.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = DCA690102DAF660E007C91A8 /* TunnelProv.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - DCBA85192E388CD400E88C06 /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = DCBA85182E388CD400E88C06 /* ZIPFoundation */; }; DCBA85862E3897BD00E88C06 /* StikImporter in Frameworks */ = {isa = PBXBuildFile; productRef = DCBA85852E3897BD00E88C06 /* StikImporter */; }; /* End PBXBuildFile section */ @@ -157,8 +157,8 @@ 17C744F02E20BED000834F17 /* Pipify in Frameworks */, 68D569C02E1B415700A5BA36 /* LanguageSupport in Frameworks */, 68D569BE2E1B415700A5BA36 /* CodeEditorView in Frameworks */, - DCBA85192E388CD400E88C06 /* ZIPFoundation in Frameworks */, - DC7E2F1C2E38D01E00725B2C /* ZSignApple in Frameworks */, + 68E714E62E6AA2B00025610F /* ZIPFoundation in Frameworks */, + 6874D2722E6B4A5E00B18184 /* ZSignApple in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -271,9 +271,9 @@ 68D569BD2E1B415700A5BA36 /* CodeEditorView */, 68D569BF2E1B415700A5BA36 /* LanguageSupport */, 17C744EF2E20BED000834F17 /* Pipify */, - DCBA85182E388CD400E88C06 /* ZIPFoundation */, DCBA85852E3897BD00E88C06 /* StikImporter */, - DC7E2F1B2E38D01E00725B2C /* ZSignApple */, + 68E714E52E6AA2B00025610F /* ZIPFoundation */, + 6874D2712E6B4A5E00B18184 /* ZSignApple */, ); productName = StikJIT; productReference = DC6F1D372D94EADD0071B2B6 /* StikDebug.app */; @@ -390,9 +390,9 @@ packageReferences = ( 68D569BC2E1B415700A5BA36 /* XCRemoteSwiftPackageReference "CodeEditorView" */, 17C744EE2E20BED000834F17 /* XCRemoteSwiftPackageReference "swiftui-pipify" */, - DCBA85172E388CD400E88C06 /* XCRemoteSwiftPackageReference "ZIPFoundation" */, DCBA85842E3897BD00E88C06 /* XCRemoteSwiftPackageReference "StikImporter" */, - DC7E2F1A2E38D01E00725B2C /* XCRemoteSwiftPackageReference "zsign-ios" */, + 68CB05252E6AA15F0075BAA9 /* XCRemoteSwiftPackageReference "zsign-ios" */, + 68E714E42E6AA2B00025610F /* XCRemoteSwiftPackageReference "ZIPFoundation" */, ); preferredProjectObjectVersion = 77; productRefGroup = DC6F1D382D94EADD0071B2B6 /* Products */; @@ -722,7 +722,7 @@ "$(PROJECT_DIR)/StikJIT/idevice", ); MACOSX_DEPLOYMENT_TARGET = 15.1; - MARKETING_VERSION = 2.1.0; + MARKETING_VERSION = 2.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.stik.sj; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; @@ -775,7 +775,7 @@ "$(PROJECT_DIR)/StikJIT/idevice", ); MACOSX_DEPLOYMENT_TARGET = 15.1; - MARKETING_VERSION = 2.1.0; + MARKETING_VERSION = 2.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.stik.sj; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; @@ -1003,6 +1003,14 @@ kind = branch; }; }; + 68CB05252E6AA15F0075BAA9 /* XCRemoteSwiftPackageReference "zsign-ios" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/zwsn/zsign-ios"; + requirement = { + branch = main; + kind = branch; + }; + }; 68D569BC2E1B415700A5BA36 /* XCRemoteSwiftPackageReference "CodeEditorView" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/mchakravarty/CodeEditorView"; @@ -1011,15 +1019,7 @@ minimumVersion = 0.15.4; }; }; - DC7E2F1A2E38D01E00725B2C /* XCRemoteSwiftPackageReference "zsign-ios" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/StephenDev0/zsign-ios"; - requirement = { - branch = main; - kind = branch; - }; - }; - DCBA85172E388CD400E88C06 /* XCRemoteSwiftPackageReference "ZIPFoundation" */ = { + 68E714E42E6AA2B00025610F /* XCRemoteSwiftPackageReference "ZIPFoundation" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/weichsel/ZIPFoundation"; requirement = { @@ -1043,6 +1043,10 @@ package = 17C744EE2E20BED000834F17 /* XCRemoteSwiftPackageReference "swiftui-pipify" */; productName = Pipify; }; + 6874D2712E6B4A5E00B18184 /* ZSignApple */ = { + isa = XCSwiftPackageProductDependency; + productName = ZSignApple; + }; 68D569BD2E1B415700A5BA36 /* CodeEditorView */ = { isa = XCSwiftPackageProductDependency; package = 68D569BC2E1B415700A5BA36 /* XCRemoteSwiftPackageReference "CodeEditorView" */; @@ -1053,14 +1057,9 @@ package = 68D569BC2E1B415700A5BA36 /* XCRemoteSwiftPackageReference "CodeEditorView" */; productName = LanguageSupport; }; - DC7E2F1B2E38D01E00725B2C /* ZSignApple */ = { + 68E714E52E6AA2B00025610F /* ZIPFoundation */ = { isa = XCSwiftPackageProductDependency; - package = DC7E2F1A2E38D01E00725B2C /* XCRemoteSwiftPackageReference "zsign-ios" */; - productName = ZSignApple; - }; - DCBA85182E388CD400E88C06 /* ZIPFoundation */ = { - isa = XCSwiftPackageProductDependency; - package = DCBA85172E388CD400E88C06 /* XCRemoteSwiftPackageReference "ZIPFoundation" */; + package = 68E714E42E6AA2B00025610F /* XCRemoteSwiftPackageReference "ZIPFoundation" */; productName = ZIPFoundation; }; DCBA85852E3897BD00E88C06 /* StikImporter */ = { diff --git a/StikDebug.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/StikDebug.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 2d5f52f..8ddaebc 100644 --- a/StikDebug.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/StikDebug.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "a7d8c65e2fd86cffbcb641147283c4520fb198ba8311739d1d27b74ef46eee96", + "originHash" : "fd6a0b1431f87fb1abbacc56899d6160059dfd4b59e9d381af1131363e73fb23", "pins" : [ { "identity" : "codeeditorview", @@ -49,7 +49,7 @@ { "identity" : "zsign-ios", "kind" : "remoteSourceControl", - "location" : "https://github.com/StephenDev0/zsign-ios", + "location" : "https://github.com/zwsn/zsign-ios", "state" : { "branch" : "main", "revision" : "a7f275fb4830ea48adf536fcddee7d05471970d0" diff --git a/StikJIT/JSSupport/ScriptListView.swift b/StikJIT/JSSupport/ScriptListView.swift index e9936e1..e861d68 100644 --- a/StikJIT/JSSupport/ScriptListView.swift +++ b/StikJIT/JSSupport/ScriptListView.swift @@ -7,6 +7,7 @@ import SwiftUI import UniformTypeIdentifiers +import UIKit struct ScriptListView: View { @State private var scripts: [URL] = [] @@ -15,121 +16,256 @@ struct ScriptListView: View { @State private var showImporter = false @AppStorage("DefaultScriptName") private var defaultScriptName = "attachDetach.js" + // Overlays / feedback + @State private var isBusy = false + @State private var alertVisible = false + @State private var alertTitle = "" + @State private var alertMessage = "" + @State private var alertIsSuccess = false + @State private var justCopied = false + + // Search + @State private var searchText = "" + var onSelectScript: ((URL?) -> Void)? = nil - + + private var filteredScripts: [URL] { + guard !searchText.isEmpty else { return scripts } + return scripts.filter { $0.lastPathComponent.localizedCaseInsensitiveContains(searchText) } + } + var body: some View { NavigationStack { - List { - Section { + ZStack { + // Background gradient + LinearGradient( + gradient: Gradient(colors: [ + Color(UIColor.systemBackground), + Color(UIColor.secondarySystemBackground) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .ignoresSafeArea() + + List { + actionsSection + if let onSelectScript { - Button("None") { - onSelectScript(nil) - } - } - ForEach(scripts, id: \.self) { script in - if let onSelectScript { + Section { Button { - onSelectScript(script) + onSelectScript(nil) } label: { HStack { - Text(script.lastPathComponent) - .font(.headline) - if defaultScriptName == script.lastPathComponent { - Spacer() - Image(systemName: "star.fill") - } + Image(systemName: "nosign") + .foregroundStyle(.secondary) + Text("None") } } + } + } + + Section { + if filteredScripts.isEmpty { + emptyRow } else { - NavigationLink { - ScriptEditorView(scriptURL: script) - } label: { - HStack { - Text(script.lastPathComponent) - .font(.headline) - if defaultScriptName == script.lastPathComponent { - Spacer() - Image(systemName: "star.fill") - } - } - } - .swipeActions(edge: .trailing) { - Button(role: .destructive) { - deleteScript(script) - } label: { - Label("Delete", systemImage: "trash") - } - Button { - saveDefaultScript(script) - } label: { - Label("Set Default", systemImage: "star") - } - .tint(.blue) + ForEach(filteredScripts, id: \.self) { script in + scriptRow(script) } } - } - } footer: { - Text("Swipe left to set a script as the default. Enable script execution after connecting in settings.") - } - } - .navigationTitle("JavaScript Files") - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button { - showNewFileAlert = true - } label: { - Label("New Script", systemImage: "plus") + } footer: { + Text("Swipe left on a script to set it as default or delete it. Enable script execution after connecting in settings.") } } - ToolbarItem(placement: .primaryAction) { - Button { - showImporter = true - } label: { - Label("Import", systemImage: "tray.and.arrow.down") - } - } - } - .onAppear(perform: loadScripts) - .alert("New Script", isPresented: $showNewFileAlert) { - TextField("Filename", text: $newFileName) - Button("Create", action: createNewScript) - Button("Cancel", role: .cancel) { } - } - .fileImporter( - isPresented: $showImporter, - allowedContentTypes: [UTType(filenameExtension: "js") ?? .plainText], - allowsMultipleSelection: false - ) { result in - switch result { - case .success(let urls): - if let fileURL = urls.first { - do { - let dest = scriptsDirectory().appendingPathComponent(fileURL.lastPathComponent) - if FileManager.default.fileExists(atPath: dest.path) { - try FileManager.default.removeItem(at: dest) - } - try FileManager.default.copyItem(at: fileURL, to: dest) - loadScripts() - } catch { - print("Import failed:", error) + .listStyle(.insetGrouped) + .navigationTitle("JavaScript Files") + .searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .automatic), prompt: "Search scripts") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + showNewFileAlert = true + } label: { + Label("New Script", systemImage: "plus") } } - case .failure(let error): - print("File import error:", error) + ToolbarItem(placement: .primaryAction) { + Button { + showImporter = true + } label: { + Label("Import", systemImage: "tray.and.arrow.down") + } + } + } + .onAppear(perform: loadScripts) + .alert("New Script", isPresented: $showNewFileAlert) { + TextField("Filename", text: $newFileName) + Button("Create", action: createNewScript) + Button("Cancel", role: .cancel) { } + } + .fileImporter( + isPresented: $showImporter, + allowedContentTypes: [UTType(filenameExtension: "js") ?? .plainText], + allowsMultipleSelection: false + ) { result in + switch result { + case .success(let urls): + if let fileURL = urls.first { + importScript(from: fileURL) + } + case .failure(let error): + presentError(title: "File Import Error", message: error.localizedDescription) + } + } + + // Busy overlay + if isBusy { + Color.black.opacity(0.2).ignoresSafeArea() + ProgressView("Working…") + .padding(14) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + + // Custom alert overlay + if alertVisible { + CustomErrorView( + title: alertTitle, + message: alertMessage, + onDismiss: { alertVisible = false }, + showButton: true, + primaryButtonText: "OK", + messageType: alertIsSuccess ? .success : .error + ) + } + + // Toast + if justCopied { + VStack { + Spacer() + Text("Copied") + .font(.footnote.weight(.semibold)) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(.ultraThinMaterial, in: Capsule()) + .overlay(Capsule().strokeBorder(Color.white.opacity(0.15), lineWidth: 1)) + .shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 3) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .padding(.bottom, 30) + } + .animation(.easeInOut(duration: 0.25), value: justCopied) } } } } - + + // MARK: - Sections + + private var actionsSection: some View { + Section { + Button { + showNewFileAlert = true + } label: { + Label("New Script", systemImage: "doc.badge.plus") + } + Button { + showImporter = true + } label: { + Label("Import", systemImage: "tray.and.arrow.down") + } + } + } + + private var emptyRow: some View { + HStack(spacing: 10) { + Image(systemName: "doc.text.magnifyingglass") + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 2) { + Text("No scripts found") + .font(.subheadline.weight(.semibold)) + Text("Tap New Script or Import to get started.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + } + + private func scriptRow(_ script: URL) -> some View { + let isDefault = defaultScriptName == script.lastPathComponent + + return Group { + if let onSelectScript { + Button { + onSelectScript(script) + } label: { + HStack { + Text(script.lastPathComponent) + Spacer() + if isDefault { + Image(systemName: "star.fill") + .foregroundColor(.yellow) + } + } + } + .contextMenu { + Button { + copyName(script) + } label: { + Label("Copy Filename", systemImage: "doc.on.doc") + } + } + } else { + NavigationLink { + ScriptEditorView(scriptURL: script) + } label: { + HStack { + Text(script.lastPathComponent) + Spacer() + if isDefault { + Image(systemName: "star.fill") + .foregroundColor(.yellow) + } + } + } + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { + deleteScript(script) + } label: { + Label("Delete", systemImage: "trash") + } + Button { + saveDefaultScript(script) + } label: { + Label("Set Default", systemImage: "star") + } + .tint(.blue) + } + .contextMenu { + Button { + copyName(script) + } label: { + Label("Copy Filename", systemImage: "doc.on.doc") + } + Button { + copyPath(script) + } label: { + Label("Copy Path", systemImage: "folder") + } + } + } + } + } + + // MARK: - File Ops + private func scriptsDirectory() -> URL { let dir = FileManager .default .urls(for: .documentDirectory, in: .userDomainMask)[0] .appendingPathComponent("scripts") - + var isDir: ObjCBool = false var exists = FileManager.default.fileExists(atPath: dir.path, isDirectory: &isDir) - + do { if exists && !isDir.boolValue { try FileManager.default.removeItem(at: dir) @@ -138,57 +274,57 @@ struct ScriptListView: View { if !exists { try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) if let bundleURL = Bundle.main.url(forResource: "attachDetach", withExtension: "js") { - try FileManager.default.copyItem( - at: bundleURL, - to: dir.appendingPathComponent("attachDetach.js") - ) + let dest = dir.appendingPathComponent("attachDetach.js") + if !FileManager.default.fileExists(atPath: dest.path) { + try FileManager.default.copyItem(at: bundleURL, to: dest) + } } } } catch { - showAlert(title: "Unable to Create Scripts Folder", - message: error.localizedDescription, - showOk: true) + presentError(title: "Unable to Create Scripts Folder", message: error.localizedDescription) } - + return dir } - + private func loadScripts() { let dir = scriptsDirectory() scripts = (try? FileManager .default .contentsOfDirectory(at: dir, includingPropertiesForKeys: nil))? - .filter { $0.pathExtension.lowercased() == "js" } ?? [] + .filter { $0.pathExtension.lowercased() == "js" } + .sorted(by: { $0.lastPathComponent.localizedCaseInsensitiveCompare($1.lastPathComponent) == .orderedAscending }) ?? [] } - + private func saveDefaultScript(_ url: URL) { defaultScriptName = url.lastPathComponent + presentSuccess(title: "Default Script Set", message: url.lastPathComponent) } - + private func createNewScript() { - guard !newFileName.isEmpty else { return } - var filename = newFileName + guard !newFileName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + var filename = newFileName.trimmingCharacters(in: .whitespacesAndNewlines) if !filename.hasSuffix(".js") { filename += ".js" } let newURL = scriptsDirectory().appendingPathComponent(filename) - + guard !FileManager.default.fileExists(atPath: newURL.path) else { - showAlert(title: "Failed to Create New Script", - message: "A script with the same name already exists.", - showOk: true) + presentError(title: "Failed to Create New Script", + message: "A script with the same name already exists.") return } - + do { try "".write(to: newURL, atomically: true, encoding: .utf8) newFileName = "" loadScripts() + presentSuccess(title: "Created", message: filename) } catch { - print("Error creating file:", error) + presentError(title: "Error Creating File", message: error.localizedDescription) } } - + private func deleteScript(_ url: URL) { do { try FileManager.default.removeItem(at: url) @@ -197,7 +333,64 @@ struct ScriptListView: View { } loadScripts() } catch { - print("Error deleting script:", error) + presentError(title: "Delete Failed", message: error.localizedDescription) + } + } + + private func importScript(from fileURL: URL) { + isBusy = true + DispatchQueue.global(qos: .userInitiated).async { + defer { + DispatchQueue.main.async { self.isBusy = false } + } + do { + let dest = scriptsDirectory().appendingPathComponent(fileURL.lastPathComponent) + if FileManager.default.fileExists(atPath: dest.path) { + try FileManager.default.removeItem(at: dest) + } + try FileManager.default.copyItem(at: fileURL, to: dest) + DispatchQueue.main.async { + self.loadScripts() + self.presentSuccess(title: "Imported", message: fileURL.lastPathComponent) + } + } catch { + DispatchQueue.main.async { + self.presentError(title: "Import Failed", message: error.localizedDescription) + } + } + } + } + + // MARK: - Feedback helpers + + private func presentError(title: String, message: String) { + alertTitle = title + alertMessage = message + alertIsSuccess = false + alertVisible = true + } + + private func presentSuccess(title: String, message: String) { + alertTitle = title + alertMessage = message + alertIsSuccess = true + alertVisible = true + } + + private func copyName(_ url: URL) { + UIPasteboard.general.string = url.lastPathComponent + showCopiedToast() + } + + private func copyPath(_ url: URL) { + UIPasteboard.general.string = url.path + showCopiedToast() + } + + private func showCopiedToast() { + withAnimation { justCopied = true } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + withAnimation { justCopied = false } } } } diff --git a/StikJIT/StikJITApp.swift b/StikJIT/StikJITApp.swift index 3cf3519..384203f 100644 --- a/StikJIT/StikJITApp.swift +++ b/StikJIT/StikJITApp.swift @@ -22,54 +22,102 @@ private func registerAdvancedOptionsDefault() { // MARK: - Welcome Sheet struct WelcomeSheetView: View { - // A callback to dismiss the sheet and trigger VPN setup. var onDismiss: (() -> Void)? + @Environment(\.colorScheme) private var colorScheme var body: some View { - VStack(spacing: 24) { - Text("Welcome!") - .font(.system(.largeTitle, design: .rounded).weight(.bold)) - .foregroundColor(.primary) - .padding(.top) + ZStack { + // Subtle gradient background to match app style + LinearGradient( + gradient: Gradient(colors: [ + Color(UIColor.systemBackground), + Color(UIColor.secondarySystemBackground) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .ignoresSafeArea() - Text("Thanks for installing the app. This brief introduction will help you get started.") - .font(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - - Text("StikDebug is an on-device debugger designed specifically for self-developed apps. It helps streamline testing and troubleshooting without sending any data to external servers.") - .font(.callout) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - - Text("The next step will prompt you to allow VPN permissions. This is necessary for the app to function properly. The VPN configuration allows your device to securely connect to itself — nothing more. Rest assured, no data is collected or sent externally. Everything stays on your device.") - .font(.callout) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - - Button(action: { - onDismiss?() - }) { - Text("Continue") - .font(.headline) - .frame(maxWidth: .infinity) - .padding() - .background(RoundedRectangle(cornerRadius: 12).fill(Color.accentColor)) - .foregroundColor(.white) + ScrollView { + VStack(spacing: 20) { + // Card container with glassy material and stroke + VStack(alignment: .leading, spacing: 16) { + // Title + Text("Welcome!") + .font(.system(.largeTitle, design: .rounded).weight(.bold)) + .foregroundColor(.primary) + .padding(.top, 8) + + // Intro + Text("Thanks for installing the app. This brief introduction will help you get started.") + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.leading) + + // App description + VStack(alignment: .leading, spacing: 6) { + Label("On‑device debugger", systemImage: "bolt.shield.fill") + .foregroundColor(.white) + .font(.headline) + Text("StikDebug is an on‑device debugger designed specifically for self‑developed apps. It helps streamline testing and troubleshooting without sending any data to external servers.") + .font(.callout) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + // VPN explanation + VStack(alignment: .leading, spacing: 6) { + Label("Why VPN permission?", systemImage: "lock.shield.fill") + .foregroundColor(.white) + .font(.headline) + Text("The next step will prompt you to allow VPN permissions. This is necessary for the app to function properly. The VPN configuration allows your device to securely connect to itself — nothing more. No data is collected or sent externally; everything stays on your device.") + .font(.callout) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + // Continue button + Button(action: { onDismiss?() }) { + Text("Continue") + .font(.system(size: 16, weight: .semibold, design: .rounded)) + .foregroundColor(.black) + .frame(height: 44) + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.white) + ) + } + .padding(.top, 8) + .accessibilityIdentifier("welcome_continue_button") + } + .padding(20) + .background( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) + ) + ) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4) + + // Footer version info for consistency + HStack { + Spacer() + Text("iOS \(UIDevice.current.systemVersion)") + .font(.footnote) + .foregroundColor(.secondary) + Spacer() + } + .padding(.top, 6) + } + .padding(.horizontal, 20) + .padding(.vertical, 30) } - .padding(.horizontal) - .padding(.bottom) } - .padding() - .background( - RoundedRectangle(cornerRadius: 30, style: .continuous) - .fill(Color(UIColor.secondarySystemBackground)) - .shadow(radius: 20) - ) - .padding() + .preferredColorScheme(.dark) } } @@ -271,10 +319,10 @@ class TunnelManager: ObservableObject { } } -// MARK: - AccentColor Environment Key +// MARK: - AccentColor Environment Key (leave available but unused) struct AccentColorKey: EnvironmentKey { - static let defaultValue: Color = .blue + static let defaultValue: Color = .white } extension EnvironmentValues { @@ -457,8 +505,7 @@ struct HeartbeatApp: App { @State private var showContinueWarning = false @State private var timeoutTimer: Timer? @StateObject private var mount = MountingProgress.shared - @StateObject private var dnsChecker = DNSChecker() // New DNS check state object - @AppStorage("appTheme") private var appTheme: String = "system" + @StateObject private var dnsChecker = DNSChecker() @Environment(\.scenePhase) private var scenePhase // Observe scene lifecycle let urls: [String] = [ @@ -482,17 +529,14 @@ struct HeartbeatApp: App { let origMethod = class_getInstanceMethod(UIDocumentPickerViewController.self, #selector(UIDocumentPickerViewController.init(forOpeningContentTypes:asCopy:)))! method_exchangeImplementations(origMethod, fixMethod) + // Force global dark mode for the entire app if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let window = windowScene.windows.first { - switch appTheme { - case "dark": - window.overrideUserInterfaceStyle = .dark - case "light": - window.overrideUserInterfaceStyle = .light - default: - window.overrideUserInterfaceStyle = .unspecified - } + window.overrideUserInterfaceStyle = .dark } + + // Ensure UIKit controls also use white tint globally + UIView.appearance().tintColor = .white } func newVerCheck() { @@ -513,20 +557,6 @@ struct HeartbeatApp: App { } } - private func applyTheme() { - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first { - switch appTheme { - case "dark": - window.overrideUserInterfaceStyle = .dark - case "light": - window.overrideUserInterfaceStyle = .light - default: - window.overrideUserInterfaceStyle = .unspecified - } - } - } - var body: some Scene { WindowGroup { Group { @@ -646,11 +676,16 @@ struct HeartbeatApp: App { isLoading2 = false }) { ConsoleLogsView() + .preferredColorScheme(.dark) } } else { MainTabView() .onAppear { - applyTheme() + // Force dark mode on main window as well + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first { + window.overrideUserInterfaceStyle = .dark + } let fileManager = FileManager.default for (index, urlString) in urls.enumerated() { let destinationURL = URL.documentsDirectory.appendingPathComponent(outputFiles[index]) @@ -680,8 +715,11 @@ struct HeartbeatApp: App { } } ) + .preferredColorScheme(.dark) } } + // Apply global white tint to all SwiftUI views in the window + .tint(Color.white) .onAppear { // On first launch, present the welcome sheet. // Otherwise, start the VPN automatically. @@ -698,7 +736,9 @@ struct HeartbeatApp: App { showWelcomeSheet = false TunnelManager.shared.startVPN() } + .preferredColorScheme(.dark) } + .preferredColorScheme(.dark) } .onChange(of: scenePhase) { newPhase in if newPhase == .active { @@ -706,19 +746,6 @@ struct HeartbeatApp: App { startHeartbeatInBackground() } } - .onChange(of: isLoading2) { newValue in - if !newValue { - timeoutTimer?.invalidate() - timeoutTimer = nil - } - } - .onChange(of: dnsChecker.dnsError) { newError in - if let errorMsg = newError, !errorMsg.contains("Not connected to WiFi") { - alert_title = "Network Issue" - alert_string = errorMsg - show_alert = true - } - } } private func checkVPNConnection(callback: @escaping (Bool, String?) -> Void) { @@ -924,46 +951,24 @@ struct LoadingView: View { @State private var animate = false @Environment(\.colorScheme) private var colorScheme - @AppStorage("customAccentColor") private var customAccentColorHex: String = "" - @AppStorage("appTheme") private var appTheme: String = "system" - - private var accentColor: Color { - if customAccentColorHex.isEmpty { - return .blue - } else { - return Color(hex: customAccentColorHex) ?? .blue - } - } - - private var isDarkMode: Bool { - switch appTheme { - case "dark": - return true - case "light": - return false - default: - return colorScheme == .dark - } - } var body: some View { ZStack { - Color(isDarkMode ? .black : .white) - .ignoresSafeArea() + Color.black.ignoresSafeArea() VStack { ZStack { Circle() .stroke(lineWidth: 8) - .foregroundColor(isDarkMode ? Color.white.opacity(0.3) : Color.black.opacity(0.1)) + .foregroundColor(Color.white.opacity(0.15)) .frame(width: 80, height: 80) Circle() .trim(from: 0, to: 0.7) .stroke(AngularGradient( gradient: Gradient(colors: [ - accentColor.opacity(0.8), - accentColor.opacity(0.3) + Color.white.opacity(0.9), + Color.white.opacity(0.4) ]), center: .center ), style: StrokeStyle(lineWidth: 6, lineCap: .round)) @@ -971,7 +976,7 @@ struct LoadingView: View { .frame(width: 80, height: 80) .animation(Animation.linear(duration: 1.2).repeatForever(autoreverses: false), value: animate) } - .shadow(color: accentColor.opacity(0.4), radius: 10, x: 0, y: 0) + .shadow(color: Color.white.opacity(0.25), radius: 10, x: 0, y: 0) .onAppear { animate = true let os = ProcessInfo.processInfo.operatingSystemVersion @@ -984,12 +989,13 @@ struct LoadingView: View { Text("Loading...") .font(.system(size: 20, weight: .medium, design: .rounded)) - .foregroundColor(isDarkMode ? .white.opacity(0.8) : .black.opacity(0.8)) + .foregroundColor(.white.opacity(0.85)) .padding(.top, 20) .opacity(animate ? 1.0 : 0.5) .animation(Animation.easeInOut(duration: 1.5).repeatForever(autoreverses: true), value: animate) } } + .preferredColorScheme(.dark) } } diff --git a/StikJIT/Views/ConsoleLogsView.swift b/StikJIT/Views/ConsoleLogsView.swift index ad7c91a..60d8ec3 100644 --- a/StikJIT/Views/ConsoleLogsView.swift +++ b/StikJIT/Views/ConsoleLogsView.swift @@ -14,6 +14,7 @@ struct ConsoleLogsView: View { @Environment(\.accentColor) private var environmentAccentColor @StateObject private var logManager = LogManager.shared @State private var autoScroll = true + @State private var paused = false @State private var scrollView: ScrollViewProxy? = nil @AppStorage("customAccentColor") private var customAccentColorHex: String = "" @@ -28,313 +29,519 @@ struct ConsoleLogsView: View { // Track if the view is active (visible) @State private var isViewActive = false - @State private var lastProcessedLineCount = 0 // Track last processed line count - @State private var isLoadingLogs = false // Track loading state - @State private var isAtBottom = true // Track if user is at bottom of logs + @State private var lastProcessedLineCount = 0 + @State private var isLoadingLogs = false + @State private var isAtBottom = true + + // Search + @State private var searchText = "" + // Filter by level + enum LevelFilter: String, CaseIterable, Identifiable { + case all = "ALL" + case info = "INFO" + case warning = "WARNING" + case error = "ERROR" + case debug = "DEBUG" + var id: String { rawValue } + } + @State private var level: LevelFilter = .all + + // Gutter toggle (line numbers) + @State private var showGutter = false + + // Share / export helpers + @State private var showShareSheet = false + @State private var shareItems: [Any] = [] + // Copy toast + @State private var justCopied = false + // Blinking cursor + @State private var showCursor = true + @State private var cursorTimer: Timer? = nil + + // Fixed terminal viewport height (taller) + private let terminalHeight: CGFloat = 520 private var accentColor: Color { if customAccentColorHex.isEmpty { - return .blue + return .white } else { - return Color(hex: customAccentColorHex) ?? .blue + return Color(hex: customAccentColorHex) ?? .white + } + } + + private var filteredLogs: [LogManager.LogEntry] { + let base = logManager.logs.filter { entry in + switch level { + case .all: return true + case .info: return entry.type == .info + case .warning: return entry.type == .warning + case .error: return entry.type == .error + case .debug: return entry.type == .debug + } + } + guard !searchText.isEmpty else { return base } + return base.filter { + $0.message.localizedCaseInsensitiveContains(searchText) + || $0.type.rawValue.localizedCaseInsensitiveContains(searchText) } } var body: some View { - NavigationView { + NavigationStack { ZStack { - // Use system background color instead of fixed black - Color(colorScheme == .dark ? .black : .white) - .edgesIgnoringSafeArea(.all) + LinearGradient( + gradient: Gradient(colors: [ + Color(UIColor.systemBackground), + Color(UIColor.secondarySystemBackground) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .ignoresSafeArea() - VStack(spacing: 0) { - // Terminal logs area with theme support - ScrollViewReader { proxy in - ScrollView { - VStack(spacing: 0) { - // Device Information - VStack(alignment: .leading, spacing: 4) { - Text("=== DEVICE INFORMATION ===") - .font(.system(size: 11, design: .monospaced)) - .foregroundColor(colorScheme == .dark ? .white : .black) - .padding(.vertical, 4) - - Text("iOS Version: \(UIDevice.current.systemVersion)") - .font(.system(size: 11, design: .monospaced)) - .foregroundColor(colorScheme == .dark ? .white : .black) - - Text("Device: \(UIDevice.current.name)") - .font(.system(size: 11, design: .monospaced)) - .foregroundColor(colorScheme == .dark ? .white : .black) - - Text("Model: \(UIDevice.current.model)") - .font(.system(size: 11, design: .monospaced)) - .foregroundColor(colorScheme == .dark ? .white : .black) - - Text("=== LOG ENTRIES ===") - .font(.system(size: 11, design: .monospaced)) - .foregroundColor(colorScheme == .dark ? .white : .black) - .padding(.vertical, 4) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 4) - - // Log entries - ForEach(logManager.logs) { logEntry in - Text(AttributedString(createLogAttributedString(logEntry))) - .font(.system(size: 11, design: .monospaced)) - .textSelection(.enabled) - .lineLimit(nil) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.vertical, 1) - .padding(.horizontal, 4) - .id(logEntry.id) - } - } - .background( - GeometryReader { geometry in - Color.clear.preference( - key: ScrollOffsetPreferenceKey.self, - value: geometry.frame(in: .named("scroll")).minY - ) - } - ) - } - .coordinateSpace(name: "scroll") - .onPreferenceChange(ScrollOffsetPreferenceKey.self) { offset in - // Consider user is at bottom if they're within 20 points of the bottom - // This gives a small buffer for slight scroll movements - isAtBottom = offset > -20 - } - .onChange(of: logManager.logs.count) { _ in - if isAtBottom { - withAnimation { - if let lastLog = logManager.logs.last { - proxy.scrollTo(lastLog.id, anchor: .bottom) - } - } - } - } - .onAppear { - scrollView = proxy - isViewActive = true - // Load logs asynchronously - Task { - await loadIdeviceLogsAsync() - } - startLogCheckTimer() - } - .onDisappear { - isViewActive = false - stopLogCheckTimer() - } + ScrollView { + VStack(spacing: 20) { + consoleCard + footerInfo } - - Spacer() - - // Action buttons section - update to be theme aware - VStack(spacing: 16) { - // Error count with red theme - HStack { - Text("\(logManager.errorCount) Errors") - .font(.headline) - .foregroundColor(.white) - .padding(.vertical, 12) - .frame(maxWidth: .infinity) - .background(Color.red) - .cornerRadius(10) - } - .padding(.horizontal) - - // Action buttons with theme-appropriate background - VStack(spacing: 1) { - // Export button - ShareLink( - item: { - // Get the path to the idevice log file - let logPath = URL.documentsDirectory.appendingPathComponent("idevice_log.txt") - - // Check if the file exists - guard FileManager.default.fileExists(atPath: logPath.path) else { - alertTitle = "Export Failed" - alertMessage = "No idevice logs found" - isError = true - showingCustomAlert = true - return URL.documentsDirectory.appendingPathComponent("empty.txt") - } - - return logPath - }(), - preview: SharePreview( - "idevice_log.txt", - image: Image(systemName: "doc.text") - ) - ) { - HStack { - Text("Export Logs") - .foregroundColor(accentColor) - Spacer() - Image(systemName: "square.and.arrow.down") - .foregroundColor(accentColor) - } - .padding(.vertical, 14) - .padding(.horizontal, 20) - .contentShape(Rectangle()) - } - .background(colorScheme == .dark ? - Color(red: 0.1, green: 0.1, blue: 0.1) : - Color(UIColor.secondarySystemBackground)) - - Divider() - .background(colorScheme == .dark ? - Color(red: 0.15, green: 0.15, blue: 0.15) : - Color(UIColor.separator)) - - // Copy button - Button(action: { - // Existing code for copying logs - var logsContent = "=== DEVICE INFORMATION ===\n" - logsContent += "Version: \(UIDevice.current.systemVersion)\n" - logsContent += "Name: \(UIDevice.current.name)\n" - logsContent += "Model: \(UIDevice.current.model)\n" - logsContent += "StikJIT Version: App Version: 1.0\n\n" - logsContent += "=== LOG ENTRIES ===\n" - - logsContent += logManager.logs.map { - "[\(formatTime(date: $0.timestamp))] [\($0.type.rawValue)] \($0.message)" - }.joined(separator: "\n") - - UIPasteboard.general.string = logsContent - - alertTitle = "Logs Copied" - alertMessage = "Logs have been copied to clipboard." - isError = false - showingCustomAlert = true - }) { - HStack { - Text("Copy Logs") - .foregroundColor(accentColor) - Spacer() - Image(systemName: "doc.on.doc") - .foregroundColor(accentColor) - } - .padding(.vertical, 14) - .padding(.horizontal, 20) - .contentShape(Rectangle()) - } - .background(colorScheme == .dark ? - Color(red: 0.1, green: 0.1, blue: 0.1) : - Color(UIColor.secondarySystemBackground)) - } - .cornerRadius(10) - .padding(.horizontal) - .padding(.bottom, 16) - } - .padding(.bottom, 8) + .padding(.horizontal, 20) + .padding(.vertical, 30) } - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .principal) { - Text("Console Logs") - .font(.headline) - .foregroundColor(colorScheme == .dark ? .white : .black) + .sheet(isPresented: $showShareSheet) { + ActivityViewController(items: shareItems) + } + + if isLoadingLogs { + Color.black.opacity(0.2).ignoresSafeArea() + ProgressView("Loading logs…") + .padding(16) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) + ) + ) + .shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4) + } + + if showingCustomAlert { + CustomErrorView( + title: alertTitle, + message: alertMessage, + onDismiss: { showingCustomAlert = false }, + showButton: true, + primaryButtonText: "OK", + messageType: isError ? .error : .success + ) + } + + if justCopied { + VStack { + Spacer() + Text("Copied") + .font(.footnote.weight(.semibold)) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(.ultraThinMaterial, in: Capsule()) + .overlay(Capsule().strokeBorder(Color.white.opacity(0.15), lineWidth: 1)) + .shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 3) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .padding(.bottom, 30) } - - ToolbarItem(placement: .navigationBarLeading) { - Button(action: { - dismiss() - }) { - HStack(spacing: 2) { - Text("Exit") - .fontWeight(.regular) - } - .foregroundColor(accentColor) + .animation(.easeInOut(duration: 0.25), value: justCopied) + } + } + .navigationTitle("Console") + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { + dismiss() + } label: { + Label("Exit", systemImage: "chevron.left") + } + } + ToolbarItemGroup(placement: .topBarTrailing) { + Button { + Task { await loadIdeviceLogsAsync() } + } label: { + Image(systemName: "arrow.clockwise") + } + Button { + exportLogs() + } label: { + Image(systemName: "square.and.arrow.up") + } + Menu { + Button { + copyLogsToPasteboard() + } label: { + Label("Copy All", systemImage: "doc.on.doc") } + Button { + shareAll() + } label: { + Label("Share…", systemImage: "square.and.arrow.up.on.square") + } + Divider() + Button { + showGutter.toggle() + } label: { + Label(showGutter ? "Hide Line Numbers" : "Show Line Numbers", systemImage: "number") + } + Divider() + Button(role: .destructive) { + logManager.clearLogs() + } label: { + Label("Clear Logs", systemImage: "trash") + } + } label: { + Image(systemName: "ellipsis.circle") + } + } + } + .onAppear { + isViewActive = true + Task { await loadIdeviceLogsAsync() } + startLogCheckTimer() + startCursorBlink() + } + .onDisappear { + isViewActive = false + stopLogCheckTimer() + stopCursorBlink() + } + } + } + + // MARK: - Console UI + + private var consoleCard: some View { + VStack(alignment: .leading, spacing: 0) { + controlStrip + searchField + consoleCanvas + promptBar + } + .background( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) + ) + ) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4) + } + + // Top strip: icon-only controls to avoid smushing + private var controlStrip: some View { + HStack(spacing: 10) { + // Chips in a horizontal scroller to prevent wrapping + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + levelFilterChip(.all) + levelFilterChip(.info) + levelFilterChip(.warning) + levelFilterChip(.error) + levelFilterChip(.debug) + } + .padding(.vertical, 2) + } + .frame(maxWidth: .infinity) + + // Right-side icon-only controls + HStack(spacing: 12) { + // Pause/Resume + Button { + paused.toggle() + } label: { + Image(systemName: paused ? "play.fill" : "pause.fill") + .font(.system(size: 14, weight: .bold)) + .foregroundColor(paused ? .green : .orange) + .padding(8) + .background( + Circle() + .fill((paused ? Color.green : Color.orange).opacity(0.18)) + ) + .overlay( + Circle() + .stroke((paused ? Color.green : Color.orange).opacity(0.45), lineWidth: 0.8) + ) + } + .accessibilityLabel(paused ? "Resume" : "Pause") + .accessibilityHint("Toggle pausing of live output") + .buttonStyle(.plain) + + // Auto-scroll toggle (icon-only) + Button { + autoScroll.toggle() + } label: { + Image(systemName: autoScroll ? "arrow.down.circle.fill" : "arrow.down.circle") + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(autoScroll ? accentColor : .secondary) + } + .accessibilityLabel("Auto Scroll") + .accessibilityValue(autoScroll ? "On" : "Off") + .buttonStyle(.plain) + + // Clear + Button { + logManager.clearLogs() + } label: { + Image(systemName: "trash") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.secondary) + } + .accessibilityLabel("Clear Logs") + .buttonStyle(.plain) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color(UIColor.tertiarySystemBackground)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder(Color.white.opacity(0.08), lineWidth: 0.5) + ) + ) + .padding(.horizontal, 12) + .padding(.top, 12) + } + + private var searchField: some View { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass").foregroundColor(.secondary) + TextField("Filter text…", text: $searchText) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .font(.caption.monospaced()) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color(UIColor.tertiarySystemBackground)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(Color.white.opacity(0.08), lineWidth: 0.5) + ) + ) + .padding(.horizontal, 12) + .padding(.top, 10) + .padding(.bottom, 8) + } + + // Fixed-size, scrollable terminal viewport with auto-scroll + private var consoleCanvas: some View { + ScrollViewReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: 0) { + consolePreface + + ForEach(Array(filteredLogs.indices), id: \.self) { idx in + let entry = filteredLogs[idx] + ConsoleLineRow( + index: idx, + entry: entry, + showGutter: showGutter, + attributed: makeAttributed(entry) + ) + .id(entry.id) } - ToolbarItem(placement: .navigationBarTrailing) { - HStack { - Button(action: { - // Load the logs again - Task { - await loadIdeviceLogsAsync() - } - }) { - Image(systemName: "arrow.clockwise") - .foregroundColor(accentColor) - } - - Button(action: { - logManager.clearLogs() - }) { - Text("Clear") - .foregroundColor(accentColor) - } + CursorLine(showGutter: showGutter, showCursor: showCursor) + .opacity(paused ? 0.5 : 1.0) + } + .background( + GeometryReader { geometry in + Color.clear.preference( + key: ScrollOffsetPreferenceKey.self, + value: geometry.frame(in: .named("consoleScroll")).minY + ) + } + ) + } + .frame(height: terminalHeight) + .coordinateSpace(name: "consoleScroll") + .background(consoleBackground) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .padding(.horizontal, 12) + .padding(.bottom, 10) + .onPreferenceChange(ScrollOffsetPreferenceKey.self) { offset in + isAtBottom = offset > -20 + } + .onChange(of: logManager.logs.count) { _ in + guard autoScroll && !paused else { return } + if isAtBottom { + withAnimation { + if let last = filteredLogs.last { + proxy.scrollTo(last.id, anchor: .bottom) } } } } - .overlay( - Group { - if showingCustomAlert { - Color.black.opacity(0.4) - .edgesIgnoringSafeArea(.all) - .overlay( - CustomErrorView( - title: alertTitle, - message: alertMessage, - onDismiss: { - showingCustomAlert = false - }, - showButton: true, - primaryButtonText: "OK", - messageType: isError ? .error : .success - ) - ) - } - } + .onAppear { + scrollView = proxy + } + } + } + + private var promptBar: some View { + HStack(spacing: 8) { + Text("\(UIDevice.current.name.replacingOccurrences(of: " ", with: "")):\u{007E}$") + .font(.system(size: 12, weight: .bold, design: .monospaced)) + .foregroundColor(accentColor) + Text(paused ? "Paused — output suppressed" : "Read-only console") + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(.secondary) + Spacer() + Button { + copyLogsToPasteboard() + } label: { + Label("Copy", systemImage: "doc.on.doc") + .labelStyle(.iconOnly) + } + .buttonStyle(.plain) + .foregroundColor(.secondary) + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(UIColor.tertiarySystemBackground)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(Color.white.opacity(0.08), lineWidth: 0.5) + ) + ) + .padding(.horizontal, 12) + .padding(.bottom, 12) + } + + private var consoleBackground: some View { + ZStack { + Color.black + LinearGradient( + colors: [ + Color.white.opacity(0.03), + .clear + ], + startPoint: .top, + endPoint: .bottom ) } } - // Update to use theme-aware colors - private func createLogAttributedString(_ logEntry: LogManager.LogEntry) -> NSAttributedString { - let fullString = NSMutableAttributedString() - - // Timestamp part - let timestampString = "[\(formatTime(date: logEntry.timestamp))]" - let timestampAttr = NSAttributedString( - string: timestampString, - attributes: [.foregroundColor: colorScheme == .dark ? UIColor.gray : UIColor.darkGray] - ) - fullString.append(timestampAttr) - fullString.append(NSAttributedString(string: " ")) - - // Log type part - let typeString = "[\(logEntry.type.rawValue)]" - let typeColor = UIColor(colorForLogType(logEntry.type)) - let typeAttr = NSAttributedString( - string: typeString, - attributes: [.foregroundColor: typeColor] - ) - fullString.append(typeAttr) - fullString.append(NSAttributedString(string: " ")) - - // Message part - let messageAttr = NSAttributedString( - string: logEntry.message, - attributes: [.foregroundColor: colorScheme == .dark ? UIColor.white : UIColor.black] - ) - fullString.append(messageAttr) - - return fullString + private var consolePreface: some View { + VStack(alignment: .leading, spacing: 0) { + consoleLine("# === DEVICE INFORMATION ===", color: .secondary, bold: true) + consoleLine("# iOS Version: \(UIDevice.current.systemVersion)", color: .secondary) + consoleLine("# Device: \(UIDevice.current.name)", color: .secondary) + consoleLine("# Model: \(UIDevice.current.model)", color: .secondary) + consoleLine("# === LOG ENTRIES ===", color: .secondary, bold: true) + } + .padding(.top, 8) } - private func timeString() -> String { - let formatter = DateFormatter() - formatter.dateFormat = "HH:mm:ss" - return formatter.string(from: Date()) + private func levelFilterChip(_ f: LevelFilter) -> some View { + let selected = level == f + return Button { + level = f + } label: { + HStack(spacing: 6) { + if f != .all { + Circle() + .fill(levelColor(f).opacity(0.9)) + .frame(width: 8, height: 8) + } + Text(f.rawValue) + .font(.caption.monospaced()) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + Capsule(style: .continuous) + .fill(selected ? accentColor.opacity(0.2) : Color(UIColor.tertiarySystemBackground)) + ) + .overlay( + Capsule(style: .continuous) + .stroke(selected ? accentColor.opacity(0.6) : Color.white.opacity(0.08), lineWidth: 0.8) + ) + .foregroundColor(selected ? accentColor : .primary) + } + .buttonStyle(.plain) + } + + private func levelColor(_ f: LevelFilter) -> Color { + switch f { + case .all: return .gray + case .info: return .green + case .warning: return .orange + case .error: return .red + case .debug: return accentColor + } + } + + private func consoleLine(_ text: String, color: Color = .secondary, bold: Bool = false) -> some View { + Text(text) + .font(.system(size: 12, weight: bold ? .semibold : .regular, design: .monospaced)) + .foregroundColor(colorScheme == .dark ? color.opacity(0.8) : color.opacity(0.9)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 2) + .padding(.horizontal, 10) + } + + private var footerInfo: some View { + HStack { + Spacer() + Text("iOS \(UIDevice.current.systemVersion)") + .font(.footnote) + .foregroundColor(.secondary) + Spacer() + } + .padding(.top, 6) + } + + // MARK: - Attributed log helpers + + private func makeAttributed(_ entry: LogManager.LogEntry) -> AttributedString { + let ns = createTerminalAttributedString(entry) + return AttributedString(ns) + } + + private func createTerminalAttributedString(_ logEntry: LogManager.LogEntry) -> NSAttributedString { + let full = NSMutableAttributedString() + + // Timestamp (dim) + let ts = "[\(formatTime(date: logEntry.timestamp))]" + full.append(NSAttributedString( + string: ts + " ", + attributes: [ + .foregroundColor: UIColor(white: 1.0, alpha: 0.45) + ] + )) + + // Type (colored) + let typeColor = UIColor(colorForLogType(logEntry.type)) + full.append(NSAttributedString( + string: "[\(logEntry.type.rawValue)] ", + attributes: [.foregroundColor: typeColor] + )) + + // Message (bright) + full.append(NSAttributedString( + string: logEntry.message, + attributes: [.foregroundColor: UIColor.white] + )) + + return full } private func formatTime(date: Date) -> String { @@ -356,15 +563,14 @@ struct ConsoleLogsView: View { } } - // Function to load idevice logs from file asynchronously + // MARK: - File loading and timer + private func loadIdeviceLogsAsync() async { guard !isLoadingLogs else { return } isLoadingLogs = true - // Get the path to the idevice log file let logPath = URL.documentsDirectory.appendingPathComponent("idevice_log.txt").path - // Check if the file exists guard FileManager.default.fileExists(atPath: logPath) else { await MainActor.run { logManager.addInfoLog("No idevice logs found (Restart the app to continue reading)") @@ -374,32 +580,25 @@ struct ConsoleLogsView: View { } do { - // Read the file content let logContent = try String(contentsOfFile: logPath, encoding: .utf8) let lines = logContent.components(separatedBy: .newlines) - // Only take the last 500 lines + // Only last 500 lines let maxLines = 500 let startIndex = max(0, lines.count - maxLines) let recentLines = Array(lines[startIndex.. lastProcessedLineCount { let newLines = Array(lines[lastProcessedLineCount.. maxLines { let excessCount = logManager.logs.count - maxLines @@ -489,11 +682,126 @@ struct ConsoleLogsView: View { isLoadingLogs = false } - // Function to stop the timer private func stopLogCheckTimer() { logCheckTimer?.invalidate() logCheckTimer = nil } + + private func startCursorBlink() { + stopCursorBlink() + cursorTimer = Timer.scheduledTimer(withTimeInterval: 0.6, repeats: true) { _ in + withAnimation(.easeInOut(duration: 0.15)) { + showCursor.toggle() + } + } + } + + private func stopCursorBlink() { + cursorTimer?.invalidate() + cursorTimer = nil + showCursor = true + } + + // MARK: - Actions (export/copy/share) + + private func allLogsText() -> String { + var logsContent = "=== DEVICE INFORMATION ===\n" + logsContent += "Version: \(UIDevice.current.systemVersion)\n" + logsContent += "Name: \(UIDevice.current.name)\n" + logsContent += "Model: \(UIDevice.current.model)\n" + logsContent += "StikJIT Version: App Version: 1.0\n\n" + logsContent += "=== LOG ENTRIES ===\n" + logsContent += filteredLogs.map { + "[\(formatTime(date: $0.timestamp))] [\($0.type.rawValue)] \($0.message)" + }.joined(separator: "\n") + return logsContent + } + + private func copyLogsToPasteboard() { + UIPasteboard.general.string = allLogsText() + let gen = UINotificationFeedbackGenerator() + gen.notificationOccurred(.success) + showCopiedToast() + } + + private func shareAll() { + shareItems = [allLogsText()] + showShareSheet = true + } + + private func exportLogs() { + let logPath = URL.documentsDirectory.appendingPathComponent("idevice_log.txt") + if FileManager.default.fileExists(atPath: logPath.path) { + shareItems = [logPath] + showShareSheet = true + } else { + alertTitle = "Export Failed" + alertMessage = "No idevice logs found" + isError = true + showingCustomAlert = true + } + } + + private func showCopiedToast() { + withAnimation { justCopied = true } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + withAnimation { justCopied = false } + } + } +} + +private struct ConsoleLineRow: View { + let index: Int + let entry: LogManager.LogEntry + let showGutter: Bool + let attributed: AttributedString + + var body: some View { + HStack(alignment: .top, spacing: 8) { + if showGutter { + Text(String(format: "%5d", index + 1)) + .font(.system(size: 11, weight: .regular, design: .monospaced)) + .foregroundColor(.white.opacity(0.35)) + .frame(width: 44, alignment: .trailing) + } + Text(attributed) + .font(.system(size: 12, weight: .regular, design: .monospaced)) + .textSelection(.enabled) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.vertical, 2) + .padding(.horizontal, 10) + } +} + +private struct CursorLine: View { + let showGutter: Bool + let showCursor: Bool + + var body: some View { + HStack(spacing: 6) { + if showGutter { + Text(" ") + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(.clear) + .frame(width: 44, alignment: .trailing) + } + Text(" ") + .font(.system(size: 12, design: .monospaced)) + .overlay( + Rectangle() + .fill(Color.white.opacity(showCursor ? 0.9 : 0.0)) + .frame(width: 8, height: 14) + .offset(x: 0, y: 1), + alignment: .leading + ) + .frame(height: 16) + } + .padding(.vertical, 2) + .padding(.horizontal, 10) + } } struct ConsoleLogsView_Previews: PreviewProvider { @@ -507,4 +815,14 @@ struct ScrollOffsetPreferenceKey: PreferenceKey { static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() } -} +} + +// MARK: - UIKit Share Sheet wrapper + +private struct ActivityViewController: UIViewControllerRepresentable { + let items: [Any] + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: items, applicationActivities: nil) + } + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) { } +} diff --git a/StikJIT/Views/Custom Error View.swift b/StikJIT/Views/Custom Error View.swift index ec2ee77..e1f0058 100644 --- a/StikJIT/Views/Custom Error View.swift +++ b/StikJIT/Views/Custom Error View.swift @@ -32,9 +32,9 @@ struct CustomErrorView: View { private var accentColor: Color { if customAccentColorHex.isEmpty { - return .blue + return .white } else { - return Color(hex: customAccentColorHex) ?? .blue + return Color(hex: customAccentColorHex) ?? .white } } @@ -164,12 +164,11 @@ struct CustomErrorView: View { } private func dismissWithAnimation() { - withAnimation(.easeOut(duration: 0.15)) { + withAnimation(.easeOut(duration: 0.18)) { opacity = 0 - scale = 0.8 + scale = 0.95 } - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) { onDismiss() } } diff --git a/StikJIT/Views/DeviceInfoManager.swift b/StikJIT/Views/DeviceInfoManager.swift index 9f45892..50669ba 100644 --- a/StikJIT/Views/DeviceInfoManager.swift +++ b/StikJIT/Views/DeviceInfoManager.swift @@ -7,52 +7,34 @@ import SwiftUI import UIKit +import UniformTypeIdentifiers @_silgen_name("ideviceinfo_c_init") private func c_deviceinfo_init(_ path: UnsafePointer) -> Int32 - @_silgen_name("ideviceinfo_c_get_xml") private func c_deviceinfo_get_xml() -> UnsafePointer? - @_silgen_name("ideviceinfo_c_cleanup") private func c_deviceinfo_cleanup() -private func initErrorMessage(_ code: Int32) -> String { - switch code { - case 1: return "Couldn’t read pairingFile.plist" - case 2: return "Unable to create device provider" - case 3: return "Cannot connect to lockdown service" - case 4: return "Unable to start lockdown session" - default: return "Unknown init error (\(code))" - } -} - -private let docs = FileManager.default - .urls(for: .documentDirectory, in: .userDomainMask)[0] +// MARK: - Device Info Manager @MainActor final class DeviceInfoManager: ObservableObject { @Published var entries: [(key: String, value: String)] = [] @Published var busy = false @Published var error: (title: String, message: String)? - private var initialized = false + private let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] func initAndLoad() { - guard !initialized else { - loadInfo() - return - } + guard !initialized else { loadInfo(); return } busy = true - let pairingPath = docs - .appendingPathComponent("pairingFile.plist") - .path - - DispatchQueue.global(qos: .userInitiated).async { - let code = pairingPath.withCString { c_deviceinfo_init($0) } - DispatchQueue.main.async { + let path = docs.appendingPathComponent("pairingFile.plist").path + Task.detached { + let code = path.withCString { c_deviceinfo_init($0) } + await MainActor.run { if code != 0 { - self.error = ("Initialization Failed", initErrorMessage(code)) + self.error = ("Initialization Failed", self.initErrorMessage(code)) self.busy = false } else { self.initialized = true @@ -64,47 +46,35 @@ final class DeviceInfoManager: ObservableObject { private func loadInfo() { busy = true - DispatchQueue.global(qos: .userInitiated).async { + Task.detached { guard let cXml = c_deviceinfo_get_xml() else { - DispatchQueue.main.async { + await MainActor.run { self.error = ("Fetch Error", "Failed to fetch device info") self.busy = false } return } defer { free(UnsafeMutableRawPointer(mutating: cXml)) } - guard let xml = String(validatingUTF8: cXml) else { - DispatchQueue.main.async { + await MainActor.run { self.error = ("Parse Error", "Invalid XML data") self.busy = false } return } - do { - guard let data = xml.data(using: .utf8) else { - throw NSError(domain: "DeviceInfo", code: 0, - userInfo: [NSLocalizedDescriptionKey: "Invalid XML encoding"]) - } - let plistObj = try PropertyListSerialization.propertyList( - from: data, - options: [], format: nil) - guard let dict = plistObj as? [String: Any] else { + let data = Data(xml.utf8) + guard let dict = try PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? [String: Any] else { throw NSError(domain: "DeviceInfo", code: 0, userInfo: [NSLocalizedDescriptionKey: "Expected dictionary"]) } - - let formatted = dict.keys.sorted().map { key in - (key, Self.convertToString(dict[key]!)) - } - - DispatchQueue.main.async { + let formatted = dict.keys.sorted().map { ($0, Self.convertToString(dict[$0]!)) } + await MainActor.run { self.entries = formatted self.busy = false } } catch { - DispatchQueue.main.async { + await MainActor.run { self.error = ("Parse Error", error.localizedDescription) self.busy = false } @@ -117,159 +87,354 @@ final class DeviceInfoManager: ObservableObject { initialized = false } - private static func convertToString(_ raw: Any) -> String { + private func initErrorMessage(_ code: Int32) -> String { + switch code { + case 1: return "Couldn’t read pairingFile.plist" + case 2: return "Unable to create device provider" + case 3: return "Cannot connect to lockdown service" + case 4: return "Unable to start lockdown session" + default: return "Unknown init error (\(code))" + } + } + + nonisolated private static func convertToString(_ raw: Any) -> String { switch raw { - case let s as String: - return s - case let n as NSNumber: - return n.stringValue - case let d as Data: - let hex = d.prefix(32).map { String(format: "%02X", $0) }.joined() - let suffix = d.count > 32 ? "…" : "" - return "Data(\(d.count) B) \(hex)\(suffix)" - case let arr as [Any]: - return "[" + arr.map { convertToString($0) }.joined(separator: ", ") + "]" - case let dic as [String: Any]: - return "{" + dic.map { "\($0.key): \(convertToString($0.value))" } - .joined(separator: ", ") + "}" - default: - return String(describing: raw) + case let s as String: return s + case let n as NSNumber: return n.stringValue + default: return String(describing: raw) } } func exportToCSV() throws -> URL { var csv = "Key,Value\n" - for (key, value) in entries { - let escKey = key.replacingOccurrences(of: "\"", with: "\"\"") - let escValue = value.replacingOccurrences(of: "\"", with: "\"\"") - csv += "\"\(escKey)\",\"\(escValue)\"\n" + for (k, v) in entries { + csv += "\"\(k.replacingOccurrences(of: "\"", with: "\"\""))\"," + csv += "\"\(v.replacingOccurrences(of: "\"", with: "\"\""))\"\n" } - let url = FileManager.default.temporaryDirectory - .appendingPathComponent("DeviceInfo-Export.csv") + let url = FileManager.default.temporaryDirectory.appendingPathComponent("DeviceInfo.csv") try csv.data(using: .utf8)?.write(to: url) return url } } +// MARK: - Device Info UI + struct DeviceInfoView: View { @StateObject private var mgr = DeviceInfoManager() + @State private var importer = false @State private var exportURL: URL? @State private var isShowingExporter = false + @State private var shareItems: [Any] = [] + @State private var showShareSheet = false + @State private var justCopied = false + + private let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + private var pairingURL: URL { docs.appendingPathComponent("pairingFile.plist") } + private var isPaired: Bool { FileManager.default.fileExists(atPath: pairingURL.path) } + + @State private var searchText = "" + @State private var alert = false + @State private var alertTitle = "" + @State private var alertMsg = "" + @State private var alertSuccess = false + + var filteredEntries: [(key: String, value: String)] { + guard !searchText.isEmpty else { return mgr.entries } + return mgr.entries.filter { + $0.key.localizedCaseInsensitiveContains(searchText) + || $0.value.localizedCaseInsensitiveContains(searchText) + } + } var body: some View { NavigationStack { ZStack { - Color(UIColor.systemBackground).ignoresSafeArea() + // Subtle depth but same color scheme + LinearGradient( + gradient: Gradient(colors: [ + Color(UIColor.systemBackground), + Color(UIColor.secondarySystemBackground) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .ignoresSafeArea() ScrollView { - VStack(spacing: 16) { - SettingsCard { - VStack(alignment: .leading, spacing: 12) { - - if mgr.entries.isEmpty { - Text(mgr.busy ? "Loading…" : "No info available") - .foregroundColor(.secondary) - } else { - ForEach(mgr.entries, id: \.key) { entry in - VStack(alignment: .leading, spacing: 4) { - Text(entry.key) - .font(.subheadline.weight(.semibold)) - .foregroundColor(.primary) - - Text(entry.value) - .font(.footnote.monospaced()) - .foregroundColor(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - .contextMenu { - Button("Copy Key") { - UIPasteboard.general.string = entry.key - } - Button("Copy Value") { - UIPasteboard.general.string = entry.value - } - Button("Copy Both") { - UIPasteboard.general.string = "\(entry.key): \(entry.value)" - } - } - Divider() - } - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(16) - } - .padding(.horizontal, 16) + VStack(spacing: 20) { + infoCard + versionInfo } - .padding(.vertical, 20) + .padding(.horizontal, 20) + .padding(.vertical, 30) } if mgr.busy { - Color.black.opacity(0.3).ignoresSafeArea() - ProgressView() - .padding() - .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12)) + Color.black.opacity(0.35).ignoresSafeArea() + ProgressView("Fetching device info…") + .padding(16) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) + ) + ) + .shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4) } - if let err = mgr.error { - CustomErrorView( - title: err.title, - message: err.message, - onDismiss: { mgr.error = nil } - ) + if alert { + CustomErrorView(title: alertTitle, + message: alertMsg, + onDismiss: { alert = false }, + messageType: alertSuccess ? .success : .error) + } + + if justCopied { + VStack { + Spacer() + Text("Copied") + .font(.footnote.weight(.semibold)) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(.ultraThinMaterial, in: Capsule()) + .overlay(Capsule().strokeBorder(Color.white.opacity(0.15), lineWidth: 1)) + .shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 3) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .padding(.bottom, 30) + } + .animation(.easeInOut(duration: 0.25), value: justCopied) } } .navigationTitle("Device Info") .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button(action: { - do { - let url = try mgr.exportToCSV() - exportURL = url - isShowingExporter = true - } catch { - mgr.error = ("Export Failed", error.localizedDescription) + ToolbarItemGroup(placement: .navigationBarTrailing) { + if isPaired { + Button { mgr.initAndLoad() } label: { + Label("Reload", systemImage: "arrow.clockwise") } - }) { - Image(systemName: "square.and.arrow.up") + + Button { + do { + exportURL = try mgr.exportToCSV() + isShowingExporter = true + } catch { + fail("Export Failed", error.localizedDescription) + } + } label: { + Label("Export", systemImage: "square.and.arrow.up") + } + .disabled(mgr.entries.isEmpty) + + Menu { + Button { copyAllText() } label: { + Label("Copy All (Text)", systemImage: "doc.on.doc") + } + Button { copyAllCSV() } label: { + Label("Copy All (CSV)", systemImage: "tablecells") + } + Button { shareAll() } label: { + Label("Share…", systemImage: "square.and.arrow.up.on.square") + } + } label: { + Image(systemName: "ellipsis.circle") + } + .disabled(mgr.entries.isEmpty) } - .disabled(mgr.entries.isEmpty) } + ToolbarItem(placement: .navigationBarLeading) { + if !isPaired { + Button { importer = true } label: { + Label("Import Pairing File", systemImage: "doc.badge.plus") + } + } + } + } + .fileImporter(isPresented: $importer, allowedContentTypes: [.propertyList]) { result in + if case .success(let url) = result { importPairing(from: url) } } .fileExporter( isPresented: $isShowingExporter, document: CSVDocument(url: exportURL), contentType: .commaSeparatedText, defaultFilename: "DeviceInfo" - ) { result in - // completion + ) { _ in notify("Export Complete", "Device info exported to CSV") } + .sheet(isPresented: $showShareSheet) { + ActivityViewController(items: shareItems) + } + .onAppear { if isPaired { mgr.initAndLoad() } } + .onDisappear { mgr.cleanup() } + } + } + + // MARK: - UI Sections + + private var infoCard: some View { + VStack(alignment: .leading, spacing: 14) { + // Glassy search field + TextField("Search device info…", text: $searchText) + .padding(12) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(Color.white.opacity(0.12), lineWidth: 1) + ) + .padding(.bottom, 10) + + if !isPaired { + VStack(alignment: .leading, spacing: 6) { + Label("No pairing file detected", systemImage: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + Text("Import your device’s pairing file to get started.") + .font(.footnote) + .foregroundColor(.secondary) + } + .padding(.vertical, 6) + } + + if mgr.entries.isEmpty { + Text(mgr.busy ? "Loading…" : (isPaired ? "No info available" : "")) + .foregroundColor(.secondary) + } else { + ForEach(filteredEntries, id: \.key) { entry in + VStack(alignment: .leading, spacing: 6) { + Text(entry.key).bold() + Text(entry.value) + .font(.caption.monospaced()) + .foregroundColor(.secondary) + .textSelection(.enabled) + } + .padding(.vertical, 4) + Divider() + .background(Color.white.opacity(0.12)) + } } } - .onAppear { mgr.initAndLoad() } - .onDisappear { mgr.cleanup() } + .padding(20) + .background( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) + ) + ) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4) + } + + private var versionInfo: some View { + HStack { + Spacer() + Text("iOS \(UIDevice.current.systemVersion)") + .font(.footnote) + .foregroundColor(.secondary) + Spacer() + } + .padding(.top, 6) + } + + // MARK: - Copy / Share helpers + + private func allAsText() -> String { + filteredEntries.map { "\($0.key): \($0.value)" }.joined(separator: "\n") + } + + private func allAsCSV() -> String { + var csv = "Key,Value\n" + for (k, v) in filteredEntries { + let kq = k.replacingOccurrences(of: "\"", with: "\"\"") + let vq = v.replacingOccurrences(of: "\"", with: "\"\"") + csv += "\"\(kq)\",\"\(vq)\"\n" + } + return csv + } + + private func copyAllText() { + UIPasteboard.general.string = allAsText() + hapticCopySuccess() + showCopiedToast() + } + + private func copyAllCSV() { + UIPasteboard.general.string = allAsCSV() + hapticCopySuccess() + showCopiedToast() + } + + private func copyToPasteboard(_ str: String) { + UIPasteboard.general.string = str + hapticCopySuccess() + showCopiedToast() + } + + private func shareAll() { + let text = allAsText() + shareItems = [text] + showShareSheet = true + } + + private func hapticCopySuccess() { + let gen = UINotificationFeedbackGenerator() + gen.notificationOccurred(.success) + } + + private func showCopiedToast() { + withAnimation { justCopied = true } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + withAnimation { justCopied = false } + } + } + + // MARK: - Pairing Import + + private func importPairing(from src: URL) { + guard src.startAccessingSecurityScopedResource() else { return } + defer { src.stopAccessingSecurityScopedResource() } + do { + if FileManager.default.fileExists(atPath: pairingURL.path) { + try FileManager.default.removeItem(at: pairingURL) + } + try FileManager.default.copyItem(at: src, to: pairingURL) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: pairingURL.path) + notify("Pairing File Added", "Your device is ready. Tap Reload to fetch info.") + mgr.initAndLoad() + } catch { + fail("Import Failed", error.localizedDescription) + } + } + + // MARK: - Alerts + + private func fail(_ title: String, _ msg: String) { + alertTitle = title; alertMsg = msg; alertSuccess = false; alert = true + } + private func notify(_ title: String, _ msg: String) { + alertTitle = title; alertMsg = msg; alertSuccess = true; alert = true } } -import UniformTypeIdentifiers +// MARK: - CSV Document struct CSVDocument: FileDocument { static var readableContentTypes: [UTType] { [] } static var writableContentTypes: [UTType] { [UTType.commaSeparatedText] } - let url: URL? - - init(url: URL?) { - self.url = url - } - - init(configuration: ReadConfiguration) throws { - fatalError("Not supported") - } - + init(url: URL?) { self.url = url } + init(configuration: ReadConfiguration) throws { fatalError("Not supported") } func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { - guard let url = url else { - throw NSError(domain: "CSVDocument", code: -1, userInfo: nil) - } + guard let url = url else { throw NSError(domain: "CSVDocument", code: -1) } return try FileWrapper(url: url, options: .immediate) } } + +// MARK: - UIKit Share Sheet wrapper + +private struct ActivityViewController: UIViewControllerRepresentable { + let items: [Any] + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: items, applicationActivities: nil) + } + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) { } +} diff --git a/StikJIT/Views/DisplayView.swift b/StikJIT/Views/DisplayView.swift index 2fd2e90..a66a01a 100644 --- a/StikJIT/Views/DisplayView.swift +++ b/StikJIT/Views/DisplayView.swift @@ -4,7 +4,10 @@ // Created by neoarz on 4/9/25. import SwiftUI +import UIKit +import UniformTypeIdentifiers +// MARK: - Accent Color Picker (Glassy style) struct AccentColorPicker: View { @Binding var selectedColor: Color @@ -15,12 +18,12 @@ struct AccentColorPicker: View { .red, // Red .init(hex: "#6A5ACD")!, // Purple .init(hex: "#DA70D6")!, // Pink - .white, // white - .black // black + .white, // White + .black // Black ] var body: some View { - VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 14) { Text("Accent Color") .font(.headline) .foregroundColor(.primary) @@ -31,12 +34,10 @@ struct AccentColorPicker: View { .fill(color) .frame(width: 28, height: 28) .overlay( - Circle() - .stroke(Color.primary.opacity(0.1), lineWidth: 1) + Circle().stroke(Color.white.opacity(0.1), lineWidth: 1) ) .overlay( - Circle() - .stroke(selectedColor == color ? Color.primary : Color.clear, lineWidth: 2) + Circle().stroke(selectedColor == color ? Color.primary : .clear, lineWidth: 2) ) .onTapGesture { selectedColor = color @@ -48,17 +49,27 @@ struct AccentColorPicker: View { .labelsHidden() .frame(width: 28, height: 28) .overlay( - Circle() - .stroke(Color.primary.opacity(0.1), lineWidth: 1) + Circle().stroke(Color.white.opacity(0.1), lineWidth: 1) ) .clipShape(Circle()) } .frame(maxWidth: .infinity) } - .padding(.top, 12) + .padding(20) + .background( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) + ) + ) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 4) } } +// MARK: - Display Settings View struct DisplayView: View { @AppStorage("username") private var username = "User" @AppStorage("customAccentColor") private var customAccentColorHex: String = "" @@ -67,6 +78,8 @@ struct DisplayView: View { @State private var selectedAccentColor: Color = .blue @Environment(\.colorScheme) private var colorScheme + @State private var justSaved = false + private var accentColor: Color { if customAccentColorHex.isEmpty { return .blue @@ -76,133 +89,164 @@ struct DisplayView: View { } var body: some View { - ZStack { - Color(colorScheme == .dark ? .black : UIColor.systemBackground) + NavigationStack { + ZStack { + // Background gradient (glassy style) + LinearGradient( + gradient: Gradient(colors: [ + Color(UIColor.systemBackground), + Color(UIColor.secondarySystemBackground) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) .ignoresSafeArea() - ScrollView { - VStack(spacing: 16) { - // App Theme Section with Accent Colors - VStack(alignment: .leading, spacing: 16) { - Text("Theme") - .font(.title3) - .fontWeight(.semibold) - .foregroundColor(.primary) - .padding(.bottom, 4) - - // Theme selector with preview images - HStack(spacing: 12) { - // Automatic Theme - ThemeOptionButton( - title: "Automatic", - imageName: "System", - isSelected: appTheme == "system", - accentColor: selectedAccentColor, - action: { appTheme = "system" } - ) - - // Light Theme - ThemeOptionButton( - title: "Light", - imageName: "LightUI", - isSelected: appTheme == "light", - accentColor: selectedAccentColor, - action: { appTheme = "light" } - ) - - // Dark Theme - ThemeOptionButton( - title: "Dark", - imageName: "DarkUI", - isSelected: appTheme == "dark", - accentColor: selectedAccentColor, - action: { appTheme = "dark" } - ) - } - .onChange(of: appTheme) { newValue in - applyTheme(newValue) - } - - Divider() - .padding(.vertical, 4) - - // Accent Color Picker - AccentColorPicker(selectedColor: Binding( - get: { selectedAccentColor }, - set: { newColor in - selectedAccentColor = newColor - saveCustomAccentColor(newColor) - } - )) + ScrollView { + VStack(spacing: 20) { + usernameCard + jitOptionsCard } - .padding(.vertical, 16) - .padding(.horizontal, 16) - .background(Color(UIColor.systemGray6)) - .cornerRadius(16) - - // Username Section - VStack(alignment: .leading, spacing: 12) { - Text("Username") - .font(.title3) - .fontWeight(.semibold) - .foregroundColor(.primary) - - HStack { - TextField("Username", text: $username) - .font(.body) - .foregroundColor(.primary) - .padding(.vertical, 8) - - if !username.isEmpty { - Button(action: { username = "" }) { - Image(systemName: "xmark.circle.fill") - .foregroundColor(Color(UIColor.tertiaryLabel)) - .font(.system(size: 16)) - } - } - } - .padding(.horizontal, 12) - .background(Color(UIColor.tertiarySystemFill)) - .cornerRadius(8) - } - .padding(.vertical, 16) - .padding(.horizontal, 16) - .background(Color(UIColor.systemGray6)) - .cornerRadius(16) - - // JIT Options Section - VStack(alignment: .leading, spacing: 12) { - Text("App List") - .font(.title3) - .fontWeight(.semibold) - .foregroundColor(.primary) - - VStack(alignment: .leading, spacing: 6) { - Toggle("Load App Icons", isOn: $loadAppIconsOnJIT) - .foregroundColor(.primary) - .tint(.green) - - Text("Disabling this will hide app icons in the app list and may improve performance, while also giving it a more minimalistic look.") - .font(.footnote) - .foregroundColor(Color(UIColor.secondaryLabel)) - } - } - .padding(.vertical, 16) - .padding(.horizontal, 16) - .background(Color(UIColor.systemGray6)) - .cornerRadius(16) + .padding(.horizontal, 20) + .padding(.vertical, 30) } - .padding(.horizontal, 16) - .padding(.vertical, 20) + + if justSaved { + VStack { + Spacer() + Text("Saved") + .font(.footnote.weight(.semibold)) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(.ultraThinMaterial, in: Capsule()) + .overlay(Capsule().strokeBorder(Color.white.opacity(0.15), lineWidth: 1)) + .shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 3) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .padding(.bottom, 30) + } + .animation(.easeInOut(duration: 0.25), value: justSaved) + } + } + .navigationTitle("Display") + .onAppear { + loadCustomAccentColor() + applyTheme(appTheme) } } - .navigationTitle("Display") - .onAppear { - loadCustomAccentColor() - applyTheme(appTheme) - } } + // MARK: - Cards + + private var usernameCard: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Username") + .font(.title3) + .fontWeight(.semibold) + .foregroundColor(.primary) + + HStack { + TextField("Username", text: $username) + .font(.body) + .foregroundColor(.primary) + .padding(.vertical, 8) + + if !username.isEmpty { + Button(action: { + username = "" + showSavedToast() + }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(Color(UIColor.tertiaryLabel)) + .font(.system(size: 16)) + } + } + } + .padding(.horizontal, 12) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(Color.white.opacity(0.12), lineWidth: 1) + ) + } + .padding(20) + .background( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) + ) + ) + .shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 4) + } + + private var jitOptionsCard: some View { + VStack(alignment: .leading, spacing: 12) { + Text("App List") + .font(.title3) + .fontWeight(.semibold) + .foregroundColor(.primary) + + VStack(alignment: .leading, spacing: 6) { + Toggle("Load App Icons", isOn: $loadAppIconsOnJIT) + .tint(accentColor) + + Text("Disabling this will hide app icons in the app list and may improve performance, while also giving it a more minimalistic look.") + .font(.footnote) + .foregroundColor(.secondary) + } + } + .padding(20) + .background( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) + ) + ) + .shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 4) + } + + private var themeCard: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Theme") + .font(.title3) + .fontWeight(.semibold) + .foregroundColor(.primary) + + HStack(spacing: 12) { + ThemeOptionButton(title: "System", imageName: "theme-system", isSelected: appTheme == "system", accentColor: accentColor) { + appTheme = "system" + applyTheme(appTheme) + showSavedToast() + } + ThemeOptionButton(title: "Light", imageName: "theme-light", isSelected: appTheme == "light", accentColor: accentColor) { + appTheme = "light" + applyTheme(appTheme) + showSavedToast() + } + ThemeOptionButton(title: "Dark", imageName: "theme-dark", isSelected: appTheme == "dark", accentColor: accentColor) { + appTheme = "dark" + applyTheme(appTheme) + showSavedToast() + } + } + } + .padding(20) + .background( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) + ) + ) + .shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 4) + } + + // MARK: - Helpers + private func loadCustomAccentColor() { if customAccentColorHex.isEmpty { selectedAccentColor = .blue @@ -211,27 +255,26 @@ struct DisplayView: View { } } - private func saveCustomAccentColor(_ color: Color) { - // Always save the custom color if we got here (since auto theme mode is disabled) - customAccentColorHex = color.toHex() ?? "" - } - private func applyTheme(_ theme: String) { - // Set the app's theme (will apply when app is restarted) if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let window = windowScene.windows.first { switch theme { - case "dark": - window.overrideUserInterfaceStyle = .dark - case "light": - window.overrideUserInterfaceStyle = .light - default: - window.overrideUserInterfaceStyle = .unspecified + case "dark": window.overrideUserInterfaceStyle = .dark + case "light": window.overrideUserInterfaceStyle = .light + default: window.overrideUserInterfaceStyle = .unspecified } } } + + private func showSavedToast() { + withAnimation { justSaved = true } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + withAnimation { justSaved = false } + } + } } +// MARK: - Theme Option Button (Glassy style) struct ThemeOptionButton: View { let title: String let imageName: String @@ -245,19 +288,21 @@ struct ThemeOptionButton: View { Image(imageName) .resizable() .aspectRatio(contentMode: .fit) - .frame(height: 160) - .cornerRadius(8) + .frame(height: 120) + .cornerRadius(12) .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(isSelected ? accentColor : Color.clear, lineWidth: 2) + RoundedRectangle(cornerRadius: 12) + .stroke(isSelected ? accentColor : .clear, lineWidth: 2) ) Text(title) .font(.caption) .foregroundColor(isSelected ? accentColor : .primary) } + .padding(8) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12)) + .shadow(color: .black.opacity(0.08), radius: 6, x: 0, y: 2) } .frame(maxWidth: .infinity) } } - diff --git a/StikJIT/Views/HomeView.swift b/StikJIT/Views/HomeView.swift index 49d2a5b..6903791 100644 --- a/StikJIT/Views/HomeView.swift +++ b/StikJIT/Views/HomeView.swift @@ -48,174 +48,112 @@ struct HomeView: View { @AppStorage("DefaultScriptName") var selectedScript = "attachDetach.js" @State var jsModel: RunJSViewModel? + // Observe VPN status + @StateObject private var tunnel = TunnelManager.shared + // Mirror heartbeat boolean + @State private var heartbeatOK = false + private var accentColor: Color { if customAccentColorHex.isEmpty { - return .blue + return .white } else { - return Color(hex: customAccentColorHex) ?? .blue + return Color(hex: customAccentColorHex) ?? .white } } + // Derived states + private var ddiMounted: Bool { isMounted() } + private var canConnectByApp: Bool { pairingFileExists && ddiMounted } + var body: some View { - ZStack { - // Use system background - Color(colorScheme == .dark ? .black : .white) - .edgesIgnoringSafeArea(.all) - - VStack(spacing: 25) { - Spacer() - VStack(spacing: 5) { - Text("Welcome to StikDebug \(username)!") - .font(.system(.largeTitle, design: .rounded)) - .fontWeight(.bold) - - Text(pairingFileExists ? "Click connect to get started" : "Pick pairing file to get started") - .font(.system(.subheadline, design: .rounded)) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - .padding(.top, 40) + NavigationStack { + ZStack { + // DeviceInfo-style subtle gradient background + LinearGradient( + gradient: Gradient(colors: [ + Color(UIColor.systemBackground), + Color(UIColor.secondarySystemBackground) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .ignoresSafeArea() - Button(action: { - - - if pairingFileExists { - // Got a pairing file, show apps - if !isMounted() { - showAlert(title: "Device Not Mounted".localized, message: "The Developer Disk Image has not been mounted yet. Check in settings for more information.".localized, showOk: true) { cool in - // No Need - } - return - } + ScrollView { + VStack(spacing: 20) { + // Top card: status + both actions + topStatusAndActionsCard - isShowingInstalledApps = true + // Tools (Console) + toolsCard - } else { - // No pairing file yet, let's get one - isShowingPairingFilePicker = true - } - }) { - HStack { - Image(systemName: pairingFileExists ? "cable.connector.horizontal" : "doc.badge.plus") - .font(.system(size: 20)) - Text(pairingFileExists ? "Connect by App" : "Select Pairing File") - .font(.system(.title3, design: .rounded)) - .fontWeight(.semibold) - } - .frame(maxWidth: .infinity) - .padding() - .background(accentColor) - .foregroundColor(accentColor.contrastText()) - .cornerRadius(16) - .shadow(color: accentColor.opacity(0.3), radius: 8, x: 0, y: 4) - } - .padding(.horizontal, 20) - - if pairingFileExists && enableAdvancedOptions { - Button(action: { - pidTextAlertShow = true - }) { - HStack { - Image(systemName: "cable.connector.horizontal") - .font(.system(size: 20)) - Text("Connect by PID") - .font(.system(.title3, design: .rounded)) - .fontWeight(.semibold) - } - .frame(maxWidth: .infinity) - .padding() - .background(accentColor) - .foregroundColor(accentColor.contrastText()) - .cornerRadius(16) - .shadow(color: accentColor.opacity(0.3), radius: 8, x: 0, y: 4) + // Helpful tips + tipsCard } .padding(.horizontal, 20) + .padding(.vertical, 30) } - Button(action: { - showingConsoleLogsView = true - }) { - HStack { - Image(systemName: "apple.terminal") - .font(.system(size: 20)) - Text("Open Console") - .font(.system(.title3, design: .rounded)) - .fontWeight(.semibold) - } - .frame(maxWidth: .infinity) - .padding() - .background(accentColor) - .foregroundColor(accentColor.contrastText()) - .cornerRadius(16) - .shadow(color: accentColor.opacity(0.3), radius: 8, x: 0, y: 4) - } - .padding(.horizontal, 20) - .sheet(isPresented: $showingConsoleLogsView) { - ConsoleLogsView() - } - - // Status message area - keeps layout consistent - ZStack { - // Progress bar for importing file - if isImportingFile { + // Busy overlay during pairing import (DeviceInfo-style) + if isImportingFile { + Color.black.opacity(0.35).ignoresSafeArea() + VStack(spacing: 12) { + ProgressView("Processing pairing file...") VStack(spacing: 8) { - HStack { - Text("Processing pairing file...") - .font(.system(.caption, design: .rounded)) - .foregroundColor(.secondaryText) - Spacer() - Text("\(Int(importProgress * 100))%") - .font(.system(.caption, design: .rounded)) - .foregroundColor(.secondaryText) - } - GeometryReader { geometry in ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 4) - .fill(Color.black.opacity(0.2)) + RoundedRectangle(cornerRadius: 6) + .fill(Color(UIColor.tertiarySystemFill)) .frame(height: 8) - RoundedRectangle(cornerRadius: 4) + RoundedRectangle(cornerRadius: 6) .fill(Color.green) .frame(width: geometry.size.width * CGFloat(importProgress), height: 8) .animation(.linear(duration: 0.3), value: importProgress) } } .frame(height: 8) + Text("\(Int(importProgress * 100))%") + .font(.caption) + .foregroundColor(.secondary) } - .padding(.horizontal, 40) + .padding(.top, 6) } - - // Success message - if showPairingFileMessage && pairingFileIsValid { - Text("✓ Pairing file successfully imported") - .font(.system(.callout, design: .rounded)) - .foregroundColor(.green) - .padding(.vertical, 4) - .padding(.horizontal, 12) - .background(Color.green.opacity(0.1)) - .cornerRadius(8) - .transition(.opacity) - } - - // Invisible text to reserve space - no layout jumps - Text(" ").opacity(0) + .padding(16) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) + ) + ) + .shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4) } - .frame(height: isImportingFile ? 60 : 30) // Adjust height based on what's showing - Spacer() + // Success toast after import (DeviceInfo-style) + if showPairingFileMessage && pairingFileIsValid && !isImportingFile { + VStack { + Spacer() + Text("✓ Pairing file successfully imported") + .font(.footnote.weight(.semibold)) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(.ultraThinMaterial, in: Capsule()) + .overlay(Capsule().strokeBorder(Color.white.opacity(0.15), lineWidth: 1)) + .shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 3) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .padding(.bottom, 30) + } + .animation(.easeInOut(duration: 0.25), value: showPairingFileMessage) + } } - .padding() + .navigationTitle("Home") } .onAppear { checkPairingFileExists() - // Don't initialize specific color value when empty - empty means "use system theme" - // This was causing the toggle to turn off when returning to settings - - // Initialize background color refreshBackground() - // Add notification observer for showing pairing file picker NotificationCenter.default.addObserver( forName: NSNotification.Name("ShowPairingFilePicker"), object: nil, @@ -227,11 +165,11 @@ struct HomeView: View { .onReceive(timer) { _ in refreshBackground() checkPairingFileExists() - + // Update heartbeat mirror from global + heartbeatOK = pubHeartBeat } .fileImporter(isPresented: $isShowingPairingFilePicker, allowedContentTypes: [UTType(filenameExtension: "mobiledevicepairing", conformingTo: .data)!, .propertyList]) {result in switch result { - case .success(let url): let fileManager = FileManager.default let accessing = url.startAccessingSecurityScopedResource() @@ -396,24 +334,291 @@ struct HomeView: View { } } - + // MARK: - Styled Sections - private func checkPairingFileExists() { - let fileExists = FileManager.default.fileExists(atPath: URL.documentsDirectory.appendingPathComponent("pairingFile.plist").path) - - // If the file exists, check if it's valid - if fileExists { - // Check if the pairing file is valid - let isValid = isPairing() - pairingFileExists = isValid + // Top card that includes: status icons + both actions + private var topStatusAndActionsCard: some View { + VStack(alignment: .leading, spacing: 14) { + // Header row with icon-only indicators + HStack(spacing: 12) { + // Pairing + indicatorCapsule( + ok: pairingFileExists, + systemImage: "doc.badge.plus", + a11y: "Pairing" + ) + // DDI + indicatorCapsule( + ok: ddiMounted, + systemImage: "externaldrive", + a11y: "Developer Disk Image" + ) + // VPN + indicatorCapsule( + ok: tunnel.tunnelStatus == .connected, + systemImage: "lock.shield", + a11y: "VPN" + ) + // Heartbeat + indicatorCapsule( + ok: heartbeatOK, + systemImage: "waveform.path.ecg", + a11y: "Heartbeat" + ) + + Spacer() + } + + // Welcome text + VStack(spacing: 4) { + Text("Welcome, \(username)") + .font(.system(.title2, design: .rounded).weight(.bold)) + .foregroundColor(.primary) + .frame(maxWidth: .infinity, alignment: .leading) + Text(helperSubtitle()) + .font(.system(.subheadline, design: .rounded)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.bottom, 4) + + // Buttons in one vertical stack + VStack(spacing: 10) { + // Connect by App / Select Pairing File + Button(action: primaryActionTapped) { + HStack { + Image(systemName: pairingFileExists ? "cable.connector.horizontal" : "doc.badge.plus") + .font(.system(size: 20)) + Text(pairingFileExists ? "Connect by App" : "Select Pairing File") + .font(.system(.title3, design: .rounded)) + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(Color.white, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .foregroundColor(.black) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.white.opacity(0.2), lineWidth: 0.5) + ) + } + .disabled(pairingFileExists && !ddiMounted) + .opacity(pairingFileExists && !ddiMounted ? 0.6 : 1.0) + + // Connect by PID (Advanced only) + if pairingFileExists && enableAdvancedOptions { + Button(action: { pidTextAlertShow = true }) { + HStack { + Image(systemName: "number.circle") + .font(.system(size: 20)) + Text("Connect by PID") + .font(.system(.title3, design: .rounded)) + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(Color.white, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .foregroundColor(.black) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.white.opacity(0.2), lineWidth: 0.5) + ) + } + } + } + + // Inline status / progress + if isImportingFile { + VStack(spacing: 8) { + HStack { + Text("Processing pairing file…") + .font(.system(.caption, design: .rounded)) + .foregroundColor(.secondary) + Spacer() + Text("\(Int(importProgress * 100))%") + .font(.system(.caption, design: .rounded)) + .foregroundColor(.secondary) + } + GeometryReader { geometry in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 6) + .fill(Color(UIColor.tertiarySystemFill)) + .frame(height: 8) + RoundedRectangle(cornerRadius: 6) + .fill(Color.green) + .frame(width: geometry.size.width * CGFloat(importProgress), height: 8) + .animation(.linear(duration: 0.3), value: importProgress) + } + } + .frame(height: 8) + } + .padding(.top, 4) + } else if showPairingFileMessage && pairingFileIsValid { + HStack(spacing: 10) { + StatusDot(color: .green) + Text("Pairing file successfully imported") + .font(.system(.callout, design: .rounded)) + .foregroundColor(.green) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.top, 4) + } + } + .padding(20) + .background( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) + ) + ) + .shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4) + } + + // Compact indicator builder: dot + SF Symbol in a capsule + private func indicatorCapsule(ok: Bool, systemImage: String, a11y: String) -> some View { + let fixedHeight: CGFloat = 32 + return HStack(spacing: 8) { + StatusDot(color: ok ? .green : .orange) + Image(systemName: systemImage) + .font(.subheadline.weight(.semibold)) + .foregroundColor(.primary) + .accessibilityLabel(a11y) + } + .frame(height: fixedHeight) + .padding(.horizontal, 10) + .background( + Capsule(style: .continuous) + .fill(Color(UIColor.tertiarySystemBackground)) + ) + } + + private var toolsCard: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Tools") + .font(.headline) + .foregroundColor(.secondary) + + Button(action: { showingConsoleLogsView = true }) { + // Centered icon + text, no chevron + HStack(spacing: 8) { + Image(systemName: "terminal") + .font(.system(size: 20)) + Text("Open Console") + .font(.system(.title3, design: .rounded)) + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity, alignment: .center) + .foregroundColor(.black) + .padding(.vertical, 14) + .padding(.horizontal, 12) + .background(Color.white, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.white.opacity(0.2), lineWidth: 0.5) + ) + } + .sheet(isPresented: $showingConsoleLogsView) { + ConsoleLogsView() + } + } + .padding(20) + .background( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) + ) + ) + .shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4) + } + + private var tipsCard: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Tips") + .font(.headline) + .foregroundColor(.secondary) + + if !pairingFileExists { + tipRow( + systemImage: "doc.badge.plus", + title: "Pairing file required", + message: "Import your device’s pairing file to begin." + ) + } + + if pairingFileExists && !ddiMounted { + tipRow( + systemImage: "externaldrive.badge.exclamationmark", + title: "Developer Disk Image not mounted", + message: "Go to Settings → Developer Disk Image and ensure it’s mounted." + ) + } + + tipRow( + systemImage: "lock.shield", + title: "Local only", + message: "StikDebug runs entirely on-device. No data leaves your device." + ) + } + .padding(20) + .background( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) + ) + ) + .shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4) + } + + private func tipRow(systemImage: String, title: String, message: String) -> some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: systemImage) + .foregroundColor(accentColor) + .font(.system(size: 18)) + .frame(width: 24) + VStack(alignment: .leading, spacing: 2) { + Text(title).font(.subheadline).fontWeight(.semibold) + Text(message).font(.footnote).foregroundColor(.secondary) + } + Spacer() + } + .padding(.vertical, 4) + } + + // MARK: - Helpers (UI) + + private func helperSubtitle() -> String { + if canConnectByApp { return "Select an app to start debugging." } + if pairingFileExists { return "Mount the Developer Disk Image in Settings." } + return "Import your pairing file to get started." + } + + private func primaryActionTapped() { + if pairingFileExists { + if !ddiMounted { + showAlert(title: "Device Not Mounted".localized, message: "The Developer Disk Image has not been mounted yet. Check in settings for more information.".localized, showOk: true) { _ in } + return + } + isShowingInstalledApps = true } else { - pairingFileExists = false + isShowingPairingFilePicker = true } } + // MARK: - Logic + + private func checkPairingFileExists() { + let fileExists = FileManager.default.fileExists(atPath: URL.documentsDirectory.appendingPathComponent("pairingFile.plist").path) + pairingFileExists = fileExists && isPairing() + } + private func refreshBackground() { - // This function is no longer needed for background color - // but we'll keep it empty to avoid breaking anything + // kept for compatibility } private func getJsCallback(_ script: Data, name: String? = nil) -> DebugAppCallback { @@ -436,7 +641,6 @@ struct HomeView: View { // if advanced mode is disabled the whole script loading will be skipped. If use default script is disabled default script will not be loaded private func startJITInBackground(bundleID: String? = nil, pid : Int? = nil, scriptData: Data? = nil, scriptName : String? = nil, triggeredByURLScheme: Bool = false) { isProcessing = true - // Add log message LogManager.shared.addInfoLog("Starting Debug for \(bundleID ?? String(pid ?? 0))") DispatchQueue.global(qos: .background).async { @@ -468,7 +672,6 @@ struct HomeView: View { scriptData = nil } - var callback: DebugAppCallback? = nil if let scriptData { @@ -481,9 +684,7 @@ struct HomeView: View { } let logger: LogFunc = { message in - if let message = message { - // Log messages from the JIT process LogManager.shared.addInfoLog(message) } } @@ -525,6 +726,50 @@ struct HomeView: View { } +// MARK: - Status Light Components + +private struct StatusLight: View { + enum State { case ok, warn } + var title: String + var state: State + + var body: some View { + let fixedHeight: CGFloat = 32 + HStack(spacing: 8) { + StatusDot(color: state == .ok ? .green : .orange) + Text(title) + .font(.subheadline.weight(.semibold)) + .foregroundColor(.primary) + } + .frame(height: fixedHeight) + .padding(.horizontal, 10) + .background( + Capsule(style: .continuous) + .fill(Color(UIColor.tertiarySystemBackground)) + ) + } +} + +private struct StatusDot: View { + var color: Color + @Environment(\.colorScheme) private var colorScheme + var body: some View { + ZStack { + Circle() + .fill(color.opacity(0.25)) + .frame(width: 20, height: 20) + Circle() + .fill(color) + .frame(width: 12, height: 12) + .shadow(color: color.opacity(0.6), radius: 4, x: 0, y: 0) + } + .overlay( + Circle() + .stroke(colorScheme == .dark ? Color.white.opacity(0.15) : Color.black.opacity(0.1), lineWidth: 0.5) + ) + } +} + class InstalledAppsViewModel: ObservableObject { @Published var apps: [String: String] = [:] diff --git a/StikJIT/Views/InstalledAppsListView.swift b/StikJIT/Views/InstalledAppsListView.swift index 1fa195e..8f30c44 100644 --- a/StikJIT/Views/InstalledAppsListView.swift +++ b/StikJIT/Views/InstalledAppsListView.swift @@ -34,7 +34,17 @@ struct InstalledAppsListView: View { var body: some View { NavigationView { - Group { + ZStack { + LinearGradient( + gradient: Gradient(colors: [ + Color(UIColor.systemBackground), + Color(UIColor.secondarySystemBackground) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .ignoresSafeArea() + if viewModel.apps.isEmpty { emptyState } else { @@ -48,85 +58,118 @@ struct InstalledAppsListView: View { } } } - .background(Color(UIColor.systemGroupedBackground).edgesIgnoringSafeArea(.all)) } private var emptyState: some View { VStack(spacing: 16) { Image(systemName: "magnifyingglass") - .resizable().scaledToFit().frame(width: 60, height: 60).foregroundColor(.gray) + .resizable() + .scaledToFit() + .frame(width: 60, height: 60) + .foregroundColor(.gray) + Text("No Debuggable App Found") - .font(.title2).fontWeight(.semibold).foregroundColor(.primary) + .font(.title2) + .fontWeight(.semibold) + .foregroundColor(.primary) + Text(""" StikDebug can only connect to apps with the "**get-task-allow**" entitlement. \ Please check if the app you want to connect to is signed with a **development** certificate. """) - .font(.body).foregroundColor(.secondary).multilineTextAlignment(.center).padding(.horizontal) + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) } - .padding().frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(UIColor.systemBackground)) + .padding(30) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) + ) + ) + .padding() + .shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4) } private var appsList: some View { - List { - if !favoriteApps.isEmpty { - Section(header: Text(String(format: "Favorites (%d/4)".localized, favoriteApps.count))) { - ForEach(favoriteApps, id: \.self) { bundleID in - AppButton( - bundleID: bundleID, - appName: viewModel.apps[bundleID] ?? bundleID, - recentApps: $recentApps, - favoriteApps: $favoriteApps, - appIcons: $appIcons, - onSelectApp: onSelectApp, - sharedDefaults: sharedDefaults - ) - } - } - } - - if !filteredRecents.isEmpty { - Section(header: Text("Recents".localized)) { - ForEach(filteredRecents, id: \.self) { bundleID in - AppButton( - bundleID: bundleID, - appName: viewModel.apps[bundleID] ?? bundleID, - recentApps: $recentApps, - favoriteApps: $favoriteApps, - appIcons: $appIcons, - onSelectApp: onSelectApp, - sharedDefaults: sharedDefaults - ) - .swipeActions(edge: .trailing) { - Button(role: .destructive) { - withAnimation { - recentApps.removeAll { $0 == bundleID } - sharedDefaults.set(recentApps, forKey: "recentApps") - WidgetCenter.shared.reloadAllTimelines() - } - } label: { - Label("Delete", systemImage: "trash") - } + ScrollView { + VStack(spacing: 18) { + if !favoriteApps.isEmpty { + glassSection(title: String(format: "Favorites (%d/4)".localized, favoriteApps.count)) { + ForEach(favoriteApps, id: \.self) { bundleID in + AppButton( + bundleID: bundleID, + appName: viewModel.apps[bundleID] ?? bundleID, + recentApps: $recentApps, + favoriteApps: $favoriteApps, + appIcons: $appIcons, + onSelectApp: onSelectApp, + sharedDefaults: sharedDefaults + ) } } } - } - Section(header: Text((favoriteApps.isEmpty && filteredRecents.isEmpty) ? "" : "All Applications".localized)) { - ForEach(viewModel.apps.sorted(by: { $0.key < $1.key }), id: \.key) { bundleID, appName in - AppButton( - bundleID: bundleID, - appName: appName, - recentApps: $recentApps, - favoriteApps: $favoriteApps, - appIcons: $appIcons, - onSelectApp: onSelectApp, - sharedDefaults: sharedDefaults - ) + if !filteredRecents.isEmpty { + glassSection(title: "Recents".localized) { + ForEach(filteredRecents, id: \.self) { bundleID in + AppButton( + bundleID: bundleID, + appName: viewModel.apps[bundleID] ?? bundleID, + recentApps: $recentApps, + favoriteApps: $favoriteApps, + appIcons: $appIcons, + onSelectApp: onSelectApp, + sharedDefaults: sharedDefaults + ) + } + } + } + + glassSection(title: (favoriteApps.isEmpty && filteredRecents.isEmpty) ? "All Applications".localized : "All Applications".localized) { + ForEach(viewModel.apps.sorted(by: { $0.key < $1.key }), id: \.key) { bundleID, appName in + AppButton( + bundleID: bundleID, + appName: appName, + recentApps: $recentApps, + favoriteApps: $favoriteApps, + appIcons: $appIcons, + onSelectApp: onSelectApp, + sharedDefaults: sharedDefaults + ) + } } } + .padding(.horizontal, 20) + .padding(.vertical, 24) } - .listStyle(.plain) + } + + private func glassSection(title: String, @ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 14) { + Text(title) + .font(.headline) + .foregroundColor(.secondary) + + VStack(spacing: 12) { + content() + } + } + .padding(20) + .background( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) + ) + ) + .shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4) } } @@ -151,7 +194,17 @@ struct AppButton: View { appText Spacer() } - .padding(.vertical, loadAppIconsOnJIT ? 0 : 8) + .padding(.vertical, loadAppIconsOnJIT ? 8 : 12) + .padding(.horizontal, 12) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) + ) + ) + .shadow(color: .black.opacity(0.1), radius: 6, x: 0, y: 3) } .contextMenu { Button(action: toggleFavorite) { @@ -186,16 +239,16 @@ struct AppButton: View { Image(uiImage: image) .resizable() .aspectRatio(contentMode: .fit) - .frame(width: 60, height: 60) + .frame(width: 54, height: 54) .cornerRadius(12) - .shadow(color: colorScheme == .dark ? .black.opacity(0.2) : .gray.opacity(0.2), radius: 3, x: 0, y: 1) + .shadow(color: .black.opacity(0.2), radius: 4, x: 0, y: 2) } else { RoundedRectangle(cornerRadius: 12) .fill(Color(UIColor.systemGray5)) - .frame(width: 60, height: 60) + .frame(width: 54, height: 54) .overlay( Image(systemName: "app") - .font(.system(size: 26)) + .font(.system(size: 24)) .foregroundColor(.gray) ) .shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1) @@ -205,13 +258,13 @@ struct AppButton: View { } private var appText: some View { - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: 3) { Text(appName) - .font(.system(size: 18, weight: .semibold)) + .font(.system(size: 17, weight: .semibold)) .foregroundColor(.primary) Text(bundleID) - .font(.system(size: 15)) - .foregroundColor(.gray) + .font(.system(size: 14)) + .foregroundColor(.secondary) .lineLimit(1) } } @@ -251,15 +304,11 @@ struct AppButton: View { private func loadAppIcon(for bundleID: String) { guard loadAppIconsOnJIT else { return } - // 1) Check disk cache first if let cachedImage = loadCachedIcon(bundleID: bundleID) { - DispatchQueue.main.async { - appIcons[bundleID] = cachedImage - } + DispatchQueue.main.async { appIcons[bundleID] = cachedImage } return } - // 2) Otherwise fetch from network and then cache AppStoreIconFetcher.getIcon(for: bundleID) { image in guard let image = image else { return } DispatchQueue.main.async { @@ -313,7 +362,6 @@ extension Array: @retroactive RawRepresentable where Element: Codable { } } -// Preview helper #Preview { InstalledAppsListView { _ in } } diff --git a/StikJIT/Views/MainTabView.swift b/StikJIT/Views/MainTabView.swift index 06360c2..fb00e55 100644 --- a/StikJIT/Views/MainTabView.swift +++ b/StikJIT/Views/MainTabView.swift @@ -6,354 +6,45 @@ // import SwiftUI -import UIKit - -struct TabItem: Identifiable, Hashable { - let id: Int - let title: String - let systemImage: String -} - -struct BlurView: UIViewRepresentable { - let style: UIBlurEffect.Style - func makeUIView(context: Context) -> UIVisualEffectView { - UIVisualEffectView(effect: UIBlurEffect(style: style)) - } - func updateUIView(_ uiView: UIVisualEffectView, context: Context) {} -} - -struct RoundedCorner: Shape { - var radius: CGFloat = .infinity - var corners: UIRectCorner = .allCorners - func path(in rect: CGRect) -> Path { - Path(UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius)).cgPath) - } -} - -extension View { - func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View { - clipShape(RoundedCorner(radius: radius, corners: corners)) - } -} - -@inline(__always) -fileprivate func smoothstep(_ x: CGFloat, _ a: CGFloat, _ b: CGFloat) -> CGFloat { - if a == b { return x >= b ? 1 : 0 } - let t = max(0, min(1, (x - a) / (b - a))) - return t * t * (3 - 2 * t) -} - -fileprivate let drawerSpring: Animation = .interactiveSpring(response: 0.42, dampingFraction: 0.86, blendDuration: 0.1) - -fileprivate struct DrawerHandle: View { - let progress: CGFloat - var body: some View { - RoundedRectangle(cornerRadius: 2.5) - .frame(width: 40, height: 5) - .foregroundColor(.secondary) - .padding(.top, 8) - .scaleEffect(1 + 0.10 * progress) - .opacity(0.9 + 0.1 * (1 - progress)) - .animation(drawerSpring, value: progress) - } -} - -fileprivate struct FavoritesDockView: View { - let favorites: [TabItem] - @Binding var selection: Int - let accentColor: Color - let progress: CGFloat - let bottomPadding: CGFloat - - var body: some View { - let fade = 1 - smoothstep(progress, 0.30, 0.55) - let yLift: CGFloat = 8 * smoothstep(progress, 0.00, 0.55) - HStack { - Spacer() - ForEach(favorites.indices, id: \.self) { i in - let item = favorites[i] - Button { - selection = item.id - } label: { - Image(systemName: item.systemImage) - .font(.system(size: 24, weight: .semibold)) - .foregroundColor(selection == item.id ? accentColor : .secondary) - .frame(maxWidth: .infinity) - .accessibilityLabel(Text(item.title)) - } - } - Spacer() - } - .padding(.vertical, 10) - .padding(.bottom, bottomPadding) - .opacity(fade) - .offset(y: yLift) - .animation(drawerSpring, value: progress) - .allowsHitTesting(progress < 0.5) - .zIndex(progress < 0.5 ? 1 : 0) - } -} - -fileprivate struct AppGridView: View { - static let columns: [GridItem] = Array(repeating: GridItem(.flexible(), spacing: 16), count: 4) - let items: [TabItem] - @Binding var selection: Int - let accentColor: Color - let progress: CGFloat - let slotMap: [Int: Int] - let assignToSlot: (Int, Int) -> Void - let debugLocationId: Int? - - var body: some View { - let fade = smoothstep(progress, 0.45, 0.75) - let yRise: CGFloat = 14 * (1 - fade) - - ScrollView { - LazyVGrid(columns: Self.columns, spacing: 16) { - ForEach(items.indices, id: \.self) { i in - let item = items[i] - Button { - selection = item.id - } label: { - VStack(spacing: 8) { - ZStack(alignment: .topTrailing) { - Image(systemName: item.systemImage) - .font(.system(size: 24, weight: .semibold)) - .frame(maxWidth: .infinity, alignment: .center) - if let slot = slotMap[item.id] { - Text("\(slot)") - .font(.system(size: 10, weight: .bold)) - .padding(4) - .background(.ultraThinMaterial, in: Capsule()) - .offset(x: 6, y: -6) - .accessibilityLabel(Text("Favorites Dock position \(slot)")) - } - } - Text(item.title) - .font(.caption) - } - .foregroundColor(selection == item.id ? accentColor : .primary) - .padding(8) - } - .contextMenu { - Text("Add to Favorites Dock") - Divider() - ForEach(1...4, id: \.self) { position in - Button { - assignToSlot(item.id, position) - } label: { - if slotMap[item.id] == position { - Label("Position \(position) • Assigned", systemImage: "checkmark.circle.fill") - } else { - Text("Position \(position)") - } - } - } - #if DEBUG - if let locId = debugLocationId, item.id == locId { - Divider() - Text("Location Options") - Button("Open Location Simulator") { - selection = locId - } - } - #endif - } - .scaleEffect(0.98 + 0.02 * fade) - } - } - .padding() - } - .scrollDisabled(true) - .scrollIndicators(.hidden) - .opacity(fade) - .offset(y: yRise) - .animation(drawerSpring, value: progress) - .allowsHitTesting(progress > 0.6) - .zIndex(progress >= 0.5 ? 1 : 0) - } -} - -struct AppDrawer: View { - let items: [TabItem] - @Binding var selection: Int - let accentColor: Color - @AppStorage("favoritesDockPosition1") private var favPos1: Int = 0 - @AppStorage("favoritesDockPosition2") private var favPos2: Int = 2 - @AppStorage("favoritesDockPosition3") private var favPos3: Int = 3 - @AppStorage("favoritesDockPosition4") private var favPos4: Int = 4 - @State private var progress: CGFloat = 0 - @State private var dragStartProgress: CGFloat = 0 - @State private var isDragging = false - private var maxHeight: CGFloat { UIScreen.main.bounds.height * 0.5 } - private var minHeight: CGFloat { 80 } - - private var favorites: [TabItem] { - let ids = [favPos1, favPos2, favPos3, favPos4] - let found: [TabItem] = ids.compactMap { id in items.first(where: { $0.id == id }) } - if found.count == 4 { return found } - let defaults = [0, 2, 3, 4].compactMap { id in items.first(where: { $0.id == id }) } - return defaults - } - - private var slotMap: [Int: Int] { - var dict: [Int: Int] = [:] - dict[favPos1] = 1 - dict[favPos2] = 2 - dict[favPos3] = 3 - dict[favPos4] = 4 - return dict - } - - private func assign(_ itemId: Int, to position: Int) { - let defaults = [0, 2, 3, 4] - switch position { - case 1: - if favPos2 == itemId { favPos2 = defaults[1] } - if favPos3 == itemId { favPos3 = defaults[2] } - if favPos4 == itemId { favPos4 = defaults[3] } - favPos1 = itemId - case 2: - if favPos1 == itemId { favPos1 = defaults[0] } - if favPos3 == itemId { favPos3 = defaults[2] } - if favPos4 == itemId { favPos4 = defaults[3] } - favPos2 = itemId - case 3: - if favPos1 == itemId { favPos1 = defaults[0] } - if favPos2 == itemId { favPos2 = defaults[1] } - if favPos4 == itemId { favPos4 = defaults[3] } - favPos3 = itemId - case 4: - if favPos1 == itemId { favPos1 = defaults[0] } - if favPos2 == itemId { favPos2 = defaults[1] } - if favPos3 == itemId { favPos3 = defaults[2] } - favPos4 = itemId - default: break - } - } - - var body: some View { - GeometryReader { geo in - let safeBottom = geo.safeAreaInsets.bottom - let totalHeight = maxHeight + safeBottom - let dockClearance: CGFloat = safeBottom + 16 - let closedY = geo.size.height - minHeight - safeBottom - dockClearance - let openY = geo.size.height - totalHeight - let travel = closedY - openY - let offsetY = closedY - progress * travel - let dockBottomPadding = safeBottom + 8 - - ZStack { - if progress > 0 { - Color.clear - .contentShape(Rectangle()) - .ignoresSafeArea() - .onTapGesture { withAnimation(drawerSpring) { progress = 0 } } - } - VStack(spacing: 0) { - DrawerHandle(progress: progress) - .onTapGesture { - withAnimation(drawerSpring) { - progress = (progress >= 0.999) ? 0 : 1 - } - } - ZStack(alignment: .top) { - FavoritesDockView(favorites: favorites, - selection: $selection, - accentColor: accentColor, - progress: progress, - bottomPadding: dockBottomPadding) - .padding(.top, 10) - AppGridView(items: items, - selection: $selection, - accentColor: accentColor, - progress: progress, - slotMap: slotMap, - assignToSlot: { itemId, pos in assign(itemId, to: pos) }, - debugLocationId: items.first(where: { $0.title == "Location" })?.id) - .padding(.top, 10) - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - } - .frame(width: geo.size.width, height: totalHeight, alignment: .top) - .background( - BlurView(style: .systemMaterial) - .cornerRadius(16 + 8 * progress, corners: [.topLeft, .topRight]) - .shadow(color: Color.black.opacity(0.15 * Double(progress)), radius: 14 * progress, x: 0, y: -4 * progress) - .ignoresSafeArea(edges: .bottom) - ) - .offset(y: offsetY) - .animation(drawerSpring, value: progress) - .gesture( - DragGesture() - .onChanged { v in - if !isDragging { - isDragging = true - dragStartProgress = progress - } - let delta = v.translation.height - let p = dragStartProgress - delta / travel - progress = min(max(p, 0), 1) - } - .onEnded { v in - isDragging = false - let end = dragStartProgress - v.translation.height / travel - let target: CGFloat = end > 0.5 ? 1 : 0 - withAnimation(drawerSpring) { progress = target } - } - ) - } - } - } -} struct MainTabView: View { @AppStorage("customAccentColor") private var customAccentColorHex: String = "" @State private var selection: Int = 0 + // You can still keep this for non-tint styling (borders, fills, etc.) if desired. private var accentColor: Color { - customAccentColorHex.isEmpty ? .blue : (Color(hex: customAccentColorHex) ?? .blue) - } - - private var tabs: [TabItem] { - var base: [TabItem] = [ - TabItem(id: 0, title: "Home", systemImage: "house"), - TabItem(id: 1, title: "Scripts", systemImage: "scroll"), - TabItem(id: 2, title: "Testing", systemImage: "square.grid.2x2"), - TabItem(id: 3, title: "Info", systemImage: "info.circle.fill"), - TabItem(id: 4, title: "Settings", systemImage: "gearshape.fill") - ] - #if DEBUG - base.append(TabItem(id: 5, title: "Location", systemImage: "globe.americas")) - #endif - return base + customAccentColorHex.isEmpty ? .white : (Color(hex: customAccentColorHex) ?? .white) } var body: some View { - ZStack(alignment: .bottom) { - Group { - switch selection { - case 0: HomeView() - case 1: ScriptListView() - case 2: IPAAppManagerView() - #if DEBUG - case 5: LocationSimulatorView(deviceIp: "10.7.0.2") - #endif - case 3: DeviceInfoView() - case 4: SettingsView() - default: HomeView() - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .accentColor(accentColor) - .environment(\.accentColor, accentColor) - AppDrawer(items: tabs, selection: $selection, accentColor: accentColor) - .ignoresSafeArea(edges: .bottom) + TabView(selection: $selection) { + HomeView() + .tabItem { Label("Home", systemImage: "house") } + .tag(0) + + ScriptListView() + .tabItem { Label("Scripts", systemImage: "scroll") } + .tag(1) + + IPAAppManagerView() + .tabItem { Label("Testing", systemImage: "square.grid.2x2.fill") } + .tag(2) + + // Replaced AP installer with Device Info + DeviceInfoView() + .tabItem { Label("Device Info", systemImage: "info.circle.fill") } + .tag(3) + + SettingsView() + .tabItem { Label("Settings", systemImage: "gearshape.fill") } + .tag(4) } + // Remove per-view accent override so the app-wide .tint(Color.white) applies everywhere. + // If you want the tab selection color to be white globally, rely on WindowGroup .tint(.white). } } -struct MainTabView_Preview: PreviewProvider { +struct MainTabView_Previews: PreviewProvider { static var previews: some View { MainTabView() } diff --git a/StikJIT/Views/SettingsView.swift b/StikJIT/Views/SettingsView.swift index e17edc5..cdacf9b 100644 --- a/StikJIT/Views/SettingsView.swift +++ b/StikJIT/Views/SettingsView.swift @@ -9,7 +9,6 @@ import UIKit struct SettingsView: View { @AppStorage("username") private var username = "User" - @AppStorage("customAccentColor") private var customAccentColorHex: String = "" @AppStorage("selectedAppIcon") private var selectedAppIcon: String = "AppIcon" @AppStorage("useDefaultScript") private var useDefaultScript = false @AppStorage("enableAdvancedOptions") private var enableAdvancedOptions = false @@ -20,7 +19,6 @@ struct SettingsView: View { @State private var isShowingPairingFilePicker = false @Environment(\.colorScheme) private var colorScheme - @State private var selectedAccentColor: Color = .blue @State private var showIconPopover = false @State private var showPairingFileMessage = false @State private var pairingFileIsValid = false @@ -52,460 +50,93 @@ struct SettingsView: View { "Wynwxst": "https://github.com/Wynwxst.png" ] - private var accentColor: Color { - if customAccentColorHex.isEmpty { - return .blue - } else { - return Color(hex: customAccentColorHex) ?? .blue - } - } - var body: some View { - ZStack { - Color(UIColor.systemBackground) + NavigationStack { + ZStack { + // Subtle depth gradient background + LinearGradient( + gradient: Gradient(colors: [ + Color(UIColor.systemBackground), + Color(UIColor.secondarySystemBackground) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) .ignoresSafeArea() - - ScrollView { - VStack(spacing: 12) { - // App Logo and Username Section - VStack(spacing: 16) { - VStack { - Image("StikJIT") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 80, height: 80) - .clipShape(RoundedRectangle(cornerRadius: 16)) - .overlay( - RoundedRectangle(cornerRadius: 16) - .stroke(Color.gray.opacity(0.2), lineWidth: 1) - ) - } - .padding(.top, 16) - - Text("StikDebug") - .font(.title2) - .fontWeight(.semibold) - .foregroundColor(.primary) + + ScrollView { + VStack(spacing: 20) { + headerCard + appearanceCard + pairingCard + ddiCard + behaviorCard + advancedCard + helpCard + versionInfo } - - Divider() - .padding(.horizontal, 16) - .opacity(0.6) - - // Appearance section - SettingsCard { - VStack(alignment: .leading, spacing: 20) { - Text("Appearance") - .font(.headline) - .foregroundColor(.primary) - .padding(.bottom, 4) - - VStack(spacing: 6) { - Button(action: { - showingDisplayView = true - }) { - HStack { - Image(systemName: "paintbrush") - .font(.system(size: 18)) - .foregroundColor(.primary.opacity(0.8)) - Text("Display") - .foregroundColor(.primary.opacity(0.8)) - Spacer() - Image(systemName: "chevron.right") - .font(.system(size: 14)) - .foregroundColor(accentColor) - } - } - .padding(.vertical, 8) - } - } - .padding(.vertical, 20) - .padding(.horizontal, 16) - } - - // Pairing File section - SettingsCard { - VStack(alignment: .leading, spacing: 20) { - Text("Pairing File") - .font(.headline) - .foregroundColor(.primary) - .padding(.bottom, 4) - - Button { - isShowingPairingFilePicker = true - } label: { - HStack { - Image(systemName: "doc.badge.plus") - .font(.system(size: 18)) - Text("Import New Pairing File") - .fontWeight(.medium) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - .foregroundColor(accentColor.contrastText()) - .background(accentColor) - .cornerRadius(12) - } - - if isImportingFile { - VStack(spacing: 10) { - HStack { - Text("Processing pairing file...") - .font(.system(.caption, design: .rounded)) - .foregroundColor(.secondary) - Spacer() - Text("\(Int(importProgress * 100))%") - .font(.system(.caption, design: .rounded)) - .foregroundColor(.secondary) - } - - GeometryReader { geometry in - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 6) - .fill(Color(UIColor.tertiarySystemFill)) - .frame(height: 10) - - RoundedRectangle(cornerRadius: 6) - .fill(Color.green) - .frame(width: geometry.size.width * CGFloat(importProgress), height: 10) - .animation(.linear(duration: 0.3), value: importProgress) - } - } - .frame(height: 10) - } - .padding(.top, 6) - } - - if showPairingFileMessage && pairingFileIsValid { - HStack { - Spacer() - Text("✓ Pairing file successfully imported") - .font(.system(.callout, design: .rounded)) - .foregroundColor(.green) - .padding(.vertical, 10) - .padding(.horizontal, 18) - .background(Color.green.opacity(0.1)) - .cornerRadius(10) - Spacer() - } - .padding(.top, 6) - .transition( - .asymmetric( - insertion: .scale(scale: 0.9) - .combined(with: .opacity) - .animation(.spring(response: 0.4, dampingFraction: 0.7)), - removal: .opacity.animation(.easeOut(duration: 0.25)) - ) - ) - } - } - .padding(.vertical, 20) - .padding(.horizontal, 16) - } - - // Developer Disk Image section - SettingsCard { - VStack(alignment: .leading, spacing: 20) { - Text("Developer Disk Image") - .font(.headline) - .foregroundColor(.primary) - .padding(.bottom, 4) - - // Status indicator with icon - HStack(spacing: 12) { - Image(systemName: mounted || (mountProg.mountProgress == 100) ? "checkmark.circle.fill" : "xmark.circle.fill") - .font(.system(size: 24)) - .foregroundColor(mounted || (mountProg.mountProgress == 100) ? .green : .red) - - Text(mounted || (mountProg.mountProgress == 100) ? "Successfully Mounted" : "Not Mounted") - .font(.system(.body, design: .rounded)) - .fontWeight(.medium) - } - .padding() - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color(UIColor.tertiarySystemBackground)) - .cornerRadius(12) - - if !(mounted || (mountProg.mountProgress == 100)) { - Text("Import pairing file and restart the app to mount DDI") - .font(.system(.caption, design: .rounded)) - .foregroundColor(.secondary) - .padding(.horizontal, 4) - } - - if mountProg.mountProgress > 0 && mountProg.mountProgress < 100 && !mounted { - VStack(spacing: 8) { - HStack { - Text("Mounting in progress...") - .font(.system(.caption, design: .rounded)) - .foregroundColor(.secondary) - Spacer() - Text("\(Int(mountProg.mountProgress))%") - .font(.system(.caption, design: .rounded)) - .foregroundColor(.secondary) - } - - GeometryReader { geometry in - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 6) - .fill(Color(UIColor.tertiarySystemFill)) - .frame(height: 8) - - RoundedRectangle(cornerRadius: 6) - .fill(Color.green) - .frame(width: geometry.size.width * CGFloat(mountProg.mountProgress / 100.0), height: 8) - .animation(.linear(duration: 0.3), value: mountProg.mountProgress) - } - } - .frame(height: 8) - } - .padding(.top, 6) - } - } - .padding(.vertical, 20) - .padding(.horizontal, 16) - .onAppear { - self.mounted = isMounted() - } - } - SettingsCard { - VStack(alignment: .leading, spacing: 20) { - Text("Behavior") - .font(.headline) - .foregroundColor(.primary) - .padding(.bottom, 4) - - Toggle("Run Default Script After Connecting", isOn: $useDefaultScript) - .foregroundColor(.primary) - .padding(.vertical, 6) - Toggle("Picture in Picture", isOn: $enablePiP) - .foregroundColor(.primary) - .padding(.vertical, 6) - } - .padding(.vertical, 20) - .padding(.horizontal, 16) - .onChange(of: enableAdvancedOptions) { _, newValue in - if !newValue { - useDefaultScript = false - enablePiP = false - enableAdvancedBetaOptions = false - enableTesting = false - } - } - .onChange(of: enableAdvancedBetaOptions) { _, newValue in - if !newValue { - enableTesting = false - } - } - } - - // About section - SettingsCard { - VStack(alignment: .leading, spacing: 20) { - Text("About") - .font(.headline) - .foregroundColor(.primary) - .padding(.bottom, 4) - - VStack(alignment: .leading, spacing: 12) { - Text("Creators") - .font(.subheadline) - .foregroundColor(.secondary) - - HStack(spacing: 16) { - VStack(spacing: 8) { - ProfileImage(url: developerProfiles["Stephen"] ?? "") - .frame(width: 60, height: 60) - - Text("Stephen") - .fontWeight(.semibold) - - Text("App Creator") - .font(.caption) - .foregroundColor(.secondary) - } - .frame(minWidth: 0, maxWidth: .infinity) - .padding(.vertical, 12) - .padding(.horizontal, 16) - .background(Color(UIColor.tertiarySystemBackground)) - .cornerRadius(12) - .onTapGesture { - if let url = URL(string: "https://github.com/StephenDev0") { - UIApplication.shared.open(url) - } - } - - VStack(spacing: 8) { - ProfileImage(url: developerProfiles["jkcoxson"] ?? "") - .frame(width: 60, height: 60) - - Text("jkcoxson") - .fontWeight(.semibold) - - Text("idevice & em_proxy") - .font(.caption) - .foregroundColor(.secondary) - } - .frame(minWidth: 0, maxWidth: .infinity) - .padding(.vertical, 12) - .padding(.horizontal, 16) - .background(Color(UIColor.tertiarySystemBackground)) - .cornerRadius(12) - .onTapGesture { - if let url = URL(string: "https://jkcoxson.com/") { - UIApplication.shared.open(url) - } - } - } - } - - Divider() - .padding(.vertical, 8) - - VStack(alignment: .leading, spacing: 12) { - Text("Developers") - .font(.subheadline) - .foregroundColor(.secondary) - - VStack(spacing: 12) { - CollaboratorRow(name: "Stossy11", url: "https://github.com/Stossy11", imageUrl: developerProfiles["Stossy11"] ?? "") - CollaboratorRow(name: "Neo", url: "https://neoarz.xyz/", imageUrl: developerProfiles["Neo"] ?? "") - CollaboratorRow(name: "Se2crid", url: "https://github.com/Se2crid", imageUrl: developerProfiles["Se2crid"] ?? "") - CollaboratorRow(name: "Huge_Black", url: "https://github.com/HugeBlack", imageUrl: developerProfiles["Huge_Black"] ?? "") - CollaboratorRow(name: "Wynwxst", url: "https://github.com/Wynwxst", imageUrl: developerProfiles["Wynwxst"] ?? "") - } - } - } - .padding(.vertical, 20) - .padding(.horizontal, 16) - } - .padding(.bottom, 4) - - // Advanced Settings Card - SettingsCard { - VStack(alignment: .leading, spacing: 20) { - Text("Advanced") - .font(.headline) - .foregroundColor(.primary) - .padding(.bottom, 4) - - VStack(spacing: 6) { - Button(action: { - showingConsoleLogsView = true - }) { - HStack { - Image(systemName: "terminal") - .font(.system(size: 18)) - .foregroundColor(.primary.opacity(0.8)) - Text("System Logs") - .foregroundColor(.primary.opacity(0.8)) - Spacer() - Image(systemName: "chevron.right") - .font(.system(size: 14)) - .foregroundColor(accentColor) - } - } - .padding(.vertical, 8) - - Button(action: { - openAppFolder() - }) { - HStack { - Image(systemName: "folder") - .font(.system(size: 18)) - .foregroundColor(.primary.opacity(0.8)) - Text("App Folder") - .foregroundColor(.primary.opacity(0.8)) - Spacer() - Image(systemName: "chevron.right") - .font(.system(size: 14)) - .foregroundColor(accentColor) - } - } - .padding(.vertical, 8) - } - } - .padding(.vertical, 20) - .padding(.horizontal, 16) - } - .padding(.bottom, 4) - .sheet(isPresented: $showingConsoleLogsView) { - ConsoleLogsView() - } - .sheet(isPresented: $showingDisplayView) { - DisplayView() - } - - // Combined Help Section (User Manual and VPN Help) - SettingsCard { - VStack(alignment: .leading, spacing: 20) { - Text("Help") - .font(.headline) - .foregroundColor(.primary) - .padding(.bottom, 4) - - Button(action: { - if let url = URL(string: "https://github.com/StephenDev0/StikDebug-Guide/blob/main/pairing_file.md") { - UIApplication.shared.open(url) - } - }) { - HStack { - Image(systemName: "questionmark.circle") - .font(.system(size: 18)) - .foregroundColor(.primary.opacity(0.8)) - Text("Pairing File Guide") - .foregroundColor(.primary.opacity(0.8)) - Spacer() - } - .padding(.vertical, 8) - } - - Button(action: { - if let url = URL(string: "https://discord.gg/qahjXNTDwS") { - UIApplication.shared.open(url) - } - }) { - HStack { - Image(systemName: "questionmark.circle") - .font(.system(size: 18)) - .foregroundColor(.primary.opacity(0.8)) - Text("Need support? Join the Discord!") - .foregroundColor(.primary.opacity(0.8)) - Spacer() - } - .padding(.vertical, 8) - } - - HStack(alignment: .center, spacing: 8) { - Image(systemName: "shield.slash") - .font(.system(size: 18)) - .foregroundColor(.primary.opacity(0.8)) - Text("You can turn off the VPN in the Settings app.") - .foregroundColor(.secondary) - } - } - .padding(.vertical, 20) - .padding(.horizontal, 16) - .frame(maxWidth: .infinity) // Ensures this card fills the available width - } - - // Version info - HStack { - Spacer() - - Text("Version \(appVersion) • iOS \(UIDevice.current.systemVersion)") - .font(.footnote) - .foregroundColor(.secondary.opacity(0.8)) - - Spacer() - } - .padding(.top, 8) - .padding(.bottom, 16) + .padding(.horizontal, 20) + .padding(.vertical, 30) + } + + // Busy overlay while importing pairing file + if isImportingFile { + Color.black.opacity(0.35).ignoresSafeArea() + VStack(spacing: 12) { + ProgressView("Processing pairing file…") + VStack(spacing: 8) { + GeometryReader { geometry in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 6) + .fill(Color(UIColor.tertiarySystemFill)) + .frame(height: 8) + + RoundedRectangle(cornerRadius: 6) + .fill(Color.green) + .frame(width: geometry.size.width * CGFloat(importProgress), height: 8) + .animation(.linear(duration: 0.3), value: importProgress) + } + } + .frame(height: 8) + Text("\(Int(importProgress * 100))%") + .font(.caption) + .foregroundColor(.secondary) + } + .padding(.top, 6) + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) + ) + ) + .shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4) + } + + // Success toast after import + if showPairingFileMessage && pairingFileIsValid && !isImportingFile { + VStack { + Spacer() + Text("✓ Pairing file successfully imported") + .font(.footnote.weight(.semibold)) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(.ultraThinMaterial, in: Capsule()) + .overlay(Capsule().strokeBorder(Color.white.opacity(0.15), lineWidth: 1)) + .shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 3) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .padding(.bottom, 30) + } + .animation(.easeInOut(duration: 0.25), value: showPairingFileMessage) } - .padding(.horizontal, 16) - .padding(.vertical, 20) } + .navigationTitle("Settings") } + // Force a white tint in Settings, overriding any global/user tint + .tint(Color.white) .fileImporter( isPresented: $isShowingPairingFilePicker, allowedContentTypes: [UTType(filenameExtension: "mobiledevicepairing", conformingTo: .data)!, .propertyList], @@ -572,21 +203,370 @@ struct SettingsView: View { print("Failed to import file: \(error)") } } - .onAppear { - loadCustomAccentColor() + .preferredColorScheme(.dark) + } + + // MARK: - Cards + + private var headerCard: some View { + materialCard { + VStack(spacing: 16) { + VStack { + Image("StikJIT") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 80, height: 80) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(Color.white.opacity(0.12), lineWidth: 1) + ) + } + Text("StikDebug") + .font(.title2.weight(.semibold)) + .foregroundColor(.primary) + } + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .center) } } - private func loadCustomAccentColor() { - if customAccentColorHex.isEmpty { - selectedAccentColor = .blue - } else { - selectedAccentColor = Color(hex: customAccentColorHex) ?? .blue + private var appearanceCard: some View { + materialCard { + VStack(alignment: .leading, spacing: 14) { + Text("Appearance") + .font(.headline) + .foregroundColor(.primary) + + Button(action: { showingDisplayView = true }) { + HStack { + Image(systemName: "paintbrush") + .font(.system(size: 18)) + .foregroundColor(.primary.opacity(0.85)) + Text("Display") + .foregroundColor(.primary.opacity(0.85)) + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 14)) + .foregroundColor(.white) + } + .padding(.vertical, 8) + } + } + .padding(4) + } + .sheet(isPresented: $showingDisplayView) { + DisplayView() + .preferredColorScheme(.dark) } } - private func saveCustomAccentColor(_ color: Color) { - customAccentColorHex = color.toHex() ?? "" + private var pairingCard: some View { + materialCard { + VStack(alignment: .leading, spacing: 14) { + Text("Pairing File") + .font(.headline) + .foregroundColor(.primary) + + Button { + isShowingPairingFilePicker = true + } label: { + HStack { + Image(systemName: "doc.badge.plus") + .font(.system(size: 18)) + Text("Import New Pairing File") + .fontWeight(.medium) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .foregroundColor(.black) + .background(Color.white, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + + if showPairingFileMessage && pairingFileIsValid && !isImportingFile { + HStack { + Image(systemName: "checkmark.circle.fill").foregroundColor(.green) + Text("Pairing file successfully imported") + .font(.callout) + .foregroundColor(.green) + Spacer() + } + .padding(.vertical, 6) + .transition(.opacity) + } + } + } + } + + private var ddiCard: some View { + materialCard { + VStack(alignment: .leading, spacing: 14) { + Text("Developer Disk Image") + .font(.headline) + .foregroundColor(.primary) + + HStack(spacing: 12) { + Image(systemName: mounted || (mountProg.mountProgress == 100) ? "checkmark.circle.fill" : "xmark.circle.fill") + .font(.system(size: 24)) + .foregroundColor(mounted || (mountProg.mountProgress == 100) ? .green : .red) + + Text(mounted || (mountProg.mountProgress == 100) ? "Successfully Mounted" : "Not Mounted") + .font(.body.weight(.medium)) + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(UIColor.tertiarySystemBackground)) + ) + + if !(mounted || (mountProg.mountProgress == 100)) { + Text("Import pairing file and restart the app to mount DDI") + .font(.caption) + .foregroundColor(.secondary) + } + + if mountProg.mountProgress > 0 && mountProg.mountProgress < 100 && !mounted { + VStack(spacing: 8) { + HStack { + Text("Mounting in progress…") + .font(.caption) + .foregroundColor(.secondary) + Spacer() + Text("\(Int(mountProg.mountProgress))%") + .font(.caption) + .foregroundColor(.secondary) + } + GeometryReader { geometry in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 6) + .fill(Color(UIColor.tertiarySystemFill)) + .frame(height: 8) + + RoundedRectangle(cornerRadius: 6) + .fill(Color.green) + .frame(width: geometry.size.width * CGFloat(mountProg.mountProgress / 100.0), height: 8) + .animation(.linear(duration: 0.3), value: mountProg.mountProgress) + } + } + .frame(height: 8) + } + } + } + .onAppear { + self.mounted = isMounted() + } + } + } + + private var behaviorCard: some View { + materialCard { + VStack(alignment: .leading, spacing: 16) { + Text("Behavior") + .font(.headline) + .foregroundColor(.primary) + + Toggle("Run Default Script After Connecting", isOn: $useDefaultScript) + .foregroundColor(.primary) + Toggle("Picture in Picture", isOn: $enablePiP) + .foregroundColor(.primary) + } + .onChange(of: enableAdvancedOptions) { _, newValue in + if !newValue { + useDefaultScript = false + enablePiP = false + enableAdvancedBetaOptions = false + enableTesting = false + } + } + .onChange(of: enableAdvancedBetaOptions) { _, newValue in + if !newValue { + enableTesting = false + } + } + } + } + + private var aboutCard: some View { + materialCard { + VStack(alignment: .leading, spacing: 16) { + Text("About") + .font(.headline) + .foregroundColor(.primary) + + VStack(alignment: .leading, spacing: 12) { + Text("Creators") + .font(.subheadline) + .foregroundColor(.secondary) + + HStack(spacing: 12) { + creatorTile(name: "Stephen", role: "App Creator", url: "https://github.com/StephenDev0", imageUrl: developerProfiles["Stephen"] ?? "") + creatorTile(name: "jkcoxson", role: "idevice & em_proxy", url: "https://jkcoxson.com/", imageUrl: developerProfiles["jkcoxson"] ?? "") + } + } + + Divider().background(Color.white.opacity(0.12)) + + VStack(alignment: .leading, spacing: 12) { + Text("Developers") + .font(.subheadline) + .foregroundColor(.secondary) + + VStack(spacing: 10) { + CollaboratorRow(name: "Stossy11", url: "https://github.com/Stossy11", imageUrl: developerProfiles["Stossy11"] ?? "") + CollaboratorRow(name: "Neo", url: "https://neoarz.xyz/", imageUrl: developerProfiles["Neo"] ?? "") + CollaboratorRow(name: "Se2crid", url: "https://github.com/Se2crid", imageUrl: developerProfiles["Se2crid"] ?? "") + CollaboratorRow(name: "Huge_Black", url: "https://github.com/HugeBlack", imageUrl: developerProfiles["Huge_Black"] ?? "") + CollaboratorRow(name: "Wynwxst", url: "https://github.com/Wynwxst", imageUrl: developerProfiles["Wynwxst"] ?? "") + } + } + } + } + } + + private var advancedCard: some View { + materialCard { + VStack(alignment: .leading, spacing: 14) { + Text("Advanced") + .font(.headline) + .foregroundColor(.primary) + + Button(action: { showingConsoleLogsView = true }) { + HStack { + Image(systemName: "terminal") + .font(.system(size: 18)) + .foregroundColor(.primary.opacity(0.8)) + Text("System Logs") + .foregroundColor(.primary.opacity(0.8)) + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 14)) + .foregroundColor(.white) + } + .padding(.vertical, 8) + } + + Button(action: { openAppFolder() }) { + HStack { + Image(systemName: "folder") + .font(.system(size: 18)) + .foregroundColor(.primary.opacity(0.8)) + Text("App Folder") + .foregroundColor(.primary.opacity(0.8)) + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 14)) + .foregroundColor(.white) + } + .padding(.vertical, 8) + } + } + } + .sheet(isPresented: $showingConsoleLogsView) { + ConsoleLogsView() + .preferredColorScheme(.dark) + } + } + + private var helpCard: some View { + materialCard { + VStack(alignment: .leading, spacing: 14) { + Text("Help") + .font(.headline) + .foregroundColor(.primary) + + Button(action: { + if let url = URL(string: "https://github.com/StephenDev0/StikDebug-Guide/blob/main/pairing_file.md") { + UIApplication.shared.open(url) + } + }) { + HStack { + Image(systemName: "questionmark.circle") + .font(.system(size: 18)) + .foregroundColor(.primary.opacity(0.8)) + Text("Pairing File Guide") + .foregroundColor(.primary.opacity(0.8)) + Spacer() + } + .padding(.vertical, 8) + } + + Button(action: { + if let url = URL(string: "https://discord.gg/qahjXNTDwS") { + UIApplication.shared.open(url) + } + }) { + HStack { + Image(systemName: "questionmark.circle") + .font(.system(size: 18)) + .foregroundColor(.primary.opacity(0.8)) + Text("Need support? Join the Discord!") + .foregroundColor(.primary.opacity(0.8)) + Spacer() + } + .padding(.vertical, 8) + } + + HStack(alignment: .center, spacing: 8) { + Image(systemName: "shield.slash") + .font(.system(size: 18)) + .foregroundColor(.primary.opacity(0.8)) + Text("You can turn off the VPN in the Settings app.") + .foregroundColor(.secondary) + } + .padding(.top, 4) + } + } + } + + private var versionInfo: some View { + HStack { + Spacer() + Text("Version \(appVersion) • iOS \(UIDevice.current.systemVersion)") + .font(.footnote) + .foregroundColor(.secondary) + Spacer() + } + .padding(.top, 6) + } + + // MARK: - Helpers (UI + logic) + + private func materialCard(@ViewBuilder content: () -> Content) -> some View { + content() + .padding(20) + .background( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) + ) + ) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4) + } + + private func creatorTile(name: String, role: String, url: String, imageUrl: String) -> some View { + Button(action: { + if let u = URL(string: url) { UIApplication.shared.open(u) } + }) { + VStack(spacing: 8) { + ProfileImage(url: imageUrl) + .frame(width: 60, height: 60) + Text(name).fontWeight(.semibold) + Text(role) + .font(.caption) + .foregroundColor(.secondary) + } + .frame(minWidth: 0, maxWidth: .infinity) + .padding(.vertical, 12) + .padding(.horizontal, 12) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(UIColor.tertiarySystemBackground)) + ) + } } private func changeAppIcon(to iconName: String) { @@ -671,15 +651,6 @@ struct LinkRow: View { var icon: String var title: String var url: String - @AppStorage("customAccentColor") private var customAccentColorHex: String = "" - - private var accentColor: Color { - if customAccentColorHex.isEmpty { - return .blue - } else { - return Color(hex: customAccentColorHex) ?? .blue - } - } var body: some View { Button(action: { @@ -693,11 +664,12 @@ struct LinkRow: View { Spacer() Image(systemName: icon) .font(.system(size: 18)) - .foregroundColor(accentColor) + .foregroundColor(.white) .frame(width: 24) } } .padding(.vertical, 8) + .preferredColorScheme(.dark) } } @@ -726,6 +698,7 @@ struct CollaboratorGridItem: View { .background(Color(UIColor.tertiarySystemBackground)) .cornerRadius(12) } + .preferredColorScheme(.dark) } } @@ -776,15 +749,6 @@ struct CollaboratorRow: View { var url: String var imageUrl: String var quote: String? - @AppStorage("customAccentColor") private var customAccentColorHex: String = "" - - private var accentColor: Color { - if customAccentColorHex.isEmpty { - return .blue - } else { - return Color(hex: customAccentColorHex) ?? .blue - } - } var body: some View { Button(action: { @@ -810,16 +774,18 @@ struct CollaboratorRow: View { Spacer() Image(systemName: "link") .font(.system(size: 16)) - .foregroundColor(accentColor) + .foregroundColor(.white) } .padding(.vertical, 8) } + .preferredColorScheme(.dark) } } struct ConsoleLogsView_Preview: PreviewProvider { static var previews: some View { ConsoleLogsView() + .preferredColorScheme(.dark) } } @@ -839,3 +805,4 @@ class FolderViewController: UIViewController { } } } + diff --git a/StikJIT/Views/Something.swift b/StikJIT/Views/Something.swift deleted file mode 100644 index 30e892c..0000000 --- a/StikJIT/Views/Something.swift +++ /dev/null @@ -1,571 +0,0 @@ -// -// Something.swift -// StikDebug -// -// Created by Stephen on 7/29/25. -// - -import SwiftUI -import UniformTypeIdentifiers -import ZIPFoundation -import ZSign -import StikImporter - -@_silgen_name("install_ipa") -private func c_install_ipa( - _ ip: UnsafePointer, - _ pairing: UnsafePointer, - _ udid: UnsafePointer?, - _ ipa: UnsafePointer -) -> Int32 - -private func installErrorMessage(_ code: Int32) -> String { - switch code { - case 0: return "Success" - case 1: return "Pairing file unreadable" - case 2: return "TCP provider error" - case 3: return "AFC connect error" - case 4: return "IPA unreadable" - case 5: return "AFC open error" - case 6: return "AFC write error" - case 7: return "Install-proxy error" - case 8: return "Device refused IPA" - case 9: return "Invalid IP address" - default: return "Unknown (\(code))" - } -} - -private let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] -private let appsJSON = docs.appendingPathComponent("signed_apps.json") -private let certsJSON = docs.appendingPathComponent("certs.json") - -struct SignedApp: Identifiable, Codable, Hashable { - let id: UUID - let name: String - let bundleID: String - let version: String - let ipaPath: String - let iconPath: String? - - var ipaURL: URL { docs.appendingPathComponent(ipaPath) } - var iconURL: URL? { iconPath.map { docs.appendingPathComponent($0) } } -} - -struct Certificate: Identifiable, Codable, Hashable { - let id: UUID - let name: String - let p12Path: String - let mobPath: String? - - var p12URL: URL { docs.appendingPathComponent(p12Path) } - var mobURL: URL? { mobPath.map { docs.appendingPathComponent($0) } } -} - -private enum FSX { - static func mkdir(_ url: URL) throws { - if !FileManager.default.fileExists(atPath: url.path) { - try FileManager.default.createDirectory(at: url, - withIntermediateDirectories: true, - attributes: [.posixPermissions: 0o755]) - } - } - static func copySecure(from src: URL, to dst: URL) throws { - let ok = src.startAccessingSecurityScopedResource() - defer { if ok { src.stopAccessingSecurityScopedResource() } } - if FileManager.default.fileExists(atPath: dst.path) { try FileManager.default.removeItem(at: dst) } - try FileManager.default.copyItem(at: src, to: dst) - try FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: dst.path) - } - static func perms(app: URL) throws { - let keys: [URLResourceKey] = [.isDirectoryKey, .isExecutableKey] - guard let e = FileManager.default.enumerator(at: app, includingPropertiesForKeys: keys) else { return } - for case let f as URL in e { - let rv = try f.resourceValues(forKeys: Set(keys)) - if rv.isDirectory == true { - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: f.path) - } else { - let exec = rv.isExecutable == true || - f.lastPathComponent == app.lastPathComponent.replacingOccurrences(of: ".app", with: "") || - (f.pathExtension.isEmpty && !f.lastPathComponent.contains(".")) - try FileManager.default.setAttributes([.posixPermissions: exec ? 0o755 : 0o644], - ofItemAtPath: f.path) - } - } - } -} - -@MainActor -final class AppSignerManager: ObservableObject { - - private var didFinishInitialLoad = false - - @Published var apps: [SignedApp] = [] { - didSet { if didFinishInitialLoad { saveApps() } } - } - @Published var certs: [Certificate] = [] { - didSet { if didFinishInitialLoad { saveCerts() } } - } - @Published var selectedCertID: UUID? - @Published var busy = false - @Published var ipaURL: URL? - @Published var ipaName: String? - - private let imports = docs.appendingPathComponent("Imports", isDirectory: true) - private let certsFolder = docs.appendingPathComponent("Certs", isDirectory: true) - - init() { - try? FSX.mkdir(imports) - try? FSX.mkdir(certsFolder) - migrateAndLoadApps() - migrateAndLoadCerts() - didFinishInitialLoad = true - } - - private func migrateAndLoadApps() { - guard let data = try? Data(contentsOf: appsJSON), - let stored = try? JSONDecoder().decode([SignedApp].self, from: data) else { return } - - var fixed: [SignedApp] = [] - for app in stored { - if FileManager.default.fileExists(atPath: app.ipaURL.path) { - fixed.append(app) - continue - } - let expectedRel = "SignedApps/\(app.name)/\(app.name).ipa" - let newAbs = docs.appendingPathComponent(expectedRel).path - if FileManager.default.fileExists(atPath: newAbs) { - let newIconRel = "SignedApps/\(app.name)/icon.png" - fixed.append(SignedApp( - id: app.id, name: app.name, bundleID: app.bundleID, - version: app.version, ipaPath: expectedRel, - iconPath: FileManager.default.fileExists(atPath: docs.appendingPathComponent(newIconRel).path) ? newIconRel : nil)) - } - } - apps = fixed - } - private func saveApps() { - guard let data = try? JSONEncoder().encode(apps) else { return } - try? data.write(to: appsJSON) - } - - private func migrateAndLoadCerts() { - guard let data = try? Data(contentsOf: certsJSON), - let stored = try? JSONDecoder().decode([Certificate].self, from: data) else { return } - - var fixed: [Certificate] = [] - for cert in stored { - if FileManager.default.fileExists(atPath: cert.p12URL.path) && - (cert.mobURL == nil || FileManager.default.fileExists(atPath: cert.mobURL!.path)) { - fixed.append(cert) // already fine - continue - } - let dir = certsFolder.appendingPathComponent(cert.id.uuidString) - let newP12Rel = "Certs/\(cert.id.uuidString)/cert.p12" - let newMobRel = "Certs/\(cert.id.uuidString)/profile.mobileprovision" - if FileManager.default.fileExists(atPath: dir.appendingPathComponent("cert.p12").path) { - fixed.append(Certificate( - id: cert.id, name: cert.name, - p12Path: newP12Rel, - mobPath: FileManager.default.fileExists(atPath: dir.appendingPathComponent("profile.mobileprovision").path) ? newMobRel : nil)) - } - } - certs = fixed - if selectedCertID == nil { selectedCertID = certs.first?.id } - } - private func saveCerts() { - guard let data = try? JSONEncoder().encode(certs) else { return } - try? data.write(to: certsJSON) - } - - func deleteApp(_ app: SignedApp) { - if let idx = apps.firstIndex(of: app) { - try? FileManager.default.removeItem(at: app.ipaURL.deletingLastPathComponent()) - apps.remove(at: idx) - } - } - - func importIPA(from url: URL) throws { - let dst = imports.appendingPathComponent("current.ipa") - try FSX.copySecure(from: url, to: dst) - ipaURL = dst - ipaName = try parseIPAName(at: dst) - } - private func parseIPAName(at ipa: URL) throws -> String? { - let tmp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) - try FSX.mkdir(tmp) - try FileManager.default.unzipItem(at: ipa, to: tmp) - let payload = tmp.appendingPathComponent("Payload") - let appDir = try FileManager.default.contentsOfDirectory(at: payload, includingPropertiesForKeys: nil) - .first { $0.pathExtension == "app" } - defer { try? FileManager.default.removeItem(at: tmp) } - guard let dir = appDir, - let dict = NSDictionary(contentsOf: dir.appendingPathComponent("Info.plist")) else { return nil } - return (dict["CFBundleDisplayName"] ?? dict["CFBundleName"]) as? String - } - - func addCertificate(name: String, p12: URL, mob: URL, password: String, - onErr: (String,String)->Void, onOK: (String,String)->Void) { - let id = UUID() - let dir = certsFolder.appendingPathComponent(id.uuidString, isDirectory: true) - do { - try FSX.mkdir(dir) - try FSX.copySecure(from: p12, to: dir.appendingPathComponent("cert.p12")) - try FSX.copySecure(from: mob, to: dir.appendingPathComponent("profile.mobileprovision")) - KeychainHelper.shared.save(password: password, forKey: id.uuidString) - - certs.append(Certificate( - id: id, name: name, - p12Path: "Certs/\(id.uuidString)/cert.p12", - mobPath: "Certs/\(id.uuidString)/profile.mobileprovision")) - selectedCertID = id - onOK("Certificate added", name) - } catch { onErr("Cert error", error.localizedDescription) } - } - - func signIPA(onErr: @escaping (String,String)->Void, - onOK: @escaping (String,String)->Void) { - guard !busy else { return } - guard let ipa = ipaURL, - let cert = certs.first(where: { $0.id == selectedCertID }) else { - onErr("Missing data", "Import IPA and select certificate first"); return - } - busy = true - Task.detached { - do { - let tmp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) - try FSX.mkdir(tmp) - _ = ipa.startAccessingSecurityScopedResource(); defer { ipa.stopAccessingSecurityScopedResource() } - - try FileManager.default.unzipItem(at: ipa, to: tmp) - let payload = tmp.appendingPathComponent("Payload") - guard let appFolder = try FileManager.default.contentsOfDirectory(at: payload, includingPropertiesForKeys: nil) - .first(where: { $0.pathExtension == "app" }) else { - throw NSError(domain: "Signer", code: 1, userInfo: [NSLocalizedDescriptionKey: "No .app found"]) - } - try FSX.perms(app: appFolder) - guard - let dict = NSDictionary(contentsOf: appFolder.appendingPathComponent("Info.plist")), - let name = (dict["CFBundleDisplayName"] ?? dict["CFBundleName"]) as? String, - let bundle = dict["CFBundleIdentifier"] as? String, - let ver = dict["CFBundleShortVersionString"] as? String - else { - throw NSError(domain: "Signer", code: 2, userInfo: [NSLocalizedDescriptionKey: "Info.plist parse error"]) - } - - let pw = KeychainHelper.shared.readPassword(forKey: cert.id.uuidString) ?? "" - let rc = zsign(appFolder.path, - cert.p12URL.path, - cert.p12URL.path, - cert.mobURL?.path ?? "", - pw, "", "") - guard rc == 0 else { - throw NSError(domain: "zsign", code: Int(rc), - userInfo: [NSLocalizedDescriptionKey: "zsign returned \(rc)"]) - } - - let outDir = docs.appendingPathComponent("SignedApps/\(name)", isDirectory: true) - if FileManager.default.fileExists(atPath: outDir.path) { try FileManager.default.removeItem(at: outDir) } - try FSX.mkdir(outDir) - let final = outDir.appendingPathComponent("\(name).ipa") - try FileManager.default.zipItem(at: payload, - to: outDir.appendingPathComponent("\(name).ipa"), - shouldKeepParent: true) - try FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: final.path) - - var iconRel: String? - if let iconURL = try? FileManager.default.contentsOfDirectory(at: appFolder, includingPropertiesForKeys: nil) - .first(where: { $0.lastPathComponent.lowercased().contains("appicon") && $0.pathExtension == "png" }) { - let dst = outDir.appendingPathComponent("icon.png") - try? FileManager.default.copyItem(at: iconURL, to: dst) - iconRel = "SignedApps/\(name)/icon.png" - } - - let relIPA = "SignedApps/\(name)/\(name).ipa" - let newApp = SignedApp( - id: UUID(), name: name, bundleID: bundle, - version: ver, ipaPath: relIPA, iconPath: iconRel) - - await MainActor.run { - self.apps.removeAll { $0.name == newApp.name } - self.apps.append(newApp) - self.busy = false - onOK("\(name) is ready for testing", "Saved to \(relIPA)") - } - } catch { - await MainActor.run { - self.busy = false - onErr("Sign error", error.localizedDescription) - } - } - } - } - - func install(app: SignedApp, ip: String, pairing: String, - onErr: @escaping (String,String)->Void, - onOK: @escaping (String,String)->Void) { - guard !busy else { return } - guard FileManager.default.fileExists(atPath: app.ipaURL.path) else { onErr("IPA missing", app.ipaURL.path); return } - guard FileManager.default.fileExists(atPath: pairing) else { onErr("Pairing missing", pairing); return } - busy = true - Task.detached { - let rc = ip.withCString { ipPtr in - pairing.withCString { pairPtr in - app.ipaURL.path.withCString { ipaPtr in - c_install_ipa(ipPtr, pairPtr, nil, ipaPtr) - } - } - } - await MainActor.run { - self.busy = false - rc == 0 ? onOK("Installed for testing", app.name) - : onErr("Install failed", installErrorMessage(rc)) - } - } - } -} - -struct IPAAppManagerView: View { - @AppStorage("customAccentColor") private var customAccentColorHex: String = "" - private var accent: Color { - customAccentColorHex.isEmpty ? .blue : Color(hex: customAccentColorHex) ?? .blue - } - - @StateObject private var mgr = AppSignerManager() - @State private var pickerShown = false - @State private var showAddCert = false - @State private var alert = false - @State private var alertTitle = "" - @State private var alertMsg = "" - @State private var alertSuccess = false - @AppStorage("deviceIP") private var deviceIP = "10.7.0.2" - private var pairingPath: String { docs.appendingPathComponent("pairingFile.plist").path } - - var body: some View { - NavigationStack { - ZStack { - Color(UIColor.systemBackground).ignoresSafeArea() - - ScrollView { - VStack(spacing: 14) { - signCard - appsCard - versionInfo - } - .padding(.horizontal, 16) - .padding(.vertical, 20) - } - - if mgr.busy { - Color.black.opacity(0.35).ignoresSafeArea() - ProgressView() - .padding() - .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12)) - } - if alert { - CustomErrorView(title: alertTitle, - message: alertMsg, - onDismiss: { alert = false }, - messageType: alertSuccess ? .success : .error) - } - } - .navigationTitle("Testing") - .stikImporter(isPresented: $pickerShown, - selectedURLs: .constant([]), - allowedContentTypes: [.item], - allowsMultipleSelection: false) { urls in - if let u = urls.first { - do { try mgr.importIPA(from: u); notify("File imported", "File saved to Imports") } - catch { fail("Import error", error.localizedDescription) } - } - } - .sheet(isPresented: $showAddCert) { - AddCertView(accent: accent) { n, p12, mob, pw in - mgr.addCertificate(name: n, p12: p12, mob: mob, password: pw, - onErr: fail, onOK: notify) - } - } - } - } - - private var signCard: some View { - SettingsCard { - VStack(alignment: .leading, spacing: 18) { - Text("Prepare for testing") - .font(.headline) - .foregroundColor(.primary) - - VStack(spacing: 10) { - importButton(label: mgr.ipaName ?? ".IPA File", - icon: "arrow.down.doc", - imported: mgr.ipaURL != nil) { pickerShown = true } - - Picker("Certificate", selection: $mgr.selectedCertID) { - ForEach(mgr.certs) { Text($0.name).tag(Optional($0.id)) } - } - .pickerStyle(.menu) - .frame(maxWidth: .infinity, alignment: .leading) - - Button("Add Developer Certificate") { showAddCert = true } - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - .background(Color.gray.opacity(0.4)) - .foregroundColor(accent.contrastText()) - .clipShape(RoundedRectangle(cornerRadius: 12)) - - Button("Prepare") { mgr.signIPA(onErr: fail, onOK: notify) } - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - .background(accent) - .foregroundColor(accent.contrastText()) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .disabled(mgr.busy || mgr.ipaURL == nil || mgr.selectedCertID == nil) - } - } - .padding(20) - } - } - - private var appsCard: some View { - SettingsCard { - VStack(alignment: .leading, spacing: 18) { - Text("Ready to test apps") - .font(.headline) - .foregroundColor(.primary) - - if mgr.apps.isEmpty { - Text("No ready to test apps yet") - .foregroundColor(.secondary) - } else { - ForEach(mgr.apps) { app in - HStack(spacing: 12) { - if let img = app.iconURL.flatMap({ UIImage(contentsOfFile: $0.path) }) { - Image(uiImage: img) - .resizable() - .frame(width: 44, height: 44) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } - VStack(alignment: .leading) { - Text(app.name).bold() - Text("v\(app.version) • \(app.bundleID)") - .font(.caption) - .foregroundColor(.secondary) - } - Spacer() - Button("Test") { - mgr.install(app: app, ip: deviceIP, pairing: pairingPath, - onErr: fail, onOK: notify) - } - .buttonStyle(.bordered) - .disabled(mgr.busy) - - Button(role: .destructive) { - mgr.deleteApp(app) - } label: { - Image(systemName: "trash") - } - .buttonStyle(.bordered) - } - .padding(.vertical, 6) - Divider() - } - } - } - .padding(20) - } - } - - private var versionInfo: some View { - HStack { - Spacer() - Text("iOS \(UIDevice.current.systemVersion)") - .font(.footnote) - .foregroundColor(.secondary) - Spacer() - } - .padding(.top, 6) - } - - private func importButton(label: String, icon: String, - imported: Bool, action: @escaping () -> Void) -> some View { - Button(action: action) { - HStack { - Image(systemName: icon) - Text(label) - Spacer() - Image(systemName: imported ? "checkmark.circle.fill" : "chevron.right") - } - .foregroundColor(imported ? .primary : .secondary) - .padding(.vertical, 10) - .padding(.horizontal, 12) - .background(Color(UIColor.secondarySystemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - } - .disabled(mgr.busy) - } - - private func fail(_ title: String, _ msg: String) { - alertTitle = title; alertMsg = msg; alertSuccess = false; alert = true - } - private func notify(_ title: String, _ msg: String) { - alertTitle = title; alertMsg = msg; alertSuccess = true; alert = true - } -} - -private struct AddCertView: View { - let accent: Color - var onSave: (String, URL, URL, String) -> Void - @Environment(\.dismiss) private var dismiss - - @State private var name = "" - @State private var p12URL: URL? - @State private var mobURL: URL? - @State private var password = "" - - @State private var pickP12 = false - @State private var pickMob = false - - var body: some View { - NavigationStack { - Form { - TextField("Certificate name", text: $name) - importRow(label: ".mobileprovision", picked: mobURL != nil) { pickMob = true } - .stikImporter(isPresented: $pickMob, - selectedURLs: .constant([]), - allowedContentTypes: [UTType.item], - allowsMultipleSelection: false) { mobURL = $0.first } - importRow(label: ".p12 file", picked: p12URL != nil) { pickP12 = true } - .stikImporter(isPresented: $pickP12, - selectedURLs: .constant([]), - allowedContentTypes: [UTType.item], - allowsMultipleSelection: false) { p12URL = $0.first } - SecureField("p12 password", text: $password) - } - .navigationTitle("New Certificate") - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button("Save") { - if let p = p12URL, let m = mobURL, !name.isEmpty { - onSave(name, p, m, password); dismiss() - } - } - .disabled(p12URL == nil || mobURL == nil || name.isEmpty) - } - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { dismiss() } - } - } - } - } - - private func importRow(label: String, picked: Bool, - action: @escaping () -> Void) -> some View { - Button(action: action) { - HStack { - Text(label) - Spacer() - Image(systemName: picked ? "checkmark.circle.fill" : "chevron.right") - .foregroundColor(picked ? .green : accent) - } - } - } -} diff --git a/StikJIT/Views/Testing.swift b/StikJIT/Views/Testing.swift new file mode 100644 index 0000000..44cf558 --- /dev/null +++ b/StikJIT/Views/Testing.swift @@ -0,0 +1,1163 @@ +// +// Testing.swift +// StikDebug 2 +// +// Created by Stephen Bove on 8/8/25. +// + +import SwiftUI +import Foundation +import UniformTypeIdentifiers +import ZIPFoundation +import ZSign +import StikImporter +import UIKit +import PhotosUI + +// MARK: - C bridge + +@_silgen_name("install_ipa") +private func c_install_ipa( + _ ip: UnsafePointer, + _ pairing: UnsafePointer, + _ udid: UnsafePointer?, + _ ipa: UnsafePointer +) -> Int32 + +private func installErrorMessage(_ code: Int32) -> String { + switch code { + case 0: return "Success" + case 1: return "Pairing file unreadable" + case 2: return "TCP provider error" + case 3: return "AFC connect error" + case 4: return "IPA unreadable" + case 5: return "AFC open error" + case 6: return "AFC write error" + case 7: return "Install-proxy error" + case 8: return "Device refused IPA" + case 9: return "Invalid IP address" + default: return "Unknown (\(code))" + } +} + +// MARK: - Files + +private let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] +private let appsJSON = docs.appendingPathComponent("signed_apps.json") +private let certsJSON = docs.appendingPathComponent("certs.json") +// Repos disabled for now +// private let reposJSON = docs.appendingPathComponent("repos.json") + +// MARK: - Models + +struct SignedApp: Identifiable, Codable, Hashable { + let id: UUID + let name: String + let bundleID: String + let version: String + let ipaPath: String + let iconPath: String? + + var ipaURL: URL { docs.appendingPathComponent(ipaPath) } + var iconURL: URL? { iconPath.map { docs.appendingPathComponent($0) } } +} + +struct Certificate: Identifiable, Codable, Hashable { + let id: UUID + let name: String + let p12Path: String + let mobPath: String? + + var p12URL: URL { docs.appendingPathComponent(p12Path) } + var mobURL: URL? { mobPath.map { docs.appendingPathComponent($0) } } +} + +// MARK: - FS helpers + +private enum FSX { + static func mkdir(_ url: URL) throws { + if !FileManager.default.fileExists(atPath: url.path) { + try FileManager.default.createDirectory(at: url, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o755]) + } + } + static func copySecure(from src: URL, to dst: URL) throws { + let ok = src.startAccessingSecurityScopedResource() + defer { if ok { src.stopAccessingSecurityScopedResource() } } + if FileManager.default.fileExists(atPath: dst.path) { try FileManager.default.removeItem(at: dst) } + try FileManager.default.copyItem(at: src, to: dst) + try FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: dst.path) + } + static func perms(app: URL) throws { + let keys: [URLResourceKey] = [.isDirectoryKey, .isExecutableKey] + guard let e = FileManager.default.enumerator(at: app, includingPropertiesForKeys: keys) else { return } + for case let f as URL in e { + let rv = try f.resourceValues(forKeys: Set(keys)) + if rv.isDirectory == true { + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: f.path) + } else { + let exec = rv.isExecutable == true || + f.lastPathComponent == app.lastPathComponent.replacingOccurrences(of: ".app", with: "") || + (f.pathExtension.isEmpty && !f.lastPathComponent.contains(".")) + try FileManager.default.setAttributes([.posixPermissions: exec ? 0o755 : 0o644], + ofItemAtPath: f.path) + } + } + } +} + +// MARK: - App Signer Manager + +@MainActor +final class AppSignerManager: ObservableObject { + private var didFinishInitialLoad = false + + @Published var apps: [SignedApp] = [] { + didSet { if didFinishInitialLoad { saveApps() } } + } + @Published var certs: [Certificate] = [] { + didSet { if didFinishInitialLoad { saveCerts() } } + } + @Published var selectedCertID: UUID? + @Published var busy = false + @Published var ipaURL: URL? + @Published var ipaName: String? + + /// Parsed from the IPA (for reference) + @Published var detectedName: String? + @Published var detectedBundleID: String? + @Published var detectedVersion: String? + + /// Advanced overrides (used if non-empty; bundleId only if `useCustomBundleID`) + @Published var advName: String = "" + @Published var advBundleID: String = "" + @Published var advVersion: String = "" + @Published var useCustomBundleID: Bool = false + + /// Advanced icon overrides + @Published var useCustomIcon: Bool = false + @Published var advIconURL: URL? + + let imports = docs.appendingPathComponent("Imports", isDirectory: true) + let certsFolder = docs.appendingPathComponent("Certs", isDirectory: true) + + init() { + try? FSX.mkdir(imports) + try? FSX.mkdir(certsFolder) + migrateAndLoadApps() + migrateAndLoadCerts() + didFinishInitialLoad = true + } + + private func migrateAndLoadApps() { + guard let data = try? Data(contentsOf: appsJSON), + let stored = try? JSONDecoder().decode([SignedApp].self, from: data) else { return } + + var fixed: [SignedApp] = [] + for app in stored { + if FileManager.default.fileExists(atPath: app.ipaURL.path) { + fixed.append(app) + continue + } + let expectedRel = "SignedApps/\(app.name)/\(app.name).ipa" + let newAbs = docs.appendingPathComponent(expectedRel).path + if FileManager.default.fileExists(atPath: newAbs) { + let newIconRel = "SignedApps/\(app.name)/icon.png" + fixed.append(SignedApp( + id: app.id, name: app.name, bundleID: app.bundleID, + version: app.version, ipaPath: expectedRel, + iconPath: FileManager.default.fileExists(atPath: docs.appendingPathComponent(newIconRel).path) ? newIconRel : nil)) + } + } + apps = fixed + } + private func saveApps() { + guard let data = try? JSONEncoder().encode(apps) else { return } + try? data.write(to: appsJSON) + } + + private func migrateAndLoadCerts() { + guard let data = try? Data(contentsOf: certsJSON), + let stored = try? JSONDecoder().decode([Certificate].self, from: data) else { return } + + var fixed: [Certificate] = [] + for cert in stored { + if FileManager.default.fileExists(atPath: cert.p12URL.path) && + (cert.mobURL == nil || FileManager.default.fileExists(atPath: cert.mobURL!.path)) { + fixed.append(cert) + continue + } + let dir = certsFolder.appendingPathComponent(cert.id.uuidString) + let newP12Rel = "Certs/\(cert.id.uuidString)/cert.p12" + let newMobRel = "Certs/\(cert.id.uuidString)/profile.mobileprovision" + if FileManager.default.fileExists(atPath: dir.appendingPathComponent("cert.p12").path) { + fixed.append(Certificate( + id: cert.id, name: cert.name, + p12Path: newP12Rel, + mobPath: FileManager.default.fileExists(atPath: dir.appendingPathComponent("profile.mobileprovision").path) ? newMobRel : nil)) + } + } + certs = fixed + if selectedCertID == nil { selectedCertID = certs.first?.id } + } + private func saveCerts() { + guard let data = try? JSONEncoder().encode(certs) else { return } + try? data.write(to: certsJSON) + } + + func deleteApp(_ app: SignedApp) { + if let idx = apps.firstIndex(of: app) { + try? FileManager.default.removeItem(at: app.ipaURL.deletingLastPathComponent()) + apps.remove(at: idx) + } + } + + func deleteCert(_ cert: Certificate) { + let dir = certsFolder.appendingPathComponent(cert.id.uuidString) + try? FileManager.default.removeItem(at: dir) + KeychainHelper.shared.deletePassword(forKey: cert.id.uuidString) + certs.removeAll { $0.id == cert.id } + if selectedCertID == cert.id { selectedCertID = certs.first?.id } + saveCerts() + } + + func importIPA(from url: URL) throws { + let dst = imports.appendingPathComponent("current.ipa") + try FSX.copySecure(from: url, to: dst) + ipaURL = dst + ipaName = try parseIPAName(at: dst) + try? parseIPAMetadata(at: dst) + } + func importIPAFromDownloads(_ localURL: URL) throws { + let dst = imports.appendingPathComponent("current.ipa") + if FileManager.default.fileExists(atPath: dst.path) { try FileManager.default.removeItem(at: dst) } + try FileManager.default.copyItem(at: localURL, to: dst) + ipaURL = dst + ipaName = try? parseIPAName(at: dst) + try? parseIPAMetadata(at: dst) + } + + private func parseIPAName(at ipa: URL) throws -> String? { + let tmp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FSX.mkdir(tmp) + try FileManager.default.unzipItem(at: ipa, to: tmp) + let payload = tmp.appendingPathComponent("Payload") + let appDir = try FileManager.default.contentsOfDirectory(at: payload, includingPropertiesForKeys: nil) + .first { $0.pathExtension == "app" } + defer { try? FileManager.default.removeItem(at: tmp) } + guard let dir = appDir, + let dict = NSDictionary(contentsOf: dir.appendingPathComponent("Info.plist")) else { return nil } + return (dict["CFBundleDisplayName"] ?? dict["CFBundleName"]) as? String + } + + private func parseIPAMetadata(at ipa: URL) throws { + let tmp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FSX.mkdir(tmp) + try FileManager.default.unzipItem(at: ipa, to: tmp) + let payload = tmp.appendingPathComponent("Payload") + let appDir = try FileManager.default.contentsOfDirectory(at: payload, includingPropertiesForKeys: nil) + .first { $0.pathExtension == "app" } + defer { try? FileManager.default.removeItem(at: tmp) } + guard let dir = appDir, + let dict = NSDictionary(contentsOf: dir.appendingPathComponent("Info.plist")) as? [String: Any] else { return } + detectedName = (dict["CFBundleDisplayName"] ?? dict["CFBundleName"]) as? String + detectedBundleID = dict["CFBundleIdentifier"] as? String + detectedVersion = dict["CFBundleShortVersionString"] as? String + } + + func addCertificate(name: String, p12: URL, mob: URL, password: String, + onErr: (String,String)->Void, onOK: (String,String)->Void) { + let id = UUID() + let dir = certsFolder.appendingPathComponent(id.uuidString, isDirectory: true) + do { + try FSX.mkdir(dir) + try FSX.copySecure(from: p12, to: dir.appendingPathComponent("cert.p12")) + try FSX.copySecure(from: mob, to: dir.appendingPathComponent("profile.mobileprovision")) + KeychainHelper.shared.save(password: password, forKey: id.uuidString) + + certs.append(Certificate( + id: id, name: name, + p12Path: "Certs/\(id.uuidString)/cert.p12", + mobPath: "Certs/\(id.uuidString)/profile.mobileprovision")) + selectedCertID = id + onOK("Certificate added", name) + } catch { onErr("Cert error", error.localizedDescription) } + } + + func signIPA(onErr: @escaping (String,String)->Void, + onOK: @escaping (String,String)->Void) { + guard !busy else { return } + guard let ipa = ipaURL, + let cert = certs.first(where: { $0.id == selectedCertID }) else { + onErr("Missing data", "Import IPA and select certificate first"); return + } + busy = true + Task.detached { [weak self] in + guard let self else { return } + do { + let tmp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FSX.mkdir(tmp) + _ = ipa.startAccessingSecurityScopedResource(); defer { ipa.stopAccessingSecurityScopedResource() } + + try FileManager.default.unzipItem(at: ipa, to: tmp) + let payload = tmp.appendingPathComponent("Payload") + guard let appFolder = try FileManager.default.contentsOfDirectory(at: payload, includingPropertiesForKeys: nil) + .first(where: { $0.pathExtension == "app" }) else { + throw NSError(domain: "Signer", code: 1, userInfo: [NSLocalizedDescriptionKey: "No .app found"]) + } + try FSX.perms(app: appFolder) + + let plistURL = appFolder.appendingPathComponent("Info.plist") + guard + let dict = NSDictionary(contentsOf: plistURL) as? [String: Any], + let curName = (dict["CFBundleDisplayName"] ?? dict["CFBundleName"]) as? String, + let curBundle = dict["CFBundleIdentifier"] as? String, + let curVer = dict["CFBundleShortVersionString"] as? String + else { + throw NSError(domain: "Signer", code: 2, userInfo: [NSLocalizedDescriptionKey: "Info.plist parse error"]) + } + + // Capture advanced fields on MainActor + var trimmedName: String = "" + var trimmedBID: String = "" + var trimmedVer: String = "" + var useCustomBID: Bool = false + var useCustomIcon: Bool = false + var pickedIconURL: URL? + await MainActor.run { + trimmedName = self.advName.trimmingCharacters(in: .whitespacesAndNewlines) + trimmedBID = self.advBundleID.trimmingCharacters(in: .whitespacesAndNewlines) + trimmedVer = self.advVersion.trimmingCharacters(in: .whitespacesAndNewlines) + useCustomBID = self.useCustomBundleID + useCustomIcon = self.useCustomIcon + pickedIconURL = self.advIconURL + } + + let newName = trimmedName.isEmpty ? curName : trimmedName + let newBundle = (useCustomBID && !trimmedBID.isEmpty) ? trimmedBID : curBundle + let newVer = trimmedVer.isEmpty ? curVer : trimmedVer + + // Write overrides into Info.plist BEFORE signing + if let m = NSMutableDictionary(contentsOf: plistURL) { + m["CFBundleDisplayName"] = newName + m["CFBundleName"] = newName + m["CFBundleShortVersionString"] = newVer + m["CFBundleIdentifier"] = newBundle + m.write(to: plistURL, atomically: true) + } + + // If requested, generate a full icon set and update CFBundleIcons + if useCustomIcon, let url = pickedIconURL, + let img = UIImage(contentsOfFile: url.path) { + let icon1024URL = try await self.writeIconSet(image: img, into: appFolder) + if let m = NSMutableDictionary(contentsOf: plistURL) { + let primary: [String: Any] = [ + "CFBundleIconFiles": ["AppIcon20", "AppIcon29", "AppIcon40", "AppIcon60", "AppIcon76", "AppIcon83.5"] + ] + m["CFBundleIcons"] = ["CFBundlePrimaryIcon": primary] + m.write(to: plistURL, atomically: true) + } + // Provide a preview fallback for our UI list + let previewDest = appFolder.appendingPathComponent("icon.png") + try? FileManager.default.removeItem(at: previewDest) + try? FileManager.default.copyItem(at: icon1024URL, to: previewDest) + } + + let pw = KeychainHelper.shared.readPassword(forKey: cert.id.uuidString) ?? "" + let rc = zsign(appFolder.path, + cert.p12URL.path, + cert.p12URL.path, + cert.mobURL?.path ?? "", + pw, "", "") + guard rc == 0 else { + throw NSError(domain: "zsign", code: Int(rc), + userInfo: [NSLocalizedDescriptionKey: "zsign returned \(rc)"]) + } + + let outDir = docs.appendingPathComponent("SignedApps/\(newName)", isDirectory: true) + if FileManager.default.fileExists(atPath: outDir.path) { try FileManager.default.removeItem(at: outDir) } + try FSX.mkdir(outDir) + try FileManager.default.zipItem(at: payload, + to: outDir.appendingPathComponent("\(newName).ipa"), + shouldKeepParent: true) + try FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: outDir.appendingPathComponent("\(newName).ipa").path) + + var iconRel: String? + if let iconURL = try? FileManager.default.contentsOfDirectory(at: appFolder, includingPropertiesForKeys: nil) + .first(where: { $0.lastPathComponent.lowercased().contains("appicon") && $0.pathExtension == "png" }) { + let dst = outDir.appendingPathComponent("icon.png") + try? FileManager.default.copyItem(at: iconURL, to: dst) + iconRel = "SignedApps/\(newName)/icon.png" + } else if FileManager.default.fileExists(atPath: appFolder.appendingPathComponent("icon.png").path) { + let dst = outDir.appendingPathComponent("icon.png") + try? FileManager.default.copyItem(at: appFolder.appendingPathComponent("icon.png"), to: dst) + iconRel = "SignedApps/\(newName)/icon.png" + } + + let relIPA = "SignedApps/\(newName)/\(newName).ipa" + let newApp = SignedApp( + id: UUID(), name: newName, bundleID: newBundle, + version: newVer, ipaPath: relIPA, iconPath: iconRel) + + await MainActor.run { + self.apps.removeAll { $0.name == newApp.name } + self.apps.append(newApp) + self.busy = false + onOK("\(newName) is ready for testing", "Saved to \(relIPA)") + } + } catch { + await MainActor.run { + self.busy = false + onErr("Sign error", error.localizedDescription) + } + } + } + } + + func install(app: SignedApp, ip: String, pairing: String, + onErr: @escaping (String,String)->Void, + onOK: @escaping (String,String)->Void) { + guard !busy else { return } + guard FileManager.default.fileExists(atPath: app.ipaURL.path) else { onErr("IPA missing", app.ipaURL.path); return } + guard FileManager.default.fileExists(atPath: pairing) else { onErr("Pairing missing", pairing); return } + busy = true + Task.detached { [weak self] in + guard let self else { return } + let rc = ip.withCString { ipPtr in + pairing.withCString { pairPtr in + app.ipaURL.path.withCString { ipaPtr in + c_install_ipa(ipPtr, pairPtr, nil, ipaPtr) + } + } + } + await MainActor.run { + self.busy = false + rc == 0 ? onOK("Installed for testing", app.name) + : onErr("Install failed", installErrorMessage(rc)) + } + } + } + + // MARK: - Icon writer helpers + + /// Writes all required icon PNGs into `appFolder` and returns the 1024px icon path. + private func writeIconSet(image: UIImage, into appFolder: URL) throws -> URL { + typealias IconOut = (px: CGFloat, suffix: String) + struct IconSpec { let base: String; let outs: [IconOut] } + let specs: [IconSpec] = [ + IconSpec(base: "AppIcon20", outs: [(40,"@2x"), (60,"@3x")]), + IconSpec(base: "AppIcon29", outs: [(58,"@2x"), (87,"@3x")]), + IconSpec(base: "AppIcon40", outs: [(80,"@2x"), (120,"@3x")]), + IconSpec(base: "AppIcon60", outs: [(120,"@2x"), (180,"@3x")]), + IconSpec(base: "AppIcon76", outs: [(76,""), (152,"@2x")]), + IconSpec(base: "AppIcon83.5", outs: [(167,"@2x")]) + ] + for spec in specs { + for out in spec.outs { + let file = "\(spec.base)\(out.suffix).png" + let url = appFolder.appendingPathComponent(file) + if let data = pngData(from: image, side: out.px) { + try data.write(to: url, options: .atomic) + try FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: url.path) + } + } + } + // 1024 marketing icon for previews + let icon1024 = appFolder.appendingPathComponent("AppIcon-1024.png") + if let data = pngData(from: image, side: 1024) { + try data.write(to: icon1024, options: .atomic) + try FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: icon1024.path) + } + return icon1024 + } + + private func pngData(from image: UIImage, side: CGFloat) -> Data? { + let size = CGSize(width: side, height: side) + let renderer = UIGraphicsImageRenderer(size: size) + return renderer.pngData { _ in + image.draw(in: CGRect(origin: .zero, size: size)) + } + } +} + +// Reusable MaterialCard matching SettingsView.materialCard styling +struct MaterialCard: View { + let content: Content + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + var body: some View { + content + .padding(20) + .background( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) + ) + ) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4) + } +} + +struct GlassCapsuleButtonStyle: ButtonStyle { + var minWidth: CGFloat? = nil + var height: CGFloat = 40 + var strokeOpacity: Double = 0.16 + func makeBody(configuration: Configuration) -> some View { + configuration.label + .frame(minWidth: minWidth) + .frame(height: height) + .padding(.horizontal, 14) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(.white.opacity(strokeOpacity), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.08), radius: configuration.isPressed ? 4 : 10, x: 0, y: configuration.isPressed ? 1 : 4) + .scaleEffect(configuration.isPressed ? 0.98 : 1) + } +} + +struct GlassIconButtonStyle: ButtonStyle { + var size: CGFloat = 40 + var strokeOpacity: Double = 0.14 + func makeBody(configuration: Configuration) -> some View { + configuration.label + .frame(width: size, height: size) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(.white.opacity(strokeOpacity), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.08), radius: configuration.isPressed ? 4 : 10, x: 0, y: configuration.isPressed ? 1 : 4) + .scaleEffect(configuration.isPressed ? 0.98 : 1) + } +} + +// MARK: - Custom glassy tab switcher (Repos disabled; only Testing) + +enum ManagerSection: String, CaseIterable, Identifiable { + case testing = "Testing" + // case repos = "Repos" + var id: String { rawValue } +} + +// MARK: - Liquid Glass Tab Switcher + +struct GlassLiquidTabSwitcher: View { + @Binding var selection: ManagerSection + var accent: Color = .white + var height: CGFloat = 56 + + private let pad: CGFloat = 6 + private let pillInset: CGFloat = 4 + private let stretchMax: CGFloat = 0.18 + + @GestureState private var dragX: CGFloat? = nil + @State private var isDragging = false + @State private var lastIndex: Int = 0 + + private var items: [ManagerSection] { [.testing] } // only testing + + var body: some View { + // Hidden per request (testing-repo selector off) + EmptyView() + } + + private func clamp(_ v: T, min lo: T, max hi: T) -> T { + Swift.max(lo, Swift.min(v, hi)) + } +} + +// MARK: - Reusable full-width glass row + +private struct GlassInputRow: View { + let leadingIcon: String + let title: String + let statusIcon: String? + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 12) { + Image(systemName: leadingIcon).imageScale(.medium) + Text(title).font(.body).lineLimit(1).truncationMode(.tail) + Spacer() + if let si = statusIcon { Image(systemName: si).imageScale(.medium) } + Image(systemName: "chevron.right").imageScale(.medium).opacity(0.8) + } + .padding(.vertical, 14) + .padding(.horizontal, 14) + .frame(maxWidth: .infinity) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(.white.opacity(0.14), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.08), radius: 8, x: 0, y: 3) + } + .buttonStyle(.plain) + } +} + +// MARK: - Certificate picker sheet + +private struct CertPickerSheet: View { + let certs: [Certificate] + @Binding var selectedID: UUID? + var onAdd: () -> Void + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + List { + Section("Certificates") { + ForEach(certs) { cert in + HStack { + Image(systemName: "checkmark.seal").foregroundStyle(.secondary) + Text(cert.name) + Spacer() + if selectedID == cert.id { + Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) + } + } + .contentShape(Rectangle()) + .onTapGesture { selectedID = cert.id; dismiss() } + } + Button { + dismiss() + onAdd() + } label: { + HStack { + Image(systemName: "plus.circle.fill") + Text("Add Certificate") + } + } + } + } + .navigationTitle("Choose Certificate") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Close") { dismiss() } + } + } + } + } +} + +// MARK: - Manage Certificates (delete) + +private struct ManageCertsView: View { + @Binding var certs: [Certificate] + @Binding var selected: UUID? + var onDelete: (Certificate) -> Void + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + List { + ForEach(certs) { cert in + HStack { + VStack(alignment: .leading) { + Text(cert.name).font(.body) + Text(cert.id.uuidString).font(.caption2).foregroundStyle(.secondary) + } + Spacer() + if selected == cert.id { + Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) + } + } + .contentShape(Rectangle()) + .onTapGesture { selected = cert.id } + } + .onDelete { idx in + for i in idx { + let c = certs[i] + onDelete(c) + } + } + } + .navigationTitle("Certificates") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + } + } + } +} + +// MARK: - Top-level view (Repos disabled) + +struct IPAAppManagerView: View { + @AppStorage("customAccentColor") private var customAccentColorHex: String = "" + private var accent: Color { + customAccentColorHex.isEmpty ? .white : Color(hex: customAccentColorHex) ?? .white + } + + @StateObject private var mgr = AppSignerManager() + + @State private var pickerShown = false + @State private var showAddCert = false + @State private var showManageCerts = false + @State private var showCertPickerSheet = false + + // CustomErrorView overlay state + @State private var alertVisible = false + @State private var alertTitle = "" + @State private var alertMsg = "" + @State private var alertType: MessageType = .info + + @AppStorage("deviceIP") private var deviceIP = "10.7.0.2" + private var pairingPath: String { docs.appendingPathComponent("pairingFile.plist").path } + + @State private var section: ManagerSection = .testing + + // For auto-scroll to “Prepare for testing” + @State private var scrollProxy: ScrollViewProxy? + private let signCardAnchor = "signCardAnchor" + + // Advanced options UI state + @State private var showAdvanced = false + @State private var pickIcon = false + @State private var photoItem: PhotosPickerItem? = nil // Photos picker + + var body: some View { + NavigationStack { + ZStack { + // Subtle depth gradient background + LinearGradient( + gradient: Gradient(colors: [ + Color(UIColor.systemBackground), + Color(UIColor.secondarySystemBackground) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .ignoresSafeArea() + + ScrollViewReader { proxy in + ScrollView { + VStack(spacing: 16) { + // Selector hidden per request + // GlassLiquidTabSwitcher(selection: $section) + + // Only Testing section + signCard.id(signCardAnchor) + appsCard + versionInfo + } + .padding(.horizontal, 16) + .padding(.vertical, 20) + } + .onAppear { scrollProxy = proxy } + } + + if mgr.busy { + Color.black.opacity(0.25).ignoresSafeArea() + ProgressView() + .padding(24) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(.white.opacity(0.20), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.20), radius: 20, x: 0, y: 10) + } + + if alertVisible { + CustomErrorView( + title: alertTitle, + message: alertMsg, + onDismiss: { alertVisible = false }, + showButton: true, + primaryButtonText: "OK", + messageType: alertType + ) + } + } + // Show nav bar title again + .navigationTitle("Testing") + .navigationBarTitleDisplayMode(.large) + .stikImporter(isPresented: $pickerShown, + selectedURLs: .constant([]), + allowedContentTypes: [.item], + allowsMultipleSelection: false) { urls in + if let u = urls.first { + do { + try mgr.importIPA(from: u) + notify("File imported", "File saved to Imports") + jumpToSigning() + } catch { + fail("Import error", error.localizedDescription) + } + } + } + .sheet(isPresented: $showAddCert) { + AddCertView(accent: accent) { n, p12, mob, pw in + mgr.addCertificate(name: n, p12: p12, mob: mob, password: pw, + onErr: fail, onOK: notify) + } + } + .sheet(isPresented: $showManageCerts) { + ManageCertsView(certs: $mgr.certs, + selected: $mgr.selectedCertID, + onDelete: { cert in mgr.deleteCert(cert) }) + } + // Photos picker → temp PNG saved → advIconURL + .onChange(of: photoItem) { newItem in + guard let newItem else { return } + Task { + if let data = try? await newItem.loadTransferable(type: Data.self), + let img = UIImage(data: data), + let png = img.pngData() { + + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("custom-icon-\(UUID().uuidString).png") + do { + try png.write(to: url, options: .atomic) + await MainActor.run { mgr.advIconURL = url } + } catch { + await MainActor.run { fail("Icon error", error.localizedDescription) } + } + + } else if let fileURL = try? await newItem.loadTransferable(type: URL.self) { + do { + let data = try Data(contentsOf: fileURL) + if let img = UIImage(data: data), + let png = img.pngData() { + + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("custom-icon-\(UUID().uuidString).png") + try png.write(to: url, options: .atomic) + await MainActor.run { mgr.advIconURL = url } + } else { + await MainActor.run { fail("Icon error", "Could not decode image data") } + } + } catch { + await MainActor.run { fail("Icon error", error.localizedDescription) } + } + + } else { + await MainActor.run { fail("Icon error", "Unsupported photo representation") } + } + } + } + } + } + + // MARK: - Testing UI + + private var selectedCertName: String { + mgr.certs.first(where: { $0.id == mgr.selectedCertID })?.name ?? "Select certificate" + } + + private var signCard: some View { + MaterialCard { + VStack(alignment: .leading, spacing: 18) { + HStack { + Text("Prepare for testing") + .font(.headline) + .foregroundStyle(.primary) + Spacer() + } + + // Certificate + GlassInputRow( + leadingIcon: "checkmark.seal.fill", + title: selectedCertName, + statusIcon: mgr.selectedCertID == nil ? nil : "checkmark.circle.fill" + ) { showCertPickerSheet.toggle() } + .sheet(isPresented: $showCertPickerSheet) { + CertPickerSheet( + certs: mgr.certs, + selectedID: $mgr.selectedCertID, + onAdd: { showAddCert = true } + ) + } + + // IPA file + GlassInputRow( + leadingIcon: "arrow.down.doc", + title: mgr.ipaName ?? ".IPA File", + statusIcon: mgr.ipaURL == nil ? nil : "checkmark.circle.fill" + ) { pickerShown = true } + + // Advanced Options + DisclosureGroup(isExpanded: $showAdvanced) { + VStack(spacing: 10) { + TextField("", text: $mgr.advName, prompt: Text("Custom app name")) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .padding(.vertical, 10) + .padding(.horizontal, 12) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(.white.opacity(0.14), lineWidth: 1)) + + Toggle(isOn: $mgr.useCustomBundleID) { Text("Use custom bundle ID") } + .toggleStyle(.switch) + + TextField("", text: $mgr.advBundleID, prompt: Text("Custom bundle ID")) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .padding(.vertical, 10) + .padding(.horizontal, 12) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(.white.opacity(0.14), lineWidth: 1)) + .disabled(!mgr.useCustomBundleID) + .opacity(mgr.useCustomBundleID ? 1 : 0.5) + + TextField("", text: $mgr.advVersion, prompt: Text("Custom version")) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .keyboardType(.numbersAndPunctuation) + .padding(.vertical, 10) + .padding(.horizontal, 12) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(.white.opacity(0.14), lineWidth: 1)) + + Toggle(isOn: $mgr.useCustomIcon) { Text("Use custom app icon") } + .toggleStyle(.switch) + + PhotosPicker(selection: $photoItem, matching: .images) { + HStack { + Image(systemName: "photo.on.rectangle") + Text(mgr.advIconURL == nil ? "Choose from Photos…" : "Photo selected") + Spacer() + Image(systemName: mgr.advIconURL == nil ? "chevron.right" : "checkmark.circle.fill") + .imageScale(.medium) + } + } + .buttonStyle(GlassCapsuleButtonStyle(minWidth: nil, height: 40)) + .disabled(!mgr.useCustomIcon) + .opacity(mgr.useCustomIcon ? 1 : 0.5) + + Button { + pickIcon = true + } label: { + HStack { + Image(systemName: "folder") + Text("Choose PNG from Files…") + Spacer() + Image(systemName: "chevron.right").imageScale(.medium) + } + } + .buttonStyle(GlassCapsuleButtonStyle(minWidth: nil, height: 40)) + .disabled(!mgr.useCustomIcon) + .opacity(mgr.useCustomIcon ? 1 : 0.5) + .stikImporter( + isPresented: $pickIcon, + selectedURLs: .constant([]), + allowedContentTypes: [UTType.png], + allowsMultipleSelection: false + ) { urls in + mgr.advIconURL = urls.first + } + + Text("Note: The provisioning profile must match the bundle ID. If not, signing will fail.") + .font(.footnote) + .foregroundStyle(.secondary) + .padding(.top, 2) + } + .padding(.top, 8) + } label: { + HStack(spacing: 8) { + Image(systemName: "slider.horizontal.3") + Text("Advanced Options") + Spacer() + } + .contentShape(Rectangle()) + } + .padding(.top, 2) + + Button("Prepare") { mgr.signIPA(onErr: fail, onOK: notify) } + .frame(maxWidth: .infinity, minHeight: 48) + .background(accent, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(.white.opacity(0.18), lineWidth: 1) + ) + .foregroundColor(accent.contrastText()) + // Removed glow/shadow from the Prepare button + .disabled(mgr.busy || mgr.ipaURL == nil || mgr.selectedCertID == nil) + } + .padding(8) + } + } + + private var appsCard: some View { + MaterialCard { + VStack(alignment: .leading, spacing: 18) { + Text("Ready to test apps") + .font(.headline) + .foregroundStyle(.primary) + + if mgr.apps.isEmpty { + // Full-width placeholder matching card content width + HStack(spacing: 12) { + Image(systemName: "app.dashed") + .imageScale(.medium) + .foregroundStyle(.secondary) + Text("No ready to test apps yet") + .foregroundStyle(.secondary) + Spacer() + } + .padding(.vertical, 14) + .padding(.horizontal, 14) + .frame(maxWidth: .infinity) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(.white.opacity(0.14), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.08), radius: 8, x: 0, y: 3) + } else { + ForEach(mgr.apps) { app in + HStack(spacing: 12) { + if let img = app.iconURL.flatMap({ UIImage(contentsOfFile: $0.path) }) { + Image(uiImage: img) + .resizable() + .frame(width: 44, height: 44) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(.white.opacity(0.15), lineWidth: 0.5) + ) + } else { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(.ultraThinMaterial) + .overlay(RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(.white.opacity(0.12), lineWidth: 0.5)) + .frame(width: 44, height: 44) + .overlay(Image(systemName: "app.fill").opacity(0.6)) + } + VStack(alignment: .leading, spacing: 2) { + Text(app.name).bold() + Text("v\(app.version) • \(app.bundleID)") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Button("Test") { + mgr.install(app: app, ip: deviceIP, pairing: pairingPath, + onErr: fail, onOK: notify) + } + .buttonStyle(GlassCapsuleButtonStyle(minWidth: 72, height: 40, strokeOpacity: 0.18)) + .disabled(mgr.busy) + + Button(role: .destructive) { mgr.deleteApp(app) } label: { + Image(systemName: "trash") + } + .buttonStyle(GlassIconButtonStyle()) + } + .padding(.vertical, 6) + Divider().opacity(0.25) + } + } + } + .padding(8) + } + } + + private var versionInfo: some View { + HStack { + Spacer() + Text("iOS \(UIDevice.current.systemVersion)") + .font(.footnote) + .foregroundStyle(.secondary) + Spacer() + } + .padding(.top, 6) + } + + // MARK: - Helpers (CustomErrorView) + + private func fail(_ title: String, _ msg: String) { + alertTitle = title + alertMsg = msg + alertType = .error + alertVisible = true + } + private func notify(_ title: String, _ msg: String) { + alertTitle = title + alertMsg = msg + alertType = .success + alertVisible = true + } + private func jumpToSigning() { + withAnimation(.easeInOut) { + section = .testing + scrollProxy?.scrollTo(signCardAnchor, anchor: .top) + } + } +} + +// MARK: - Add Certificate sheet + +private struct AddCertView: View { + let accent: Color + var onSave: (String, URL, URL, String) -> Void + @Environment(\.dismiss) private var dismiss + + @State private var name = "" + @State private var p12URL: URL? + @State private var mobURL: URL? + @State private var password = "" + + @State private var pickP12 = false + @State private var pickMob = false + + var body: some View { + NavigationStack { + Form { + Section { + TextField("Certificate name", text: $name) + importRow(label: ".mobileprovision", picked: mobURL != nil) { pickMob = true } + .stikImporter(isPresented: $pickMob, + selectedURLs: .constant([]), + allowedContentTypes: [UTType.item], + allowsMultipleSelection: false) { mobURL = $0.first } + importRow(label: ".p12 file", picked: p12URL != nil) { pickP12 = true } + .stikImporter(isPresented: $pickP12, + selectedURLs: .constant([]), + allowedContentTypes: [UTType.item], + allowsMultipleSelection: false) { p12URL = $0.first } + SecureField("p12 password", text: $password) + } + } + .navigationTitle("New Certificate") + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + if let p = p12URL, let m = mobURL, !name.isEmpty { + onSave(name, p, m, password); dismiss() + } + } + .disabled(p12URL == nil || mobURL == nil || name.isEmpty) + } + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + } + } + + private func importRow(label: String, picked: Bool, + action: @escaping () -> Void) -> some View { + Button(action: action) { + HStack { + Text(label) + Spacer() + Image(systemName: picked ? "checkmark.circle.fill" : "chevron.right") + } + } + .buttonStyle(GlassCapsuleButtonStyle(minWidth: nil, height: 40)) + } +}