mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-16 04:20:30 +00:00
UX Improvements
This commit is contained in:
parent
7c97db579c
commit
b64733fa52
2 changed files with 691 additions and 510 deletions
|
|
@ -8,6 +8,7 @@
|
|||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
import Pipify
|
||||
import UIKit
|
||||
|
||||
struct JITEnableConfiguration {
|
||||
var bundleID: String? = nil
|
||||
|
|
@ -34,8 +35,9 @@ struct HomeView: View {
|
|||
@State private var showingConsoleLogsView = false
|
||||
@State private var importProgress: Float = 0.0
|
||||
|
||||
@State private var pidTextAlertShow = false
|
||||
@State private var pidStr = ""
|
||||
@State private var showPIDSheet = false
|
||||
@AppStorage("recentPIDs") private var recentPIDs: [Int] = []
|
||||
@State private var justCopied = false
|
||||
|
||||
@State private var viewDidAppeared = false
|
||||
@State private var pendingJITEnableConfiguration : JITEnableConfiguration? = nil
|
||||
|
|
@ -48,27 +50,20 @@ 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 .white
|
||||
} else {
|
||||
return Color(hex: customAccentColorHex) ?? .white
|
||||
}
|
||||
if customAccentColorHex.isEmpty { return .white }
|
||||
return Color(hex: customAccentColorHex) ?? .white
|
||||
}
|
||||
|
||||
// Derived states
|
||||
private var ddiMounted: Bool { isMounted() }
|
||||
private var canConnectByApp: Bool { pairingFileExists && ddiMounted }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
// DeviceInfo-style subtle gradient background
|
||||
LinearGradient(
|
||||
gradient: Gradient(colors: [
|
||||
Color(UIColor.systemBackground),
|
||||
|
|
@ -81,71 +76,34 @@ struct HomeView: View {
|
|||
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
// Top card: status + both actions
|
||||
topStatusAndActionsCard
|
||||
|
||||
// Tools (Console)
|
||||
toolsCard
|
||||
|
||||
// Helpful tips
|
||||
tipsCard
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 30)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
GeometryReader { geometry in
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(Color(UIColor.tertiarySystemFill))
|
||||
.frame(height: 8)
|
||||
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(Color.green)
|
||||
.frame(width: geometry.size.width * CGFloat(importProgress), height: 8)
|
||||
.animation(.linear(duration: 0.3), value: importProgress)
|
||||
}
|
||||
}
|
||||
.frame(height: 8)
|
||||
Text("\(Int(importProgress * 100))%")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.top, 6)
|
||||
}
|
||||
.padding(16)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4)
|
||||
ProgressView("Processing pairing file…")
|
||||
.padding(16)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4)
|
||||
}
|
||||
|
||||
// Success toast after import (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)
|
||||
toast("✓ Pairing file successfully imported")
|
||||
}
|
||||
if justCopied {
|
||||
toast("Copied")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Home")
|
||||
|
|
@ -153,22 +111,18 @@ struct HomeView: View {
|
|||
.onAppear {
|
||||
checkPairingFileExists()
|
||||
refreshBackground()
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: NSNotification.Name("ShowPairingFilePicker"),
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { _ in
|
||||
isShowingPairingFilePicker = true
|
||||
}
|
||||
) { _ in isShowingPairingFilePicker = true }
|
||||
}
|
||||
.onReceive(timer) { _ in
|
||||
refreshBackground()
|
||||
checkPairingFileExists()
|
||||
// Update heartbeat mirror from global
|
||||
heartbeatOK = pubHeartBeat
|
||||
}
|
||||
.fileImporter(isPresented: $isShowingPairingFilePicker, allowedContentTypes: [UTType(filenameExtension: "mobiledevicepairing", conformingTo: .data)!, .propertyList]) {result in
|
||||
.fileImporter(isPresented: $isShowingPairingFilePicker, allowedContentTypes: [UTType(filenameExtension: "mobiledevicepairing", conformingTo: .data)!, .propertyList]) { result in
|
||||
switch result {
|
||||
case .success(let url):
|
||||
let fileManager = FileManager.default
|
||||
|
|
@ -176,61 +130,41 @@ struct HomeView: View {
|
|||
|
||||
if fileManager.fileExists(atPath: url.path) {
|
||||
do {
|
||||
if fileManager.fileExists(atPath: URL.documentsDirectory.appendingPathComponent("pairingFile.plist").path) {
|
||||
try fileManager.removeItem(at: URL.documentsDirectory.appendingPathComponent("pairingFile.plist"))
|
||||
let dest = URL.documentsDirectory.appendingPathComponent("pairingFile.plist")
|
||||
if fileManager.fileExists(atPath: dest.path) {
|
||||
try fileManager.removeItem(at: dest)
|
||||
}
|
||||
try fileManager.copyItem(at: url, to: dest)
|
||||
|
||||
try fileManager.copyItem(at: url, to: URL.documentsDirectory.appendingPathComponent("pairingFile.plist"))
|
||||
print("File copied successfully!")
|
||||
|
||||
// Show progress bar and initialize progress
|
||||
DispatchQueue.main.async {
|
||||
isImportingFile = true
|
||||
importProgress = 0.0
|
||||
importProgress = 0
|
||||
pairingFileExists = true
|
||||
}
|
||||
|
||||
// Start heartbeat in background
|
||||
startHeartbeatInBackground()
|
||||
|
||||
// Create timer to update progress instead of sleeping
|
||||
let progressTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { timer in
|
||||
let progressTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { t in
|
||||
DispatchQueue.main.async {
|
||||
if importProgress < 1.0 {
|
||||
if importProgress < 1 {
|
||||
importProgress += 0.25
|
||||
} else {
|
||||
timer.invalidate()
|
||||
t.invalidate()
|
||||
isImportingFile = false
|
||||
pairingFileIsValid = true
|
||||
|
||||
// Show success message
|
||||
withAnimation {
|
||||
showPairingFileMessage = true
|
||||
}
|
||||
|
||||
// Hide message after delay
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
|
||||
withAnimation {
|
||||
showPairingFileMessage = false
|
||||
}
|
||||
withAnimation { showPairingFileMessage = true }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
||||
withAnimation { showPairingFileMessage = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure timer keeps running
|
||||
RunLoop.current.add(progressTimer, forMode: .common)
|
||||
|
||||
} catch {
|
||||
print("Error copying file: \(error)")
|
||||
}
|
||||
} else {
|
||||
print("Source file does not exist.")
|
||||
}
|
||||
|
||||
if accessing {
|
||||
url.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
if accessing { url.stopAccessingSecurityScopedResource() }
|
||||
case .failure(let error):
|
||||
print("Failed to import file: \(error)")
|
||||
}
|
||||
|
|
@ -255,9 +189,7 @@ struct HomeView: View {
|
|||
RunJSView(model: jsModel)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") {
|
||||
scriptViewShow = false
|
||||
}
|
||||
Button("Done") { scriptViewShow = false }
|
||||
}
|
||||
}
|
||||
.navigationTitle(selectedScript)
|
||||
|
|
@ -265,43 +197,20 @@ struct HomeView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: scriptViewShow) { oldValue, newValue in
|
||||
if !newValue, let jsModel {
|
||||
jsModel.executionInterrupted = true
|
||||
jsModel.semaphore?.signal()
|
||||
}
|
||||
.sheet(isPresented: $showPIDSheet) {
|
||||
ConnectByPIDSheet(
|
||||
recentPIDs: $recentPIDs,
|
||||
onPasteCopyToast: { showCopiedToast() },
|
||||
onConnect: { pid in
|
||||
HapticFeedbackHelper.trigger()
|
||||
startJITInBackground(pid: pid)
|
||||
}
|
||||
)
|
||||
}
|
||||
.textFieldAlert(
|
||||
isPresented: $pidTextAlertShow,
|
||||
title: "Please enter the PID of the process you want to connect to".localized,
|
||||
text: $pidStr,
|
||||
placeholder: "",
|
||||
action: { newText in
|
||||
|
||||
guard let pidStr = newText, pidStr != "" else {
|
||||
return
|
||||
}
|
||||
|
||||
guard let pid = Int(pidStr) else {
|
||||
showAlert(title: "", message: "Invalid PID".localized, showOk: true, completion: { _ in })
|
||||
return
|
||||
}
|
||||
startJITInBackground(pid: pid)
|
||||
|
||||
},
|
||||
actionCancel: {_ in
|
||||
pidStr = ""
|
||||
}
|
||||
)
|
||||
.onOpenURL { url in
|
||||
print(url.path)
|
||||
if url.host != "enable-jit" {
|
||||
return
|
||||
}
|
||||
|
||||
guard url.host == "enable-jit" else { return }
|
||||
var config = JITEnableConfiguration()
|
||||
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||
|
||||
if let pidStr = components?.queryItems?.first(where: { $0.name == "pid" })?.value, let pid = Int(pidStr) {
|
||||
config.pid = pid
|
||||
}
|
||||
|
|
@ -317,59 +226,32 @@ struct HomeView: View {
|
|||
if let scriptName = components?.queryItems?.first(where: { $0.name == "script-name" })?.value {
|
||||
config.scriptName = scriptName
|
||||
}
|
||||
|
||||
if viewDidAppeared {
|
||||
startJITInBackground(bundleID: config.bundleID, pid: config.pid, scriptData: config.scriptData, scriptName: config.scriptName, triggeredByURLScheme: true)
|
||||
} else {
|
||||
pendingJITEnableConfiguration = config
|
||||
}
|
||||
|
||||
}
|
||||
.onAppear() {
|
||||
.onAppear {
|
||||
viewDidAppeared = true
|
||||
if let config = pendingJITEnableConfiguration {
|
||||
startJITInBackground(bundleID: config.bundleID, pid: config.pid, scriptData: config.scriptData, scriptName: config.scriptName, triggeredByURLScheme: true)
|
||||
self.pendingJITEnableConfiguration = nil
|
||||
pendingJITEnableConfiguration = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Styled Sections
|
||||
|
||||
// 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"
|
||||
)
|
||||
|
||||
indicatorCapsule(ok: pairingFileExists, systemImage: "doc.badge.plus", a11y: "Pairing")
|
||||
indicatorCapsule(ok: ddiMounted, systemImage: "externaldrive", a11y: "Developer Disk Image")
|
||||
indicatorCapsule(ok: tunnel.tunnelStatus == .connected, systemImage: "lock.shield", a11y: "VPN")
|
||||
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))
|
||||
|
|
@ -378,56 +260,26 @@ struct HomeView: View {
|
|||
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)
|
||||
whiteCardButtonLabel(
|
||||
icon: pairingFileExists ? "cable.connector.horizontal" : "doc.badge.plus",
|
||||
title: pairingFileExists ? "Connect by App" : "Select Pairing File"
|
||||
)
|
||||
}
|
||||
.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)
|
||||
)
|
||||
Button(action: { showPIDSheet = true }) {
|
||||
whiteCardButtonLabel(icon: "number.circle", title: "Connect by PID")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inline status / progress
|
||||
|
||||
if isImportingFile {
|
||||
VStack(spacing: 8) {
|
||||
HStack {
|
||||
|
|
@ -439,14 +291,11 @@ struct HomeView: View {
|
|||
.font(.system(.caption, design: .rounded))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
GeometryReader { geometry in
|
||||
GeometryReader { geo 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)
|
||||
RoundedRectangle(cornerRadius: 6).fill(Color(UIColor.tertiarySystemFill)).frame(height: 8)
|
||||
RoundedRectangle(cornerRadius: 6).fill(Color.green)
|
||||
.frame(width: geo.size.width * CGFloat(importProgress), height: 8)
|
||||
.animation(.linear(duration: 0.3), value: importProgress)
|
||||
}
|
||||
}
|
||||
|
|
@ -459,7 +308,6 @@ struct HomeView: View {
|
|||
Text("Pairing file successfully imported")
|
||||
.font(.system(.callout, design: .rounded))
|
||||
.foregroundColor(.green)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
|
@ -476,52 +324,41 @@ struct HomeView: View {
|
|||
.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) {
|
||||
HStack(spacing: 8) {
|
||||
StatusDot(color: ok ? .green : .orange)
|
||||
Image(systemName: systemImage)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.primary)
|
||||
.accessibilityLabel(a11y)
|
||||
}
|
||||
.frame(height: fixedHeight)
|
||||
.frame(height: 32)
|
||||
.padding(.horizontal, 10)
|
||||
.background(
|
||||
Capsule(style: .continuous)
|
||||
.fill(Color(UIColor.tertiarySystemBackground))
|
||||
.background(Capsule(style: .continuous).fill(Color(UIColor.tertiarySystemBackground)))
|
||||
}
|
||||
|
||||
private func whiteCardButtonLabel(icon: String, title: String) -> some View {
|
||||
HStack {
|
||||
Image(systemName: icon).font(.system(size: 20))
|
||||
Text(title).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)
|
||||
)
|
||||
}
|
||||
|
||||
private var toolsCard: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Tools")
|
||||
.font(.headline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
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()
|
||||
whiteCardButtonLabel(icon: "terminal", title: "Open Console")
|
||||
}
|
||||
.sheet(isPresented: $showingConsoleLogsView) { ConsoleLogsView() }
|
||||
}
|
||||
.padding(20)
|
||||
.background(
|
||||
|
|
@ -537,31 +374,14 @@ struct HomeView: View {
|
|||
|
||||
private var tipsCard: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Tips")
|
||||
.font(.headline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
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."
|
||||
)
|
||||
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: "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."
|
||||
)
|
||||
tipRow(systemImage: "lock.shield", title: "Local only", message: "StikDebug runs entirely on-device. No data leaves your device.")
|
||||
}
|
||||
.padding(20)
|
||||
.background(
|
||||
|
|
@ -580,7 +400,6 @@ struct HomeView: View {
|
|||
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)
|
||||
|
|
@ -590,8 +409,6 @@ struct HomeView: View {
|
|||
.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." }
|
||||
|
|
@ -610,35 +427,46 @@ struct HomeView: View {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Logic
|
||||
private func showCopiedToast() {
|
||||
withAnimation { justCopied = true }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||
withAnimation { justCopied = false }
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func toast(_ text: String) -> some View {
|
||||
VStack {
|
||||
Spacer()
|
||||
Text(text)
|
||||
.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: text)
|
||||
}
|
||||
|
||||
private func checkPairingFileExists() {
|
||||
let fileExists = FileManager.default.fileExists(atPath: URL.documentsDirectory.appendingPathComponent("pairingFile.plist").path)
|
||||
pairingFileExists = fileExists && isPairing()
|
||||
}
|
||||
|
||||
private func refreshBackground() {
|
||||
// kept for compatibility
|
||||
}
|
||||
private func refreshBackground() { }
|
||||
|
||||
private func getJsCallback(_ script: Data, name: String? = nil) -> DebugAppCallback {
|
||||
return { pid, debugProxyHandle, semaphore in
|
||||
jsModel = RunJSViewModel(pid: Int(pid), debugProxy: debugProxyHandle, semaphore: semaphore)
|
||||
scriptViewShow = true
|
||||
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
do {
|
||||
try jsModel?.runScript(data: script, name: name)
|
||||
} catch {
|
||||
showAlert(title: "Error Occurred While Executing the Default Script.".localized, message: error.localizedDescription, showOk: true)
|
||||
}
|
||||
do { try jsModel?.runScript(data: script, name: name) }
|
||||
catch { showAlert(title: "Error Occurred While Executing the Default Script.".localized, message: error.localizedDescription, showOk: true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// launch app following this order: pid > bundleID
|
||||
// load script following this order: scriptData > script file from script name > saved script for bundleID > default script
|
||||
// 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
|
||||
LogManager.shared.addInfoLog("Starting Debug for \(bundleID ?? String(pid ?? 0))")
|
||||
|
|
@ -650,22 +478,12 @@ struct HomeView: View {
|
|||
if scriptName == nil, let bundleID, let mapping = UserDefaults.standard.dictionary(forKey: "BundleScriptMap") as? [String: String] {
|
||||
scriptName = mapping[bundleID]
|
||||
}
|
||||
|
||||
if useDefaultScript && scriptName == nil {
|
||||
scriptName = selectedScript
|
||||
}
|
||||
|
||||
if useDefaultScript && scriptName == nil { scriptName = selectedScript }
|
||||
if scriptData == nil, let scriptName {
|
||||
let selectedScriptURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
let url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent("scripts").appendingPathComponent(scriptName)
|
||||
|
||||
if FileManager.default.fileExists(atPath: selectedScriptURL.path) {
|
||||
do {
|
||||
scriptData = try Data(contentsOf: selectedScriptURL)
|
||||
} catch {
|
||||
print("failed to load data from script \(error)")
|
||||
}
|
||||
|
||||
if FileManager.default.fileExists(atPath: url.path) {
|
||||
do { scriptData = try Data(contentsOf: url) } catch { print("script load error: \(error)") }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
@ -673,29 +491,22 @@ struct HomeView: View {
|
|||
}
|
||||
|
||||
var callback: DebugAppCallback? = nil
|
||||
|
||||
if let scriptData {
|
||||
callback = getJsCallback(scriptData, name: scriptName ?? bundleID ?? "Script")
|
||||
if triggeredByURLScheme {
|
||||
usleep(500000)
|
||||
}
|
||||
|
||||
if let sd = scriptData {
|
||||
callback = getJsCallback(sd, name: scriptName ?? bundleID ?? "Script")
|
||||
if triggeredByURLScheme { usleep(500000) }
|
||||
pipRequired = true
|
||||
}
|
||||
|
||||
let logger: LogFunc = { message in
|
||||
if let message = message {
|
||||
LogManager.shared.addInfoLog(message)
|
||||
}
|
||||
}
|
||||
var success : Bool
|
||||
let logger: LogFunc = { message in if let message { LogManager.shared.addInfoLog(message) } }
|
||||
var success: Bool
|
||||
if let pid {
|
||||
success = JITEnableContext.shared.debugApp(withPID: Int32(pid), logger: logger, jsCallback: callback)
|
||||
if success { DispatchQueue.main.async { addRecentPID(pid) } }
|
||||
} else if let bundleID {
|
||||
success = JITEnableContext.shared.debugApp(withBundleID: bundleID, logger: logger, jsCallback: callback)
|
||||
} else {
|
||||
DispatchQueue.main.async {
|
||||
showAlert(title: "Failed to Debug App".localized, message: "Either bundle ID or PID should be specified.".localized, showOk: true)
|
||||
showAlert(title: "Failed to Debug App".localized, message: "Either bundle ID or PID should be specified.".localized, showOk: true)
|
||||
}
|
||||
success = false
|
||||
}
|
||||
|
|
@ -710,43 +521,18 @@ struct HomeView: View {
|
|||
}
|
||||
}
|
||||
|
||||
func base64URLToBase64(_ base64url: String) -> String {
|
||||
var base64 = base64url
|
||||
.replacingOccurrences(of: "-", with: "+")
|
||||
.replacingOccurrences(of: "_", with: "/")
|
||||
|
||||
// Pad with "=" to make length a multiple of 4
|
||||
let paddingLength = 4 - (base64.count % 4)
|
||||
if paddingLength < 4 {
|
||||
base64 += String(repeating: "=", count: paddingLength)
|
||||
}
|
||||
|
||||
return base64
|
||||
private func addRecentPID(_ pid: Int) {
|
||||
var list = recentPIDs.filter { $0 != pid }
|
||||
list.insert(pid, at: 0)
|
||||
if list.count > 8 { list = Array(list.prefix(8)) }
|
||||
recentPIDs = list
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 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))
|
||||
)
|
||||
func base64URLToBase64(_ base64url: String) -> String {
|
||||
var base64 = base64url.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
||||
let pad = 4 - (base64.count % 4)
|
||||
if pad < 4 { base64 += String(repeating: "=", count: pad) }
|
||||
return base64
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -755,40 +541,230 @@ private struct StatusDot: View {
|
|||
@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)
|
||||
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)
|
||||
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] = [:]
|
||||
// MARK: - Connect-by-PID Sheet (minus/plus removed)
|
||||
|
||||
private struct ConnectByPIDSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Binding var recentPIDs: [Int]
|
||||
@State private var pidText: String = ""
|
||||
@State private var errorText: String? = nil
|
||||
@FocusState private var focused: Bool
|
||||
var onPasteCopyToast: () -> Void
|
||||
var onConnect: (Int) -> Void
|
||||
|
||||
init() {
|
||||
loadApps()
|
||||
private var isValid: Bool {
|
||||
if let v = Int(pidText), v > 0 { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
func loadApps() {
|
||||
do {
|
||||
self.apps = try JITEnableContext.shared.getAppList()
|
||||
} catch {
|
||||
print(error)
|
||||
self.apps = [:]
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
ZStack {
|
||||
LinearGradient(
|
||||
gradient: Gradient(colors: [
|
||||
Color(UIColor.systemBackground),
|
||||
Color(UIColor.secondarySystemBackground)
|
||||
]),
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Enter a Process ID").font(.headline).foregroundColor(.primary)
|
||||
|
||||
// TextField + Clear control
|
||||
TextField("e.g. 1234", text: $pidText)
|
||||
.keyboardType(.numberPad)
|
||||
.textContentType(.oneTimeCode)
|
||||
.font(.system(.title3, design: .rounded))
|
||||
.padding(12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1)
|
||||
)
|
||||
.focused($focused)
|
||||
.onChange(of: pidText) { _, newVal in validate(newVal) }
|
||||
|
||||
// Paste + Clear row
|
||||
HStack(spacing: 10) {
|
||||
CapsuleButton(systemName: "doc.on.clipboard", title: "Paste") {
|
||||
if let n = UIPasteboard.general.string?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
let v = Int(n), v > 0 {
|
||||
pidText = String(v)
|
||||
validate(pidText)
|
||||
onPasteCopyToast()
|
||||
} else {
|
||||
errorText = "No valid PID on the clipboard."
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
}
|
||||
}
|
||||
|
||||
CapsuleButton(systemName: "xmark", title: "Clear") {
|
||||
pidText = ""
|
||||
errorText = nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if let errorText {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "exclamationmark.triangle.fill").font(.footnote)
|
||||
Text(errorText).font(.footnote)
|
||||
}
|
||||
.foregroundColor(.orange)
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
if !recentPIDs.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Recents")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.secondary)
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(recentPIDs, id: \.self) { pid in
|
||||
Button {
|
||||
pidText = String(pid); validate(pidText)
|
||||
} label: {
|
||||
Text("#\(pid)")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 10)
|
||||
.background(
|
||||
Capsule(style: .continuous)
|
||||
.fill(Color(UIColor.tertiarySystemBackground))
|
||||
)
|
||||
}
|
||||
.contextMenu {
|
||||
Button(role: .destructive) {
|
||||
removeRecent(pid)
|
||||
} label: { Label("Remove", systemImage: "trash") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
guard let pid = Int(pidText), pid > 0 else { return }
|
||||
onConnect(pid)
|
||||
addRecent(pid)
|
||||
dismiss()
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "bolt.horizontal.circle").font(.system(size: 20))
|
||||
Text("Connect")
|
||||
.font(.system(.title3, design: .rounded))
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.background((isValid ? Color.white : Color.white.opacity(0.6)), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.foregroundColor(.black)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.stroke(Color.white.opacity(0.2), lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
.disabled(!isValid)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.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)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 30)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Connect by PID")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Cancel") { dismiss() } } }
|
||||
.onAppear { focused = true }
|
||||
}
|
||||
}
|
||||
|
||||
// Small glassy square icon button
|
||||
private func iconSquareButton(systemName: String, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: systemName)
|
||||
.font(.headline)
|
||||
.frame(width: 36, height: 36)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(Color(UIColor.tertiarySystemBackground))
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
private func validate(_ text: String) {
|
||||
if text.isEmpty { errorText = nil; return }
|
||||
if Int(text) == nil || Int(text)! <= 0 { errorText = "Please enter a positive number." }
|
||||
else { errorText = nil }
|
||||
}
|
||||
private func addRecent(_ pid: Int) {
|
||||
var list = recentPIDs.filter { $0 != pid }
|
||||
list.insert(pid, at: 0)
|
||||
if list.count > 8 { list = Array(list.prefix(8)) }
|
||||
recentPIDs = list
|
||||
}
|
||||
private func removeRecent(_ pid: Int) { recentPIDs.removeAll { $0 == pid } }
|
||||
private func prefillFromClipboardIfPossible() {
|
||||
if let s = UIPasteboard.general.string?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
let v = Int(s), v > 0 {
|
||||
pidText = String(v); errorText = nil
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func CapsuleButton(systemName: String, title: String, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: systemName)
|
||||
Text(title).font(.subheadline.weight(.semibold))
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 12)
|
||||
.background(Capsule(style: .continuous).fill(Color(UIColor.tertiarySystemBackground)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - InstalledAppsViewModel
|
||||
|
||||
|
||||
#Preview {
|
||||
HomeView()
|
||||
class InstalledAppsViewModel: ObservableObject {
|
||||
@Published var apps: [String: String] = [:]
|
||||
init() { loadApps() }
|
||||
func loadApps() {
|
||||
do { self.apps = try JITEnableContext.shared.getAppList() }
|
||||
catch { print(error); self.apps = [:] }
|
||||
}
|
||||
}
|
||||
|
||||
#Preview { HomeView() }
|
||||
|
|
|
|||
|
|
@ -8,9 +8,13 @@
|
|||
import SwiftUI
|
||||
import UIKit
|
||||
import WidgetKit
|
||||
import Combine
|
||||
|
||||
// MARK: - Installed Apps List
|
||||
|
||||
struct InstalledAppsListView: View {
|
||||
@StateObject private var viewModel = InstalledAppsViewModel()
|
||||
|
||||
@State private var appIcons: [String: UIImage] = [:]
|
||||
private let sharedDefaults = UserDefaults(suiteName: "group.com.stik.sj")!
|
||||
|
||||
|
|
@ -20,11 +24,13 @@ struct InstalledAppsListView: View {
|
|||
if favoriteApps.count > 4 {
|
||||
favoriteApps = Array(favoriteApps.prefix(4))
|
||||
}
|
||||
sharedDefaults.set(favoriteApps, forKey: "favoriteApps")
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
persistIfChanged()
|
||||
}
|
||||
}
|
||||
|
||||
@AppStorage("performanceMode") private var performanceMode = false
|
||||
@State private var showPerformanceToast = false
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
var onSelectApp: (String) -> Void
|
||||
|
||||
|
|
@ -33,115 +39,152 @@ struct InstalledAppsListView: View {
|
|||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
LinearGradient(
|
||||
gradient: Gradient(colors: [
|
||||
Color(UIColor.systemBackground),
|
||||
Color(UIColor.secondarySystemBackground)
|
||||
]),
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
BackgroundGradient().ignoresSafeArea()
|
||||
|
||||
if viewModel.apps.isEmpty {
|
||||
emptyState
|
||||
.padding(.horizontal)
|
||||
.transition(.opacity.combined(with: .scale))
|
||||
} else {
|
||||
appsList
|
||||
.transition(.opacity)
|
||||
.transaction { t in t.disablesAnimations = true }
|
||||
}
|
||||
|
||||
if showPerformanceToast {
|
||||
VStack {
|
||||
Spacer()
|
||||
Text(performanceMode ? "Performance Mode On" : "Performance Mode Off")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(.ultraThinMaterial, in: Capsule())
|
||||
.shadow(radius: 4)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
.padding(.bottom, 40)
|
||||
}
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: showPerformanceToast)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Installed Apps".localized)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button {
|
||||
performanceMode.toggle()
|
||||
Haptics.selection()
|
||||
showPerformanceToast = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
||||
withAnimation {
|
||||
showPerformanceToast = false
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: performanceMode ? "bolt.fill" : "bolt.slash.fill")
|
||||
.imageScale(.large)
|
||||
.foregroundStyle(performanceMode ? .yellow : .secondary)
|
||||
.accessibilityLabel("Toggle Performance Mode")
|
||||
.accessibilityValue(performanceMode ? "On" : "Off")
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Empty State
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 60, height: 60)
|
||||
.foregroundColor(.gray)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text("No Debuggable App Found")
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.primary)
|
||||
.font(.title2.weight(.semibold))
|
||||
.foregroundStyle(.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)
|
||||
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)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.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)
|
||||
.padding(24)
|
||||
.glassCard(cornerRadius: 24, material: .thinMaterial, strokeOpacity: 0.12)
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("No debuggable app found. Ensure the app is signed for development.")
|
||||
}
|
||||
|
||||
// MARK: Apps List
|
||||
|
||||
private var appsList: some View {
|
||||
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
|
||||
)
|
||||
glassSection(
|
||||
title: String(format: "Favorites (%d/4)".localized, favoriteApps.count)
|
||||
) {
|
||||
LazyVStack(spacing: 12) {
|
||||
ForEach(favoriteApps, id: \.self) { bundleID in
|
||||
AppButton(
|
||||
bundleID: bundleID,
|
||||
appName: viewModel.apps[bundleID] ?? bundleID,
|
||||
recentApps: $recentApps,
|
||||
favoriteApps: $favoriteApps,
|
||||
appIcons: $appIcons,
|
||||
onSelectApp: onSelectApp,
|
||||
sharedDefaults: sharedDefaults,
|
||||
performanceMode: performanceMode
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
LazyVStack(spacing: 12) {
|
||||
ForEach(filteredRecents, id: \.self) { bundleID in
|
||||
AppButton(
|
||||
bundleID: bundleID,
|
||||
appName: viewModel.apps[bundleID] ?? bundleID,
|
||||
recentApps: $recentApps,
|
||||
favoriteApps: $favoriteApps,
|
||||
appIcons: $appIcons,
|
||||
onSelectApp: onSelectApp,
|
||||
sharedDefaults: sharedDefaults,
|
||||
performanceMode: performanceMode
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
glassSection(title: "All Applications".localized) {
|
||||
LazyVStack(spacing: 12) {
|
||||
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,
|
||||
performanceMode: performanceMode
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -150,62 +193,95 @@ Please check if the app you want to connect to is signed with a **development**
|
|||
}
|
||||
}
|
||||
|
||||
private func glassSection<Content: View>(title: String, @ViewBuilder content: () -> Content) -> some View {
|
||||
// MARK: Section Wrapper
|
||||
|
||||
private func glassSection<Content: View>(
|
||||
title: String,
|
||||
@ViewBuilder content: () -> Content
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityAddTraits(.isHeader)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
content()
|
||||
}
|
||||
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)
|
||||
.glassCard(material: .thinMaterial, strokeOpacity: 0.12)
|
||||
}
|
||||
|
||||
// MARK: Persistence gate (avoid redundant writes + reloads)
|
||||
|
||||
private func persistIfChanged() {
|
||||
var touched = false
|
||||
let prevR = (sharedDefaults.array(forKey: "recentApps") as? [String]) ?? []
|
||||
let prevF = (sharedDefaults.array(forKey: "favoriteApps") as? [String]) ?? []
|
||||
|
||||
if prevR != recentApps {
|
||||
sharedDefaults.set(recentApps, forKey: "recentApps")
|
||||
touched = true
|
||||
}
|
||||
if prevF != favoriteApps {
|
||||
sharedDefaults.set(favoriteApps, forKey: "favoriteApps")
|
||||
touched = true
|
||||
}
|
||||
if touched { WidgetCenter.shared.reloadAllTimelines() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - App Button Row
|
||||
|
||||
struct AppButton: View {
|
||||
let bundleID: String
|
||||
let appName: String
|
||||
|
||||
@Binding var recentApps: [String]
|
||||
@Binding var favoriteApps: [String]
|
||||
@Binding var appIcons: [String: UIImage]
|
||||
|
||||
@AppStorage("loadAppIconsOnJIT") private var loadAppIconsOnJIT = true
|
||||
@AppStorage("enableAdvancedOptions") private var enableAdvancedOptions = false
|
||||
|
||||
var onSelectApp: (String) -> Void
|
||||
let sharedDefaults: UserDefaults
|
||||
let performanceMode: Bool
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@State private var showScriptPicker = false
|
||||
|
||||
var body: some View {
|
||||
Button(action: selectApp) {
|
||||
HStack(spacing: loadAppIconsOnJIT ? 16 : 12) {
|
||||
iconView
|
||||
appText
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(appName)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
|
||||
Text(bundleID)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if favoriteApps.contains(bundleID) {
|
||||
Image(systemName: "star.fill")
|
||||
.imageScale(.medium)
|
||||
.foregroundStyle(.yellow)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
.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)
|
||||
.background(rowBackground)
|
||||
.contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu {
|
||||
Button(action: toggleFavorite) {
|
||||
Label(
|
||||
|
|
@ -214,25 +290,51 @@ struct AppButton: View {
|
|||
)
|
||||
.disabled(!favoriteApps.contains(bundleID) && favoriteApps.count >= 4)
|
||||
}
|
||||
Button { UIPasteboard.general.string = bundleID } label: {
|
||||
Button {
|
||||
UIPasteboard.general.string = bundleID
|
||||
Haptics.light()
|
||||
} label: {
|
||||
Label("Copy Bundle ID", systemImage: "doc.on.doc")
|
||||
}
|
||||
if enableAdvancedOptions {
|
||||
Button {
|
||||
showScriptPicker = true
|
||||
} label: {
|
||||
Button { showScriptPicker = true } label: {
|
||||
Label("Assign Script", systemImage: "chevron.left.slash.chevron.right")
|
||||
}
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
Button {
|
||||
toggleFavorite()
|
||||
} label: {
|
||||
Label(favoriteApps.contains(bundleID) ? "Unfavorite" : "Favorite", systemImage: "star")
|
||||
}
|
||||
.tint(.yellow)
|
||||
|
||||
Button {
|
||||
UIPasteboard.general.string = bundleID
|
||||
Haptics.light()
|
||||
} label: {
|
||||
Label("Copy ID", systemImage: "doc.on.doc")
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showScriptPicker) {
|
||||
ScriptListView { url in
|
||||
assignScript(url)
|
||||
showScriptPicker = false
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if loadAppIconsOnJIT {
|
||||
loadAppIcon(for: bundleID)
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel("\(appName)")
|
||||
.accessibilityHint("Double-tap to select. Swipe for actions, long-press for options.")
|
||||
}
|
||||
|
||||
// MARK: Icon
|
||||
|
||||
private var iconView: some View {
|
||||
Group {
|
||||
if loadAppIconsOnJIT, let image = appIcons[bundleID] {
|
||||
|
|
@ -240,55 +342,68 @@ struct AppButton: View {
|
|||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 54, height: 54)
|
||||
.cornerRadius(12)
|
||||
.shadow(color: .black.opacity(0.2), radius: 4, x: 0, y: 2)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.18), radius: 3, x: 0, y: 1.5)
|
||||
.transition(.opacity.combined(with: .scale))
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(Color(UIColor.systemGray5))
|
||||
.frame(width: 54, height: 54)
|
||||
.overlay(
|
||||
Image(systemName: "app")
|
||||
.font(.system(size: 24))
|
||||
.foregroundColor(.gray)
|
||||
.font(.system(size: 24, weight: .regular))
|
||||
.foregroundStyle(.gray)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1)
|
||||
.onAppear { loadAppIcon(for: bundleID) }
|
||||
}
|
||||
}
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
// MARK: Row Background
|
||||
|
||||
private var rowBackground: some View {
|
||||
Group {
|
||||
if performanceMode {
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.fill(Color(.secondarySystemBackground).opacity(0.65))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.stroke(.white.opacity(0.10), lineWidth: 1)
|
||||
)
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.stroke(.white.opacity(0.15), lineWidth: 1)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.08), radius: 6, x: 0, y: 3)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var appText: some View {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(appName)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundColor(.primary)
|
||||
Text(bundleID)
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
// MARK: Actions
|
||||
|
||||
private func selectApp() {
|
||||
Haptics.selection()
|
||||
recentApps.removeAll { $0 == bundleID }
|
||||
recentApps.insert(bundleID, at: 0)
|
||||
if recentApps.count > 3 { recentApps = Array(recentApps.prefix(3)) }
|
||||
sharedDefaults.set(recentApps, forKey: "recentApps")
|
||||
sharedDefaults.set(favoriteApps, forKey: "favoriteApps")
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
if recentApps.count > 3 {
|
||||
recentApps = Array(recentApps.prefix(3))
|
||||
}
|
||||
persistIfChanged()
|
||||
onSelectApp(bundleID)
|
||||
}
|
||||
|
||||
private func toggleFavorite() {
|
||||
Haptics.light()
|
||||
if favoriteApps.contains(bundleID) {
|
||||
favoriteApps.removeAll { $0 == bundleID }
|
||||
} else if favoriteApps.count < 4 {
|
||||
favoriteApps.insert(bundleID, at: 0)
|
||||
recentApps.removeAll { $0 == bundleID }
|
||||
}
|
||||
sharedDefaults.set(recentApps, forKey: "recentApps")
|
||||
sharedDefaults.set(favoriteApps, forKey: "favoriteApps")
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
persistIfChanged()
|
||||
}
|
||||
|
||||
private func assignScript(_ url: URL?) {
|
||||
|
|
@ -299,32 +414,55 @@ struct AppButton: View {
|
|||
mapping.removeValue(forKey: bundleID)
|
||||
}
|
||||
UserDefaults.standard.set(mapping, forKey: "BundleScriptMap")
|
||||
Haptics.light()
|
||||
}
|
||||
|
||||
private func persistIfChanged() {
|
||||
var touched = false
|
||||
let prevR = (sharedDefaults.array(forKey: "recentApps") as? [String]) ?? []
|
||||
let prevF = (sharedDefaults.array(forKey: "favoriteApps") as? [String]) ?? []
|
||||
|
||||
if prevR != recentApps {
|
||||
sharedDefaults.set(recentApps, forKey: "recentApps")
|
||||
touched = true
|
||||
}
|
||||
if prevF != favoriteApps {
|
||||
sharedDefaults.set(favoriteApps, forKey: "favoriteApps")
|
||||
touched = true
|
||||
}
|
||||
if touched { WidgetCenter.shared.reloadAllTimelines() }
|
||||
}
|
||||
|
||||
private func loadAppIcon(for bundleID: String) {
|
||||
guard loadAppIconsOnJIT else { return }
|
||||
|
||||
if let cachedImage = loadCachedIcon(bundleID: bundleID) {
|
||||
DispatchQueue.main.async { appIcons[bundleID] = cachedImage }
|
||||
if let mem = IconCache.shared.cached(bundleID) {
|
||||
appIcons[bundleID] = mem
|
||||
return
|
||||
}
|
||||
|
||||
AppStoreIconFetcher.getIcon(for: bundleID) { image in
|
||||
guard let image = image else { return }
|
||||
DispatchQueue.main.async {
|
||||
withAnimation(.easeIn(duration: 0.2)) {
|
||||
appIcons[bundleID] = image
|
||||
if let disk = loadCachedIcon(bundleID: bundleID) {
|
||||
IconCache.shared.store(disk, for: bundleID)
|
||||
appIcons[bundleID] = disk
|
||||
return
|
||||
}
|
||||
IconCache.shared.enqueue { [bundleID] in
|
||||
AppStoreIconFetcher.getIcon(for: bundleID) { image in
|
||||
guard let image else { return }
|
||||
IconCache.shared.store(image, for: bundleID)
|
||||
DispatchQueue.main.async {
|
||||
withAnimation(.linear(duration: 0.12)) {
|
||||
appIcons[bundleID] = image
|
||||
}
|
||||
saveIconToGroup(image, bundleID: bundleID)
|
||||
}
|
||||
}
|
||||
saveIconToGroup(image, bundleID: bundleID)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadCachedIcon(bundleID: String) -> UIImage? {
|
||||
guard
|
||||
let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.stik.sj")
|
||||
else { return nil }
|
||||
|
||||
guard let containerURL = FileManager.default
|
||||
.containerURL(forSecurityApplicationGroupIdentifier: "group.com.stik.sj") else {
|
||||
return nil
|
||||
}
|
||||
let iconsDir = containerURL.appendingPathComponent("icons", isDirectory: true)
|
||||
let fileURL = iconsDir.appendingPathComponent("\(bundleID).png")
|
||||
if FileManager.default.fileExists(atPath: fileURL.path) {
|
||||
|
|
@ -334,18 +472,83 @@ struct AppButton: View {
|
|||
}
|
||||
}
|
||||
|
||||
fileprivate func saveIconToGroup(_ image: UIImage, bundleID: String) {
|
||||
guard
|
||||
let data = image.pngData(),
|
||||
let container = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.stik.sj")
|
||||
else { return }
|
||||
// MARK: - IconCache
|
||||
|
||||
final class IconCache {
|
||||
static let shared = IconCache()
|
||||
private let mem = NSCache<NSString, UIImage>()
|
||||
private let queue = OperationQueue()
|
||||
private init() {
|
||||
queue.maxConcurrentOperationCount = 4
|
||||
mem.countLimit = 1000
|
||||
mem.totalCostLimit = 64 * 1024 * 1024
|
||||
}
|
||||
func cached(_ key: String) -> UIImage? { mem.object(forKey: key as NSString) }
|
||||
func store(_ image: UIImage, for key: String) {
|
||||
let cost = Int(image.size.width * image.size.height * image.scale * image.scale)
|
||||
mem.setObject(image, forKey: key as NSString, cost: cost)
|
||||
}
|
||||
func enqueue(_ op: @escaping () -> Void) { queue.addOperation(op) }
|
||||
}
|
||||
|
||||
// MARK: - Shared UI Bits
|
||||
|
||||
private struct BackgroundGradient: View {
|
||||
var body: some View {
|
||||
LinearGradient(
|
||||
colors: [Color(UIColor.systemBackground), Color(UIColor.secondarySystemBackground)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct GlassCard: ViewModifier {
|
||||
var cornerRadius: CGFloat = 20
|
||||
var material: Material = .ultraThinMaterial
|
||||
var strokeOpacity: Double = 0.15
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||
.fill(material)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||
.stroke(.white.opacity(strokeOpacity), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4)
|
||||
}
|
||||
}
|
||||
|
||||
private extension View {
|
||||
func glassCard(
|
||||
cornerRadius: CGFloat = 20,
|
||||
material: Material = .ultraThinMaterial,
|
||||
strokeOpacity: Double = 0.15
|
||||
) -> some View {
|
||||
modifier(GlassCard(cornerRadius: cornerRadius, material: material, strokeOpacity: strokeOpacity))
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func saveIconToGroup(_ image: UIImage, bundleID: String) {
|
||||
guard let data = image.pngData(),
|
||||
let container = FileManager.default
|
||||
.containerURL(forSecurityApplicationGroupIdentifier: "group.com.stik.sj")
|
||||
else { return }
|
||||
let iconsDir = container.appendingPathComponent("icons", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: iconsDir, withIntermediateDirectories: true)
|
||||
let fileURL = iconsDir.appendingPathComponent("\(bundleID).png")
|
||||
try? data.write(to: fileURL)
|
||||
}
|
||||
|
||||
enum Haptics {
|
||||
static func light() { UIImpactFeedbackGenerator(style: .light).impactOccurred() }
|
||||
static func selection() { UISelectionFeedbackGenerator().selectionChanged() }
|
||||
}
|
||||
|
||||
// MARK: - Utilities
|
||||
|
||||
extension Array: @retroactive RawRepresentable where Element: Codable {
|
||||
public init?(rawValue: String) {
|
||||
guard let data = rawValue.data(using: .utf8),
|
||||
|
|
@ -353,7 +556,6 @@ extension Array: @retroactive RawRepresentable where Element: Codable {
|
|||
else { return nil }
|
||||
self = result
|
||||
}
|
||||
|
||||
public var rawValue: String {
|
||||
guard let data = try? JSONEncoder().encode(self),
|
||||
let result = String(data: data, encoding: .utf8)
|
||||
|
|
@ -362,6 +564,9 @@ extension Array: @retroactive RawRepresentable where Element: Codable {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
InstalledAppsListView { _ in }
|
||||
.environment(\.colorScheme, .dark)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue