mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-15 20:10:30 +00:00
2.2.0 - Semi Broken
This commit is contained in:
parent
a40956bd47
commit
78c0ccb495
14 changed files with 3733 additions and 2465 deletions
|
|
@ -8,15 +8,15 @@
|
|||
|
||||
/* Begin PBXBuildFile section */
|
||||
17C744F02E20BED000834F17 /* Pipify in Frameworks */ = {isa = PBXBuildFile; productRef = 17C744EF2E20BED000834F17 /* Pipify */; };
|
||||
6874D2722E6B4A5E00B18184 /* ZSignApple in Frameworks */ = {isa = PBXBuildFile; productRef = 6874D2712E6B4A5E00B18184 /* ZSignApple */; };
|
||||
68D569BE2E1B415700A5BA36 /* CodeEditorView in Frameworks */ = {isa = PBXBuildFile; productRef = 68D569BD2E1B415700A5BA36 /* CodeEditorView */; };
|
||||
68D569C02E1B415700A5BA36 /* LanguageSupport in Frameworks */ = {isa = PBXBuildFile; productRef = 68D569BF2E1B415700A5BA36 /* LanguageSupport */; };
|
||||
68E714E62E6AA2B00025610F /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = 68E714E52E6AA2B00025610F /* ZIPFoundation */; };
|
||||
DC139F6E2DE97EA400F63846 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DC139F6D2DE97EA400F63846 /* WidgetKit.framework */; };
|
||||
DC139F702DE97EA400F63846 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DC139F6F2DE97EA400F63846 /* SwiftUI.framework */; };
|
||||
DC139F812DE97EA600F63846 /* DebugWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = DC139F6C2DE97EA400F63846 /* DebugWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
DC7E2F1C2E38D01E00725B2C /* ZSignApple in Frameworks */ = {isa = PBXBuildFile; productRef = DC7E2F1B2E38D01E00725B2C /* ZSignApple */; };
|
||||
DCA690122DAF660E007C91A8 /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DCA690112DAF660E007C91A8 /* NetworkExtension.framework */; };
|
||||
DCA6901A2DAF660E007C91A8 /* TunnelProv.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = DCA690102DAF660E007C91A8 /* TunnelProv.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
DCBA85192E388CD400E88C06 /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = DCBA85182E388CD400E88C06 /* ZIPFoundation */; };
|
||||
DCBA85862E3897BD00E88C06 /* StikImporter in Frameworks */ = {isa = PBXBuildFile; productRef = DCBA85852E3897BD00E88C06 /* StikImporter */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
|
|
@ -157,8 +157,8 @@
|
|||
17C744F02E20BED000834F17 /* Pipify in Frameworks */,
|
||||
68D569C02E1B415700A5BA36 /* LanguageSupport in Frameworks */,
|
||||
68D569BE2E1B415700A5BA36 /* CodeEditorView in Frameworks */,
|
||||
DCBA85192E388CD400E88C06 /* ZIPFoundation in Frameworks */,
|
||||
DC7E2F1C2E38D01E00725B2C /* ZSignApple in Frameworks */,
|
||||
68E714E62E6AA2B00025610F /* ZIPFoundation in Frameworks */,
|
||||
6874D2722E6B4A5E00B18184 /* ZSignApple in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
|
@ -271,9 +271,9 @@
|
|||
68D569BD2E1B415700A5BA36 /* CodeEditorView */,
|
||||
68D569BF2E1B415700A5BA36 /* LanguageSupport */,
|
||||
17C744EF2E20BED000834F17 /* Pipify */,
|
||||
DCBA85182E388CD400E88C06 /* ZIPFoundation */,
|
||||
DCBA85852E3897BD00E88C06 /* StikImporter */,
|
||||
DC7E2F1B2E38D01E00725B2C /* ZSignApple */,
|
||||
68E714E52E6AA2B00025610F /* ZIPFoundation */,
|
||||
6874D2712E6B4A5E00B18184 /* ZSignApple */,
|
||||
);
|
||||
productName = StikJIT;
|
||||
productReference = DC6F1D372D94EADD0071B2B6 /* StikDebug.app */;
|
||||
|
|
@ -390,9 +390,9 @@
|
|||
packageReferences = (
|
||||
68D569BC2E1B415700A5BA36 /* XCRemoteSwiftPackageReference "CodeEditorView" */,
|
||||
17C744EE2E20BED000834F17 /* XCRemoteSwiftPackageReference "swiftui-pipify" */,
|
||||
DCBA85172E388CD400E88C06 /* XCRemoteSwiftPackageReference "ZIPFoundation" */,
|
||||
DCBA85842E3897BD00E88C06 /* XCRemoteSwiftPackageReference "StikImporter" */,
|
||||
DC7E2F1A2E38D01E00725B2C /* XCRemoteSwiftPackageReference "zsign-ios" */,
|
||||
68CB05252E6AA15F0075BAA9 /* XCRemoteSwiftPackageReference "zsign-ios" */,
|
||||
68E714E42E6AA2B00025610F /* XCRemoteSwiftPackageReference "ZIPFoundation" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = DC6F1D382D94EADD0071B2B6 /* Products */;
|
||||
|
|
@ -722,7 +722,7 @@
|
|||
"$(PROJECT_DIR)/StikJIT/idevice",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.1;
|
||||
MARKETING_VERSION = 2.1.0;
|
||||
MARKETING_VERSION = 2.2.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.stik.sj;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = auto;
|
||||
|
|
@ -775,7 +775,7 @@
|
|||
"$(PROJECT_DIR)/StikJIT/idevice",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.1;
|
||||
MARKETING_VERSION = 2.1.0;
|
||||
MARKETING_VERSION = 2.2.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.stik.sj;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = auto;
|
||||
|
|
@ -1003,6 +1003,14 @@
|
|||
kind = branch;
|
||||
};
|
||||
};
|
||||
68CB05252E6AA15F0075BAA9 /* XCRemoteSwiftPackageReference "zsign-ios" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/zwsn/zsign-ios";
|
||||
requirement = {
|
||||
branch = main;
|
||||
kind = branch;
|
||||
};
|
||||
};
|
||||
68D569BC2E1B415700A5BA36 /* XCRemoteSwiftPackageReference "CodeEditorView" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/mchakravarty/CodeEditorView";
|
||||
|
|
@ -1011,15 +1019,7 @@
|
|||
minimumVersion = 0.15.4;
|
||||
};
|
||||
};
|
||||
DC7E2F1A2E38D01E00725B2C /* XCRemoteSwiftPackageReference "zsign-ios" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/StephenDev0/zsign-ios";
|
||||
requirement = {
|
||||
branch = main;
|
||||
kind = branch;
|
||||
};
|
||||
};
|
||||
DCBA85172E388CD400E88C06 /* XCRemoteSwiftPackageReference "ZIPFoundation" */ = {
|
||||
68E714E42E6AA2B00025610F /* XCRemoteSwiftPackageReference "ZIPFoundation" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/weichsel/ZIPFoundation";
|
||||
requirement = {
|
||||
|
|
@ -1043,6 +1043,10 @@
|
|||
package = 17C744EE2E20BED000834F17 /* XCRemoteSwiftPackageReference "swiftui-pipify" */;
|
||||
productName = Pipify;
|
||||
};
|
||||
6874D2712E6B4A5E00B18184 /* ZSignApple */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = ZSignApple;
|
||||
};
|
||||
68D569BD2E1B415700A5BA36 /* CodeEditorView */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 68D569BC2E1B415700A5BA36 /* XCRemoteSwiftPackageReference "CodeEditorView" */;
|
||||
|
|
@ -1053,14 +1057,9 @@
|
|||
package = 68D569BC2E1B415700A5BA36 /* XCRemoteSwiftPackageReference "CodeEditorView" */;
|
||||
productName = LanguageSupport;
|
||||
};
|
||||
DC7E2F1B2E38D01E00725B2C /* ZSignApple */ = {
|
||||
68E714E52E6AA2B00025610F /* ZIPFoundation */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = DC7E2F1A2E38D01E00725B2C /* XCRemoteSwiftPackageReference "zsign-ios" */;
|
||||
productName = ZSignApple;
|
||||
};
|
||||
DCBA85182E388CD400E88C06 /* ZIPFoundation */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = DCBA85172E388CD400E88C06 /* XCRemoteSwiftPackageReference "ZIPFoundation" */;
|
||||
package = 68E714E42E6AA2B00025610F /* XCRemoteSwiftPackageReference "ZIPFoundation" */;
|
||||
productName = ZIPFoundation;
|
||||
};
|
||||
DCBA85852E3897BD00E88C06 /* StikImporter */ = {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"originHash" : "a7d8c65e2fd86cffbcb641147283c4520fb198ba8311739d1d27b74ef46eee96",
|
||||
"originHash" : "fd6a0b1431f87fb1abbacc56899d6160059dfd4b59e9d381af1131363e73fb23",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "codeeditorview",
|
||||
|
|
@ -49,7 +49,7 @@
|
|||
{
|
||||
"identity" : "zsign-ios",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/StephenDev0/zsign-ios",
|
||||
"location" : "https://github.com/zwsn/zsign-ios",
|
||||
"state" : {
|
||||
"branch" : "main",
|
||||
"revision" : "a7f275fb4830ea48adf536fcddee7d05471970d0"
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
import UIKit
|
||||
|
||||
struct ScriptListView: View {
|
||||
@State private var scripts: [URL] = []
|
||||
|
|
@ -15,121 +16,256 @@ struct ScriptListView: View {
|
|||
@State private var showImporter = false
|
||||
@AppStorage("DefaultScriptName") private var defaultScriptName = "attachDetach.js"
|
||||
|
||||
// Overlays / feedback
|
||||
@State private var isBusy = false
|
||||
@State private var alertVisible = false
|
||||
@State private var alertTitle = ""
|
||||
@State private var alertMessage = ""
|
||||
@State private var alertIsSuccess = false
|
||||
@State private var justCopied = false
|
||||
|
||||
// Search
|
||||
@State private var searchText = ""
|
||||
|
||||
var onSelectScript: ((URL?) -> Void)? = nil
|
||||
|
||||
|
||||
private var filteredScripts: [URL] {
|
||||
guard !searchText.isEmpty else { return scripts }
|
||||
return scripts.filter { $0.lastPathComponent.localizedCaseInsensitiveContains(searchText) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
ZStack {
|
||||
// Background gradient
|
||||
LinearGradient(
|
||||
gradient: Gradient(colors: [
|
||||
Color(UIColor.systemBackground),
|
||||
Color(UIColor.secondarySystemBackground)
|
||||
]),
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
|
||||
List {
|
||||
actionsSection
|
||||
|
||||
if let onSelectScript {
|
||||
Button("None") {
|
||||
onSelectScript(nil)
|
||||
}
|
||||
}
|
||||
ForEach(scripts, id: \.self) { script in
|
||||
if let onSelectScript {
|
||||
Section {
|
||||
Button {
|
||||
onSelectScript(script)
|
||||
onSelectScript(nil)
|
||||
} label: {
|
||||
HStack {
|
||||
Text(script.lastPathComponent)
|
||||
.font(.headline)
|
||||
if defaultScriptName == script.lastPathComponent {
|
||||
Spacer()
|
||||
Image(systemName: "star.fill")
|
||||
}
|
||||
Image(systemName: "nosign")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("None")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
if filteredScripts.isEmpty {
|
||||
emptyRow
|
||||
} else {
|
||||
NavigationLink {
|
||||
ScriptEditorView(scriptURL: script)
|
||||
} label: {
|
||||
HStack {
|
||||
Text(script.lastPathComponent)
|
||||
.font(.headline)
|
||||
if defaultScriptName == script.lastPathComponent {
|
||||
Spacer()
|
||||
Image(systemName: "star.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button(role: .destructive) {
|
||||
deleteScript(script)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
Button {
|
||||
saveDefaultScript(script)
|
||||
} label: {
|
||||
Label("Set Default", systemImage: "star")
|
||||
}
|
||||
.tint(.blue)
|
||||
ForEach(filteredScripts, id: \.self) { script in
|
||||
scriptRow(script)
|
||||
}
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text("Swipe left to set a script as the default. Enable script execution after connecting in settings.")
|
||||
}
|
||||
}
|
||||
.navigationTitle("JavaScript Files")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
showNewFileAlert = true
|
||||
} label: {
|
||||
Label("New Script", systemImage: "plus")
|
||||
} footer: {
|
||||
Text("Swipe left on a script to set it as default or delete it. Enable script execution after connecting in settings.")
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
showImporter = true
|
||||
} label: {
|
||||
Label("Import", systemImage: "tray.and.arrow.down")
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear(perform: loadScripts)
|
||||
.alert("New Script", isPresented: $showNewFileAlert) {
|
||||
TextField("Filename", text: $newFileName)
|
||||
Button("Create", action: createNewScript)
|
||||
Button("Cancel", role: .cancel) { }
|
||||
}
|
||||
.fileImporter(
|
||||
isPresented: $showImporter,
|
||||
allowedContentTypes: [UTType(filenameExtension: "js") ?? .plainText],
|
||||
allowsMultipleSelection: false
|
||||
) { result in
|
||||
switch result {
|
||||
case .success(let urls):
|
||||
if let fileURL = urls.first {
|
||||
do {
|
||||
let dest = scriptsDirectory().appendingPathComponent(fileURL.lastPathComponent)
|
||||
if FileManager.default.fileExists(atPath: dest.path) {
|
||||
try FileManager.default.removeItem(at: dest)
|
||||
}
|
||||
try FileManager.default.copyItem(at: fileURL, to: dest)
|
||||
loadScripts()
|
||||
} catch {
|
||||
print("Import failed:", error)
|
||||
.listStyle(.insetGrouped)
|
||||
.navigationTitle("JavaScript Files")
|
||||
.searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .automatic), prompt: "Search scripts")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
showNewFileAlert = true
|
||||
} label: {
|
||||
Label("New Script", systemImage: "plus")
|
||||
}
|
||||
}
|
||||
case .failure(let error):
|
||||
print("File import error:", error)
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
showImporter = true
|
||||
} label: {
|
||||
Label("Import", systemImage: "tray.and.arrow.down")
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear(perform: loadScripts)
|
||||
.alert("New Script", isPresented: $showNewFileAlert) {
|
||||
TextField("Filename", text: $newFileName)
|
||||
Button("Create", action: createNewScript)
|
||||
Button("Cancel", role: .cancel) { }
|
||||
}
|
||||
.fileImporter(
|
||||
isPresented: $showImporter,
|
||||
allowedContentTypes: [UTType(filenameExtension: "js") ?? .plainText],
|
||||
allowsMultipleSelection: false
|
||||
) { result in
|
||||
switch result {
|
||||
case .success(let urls):
|
||||
if let fileURL = urls.first {
|
||||
importScript(from: fileURL)
|
||||
}
|
||||
case .failure(let error):
|
||||
presentError(title: "File Import Error", message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
// Busy overlay
|
||||
if isBusy {
|
||||
Color.black.opacity(0.2).ignoresSafeArea()
|
||||
ProgressView("Working…")
|
||||
.padding(14)
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
}
|
||||
|
||||
// Custom alert overlay
|
||||
if alertVisible {
|
||||
CustomErrorView(
|
||||
title: alertTitle,
|
||||
message: alertMessage,
|
||||
onDismiss: { alertVisible = false },
|
||||
showButton: true,
|
||||
primaryButtonText: "OK",
|
||||
messageType: alertIsSuccess ? .success : .error
|
||||
)
|
||||
}
|
||||
|
||||
// Toast
|
||||
if justCopied {
|
||||
VStack {
|
||||
Spacer()
|
||||
Text("Copied")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(.ultraThinMaterial, in: Capsule())
|
||||
.overlay(Capsule().strokeBorder(Color.white.opacity(0.15), lineWidth: 1))
|
||||
.shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 3)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
.padding(.bottom, 30)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.25), value: justCopied)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Sections
|
||||
|
||||
private var actionsSection: some View {
|
||||
Section {
|
||||
Button {
|
||||
showNewFileAlert = true
|
||||
} label: {
|
||||
Label("New Script", systemImage: "doc.badge.plus")
|
||||
}
|
||||
Button {
|
||||
showImporter = true
|
||||
} label: {
|
||||
Label("Import", systemImage: "tray.and.arrow.down")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyRow: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "doc.text.magnifyingglass")
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("No scripts found")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("Tap New Script or Import to get started.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private func scriptRow(_ script: URL) -> some View {
|
||||
let isDefault = defaultScriptName == script.lastPathComponent
|
||||
|
||||
return Group {
|
||||
if let onSelectScript {
|
||||
Button {
|
||||
onSelectScript(script)
|
||||
} label: {
|
||||
HStack {
|
||||
Text(script.lastPathComponent)
|
||||
Spacer()
|
||||
if isDefault {
|
||||
Image(systemName: "star.fill")
|
||||
.foregroundColor(.yellow)
|
||||
}
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
Button {
|
||||
copyName(script)
|
||||
} label: {
|
||||
Label("Copy Filename", systemImage: "doc.on.doc")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
NavigationLink {
|
||||
ScriptEditorView(scriptURL: script)
|
||||
} label: {
|
||||
HStack {
|
||||
Text(script.lastPathComponent)
|
||||
Spacer()
|
||||
if isDefault {
|
||||
Image(systemName: "star.fill")
|
||||
.foregroundColor(.yellow)
|
||||
}
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button(role: .destructive) {
|
||||
deleteScript(script)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
Button {
|
||||
saveDefaultScript(script)
|
||||
} label: {
|
||||
Label("Set Default", systemImage: "star")
|
||||
}
|
||||
.tint(.blue)
|
||||
}
|
||||
.contextMenu {
|
||||
Button {
|
||||
copyName(script)
|
||||
} label: {
|
||||
Label("Copy Filename", systemImage: "doc.on.doc")
|
||||
}
|
||||
Button {
|
||||
copyPath(script)
|
||||
} label: {
|
||||
Label("Copy Path", systemImage: "folder")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - File Ops
|
||||
|
||||
private func scriptsDirectory() -> URL {
|
||||
let dir = FileManager
|
||||
.default
|
||||
.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent("scripts")
|
||||
|
||||
|
||||
var isDir: ObjCBool = false
|
||||
var exists = FileManager.default.fileExists(atPath: dir.path, isDirectory: &isDir)
|
||||
|
||||
|
||||
do {
|
||||
if exists && !isDir.boolValue {
|
||||
try FileManager.default.removeItem(at: dir)
|
||||
|
|
@ -138,57 +274,57 @@ struct ScriptListView: View {
|
|||
if !exists {
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
if let bundleURL = Bundle.main.url(forResource: "attachDetach", withExtension: "js") {
|
||||
try FileManager.default.copyItem(
|
||||
at: bundleURL,
|
||||
to: dir.appendingPathComponent("attachDetach.js")
|
||||
)
|
||||
let dest = dir.appendingPathComponent("attachDetach.js")
|
||||
if !FileManager.default.fileExists(atPath: dest.path) {
|
||||
try FileManager.default.copyItem(at: bundleURL, to: dest)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
showAlert(title: "Unable to Create Scripts Folder",
|
||||
message: error.localizedDescription,
|
||||
showOk: true)
|
||||
presentError(title: "Unable to Create Scripts Folder", message: error.localizedDescription)
|
||||
}
|
||||
|
||||
|
||||
return dir
|
||||
}
|
||||
|
||||
|
||||
private func loadScripts() {
|
||||
let dir = scriptsDirectory()
|
||||
scripts = (try? FileManager
|
||||
.default
|
||||
.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil))?
|
||||
.filter { $0.pathExtension.lowercased() == "js" } ?? []
|
||||
.filter { $0.pathExtension.lowercased() == "js" }
|
||||
.sorted(by: { $0.lastPathComponent.localizedCaseInsensitiveCompare($1.lastPathComponent) == .orderedAscending }) ?? []
|
||||
}
|
||||
|
||||
|
||||
private func saveDefaultScript(_ url: URL) {
|
||||
defaultScriptName = url.lastPathComponent
|
||||
presentSuccess(title: "Default Script Set", message: url.lastPathComponent)
|
||||
}
|
||||
|
||||
|
||||
private func createNewScript() {
|
||||
guard !newFileName.isEmpty else { return }
|
||||
var filename = newFileName
|
||||
guard !newFileName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
|
||||
var filename = newFileName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !filename.hasSuffix(".js") {
|
||||
filename += ".js"
|
||||
}
|
||||
let newURL = scriptsDirectory().appendingPathComponent(filename)
|
||||
|
||||
|
||||
guard !FileManager.default.fileExists(atPath: newURL.path) else {
|
||||
showAlert(title: "Failed to Create New Script",
|
||||
message: "A script with the same name already exists.",
|
||||
showOk: true)
|
||||
presentError(title: "Failed to Create New Script",
|
||||
message: "A script with the same name already exists.")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
do {
|
||||
try "".write(to: newURL, atomically: true, encoding: .utf8)
|
||||
newFileName = ""
|
||||
loadScripts()
|
||||
presentSuccess(title: "Created", message: filename)
|
||||
} catch {
|
||||
print("Error creating file:", error)
|
||||
presentError(title: "Error Creating File", message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func deleteScript(_ url: URL) {
|
||||
do {
|
||||
try FileManager.default.removeItem(at: url)
|
||||
|
|
@ -197,7 +333,64 @@ struct ScriptListView: View {
|
|||
}
|
||||
loadScripts()
|
||||
} catch {
|
||||
print("Error deleting script:", error)
|
||||
presentError(title: "Delete Failed", message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func importScript(from fileURL: URL) {
|
||||
isBusy = true
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
defer {
|
||||
DispatchQueue.main.async { self.isBusy = false }
|
||||
}
|
||||
do {
|
||||
let dest = scriptsDirectory().appendingPathComponent(fileURL.lastPathComponent)
|
||||
if FileManager.default.fileExists(atPath: dest.path) {
|
||||
try FileManager.default.removeItem(at: dest)
|
||||
}
|
||||
try FileManager.default.copyItem(at: fileURL, to: dest)
|
||||
DispatchQueue.main.async {
|
||||
self.loadScripts()
|
||||
self.presentSuccess(title: "Imported", message: fileURL.lastPathComponent)
|
||||
}
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
self.presentError(title: "Import Failed", message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Feedback helpers
|
||||
|
||||
private func presentError(title: String, message: String) {
|
||||
alertTitle = title
|
||||
alertMessage = message
|
||||
alertIsSuccess = false
|
||||
alertVisible = true
|
||||
}
|
||||
|
||||
private func presentSuccess(title: String, message: String) {
|
||||
alertTitle = title
|
||||
alertMessage = message
|
||||
alertIsSuccess = true
|
||||
alertVisible = true
|
||||
}
|
||||
|
||||
private func copyName(_ url: URL) {
|
||||
UIPasteboard.general.string = url.lastPathComponent
|
||||
showCopiedToast()
|
||||
}
|
||||
|
||||
private func copyPath(_ url: URL) {
|
||||
UIPasteboard.general.string = url.path
|
||||
showCopiedToast()
|
||||
}
|
||||
|
||||
private func showCopiedToast() {
|
||||
withAnimation { justCopied = true }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||
withAnimation { justCopied = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,54 +22,102 @@ private func registerAdvancedOptionsDefault() {
|
|||
// MARK: - Welcome Sheet
|
||||
|
||||
struct WelcomeSheetView: View {
|
||||
// A callback to dismiss the sheet and trigger VPN setup.
|
||||
var onDismiss: (() -> Void)?
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
Text("Welcome!")
|
||||
.font(.system(.largeTitle, design: .rounded).weight(.bold))
|
||||
.foregroundColor(.primary)
|
||||
.padding(.top)
|
||||
ZStack {
|
||||
// Subtle gradient background to match app style
|
||||
LinearGradient(
|
||||
gradient: Gradient(colors: [
|
||||
Color(UIColor.systemBackground),
|
||||
Color(UIColor.secondarySystemBackground)
|
||||
]),
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
|
||||
Text("Thanks for installing the app. This brief introduction will help you get started.")
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
|
||||
Text("StikDebug is an on-device debugger designed specifically for self-developed apps. It helps streamline testing and troubleshooting without sending any data to external servers.")
|
||||
.font(.callout)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
|
||||
Text("The next step will prompt you to allow VPN permissions. This is necessary for the app to function properly. The VPN configuration allows your device to securely connect to itself — nothing more. Rest assured, no data is collected or sent externally. Everything stays on your device.")
|
||||
.font(.callout)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
|
||||
Button(action: {
|
||||
onDismiss?()
|
||||
}) {
|
||||
Text("Continue")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(RoundedRectangle(cornerRadius: 12).fill(Color.accentColor))
|
||||
.foregroundColor(.white)
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
// Card container with glassy material and stroke
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
// Title
|
||||
Text("Welcome!")
|
||||
.font(.system(.largeTitle, design: .rounded).weight(.bold))
|
||||
.foregroundColor(.primary)
|
||||
.padding(.top, 8)
|
||||
|
||||
// Intro
|
||||
Text("Thanks for installing the app. This brief introduction will help you get started.")
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
// App description
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Label("On‑device debugger", systemImage: "bolt.shield.fill")
|
||||
.foregroundColor(.white)
|
||||
.font(.headline)
|
||||
Text("StikDebug is an on‑device debugger designed specifically for self‑developed apps. It helps streamline testing and troubleshooting without sending any data to external servers.")
|
||||
.font(.callout)
|
||||
.foregroundColor(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
// VPN explanation
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Label("Why VPN permission?", systemImage: "lock.shield.fill")
|
||||
.foregroundColor(.white)
|
||||
.font(.headline)
|
||||
Text("The next step will prompt you to allow VPN permissions. This is necessary for the app to function properly. The VPN configuration allows your device to securely connect to itself — nothing more. No data is collected or sent externally; everything stays on your device.")
|
||||
.font(.callout)
|
||||
.foregroundColor(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
// Continue button
|
||||
Button(action: { onDismiss?() }) {
|
||||
Text("Continue")
|
||||
.font(.system(size: 16, weight: .semibold, design: .rounded))
|
||||
.foregroundColor(.black)
|
||||
.frame(height: 44)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(Color.white)
|
||||
)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
.accessibilityIdentifier("welcome_continue_button")
|
||||
}
|
||||
.padding(20)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4)
|
||||
|
||||
// Footer version info for consistency
|
||||
HStack {
|
||||
Spacer()
|
||||
Text("iOS \(UIDevice.current.systemVersion)")
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.top, 6)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 30)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom)
|
||||
}
|
||||
.padding()
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 30, style: .continuous)
|
||||
.fill(Color(UIColor.secondarySystemBackground))
|
||||
.shadow(radius: 20)
|
||||
)
|
||||
.padding()
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -271,10 +319,10 @@ class TunnelManager: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - AccentColor Environment Key
|
||||
// MARK: - AccentColor Environment Key (leave available but unused)
|
||||
|
||||
struct AccentColorKey: EnvironmentKey {
|
||||
static let defaultValue: Color = .blue
|
||||
static let defaultValue: Color = .white
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
|
|
@ -457,8 +505,7 @@ struct HeartbeatApp: App {
|
|||
@State private var showContinueWarning = false
|
||||
@State private var timeoutTimer: Timer?
|
||||
@StateObject private var mount = MountingProgress.shared
|
||||
@StateObject private var dnsChecker = DNSChecker() // New DNS check state object
|
||||
@AppStorage("appTheme") private var appTheme: String = "system"
|
||||
@StateObject private var dnsChecker = DNSChecker()
|
||||
@Environment(\.scenePhase) private var scenePhase // Observe scene lifecycle
|
||||
|
||||
let urls: [String] = [
|
||||
|
|
@ -482,17 +529,14 @@ struct HeartbeatApp: App {
|
|||
let origMethod = class_getInstanceMethod(UIDocumentPickerViewController.self, #selector(UIDocumentPickerViewController.init(forOpeningContentTypes:asCopy:)))!
|
||||
method_exchangeImplementations(origMethod, fixMethod)
|
||||
|
||||
// Force global dark mode for the entire app
|
||||
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let window = windowScene.windows.first {
|
||||
switch appTheme {
|
||||
case "dark":
|
||||
window.overrideUserInterfaceStyle = .dark
|
||||
case "light":
|
||||
window.overrideUserInterfaceStyle = .light
|
||||
default:
|
||||
window.overrideUserInterfaceStyle = .unspecified
|
||||
}
|
||||
window.overrideUserInterfaceStyle = .dark
|
||||
}
|
||||
|
||||
// Ensure UIKit controls also use white tint globally
|
||||
UIView.appearance().tintColor = .white
|
||||
}
|
||||
|
||||
func newVerCheck() {
|
||||
|
|
@ -513,20 +557,6 @@ struct HeartbeatApp: App {
|
|||
}
|
||||
}
|
||||
|
||||
private func applyTheme() {
|
||||
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let window = windowScene.windows.first {
|
||||
switch appTheme {
|
||||
case "dark":
|
||||
window.overrideUserInterfaceStyle = .dark
|
||||
case "light":
|
||||
window.overrideUserInterfaceStyle = .light
|
||||
default:
|
||||
window.overrideUserInterfaceStyle = .unspecified
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
Group {
|
||||
|
|
@ -646,11 +676,16 @@ struct HeartbeatApp: App {
|
|||
isLoading2 = false
|
||||
}) {
|
||||
ConsoleLogsView()
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
} else {
|
||||
MainTabView()
|
||||
.onAppear {
|
||||
applyTheme()
|
||||
// Force dark mode on main window as well
|
||||
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let window = windowScene.windows.first {
|
||||
window.overrideUserInterfaceStyle = .dark
|
||||
}
|
||||
let fileManager = FileManager.default
|
||||
for (index, urlString) in urls.enumerated() {
|
||||
let destinationURL = URL.documentsDirectory.appendingPathComponent(outputFiles[index])
|
||||
|
|
@ -680,8 +715,11 @@ struct HeartbeatApp: App {
|
|||
}
|
||||
}
|
||||
)
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
}
|
||||
// Apply global white tint to all SwiftUI views in the window
|
||||
.tint(Color.white)
|
||||
.onAppear {
|
||||
// On first launch, present the welcome sheet.
|
||||
// Otherwise, start the VPN automatically.
|
||||
|
|
@ -698,7 +736,9 @@ struct HeartbeatApp: App {
|
|||
showWelcomeSheet = false
|
||||
TunnelManager.shared.startVPN()
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
.onChange(of: scenePhase) { newPhase in
|
||||
if newPhase == .active {
|
||||
|
|
@ -706,19 +746,6 @@ struct HeartbeatApp: App {
|
|||
startHeartbeatInBackground()
|
||||
}
|
||||
}
|
||||
.onChange(of: isLoading2) { newValue in
|
||||
if !newValue {
|
||||
timeoutTimer?.invalidate()
|
||||
timeoutTimer = nil
|
||||
}
|
||||
}
|
||||
.onChange(of: dnsChecker.dnsError) { newError in
|
||||
if let errorMsg = newError, !errorMsg.contains("Not connected to WiFi") {
|
||||
alert_title = "Network Issue"
|
||||
alert_string = errorMsg
|
||||
show_alert = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func checkVPNConnection(callback: @escaping (Bool, String?) -> Void) {
|
||||
|
|
@ -924,46 +951,24 @@ struct LoadingView: View {
|
|||
|
||||
@State private var animate = false
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@AppStorage("customAccentColor") private var customAccentColorHex: String = ""
|
||||
@AppStorage("appTheme") private var appTheme: String = "system"
|
||||
|
||||
private var accentColor: Color {
|
||||
if customAccentColorHex.isEmpty {
|
||||
return .blue
|
||||
} else {
|
||||
return Color(hex: customAccentColorHex) ?? .blue
|
||||
}
|
||||
}
|
||||
|
||||
private var isDarkMode: Bool {
|
||||
switch appTheme {
|
||||
case "dark":
|
||||
return true
|
||||
case "light":
|
||||
return false
|
||||
default:
|
||||
return colorScheme == .dark
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color(isDarkMode ? .black : .white)
|
||||
.ignoresSafeArea()
|
||||
Color.black.ignoresSafeArea()
|
||||
|
||||
VStack {
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(lineWidth: 8)
|
||||
.foregroundColor(isDarkMode ? Color.white.opacity(0.3) : Color.black.opacity(0.1))
|
||||
.foregroundColor(Color.white.opacity(0.15))
|
||||
.frame(width: 80, height: 80)
|
||||
|
||||
Circle()
|
||||
.trim(from: 0, to: 0.7)
|
||||
.stroke(AngularGradient(
|
||||
gradient: Gradient(colors: [
|
||||
accentColor.opacity(0.8),
|
||||
accentColor.opacity(0.3)
|
||||
Color.white.opacity(0.9),
|
||||
Color.white.opacity(0.4)
|
||||
]),
|
||||
center: .center
|
||||
), style: StrokeStyle(lineWidth: 6, lineCap: .round))
|
||||
|
|
@ -971,7 +976,7 @@ struct LoadingView: View {
|
|||
.frame(width: 80, height: 80)
|
||||
.animation(Animation.linear(duration: 1.2).repeatForever(autoreverses: false), value: animate)
|
||||
}
|
||||
.shadow(color: accentColor.opacity(0.4), radius: 10, x: 0, y: 0)
|
||||
.shadow(color: Color.white.opacity(0.25), radius: 10, x: 0, y: 0)
|
||||
.onAppear {
|
||||
animate = true
|
||||
let os = ProcessInfo.processInfo.operatingSystemVersion
|
||||
|
|
@ -984,12 +989,13 @@ struct LoadingView: View {
|
|||
|
||||
Text("Loading...")
|
||||
.font(.system(size: 20, weight: .medium, design: .rounded))
|
||||
.foregroundColor(isDarkMode ? .white.opacity(0.8) : .black.opacity(0.8))
|
||||
.foregroundColor(.white.opacity(0.85))
|
||||
.padding(.top, 20)
|
||||
.opacity(animate ? 1.0 : 0.5)
|
||||
.animation(Animation.easeInOut(duration: 1.5).repeatForever(autoreverses: true), value: animate)
|
||||
}
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -32,9 +32,9 @@ struct CustomErrorView: View {
|
|||
|
||||
private var accentColor: Color {
|
||||
if customAccentColorHex.isEmpty {
|
||||
return .blue
|
||||
return .white
|
||||
} else {
|
||||
return Color(hex: customAccentColorHex) ?? .blue
|
||||
return Color(hex: customAccentColorHex) ?? .white
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -164,12 +164,11 @@ struct CustomErrorView: View {
|
|||
}
|
||||
|
||||
private func dismissWithAnimation() {
|
||||
withAnimation(.easeOut(duration: 0.15)) {
|
||||
withAnimation(.easeOut(duration: 0.18)) {
|
||||
opacity = 0
|
||||
scale = 0.8
|
||||
scale = 0.95
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) {
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,52 +7,34 @@
|
|||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
@_silgen_name("ideviceinfo_c_init")
|
||||
private func c_deviceinfo_init(_ path: UnsafePointer<CChar>) -> Int32
|
||||
|
||||
@_silgen_name("ideviceinfo_c_get_xml")
|
||||
private func c_deviceinfo_get_xml() -> UnsafePointer<CChar>?
|
||||
|
||||
@_silgen_name("ideviceinfo_c_cleanup")
|
||||
private func c_deviceinfo_cleanup()
|
||||
|
||||
private func initErrorMessage(_ code: Int32) -> String {
|
||||
switch code {
|
||||
case 1: return "Couldn’t read pairingFile.plist"
|
||||
case 2: return "Unable to create device provider"
|
||||
case 3: return "Cannot connect to lockdown service"
|
||||
case 4: return "Unable to start lockdown session"
|
||||
default: return "Unknown init error (\(code))"
|
||||
}
|
||||
}
|
||||
|
||||
private let docs = FileManager.default
|
||||
.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
// MARK: - Device Info Manager
|
||||
|
||||
@MainActor
|
||||
final class DeviceInfoManager: ObservableObject {
|
||||
@Published var entries: [(key: String, value: String)] = []
|
||||
@Published var busy = false
|
||||
@Published var error: (title: String, message: String)?
|
||||
|
||||
private var initialized = false
|
||||
private let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
|
||||
func initAndLoad() {
|
||||
guard !initialized else {
|
||||
loadInfo()
|
||||
return
|
||||
}
|
||||
guard !initialized else { loadInfo(); return }
|
||||
busy = true
|
||||
let pairingPath = docs
|
||||
.appendingPathComponent("pairingFile.plist")
|
||||
.path
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
let code = pairingPath.withCString { c_deviceinfo_init($0) }
|
||||
DispatchQueue.main.async {
|
||||
let path = docs.appendingPathComponent("pairingFile.plist").path
|
||||
Task.detached {
|
||||
let code = path.withCString { c_deviceinfo_init($0) }
|
||||
await MainActor.run {
|
||||
if code != 0 {
|
||||
self.error = ("Initialization Failed", initErrorMessage(code))
|
||||
self.error = ("Initialization Failed", self.initErrorMessage(code))
|
||||
self.busy = false
|
||||
} else {
|
||||
self.initialized = true
|
||||
|
|
@ -64,47 +46,35 @@ final class DeviceInfoManager: ObservableObject {
|
|||
|
||||
private func loadInfo() {
|
||||
busy = true
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
Task.detached {
|
||||
guard let cXml = c_deviceinfo_get_xml() else {
|
||||
DispatchQueue.main.async {
|
||||
await MainActor.run {
|
||||
self.error = ("Fetch Error", "Failed to fetch device info")
|
||||
self.busy = false
|
||||
}
|
||||
return
|
||||
}
|
||||
defer { free(UnsafeMutableRawPointer(mutating: cXml)) }
|
||||
|
||||
guard let xml = String(validatingUTF8: cXml) else {
|
||||
DispatchQueue.main.async {
|
||||
await MainActor.run {
|
||||
self.error = ("Parse Error", "Invalid XML data")
|
||||
self.busy = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
guard let data = xml.data(using: .utf8) else {
|
||||
throw NSError(domain: "DeviceInfo", code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Invalid XML encoding"])
|
||||
}
|
||||
let plistObj = try PropertyListSerialization.propertyList(
|
||||
from: data,
|
||||
options: [], format: nil)
|
||||
guard let dict = plistObj as? [String: Any] else {
|
||||
let data = Data(xml.utf8)
|
||||
guard let dict = try PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? [String: Any] else {
|
||||
throw NSError(domain: "DeviceInfo", code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Expected dictionary"])
|
||||
}
|
||||
|
||||
let formatted = dict.keys.sorted().map { key in
|
||||
(key, Self.convertToString(dict[key]!))
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
let formatted = dict.keys.sorted().map { ($0, Self.convertToString(dict[$0]!)) }
|
||||
await MainActor.run {
|
||||
self.entries = formatted
|
||||
self.busy = false
|
||||
}
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
await MainActor.run {
|
||||
self.error = ("Parse Error", error.localizedDescription)
|
||||
self.busy = false
|
||||
}
|
||||
|
|
@ -117,159 +87,354 @@ final class DeviceInfoManager: ObservableObject {
|
|||
initialized = false
|
||||
}
|
||||
|
||||
private static func convertToString(_ raw: Any) -> String {
|
||||
private func initErrorMessage(_ code: Int32) -> String {
|
||||
switch code {
|
||||
case 1: return "Couldn’t read pairingFile.plist"
|
||||
case 2: return "Unable to create device provider"
|
||||
case 3: return "Cannot connect to lockdown service"
|
||||
case 4: return "Unable to start lockdown session"
|
||||
default: return "Unknown init error (\(code))"
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static func convertToString(_ raw: Any) -> String {
|
||||
switch raw {
|
||||
case let s as String:
|
||||
return s
|
||||
case let n as NSNumber:
|
||||
return n.stringValue
|
||||
case let d as Data:
|
||||
let hex = d.prefix(32).map { String(format: "%02X", $0) }.joined()
|
||||
let suffix = d.count > 32 ? "…" : ""
|
||||
return "Data(\(d.count) B) \(hex)\(suffix)"
|
||||
case let arr as [Any]:
|
||||
return "[" + arr.map { convertToString($0) }.joined(separator: ", ") + "]"
|
||||
case let dic as [String: Any]:
|
||||
return "{" + dic.map { "\($0.key): \(convertToString($0.value))" }
|
||||
.joined(separator: ", ") + "}"
|
||||
default:
|
||||
return String(describing: raw)
|
||||
case let s as String: return s
|
||||
case let n as NSNumber: return n.stringValue
|
||||
default: return String(describing: raw)
|
||||
}
|
||||
}
|
||||
|
||||
func exportToCSV() throws -> URL {
|
||||
var csv = "Key,Value\n"
|
||||
for (key, value) in entries {
|
||||
let escKey = key.replacingOccurrences(of: "\"", with: "\"\"")
|
||||
let escValue = value.replacingOccurrences(of: "\"", with: "\"\"")
|
||||
csv += "\"\(escKey)\",\"\(escValue)\"\n"
|
||||
for (k, v) in entries {
|
||||
csv += "\"\(k.replacingOccurrences(of: "\"", with: "\"\""))\","
|
||||
csv += "\"\(v.replacingOccurrences(of: "\"", with: "\"\""))\"\n"
|
||||
}
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("DeviceInfo-Export.csv")
|
||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent("DeviceInfo.csv")
|
||||
try csv.data(using: .utf8)?.write(to: url)
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Device Info UI
|
||||
|
||||
struct DeviceInfoView: View {
|
||||
@StateObject private var mgr = DeviceInfoManager()
|
||||
@State private var importer = false
|
||||
@State private var exportURL: URL?
|
||||
@State private var isShowingExporter = false
|
||||
@State private var shareItems: [Any] = []
|
||||
@State private var showShareSheet = false
|
||||
@State private var justCopied = false
|
||||
|
||||
private let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
private var pairingURL: URL { docs.appendingPathComponent("pairingFile.plist") }
|
||||
private var isPaired: Bool { FileManager.default.fileExists(atPath: pairingURL.path) }
|
||||
|
||||
@State private var searchText = ""
|
||||
@State private var alert = false
|
||||
@State private var alertTitle = ""
|
||||
@State private var alertMsg = ""
|
||||
@State private var alertSuccess = false
|
||||
|
||||
var filteredEntries: [(key: String, value: String)] {
|
||||
guard !searchText.isEmpty else { return mgr.entries }
|
||||
return mgr.entries.filter {
|
||||
$0.key.localizedCaseInsensitiveContains(searchText)
|
||||
|| $0.value.localizedCaseInsensitiveContains(searchText)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
Color(UIColor.systemBackground).ignoresSafeArea()
|
||||
// Subtle depth but same color scheme
|
||||
LinearGradient(
|
||||
gradient: Gradient(colors: [
|
||||
Color(UIColor.systemBackground),
|
||||
Color(UIColor.secondarySystemBackground)
|
||||
]),
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
SettingsCard {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
|
||||
if mgr.entries.isEmpty {
|
||||
Text(mgr.busy ? "Loading…" : "No info available")
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
ForEach(mgr.entries, id: \.key) { entry in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(entry.key)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text(entry.value)
|
||||
.font(.footnote.monospaced())
|
||||
.foregroundColor(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.contextMenu {
|
||||
Button("Copy Key") {
|
||||
UIPasteboard.general.string = entry.key
|
||||
}
|
||||
Button("Copy Value") {
|
||||
UIPasteboard.general.string = entry.value
|
||||
}
|
||||
Button("Copy Both") {
|
||||
UIPasteboard.general.string = "\(entry.key): \(entry.value)"
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(16)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
VStack(spacing: 20) {
|
||||
infoCard
|
||||
versionInfo
|
||||
}
|
||||
.padding(.vertical, 20)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 30)
|
||||
}
|
||||
|
||||
if mgr.busy {
|
||||
Color.black.opacity(0.3).ignoresSafeArea()
|
||||
ProgressView()
|
||||
.padding()
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12))
|
||||
Color.black.opacity(0.35).ignoresSafeArea()
|
||||
ProgressView("Fetching device info…")
|
||||
.padding(16)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4)
|
||||
}
|
||||
|
||||
if let err = mgr.error {
|
||||
CustomErrorView(
|
||||
title: err.title,
|
||||
message: err.message,
|
||||
onDismiss: { mgr.error = nil }
|
||||
)
|
||||
if alert {
|
||||
CustomErrorView(title: alertTitle,
|
||||
message: alertMsg,
|
||||
onDismiss: { alert = false },
|
||||
messageType: alertSuccess ? .success : .error)
|
||||
}
|
||||
|
||||
if justCopied {
|
||||
VStack {
|
||||
Spacer()
|
||||
Text("Copied")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(.ultraThinMaterial, in: Capsule())
|
||||
.overlay(Capsule().strokeBorder(Color.white.opacity(0.15), lineWidth: 1))
|
||||
.shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 3)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
.padding(.bottom, 30)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.25), value: justCopied)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Device Info")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(action: {
|
||||
do {
|
||||
let url = try mgr.exportToCSV()
|
||||
exportURL = url
|
||||
isShowingExporter = true
|
||||
} catch {
|
||||
mgr.error = ("Export Failed", error.localizedDescription)
|
||||
ToolbarItemGroup(placement: .navigationBarTrailing) {
|
||||
if isPaired {
|
||||
Button { mgr.initAndLoad() } label: {
|
||||
Label("Reload", systemImage: "arrow.clockwise")
|
||||
}
|
||||
}) {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
|
||||
Button {
|
||||
do {
|
||||
exportURL = try mgr.exportToCSV()
|
||||
isShowingExporter = true
|
||||
} catch {
|
||||
fail("Export Failed", error.localizedDescription)
|
||||
}
|
||||
} label: {
|
||||
Label("Export", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
.disabled(mgr.entries.isEmpty)
|
||||
|
||||
Menu {
|
||||
Button { copyAllText() } label: {
|
||||
Label("Copy All (Text)", systemImage: "doc.on.doc")
|
||||
}
|
||||
Button { copyAllCSV() } label: {
|
||||
Label("Copy All (CSV)", systemImage: "tablecells")
|
||||
}
|
||||
Button { shareAll() } label: {
|
||||
Label("Share…", systemImage: "square.and.arrow.up.on.square")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
.disabled(mgr.entries.isEmpty)
|
||||
}
|
||||
.disabled(mgr.entries.isEmpty)
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
if !isPaired {
|
||||
Button { importer = true } label: {
|
||||
Label("Import Pairing File", systemImage: "doc.badge.plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.fileImporter(isPresented: $importer, allowedContentTypes: [.propertyList]) { result in
|
||||
if case .success(let url) = result { importPairing(from: url) }
|
||||
}
|
||||
.fileExporter(
|
||||
isPresented: $isShowingExporter,
|
||||
document: CSVDocument(url: exportURL),
|
||||
contentType: .commaSeparatedText,
|
||||
defaultFilename: "DeviceInfo"
|
||||
) { result in
|
||||
// completion
|
||||
) { _ in notify("Export Complete", "Device info exported to CSV") }
|
||||
.sheet(isPresented: $showShareSheet) {
|
||||
ActivityViewController(items: shareItems)
|
||||
}
|
||||
.onAppear { if isPaired { mgr.initAndLoad() } }
|
||||
.onDisappear { mgr.cleanup() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UI Sections
|
||||
|
||||
private var infoCard: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
// Glassy search field
|
||||
TextField("Search device info…", text: $searchText)
|
||||
.padding(12)
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1)
|
||||
)
|
||||
.padding(.bottom, 10)
|
||||
|
||||
if !isPaired {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Label("No pairing file detected", systemImage: "exclamationmark.triangle.fill")
|
||||
.foregroundColor(.orange)
|
||||
Text("Import your device’s pairing file to get started.")
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
|
||||
if mgr.entries.isEmpty {
|
||||
Text(mgr.busy ? "Loading…" : (isPaired ? "No info available" : ""))
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
ForEach(filteredEntries, id: \.key) { entry in
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(entry.key).bold()
|
||||
Text(entry.value)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundColor(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
Divider()
|
||||
.background(Color.white.opacity(0.12))
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear { mgr.initAndLoad() }
|
||||
.onDisappear { mgr.cleanup() }
|
||||
.padding(20)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4)
|
||||
}
|
||||
|
||||
private var versionInfo: some View {
|
||||
HStack {
|
||||
Spacer()
|
||||
Text("iOS \(UIDevice.current.systemVersion)")
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.top, 6)
|
||||
}
|
||||
|
||||
// MARK: - Copy / Share helpers
|
||||
|
||||
private func allAsText() -> String {
|
||||
filteredEntries.map { "\($0.key): \($0.value)" }.joined(separator: "\n")
|
||||
}
|
||||
|
||||
private func allAsCSV() -> String {
|
||||
var csv = "Key,Value\n"
|
||||
for (k, v) in filteredEntries {
|
||||
let kq = k.replacingOccurrences(of: "\"", with: "\"\"")
|
||||
let vq = v.replacingOccurrences(of: "\"", with: "\"\"")
|
||||
csv += "\"\(kq)\",\"\(vq)\"\n"
|
||||
}
|
||||
return csv
|
||||
}
|
||||
|
||||
private func copyAllText() {
|
||||
UIPasteboard.general.string = allAsText()
|
||||
hapticCopySuccess()
|
||||
showCopiedToast()
|
||||
}
|
||||
|
||||
private func copyAllCSV() {
|
||||
UIPasteboard.general.string = allAsCSV()
|
||||
hapticCopySuccess()
|
||||
showCopiedToast()
|
||||
}
|
||||
|
||||
private func copyToPasteboard(_ str: String) {
|
||||
UIPasteboard.general.string = str
|
||||
hapticCopySuccess()
|
||||
showCopiedToast()
|
||||
}
|
||||
|
||||
private func shareAll() {
|
||||
let text = allAsText()
|
||||
shareItems = [text]
|
||||
showShareSheet = true
|
||||
}
|
||||
|
||||
private func hapticCopySuccess() {
|
||||
let gen = UINotificationFeedbackGenerator()
|
||||
gen.notificationOccurred(.success)
|
||||
}
|
||||
|
||||
private func showCopiedToast() {
|
||||
withAnimation { justCopied = true }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||
withAnimation { justCopied = false }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pairing Import
|
||||
|
||||
private func importPairing(from src: URL) {
|
||||
guard src.startAccessingSecurityScopedResource() else { return }
|
||||
defer { src.stopAccessingSecurityScopedResource() }
|
||||
do {
|
||||
if FileManager.default.fileExists(atPath: pairingURL.path) {
|
||||
try FileManager.default.removeItem(at: pairingURL)
|
||||
}
|
||||
try FileManager.default.copyItem(at: src, to: pairingURL)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: pairingURL.path)
|
||||
notify("Pairing File Added", "Your device is ready. Tap Reload to fetch info.")
|
||||
mgr.initAndLoad()
|
||||
} catch {
|
||||
fail("Import Failed", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Alerts
|
||||
|
||||
private func fail(_ title: String, _ msg: String) {
|
||||
alertTitle = title; alertMsg = msg; alertSuccess = false; alert = true
|
||||
}
|
||||
private func notify(_ title: String, _ msg: String) {
|
||||
alertTitle = title; alertMsg = msg; alertSuccess = true; alert = true
|
||||
}
|
||||
}
|
||||
|
||||
import UniformTypeIdentifiers
|
||||
// MARK: - CSV Document
|
||||
|
||||
struct CSVDocument: FileDocument {
|
||||
static var readableContentTypes: [UTType] { [] }
|
||||
static var writableContentTypes: [UTType] { [UTType.commaSeparatedText] }
|
||||
|
||||
let url: URL?
|
||||
|
||||
init(url: URL?) {
|
||||
self.url = url
|
||||
}
|
||||
|
||||
init(configuration: ReadConfiguration) throws {
|
||||
fatalError("Not supported")
|
||||
}
|
||||
|
||||
init(url: URL?) { self.url = url }
|
||||
init(configuration: ReadConfiguration) throws { fatalError("Not supported") }
|
||||
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
|
||||
guard let url = url else {
|
||||
throw NSError(domain: "CSVDocument", code: -1, userInfo: nil)
|
||||
}
|
||||
guard let url = url else { throw NSError(domain: "CSVDocument", code: -1) }
|
||||
return try FileWrapper(url: url, options: .immediate)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UIKit Share Sheet wrapper
|
||||
|
||||
private struct ActivityViewController: UIViewControllerRepresentable {
|
||||
let items: [Any]
|
||||
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||
UIActivityViewController(activityItems: items, applicationActivities: nil)
|
||||
}
|
||||
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) { }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@
|
|||
// Created by neoarz on 4/9/25.
|
||||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
// MARK: - Accent Color Picker (Glassy style)
|
||||
struct AccentColorPicker: View {
|
||||
@Binding var selectedColor: Color
|
||||
|
||||
|
|
@ -15,12 +18,12 @@ struct AccentColorPicker: View {
|
|||
.red, // Red
|
||||
.init(hex: "#6A5ACD")!, // Purple
|
||||
.init(hex: "#DA70D6")!, // Pink
|
||||
.white, // white
|
||||
.black // black
|
||||
.white, // White
|
||||
.black // Black
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Accent Color")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
|
@ -31,12 +34,10 @@ struct AccentColorPicker: View {
|
|||
.fill(color)
|
||||
.frame(width: 28, height: 28)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(Color.primary.opacity(0.1), lineWidth: 1)
|
||||
Circle().stroke(Color.white.opacity(0.1), lineWidth: 1)
|
||||
)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(selectedColor == color ? Color.primary : Color.clear, lineWidth: 2)
|
||||
Circle().stroke(selectedColor == color ? Color.primary : .clear, lineWidth: 2)
|
||||
)
|
||||
.onTapGesture {
|
||||
selectedColor = color
|
||||
|
|
@ -48,17 +49,27 @@ struct AccentColorPicker: View {
|
|||
.labelsHidden()
|
||||
.frame(width: 28, height: 28)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(Color.primary.opacity(0.1), lineWidth: 1)
|
||||
Circle().stroke(Color.white.opacity(0.1), lineWidth: 1)
|
||||
)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.padding(.top, 12)
|
||||
.padding(20)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 4)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Display Settings View
|
||||
struct DisplayView: View {
|
||||
@AppStorage("username") private var username = "User"
|
||||
@AppStorage("customAccentColor") private var customAccentColorHex: String = ""
|
||||
|
|
@ -67,6 +78,8 @@ struct DisplayView: View {
|
|||
@State private var selectedAccentColor: Color = .blue
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
@State private var justSaved = false
|
||||
|
||||
private var accentColor: Color {
|
||||
if customAccentColorHex.isEmpty {
|
||||
return .blue
|
||||
|
|
@ -76,133 +89,164 @@ struct DisplayView: View {
|
|||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color(colorScheme == .dark ? .black : UIColor.systemBackground)
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
// Background gradient (glassy style)
|
||||
LinearGradient(
|
||||
gradient: Gradient(colors: [
|
||||
Color(UIColor.systemBackground),
|
||||
Color(UIColor.secondarySystemBackground)
|
||||
]),
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
// App Theme Section with Accent Colors
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Theme")
|
||||
.font(.title3)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.primary)
|
||||
.padding(.bottom, 4)
|
||||
|
||||
// Theme selector with preview images
|
||||
HStack(spacing: 12) {
|
||||
// Automatic Theme
|
||||
ThemeOptionButton(
|
||||
title: "Automatic",
|
||||
imageName: "System",
|
||||
isSelected: appTheme == "system",
|
||||
accentColor: selectedAccentColor,
|
||||
action: { appTheme = "system" }
|
||||
)
|
||||
|
||||
// Light Theme
|
||||
ThemeOptionButton(
|
||||
title: "Light",
|
||||
imageName: "LightUI",
|
||||
isSelected: appTheme == "light",
|
||||
accentColor: selectedAccentColor,
|
||||
action: { appTheme = "light" }
|
||||
)
|
||||
|
||||
// Dark Theme
|
||||
ThemeOptionButton(
|
||||
title: "Dark",
|
||||
imageName: "DarkUI",
|
||||
isSelected: appTheme == "dark",
|
||||
accentColor: selectedAccentColor,
|
||||
action: { appTheme = "dark" }
|
||||
)
|
||||
}
|
||||
.onChange(of: appTheme) { newValue in
|
||||
applyTheme(newValue)
|
||||
}
|
||||
|
||||
Divider()
|
||||
.padding(.vertical, 4)
|
||||
|
||||
// Accent Color Picker
|
||||
AccentColorPicker(selectedColor: Binding(
|
||||
get: { selectedAccentColor },
|
||||
set: { newColor in
|
||||
selectedAccentColor = newColor
|
||||
saveCustomAccentColor(newColor)
|
||||
}
|
||||
))
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
usernameCard
|
||||
jitOptionsCard
|
||||
}
|
||||
.padding(.vertical, 16)
|
||||
.padding(.horizontal, 16)
|
||||
.background(Color(UIColor.systemGray6))
|
||||
.cornerRadius(16)
|
||||
|
||||
// Username Section
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Username")
|
||||
.font(.title3)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
HStack {
|
||||
TextField("Username", text: $username)
|
||||
.font(.body)
|
||||
.foregroundColor(.primary)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
if !username.isEmpty {
|
||||
Button(action: { username = "" }) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundColor(Color(UIColor.tertiaryLabel))
|
||||
.font(.system(size: 16))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.background(Color(UIColor.tertiarySystemFill))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.padding(.vertical, 16)
|
||||
.padding(.horizontal, 16)
|
||||
.background(Color(UIColor.systemGray6))
|
||||
.cornerRadius(16)
|
||||
|
||||
// JIT Options Section
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("App List")
|
||||
.font(.title3)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Toggle("Load App Icons", isOn: $loadAppIconsOnJIT)
|
||||
.foregroundColor(.primary)
|
||||
.tint(.green)
|
||||
|
||||
Text("Disabling this will hide app icons in the app list and may improve performance, while also giving it a more minimalistic look.")
|
||||
.font(.footnote)
|
||||
.foregroundColor(Color(UIColor.secondaryLabel))
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 16)
|
||||
.padding(.horizontal, 16)
|
||||
.background(Color(UIColor.systemGray6))
|
||||
.cornerRadius(16)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 30)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 20)
|
||||
|
||||
if justSaved {
|
||||
VStack {
|
||||
Spacer()
|
||||
Text("Saved")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(.ultraThinMaterial, in: Capsule())
|
||||
.overlay(Capsule().strokeBorder(Color.white.opacity(0.15), lineWidth: 1))
|
||||
.shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 3)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
.padding(.bottom, 30)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.25), value: justSaved)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Display")
|
||||
.onAppear {
|
||||
loadCustomAccentColor()
|
||||
applyTheme(appTheme)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Display")
|
||||
.onAppear {
|
||||
loadCustomAccentColor()
|
||||
applyTheme(appTheme)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cards
|
||||
|
||||
private var usernameCard: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Username")
|
||||
.font(.title3)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
HStack {
|
||||
TextField("Username", text: $username)
|
||||
.font(.body)
|
||||
.foregroundColor(.primary)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
if !username.isEmpty {
|
||||
Button(action: {
|
||||
username = ""
|
||||
showSavedToast()
|
||||
}) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundColor(Color(UIColor.tertiaryLabel))
|
||||
.font(.system(size: 16))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.padding(20)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 4)
|
||||
}
|
||||
|
||||
private var jitOptionsCard: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("App List")
|
||||
.font(.title3)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Toggle("Load App Icons", isOn: $loadAppIconsOnJIT)
|
||||
.tint(accentColor)
|
||||
|
||||
Text("Disabling this will hide app icons in the app list and may improve performance, while also giving it a more minimalistic look.")
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 4)
|
||||
}
|
||||
|
||||
private var themeCard: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Theme")
|
||||
.font(.title3)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
ThemeOptionButton(title: "System", imageName: "theme-system", isSelected: appTheme == "system", accentColor: accentColor) {
|
||||
appTheme = "system"
|
||||
applyTheme(appTheme)
|
||||
showSavedToast()
|
||||
}
|
||||
ThemeOptionButton(title: "Light", imageName: "theme-light", isSelected: appTheme == "light", accentColor: accentColor) {
|
||||
appTheme = "light"
|
||||
applyTheme(appTheme)
|
||||
showSavedToast()
|
||||
}
|
||||
ThemeOptionButton(title: "Dark", imageName: "theme-dark", isSelected: appTheme == "dark", accentColor: accentColor) {
|
||||
appTheme = "dark"
|
||||
applyTheme(appTheme)
|
||||
showSavedToast()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 4)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func loadCustomAccentColor() {
|
||||
if customAccentColorHex.isEmpty {
|
||||
selectedAccentColor = .blue
|
||||
|
|
@ -211,27 +255,26 @@ struct DisplayView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func saveCustomAccentColor(_ color: Color) {
|
||||
// Always save the custom color if we got here (since auto theme mode is disabled)
|
||||
customAccentColorHex = color.toHex() ?? ""
|
||||
}
|
||||
|
||||
private func applyTheme(_ theme: String) {
|
||||
// Set the app's theme (will apply when app is restarted)
|
||||
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let window = windowScene.windows.first {
|
||||
switch theme {
|
||||
case "dark":
|
||||
window.overrideUserInterfaceStyle = .dark
|
||||
case "light":
|
||||
window.overrideUserInterfaceStyle = .light
|
||||
default:
|
||||
window.overrideUserInterfaceStyle = .unspecified
|
||||
case "dark": window.overrideUserInterfaceStyle = .dark
|
||||
case "light": window.overrideUserInterfaceStyle = .light
|
||||
default: window.overrideUserInterfaceStyle = .unspecified
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func showSavedToast() {
|
||||
withAnimation { justSaved = true }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||
withAnimation { justSaved = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Theme Option Button (Glassy style)
|
||||
struct ThemeOptionButton: View {
|
||||
let title: String
|
||||
let imageName: String
|
||||
|
|
@ -245,19 +288,21 @@ struct ThemeOptionButton: View {
|
|||
Image(imageName)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(height: 160)
|
||||
.cornerRadius(8)
|
||||
.frame(height: 120)
|
||||
.cornerRadius(12)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(isSelected ? accentColor : Color.clear, lineWidth: 2)
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(isSelected ? accentColor : .clear, lineWidth: 2)
|
||||
)
|
||||
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundColor(isSelected ? accentColor : .primary)
|
||||
}
|
||||
.padding(8)
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12))
|
||||
.shadow(color: .black.opacity(0.08), radius: 6, x: 0, y: 2)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -48,174 +48,112 @@ struct HomeView: View {
|
|||
@AppStorage("DefaultScriptName") var selectedScript = "attachDetach.js"
|
||||
@State var jsModel: RunJSViewModel?
|
||||
|
||||
// Observe VPN status
|
||||
@StateObject private var tunnel = TunnelManager.shared
|
||||
// Mirror heartbeat boolean
|
||||
@State private var heartbeatOK = false
|
||||
|
||||
private var accentColor: Color {
|
||||
if customAccentColorHex.isEmpty {
|
||||
return .blue
|
||||
return .white
|
||||
} else {
|
||||
return Color(hex: customAccentColorHex) ?? .blue
|
||||
return Color(hex: customAccentColorHex) ?? .white
|
||||
}
|
||||
}
|
||||
|
||||
// Derived states
|
||||
private var ddiMounted: Bool { isMounted() }
|
||||
private var canConnectByApp: Bool { pairingFileExists && ddiMounted }
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Use system background
|
||||
Color(colorScheme == .dark ? .black : .white)
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
|
||||
VStack(spacing: 25) {
|
||||
Spacer()
|
||||
VStack(spacing: 5) {
|
||||
Text("Welcome to StikDebug \(username)!")
|
||||
.font(.system(.largeTitle, design: .rounded))
|
||||
.fontWeight(.bold)
|
||||
|
||||
Text(pairingFileExists ? "Click connect to get started" : "Pick pairing file to get started")
|
||||
.font(.system(.subheadline, design: .rounded))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(.top, 40)
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
// DeviceInfo-style subtle gradient background
|
||||
LinearGradient(
|
||||
gradient: Gradient(colors: [
|
||||
Color(UIColor.systemBackground),
|
||||
Color(UIColor.secondarySystemBackground)
|
||||
]),
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
|
||||
Button(action: {
|
||||
|
||||
|
||||
if pairingFileExists {
|
||||
// Got a pairing file, show apps
|
||||
if !isMounted() {
|
||||
showAlert(title: "Device Not Mounted".localized, message: "The Developer Disk Image has not been mounted yet. Check in settings for more information.".localized, showOk: true) { cool in
|
||||
// No Need
|
||||
}
|
||||
return
|
||||
}
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
// Top card: status + both actions
|
||||
topStatusAndActionsCard
|
||||
|
||||
isShowingInstalledApps = true
|
||||
// Tools (Console)
|
||||
toolsCard
|
||||
|
||||
} else {
|
||||
// No pairing file yet, let's get one
|
||||
isShowingPairingFilePicker = true
|
||||
}
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: pairingFileExists ? "cable.connector.horizontal" : "doc.badge.plus")
|
||||
.font(.system(size: 20))
|
||||
Text(pairingFileExists ? "Connect by App" : "Select Pairing File")
|
||||
.font(.system(.title3, design: .rounded))
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(accentColor)
|
||||
.foregroundColor(accentColor.contrastText())
|
||||
.cornerRadius(16)
|
||||
.shadow(color: accentColor.opacity(0.3), radius: 8, x: 0, y: 4)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
if pairingFileExists && enableAdvancedOptions {
|
||||
Button(action: {
|
||||
pidTextAlertShow = true
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "cable.connector.horizontal")
|
||||
.font(.system(size: 20))
|
||||
Text("Connect by PID")
|
||||
.font(.system(.title3, design: .rounded))
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(accentColor)
|
||||
.foregroundColor(accentColor.contrastText())
|
||||
.cornerRadius(16)
|
||||
.shadow(color: accentColor.opacity(0.3), radius: 8, x: 0, y: 4)
|
||||
// Helpful tips
|
||||
tipsCard
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 30)
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
showingConsoleLogsView = true
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "apple.terminal")
|
||||
.font(.system(size: 20))
|
||||
Text("Open Console")
|
||||
.font(.system(.title3, design: .rounded))
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(accentColor)
|
||||
.foregroundColor(accentColor.contrastText())
|
||||
.cornerRadius(16)
|
||||
.shadow(color: accentColor.opacity(0.3), radius: 8, x: 0, y: 4)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.sheet(isPresented: $showingConsoleLogsView) {
|
||||
ConsoleLogsView()
|
||||
}
|
||||
|
||||
// Status message area - keeps layout consistent
|
||||
ZStack {
|
||||
// Progress bar for importing file
|
||||
if isImportingFile {
|
||||
// Busy overlay during pairing import (DeviceInfo-style)
|
||||
if isImportingFile {
|
||||
Color.black.opacity(0.35).ignoresSafeArea()
|
||||
VStack(spacing: 12) {
|
||||
ProgressView("Processing pairing file...")
|
||||
VStack(spacing: 8) {
|
||||
HStack {
|
||||
Text("Processing pairing file...")
|
||||
.font(.system(.caption, design: .rounded))
|
||||
.foregroundColor(.secondaryText)
|
||||
Spacer()
|
||||
Text("\(Int(importProgress * 100))%")
|
||||
.font(.system(.caption, design: .rounded))
|
||||
.foregroundColor(.secondaryText)
|
||||
}
|
||||
|
||||
GeometryReader { geometry in
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color.black.opacity(0.2))
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(Color(UIColor.tertiarySystemFill))
|
||||
.frame(height: 8)
|
||||
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(Color.green)
|
||||
.frame(width: geometry.size.width * CGFloat(importProgress), height: 8)
|
||||
.animation(.linear(duration: 0.3), value: importProgress)
|
||||
}
|
||||
}
|
||||
.frame(height: 8)
|
||||
Text("\(Int(importProgress * 100))%")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 40)
|
||||
.padding(.top, 6)
|
||||
}
|
||||
|
||||
// Success message
|
||||
if showPairingFileMessage && pairingFileIsValid {
|
||||
Text("✓ Pairing file successfully imported")
|
||||
.font(.system(.callout, design: .rounded))
|
||||
.foregroundColor(.green)
|
||||
.padding(.vertical, 4)
|
||||
.padding(.horizontal, 12)
|
||||
.background(Color.green.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
// Invisible text to reserve space - no layout jumps
|
||||
Text(" ").opacity(0)
|
||||
.padding(16)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4)
|
||||
}
|
||||
.frame(height: isImportingFile ? 60 : 30) // Adjust height based on what's showing
|
||||
|
||||
Spacer()
|
||||
// Success toast after import (DeviceInfo-style)
|
||||
if showPairingFileMessage && pairingFileIsValid && !isImportingFile {
|
||||
VStack {
|
||||
Spacer()
|
||||
Text("✓ Pairing file successfully imported")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(.ultraThinMaterial, in: Capsule())
|
||||
.overlay(Capsule().strokeBorder(Color.white.opacity(0.15), lineWidth: 1))
|
||||
.shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 3)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
.padding(.bottom, 30)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.25), value: showPairingFileMessage)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.navigationTitle("Home")
|
||||
}
|
||||
.onAppear {
|
||||
checkPairingFileExists()
|
||||
// Don't initialize specific color value when empty - empty means "use system theme"
|
||||
// This was causing the toggle to turn off when returning to settings
|
||||
|
||||
// Initialize background color
|
||||
refreshBackground()
|
||||
|
||||
// Add notification observer for showing pairing file picker
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: NSNotification.Name("ShowPairingFilePicker"),
|
||||
object: nil,
|
||||
|
|
@ -227,11 +165,11 @@ struct HomeView: View {
|
|||
.onReceive(timer) { _ in
|
||||
refreshBackground()
|
||||
checkPairingFileExists()
|
||||
|
||||
// Update heartbeat mirror from global
|
||||
heartbeatOK = pubHeartBeat
|
||||
}
|
||||
.fileImporter(isPresented: $isShowingPairingFilePicker, allowedContentTypes: [UTType(filenameExtension: "mobiledevicepairing", conformingTo: .data)!, .propertyList]) {result in
|
||||
switch result {
|
||||
|
||||
case .success(let url):
|
||||
let fileManager = FileManager.default
|
||||
let accessing = url.startAccessingSecurityScopedResource()
|
||||
|
|
@ -396,24 +334,291 @@ struct HomeView: View {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Styled Sections
|
||||
|
||||
private func checkPairingFileExists() {
|
||||
let fileExists = FileManager.default.fileExists(atPath: URL.documentsDirectory.appendingPathComponent("pairingFile.plist").path)
|
||||
|
||||
// If the file exists, check if it's valid
|
||||
if fileExists {
|
||||
// Check if the pairing file is valid
|
||||
let isValid = isPairing()
|
||||
pairingFileExists = isValid
|
||||
// Top card that includes: status icons + both actions
|
||||
private var topStatusAndActionsCard: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
// Header row with icon-only indicators
|
||||
HStack(spacing: 12) {
|
||||
// Pairing
|
||||
indicatorCapsule(
|
||||
ok: pairingFileExists,
|
||||
systemImage: "doc.badge.plus",
|
||||
a11y: "Pairing"
|
||||
)
|
||||
// DDI
|
||||
indicatorCapsule(
|
||||
ok: ddiMounted,
|
||||
systemImage: "externaldrive",
|
||||
a11y: "Developer Disk Image"
|
||||
)
|
||||
// VPN
|
||||
indicatorCapsule(
|
||||
ok: tunnel.tunnelStatus == .connected,
|
||||
systemImage: "lock.shield",
|
||||
a11y: "VPN"
|
||||
)
|
||||
// Heartbeat
|
||||
indicatorCapsule(
|
||||
ok: heartbeatOK,
|
||||
systemImage: "waveform.path.ecg",
|
||||
a11y: "Heartbeat"
|
||||
)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
// Welcome text
|
||||
VStack(spacing: 4) {
|
||||
Text("Welcome, \(username)")
|
||||
.font(.system(.title2, design: .rounded).weight(.bold))
|
||||
.foregroundColor(.primary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Text(helperSubtitle())
|
||||
.font(.system(.subheadline, design: .rounded))
|
||||
.foregroundColor(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.bottom, 4)
|
||||
|
||||
// Buttons in one vertical stack
|
||||
VStack(spacing: 10) {
|
||||
// Connect by App / Select Pairing File
|
||||
Button(action: primaryActionTapped) {
|
||||
HStack {
|
||||
Image(systemName: pairingFileExists ? "cable.connector.horizontal" : "doc.badge.plus")
|
||||
.font(.system(size: 20))
|
||||
Text(pairingFileExists ? "Connect by App" : "Select Pairing File")
|
||||
.font(.system(.title3, design: .rounded))
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.background(Color.white, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.foregroundColor(.black)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.stroke(Color.white.opacity(0.2), lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
.disabled(pairingFileExists && !ddiMounted)
|
||||
.opacity(pairingFileExists && !ddiMounted ? 0.6 : 1.0)
|
||||
|
||||
// Connect by PID (Advanced only)
|
||||
if pairingFileExists && enableAdvancedOptions {
|
||||
Button(action: { pidTextAlertShow = true }) {
|
||||
HStack {
|
||||
Image(systemName: "number.circle")
|
||||
.font(.system(size: 20))
|
||||
Text("Connect by PID")
|
||||
.font(.system(.title3, design: .rounded))
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.background(Color.white, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.foregroundColor(.black)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.stroke(Color.white.opacity(0.2), lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inline status / progress
|
||||
if isImportingFile {
|
||||
VStack(spacing: 8) {
|
||||
HStack {
|
||||
Text("Processing pairing file…")
|
||||
.font(.system(.caption, design: .rounded))
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
Text("\(Int(importProgress * 100))%")
|
||||
.font(.system(.caption, design: .rounded))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
GeometryReader { geometry in
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(Color(UIColor.tertiarySystemFill))
|
||||
.frame(height: 8)
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(Color.green)
|
||||
.frame(width: geometry.size.width * CGFloat(importProgress), height: 8)
|
||||
.animation(.linear(duration: 0.3), value: importProgress)
|
||||
}
|
||||
}
|
||||
.frame(height: 8)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
} else if showPairingFileMessage && pairingFileIsValid {
|
||||
HStack(spacing: 10) {
|
||||
StatusDot(color: .green)
|
||||
Text("Pairing file successfully imported")
|
||||
.font(.system(.callout, design: .rounded))
|
||||
.foregroundColor(.green)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4)
|
||||
}
|
||||
|
||||
// Compact indicator builder: dot + SF Symbol in a capsule
|
||||
private func indicatorCapsule(ok: Bool, systemImage: String, a11y: String) -> some View {
|
||||
let fixedHeight: CGFloat = 32
|
||||
return HStack(spacing: 8) {
|
||||
StatusDot(color: ok ? .green : .orange)
|
||||
Image(systemName: systemImage)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.primary)
|
||||
.accessibilityLabel(a11y)
|
||||
}
|
||||
.frame(height: fixedHeight)
|
||||
.padding(.horizontal, 10)
|
||||
.background(
|
||||
Capsule(style: .continuous)
|
||||
.fill(Color(UIColor.tertiarySystemBackground))
|
||||
)
|
||||
}
|
||||
|
||||
private var toolsCard: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Tools")
|
||||
.font(.headline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Button(action: { showingConsoleLogsView = true }) {
|
||||
// Centered icon + text, no chevron
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "terminal")
|
||||
.font(.system(size: 20))
|
||||
Text("Open Console")
|
||||
.font(.system(.title3, design: .rounded))
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.foregroundColor(.black)
|
||||
.padding(.vertical, 14)
|
||||
.padding(.horizontal, 12)
|
||||
.background(Color.white, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.stroke(Color.white.opacity(0.2), lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $showingConsoleLogsView) {
|
||||
ConsoleLogsView()
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4)
|
||||
}
|
||||
|
||||
private var tipsCard: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Tips")
|
||||
.font(.headline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
if !pairingFileExists {
|
||||
tipRow(
|
||||
systemImage: "doc.badge.plus",
|
||||
title: "Pairing file required",
|
||||
message: "Import your device’s pairing file to begin."
|
||||
)
|
||||
}
|
||||
|
||||
if pairingFileExists && !ddiMounted {
|
||||
tipRow(
|
||||
systemImage: "externaldrive.badge.exclamationmark",
|
||||
title: "Developer Disk Image not mounted",
|
||||
message: "Go to Settings → Developer Disk Image and ensure it’s mounted."
|
||||
)
|
||||
}
|
||||
|
||||
tipRow(
|
||||
systemImage: "lock.shield",
|
||||
title: "Local only",
|
||||
message: "StikDebug runs entirely on-device. No data leaves your device."
|
||||
)
|
||||
}
|
||||
.padding(20)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4)
|
||||
}
|
||||
|
||||
private func tipRow(systemImage: String, title: String, message: String) -> some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: systemImage)
|
||||
.foregroundColor(accentColor)
|
||||
.font(.system(size: 18))
|
||||
.frame(width: 24)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title).font(.subheadline).fontWeight(.semibold)
|
||||
Text(message).font(.footnote).foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
// MARK: - Helpers (UI)
|
||||
|
||||
private func helperSubtitle() -> String {
|
||||
if canConnectByApp { return "Select an app to start debugging." }
|
||||
if pairingFileExists { return "Mount the Developer Disk Image in Settings." }
|
||||
return "Import your pairing file to get started."
|
||||
}
|
||||
|
||||
private func primaryActionTapped() {
|
||||
if pairingFileExists {
|
||||
if !ddiMounted {
|
||||
showAlert(title: "Device Not Mounted".localized, message: "The Developer Disk Image has not been mounted yet. Check in settings for more information.".localized, showOk: true) { _ in }
|
||||
return
|
||||
}
|
||||
isShowingInstalledApps = true
|
||||
} else {
|
||||
pairingFileExists = false
|
||||
isShowingPairingFilePicker = true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Logic
|
||||
|
||||
private func checkPairingFileExists() {
|
||||
let fileExists = FileManager.default.fileExists(atPath: URL.documentsDirectory.appendingPathComponent("pairingFile.plist").path)
|
||||
pairingFileExists = fileExists && isPairing()
|
||||
}
|
||||
|
||||
private func refreshBackground() {
|
||||
// This function is no longer needed for background color
|
||||
// but we'll keep it empty to avoid breaking anything
|
||||
// kept for compatibility
|
||||
}
|
||||
|
||||
private func getJsCallback(_ script: Data, name: String? = nil) -> DebugAppCallback {
|
||||
|
|
@ -436,7 +641,6 @@ struct HomeView: View {
|
|||
// if advanced mode is disabled the whole script loading will be skipped. If use default script is disabled default script will not be loaded
|
||||
private func startJITInBackground(bundleID: String? = nil, pid : Int? = nil, scriptData: Data? = nil, scriptName : String? = nil, triggeredByURLScheme: Bool = false) {
|
||||
isProcessing = true
|
||||
// Add log message
|
||||
LogManager.shared.addInfoLog("Starting Debug for \(bundleID ?? String(pid ?? 0))")
|
||||
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
|
|
@ -468,7 +672,6 @@ struct HomeView: View {
|
|||
scriptData = nil
|
||||
}
|
||||
|
||||
|
||||
var callback: DebugAppCallback? = nil
|
||||
|
||||
if let scriptData {
|
||||
|
|
@ -481,9 +684,7 @@ struct HomeView: View {
|
|||
}
|
||||
|
||||
let logger: LogFunc = { message in
|
||||
|
||||
if let message = message {
|
||||
// Log messages from the JIT process
|
||||
LogManager.shared.addInfoLog(message)
|
||||
}
|
||||
}
|
||||
|
|
@ -525,6 +726,50 @@ struct HomeView: View {
|
|||
|
||||
}
|
||||
|
||||
// MARK: - Status Light Components
|
||||
|
||||
private struct StatusLight: View {
|
||||
enum State { case ok, warn }
|
||||
var title: String
|
||||
var state: State
|
||||
|
||||
var body: some View {
|
||||
let fixedHeight: CGFloat = 32
|
||||
HStack(spacing: 8) {
|
||||
StatusDot(color: state == .ok ? .green : .orange)
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
.frame(height: fixedHeight)
|
||||
.padding(.horizontal, 10)
|
||||
.background(
|
||||
Capsule(style: .continuous)
|
||||
.fill(Color(UIColor.tertiarySystemBackground))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct StatusDot: View {
|
||||
var color: Color
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(color.opacity(0.25))
|
||||
.frame(width: 20, height: 20)
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: 12, height: 12)
|
||||
.shadow(color: color.opacity(0.6), radius: 4, x: 0, y: 0)
|
||||
}
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(colorScheme == .dark ? Color.white.opacity(0.15) : Color.black.opacity(0.1), lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class InstalledAppsViewModel: ObservableObject {
|
||||
@Published var apps: [String: String] = [:]
|
||||
|
||||
|
|
|
|||
|
|
@ -34,7 +34,17 @@ struct InstalledAppsListView: View {
|
|||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
Group {
|
||||
ZStack {
|
||||
LinearGradient(
|
||||
gradient: Gradient(colors: [
|
||||
Color(UIColor.systemBackground),
|
||||
Color(UIColor.secondarySystemBackground)
|
||||
]),
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
|
||||
if viewModel.apps.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
|
|
@ -48,85 +58,118 @@ struct InstalledAppsListView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
.background(Color(UIColor.systemGroupedBackground).edgesIgnoringSafeArea(.all))
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.resizable().scaledToFit().frame(width: 60, height: 60).foregroundColor(.gray)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 60, height: 60)
|
||||
.foregroundColor(.gray)
|
||||
|
||||
Text("No Debuggable App Found")
|
||||
.font(.title2).fontWeight(.semibold).foregroundColor(.primary)
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text("""
|
||||
StikDebug can only connect to apps with the "**get-task-allow**" entitlement. \
|
||||
Please check if the app you want to connect to is signed with a **development** certificate.
|
||||
""")
|
||||
.font(.body).foregroundColor(.secondary).multilineTextAlignment(.center).padding(.horizontal)
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.padding().frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(UIColor.systemBackground))
|
||||
.padding(30)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
.padding()
|
||||
.shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4)
|
||||
}
|
||||
|
||||
private var appsList: some View {
|
||||
List {
|
||||
if !favoriteApps.isEmpty {
|
||||
Section(header: Text(String(format: "Favorites (%d/4)".localized, favoriteApps.count))) {
|
||||
ForEach(favoriteApps, id: \.self) { bundleID in
|
||||
AppButton(
|
||||
bundleID: bundleID,
|
||||
appName: viewModel.apps[bundleID] ?? bundleID,
|
||||
recentApps: $recentApps,
|
||||
favoriteApps: $favoriteApps,
|
||||
appIcons: $appIcons,
|
||||
onSelectApp: onSelectApp,
|
||||
sharedDefaults: sharedDefaults
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !filteredRecents.isEmpty {
|
||||
Section(header: Text("Recents".localized)) {
|
||||
ForEach(filteredRecents, id: \.self) { bundleID in
|
||||
AppButton(
|
||||
bundleID: bundleID,
|
||||
appName: viewModel.apps[bundleID] ?? bundleID,
|
||||
recentApps: $recentApps,
|
||||
favoriteApps: $favoriteApps,
|
||||
appIcons: $appIcons,
|
||||
onSelectApp: onSelectApp,
|
||||
sharedDefaults: sharedDefaults
|
||||
)
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button(role: .destructive) {
|
||||
withAnimation {
|
||||
recentApps.removeAll { $0 == bundleID }
|
||||
sharedDefaults.set(recentApps, forKey: "recentApps")
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
ScrollView {
|
||||
VStack(spacing: 18) {
|
||||
if !favoriteApps.isEmpty {
|
||||
glassSection(title: String(format: "Favorites (%d/4)".localized, favoriteApps.count)) {
|
||||
ForEach(favoriteApps, id: \.self) { bundleID in
|
||||
AppButton(
|
||||
bundleID: bundleID,
|
||||
appName: viewModel.apps[bundleID] ?? bundleID,
|
||||
recentApps: $recentApps,
|
||||
favoriteApps: $favoriteApps,
|
||||
appIcons: $appIcons,
|
||||
onSelectApp: onSelectApp,
|
||||
sharedDefaults: sharedDefaults
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text((favoriteApps.isEmpty && filteredRecents.isEmpty) ? "" : "All Applications".localized)) {
|
||||
ForEach(viewModel.apps.sorted(by: { $0.key < $1.key }), id: \.key) { bundleID, appName in
|
||||
AppButton(
|
||||
bundleID: bundleID,
|
||||
appName: appName,
|
||||
recentApps: $recentApps,
|
||||
favoriteApps: $favoriteApps,
|
||||
appIcons: $appIcons,
|
||||
onSelectApp: onSelectApp,
|
||||
sharedDefaults: sharedDefaults
|
||||
)
|
||||
if !filteredRecents.isEmpty {
|
||||
glassSection(title: "Recents".localized) {
|
||||
ForEach(filteredRecents, id: \.self) { bundleID in
|
||||
AppButton(
|
||||
bundleID: bundleID,
|
||||
appName: viewModel.apps[bundleID] ?? bundleID,
|
||||
recentApps: $recentApps,
|
||||
favoriteApps: $favoriteApps,
|
||||
appIcons: $appIcons,
|
||||
onSelectApp: onSelectApp,
|
||||
sharedDefaults: sharedDefaults
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
glassSection(title: (favoriteApps.isEmpty && filteredRecents.isEmpty) ? "All Applications".localized : "All Applications".localized) {
|
||||
ForEach(viewModel.apps.sorted(by: { $0.key < $1.key }), id: \.key) { bundleID, appName in
|
||||
AppButton(
|
||||
bundleID: bundleID,
|
||||
appName: appName,
|
||||
recentApps: $recentApps,
|
||||
favoriteApps: $favoriteApps,
|
||||
appIcons: $appIcons,
|
||||
onSelectApp: onSelectApp,
|
||||
sharedDefaults: sharedDefaults
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 24)
|
||||
}
|
||||
.listStyle(.plain)
|
||||
}
|
||||
|
||||
private func glassSection<Content: View>(title: String, @ViewBuilder content: () -> Content) -> some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -151,7 +194,17 @@ struct AppButton: View {
|
|||
appText
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, loadAppIconsOnJIT ? 0 : 8)
|
||||
.padding(.vertical, loadAppIconsOnJIT ? 8 : 12)
|
||||
.padding(.horizontal, 12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.1), radius: 6, x: 0, y: 3)
|
||||
}
|
||||
.contextMenu {
|
||||
Button(action: toggleFavorite) {
|
||||
|
|
@ -186,16 +239,16 @@ struct AppButton: View {
|
|||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 60, height: 60)
|
||||
.frame(width: 54, height: 54)
|
||||
.cornerRadius(12)
|
||||
.shadow(color: colorScheme == .dark ? .black.opacity(0.2) : .gray.opacity(0.2), radius: 3, x: 0, y: 1)
|
||||
.shadow(color: .black.opacity(0.2), radius: 4, x: 0, y: 2)
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color(UIColor.systemGray5))
|
||||
.frame(width: 60, height: 60)
|
||||
.frame(width: 54, height: 54)
|
||||
.overlay(
|
||||
Image(systemName: "app")
|
||||
.font(.system(size: 26))
|
||||
.font(.system(size: 24))
|
||||
.foregroundColor(.gray)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1)
|
||||
|
|
@ -205,13 +258,13 @@ struct AppButton: View {
|
|||
}
|
||||
|
||||
private var appText: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(appName)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundColor(.primary)
|
||||
Text(bundleID)
|
||||
.font(.system(size: 15))
|
||||
.foregroundColor(.gray)
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
|
@ -251,15 +304,11 @@ struct AppButton: View {
|
|||
private func loadAppIcon(for bundleID: String) {
|
||||
guard loadAppIconsOnJIT else { return }
|
||||
|
||||
// 1) Check disk cache first
|
||||
if let cachedImage = loadCachedIcon(bundleID: bundleID) {
|
||||
DispatchQueue.main.async {
|
||||
appIcons[bundleID] = cachedImage
|
||||
}
|
||||
DispatchQueue.main.async { appIcons[bundleID] = cachedImage }
|
||||
return
|
||||
}
|
||||
|
||||
// 2) Otherwise fetch from network and then cache
|
||||
AppStoreIconFetcher.getIcon(for: bundleID) { image in
|
||||
guard let image = image else { return }
|
||||
DispatchQueue.main.async {
|
||||
|
|
@ -313,7 +362,6 @@ extension Array: @retroactive RawRepresentable where Element: Codable {
|
|||
}
|
||||
}
|
||||
|
||||
// Preview helper
|
||||
#Preview {
|
||||
InstalledAppsListView { _ in }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,354 +6,45 @@
|
|||
//
|
||||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct TabItem: Identifiable, Hashable {
|
||||
let id: Int
|
||||
let title: String
|
||||
let systemImage: String
|
||||
}
|
||||
|
||||
struct BlurView: UIViewRepresentable {
|
||||
let style: UIBlurEffect.Style
|
||||
func makeUIView(context: Context) -> UIVisualEffectView {
|
||||
UIVisualEffectView(effect: UIBlurEffect(style: style))
|
||||
}
|
||||
func updateUIView(_ uiView: UIVisualEffectView, context: Context) {}
|
||||
}
|
||||
|
||||
struct RoundedCorner: Shape {
|
||||
var radius: CGFloat = .infinity
|
||||
var corners: UIRectCorner = .allCorners
|
||||
func path(in rect: CGRect) -> Path {
|
||||
Path(UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius)).cgPath)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View {
|
||||
clipShape(RoundedCorner(radius: radius, corners: corners))
|
||||
}
|
||||
}
|
||||
|
||||
@inline(__always)
|
||||
fileprivate func smoothstep(_ x: CGFloat, _ a: CGFloat, _ b: CGFloat) -> CGFloat {
|
||||
if a == b { return x >= b ? 1 : 0 }
|
||||
let t = max(0, min(1, (x - a) / (b - a)))
|
||||
return t * t * (3 - 2 * t)
|
||||
}
|
||||
|
||||
fileprivate let drawerSpring: Animation = .interactiveSpring(response: 0.42, dampingFraction: 0.86, blendDuration: 0.1)
|
||||
|
||||
fileprivate struct DrawerHandle: View {
|
||||
let progress: CGFloat
|
||||
var body: some View {
|
||||
RoundedRectangle(cornerRadius: 2.5)
|
||||
.frame(width: 40, height: 5)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.top, 8)
|
||||
.scaleEffect(1 + 0.10 * progress)
|
||||
.opacity(0.9 + 0.1 * (1 - progress))
|
||||
.animation(drawerSpring, value: progress)
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct FavoritesDockView: View {
|
||||
let favorites: [TabItem]
|
||||
@Binding var selection: Int
|
||||
let accentColor: Color
|
||||
let progress: CGFloat
|
||||
let bottomPadding: CGFloat
|
||||
|
||||
var body: some View {
|
||||
let fade = 1 - smoothstep(progress, 0.30, 0.55)
|
||||
let yLift: CGFloat = 8 * smoothstep(progress, 0.00, 0.55)
|
||||
HStack {
|
||||
Spacer()
|
||||
ForEach(favorites.indices, id: \.self) { i in
|
||||
let item = favorites[i]
|
||||
Button {
|
||||
selection = item.id
|
||||
} label: {
|
||||
Image(systemName: item.systemImage)
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
.foregroundColor(selection == item.id ? accentColor : .secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.accessibilityLabel(Text(item.title))
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
.padding(.bottom, bottomPadding)
|
||||
.opacity(fade)
|
||||
.offset(y: yLift)
|
||||
.animation(drawerSpring, value: progress)
|
||||
.allowsHitTesting(progress < 0.5)
|
||||
.zIndex(progress < 0.5 ? 1 : 0)
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct AppGridView: View {
|
||||
static let columns: [GridItem] = Array(repeating: GridItem(.flexible(), spacing: 16), count: 4)
|
||||
let items: [TabItem]
|
||||
@Binding var selection: Int
|
||||
let accentColor: Color
|
||||
let progress: CGFloat
|
||||
let slotMap: [Int: Int]
|
||||
let assignToSlot: (Int, Int) -> Void
|
||||
let debugLocationId: Int?
|
||||
|
||||
var body: some View {
|
||||
let fade = smoothstep(progress, 0.45, 0.75)
|
||||
let yRise: CGFloat = 14 * (1 - fade)
|
||||
|
||||
ScrollView {
|
||||
LazyVGrid(columns: Self.columns, spacing: 16) {
|
||||
ForEach(items.indices, id: \.self) { i in
|
||||
let item = items[i]
|
||||
Button {
|
||||
selection = item.id
|
||||
} label: {
|
||||
VStack(spacing: 8) {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Image(systemName: item.systemImage)
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
if let slot = slotMap[item.id] {
|
||||
Text("\(slot)")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.padding(4)
|
||||
.background(.ultraThinMaterial, in: Capsule())
|
||||
.offset(x: 6, y: -6)
|
||||
.accessibilityLabel(Text("Favorites Dock position \(slot)"))
|
||||
}
|
||||
}
|
||||
Text(item.title)
|
||||
.font(.caption)
|
||||
}
|
||||
.foregroundColor(selection == item.id ? accentColor : .primary)
|
||||
.padding(8)
|
||||
}
|
||||
.contextMenu {
|
||||
Text("Add to Favorites Dock")
|
||||
Divider()
|
||||
ForEach(1...4, id: \.self) { position in
|
||||
Button {
|
||||
assignToSlot(item.id, position)
|
||||
} label: {
|
||||
if slotMap[item.id] == position {
|
||||
Label("Position \(position) • Assigned", systemImage: "checkmark.circle.fill")
|
||||
} else {
|
||||
Text("Position \(position)")
|
||||
}
|
||||
}
|
||||
}
|
||||
#if DEBUG
|
||||
if let locId = debugLocationId, item.id == locId {
|
||||
Divider()
|
||||
Text("Location Options")
|
||||
Button("Open Location Simulator") {
|
||||
selection = locId
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.scaleEffect(0.98 + 0.02 * fade)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.scrollDisabled(true)
|
||||
.scrollIndicators(.hidden)
|
||||
.opacity(fade)
|
||||
.offset(y: yRise)
|
||||
.animation(drawerSpring, value: progress)
|
||||
.allowsHitTesting(progress > 0.6)
|
||||
.zIndex(progress >= 0.5 ? 1 : 0)
|
||||
}
|
||||
}
|
||||
|
||||
struct AppDrawer: View {
|
||||
let items: [TabItem]
|
||||
@Binding var selection: Int
|
||||
let accentColor: Color
|
||||
@AppStorage("favoritesDockPosition1") private var favPos1: Int = 0
|
||||
@AppStorage("favoritesDockPosition2") private var favPos2: Int = 2
|
||||
@AppStorage("favoritesDockPosition3") private var favPos3: Int = 3
|
||||
@AppStorage("favoritesDockPosition4") private var favPos4: Int = 4
|
||||
@State private var progress: CGFloat = 0
|
||||
@State private var dragStartProgress: CGFloat = 0
|
||||
@State private var isDragging = false
|
||||
private var maxHeight: CGFloat { UIScreen.main.bounds.height * 0.5 }
|
||||
private var minHeight: CGFloat { 80 }
|
||||
|
||||
private var favorites: [TabItem] {
|
||||
let ids = [favPos1, favPos2, favPos3, favPos4]
|
||||
let found: [TabItem] = ids.compactMap { id in items.first(where: { $0.id == id }) }
|
||||
if found.count == 4 { return found }
|
||||
let defaults = [0, 2, 3, 4].compactMap { id in items.first(where: { $0.id == id }) }
|
||||
return defaults
|
||||
}
|
||||
|
||||
private var slotMap: [Int: Int] {
|
||||
var dict: [Int: Int] = [:]
|
||||
dict[favPos1] = 1
|
||||
dict[favPos2] = 2
|
||||
dict[favPos3] = 3
|
||||
dict[favPos4] = 4
|
||||
return dict
|
||||
}
|
||||
|
||||
private func assign(_ itemId: Int, to position: Int) {
|
||||
let defaults = [0, 2, 3, 4]
|
||||
switch position {
|
||||
case 1:
|
||||
if favPos2 == itemId { favPos2 = defaults[1] }
|
||||
if favPos3 == itemId { favPos3 = defaults[2] }
|
||||
if favPos4 == itemId { favPos4 = defaults[3] }
|
||||
favPos1 = itemId
|
||||
case 2:
|
||||
if favPos1 == itemId { favPos1 = defaults[0] }
|
||||
if favPos3 == itemId { favPos3 = defaults[2] }
|
||||
if favPos4 == itemId { favPos4 = defaults[3] }
|
||||
favPos2 = itemId
|
||||
case 3:
|
||||
if favPos1 == itemId { favPos1 = defaults[0] }
|
||||
if favPos2 == itemId { favPos2 = defaults[1] }
|
||||
if favPos4 == itemId { favPos4 = defaults[3] }
|
||||
favPos3 = itemId
|
||||
case 4:
|
||||
if favPos1 == itemId { favPos1 = defaults[0] }
|
||||
if favPos2 == itemId { favPos2 = defaults[1] }
|
||||
if favPos3 == itemId { favPos3 = defaults[2] }
|
||||
favPos4 = itemId
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let safeBottom = geo.safeAreaInsets.bottom
|
||||
let totalHeight = maxHeight + safeBottom
|
||||
let dockClearance: CGFloat = safeBottom + 16
|
||||
let closedY = geo.size.height - minHeight - safeBottom - dockClearance
|
||||
let openY = geo.size.height - totalHeight
|
||||
let travel = closedY - openY
|
||||
let offsetY = closedY - progress * travel
|
||||
let dockBottomPadding = safeBottom + 8
|
||||
|
||||
ZStack {
|
||||
if progress > 0 {
|
||||
Color.clear
|
||||
.contentShape(Rectangle())
|
||||
.ignoresSafeArea()
|
||||
.onTapGesture { withAnimation(drawerSpring) { progress = 0 } }
|
||||
}
|
||||
VStack(spacing: 0) {
|
||||
DrawerHandle(progress: progress)
|
||||
.onTapGesture {
|
||||
withAnimation(drawerSpring) {
|
||||
progress = (progress >= 0.999) ? 0 : 1
|
||||
}
|
||||
}
|
||||
ZStack(alignment: .top) {
|
||||
FavoritesDockView(favorites: favorites,
|
||||
selection: $selection,
|
||||
accentColor: accentColor,
|
||||
progress: progress,
|
||||
bottomPadding: dockBottomPadding)
|
||||
.padding(.top, 10)
|
||||
AppGridView(items: items,
|
||||
selection: $selection,
|
||||
accentColor: accentColor,
|
||||
progress: progress,
|
||||
slotMap: slotMap,
|
||||
assignToSlot: { itemId, pos in assign(itemId, to: pos) },
|
||||
debugLocationId: items.first(where: { $0.title == "Location" })?.id)
|
||||
.padding(.top, 10)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
.frame(width: geo.size.width, height: totalHeight, alignment: .top)
|
||||
.background(
|
||||
BlurView(style: .systemMaterial)
|
||||
.cornerRadius(16 + 8 * progress, corners: [.topLeft, .topRight])
|
||||
.shadow(color: Color.black.opacity(0.15 * Double(progress)), radius: 14 * progress, x: 0, y: -4 * progress)
|
||||
.ignoresSafeArea(edges: .bottom)
|
||||
)
|
||||
.offset(y: offsetY)
|
||||
.animation(drawerSpring, value: progress)
|
||||
.gesture(
|
||||
DragGesture()
|
||||
.onChanged { v in
|
||||
if !isDragging {
|
||||
isDragging = true
|
||||
dragStartProgress = progress
|
||||
}
|
||||
let delta = v.translation.height
|
||||
let p = dragStartProgress - delta / travel
|
||||
progress = min(max(p, 0), 1)
|
||||
}
|
||||
.onEnded { v in
|
||||
isDragging = false
|
||||
let end = dragStartProgress - v.translation.height / travel
|
||||
let target: CGFloat = end > 0.5 ? 1 : 0
|
||||
withAnimation(drawerSpring) { progress = target }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MainTabView: View {
|
||||
@AppStorage("customAccentColor") private var customAccentColorHex: String = ""
|
||||
@State private var selection: Int = 0
|
||||
|
||||
// You can still keep this for non-tint styling (borders, fills, etc.) if desired.
|
||||
private var accentColor: Color {
|
||||
customAccentColorHex.isEmpty ? .blue : (Color(hex: customAccentColorHex) ?? .blue)
|
||||
}
|
||||
|
||||
private var tabs: [TabItem] {
|
||||
var base: [TabItem] = [
|
||||
TabItem(id: 0, title: "Home", systemImage: "house"),
|
||||
TabItem(id: 1, title: "Scripts", systemImage: "scroll"),
|
||||
TabItem(id: 2, title: "Testing", systemImage: "square.grid.2x2"),
|
||||
TabItem(id: 3, title: "Info", systemImage: "info.circle.fill"),
|
||||
TabItem(id: 4, title: "Settings", systemImage: "gearshape.fill")
|
||||
]
|
||||
#if DEBUG
|
||||
base.append(TabItem(id: 5, title: "Location", systemImage: "globe.americas"))
|
||||
#endif
|
||||
return base
|
||||
customAccentColorHex.isEmpty ? .white : (Color(hex: customAccentColorHex) ?? .white)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
Group {
|
||||
switch selection {
|
||||
case 0: HomeView()
|
||||
case 1: ScriptListView()
|
||||
case 2: IPAAppManagerView()
|
||||
#if DEBUG
|
||||
case 5: LocationSimulatorView(deviceIp: "10.7.0.2")
|
||||
#endif
|
||||
case 3: DeviceInfoView()
|
||||
case 4: SettingsView()
|
||||
default: HomeView()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.accentColor(accentColor)
|
||||
.environment(\.accentColor, accentColor)
|
||||
AppDrawer(items: tabs, selection: $selection, accentColor: accentColor)
|
||||
.ignoresSafeArea(edges: .bottom)
|
||||
TabView(selection: $selection) {
|
||||
HomeView()
|
||||
.tabItem { Label("Home", systemImage: "house") }
|
||||
.tag(0)
|
||||
|
||||
ScriptListView()
|
||||
.tabItem { Label("Scripts", systemImage: "scroll") }
|
||||
.tag(1)
|
||||
|
||||
IPAAppManagerView()
|
||||
.tabItem { Label("Testing", systemImage: "square.grid.2x2.fill") }
|
||||
.tag(2)
|
||||
|
||||
// Replaced AP installer with Device Info
|
||||
DeviceInfoView()
|
||||
.tabItem { Label("Device Info", systemImage: "info.circle.fill") }
|
||||
.tag(3)
|
||||
|
||||
SettingsView()
|
||||
.tabItem { Label("Settings", systemImage: "gearshape.fill") }
|
||||
.tag(4)
|
||||
}
|
||||
// Remove per-view accent override so the app-wide .tint(Color.white) applies everywhere.
|
||||
// If you want the tab selection color to be white globally, rely on WindowGroup .tint(.white).
|
||||
}
|
||||
}
|
||||
|
||||
struct MainTabView_Preview: PreviewProvider {
|
||||
struct MainTabView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
MainTabView()
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,571 +0,0 @@
|
|||
//
|
||||
// Something.swift
|
||||
// StikDebug
|
||||
//
|
||||
// Created by Stephen on 7/29/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
import ZIPFoundation
|
||||
import ZSign
|
||||
import StikImporter
|
||||
|
||||
@_silgen_name("install_ipa")
|
||||
private func c_install_ipa(
|
||||
_ ip: UnsafePointer<CChar>,
|
||||
_ pairing: UnsafePointer<CChar>,
|
||||
_ udid: UnsafePointer<CChar>?,
|
||||
_ ipa: UnsafePointer<CChar>
|
||||
) -> Int32
|
||||
|
||||
private func installErrorMessage(_ code: Int32) -> String {
|
||||
switch code {
|
||||
case 0: return "Success"
|
||||
case 1: return "Pairing file unreadable"
|
||||
case 2: return "TCP provider error"
|
||||
case 3: return "AFC connect error"
|
||||
case 4: return "IPA unreadable"
|
||||
case 5: return "AFC open error"
|
||||
case 6: return "AFC write error"
|
||||
case 7: return "Install-proxy error"
|
||||
case 8: return "Device refused IPA"
|
||||
case 9: return "Invalid IP address"
|
||||
default: return "Unknown (\(code))"
|
||||
}
|
||||
}
|
||||
|
||||
private let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
private let appsJSON = docs.appendingPathComponent("signed_apps.json")
|
||||
private let certsJSON = docs.appendingPathComponent("certs.json")
|
||||
|
||||
struct SignedApp: Identifiable, Codable, Hashable {
|
||||
let id: UUID
|
||||
let name: String
|
||||
let bundleID: String
|
||||
let version: String
|
||||
let ipaPath: String
|
||||
let iconPath: String?
|
||||
|
||||
var ipaURL: URL { docs.appendingPathComponent(ipaPath) }
|
||||
var iconURL: URL? { iconPath.map { docs.appendingPathComponent($0) } }
|
||||
}
|
||||
|
||||
struct Certificate: Identifiable, Codable, Hashable {
|
||||
let id: UUID
|
||||
let name: String
|
||||
let p12Path: String
|
||||
let mobPath: String?
|
||||
|
||||
var p12URL: URL { docs.appendingPathComponent(p12Path) }
|
||||
var mobURL: URL? { mobPath.map { docs.appendingPathComponent($0) } }
|
||||
}
|
||||
|
||||
private enum FSX {
|
||||
static func mkdir(_ url: URL) throws {
|
||||
if !FileManager.default.fileExists(atPath: url.path) {
|
||||
try FileManager.default.createDirectory(at: url,
|
||||
withIntermediateDirectories: true,
|
||||
attributes: [.posixPermissions: 0o755])
|
||||
}
|
||||
}
|
||||
static func copySecure(from src: URL, to dst: URL) throws {
|
||||
let ok = src.startAccessingSecurityScopedResource()
|
||||
defer { if ok { src.stopAccessingSecurityScopedResource() } }
|
||||
if FileManager.default.fileExists(atPath: dst.path) { try FileManager.default.removeItem(at: dst) }
|
||||
try FileManager.default.copyItem(at: src, to: dst)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: dst.path)
|
||||
}
|
||||
static func perms(app: URL) throws {
|
||||
let keys: [URLResourceKey] = [.isDirectoryKey, .isExecutableKey]
|
||||
guard let e = FileManager.default.enumerator(at: app, includingPropertiesForKeys: keys) else { return }
|
||||
for case let f as URL in e {
|
||||
let rv = try f.resourceValues(forKeys: Set(keys))
|
||||
if rv.isDirectory == true {
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: f.path)
|
||||
} else {
|
||||
let exec = rv.isExecutable == true ||
|
||||
f.lastPathComponent == app.lastPathComponent.replacingOccurrences(of: ".app", with: "") ||
|
||||
(f.pathExtension.isEmpty && !f.lastPathComponent.contains("."))
|
||||
try FileManager.default.setAttributes([.posixPermissions: exec ? 0o755 : 0o644],
|
||||
ofItemAtPath: f.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class AppSignerManager: ObservableObject {
|
||||
|
||||
private var didFinishInitialLoad = false
|
||||
|
||||
@Published var apps: [SignedApp] = [] {
|
||||
didSet { if didFinishInitialLoad { saveApps() } }
|
||||
}
|
||||
@Published var certs: [Certificate] = [] {
|
||||
didSet { if didFinishInitialLoad { saveCerts() } }
|
||||
}
|
||||
@Published var selectedCertID: UUID?
|
||||
@Published var busy = false
|
||||
@Published var ipaURL: URL?
|
||||
@Published var ipaName: String?
|
||||
|
||||
private let imports = docs.appendingPathComponent("Imports", isDirectory: true)
|
||||
private let certsFolder = docs.appendingPathComponent("Certs", isDirectory: true)
|
||||
|
||||
init() {
|
||||
try? FSX.mkdir(imports)
|
||||
try? FSX.mkdir(certsFolder)
|
||||
migrateAndLoadApps()
|
||||
migrateAndLoadCerts()
|
||||
didFinishInitialLoad = true
|
||||
}
|
||||
|
||||
private func migrateAndLoadApps() {
|
||||
guard let data = try? Data(contentsOf: appsJSON),
|
||||
let stored = try? JSONDecoder().decode([SignedApp].self, from: data) else { return }
|
||||
|
||||
var fixed: [SignedApp] = []
|
||||
for app in stored {
|
||||
if FileManager.default.fileExists(atPath: app.ipaURL.path) {
|
||||
fixed.append(app)
|
||||
continue
|
||||
}
|
||||
let expectedRel = "SignedApps/\(app.name)/\(app.name).ipa"
|
||||
let newAbs = docs.appendingPathComponent(expectedRel).path
|
||||
if FileManager.default.fileExists(atPath: newAbs) {
|
||||
let newIconRel = "SignedApps/\(app.name)/icon.png"
|
||||
fixed.append(SignedApp(
|
||||
id: app.id, name: app.name, bundleID: app.bundleID,
|
||||
version: app.version, ipaPath: expectedRel,
|
||||
iconPath: FileManager.default.fileExists(atPath: docs.appendingPathComponent(newIconRel).path) ? newIconRel : nil))
|
||||
}
|
||||
}
|
||||
apps = fixed
|
||||
}
|
||||
private func saveApps() {
|
||||
guard let data = try? JSONEncoder().encode(apps) else { return }
|
||||
try? data.write(to: appsJSON)
|
||||
}
|
||||
|
||||
private func migrateAndLoadCerts() {
|
||||
guard let data = try? Data(contentsOf: certsJSON),
|
||||
let stored = try? JSONDecoder().decode([Certificate].self, from: data) else { return }
|
||||
|
||||
var fixed: [Certificate] = []
|
||||
for cert in stored {
|
||||
if FileManager.default.fileExists(atPath: cert.p12URL.path) &&
|
||||
(cert.mobURL == nil || FileManager.default.fileExists(atPath: cert.mobURL!.path)) {
|
||||
fixed.append(cert) // already fine
|
||||
continue
|
||||
}
|
||||
let dir = certsFolder.appendingPathComponent(cert.id.uuidString)
|
||||
let newP12Rel = "Certs/\(cert.id.uuidString)/cert.p12"
|
||||
let newMobRel = "Certs/\(cert.id.uuidString)/profile.mobileprovision"
|
||||
if FileManager.default.fileExists(atPath: dir.appendingPathComponent("cert.p12").path) {
|
||||
fixed.append(Certificate(
|
||||
id: cert.id, name: cert.name,
|
||||
p12Path: newP12Rel,
|
||||
mobPath: FileManager.default.fileExists(atPath: dir.appendingPathComponent("profile.mobileprovision").path) ? newMobRel : nil))
|
||||
}
|
||||
}
|
||||
certs = fixed
|
||||
if selectedCertID == nil { selectedCertID = certs.first?.id }
|
||||
}
|
||||
private func saveCerts() {
|
||||
guard let data = try? JSONEncoder().encode(certs) else { return }
|
||||
try? data.write(to: certsJSON)
|
||||
}
|
||||
|
||||
func deleteApp(_ app: SignedApp) {
|
||||
if let idx = apps.firstIndex(of: app) {
|
||||
try? FileManager.default.removeItem(at: app.ipaURL.deletingLastPathComponent())
|
||||
apps.remove(at: idx)
|
||||
}
|
||||
}
|
||||
|
||||
func importIPA(from url: URL) throws {
|
||||
let dst = imports.appendingPathComponent("current.ipa")
|
||||
try FSX.copySecure(from: url, to: dst)
|
||||
ipaURL = dst
|
||||
ipaName = try parseIPAName(at: dst)
|
||||
}
|
||||
private func parseIPAName(at ipa: URL) throws -> String? {
|
||||
let tmp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FSX.mkdir(tmp)
|
||||
try FileManager.default.unzipItem(at: ipa, to: tmp)
|
||||
let payload = tmp.appendingPathComponent("Payload")
|
||||
let appDir = try FileManager.default.contentsOfDirectory(at: payload, includingPropertiesForKeys: nil)
|
||||
.first { $0.pathExtension == "app" }
|
||||
defer { try? FileManager.default.removeItem(at: tmp) }
|
||||
guard let dir = appDir,
|
||||
let dict = NSDictionary(contentsOf: dir.appendingPathComponent("Info.plist")) else { return nil }
|
||||
return (dict["CFBundleDisplayName"] ?? dict["CFBundleName"]) as? String
|
||||
}
|
||||
|
||||
func addCertificate(name: String, p12: URL, mob: URL, password: String,
|
||||
onErr: (String,String)->Void, onOK: (String,String)->Void) {
|
||||
let id = UUID()
|
||||
let dir = certsFolder.appendingPathComponent(id.uuidString, isDirectory: true)
|
||||
do {
|
||||
try FSX.mkdir(dir)
|
||||
try FSX.copySecure(from: p12, to: dir.appendingPathComponent("cert.p12"))
|
||||
try FSX.copySecure(from: mob, to: dir.appendingPathComponent("profile.mobileprovision"))
|
||||
KeychainHelper.shared.save(password: password, forKey: id.uuidString)
|
||||
|
||||
certs.append(Certificate(
|
||||
id: id, name: name,
|
||||
p12Path: "Certs/\(id.uuidString)/cert.p12",
|
||||
mobPath: "Certs/\(id.uuidString)/profile.mobileprovision"))
|
||||
selectedCertID = id
|
||||
onOK("Certificate added", name)
|
||||
} catch { onErr("Cert error", error.localizedDescription) }
|
||||
}
|
||||
|
||||
func signIPA(onErr: @escaping (String,String)->Void,
|
||||
onOK: @escaping (String,String)->Void) {
|
||||
guard !busy else { return }
|
||||
guard let ipa = ipaURL,
|
||||
let cert = certs.first(where: { $0.id == selectedCertID }) else {
|
||||
onErr("Missing data", "Import IPA and select certificate first"); return
|
||||
}
|
||||
busy = true
|
||||
Task.detached {
|
||||
do {
|
||||
let tmp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FSX.mkdir(tmp)
|
||||
_ = ipa.startAccessingSecurityScopedResource(); defer { ipa.stopAccessingSecurityScopedResource() }
|
||||
|
||||
try FileManager.default.unzipItem(at: ipa, to: tmp)
|
||||
let payload = tmp.appendingPathComponent("Payload")
|
||||
guard let appFolder = try FileManager.default.contentsOfDirectory(at: payload, includingPropertiesForKeys: nil)
|
||||
.first(where: { $0.pathExtension == "app" }) else {
|
||||
throw NSError(domain: "Signer", code: 1, userInfo: [NSLocalizedDescriptionKey: "No .app found"])
|
||||
}
|
||||
try FSX.perms(app: appFolder)
|
||||
guard
|
||||
let dict = NSDictionary(contentsOf: appFolder.appendingPathComponent("Info.plist")),
|
||||
let name = (dict["CFBundleDisplayName"] ?? dict["CFBundleName"]) as? String,
|
||||
let bundle = dict["CFBundleIdentifier"] as? String,
|
||||
let ver = dict["CFBundleShortVersionString"] as? String
|
||||
else {
|
||||
throw NSError(domain: "Signer", code: 2, userInfo: [NSLocalizedDescriptionKey: "Info.plist parse error"])
|
||||
}
|
||||
|
||||
let pw = KeychainHelper.shared.readPassword(forKey: cert.id.uuidString) ?? ""
|
||||
let rc = zsign(appFolder.path,
|
||||
cert.p12URL.path,
|
||||
cert.p12URL.path,
|
||||
cert.mobURL?.path ?? "",
|
||||
pw, "", "")
|
||||
guard rc == 0 else {
|
||||
throw NSError(domain: "zsign", code: Int(rc),
|
||||
userInfo: [NSLocalizedDescriptionKey: "zsign returned \(rc)"])
|
||||
}
|
||||
|
||||
let outDir = docs.appendingPathComponent("SignedApps/\(name)", isDirectory: true)
|
||||
if FileManager.default.fileExists(atPath: outDir.path) { try FileManager.default.removeItem(at: outDir) }
|
||||
try FSX.mkdir(outDir)
|
||||
let final = outDir.appendingPathComponent("\(name).ipa")
|
||||
try FileManager.default.zipItem(at: payload,
|
||||
to: outDir.appendingPathComponent("\(name).ipa"),
|
||||
shouldKeepParent: true)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: final.path)
|
||||
|
||||
var iconRel: String?
|
||||
if let iconURL = try? FileManager.default.contentsOfDirectory(at: appFolder, includingPropertiesForKeys: nil)
|
||||
.first(where: { $0.lastPathComponent.lowercased().contains("appicon") && $0.pathExtension == "png" }) {
|
||||
let dst = outDir.appendingPathComponent("icon.png")
|
||||
try? FileManager.default.copyItem(at: iconURL, to: dst)
|
||||
iconRel = "SignedApps/\(name)/icon.png"
|
||||
}
|
||||
|
||||
let relIPA = "SignedApps/\(name)/\(name).ipa"
|
||||
let newApp = SignedApp(
|
||||
id: UUID(), name: name, bundleID: bundle,
|
||||
version: ver, ipaPath: relIPA, iconPath: iconRel)
|
||||
|
||||
await MainActor.run {
|
||||
self.apps.removeAll { $0.name == newApp.name }
|
||||
self.apps.append(newApp)
|
||||
self.busy = false
|
||||
onOK("\(name) is ready for testing", "Saved to \(relIPA)")
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.busy = false
|
||||
onErr("Sign error", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func install(app: SignedApp, ip: String, pairing: String,
|
||||
onErr: @escaping (String,String)->Void,
|
||||
onOK: @escaping (String,String)->Void) {
|
||||
guard !busy else { return }
|
||||
guard FileManager.default.fileExists(atPath: app.ipaURL.path) else { onErr("IPA missing", app.ipaURL.path); return }
|
||||
guard FileManager.default.fileExists(atPath: pairing) else { onErr("Pairing missing", pairing); return }
|
||||
busy = true
|
||||
Task.detached {
|
||||
let rc = ip.withCString { ipPtr in
|
||||
pairing.withCString { pairPtr in
|
||||
app.ipaURL.path.withCString { ipaPtr in
|
||||
c_install_ipa(ipPtr, pairPtr, nil, ipaPtr)
|
||||
}
|
||||
}
|
||||
}
|
||||
await MainActor.run {
|
||||
self.busy = false
|
||||
rc == 0 ? onOK("Installed for testing", app.name)
|
||||
: onErr("Install failed", installErrorMessage(rc))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct IPAAppManagerView: View {
|
||||
@AppStorage("customAccentColor") private var customAccentColorHex: String = ""
|
||||
private var accent: Color {
|
||||
customAccentColorHex.isEmpty ? .blue : Color(hex: customAccentColorHex) ?? .blue
|
||||
}
|
||||
|
||||
@StateObject private var mgr = AppSignerManager()
|
||||
@State private var pickerShown = false
|
||||
@State private var showAddCert = false
|
||||
@State private var alert = false
|
||||
@State private var alertTitle = ""
|
||||
@State private var alertMsg = ""
|
||||
@State private var alertSuccess = false
|
||||
@AppStorage("deviceIP") private var deviceIP = "10.7.0.2"
|
||||
private var pairingPath: String { docs.appendingPathComponent("pairingFile.plist").path }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
Color(UIColor.systemBackground).ignoresSafeArea()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 14) {
|
||||
signCard
|
||||
appsCard
|
||||
versionInfo
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 20)
|
||||
}
|
||||
|
||||
if mgr.busy {
|
||||
Color.black.opacity(0.35).ignoresSafeArea()
|
||||
ProgressView()
|
||||
.padding()
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
if alert {
|
||||
CustomErrorView(title: alertTitle,
|
||||
message: alertMsg,
|
||||
onDismiss: { alert = false },
|
||||
messageType: alertSuccess ? .success : .error)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Testing")
|
||||
.stikImporter(isPresented: $pickerShown,
|
||||
selectedURLs: .constant([]),
|
||||
allowedContentTypes: [.item],
|
||||
allowsMultipleSelection: false) { urls in
|
||||
if let u = urls.first {
|
||||
do { try mgr.importIPA(from: u); notify("File imported", "File saved to Imports") }
|
||||
catch { fail("Import error", error.localizedDescription) }
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showAddCert) {
|
||||
AddCertView(accent: accent) { n, p12, mob, pw in
|
||||
mgr.addCertificate(name: n, p12: p12, mob: mob, password: pw,
|
||||
onErr: fail, onOK: notify)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var signCard: some View {
|
||||
SettingsCard {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
Text("Prepare for testing")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
VStack(spacing: 10) {
|
||||
importButton(label: mgr.ipaName ?? ".IPA File",
|
||||
icon: "arrow.down.doc",
|
||||
imported: mgr.ipaURL != nil) { pickerShown = true }
|
||||
|
||||
Picker("Certificate", selection: $mgr.selectedCertID) {
|
||||
ForEach(mgr.certs) { Text($0.name).tag(Optional($0.id)) }
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Button("Add Developer Certificate") { showAddCert = true }
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.background(Color.gray.opacity(0.4))
|
||||
.foregroundColor(accent.contrastText())
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
|
||||
Button("Prepare") { mgr.signIPA(onErr: fail, onOK: notify) }
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.background(accent)
|
||||
.foregroundColor(accent.contrastText())
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.disabled(mgr.busy || mgr.ipaURL == nil || mgr.selectedCertID == nil)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
}
|
||||
|
||||
private var appsCard: some View {
|
||||
SettingsCard {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
Text("Ready to test apps")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
if mgr.apps.isEmpty {
|
||||
Text("No ready to test apps yet")
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
ForEach(mgr.apps) { app in
|
||||
HStack(spacing: 12) {
|
||||
if let img = app.iconURL.flatMap({ UIImage(contentsOfFile: $0.path) }) {
|
||||
Image(uiImage: img)
|
||||
.resizable()
|
||||
.frame(width: 44, height: 44)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
VStack(alignment: .leading) {
|
||||
Text(app.name).bold()
|
||||
Text("v\(app.version) • \(app.bundleID)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button("Test") {
|
||||
mgr.install(app: app, ip: deviceIP, pairing: pairingPath,
|
||||
onErr: fail, onOK: notify)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(mgr.busy)
|
||||
|
||||
Button(role: .destructive) {
|
||||
mgr.deleteApp(app)
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
}
|
||||
|
||||
private var versionInfo: some View {
|
||||
HStack {
|
||||
Spacer()
|
||||
Text("iOS \(UIDevice.current.systemVersion)")
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.top, 6)
|
||||
}
|
||||
|
||||
private func importButton(label: String, icon: String,
|
||||
imported: Bool, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
HStack {
|
||||
Image(systemName: icon)
|
||||
Text(label)
|
||||
Spacer()
|
||||
Image(systemName: imported ? "checkmark.circle.fill" : "chevron.right")
|
||||
}
|
||||
.foregroundColor(imported ? .primary : .secondary)
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 12)
|
||||
.background(Color(UIColor.secondarySystemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.disabled(mgr.busy)
|
||||
}
|
||||
|
||||
private func fail(_ title: String, _ msg: String) {
|
||||
alertTitle = title; alertMsg = msg; alertSuccess = false; alert = true
|
||||
}
|
||||
private func notify(_ title: String, _ msg: String) {
|
||||
alertTitle = title; alertMsg = msg; alertSuccess = true; alert = true
|
||||
}
|
||||
}
|
||||
|
||||
private struct AddCertView: View {
|
||||
let accent: Color
|
||||
var onSave: (String, URL, URL, String) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var name = ""
|
||||
@State private var p12URL: URL?
|
||||
@State private var mobURL: URL?
|
||||
@State private var password = ""
|
||||
|
||||
@State private var pickP12 = false
|
||||
@State private var pickMob = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
TextField("Certificate name", text: $name)
|
||||
importRow(label: ".mobileprovision", picked: mobURL != nil) { pickMob = true }
|
||||
.stikImporter(isPresented: $pickMob,
|
||||
selectedURLs: .constant([]),
|
||||
allowedContentTypes: [UTType.item],
|
||||
allowsMultipleSelection: false) { mobURL = $0.first }
|
||||
importRow(label: ".p12 file", picked: p12URL != nil) { pickP12 = true }
|
||||
.stikImporter(isPresented: $pickP12,
|
||||
selectedURLs: .constant([]),
|
||||
allowedContentTypes: [UTType.item],
|
||||
allowsMultipleSelection: false) { p12URL = $0.first }
|
||||
SecureField("p12 password", text: $password)
|
||||
}
|
||||
.navigationTitle("New Certificate")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") {
|
||||
if let p = p12URL, let m = mobURL, !name.isEmpty {
|
||||
onSave(name, p, m, password); dismiss()
|
||||
}
|
||||
}
|
||||
.disabled(p12URL == nil || mobURL == nil || name.isEmpty)
|
||||
}
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func importRow(label: String, picked: Bool,
|
||||
action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
HStack {
|
||||
Text(label)
|
||||
Spacer()
|
||||
Image(systemName: picked ? "checkmark.circle.fill" : "chevron.right")
|
||||
.foregroundColor(picked ? .green : accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1163
StikJIT/Views/Testing.swift
Normal file
1163
StikJIT/Views/Testing.swift
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue