2.2.0 - Semi Broken

This commit is contained in:
Stephen B 2025-09-05 12:44:01 -04:00
parent a40956bd47
commit 78c0ccb495
14 changed files with 3733 additions and 2465 deletions

View file

@ -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 */ = {

View file

@ -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"

View file

@ -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 }
}
}
}

View file

@ -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

View file

@ -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()
}
}

View file

@ -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) { }
}

View file

@ -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)
}
}

View file

@ -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] = [:]

View file

@ -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 }
}

View file

@ -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

View file

@ -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

File diff suppressed because it is too large Load diff