diff --git a/StikDebug.xcodeproj/project.pbxproj b/StikDebug.xcodeproj/project.pbxproj index efc1bab..469e5b4 100644 --- a/StikDebug.xcodeproj/project.pbxproj +++ b/StikDebug.xcodeproj/project.pbxproj @@ -725,7 +725,7 @@ "$(PROJECT_DIR)/StikJIT/idevice", ); MACOSX_DEPLOYMENT_TARGET = 15.1; - MARKETING_VERSION = 2.2.3; + MARKETING_VERSION = 2.2.5; PRODUCT_BUNDLE_IDENTIFIER = com.stik.sj; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; @@ -778,7 +778,7 @@ "$(PROJECT_DIR)/StikJIT/idevice", ); MACOSX_DEPLOYMENT_TARGET = 15.1; - MARKETING_VERSION = 2.2.3; + MARKETING_VERSION = 2.2.5; PRODUCT_BUNDLE_IDENTIFIER = com.stik.sj; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; diff --git a/StikJIT/JSSupport/ScriptEditorView.swift b/StikJIT/JSSupport/ScriptEditorView.swift index 57a2b70..bb12ee9 100644 --- a/StikJIT/JSSupport/ScriptEditorView.swift +++ b/StikJIT/JSSupport/ScriptEditorView.swift @@ -25,7 +25,7 @@ struct ScriptEditorView: View { text: $scriptContent, position: $position, messages: $messages, - language: .swift() // ← JavaScript syntax highlighting + language: .swift() ) .font(.system(.footnote, design: .monospaced)) .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -36,7 +36,6 @@ struct ScriptEditorView: View { Divider() - // Equal-width, centered, rounded-rectangle buttons HStack(spacing: 12) { WideGlassyButton(title: "Cancel", systemImage: "xmark") { dismiss() @@ -81,12 +80,12 @@ private struct WideGlassyButton: View { .lineLimit(1) .minimumScaleFactor(0.85) } - .frame(maxWidth: .infinity, alignment: .center) // center label+icon + .frame(maxWidth: .infinity, alignment: .center) .padding(.vertical, 10) .padding(.horizontal, 14) } .frame(height: 44) - .frame(maxWidth: .infinity) // equal widths within HStack + .frame(maxWidth: .infinity) .background( RoundedRectangle(cornerRadius: 12, style: .continuous) .fill(.ultraThinMaterial) diff --git a/StikJIT/JSSupport/ScriptListView.swift b/StikJIT/JSSupport/ScriptListView.swift index 1d7aab7..5a89417 100644 --- a/StikJIT/JSSupport/ScriptListView.swift +++ b/StikJIT/JSSupport/ScriptListView.swift @@ -24,46 +24,36 @@ struct ScriptListView: View { @State private var justCopied = false @State private var searchText = "" - // delete confirmation @State private var showDeleteConfirmation = false @State private var pendingDelete: URL? = nil var onSelectScript: ((URL?) -> Void)? = nil + private var isPickerMode: Bool { onSelectScript != nil } + private var filteredScripts: [URL] { guard !searchText.isEmpty else { return scripts } return scripts.filter { $0.lastPathComponent.localizedCaseInsensitiveContains(searchText) } } + + @AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue + private var currentTheme: AppTheme { AppTheme(rawValue: appThemeRaw) ?? .system } var body: some View { NavigationStack { ZStack { - // Background gradient - LinearGradient( - gradient: Gradient(colors: [ - Color(UIColor.systemBackground), - Color(UIColor.secondarySystemBackground) - ]), - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - .ignoresSafeArea() + ThemedBackground(style: currentTheme.backgroundStyle) + .ignoresSafeArea() ScrollView { VStack(spacing: 20) { - // Search + actions card headerCard if filteredScripts.isEmpty { emptyCard } else { ForEach(filteredScripts, id: \.self) { script in - NavigationLink { - ScriptEditorView(scriptURL: script) - } label: { - scriptCard(script) - } - .buttonStyle(.plain) + scriptRow(script) } } } @@ -109,14 +99,16 @@ struct ScriptListView: View { .animation(.easeInOut(duration: 0.25), value: justCopied) } } - .navigationTitle("Scripts") + .navigationTitle(isPickerMode ? "Choose Script" : "Scripts") .toolbar { ToolbarItemGroup(placement: .navigationBarTrailing) { - Button { showNewFileAlert = true } label: { - Label("New", systemImage: "doc.badge.plus") - } - Button { showImporter = true } label: { - Label("Import", systemImage: "tray.and.arrow.down") + if !isPickerMode { + Button { showNewFileAlert = true } label: { + Label("New", systemImage: "doc.badge.plus") + } + Button { showImporter = true } label: { + Label("Import", systemImage: "tray.and.arrow.down") + } } } } @@ -156,13 +148,21 @@ struct ScriptListView: View { .strokeBorder(Color.white.opacity(0.12), lineWidth: 1) ) - // Equal-width rounded-rectangle buttons with centered content HStack(spacing: 12) { - WideGlassyButton(title: "New", systemImage: "doc.badge.plus") { - showNewFileAlert = true - } - WideGlassyButton(title: "Import", systemImage: "tray.and.arrow.down") { - showImporter = true + if isPickerMode { + WideGlassyButton(title: "None", systemImage: "nosign") { + onSelectScript?(nil) + } + WideGlassyButton(title: "Import", systemImage: "tray.and.arrow.down") { + showImporter = true + } + } else { + WideGlassyButton(title: "New", systemImage: "doc.badge.plus") { + showNewFileAlert = true + } + WideGlassyButton(title: "Import", systemImage: "tray.and.arrow.down") { + showImporter = true + } } } } @@ -170,7 +170,26 @@ struct ScriptListView: View { .background(glassyBackground) } - private func scriptCard(_ script: URL) -> some View { + @ViewBuilder + private func scriptRow(_ script: URL) -> some View { + if isPickerMode { + Button { + onSelectScript?(script) + } label: { + scriptCard(script, showDefaultStar: true, showDelete: false) + } + .buttonStyle(.plain) + } else { + NavigationLink { + ScriptEditorView(scriptURL: script) + } label: { + scriptCard(script, showDefaultStar: true, showDelete: true) + } + .buttonStyle(.plain) + } + } + + private func scriptCard(_ script: URL, showDefaultStar: Bool, showDelete: Bool) -> some View { let isDefault = defaultScriptName == script.lastPathComponent return HStack(spacing: 12) { @@ -184,19 +203,21 @@ struct ScriptListView: View { Spacer() - if isDefault { + if showDefaultStar, isDefault { Image(systemName: "star.fill") .foregroundColor(.yellow) } - Button(role: .destructive) { - pendingDelete = script - showDeleteConfirmation = true - } label: { - Image(systemName: "trash") - .foregroundColor(.red) + if showDelete { + Button(role: .destructive) { + pendingDelete = script + showDeleteConfirmation = true + } label: { + Image(systemName: "trash") + .foregroundColor(.red) + } + .buttonStyle(.borderless) } - .buttonStyle(.borderless) } .padding(20) .background(glassyBackground) @@ -207,18 +228,20 @@ struct ScriptListView: View { Button { copyPath(script) } label: { Label("Copy Path", systemImage: "folder") } - Button { saveDefaultScript(script) } label: { - Label("Set Default", systemImage: "star") + if !isPickerMode { + Button { saveDefaultScript(script) } label: { + Label("Set Default", systemImage: "star") + } } - // (Delete removed from context menu) } } private var emptyCard: some View { VStack(spacing: 6) { - Label("No scripts found", systemImage: "doc.text.magnifyingglass") + Label(isPickerMode ? "No scripts available" : "No scripts found", + systemImage: "doc.text.magnifyingglass") .font(.subheadline.weight(.semibold)) - Text("Tap New or Import to get started.") + Text(isPickerMode ? "Import a file or choose None." : "Tap New or Import to get started.") .font(.footnote) .foregroundColor(.secondary) } @@ -243,7 +266,7 @@ struct ScriptListView: View { 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) + let exists = FileManager.default.fileExists(atPath: dir.path, isDirectory: &isDir) do { if exists && !isDir.boolValue { try FileManager.default.removeItem(at: dir) @@ -311,7 +334,7 @@ struct ScriptListView: View { DispatchQueue.global(qos: .userInitiated).async { defer { DispatchQueue.main.async { self.isBusy = false } } do { - let dest = scriptsDirectory().appendingPathComponent(fileURL.lastPathComponent) + let dest = self.scriptsDirectory().appendingPathComponent(fileURL.lastPathComponent) if FileManager.default.fileExists(atPath: dest.path) { try FileManager.default.removeItem(at: dest) } @@ -375,11 +398,11 @@ private struct WideGlassyButton: View { .lineLimit(1) .minimumScaleFactor(0.8) } - .frame(maxWidth: .infinity, alignment: .center) // center contents horizontally + .frame(maxWidth: .infinity, alignment: .center) .padding(.horizontal, 14) } .frame(height: 44) - .frame(maxWidth: .infinity) // make both buttons equal width + .frame(maxWidth: .infinity) .background( RoundedRectangle(cornerRadius: 12, style: .continuous) .fill(.ultraThinMaterial) diff --git a/StikJIT/StikJITApp.swift b/StikJIT/StikJITApp.swift index 384203f..edb52b5 100644 --- a/StikJIT/StikJITApp.swift +++ b/StikJIT/StikJITApp.swift @@ -24,19 +24,16 @@ private func registerAdvancedOptionsDefault() { struct WelcomeSheetView: View { var onDismiss: (() -> Void)? @Environment(\.colorScheme) private var colorScheme + @AppStorage("customAccentColor") private var customAccentColorHex: String = "" + + private var accent: Color { + customAccentColorHex.isEmpty ? .accentColor : (Color(hex: customAccentColorHex) ?? .accentColor) + } var body: some View { ZStack { - // Subtle gradient background to match app style - LinearGradient( - gradient: Gradient(colors: [ - Color(UIColor.systemBackground), - Color(UIColor.secondarySystemBackground) - ]), - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - .ignoresSafeArea() + // Background now comes from global BackgroundContainer + Color.clear.ignoresSafeArea() ScrollView { VStack(spacing: 20) { @@ -57,7 +54,7 @@ struct WelcomeSheetView: View { // App description VStack(alignment: .leading, spacing: 6) { Label("On‑device debugger", systemImage: "bolt.shield.fill") - .foregroundColor(.white) + .foregroundColor(accent) .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) @@ -68,7 +65,7 @@ struct WelcomeSheetView: View { // VPN explanation VStack(alignment: .leading, spacing: 6) { Label("Why VPN permission?", systemImage: "lock.shield.fill") - .foregroundColor(.white) + .foregroundColor(accent) .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) @@ -80,12 +77,16 @@ struct WelcomeSheetView: View { Button(action: { onDismiss?() }) { Text("Continue") .font(.system(size: 16, weight: .semibold, design: .rounded)) - .foregroundColor(.black) + .foregroundColor(accent.contrastText()) .frame(height: 44) .frame(maxWidth: .infinity) .background( RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(Color.white) + .fill(accent) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.primary.opacity(0.15), lineWidth: 1) ) } .padding(.top, 8) @@ -97,11 +98,11 @@ struct WelcomeSheetView: View { .fill(.ultraThinMaterial) .overlay( RoundedRectangle(cornerRadius: 20, style: .continuous) - .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) + .strokeBorder(Color.primary.opacity(0.15), lineWidth: 1) ) ) .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) - .shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4) + .shadow(color: .black.opacity(colorScheme == .dark ? 0.15 : 0.08), radius: 12, x: 0, y: 4) // Footer version info for consistency HStack { @@ -117,7 +118,7 @@ struct WelcomeSheetView: View { .padding(.vertical, 30) } } - .preferredColorScheme(.dark) + // Inherit preferredColorScheme from BackgroundContainer (no local override) } } @@ -322,7 +323,7 @@ class TunnelManager: ObservableObject { // MARK: - AccentColor Environment Key (leave available but unused) struct AccentColorKey: EnvironmentKey { - static let defaultValue: Color = .white + static let defaultValue: Color = .accentColor } extension EnvironmentValues { @@ -406,7 +407,7 @@ class DNSChecker: ObservableObject { group.leave() } - group.notify(queue: DispatchQueue.main) { + group.notify(queue: .main) { if self.controlIP == nil { self.dnsError = "No internet connection." print("Control host lookup failed, so no internet connection.") @@ -430,11 +431,7 @@ class DNSChecker: ObservableObject { private func checkIfConnectedToWifi(completion: @escaping (Bool) -> Void) { let monitor = NWPathMonitor(requiredInterfaceType: .wifi) monitor.pathUpdateHandler = { path in - if path.status == .satisfied { - completion(true) - } else { - completion(false) - } + completion(path.status == .satisfied) monitor.cancel() } let queue = DispatchQueue.global(qos: .background) @@ -456,9 +453,7 @@ class DNSChecker: ObservableObject { var res: UnsafeMutablePointer? let err = getaddrinfo(host, nil, &hints, &res) if err != 0 { - DispatchQueue.main.async { - completion(nil) - } + DispatchQueue.main.async { completion(nil) } return } @@ -477,9 +472,7 @@ class DNSChecker: ObservableObject { ptr = ptr?.pointee.ai_next } freeaddrinfo(res) - DispatchQueue.main.async { - completion(ipAddress) - } + DispatchQueue.main.async { completion(ipAddress) } } } } @@ -492,6 +485,8 @@ var pubHeartBeat = false @main struct HeartbeatApp: App { @AppStorage("hasLaunchedBefore") var hasLaunchedBefore: Bool = false + @AppStorage("customAccentColor") private var customAccentColorHex: String = "" + @AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue @State private var showWelcomeSheet: Bool = false @State private var isLoading2 = true @State private var isPairing = false @@ -529,14 +524,15 @@ 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 { - window.overrideUserInterfaceStyle = .dark - } - - // Ensure UIKit controls also use white tint globally - UIView.appearance().tintColor = .white + // Initialize UIKit tint from stored accent at launch + HeartbeatApp.updateUIKitTintFromStoredAccent() + } + + // Make this static so we can call it without capturing self in init + private static func updateUIKitTintFromStoredAccent() { + let hex = UserDefaults.standard.string(forKey: "customAccentColor") ?? "" + let color = hex.isEmpty ? UIColor.tintColor : UIColor(Color(hex: hex) ?? .accentColor) + UIView.appearance().tintColor = color } func newVerCheck() { @@ -557,188 +553,195 @@ struct HeartbeatApp: App { } } + private var globalAccent: Color { + let hex = customAccentColorHex + return hex.isEmpty ? .accentColor : (Color(hex: hex) ?? .accentColor) + } + var body: some Scene { WindowGroup { - Group { - if isLoading2 { - LoadingView(showAlert: $show_alert, alertTitle: $alert_title, alertMessage: $alert_string) - .onAppear { - dnsChecker.checkDNS() - timeoutTimer?.invalidate() - timeoutTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: false) { _ in - if isLoading2 { - showTimeoutError = true + BackgroundContainer { + Group { + if isLoading2 { + LoadingView(showAlert: $show_alert, alertTitle: $alert_title, alertMessage: $alert_string) + .onAppear { + dnsChecker.checkDNS() + timeoutTimer?.invalidate() + timeoutTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: false) { _ in + if isLoading2 { + showTimeoutError = true + } } - } - checkVPNConnection() { result, vpn_error in - if result { - if FileManager.default.fileExists(atPath: URL.documentsDirectory.appendingPathComponent("pairingFile.plist").path) { - Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { timer in - if pubHeartBeat { - isLoading2 = false - timer.invalidate() - } else { - if let error { - if error == -9 { // InvalidHostID is -9 - isPairing = true - } else { - startHeartbeatInBackground() + checkVPNConnection() { result, vpn_error in + if result { + if FileManager.default.fileExists(atPath: URL.documentsDirectory.appendingPathComponent("pairingFile.plist").path) { + Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { timer in + if pubHeartBeat { + isLoading2 = false + timer.invalidate() + } else { + if let error { + if error == -9 { // InvalidHostID is -9 + isPairing = true + } else { + startHeartbeatInBackground() + } + self.error = nil } - self.error = nil } } - } - startHeartbeatInBackground() - } else { - isLoading2 = false - } - } else if let vpn_error { - showAlert(title: "Error", message: "EM Proxy failed to connect: \(vpn_error)", showOk: true) { _ in - exit(0) - } - } - } - } - .fileImporter( - isPresented: $isPairing, - allowedContentTypes: [ - UTType(filenameExtension: "mobiledevicepairing", conformingTo: .data)!, - .propertyList - ] - ) { result in - switch result { - case .success(let url): - let fileManager = FileManager.default - let accessing = url.startAccessingSecurityScopedResource() - - if fileManager.fileExists(atPath: url.path) { - do { - if fileManager.fileExists(atPath: URL.documentsDirectory.appendingPathComponent("pairingFile.plist").path) { - try fileManager.removeItem(at: URL.documentsDirectory.appendingPathComponent("pairingFile.plist")) - } - try fileManager.copyItem(at: url, to: URL.documentsDirectory.appendingPathComponent("pairingFile.plist")) - print("File copied successfully!") - startHeartbeatInBackground() - } catch { - print("Error copying file: \(error)") - } - } else { - print("Source file does not exist.") - } - - if accessing { - url.stopAccessingSecurityScopedResource() - } - case .failure(_): - print("Failed") - } - } - .overlay( - ZStack { - if showTimeoutError { - CustomErrorView( - title: "Connection Error", - message: "Check your connection and ensure your pairing file is valid and try again.", - onDismiss: { - showTimeoutError = false - }, - showButton: true, - primaryButtonText: "Continue Anyway", - secondaryButtonText: "View Logs", - onPrimaryButtonTap: { - showContinueWarning = true - }, - onSecondaryButtonTap: { - showLogs = true - }, - showSecondaryButton: true - ) - } - - if showContinueWarning { - CustomErrorView( - title: "Proceeding Without Connection", - message: "StikDebug will not function as expected if you choose to continue.", - onDismiss: { - showContinueWarning = false - }, - showButton: true, - primaryButtonText: "I Understand", - onPrimaryButtonTap: { - showContinueWarning = false + startHeartbeatInBackground() + } else { isLoading2 = false } - ) - } - } - ) - .sheet(isPresented: $showLogs, onDismiss: { - isLoading2 = false - }) { - ConsoleLogsView() - .preferredColorScheme(.dark) - } - } else { - MainTabView() - .onAppear { - // 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]) - if !fileManager.fileExists(atPath: destinationURL.path) { - downloadFile(from: urlString, to: destinationURL) { result in - if (result != "") { - alert_title = "An Error has Occurred" - alert_string = "[Download DDI Error]: " + result - show_alert = true + } else if let vpn_error { + showAlert(title: "Error", message: "EM Proxy failed to connect: \(vpn_error)", showOk: true) { _ in + exit(0) } } } } - } - .overlay( - ZStack { - if show_alert { - CustomErrorView( - title: alert_title, - message: alert_string, - onDismiss: { - show_alert = false - }, - showButton: true, - primaryButtonText: "OK" - ) + .fileImporter( + isPresented: $isPairing, + allowedContentTypes: [ + UTType(filenameExtension: "mobiledevicepairing", conformingTo: .data)!, + .propertyList + ] + ) { result in + switch result { + case .success(let url): + let fileManager = FileManager.default + let accessing = url.startAccessingSecurityScopedResource() + + if fileManager.fileExists(atPath: url.path) { + do { + if fileManager.fileExists(atPath: URL.documentsDirectory.appendingPathComponent("pairingFile.plist").path) { + try fileManager.removeItem(at: URL.documentsDirectory.appendingPathComponent("pairingFile.plist")) + } + try fileManager.copyItem(at: url, to: URL.documentsDirectory.appendingPathComponent("pairingFile.plist")) + print("File copied successfully!") + startHeartbeatInBackground() + } catch { + print("Error copying file: \(error)") + } + } else { + print("Source file does not exist.") + } + + if accessing { + url.stopAccessingSecurityScopedResource() + } + case .failure(_): + print("Failed") } } - ) - .preferredColorScheme(.dark) + .overlay( + ZStack { + if showTimeoutError { + CustomErrorView( + title: "Connection Error", + message: "Check your connection and ensure your pairing file is valid and try again.", + onDismiss: { + showTimeoutError = false + }, + showButton: true, + primaryButtonText: "Continue Anyway", + secondaryButtonText: "View Logs", + onPrimaryButtonTap: { + showContinueWarning = true + }, + onSecondaryButtonTap: { + showLogs = true + }, + showSecondaryButton: true + ) + } + + if showContinueWarning { + CustomErrorView( + title: "Proceeding Without Connection", + message: "StikDebug will not function as expected if you choose to continue.", + onDismiss: { + showContinueWarning = false + }, + showButton: true, + primaryButtonText: "I Understand", + onPrimaryButtonTap: { + showContinueWarning = false + isLoading2 = false + } + ) + } + } + ) + .sheet(isPresented: $showLogs, onDismiss: { + isLoading2 = false + }) { + ConsoleLogsView() + } + } else { + MainTabView() + .onAppear { + let fileManager = FileManager.default + for (index, urlString) in urls.enumerated() { + let destinationURL = URL.documentsDirectory.appendingPathComponent(outputFiles[index]) + if !fileManager.fileExists(atPath: destinationURL.path) { + downloadFile(from: urlString, to: destinationURL) { result in + if (result != "") { + alert_title = "An Error has Occurred" + alert_string = "[Download DDI Error]: " + result + show_alert = true + } + } + } + } + } + .overlay( + ZStack { + if show_alert { + CustomErrorView( + title: alert_title, + message: alert_string, + onDismiss: { + show_alert = false + }, + showButton: true, + primaryButtonText: "OK" + ) + } + } + ) + } + } + // Apply global tint to all SwiftUI views in this window + .tint(globalAccent) + .onAppear { + // On first launch, present the welcome sheet. + // Otherwise, start the VPN automatically. + if !hasLaunchedBefore { + showWelcomeSheet = true + } else { + TunnelManager.shared.startVPN() + } + // Update UIKit tint now and subscribe to changes without capturing self + HeartbeatApp.updateUIKitTintFromStoredAccent() + NotificationCenter.default.addObserver( + forName: UserDefaults.didChangeNotification, + object: nil, + queue: .main + ) { _ in + HeartbeatApp.updateUIKitTintFromStoredAccent() + } + } + .sheet(isPresented: $showWelcomeSheet) { + WelcomeSheetView { + // When the user taps "Continue", mark the app as launched and start the VPN. + hasLaunchedBefore = true + showWelcomeSheet = false + TunnelManager.shared.startVPN() + } } } - // 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. - if !hasLaunchedBefore { - showWelcomeSheet = true - } else { - TunnelManager.shared.startVPN() - } - } - .sheet(isPresented: $showWelcomeSheet) { - WelcomeSheetView { - // When the user taps "Continue", mark the app as launched and start the VPN. - hasLaunchedBefore = true - showWelcomeSheet = false - TunnelManager.shared.startVPN() - } - .preferredColorScheme(.dark) - } - .preferredColorScheme(.dark) } .onChange(of: scenePhase) { newPhase in if newPhase == .active { @@ -804,7 +807,7 @@ actor FunctionGuard { func execute(_ work: @escaping @Sendable () -> T) async -> T { if let task = runningTask { - return await task.value // Return existing task's result if running + return await task.value } let task = Task.detached { work() } runningTask = task @@ -903,7 +906,6 @@ func startHeartbeatInBackground() { } else { print("Error: \(message ?? "") (Code: \(result))") DispatchQueue.main.async { - // Special handling for InvalidHostID error (code -9) if result == -9 { do { try FileManager.default.removeItem(at: URL.documentsDirectory.appendingPathComponent("pairingFile.plist")) @@ -951,32 +953,45 @@ struct LoadingView: View { @State private var animate = false @Environment(\.colorScheme) private var colorScheme + @AppStorage("customAccentColor") private var customAccentColorHex: String = "" + + private var accentColor: Color { + if customAccentColorHex.isEmpty { + return .accentColor + } else { + return Color(hex: customAccentColorHex) ?? .accentColor + } + } var body: some View { ZStack { - Color.black.ignoresSafeArea() + // Background now comes from global BackgroundContainer + Color.clear.ignoresSafeArea() VStack { ZStack { Circle() .stroke(lineWidth: 8) - .foregroundColor(Color.white.opacity(0.15)) + .foregroundColor(accentColor.opacity(0.18)) .frame(width: 80, height: 80) Circle() .trim(from: 0, to: 0.7) - .stroke(AngularGradient( - gradient: Gradient(colors: [ - Color.white.opacity(0.9), - Color.white.opacity(0.4) - ]), - center: .center - ), style: StrokeStyle(lineWidth: 6, lineCap: .round)) + .stroke( + AngularGradient( + gradient: Gradient(colors: [ + accentColor.opacity(0.95), + accentColor.opacity(0.45) + ]), + center: .center + ), + style: StrokeStyle(lineWidth: 6, lineCap: .round) + ) .rotationEffect(.degrees(animate ? 360 : 0)) .frame(width: 80, height: 80) .animation(Animation.linear(duration: 1.2).repeatForever(autoreverses: false), value: animate) } - .shadow(color: Color.white.opacity(0.25), radius: 10, x: 0, y: 0) + .shadow(color: accentColor.opacity(0.25), radius: 10, x: 0, y: 0) .onAppear { animate = true let os = ProcessInfo.processInfo.operatingSystemVersion @@ -989,13 +1004,13 @@ struct LoadingView: View { Text("Loading...") .font(.system(size: 20, weight: .medium, design: .rounded)) - .foregroundColor(.white.opacity(0.85)) + .foregroundColor(.primary) .padding(.top, 20) .opacity(animate ? 1.0 : 0.5) .animation(Animation.easeInOut(duration: 1.5).repeatForever(autoreverses: true), value: animate) } } - .preferredColorScheme(.dark) + // Inherit system color scheme } } @@ -1094,3 +1109,4 @@ func downloadFile(from urlString: String, to destinationURL: URL, completion: @e task.resume() completion("") } + diff --git a/StikJIT/Utilities/AppTheme.swift b/StikJIT/Utilities/AppTheme.swift new file mode 100644 index 0000000..72a9b60 --- /dev/null +++ b/StikJIT/Utilities/AppTheme.swift @@ -0,0 +1,283 @@ +// +// AppTheme.swift +// StikJIT +// +// Created by Assistant on 9/12/25. +// + +import SwiftUI + +// MARK: - AppTheme model + +enum AppTheme: String, CaseIterable, Identifiable { + case system // follows system appearance; static gradient + case darkStatic // dark + static gradient + case neonAnimated // animated gradient + case blobs // morphing blobs + case particles // subtle particle field + + var id: String { rawValue } + + var displayName: String { + switch self { + case .system: return "System" + case .darkStatic: return "Dark" + case .neonAnimated: return "Neon" + case .blobs: return "Blobs" + case .particles: return "Particles" + } + } + + // If you want the theme to force a color scheme, set it here. + // Your app currently forces dark globally; we keep nil so nothing fights it. + var preferredColorScheme: ColorScheme? { + switch self { + case .system: return nil + case .darkStatic: return .dark + case .neonAnimated: return .dark + case .blobs: return .dark + case .particles: return .dark + } + } + + var backgroundStyle: BackgroundStyle { + switch self { + case .system: return .staticGradient + case .darkStatic: return .staticGradient + case .neonAnimated: return .animatedGradient + case .blobs: return .blobs + case .particles: return .particles + } + } +} + +// MARK: - Background styles and factory + +enum BackgroundStyle { + case staticGradient + case animatedGradient + case blobs + case particles +} + +// Slightly brighter, clearly-not-black static background that still respects dark mode +private func staticBackgroundGradient() -> LinearGradient { + LinearGradient( + gradient: Gradient(colors: [ + Color(UIColor.systemBackground), + Color(UIColor.secondarySystemBackground) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) +} + +struct ThemedBackground: View { + let style: BackgroundStyle + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + Group { + switch style { + case .staticGradient: + staticBackgroundGradient() + .ignoresSafeArea() + case .animatedGradient: + if reduceMotion { + staticBackgroundGradient() + .ignoresSafeArea() + } else { + AnimatedGradientBackground() + } + case .blobs: + if reduceMotion { + staticBackgroundGradient() + .ignoresSafeArea() + } else { + BlobBackground() + } + case .particles: + if reduceMotion { + staticBackgroundGradient() + .ignoresSafeArea() + } else { + ParticleFieldBackground() + } + } + } + } +} + +// MARK: - Background container to use at app root + +struct BackgroundContainer: View { + @AppStorage("appTheme") private var rawTheme: String = AppTheme.system.rawValue + let content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + private var theme: AppTheme { + AppTheme(rawValue: rawTheme) ?? .system + } + + var body: some View { + ZStack { + ThemedBackground(style: theme.backgroundStyle) + .ignoresSafeArea() + content + } + .preferredColorScheme(theme.preferredColorScheme) // nil means no override + } +} + +// MARK: - Animated backgrounds + +private struct AnimatedGradientBackground: View { + @State private var t: Double = 0 + + var body: some View { + TimelineView(.animation) { timeline in + let now = timeline.date.timeIntervalSinceReferenceDate + let speed = 0.15 + let phase = now * speed + + let c1 = Color(hue: (sin(phase) * 0.5 + 0.5), saturation: 0.55, brightness: 0.45) + let c2 = Color(hue: (sin(phase + .pi * 0.66) * 0.5 + 0.5), saturation: 0.75, brightness: 0.35) + let c3 = Color(hue: (sin(phase + .pi * 1.33) * 0.5 + 0.5), saturation: 0.65, brightness: 0.30) + + let angle = Angle(degrees: (sin(phase * 0.7) * 45) + 45) + + AngularGradient(colors: [c1, c2, c3, c1], center: .center, angle: angle) + .saturation(1.0) + .brightness(0.0) + .opacity(0.9) + .ignoresSafeArea() + .overlay( + LinearGradient(colors: [.black.opacity(0.25), .clear], + startPoint: .top, + endPoint: .bottom) + .ignoresSafeArea() + ) + } + } +} + +private struct BlobBackground: View { + @State private var t: CGFloat = 0 + + var body: some View { + Canvas { ctx, size in + let baseColors: [Color] = [ + .blue.opacity(0.35), + .purple.opacity(0.35), + .pink.opacity(0.35), + .cyan.opacity(0.35) + ] + + let blobs = 6 + let radius = min(size.width, size.height) * 0.45 + + for i in 0.. geo.size.width { p.position.x = 0 } + if p.position.y < 0 { p.position.y = geo.size.height } + if p.position.y > geo.size.height { p.position.y = 0 } + + next[i] = p + } + particles = next + } + } + } + } + .ignoresSafeArea() + } +} diff --git a/StikJIT/Utilities/Extensions.swift b/StikJIT/Utilities/Extensions.swift index d24602b..8bbec6e 100644 --- a/StikJIT/Utilities/Extensions.swift +++ b/StikJIT/Utilities/Extensions.swift @@ -13,12 +13,6 @@ extension FileManager { } } -public extension ProcessInfo { - var hasTXM: Bool { - { if let boot = FileManager.default.filePath(atPath: "/System/Volumes/Preboot", withLength: 36), let file = FileManager.default.filePath(atPath: "\(boot)/boot", withLength: 96) { return access("\(file)/usr/standalone/firmware/FUD/Ap,TrustedExecutionMonitor.img4", F_OK) == 0 } else { return (FileManager.default.filePath(atPath: "/private/preboot", withLength: 96).map { access("\($0)/usr/standalone/firmware/FUD/Ap,TrustedExecutionMonitor.img4", F_OK) == 0 }) ?? false } }() - } -} - extension UIDocumentPickerViewController { @objc func fix_init(forOpeningContentTypes contentTypes: [UTType], asCopy: Bool) -> UIDocumentPickerViewController { return fix_init(forOpeningContentTypes: contentTypes, asCopy: true) diff --git a/StikJIT/Views/ConsoleLogsView.swift b/StikJIT/Views/ConsoleLogsView.swift index 60d8ec3..e9c5388 100644 --- a/StikJIT/Views/ConsoleLogsView.swift +++ b/StikJIT/Views/ConsoleLogsView.swift @@ -14,534 +14,283 @@ 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 = "" - // Alert handling @State private var showingCustomAlert = false @State private var alertMessage = "" @State private var alertTitle = "" @State private var isError = false - // Timer to check for log updates @State private var logCheckTimer: Timer? = nil - // Track if the view is active (visible) @State private var isViewActive = false @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 .white + return .blue } else { - 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) + return Color(hex: customAccentColorHex) ?? .blue } } var body: some View { - NavigationStack { + NavigationView { ZStack { - LinearGradient( - gradient: Gradient(colors: [ - Color(UIColor.systemBackground), - Color(UIColor.secondarySystemBackground) - ]), - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - .ignoresSafeArea() + Color(colorScheme == .dark ? .black : .white) + .edgesIgnoringSafeArea(.all) - ScrollView { - VStack(spacing: 20) { - consoleCard - footerInfo - } - .padding(.horizontal, 20) - .padding(.vertical, 30) - } - .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) - } - .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") + VStack(spacing: 0) { + ScrollViewReader { proxy in + ScrollView { + VStack(spacing: 0) { + 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) + + 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 + ) + } + ) } - Button { - shareAll() - } label: { - Label("Share…", systemImage: "square.and.arrow.up.on.square") + .coordinateSpace(name: "scroll") + .onPreferenceChange(ScrollOffsetPreferenceKey.self) { offset in + isAtBottom = offset > -20 } - Divider() - Button { - showGutter.toggle() - } label: { - Label(showGutter ? "Hide Line Numbers" : "Show Line Numbers", systemImage: "number") + .onChange(of: logManager.logs.count) { _ in + if isAtBottom { + withAnimation { + if let lastLog = logManager.logs.last { + proxy.scrollTo(lastLog.id, anchor: .bottom) + } + } + } } - Divider() - Button(role: .destructive) { - logManager.clearLogs() - } label: { - Label("Clear Logs", systemImage: "trash") + .onAppear { + scrollView = proxy + isViewActive = true + Task { + await loadIdeviceLogsAsync() + } + startLogCheckTimer() + } + .onDisappear { + isViewActive = false + stopLogCheckTimer() } - } 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) } - 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 - ) + Spacer(minLength: 0) + + HStack(spacing: 12) { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .imageScale(.small) + .foregroundColor(.red) + Text("\(logManager.errorCount) Errors") + .font(.subheadline) + .foregroundColor(.secondary) + } + Spacer(minLength: 8) + + Button { + 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 + } label: { + HStack(spacing: 6) { + Image(systemName: "doc.on.doc") + Text("Copy") + } + .foregroundColor(accentColor) + } + .buttonStyle(GlassOvalButtonStyle(height: 36, strokeOpacity: 0.18)) + + exportControl } - ) - } - .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) + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + Text("Console Logs") + .font(.headline) + .foregroundColor(colorScheme == .dark ? .white : .black) + } + + ToolbarItem(placement: .navigationBarLeading) { + Button(action: { dismiss() }) { + HStack(spacing: 2) { + Text("Exit") + .fontWeight(.regular) + } + .foregroundColor(accentColor) + } + } + + ToolbarItem(placement: .navigationBarTrailing) { + HStack { + Button(action: { + Task { await loadIdeviceLogsAsync() } + }) { + Image(systemName: "arrow.clockwise") + .foregroundColor(accentColor) + } + + Button(action: { + logManager.clearLogs() + }) { + Text("Clear") + .foregroundColor(accentColor) + } } } } } - .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 - ) - } - } - - 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 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) + 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 + ) + ) + } + } ) - .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() + @ViewBuilder + private var exportControl: some View { + let logURL: URL = URL.documentsDirectory.appendingPathComponent("idevice_log.txt") + if FileManager.default.fileExists(atPath: logURL.path) { + ShareLink( + item: logURL, + preview: SharePreview("idevice_log.txt", image: Image(systemName: "doc.text")) + ) { + HStack(spacing: 6) { + Image(systemName: "square.and.arrow.up") + Text("Export") + } + .foregroundColor(accentColor) + } + .buttonStyle(GlassOvalButtonStyle(height: 36, strokeOpacity: 0.18)) + } else { + Button { + alertTitle = "Export Failed" + alertMessage = "No idevice logs found" + isError = true + showingCustomAlert = true + } label: { + HStack(spacing: 6) { + Image(systemName: "square.and.arrow.up") + Text("Export") + } + .foregroundColor(accentColor) + } + .buttonStyle(GlassOvalButtonStyle(height: 36, strokeOpacity: 0.18)) } - .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() + private func createLogAttributedString(_ logEntry: LogManager.LogEntry) -> NSAttributedString { + let fullString = NSMutableAttributedString() - // Timestamp (dim) - let ts = "[\(formatTime(date: logEntry.timestamp))]" - full.append(NSAttributedString( - string: ts + " ", - attributes: [ - .foregroundColor: UIColor(white: 1.0, alpha: 0.45) - ] - )) + 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: " ")) - // Type (colored) + let typeString = "[\(logEntry.type.rawValue)]" let typeColor = UIColor(colorForLogType(logEntry.type)) - full.append(NSAttributedString( - string: "[\(logEntry.type.rawValue)] ", + let typeAttr = NSAttributedString( + string: typeString, attributes: [.foregroundColor: typeColor] - )) + ) + fullString.append(typeAttr) + fullString.append(NSAttributedString(string: " ")) - // Message (bright) - full.append(NSAttributedString( + let messageAttr = NSAttributedString( string: logEntry.message, - attributes: [.foregroundColor: UIColor.white] - )) + attributes: [.foregroundColor: colorScheme == .dark ? UIColor.white : UIColor.black] + ) + fullString.append(messageAttr) - return full + return fullString + } + + private func timeString() -> String { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss" + return formatter.string(from: Date()) } private func formatTime(date: Date) -> String { @@ -563,8 +312,6 @@ struct ConsoleLogsView: View { } } - // MARK: - File loading and timer - private func loadIdeviceLogsAsync() async { guard !isLoadingLogs else { return } isLoadingLogs = true @@ -583,10 +330,10 @@ struct ConsoleLogsView: View { let logContent = try String(contentsOfFile: logPath, encoding: .utf8) let lines = logContent.components(separatedBy: .newlines) - // Only last 500 lines let maxLines = 500 let startIndex = max(0, lines.count - maxLines) let recentLines = Array(lines[startIndex.. maxLines { let excessCount = logManager.logs.count - maxLines @@ -686,121 +436,22 @@ struct ConsoleLogsView: View { 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) +private struct GlassOvalButtonStyle: ButtonStyle { + var height: CGFloat = 36 + var strokeOpacity: Double = 0.16 + func makeBody(configuration: Configuration) -> some View { + configuration.label + .padding(.horizontal, 14) + .frame(height: height) + .background(.ultraThinMaterial, in: Capsule()) + .overlay( + Capsule() + .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) } } @@ -816,13 +467,3 @@ struct ScrollOffsetPreferenceKey: PreferenceKey { 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 e1f0058..f09aee9 100644 --- a/StikJIT/Views/Custom Error View.swift +++ b/StikJIT/Views/Custom Error View.swift @@ -41,7 +41,6 @@ struct CustomErrorView: View { var body: some View { ZStack { - // Background overlay Color.black.opacity(0.6) .edgesIgnoringSafeArea(.all) .opacity(opacity) @@ -51,9 +50,7 @@ struct CustomErrorView: View { } } - // Card itself VStack(spacing: 12) { - // Icon based on messageType switch messageType { case .error: Image(systemName: "exclamationmark.triangle.fill") @@ -72,19 +69,16 @@ struct CustomErrorView: View { .padding(.top, 8) } - // Title - slightly smaller Text(title) .font(.system(size: 18, weight: .bold, design: .rounded)) .foregroundColor(colorScheme == .dark ? .white : .black) .multilineTextAlignment(.center) - // Divider Rectangle() .frame(height: 1) .foregroundColor(colorScheme == .dark ? .white.opacity(0.2) : .black.opacity(0.2)) .padding(.horizontal, 12) - // Message Text(LocalizedStringKey(message)) .font(.system(size: 15, design: .rounded)) .foregroundColor(colorScheme == .dark ? .white.opacity(0.9) : .black.opacity(0.9)) @@ -93,12 +87,11 @@ struct CustomErrorView: View { .padding(.horizontal, 12) - // Dismiss button (only shown when showButton is true) if showButton { VStack(spacing: 6) { Button(action: { dismissWithAnimation() - onPrimaryButtonTap?() // Call primary action if provided + onPrimaryButtonTap?() }) { Text(primaryButtonText) .font(.system(size: 16, weight: .semibold, design: .rounded)) @@ -114,7 +107,7 @@ struct CustomErrorView: View { if showSecondaryButton { Button(action: { dismissWithAnimation() - onSecondaryButtonTap?() // Call secondary action if provided + onSecondaryButtonTap?() }) { Text(secondaryButtonText) .font(.system(size: 16, weight: .medium, design: .rounded)) @@ -136,7 +129,6 @@ struct CustomErrorView: View { .frame(height: 12) } } - // Made the card smaller overall .frame(width: min(UIScreen.main.bounds.width - 80, 300)) .background( RoundedRectangle(cornerRadius: 16) diff --git a/StikJIT/Views/DeviceInfoManager.swift b/StikJIT/Views/DeviceInfoManager.swift index 50669ba..9bc829f 100644 --- a/StikJIT/Views/DeviceInfoManager.swift +++ b/StikJIT/Views/DeviceInfoManager.swift @@ -145,25 +145,19 @@ struct DeviceInfoView: View { || $0.value.localizedCaseInsensitiveContains(searchText) } } + + @AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue + private var currentTheme: AppTheme { AppTheme(rawValue: appThemeRaw) ?? .system } var body: some View { NavigationStack { ZStack { - // Subtle depth but same color scheme - LinearGradient( - gradient: Gradient(colors: [ - Color(UIColor.systemBackground), - Color(UIColor.secondarySystemBackground) - ]), - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - .ignoresSafeArea() + ThemedBackground(style: currentTheme.backgroundStyle) + .ignoresSafeArea() ScrollView { VStack(spacing: 20) { infoCard - versionInfo } .padding(.horizontal, 20) .padding(.vertical, 30) @@ -272,7 +266,6 @@ struct DeviceInfoView: View { 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)) @@ -324,17 +317,6 @@ struct DeviceInfoView: View { .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 { diff --git a/StikJIT/Views/DisplayView.swift b/StikJIT/Views/DisplayView.swift index a66a01a..7c0008c 100644 --- a/StikJIT/Views/DisplayView.swift +++ b/StikJIT/Views/DisplayView.swift @@ -12,14 +12,14 @@ struct AccentColorPicker: View { @Binding var selectedColor: Color let colors: [Color] = [ - .blue, // Default system blue - .init(hex: "#7FFFD4")!, // Aqua - .init(hex: "#50C878")!, // Green - .red, // Red - .init(hex: "#6A5ACD")!, // Purple - .init(hex: "#DA70D6")!, // Pink - .white, // White - .black // Black + .blue, + .init(hex: "#7FFFD4")!, + .init(hex: "#50C878")!, + .red, + .init(hex: "#6A5ACD")!, + .init(hex: "#DA70D6")!, + .white, + .black ] var body: some View { @@ -44,7 +44,6 @@ struct AccentColorPicker: View { } } - // Direct Color Picker Circle ColorPicker("", selection: $selectedColor) .labelsHidden() .frame(width: 28, height: 28) @@ -73,38 +72,46 @@ struct AccentColorPicker: View { struct DisplayView: View { @AppStorage("username") private var username = "User" @AppStorage("customAccentColor") private var customAccentColorHex: String = "" - @AppStorage("appTheme") private var appTheme: String = "system" + @AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue @AppStorage("loadAppIconsOnJIT") private var loadAppIconsOnJIT = true - @State private var selectedAccentColor: Color = .blue + @State private var selectedAccentColor: Color = .white @Environment(\.colorScheme) private var colorScheme @State private var justSaved = false private var accentColor: Color { if customAccentColorHex.isEmpty { - return .blue + return .white } else { - return Color(hex: customAccentColorHex) ?? .blue + return Color(hex: customAccentColorHex) ?? .white } } + private var selectedTheme: AppTheme { + get { AppTheme(rawValue: appThemeRaw) ?? .system } + set { appThemeRaw = newValue.rawValue } + } + + private var hardcodedGradient: some View { + LinearGradient( + gradient: Gradient(colors: [ + Color(UIColor.systemBackground), + Color(UIColor.secondarySystemBackground) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + var body: some View { NavigationStack { ZStack { - // Background gradient (glassy style) - LinearGradient( - gradient: Gradient(colors: [ - Color(UIColor.systemBackground), - Color(UIColor.secondarySystemBackground) - ]), - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - .ignoresSafeArea() + hardcodedGradient.ignoresSafeArea() ScrollView { VStack(spacing: 20) { usernameCard + accentCard jitOptionsCard } .padding(.horizontal, 20) @@ -130,9 +137,10 @@ struct DisplayView: View { .navigationTitle("Display") .onAppear { loadCustomAccentColor() - applyTheme(appTheme) + applyTheme(selectedTheme) } } + .tint(selectedAccentColor) } // MARK: - Cards @@ -180,6 +188,70 @@ struct DisplayView: View { .shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 4) } + private var accentCard: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Accent") + .font(.title3) + .fontWeight(.semibold) + .foregroundColor(.primary) + + AccentColorPicker(selectedColor: $selectedAccentColor) + + HStack(spacing: 12) { + Button { + if let hex = selectedAccentColor.toHex() { + customAccentColorHex = hex + } else { + customAccentColorHex = "" + } + showSavedToast() + } label: { + HStack { + Image(systemName: "checkmark.circle.fill") + Text("Save") + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(selectedAccentColor) + ) + .foregroundColor(selectedAccentColor.contrastText()) + } + + Button { + customAccentColorHex = "" + selectedAccentColor = .white + showSavedToast() + } label: { + HStack { + Image(systemName: "arrow.uturn.backward.circle") + Text("Reset") + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(UIColor.tertiarySystemBackground)) + ) + } + } + } + .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) + .onChange(of: selectedAccentColor) { _, _ in + } + } + private var jitOptionsCard: some View { VStack(alignment: .leading, spacing: 12) { Text("App List") @@ -215,21 +287,15 @@ struct DisplayView: View { .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() + // Grid of theme previews + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) { + ForEach(AppTheme.allCases, id: \.self) { theme in + ThemePreviewCard(theme: theme, selected: selectedTheme == theme) { + // Write directly to AppStorage to avoid mutating through a computed property + appThemeRaw = theme.rawValue + applyTheme(theme) + showSavedToast() + } } } } @@ -249,19 +315,20 @@ struct DisplayView: View { private func loadCustomAccentColor() { if customAccentColorHex.isEmpty { - selectedAccentColor = .blue + selectedAccentColor = .white } else { - selectedAccentColor = Color(hex: customAccentColorHex) ?? .blue + selectedAccentColor = Color(hex: customAccentColorHex) ?? .white } } - private func applyTheme(_ theme: String) { + private func applyTheme(_ theme: AppTheme) { 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 .darkStatic, .neonAnimated, .blobs, .particles: + window.overrideUserInterfaceStyle = .dark + case .system: + window.overrideUserInterfaceStyle = .unspecified } } } @@ -274,35 +341,52 @@ struct DisplayView: View { } } -// MARK: - Theme Option Button (Glassy style) -struct ThemeOptionButton: View { - let title: String - let imageName: String - let isSelected: Bool - let accentColor: Color +// MARK: - Theme Preview Card + +private struct ThemePreviewCard: View { + let theme: AppTheme + let selected: Bool let action: () -> Void + private var hardcodedGradient: some View { + LinearGradient( + gradient: Gradient(colors: [ + Color(UIColor.systemBackground), + Color(UIColor.secondarySystemBackground) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + var body: some View { Button(action: action) { - VStack(spacing: 8) { - Image(imageName) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(height: 120) - .cornerRadius(12) + ZStack { + hardcodedGradient .overlay( RoundedRectangle(cornerRadius: 12) - .stroke(isSelected ? accentColor : .clear, lineWidth: 2) + .fill(.ultraThinMaterial) + .padding(8) ) + .clipShape(RoundedRectangle(cornerRadius: 12)) - Text(title) - .font(.caption) - .foregroundColor(isSelected ? accentColor : .primary) + VStack(spacing: 6) { + Text(theme.displayName) + .font(.footnote.weight(.semibold)) + .foregroundColor(.primary) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(.ultraThinMaterial, in: Capsule()) + } + .padding(8) } - .padding(8) - .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12)) + .frame(height: 120) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(selected ? Color.accentColor : Color.white.opacity(0.12), lineWidth: selected ? 2 : 1) + ) .shadow(color: .black.opacity(0.08), radius: 6, x: 0, y: 2) } - .frame(maxWidth: .infinity) + .buttonStyle(.plain) } } diff --git a/StikJIT/Views/HomeView.swift b/StikJIT/Views/HomeView.swift index a27e846..5edad43 100644 --- a/StikJIT/Views/HomeView.swift +++ b/StikJIT/Views/HomeView.swift @@ -52,6 +52,11 @@ struct HomeView: View { @StateObject private var tunnel = TunnelManager.shared @State private var heartbeatOK = false + + @AppStorage("showiOS26Disclaimer") private var showiOS26Disclaimer: Bool = true + + @AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue + private var currentTheme: AppTheme { AppTheme(rawValue: appThemeRaw) ?? .system } private var accentColor: Color { if customAccentColorHex.isEmpty { return .white } @@ -60,22 +65,25 @@ struct HomeView: View { private var ddiMounted: Bool { isMounted() } private var canConnectByApp: Bool { pairingFileExists && ddiMounted } + + private var isOnOrAfteriOS26: Bool { + let v = ProcessInfo.processInfo.operatingSystemVersion + return v.majorVersion >= 26 + } var body: some View { NavigationStack { ZStack { - LinearGradient( - gradient: Gradient(colors: [ - Color(UIColor.systemBackground), - Color(UIColor.secondarySystemBackground) - ]), - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - .ignoresSafeArea() + ThemedBackground(style: currentTheme.backgroundStyle) + .ignoresSafeArea() ScrollView { VStack(spacing: 20) { + if isOnOrAfteriOS26 || showiOS26Disclaimer { + disclaimerCard + .transition(.opacity.combined(with: .move(edge: .top))) + } + topStatusAndActionsCard toolsCard tipsCard @@ -131,7 +139,7 @@ struct HomeView: View { if fileManager.fileExists(atPath: url.path) { do { let dest = URL.documentsDirectory.appendingPathComponent("pairingFile.plist") - if fileManager.fileExists(atPath: dest.path) { + if FileManager.default.fileExists(atPath: dest.path) { try fileManager.removeItem(at: dest) } try fileManager.copyItem(at: url, to: dest) @@ -174,7 +182,47 @@ struct HomeView: View { bundleID = selectedBundle isShowingInstalledApps = false HapticFeedbackHelper.trigger() - startJITInBackground(bundleID: selectedBundle) + + var autoScriptData: Data? = nil + var autoScriptName: String? = nil + + let appName: String? = (try? JITEnableContext.shared.getAppList()[selectedBundle]) + + if #available(iOS 26, *) { + if ProcessInfo.processInfo.hasTXM, let appName { + if appName == "maciOS" { + if let url = Bundle.main.url(forResource: "script1", withExtension: "js"), + let data = try? Data(contentsOf: url) { + autoScriptData = data + autoScriptName = "script1.js" + } + } else if appName == "Amethyst" { + if let url = Bundle.main.url(forResource: "script2", withExtension: "js"), + let data = try? Data(contentsOf: url) { + autoScriptData = data + autoScriptName = "script2.js" + } + } else if appName == "MeloNX" { + if let url = Bundle.main.url(forResource: "melo", withExtension: "js"), + let data = try? Data(contentsOf: url) { + autoScriptData = data + autoScriptName = "melo.js" + } + } else if appName == "UTM" { + if let url = Bundle.main.url(forResource: "utmjit", withExtension: "js"), + let data = try? Data(contentsOf: url) { + autoScriptData = data + autoScriptName = "utmjit.js" + } + } + } + } + + startJITInBackground(bundleID: selectedBundle, + pid: nil, + scriptData: autoScriptData, + scriptName: autoScriptName, + triggeredByURLScheme: false) } } .pipify(isPresented: Binding( @@ -344,8 +392,8 @@ struct HomeView: View { } .frame(maxWidth: .infinity) .padding(.vertical, 14) - .background(Color.white, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .foregroundColor(.black) + .background(accentColor, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .foregroundColor(accentColor.contrastText()) .overlay( RoundedRectangle(cornerRadius: 12, style: .continuous) .stroke(Color.white.opacity(0.2), lineWidth: 0.5) @@ -356,7 +404,18 @@ struct HomeView: View { VStack(alignment: .leading, spacing: 12) { Text("Tools").font(.headline).foregroundColor(.secondary) Button(action: { showingConsoleLogsView = true }) { - whiteCardButtonLabel(icon: "terminal", title: "Open Console") + HStack { + Image(systemName: "terminal").font(.system(size: 20)) + Text("Open Console").font(.system(.title3, design: .rounded)).fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(accentColor, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .foregroundColor(accentColor.contrastText()) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.white.opacity(0.2), lineWidth: 0.5) + ) } .sheet(isPresented: $showingConsoleLogsView) { ConsoleLogsView() } } @@ -382,6 +441,32 @@ struct HomeView: View { 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.") + + Button { + if let url = URL(string: "https://github.com/StephenDev0/StikDebug-Guide/blob/main/pairing_file.md") { + UIApplication.shared.open(url) + } + } label: { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "questionmark.circle") + .foregroundColor(accentColor) + .font(.system(size: 18)) + VStack(alignment: .leading, spacing: 2) { + Text("Pairing File Guide") + .font(.subheadline).fontWeight(.semibold) + Text("Learn how to create and import your pairing file.") + .font(.footnote) + .foregroundColor(.secondary) + } + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 14)) + .foregroundColor(.secondary) + .padding(.top, 2) + } + .padding(.vertical, 4) + } + .buttonStyle(.plain) } .padding(20) .background( @@ -467,7 +552,7 @@ struct HomeView: View { } } - private func startJITInBackground(bundleID: String? = nil, pid : Int? = nil, scriptData: Data? = nil, scriptName : String? = nil, triggeredByURLScheme: Bool = false) { + private func startJITInBackground(bundleID: String? = nil, pid : Int? = nil, scriptData: Data? = nil, scriptName: String? = nil, triggeredByURLScheme: Bool = false) { isProcessing = true LogManager.shared.addInfoLog("Starting Debug for \(bundleID ?? String(pid ?? 0))") @@ -487,14 +572,16 @@ struct HomeView: View { } } } else { - scriptData = nil + // keep passed-in auto script if provided; otherwise nil } var callback: DebugAppCallback? = nil - if let sd = scriptData { + if ProcessInfo.processInfo.hasTXM, let sd = scriptData { callback = getJsCallback(sd, name: scriptName ?? bundleID ?? "Script") if triggeredByURLScheme { usleep(500000) } pipRequired = true + } else { + pipRequired = false } let logger: LogFunc = { message in if let message { LogManager.shared.addInfoLog(message) } } @@ -534,6 +621,56 @@ struct HomeView: View { if pad < 4 { base64 += String(repeating: "=", count: pad) } return base64 } + + // MARK: - iOS 26+ Disclaimer Card (above main card) + + private var disclaimerCard: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.yellow) + .imageScale(.large) + + VStack(alignment: .leading, spacing: 4) { + Text("Important for iOS 26+") + .font(.subheadline.weight(.semibold)) + .foregroundColor(.primary) + Text("Limited compatibility on iOS 26 and later. Some apps may not function as expected yet. We’re actively improving support over time.") + .font(.footnote) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer() + + if !isOnOrAfteriOS26 { + Button { + withAnimation { + showiOS26Disclaimer = false + } + } label: { + Image(systemName: "xmark") + .foregroundColor(.secondary) + .padding(8) + .background( + Circle().fill(Color(UIColor.tertiarySystemBackground)) + ) + } + .buttonStyle(.plain) + .accessibilityLabel("Dismiss") + } + } + .padding(14) + .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) + .accessibilityElement(children: .combine) + .accessibilityLabel("Important notice for iOS 26 and later. Limited compatibility; improvements are ongoing.") + } } private struct StatusDot: View { @@ -567,25 +704,18 @@ private struct ConnectByPIDSheet: View { return false } + private let capsuleHeight: CGFloat = 40 + var body: some View { NavigationView { ZStack { - LinearGradient( - gradient: Gradient(colors: [ - Color(UIColor.systemBackground), - Color(UIColor.secondarySystemBackground) - ]), - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - .ignoresSafeArea() + Color.clear.ignoresSafeArea() ScrollView { VStack(spacing: 20) { VStack(alignment: .leading, spacing: 14) { Text("Enter a Process ID").font(.headline).foregroundColor(.primary) - // TextField + Clear control TextField("e.g. 1234", text: $pidText) .keyboardType(.numberPad) .textContentType(.oneTimeCode) @@ -604,7 +734,7 @@ private struct ConnectByPIDSheet: View { // Paste + Clear row HStack(spacing: 10) { - CapsuleButton(systemName: "doc.on.clipboard", title: "Paste") { + CapsuleButton(systemName: "doc.on.clipboard", title: "Paste", height: capsuleHeight) { if let n = UIPasteboard.general.string?.trimmingCharacters(in: .whitespacesAndNewlines), let v = Int(n), v > 0 { pidText = String(v) @@ -616,7 +746,7 @@ private struct ConnectByPIDSheet: View { } } - CapsuleButton(systemName: "xmark", title: "Clear") { + CapsuleButton(systemName: "xmark", title: "Clear", height: capsuleHeight) { pidText = "" errorText = nil } @@ -677,8 +807,8 @@ private struct ConnectByPIDSheet: View { } .frame(maxWidth: .infinity) .padding(.vertical, 14) - .background((isValid ? Color.white : Color.white.opacity(0.6)), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .foregroundColor(.black) + .background(Color.accentColor, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .foregroundColor(Color.accentColor.contrastText()) .overlay( RoundedRectangle(cornerRadius: 12, style: .continuous) .stroke(Color.white.opacity(0.2), lineWidth: 0.5) @@ -743,28 +873,36 @@ private struct ConnectByPIDSheet: View { } } - @ViewBuilder private func CapsuleButton(systemName: String, title: String, action: @escaping () -> Void) -> some View { + @ViewBuilder private func CapsuleButton(systemName: String, title: String, height: CGFloat = 40, action: @escaping () -> Void) -> some View { Button(action: action) { HStack(spacing: 6) { Image(systemName: systemName) Text(title).font(.subheadline.weight(.semibold)) } - .padding(.vertical, 8) + .frame(height: height) // enforce uniform height .padding(.horizontal, 12) .background(Capsule(style: .continuous).fill(Color(UIColor.tertiarySystemBackground))) } - } -} - -// MARK: - InstalledAppsViewModel - -class InstalledAppsViewModel: ObservableObject { - @Published var apps: [String: String] = [:] - init() { loadApps() } - func loadApps() { - do { self.apps = try JITEnableContext.shared.getAppList() } - catch { print(error); self.apps = [:] } + .buttonStyle(.plain) + .contentShape(Rectangle()) } } #Preview { HomeView() } + +// MARK: - TXM detection + +public extension ProcessInfo { + var hasTXM: Bool { + { + if let boot = FileManager.default.filePath(atPath: "/System/Volumes/Preboot", withLength: 36), + let file = FileManager.default.filePath(atPath: "\(boot)/boot", withLength: 96) { + return access("\(file)/usr/standalone/firmware/FUD/Ap,TrustedExecutionMonitor.img4", F_OK) == 0 + } else { + return (FileManager.default.filePath(atPath: "/private/preboot", withLength: 96).map { + access("\($0)/usr/standalone/firmware/FUD/Ap,TrustedExecutionMonitor.img4", F_OK) == 0 + }) ?? false + } + }() + } +} diff --git a/StikJIT/Views/InstalledAppsListView.swift b/StikJIT/Views/InstalledAppsListView.swift index 34e4f78..9bc2218 100644 --- a/StikJIT/Views/InstalledAppsListView.swift +++ b/StikJIT/Views/InstalledAppsListView.swift @@ -41,7 +41,7 @@ struct InstalledAppsListView: View { var body: some View { NavigationStack { ZStack { - BackgroundGradient().ignoresSafeArea() + Color.clear.ignoresSafeArea() if viewModel.apps.isEmpty { emptyState @@ -570,3 +570,12 @@ extension Array: @retroactive RawRepresentable where Element: Codable { InstalledAppsListView { _ in } .environment(\.colorScheme, .dark) } + +class InstalledAppsViewModel: ObservableObject { + @Published var apps: [String: String] = [:] + init() { loadApps() } + func loadApps() { + do { self.apps = try JITEnableContext.shared.getAppList() } + catch { print(error); self.apps = [:] } + } +} diff --git a/StikJIT/Views/MainTabView.swift b/StikJIT/Views/MainTabView.swift index b877421..1041d31 100644 --- a/StikJIT/Views/MainTabView.swift +++ b/StikJIT/Views/MainTabView.swift @@ -21,6 +21,9 @@ struct MainTabView: View { var body: some View { ZStack { + // Allow global themed background to show + Color.clear.ignoresSafeArea() + // Main tabs TabView(selection: $selection) { HomeView() @@ -39,22 +42,16 @@ struct MainTabView: View { .tabItem { Label("Settings", systemImage: "gearshape.fill") } .tag(4) } + .id(customAccentColorHex) + .tint(accentColor) + .preferredColorScheme(.dark) .onAppear { checkForUpdate() } - // Force update overlay if showForceUpdate { ZStack { - LinearGradient( - gradient: Gradient(colors: [ - Color(UIColor.systemBackground), - Color(UIColor.secondarySystemBackground) - ]), - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - .ignoresSafeArea() + Color.black.opacity(0.001).ignoresSafeArea() VStack(spacing: 20) { Text("Update Required") diff --git a/StikJIT/Views/SettingsView.swift b/StikJIT/Views/SettingsView.swift index cdacf9b..ab0f51b 100644 --- a/StikJIT/Views/SettingsView.swift +++ b/StikJIT/Views/SettingsView.swift @@ -15,7 +15,10 @@ struct SettingsView: View { @AppStorage("enableAdvancedBetaOptions") private var enableAdvancedBetaOptions = false @AppStorage("enableTesting") private var enableTesting = false @AppStorage("enablePiP") private var enablePiP = false - + @AppStorage("customAccentColor") private var customAccentColorHex: String = "" + @AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue + private var currentTheme: AppTheme { AppTheme(rawValue: appThemeRaw) ?? .system } + @State private var isShowingPairingFilePicker = false @Environment(\.colorScheme) private var colorScheme @@ -27,10 +30,6 @@ struct SettingsView: View { @State private var is_lc = false @State private var showColorPickerPopup = false - @StateObject private var mountProg = MountingProgress.shared - - @State private var mounted = false - @State private var showingConsoleLogsView = false @State private var showingDisplayView = false @@ -38,7 +37,11 @@ struct SettingsView: View { let marketingVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" return marketingVersion } - + + private var accentColor: Color { + if customAccentColorHex.isEmpty { return .white } + return Color(hex: customAccentColorHex) ?? .white + } // Developer profile image URLs private let developerProfiles: [String: String] = [ "Stephen": "https://github.com/StephenDev0.png", @@ -54,22 +57,14 @@ struct SettingsView: View { NavigationStack { ZStack { // Subtle depth gradient background - LinearGradient( - gradient: Gradient(colors: [ - Color(UIColor.systemBackground), - Color(UIColor.secondarySystemBackground) - ]), - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - .ignoresSafeArea() + ThemedBackground(style: currentTheme.backgroundStyle) + .ignoresSafeArea() ScrollView { VStack(spacing: 20) { headerCard appearanceCard pairingCard - ddiCard behaviorCard advancedCard helpCard @@ -209,7 +204,7 @@ struct SettingsView: View { // MARK: - Cards private var headerCard: some View { - materialCard { + glassCard { VStack(spacing: 16) { VStack { Image("StikJIT") @@ -232,7 +227,7 @@ struct SettingsView: View { } private var appearanceCard: some View { - materialCard { + glassCard { VStack(alignment: .leading, spacing: 14) { Text("Appearance") .font(.headline) @@ -262,7 +257,7 @@ struct SettingsView: View { } private var pairingCard: some View { - materialCard { + glassCard { VStack(alignment: .leading, spacing: 14) { Text("Pairing File") .font(.headline) @@ -279,8 +274,12 @@ struct SettingsView: View { } .frame(maxWidth: .infinity) .padding(.vertical, 14) - .foregroundColor(.black) - .background(Color.white, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .foregroundColor(accentColor.contrastText()) + .background(accentColor, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.white.opacity(0.2), lineWidth: 0.5) + ) } if showPairingFileMessage && pairingFileIsValid && !isImportingFile { @@ -298,78 +297,17 @@ struct SettingsView: View { } } - 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 { + glassCard { VStack(alignment: .leading, spacing: 16) { Text("Behavior") .font(.headline) .foregroundColor(.primary) Toggle("Run Default Script After Connecting", isOn: $useDefaultScript) - .foregroundColor(.primary) + .tint(accentColor) Toggle("Picture in Picture", isOn: $enablePiP) - .foregroundColor(.primary) + .tint(accentColor) } .onChange(of: enableAdvancedOptions) { _, newValue in if !newValue { @@ -386,46 +324,9 @@ struct SettingsView: View { } } } - - 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 { + glassCard { VStack(alignment: .leading, spacing: 14) { Text("Advanced") .font(.headline) @@ -469,7 +370,7 @@ struct SettingsView: View { } private var helpCard: some View { - materialCard { + glassCard { VStack(alignment: .leading, spacing: 14) { Text("Help") .font(.headline) @@ -520,9 +421,10 @@ struct SettingsView: View { } private var versionInfo: some View { - HStack { + let txmLabel = ProcessInfo.processInfo.hasTXM ? "TXM" : "Non TXM" + return HStack { Spacer() - Text("Version \(appVersion) • iOS \(UIDevice.current.systemVersion)") + Text("Version \(appVersion) • iOS \(UIDevice.current.systemVersion) • \(txmLabel)") .font(.footnote) .foregroundColor(.secondary) Spacer() @@ -532,7 +434,7 @@ struct SettingsView: View { // MARK: - Helpers (UI + logic) - private func materialCard(@ViewBuilder content: () -> Content) -> some View { + private func glassCard(@ViewBuilder content: () -> Content) -> some View { content() .padding(20) .background( @@ -546,29 +448,7 @@ struct SettingsView: View { .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) { selectedAppIcon = iconName UIApplication.shared.setAlternateIconName(iconName == "AppIcon" ? nil : iconName) { error in @@ -673,115 +553,6 @@ struct LinkRow: View { } } -struct CollaboratorGridItem: View { - var name: String - var url: String - var imageUrl: String - - var body: some View { - Button(action: { - if let url = URL(string: url) { - UIApplication.shared.open(url) - } - }) { - VStack(spacing: 8) { - ProfileImage(url: imageUrl) - .frame(width: 50, height: 50) - Text(name) - .foregroundColor(.primary) - .fontWeight(.medium) - .font(.subheadline) - } - .frame(minWidth: 80) - .padding(.vertical, 8) - .padding(.horizontal, 12) - .background(Color(UIColor.tertiarySystemBackground)) - .cornerRadius(12) - } - .preferredColorScheme(.dark) - } -} - -struct ProfileImage: View { - var url: String - @State private var image: UIImage? - - var body: some View { - Group { - if let image = image { - Image(uiImage: image) - .resizable() - .aspectRatio(contentMode: .fill) - .clipShape(Circle()) - .overlay( - Circle() - .stroke(Color.gray.opacity(0.2), lineWidth: 1) - ) - } else { - Circle() - .fill(Color(UIColor.systemGray4)) - .overlay( - Image(systemName: "person.fill") - .font(.system(size: 16)) - .foregroundColor(.white) - ) - .onAppear { - loadImage() - } - } - } - } - - private func loadImage() { - guard let imageUrl = URL(string: url) else { return } - URLSession.shared.dataTask(with: imageUrl) { data, response, error in - if let data = data, let downloadedImage = UIImage(data: data) { - DispatchQueue.main.async { - self.image = downloadedImage - } - } - }.resume() - } -} - -struct CollaboratorRow: View { - var name: String - var url: String - var imageUrl: String - var quote: String? - - var body: some View { - Button(action: { - if let url = URL(string: url) { - UIApplication.shared.open(url) - } - }) { - HStack(spacing: 12) { - ProfileImage(url: imageUrl) - .frame(width: 40, height: 40) - VStack(alignment: .leading, spacing: 2) { - Text(name) - .foregroundColor(.primary) - .fontWeight(.medium) - if let quote = quote { - Text("“\(quote)”") - .font(.subheadline) - .foregroundColor(.secondary) - .lineLimit(1) - .truncationMode(.tail) - } - } - Spacer() - Image(systemName: "link") - .font(.system(size: 16)) - .foregroundColor(.white) - } - .padding(.vertical, 8) - } - .preferredColorScheme(.dark) - } -} - struct ConsoleLogsView_Preview: PreviewProvider { static var previews: some View { ConsoleLogsView() @@ -805,4 +576,3 @@ class FolderViewController: UIViewController { } } } - diff --git a/StikJIT/Views/SharedStubs.swift b/StikJIT/Views/SharedStubs.swift new file mode 100644 index 0000000..45f6019 --- /dev/null +++ b/StikJIT/Views/SharedStubs.swift @@ -0,0 +1,32 @@ +// +// SharedStubs.swift +// StikJIT +// +// Created by Stephen on 09/12/2025. +// + +import SwiftUI +import UniformTypeIdentifiers +import UIKit + +// MARK: - Shared glass card wrapper (renamed to avoid conflicts) + +@ViewBuilder +func appGlassCard(@ViewBuilder _ content: () -> Content) -> some View { + MaterialCard { + content() + } +} + +// MARK: - Open App Folder helper + +func openAppFolder() { + let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + let controller = UIActivityViewController(activityItems: [docs], applicationActivities: nil) + controller.excludedActivityTypes = [.assignToContact, .saveToCameraRoll, .postToFacebook, .postToTwitter] + DispatchQueue.main.async { + guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let root = scene.windows.first?.rootViewController else { return } + root.present(controller, animated: true) + } +} diff --git a/StikJIT/melo.js b/StikJIT/melo.js new file mode 100644 index 0000000..0fa8050 --- /dev/null +++ b/StikJIT/melo.js @@ -0,0 +1,107 @@ +function littleEndianHexStringToNumber(hexStr) { + const bytes = []; + for (let i = 0; i < hexStr.length; i += 2) { + bytes.push(parseInt(hexStr.substr(i, 2), 16)); + } + let num = 0n; + for (let i = 4; i >= 0; i--) { + num = (num << 8n) | BigInt(bytes[i]); + } + return num; +} + +function numberToLittleEndianHexString(num) { + const bytes = []; + for (let i = 0; i < 5; i++) { + bytes.push(Number(num & 0xFFn)); + num >>= 8n; + } + while (bytes.length < 8) { + bytes.push(0); + } + return bytes.map(b => b.toString(16).padStart(2, '0')).join(''); +} + +function littleEndianHexToU32(hexStr) { + return parseInt(hexStr.match(/../g).reverse().join(''), 16); +} + +function extractBrkImmediate(u32) { + return (u32 >> 5) & 0xFFFF; +} + +function attach(breakpointcount) { + let pid = get_pid(); + log(`pid = ${pid}`); + let attachResponse = send_command(`vAttach;${pid.toString(16)}`); + log(`attach_response = ${attachResponse}`); + + let validBreakpoints = 0; + let totalBreakpoints = 0; + + while (validBreakpoints < breakpointcount) { + totalBreakpoints++; + log(`Handling breakpoint ${totalBreakpoints} (looking for valid breakpoint ${validBreakpoints + 1}/${breakpointcount})`); + + let brkResponse = send_command(`c`); + log(`brkResponse = ${brkResponse}`); + + let tidMatch = /T[0-9a-f]+thread:(?[0-9a-f]+);/.exec(brkResponse); + let tid = tidMatch ? tidMatch.groups['tid'] : null; + let pcMatch = /20:(?[0-9a-f]{16});/.exec(brkResponse); + let pc = pcMatch ? pcMatch.groups['reg'] : null; + let x0Match = /00:(?[0-9a-f]{16});/.exec(brkResponse); + let x0 = x0Match ? x0Match.groups['reg'] : null; + + if (!tid || !pc || !x0) { + log(`Failed to extract registers: tid=${tid}, pc=${pc}, x0=${x0}`); + continue; + } + + const pcNum = littleEndianHexStringToNumber(pc); + const x0Num = littleEndianHexStringToNumber(x0); + log(`tid = ${tid}, pc = ${pcNum.toString(16)}, x0 = ${x0Num.toString(16)}`); + + let instructionResponse = send_command(`m${pcNum.toString(16)},4`); + log(`instruction at pc: ${instructionResponse}`); + let instrU32 = littleEndianHexToU32(instructionResponse); + let brkImmediate = extractBrkImmediate(instrU32); + log(`BRK immediate: 0x${brkImmediate.toString(16)} (${brkImmediate})`); + + if (brkImmediate !== 0x69) { + log(`Skipping breakpoint: brk immediate was not 0x69 (was 0x${brkImmediate.toString(16)})`); + continue; + } + + log(`BRK immediate matches expected value 0x69 - processing valid breakpoint ${validBreakpoints + 1}/${breakpointcount}`); + + let requestRXResponse = send_command(`_M${x0Num.toString(16)},rx`); + log(`requestRXResponse = ${requestRXResponse}`); + + if (!requestRXResponse || requestRXResponse.length === 0) { + log(`Failed to allocate RX memory`); + continue; + } + + let jitPageAddress = BigInt(`0x${requestRXResponse}`); + log(`Allocated JIT page at address: 0x${jitPageAddress.toString(16)}`); + + let prepareJITPageResponse = prepare_memory_region(jitPageAddress, x0Num); + log(`prepareJITPageResponse = ${prepareJITPageResponse}`); + + let putX0Response = send_command(`P0=${numberToLittleEndianHexString(jitPageAddress)};thread:${tid};`); + log(`putX0Response = ${putX0Response}`); + + let pcPlus4 = numberToLittleEndianHexString(pcNum + 4n); + let pcPlus4Response = send_command(`P20=${pcPlus4};thread:${tid};`); + log(`pcPlus4Response = ${pcPlus4Response}`); + + validBreakpoints++; + log(`Completed valid breakpoint ${validBreakpoints}/${breakpointcount} - returning address 0x${jitPageAddress.toString(16)}`); + } + + let detachResponse = send_command(`D`); + log(`detachResponse = ${detachResponse}`); +} + +attach(3); // MeloNX uses 3 breakpoints, adjust as needed. \ No newline at end of file diff --git a/StikJIT/script1.js b/StikJIT/script1.js new file mode 100644 index 0000000..4340adb --- /dev/null +++ b/StikJIT/script1.js @@ -0,0 +1,102 @@ +function littleEndianHexStringToNumber(hexStr) { + const bytes = []; + for (let i = 0; i < hexStr.length; i += 2) { + bytes.push(parseInt(hexStr.substr(i, 2), 16)); + } + let num = 0n; + for (let i = 4; i >= 0; i--) { + num = (num << 8n) | BigInt(bytes[i]); + } + return num; +} + +function numberToLittleEndianHexString(num) { + const bytes = []; + for (let i = 0; i < 5; i++) { + bytes.push(Number(num & 0xFFn)); + num >>= 8n; + } + while (bytes.length < 8) { + bytes.push(0); + } + return bytes.map(b => b.toString(16).padStart(2, '0')).join(''); +} + +function littleEndianHexToU32(hexStr) { + return parseInt(hexStr.match(/../g).reverse().join(''), 16); +} + +function extractBrkImmediate(u32) { + return (u32 >> 5) & 0xFFFF; +} + +function attach(breakpointcount) { + let pid = get_pid(); + log(`pid = ${pid}`); + let attachResponse = send_command(`vAttach;${pid.toString(16)}`); + log(`attach_response = ${attachResponse}`); + + let validBreakpoints = 0; + let totalBreakpoints = 0; + + while (validBreakpoints < breakpointcount) { + totalBreakpoints++; + log(`Handling breakpoint ${totalBreakpoints} (looking for valid breakpoint ${validBreakpoints + 1}/${breakpointcount})`); + + let brkResponse = send_command(`c`); + log(`brkResponse = ${brkResponse}`); + + let tidMatch = /T[0-9a-f]+thread:(?[0-9a-f]+);/.exec(brkResponse); + let tid = tidMatch ? tidMatch.groups['tid'] : null; + let pcMatch = /20:(?[0-9a-f]{16});/.exec(brkResponse); + let pc = pcMatch ? pcMatch.groups['reg'] : null; + let x0Match = /00:(?[0-9a-f]{16});/.exec(brkResponse); + let x0 = x0Match ? x0Match.groups['reg'] : null; + let x1Match = /01:(?[0-9a-f]{16});/.exec(brkResponse); + let x1 = x1Match ? x1Match.groups['reg'] : null; + + if (!tid || !pc || !x0) { + log(`Failed to extract registers: tid=${tid}, pc=${pc}, x0=${x0}, x1=${x1}`); + continue; + } + + const pcNum = littleEndianHexStringToNumber(pc); + const x0Num = littleEndianHexStringToNumber(x0); + const x1Num = littleEndianHexStringToNumber(x1); + log(`tid = ${tid}, pc = ${pcNum.toString(16)}, x0 = ${x0Num.toString(16)}, x1 = ${x1Num.toString(16)}`); + + let instructionResponse = send_command(`m${pcNum.toString(16)},4`); + log(`instruction at pc: ${instructionResponse}`); + let instrU32 = littleEndianHexToU32(instructionResponse); + let brkImmediate = extractBrkImmediate(instrU32); + log(`BRK immediate: 0x${brkImmediate.toString(16)} (${brkImmediate})`); + + if (brkImmediate !== 0x69) { + log(`Skipping breakpoint: brk immediate was not 0x69 (was 0x${brkImmediate.toString(16)})`); + continue; + } + + log(`BRK immediate matches expected value 0x69 - processing valid breakpoint ${validBreakpoints + 1}/${breakpointcount}`); + + let jitPageAddress = x0Num + log(`Got rx address: 0x${jitPageAddress.toString(16)}`); + + let prepareJITPageResponse = prepare_memory_region(jitPageAddress, x1Num); + log(`prepareJITPageResponse = ${prepareJITPageResponse}`); + + let putX0Response = send_command(`P0=${numberToLittleEndianHexString(jitPageAddress)};thread:${tid};`); + log(`putX0Response = ${putX0Response}`); + + let pcPlus4 = numberToLittleEndianHexString(pcNum + 4n); + let pcPlus4Response = send_command(`P20=${pcPlus4};thread:${tid};`); + log(`pcPlus4Response = ${pcPlus4Response}`); + + validBreakpoints++; + log(`Completed valid breakpoint ${validBreakpoints}/${breakpointcount} - returning address 0x${jitPageAddress.toString(16)}`); + } + + let detachResponse = send_command(`D`); + log(`detachResponse = ${detachResponse}`); +} + +attach(3); // MeloNX uses 3 breakpoints, adjust as needed. diff --git a/StikJIT/script2.js b/StikJIT/script2.js new file mode 100644 index 0000000..b90c0c5 --- /dev/null +++ b/StikJIT/script2.js @@ -0,0 +1,89 @@ + +function littleEndianHexStringToNumber(hexStr) { + // Convert hex string to byte array + const bytes = []; + for (let i = 0; i < hexStr.length; i += 2) { + bytes.push(parseInt(hexStr.substr(i, 2), 16)); + } + + // Assemble number from first 5 bytes (little-endian) + let num = 0n; + for (let i = 4; i >= 0; i--) { + num = (num << 8n) | BigInt(bytes[i]); + } + + return num; // BigInt: 0x01016644fc +} + +function numberToLittleEndianHexString(num) { + // Extract 5 bytes in little-endian order + const bytes = []; + for (let i = 0; i < 5; i++) { + bytes.push(Number(num & 0xFFn)); + num >>= 8n; + } + + // Pad with 3 zero bytes to make 8 bytes + while (bytes.length < 8) { + bytes.push(0); + } + + // Convert to hex string + return bytes.map(b => b.toString(16).padStart(2, '0')).join(''); +} + +function attach() { + let pid = get_pid(); + log(`pid = ${pid}`) + + // attach + let attachResponse = send_command(`vAttach;${pid.toString(16)}`) + log(`attach_response = ${attachResponse}`) + + // wait for brk + let brkResponse = send_command(`c`) + log(`brkResponse = ${brkResponse}`) + + let tid = /T[0-9a-f]+thread:(?[0-9a-f]+);/.exec(brkResponse).groups['tid'] + let pc = /20:(?[0-9a-f]{16});/.exec(brkResponse).groups['reg'] + let x0 = /00:(?[0-9a-f]{16});/.exec(brkResponse).groups['reg'] + + log(`tid = ${tid}, pc = ${littleEndianHexStringToNumber(pc).toString(16)}, x0 = ${littleEndianHexStringToNumber(x0).toString(16)}`) + + pc = numberToLittleEndianHexString(littleEndianHexStringToNumber(pc) + BigInt(4)) + let x0Num = littleEndianHexStringToNumber(x0) + + // pc+4 + let pcPlus4Response = send_command(`P20=${pc};thread:${tid};`) + log(`pcPlus4Response = ${pcPlus4Response}`) + + // apply for jit page of requested size + let requestRXResponse = send_command(`_M${x0Num.toString(16)},rx`) + log(`requestRXResponse = ${requestRXResponse}`) + let jitPageAddress = BigInt(`0x${requestRXResponse}`) + + // TODO fill the page + let a = 0; + try { + for(let i = 0n; i < x0Num; i += 16384n) { + let curPointer = jitPageAddress + i; + let response = send_command(`M${curPointer.toString(16)},1:69`) + if(a % 1000 == 0) { + log(`progress: ${a}/${Math.ceil(Number(x0Num / BigInt(16384)))}, response = ${response}`) + } + ++a; + } + log(`memory write completed. command executed: ${a}`) + }catch(e) { + log(`memory write failed at command ${a}`) + } + + // put page address in x0 + let putX0Response = send_command(`P0=${numberToLittleEndianHexString(jitPageAddress)};thread:${tid};`) + log(`putX0Response = ${putX0Response}`) + // detach + let detachResponse = send_command(`D`) + log(`detachResponse = ${detachResponse}`) +} + +attach() \ No newline at end of file diff --git a/StikJIT/utmjit.js b/StikJIT/utmjit.js new file mode 100644 index 0000000..090544c --- /dev/null +++ b/StikJIT/utmjit.js @@ -0,0 +1,98 @@ +function littleEndianHexStringToNumber(hexStr) { + const bytes = []; + for (let i = 0; i < hexStr.length; i += 2) { + bytes.push(parseInt(hexStr.substr(i, 2), 16)); + } + let num = 0n; + for (let i = 4; i >= 0; i--) { + num = (num << 8n) | BigInt(bytes[i]); + } + return num; +} + +function numberToLittleEndianHexString(num) { + const bytes = []; + for (let i = 0; i < 5; i++) { + bytes.push(Number(num & 0xFFn)); + num >>= 8n; + } + while (bytes.length < 8) { + bytes.push(0); + } + return bytes.map(b => b.toString(16).padStart(2, '0')).join(''); +} + +function littleEndianHexToU32(hexStr) { + return parseInt(hexStr.match(/../g).reverse().join(''), 16); +} + +function extractBrkImmediate(u32) { + return (u32 >> 5) & 0xFFFF; +} + +function attach(breakpointcount) { + let pid = get_pid(); + log(`pid = ${pid}`); + let attachResponse = send_command(`vAttach;${pid.toString(16)}`); + log(`attach_response = ${attachResponse}`); + + let validBreakpoints = 0; + let totalBreakpoints = 0; + + while (validBreakpoints < breakpointcount) { + totalBreakpoints++; + log(`Handling breakpoint ${totalBreakpoints} (looking for valid breakpoint ${validBreakpoints + 1}/${breakpointcount})`); + + let brkResponse = send_command(`c`); + log(`brkResponse = ${brkResponse}`); + + let tidMatch = /T[0-9a-f]+thread:(?[0-9a-f]+);/.exec(brkResponse); + let tid = tidMatch ? tidMatch.groups['tid'] : null; + let pcMatch = /20:(?[0-9a-f]{16});/.exec(brkResponse); + let pc = pcMatch ? pcMatch.groups['reg'] : null; + let x0Match = /00:(?[0-9a-f]{16});/.exec(brkResponse); + let x0 = x0Match ? x0Match.groups['reg'] : null; + let x1Match = /01:(?[0-9a-f]{16});/.exec(brkResponse); + let x1 = x1Match ? x1Match.groups['reg'] : null; + + if (!tid || !pc || !x0 || !x1) { + log(`Failed to extract registers: tid=${tid}, pc=${pc}, x0=${x0}, x1=${x1}`); + continue; + } + + const pcNum = littleEndianHexStringToNumber(pc); + const x0Num = littleEndianHexStringToNumber(x0); + const x1Num = littleEndianHexStringToNumber(x1); + log(`tid = ${tid}, pc = ${pcNum.toString(16)}, x0 = ${x0Num.toString(16)}, x1 = ${x1Num.toString(16)}`); + + let instructionResponse = send_command(`m${pcNum.toString(16)},4`); + log(`instruction at pc: ${instructionResponse}`); + let instrU32 = littleEndianHexToU32(instructionResponse); + let brkImmediate = extractBrkImmediate(instrU32); + log(`BRK immediate: 0x${brkImmediate.toString(16)} (${brkImmediate})`); + + if (brkImmediate !== 0x69) { + log(`Skipping breakpoint: brk immediate was not 0x69 (was 0x${brkImmediate.toString(16)})`); + continue; + } + + log(`BRK immediate matches expected value 0x69 - processing valid breakpoint ${validBreakpoints + 1}/${breakpointcount}`); + + log(`Allocated JIT page at address: 0x${x0Num.toString(16)}`); + + let prepareJITPageResponse = prepare_memory_region(x0Num, x1Num); + log(`prepareJITPageResponse = ${prepareJITPageResponse}`); + + let pcPlus4 = numberToLittleEndianHexString(pcNum + 4n); + let pcPlus4Response = send_command(`P20=${pcPlus4};thread:${tid};`); + log(`pcPlus4Response = ${pcPlus4Response}`); + + validBreakpoints++; + log(`Completed valid breakpoint ${validBreakpoints}/${breakpointcount}`); + } + + let detachResponse = send_command(`D`); + log(`detachResponse = ${detachResponse}`); +} + +attach(1); \ No newline at end of file