mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-15 20:10:30 +00:00
2.2.5 (Sorry - A little messy)
This commit is contained in:
parent
c5bf809472
commit
0424339135
19 changed files with 1658 additions and 1302 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<addrinfo>?
|
||||
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<T> {
|
|||
|
||||
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("")
|
||||
}
|
||||
|
||||
|
|
|
|||
283
StikJIT/Utilities/AppTheme.swift
Normal file
283
StikJIT/Utilities/AppTheme.swift
Normal file
|
|
@ -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<Content: View>: 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..<blobs {
|
||||
let p = CGFloat(i) / CGFloat(blobs)
|
||||
let angle = t * (0.5 + 0.1 * CGFloat(i)) + p * .pi * 2
|
||||
let r = radius * (0.4 + 0.2 * sin(t + CGFloat(i)))
|
||||
|
||||
let x = size.width * 0.5 + cos(angle) * r
|
||||
let y = size.height * 0.5 + sin(angle * 0.9) * r * 0.7
|
||||
|
||||
var path = Path()
|
||||
path.addEllipse(in: CGRect(x: x - 140, y: y - 140, width: 280, height: 280))
|
||||
|
||||
ctx.addFilter(.blur(radius: 40))
|
||||
ctx.fill(path, with: .color(baseColors[i % baseColors.count]))
|
||||
}
|
||||
}
|
||||
.background(staticBackgroundGradient())
|
||||
.ignoresSafeArea()
|
||||
.onAppear {
|
||||
withAnimation(.linear(duration: 12).repeatForever(autoreverses: false)) {
|
||||
t = .pi * 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ParticleFieldBackground: View {
|
||||
struct Particle: Identifiable {
|
||||
let id = UUID()
|
||||
var position: CGPoint
|
||||
var velocity: CGVector
|
||||
var size: CGFloat
|
||||
var opacity: Double
|
||||
}
|
||||
|
||||
@State private var particles: [Particle] = []
|
||||
private let count = 120
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
TimelineView(.animation) { _ in
|
||||
ZStack {
|
||||
staticBackgroundGradient()
|
||||
.ignoresSafeArea()
|
||||
|
||||
Canvas { ctx, size in
|
||||
for p in particles {
|
||||
var circle = Path(ellipseIn: CGRect(x: p.position.x,
|
||||
y: p.position.y,
|
||||
width: p.size,
|
||||
height: p.size))
|
||||
ctx.addFilter(.blur(radius: 1.2))
|
||||
ctx.opacity = p.opacity
|
||||
ctx.fill(circle, with: .color(.white.opacity(0.85)))
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if particles.isEmpty {
|
||||
particles = (0..<count).map { _ in
|
||||
Particle(
|
||||
position: CGPoint(x: .random(in: 0...geo.size.width),
|
||||
y: .random(in: 0...geo.size.height)),
|
||||
velocity: CGVector(dx: .random(in: -0.3...0.3),
|
||||
dy: .random(in: -0.3...0.3)),
|
||||
size: .random(in: 1.5...3.5),
|
||||
opacity: .random(in: 0.15...0.45)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: geo.size) { _, newSize in
|
||||
particles = particles.map { p in
|
||||
var np = p
|
||||
np.position.x = min(max(0, np.position.x), newSize.width)
|
||||
np.position.y = min(max(0, np.position.y), newSize.height)
|
||||
return np
|
||||
}
|
||||
}
|
||||
.task {
|
||||
while true {
|
||||
try? await Task.sleep(nanoseconds: 16_000_000)
|
||||
var next = particles
|
||||
for i in next.indices {
|
||||
var p = next[i]
|
||||
p.position.x += p.velocity.dx
|
||||
p.position.y += p.velocity.dy
|
||||
|
||||
if p.position.x < 0 { p.position.x = geo.size.width }
|
||||
if p.position.x > 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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..<lines.count])
|
||||
|
||||
lastProcessedLineCount = lines.count
|
||||
|
||||
await MainActor.run {
|
||||
|
|
@ -594,11 +341,12 @@ struct ConsoleLogsView: View {
|
|||
|
||||
for line in recentLines {
|
||||
if line.isEmpty { continue }
|
||||
|
||||
if line.contains("=== DEVICE INFORMATION ===") ||
|
||||
line.contains("Version:") ||
|
||||
line.contains("Name:") ||
|
||||
line.contains("Model:") ||
|
||||
line.contains("=== LOG ENTRIES ===") {
|
||||
line.contains("Version:") ||
|
||||
line.contains("Name:") ||
|
||||
line.contains("Model:") ||
|
||||
line.contains("=== LOG ENTRIES ===") {
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -626,8 +374,10 @@ struct ConsoleLogsView: View {
|
|||
|
||||
private func startLogCheckTimer() {
|
||||
logCheckTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { _ in
|
||||
if isViewActive && !paused {
|
||||
Task { await checkForNewLogs() }
|
||||
if isViewActive {
|
||||
Task {
|
||||
await checkForNewLogs()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -654,6 +404,7 @@ struct ConsoleLogsView: View {
|
|||
await MainActor.run {
|
||||
for line in newLines {
|
||||
if line.isEmpty { continue }
|
||||
|
||||
if line.contains("ERROR") || line.contains("Error") {
|
||||
logManager.addErrorLog(line)
|
||||
} else if line.contains("WARNING") || line.contains("Warning") {
|
||||
|
|
@ -665,7 +416,6 @@ struct ConsoleLogsView: View {
|
|||
}
|
||||
}
|
||||
|
||||
// Keep only the last 500 lines
|
||||
let maxLines = 500
|
||||
if logManager.logs.count > 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) { }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = [:] }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
||||
private func glassCard<Content: View>(@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 {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
32
StikJIT/Views/SharedStubs.swift
Normal file
32
StikJIT/Views/SharedStubs.swift
Normal file
|
|
@ -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<Content: View>(@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)
|
||||
}
|
||||
}
|
||||
107
StikJIT/melo.js
Normal file
107
StikJIT/melo.js
Normal file
|
|
@ -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:(?<tid>[0-9a-f]+);/.exec(brkResponse);
|
||||
let tid = tidMatch ? tidMatch.groups['tid'] : null;
|
||||
let pcMatch = /20:(?<reg>[0-9a-f]{16});/.exec(brkResponse);
|
||||
let pc = pcMatch ? pcMatch.groups['reg'] : null;
|
||||
let x0Match = /00:(?<reg>[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.
|
||||
102
StikJIT/script1.js
Normal file
102
StikJIT/script1.js
Normal file
|
|
@ -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:(?<tid>[0-9a-f]+);/.exec(brkResponse);
|
||||
let tid = tidMatch ? tidMatch.groups['tid'] : null;
|
||||
let pcMatch = /20:(?<reg>[0-9a-f]{16});/.exec(brkResponse);
|
||||
let pc = pcMatch ? pcMatch.groups['reg'] : null;
|
||||
let x0Match = /00:(?<reg>[0-9a-f]{16});/.exec(brkResponse);
|
||||
let x0 = x0Match ? x0Match.groups['reg'] : null;
|
||||
let x1Match = /01:(?<reg>[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.
|
||||
89
StikJIT/script2.js
Normal file
89
StikJIT/script2.js
Normal file
|
|
@ -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:(?<tid>[0-9a-f]+);/.exec(brkResponse).groups['tid']
|
||||
let pc = /20:(?<reg>[0-9a-f]{16});/.exec(brkResponse).groups['reg']
|
||||
let x0 = /00:(?<reg>[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()
|
||||
98
StikJIT/utmjit.js
Normal file
98
StikJIT/utmjit.js
Normal file
|
|
@ -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:(?<tid>[0-9a-f]+);/.exec(brkResponse);
|
||||
let tid = tidMatch ? tidMatch.groups['tid'] : null;
|
||||
let pcMatch = /20:(?<reg>[0-9a-f]{16});/.exec(brkResponse);
|
||||
let pc = pcMatch ? pcMatch.groups['reg'] : null;
|
||||
let x0Match = /00:(?<reg>[0-9a-f]{16});/.exec(brkResponse);
|
||||
let x0 = x0Match ? x0Match.groups['reg'] : null;
|
||||
let x1Match = /01:(?<reg>[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);
|
||||
Loading…
Add table
Add a link
Reference in a new issue