UX Improvements

This commit is contained in:
Stephen B 2025-09-09 12:30:35 -04:00
parent 7c97db579c
commit b64733fa52
2 changed files with 691 additions and 510 deletions

View file

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

View file

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